@nori-zk/mina-token-bridge
Version:
Nori ethereum state settelment and nETH token bridge zkApp
283 lines (282 loc) • 14.3 kB
TypeScript
import { AccountUpdate, Bool, Field, SmartContract, State, UInt64, PublicKey, TokenContract, Provable, type DeployArgs, VerificationKey, AccountUpdateForest } from 'o1js';
import { FrC, NodeProofLeft } from '@nori-zk/proof-conversion/min';
import { EthInput, Bytes32FieldPair } from '@nori-zk/o1js-zk-utils';
import { MerkleTreeContractDepositAttestorInput } from './depositAttestation.js';
import { SCRAMWitness } from './scram.js';
export type FungibleTokenAdminBase = SmartContract & {
canMint(accountUpdate: AccountUpdate): Promise<Bool>;
canChangeAdmin(admin: PublicKey): Promise<Bool>;
canPause(): Promise<Bool>;
canResume(): Promise<Bool>;
canChangeVerificationKey(vk: VerificationKey): Promise<Bool>;
};
export interface NoriTokenControllerDeployProps extends Exclude<DeployArgs, undefined> {
adminPublicKey: PublicKey;
tokenBaseAddress: PublicKey;
storageVKHash: Field;
newStoreHash: Bytes32FieldPair;
ethTokenBridgeAddress: Field;
noriHeliosProgramPi0: FrC;
proofConversionPO2: Field;
genesisRoot: Field;
}
declare const BurnEvent_base: (new (value: {
from: PublicKey;
amount: UInt64;
burnedSoFar: UInt64;
receiverEth: import("o1js/dist/node/lib/provable/field.js").Field;
}) => {
from: PublicKey;
amount: UInt64;
burnedSoFar: UInt64;
receiverEth: import("o1js/dist/node/lib/provable/field.js").Field;
}) & {
_isStruct: true;
} & Omit<import("o1js/dist/node/lib/provable/types/provable-intf.js").Provable<{
from: PublicKey;
amount: UInt64;
burnedSoFar: UInt64;
receiverEth: import("o1js/dist/node/lib/provable/field.js").Field;
}, {
from: {
x: bigint;
isOdd: boolean;
};
amount: bigint;
burnedSoFar: bigint;
receiverEth: bigint;
}>, "fromFields"> & {
fromFields: (fields: import("o1js/dist/node/lib/provable/field.js").Field[]) => {
from: PublicKey;
amount: UInt64;
burnedSoFar: UInt64;
receiverEth: import("o1js/dist/node/lib/provable/field.js").Field;
};
} & {
fromValue: (value: {
from: PublicKey | {
x: Field | bigint;
isOdd: Bool | boolean;
};
amount: number | bigint | UInt64;
burnedSoFar: number | bigint | UInt64;
receiverEth: string | number | bigint | import("o1js/dist/node/lib/provable/field.js").Field;
}) => {
from: PublicKey;
amount: UInt64;
burnedSoFar: UInt64;
receiverEth: import("o1js/dist/node/lib/provable/field.js").Field;
};
toInput: (x: {
from: PublicKey;
amount: UInt64;
burnedSoFar: UInt64;
receiverEth: import("o1js/dist/node/lib/provable/field.js").Field;
}) => {
fields?: Field[] | undefined;
packed?: [Field, number][] | undefined;
};
toJSON: (x: {
from: PublicKey;
amount: UInt64;
burnedSoFar: UInt64;
receiverEth: import("o1js/dist/node/lib/provable/field.js").Field;
}) => {
from: string;
amount: string;
burnedSoFar: string;
receiverEth: string;
};
fromJSON: (x: {
from: string;
amount: string;
burnedSoFar: string;
receiverEth: string;
}) => {
from: PublicKey;
amount: UInt64;
burnedSoFar: UInt64;
receiverEth: import("o1js/dist/node/lib/provable/field.js").Field;
};
empty: () => {
from: PublicKey;
amount: UInt64;
burnedSoFar: UInt64;
receiverEth: import("o1js/dist/node/lib/provable/field.js").Field;
};
};
export declare class BurnEvent extends BurnEvent_base {
}
declare class DepositRootAction extends Field {
}
/**
* NoriTokenBridge — Mina anchor for the Nori ETH ↔ Mina token bridge.
*
* Verifies SP1 consensus MPT transition proofs (`update`), maintains a rolling
* window of verified Ethereum deposit roots, and mints the corresponding
* FungibleToken balance when a user presents a matching deposit plus SCRAM
* witness (`noriMint`). Also supports burning for the Mina → ETH direction
* (`alignedLock`).
*
* Acts as the admin contract for the FungibleToken — `canMint` gates minting
* via a single-use `mintLock` flag that `noriMint` flips immediately before
* calling `token.mint`. Direct calls to `FungibleToken.mint` therefore fail.
*/
export declare class NoriTokenBridge extends TokenContract implements FungibleTokenAdminBase {
/** Admin key authorised to set integrity params, update VKs, rotate the store hash, and inject deposit roots. */
adminPublicKey: State<PublicKey>;
/** Address of the FungibleToken this bridge mints into / burns from. */
tokenBaseAddress: State<PublicKey>;
/** Required VK hash for every per-user NoriStorageInterface account (enforced in setUpStorage). */
storageVKHash: State<import("o1js/dist/node/lib/provable/field.js").Field>;
/**
* Single-use mint gate. `noriMint` clears this (false) just before calling
* `token.mint`; `canMint` then requires it false and re-locks it (true).
* Any `FungibleToken.mint` call not originating from `noriMint` fails
* because the lock remains true.
*/
mintLock: State<import("o1js/dist/node/lib/provable/bool.js").Bool>;
/** Poseidon hash of the most recently verified Ethereum execution state root (Field(1) before the first update). */
verifiedStateRoot: State<import("o1js/dist/node/lib/provable/field.js").Field>;
/** Latest Ethereum slot verified by this bridge (strictly increasing). */
latestHead: State<UInt64>;
/**
* Public input 0 from the SP1 consensus MPT transition proof (sp1Proof.proof.Plonk.public_inputs[0]),
* the Nori SP1 Helios program identifier (bridgeHeadNoriSP1HeliosProgramPi0).
* Canonical value committed in o1js-zk-utils/src/integrity/nori-sp1-helios-program.pi0.json —
* a copy of nori-elf/nori-sp1-helios-program.pi0.json from bridge-head.
* Set on-chain via updateNoriHeliosProgramPi0 after deployment (not baked into the circuit).
* Changes frequently as the Helios light client evolves.
*/
noriHeliosProgramPi0: State<FrC>;
/**
* Public output 2 from the converted consensus MPT transition proof
* (proofConversionOutput.proofData.publicOutput[2]).
* Canonical value committed in o1js-zk-utils/src/integrity/ProofConversion.sp1ToPlonk.po2.json.
* Set on-chain via updateProofConversionPO2 after deployment (not baked into the circuit).
* Infrequently changes, for instance when SP1 undergoes a major version upgrade
* (e.g. v5 -> v6) that affects the cryptography of proof conversion.
*/
proofConversionPO2: State<import("o1js/dist/node/lib/provable/field.js").Field>;
/**
* Poseidon hash of the Ethereum chain genesis validators root.
* Immutable after deploy — no admin setter exists by design.
*
* The Helios store hash (latestHeliusStoreInputHash*) is an opaque commitment over a
* serialized light client store containing finalized headers, sync committees, and
* optionally a next sync committee / best valid update. It must be upgradable because:
* 1. The store serialization format may need to change as Helios evolves.
* 2. A best-valid-update scenario (e.g. extended finality failure on Ethereum) would
* require a forced Helios store reconstruction and therefore a new store hash.
*
* That necessary upgradability is a governance attack surface: because the store hash is
* opaque, a compromised governance update could swap it to one rooted on a different chain
* with no visible on-chain evidence or verification. This field prevents that — `update`
* verifies the proof's genesis root against this value, so governance can rotate the
* store hash (subject to the strictly-increasing slot constraint) but cannot redirect
* the bridge to a different chain. The genesis validators root is a
* fixed, well-known constant that does not change across non-contentious hard forks.
*/
genesisRoot: State<import("o1js/dist/node/lib/provable/field.js").Field>;
/** Chain-linkage hash (high byte) — the next proof's `inputStoreHash` must match this + lower bytes. */
latestHeliusStoreInputHashHighByte: State<import("o1js/dist/node/lib/provable/field.js").Field>;
/** Chain-linkage hash (lower 31 bytes), pair with `latestHeliusStoreInputHashHighByte`. */
latestHeliusStoreInputHashLowerBytes: State<import("o1js/dist/node/lib/provable/field.js").Field>;
/** Deposits root from the most recent successful `update` (exposed for off-chain consumers). */
latestVerifiedContractDepositsRoot: State<import("o1js/dist/node/lib/provable/field.js").Field>;
/** The Ethereum contract address associated with this token bridge. */
ethTokenBridgeAddress: State<import("o1js/dist/node/lib/provable/field.js").Field>;
/** Action-state hash marking the start of the valid deposit-root window. */
windowStart: State<import("o1js/dist/node/lib/provable/field.js").Field>;
/** Number of deposit-root actions currently in the window (max maxWindow). */
windowSize: State<import("o1js/dist/node/lib/provable/field.js").Field>;
readonly events: {
Burn: typeof BurnEvent;
};
reducer: {
dispatch(action: DepositRootAction): void;
reduce<State>(actions: import("o1js").MerkleList<import("o1js").MerkleList<DepositRootAction>>, stateType: Provable<State>, reduce: (state: State, action: DepositRootAction) => State, initial: State, options?: {
maxUpdatesWithActions?: number;
maxActionsPerUpdate?: number;
skipActionStatePrecondition?: boolean;
}): State;
forEach(actions: import("o1js").MerkleList<import("o1js").MerkleList<DepositRootAction>>, reduce: (action: DepositRootAction) => void, options?: {
maxUpdatesWithActions?: number;
maxActionsPerUpdate?: number;
skipActionStatePrecondition?: boolean;
}): void;
getActions({ fromActionState, endActionState, }?: {
fromActionState?: Field;
endActionState?: Field;
}): import("o1js").MerkleList<import("o1js").MerkleList<DepositRootAction>>;
fetchActions({ fromActionState, endActionState, }?: {
fromActionState?: Field;
endActionState?: Field;
}): Promise<DepositRootAction[][]>;
};
deploy(props: NoriTokenControllerDeployProps): Promise<void>;
approveBase(_forest: AccountUpdateForest): Promise<void>;
private ethVerify;
/**
* Settle the latest Ethereum state root and NoriTokenBridge deposit root
* by verifying converted SP1 nori-bride-head proof
* @param input public outputs from the SP1 proof
* @param proof converted SP1 to Plonk proof, verified against the on-chain VK for the conversion circuit
*
* Verify an SP1 consensus MPT transition proof and advance the bridge
* head. On success:
* - `input.genesisRoot` is verified against the immutable on-chain `genesisRoot`
* (rejects proofs from a different chain)
* - `input.contractAddress` is verified against `ethTokenBridgeAddress`
* - `latestHead` is set to `input.outputSlot` (must strictly increase)
* - `verifiedStateRoot` is set to Poseidon(`input.executionStateRoot`)
* - `latestHeliusStoreInputHash{HighByte,LowerBytes}` advance to the
* new store hash (prior values must match the proof's `inputStoreHash`)
* - `input.verifiedContractDepositsRoot` is dispatched into the rolling
* window; when the window is full, the oldest action is evicted —
* its identity is derived in-circuit via the reducer (no caller witness).
*/
update(input: EthInput, proof: NodeProofLeft): Promise<void>;
/**
* Dispatch a new deposit root action and evict the oldest if the window is full.
*
* The oldest action is derived in-circuit by iterating the current window's
* actions via the reducer. Because o1js's reducer asserts that the chain
* `windowStart -> ... -> account.actionState` matches the actions it yields
* (and Poseidon is collision-resistant), a poisoned `windowStart` cannot
* pass reduce — and the action captured here is provably the real oldest.
*
* Order matters: reduce runs over the current window BEFORE the new root is
* dispatched, so the new root is not part of the reduction scope.
*/
private dispatchAndEvict;
setUpStorage(user: PublicKey, vk: VerificationKey): Promise<void>;
noriMint(merkleTreeContractDepositAttestorInput: MerkleTreeContractDepositAttestorInput, SCRAMWitness: SCRAMWitness, windowStartWitness: Field): Promise<void>;
/**
* @param amountToBurn the amount the user wants to burn on Mina. Must be greater than minBridgeBurnAmount.
* @param receiver - the Ethereum address (as a Field) that will receive the bridged tokens on the other side. Must be provided by the user when burning.
*/
alignedLock(amountToBurn: Field, receiver: Field): Promise<void>;
private ensureAdminSignature;
/**
* Update the verification key.
* Required if proof-conversion vk has to change.
* May be required for any changes with ethVerify() like EthInput type
*/
updateVerificationKey(vk: VerificationKey): Promise<void>;
updateNoriHeliosProgramPi0(newPi0: FrC): Promise<void>;
updateProofConversionPO2(newPO2: Field): Promise<void>;
updateStoreHash(newStoreHash: Bytes32FieldPair): Promise<void>;
/**
* FungibleToken admin hook. Pass-through gate: noriMint clears mintLock
* immediately before calling token.mint; this method consumes that
* clearance and re-locks. Direct calls to FungibleToken.mint therefore
* fail because mintLock remains true outside of an active noriMint.
*/
canMint(_accountUpdate: AccountUpdate): Promise<import("o1js/dist/node/lib/provable/bool.js").Bool>;
canChangeAdmin(_admin: PublicKey): Promise<import("o1js/dist/node/lib/provable/bool.js").Bool>;
canPause(): Promise<Bool>;
canResume(): Promise<Bool>;
canChangeVerificationKey(_vk: VerificationKey): Promise<Bool>;
}
export {};