@nori-zk/mina-token-bridge
Version:
Nori ethereum state settelment and nETH token bridge zkApp
675 lines • 36.6 kB
JavaScript
import { __decorate, __metadata } from "tslib";
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { AccountUpdate, assert, Bool, Field, State, method, state, Poseidon, UInt64, PublicKey, Permissions, TokenContract, Provable, Bytes, Struct, Reducer, VerificationKey } from 'o1js';
// NodeProofLeft must be a value import for @method decorator runtime validation
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { FrC, NodeProofLeft, parsePlonkPublicInputsProvable, } from '@nori-zk/proof-conversion/min';
// EthInput must be a value import for @method decorator runtime validation
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { EthInput, bytes32LEToFieldProvable, Bytes20, Bytes32, Bytes32FieldPair, proofConversionSP1ToPlonkVkData, } from '@nori-zk/o1js-zk-utils';
import { NoriStorageInterface } from './NoriStorageInterface.js';
import { FungibleToken } from './TokenBase.js';
// MerkleTreeContractDepositAttestorInput must be a value import for @method decorator runtime validation
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { MerkleTreeContractDepositAttestorInput, extractCodeChallengeAndTotalLocked, getContractDepositSlotRootFromContractDepositAndWitness, } from './depositAttestation.js';
// SCRAMWitness must be a value import for @method decorator runtime validation
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { SCRAMWitness, verifyCodeChallenge } from './scram.js';
import { maxWindow, minBridgeBurnAmount } from './NoriTokenBridge.const.js';
// ---------------------------------------------------------------------------
// Action hash-chain helpers (match Mina's internal Poseidon-based action hashing)
// See: o1js/src/lib/mina/v1/events.ts — Actions.pushEvent / updateSequenceState
// ---------------------------------------------------------------------------
function poseidonInitialState() {
return [Field(0), Field(0), Field(0)];
}
function poseidonSalt(prefix) {
// Encode prefix string as a single Field (same as o1js prefixToField)
const bytes = new TextEncoder().encode(prefix);
let acc = 0n;
for (let i = bytes.length - 1; i >= 0; i--) {
acc = acc * 256n + BigInt(bytes[i]);
}
return Poseidon.update(poseidonInitialState(), [Field(acc)]);
}
function hashWithPrefix(prefix, input) {
return Poseidon.update(poseidonSalt(prefix), input)[0];
}
/** Hash of an empty inner action list: salt('MinaZkappActionsEmpty')[0] */
const emptyActionsHash = poseidonSalt('MinaZkappActionsEmpty')[0];
/**
* Compute the inner action-list hash for a single-action transaction.
* Matches: Actions.pushEvent(Actions.empty(), [actionField])
* = hashWithPrefix('MinaZkappSeqEvents**', [emptyActionsHash, hashWithPrefix('MinaZkappEvent******', [action])])
*/
function singleActionInnerHash(action) {
const eventHash = hashWithPrefix('MinaZkappEvent******', [action]);
return hashWithPrefix('MinaZkappSeqEvents**', [emptyActionsHash, eventHash]);
}
/**
* Advance the outer action-state by one transaction (which contained one action).
* Matches: Actions.updateSequenceState(state, innerHash)
* = hashWithPrefix('MinaZkappSeqEvents**', [state, innerHash])
*/
function advanceActionState(state, innerHash) {
return hashWithPrefix('MinaZkappSeqEvents**', [state, innerHash]);
}
export class BurnEvent extends Struct({
from: PublicKey,
amount: UInt64,
burnedSoFar: UInt64,
receiverEth: Field
}) {
}
class DepositRootAction extends Field {
}
/** Accumulator for capturing the first action in a reducer pass. */
class FirstActionAcc extends Struct({ found: Bool, value: 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 class NoriTokenBridge extends TokenContract {
constructor() {
super(...arguments);
/** Admin key authorised to set integrity params, update VKs, rotate the store hash, and inject deposit roots. */
this.adminPublicKey = State();
/** Address of the FungibleToken this bridge mints into / burns from. */
this.tokenBaseAddress = State();
/** Required VK hash for every per-user NoriStorageInterface account (enforced in setUpStorage). */
this.storageVKHash = State();
/**
* 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.
*/
this.mintLock = State();
/** Poseidon hash of the most recently verified Ethereum execution state root (Field(1) before the first update). */
this.verifiedStateRoot = State();
/** Latest Ethereum slot verified by this bridge (strictly increasing). */
this.latestHead = State();
/**
* 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.
*/
this.noriHeliosProgramPi0 = State();
/**
* 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.
*/
this.proofConversionPO2 = State();
/**
* 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.
*/
this.genesisRoot = State();
/** Chain-linkage hash (high byte) — the next proof's `inputStoreHash` must match this + lower bytes. */
this.latestHeliusStoreInputHashHighByte = State();
/** Chain-linkage hash (lower 31 bytes), pair with `latestHeliusStoreInputHashHighByte`. */
this.latestHeliusStoreInputHashLowerBytes = State();
/** Deposits root from the most recent successful `update` (exposed for off-chain consumers). */
this.latestVerifiedContractDepositsRoot = State();
/** The Ethereum contract address associated with this token bridge. */
this.ethTokenBridgeAddress = State();
/** Action-state hash marking the start of the valid deposit-root window. */
this.windowStart = State();
/** Number of deposit-root actions currently in the window (max maxWindow). */
this.windowSize = State();
this.events = {
Burn: BurnEvent
};
this.reducer = Reducer({ actionType: DepositRootAction });
}
async deploy(props) {
await super.deploy(props);
this.adminPublicKey.set(props.adminPublicKey);
this.tokenBaseAddress.set(props.tokenBaseAddress);
this.storageVKHash.set(props.storageVKHash);
this.mintLock.set(Bool(true));
this.account.permissions.set({
...Permissions.default(),
setVerificationKey: Permissions.VerificationKey.impossibleDuringCurrentVersion(),
setPermissions: Permissions.impossible(),
editState: Permissions.proof(),
send: Permissions.proof(),
// Must stay proof-only until o1js supports `Precondition on Account
// Permissions` — otherwise a token-owner signature could approve
// arbitrary account updates against this contract.
access: Permissions.proof()
});
const isInitialized = this.account.provedState.getAndRequireEquals();
isInitialized.assertFalse('NoriTokenBridge has already been initialized!');
this.latestHead.set(UInt64.from(0));
this.verifiedStateRoot.set(Field(1));
// Set inital state of store hash.
// await this.updateStoreHash(newStoreHash); // Reintroduce this instead of the immediate below when we can
// verify that this.admin.getAndRequireEquals() == adminPublicKey immediately after this.admin.set(adminPublicKey);
this.latestHeliusStoreInputHashHighByte.set(props.newStoreHash.highByteField);
this.latestHeliusStoreInputHashLowerBytes.set(props.newStoreHash.lowerBytesField);
// Action window starts empty
this.windowStart.set(Reducer.initialActionState);
this.windowSize.set(Field(0));
// Ethereum token bridge contract address — verified against the proof in update().
this.ethTokenBridgeAddress.set(props.ethTokenBridgeAddress);
// Immutable chain anchor — prevents governance store hash rotations from redirecting the bridge to a different chain.
this.genesisRoot.set(props.genesisRoot);
// SP1 program identifier — updatable via updateNoriHeliosProgramPi0() as Helios evolves.
this.noriHeliosProgramPi0.set(props.noriHeliosProgramPi0);
// Proof conversion public output — updatable via updateProofConversionPO2() on SP1 major upgrades.
this.proofConversionPO2.set(props.proofConversionPO2);
}
approveBase(_forest) {
throw Error('block updates');
}
ethVerify(input, proof) {
// sp1Proof.proof.Plonk.public_inputs[0] — read from on-chain state (set via updateNoriHeliosProgramPi0)
const ethPlonkVK = this.noriHeliosProgramPi0.getAndRequireEquals();
// proofConversionOutput.proofData.publicOutput[2] — read from on-chain state (set via updateProofConversionPO2)
const ethNodeVk = this.proofConversionPO2.getAndRequireEquals();
// Verification of proof conversion
// vk = proofConversionOutput.vkData
// this is also from nodeVK
// const vk = VerificationKey.fromJSON(JSON.stringify(proofConversionSP1ToPlonkVkData));
const vk = VerificationKey.fromValue({
data: proofConversionSP1ToPlonkVkData.data,
hash: Field(proofConversionSP1ToPlonkVkData.hash),
});
proof.verify(vk);
// Passed proof matches extracted public entry 2
proof.publicOutput.subtreeVkDigest.assertEquals(ethNodeVk);
Provable.log('newHead slot', input.outputSlot);
// Verification of the input
let bytes = [];
bytes = bytes.concat(input.inputSlot.toBytesBE());
bytes = bytes.concat(input.inputStoreHash.bytes);
bytes = bytes.concat(input.outputSlot.toBytesBE());
bytes = bytes.concat(input.outputStoreHash.bytes);
bytes = bytes.concat(input.executionStateRoot.bytes);
bytes = bytes.concat(input.verifiedContractDepositsRoot.bytes);
bytes = bytes.concat(input.nextSyncCommitteeHash.bytes);
bytes = bytes.concat(input.contractAddress.bytes);
bytes = bytes.concat(input.genesisRoot.bytes);
// Check that zkprograminput is same as passed to the SP1 program
const pi0 = ethPlonkVK;
const pi1 = parsePlonkPublicInputsProvable(Bytes.from(bytes));
const piDigest = Poseidon.hashPacked(Provable.Array(FrC.provable, 2), [
pi0,
pi1,
]);
Provable.log('piDigest', piDigest);
Provable.log('proof.publicOutput.rightOut', proof.publicOutput.rightOut);
piDigest.assertEquals(proof.publicOutput.rightOut);
return {
ethGenesisRootBytes: input.genesisRoot.bytes,
ethTokenBridgeAddressBytes: input.contractAddress.bytes
};
}
/**
* 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).
*/
async update(input, proof) {
// Verify transition proof.
const { ethGenesisRootBytes, ethTokenBridgeAddressBytes } = this.ethVerify(input, proof);
// Verify ethereum proof is of the correct address
const ethTokenBridgeAddress = new Bytes20(ethTokenBridgeAddressBytes).toField();
const expectedEthTokenBridgeAddress = this.ethTokenBridgeAddress.getAndRequireEquals();
expectedEthTokenBridgeAddress.assertEquals(ethTokenBridgeAddress, 'The contract address extracted from the proof must match the one set in the bridge head contract.');
// Verify ethereum proof is on the correct chain
const ethGenesisRoot = new Bytes32(ethGenesisRootBytes).toFields();
const ethGenesisRootHash = Poseidon.hash(ethGenesisRoot);
const expectedGenesisRootHash = this.genesisRoot.getAndRequireEquals();
expectedGenesisRootHash.assertEquals(ethGenesisRootHash, 'The genesis validators root extracted from the proof must match the one set at deploy.');
const proofHead = input.outputSlot;
const executionStateRoot = input.executionStateRoot;
const currentSlot = this.latestHead.getAndRequireEquals();
const newStoreHash = Bytes32FieldPair.fromBytes32(input.outputStoreHash);
Provable.asProver(() => {
Provable.log('Proof input store hash values were:');
Provable.log(input.outputStoreHash.bytes[0].value);
Provable.log(input.outputStoreHash.bytes.slice(1, 33).map((b) => b.value));
Provable.log('Public outputs created:', newStoreHash.highByteField, newStoreHash.lowerBytesField);
Provable.log('Current slot', currentSlot);
});
const prevStoreHash = Bytes32FieldPair.fromBytes32(input.inputStoreHash);
// Verification of the previous store hash higher byte.
prevStoreHash.highByteField.assertEquals(this.latestHeliusStoreInputHashHighByte.getAndRequireEquals(), 'The latest transition proofs\' input helios store hash higher byte, must match the contracts\' helios store hash higher byte.');
Provable.asProver(() => {
Provable.log('ethProof.prevStoreHashHighByteField vs this.latestHeliusStoreInputHashHighByte', prevStoreHash.highByteField.toString(), this.latestHeliusStoreInputHashHighByte.get().toString());
});
// Verification of previous store hash lower bytes.
prevStoreHash.lowerBytesField.assertEquals(this.latestHeliusStoreInputHashLowerBytes.getAndRequireEquals(), 'The latest transition proofs\' input helios store hash lower bytes, must match the contracts\' helios store hash lower bytes.');
Provable.asProver(() => {
Provable.log('ethProof.prevStoreHashLowerBytesField vs this.latestHeliusStoreInputHashLowerBytes', prevStoreHash.lowerBytesField.toString(), this.latestHeliusStoreInputHashLowerBytes.get().toString());
});
// Verification of slot progress.
proofHead.assertGreaterThan(currentSlot, 'Proof head must be greater than current head.');
// Verification that next sync commitee is non zero (could brick the bridge head otherwise)
let nextSyncCommitteeZeroAcc = new Field(0);
for (let i = 0; i < 32; i++) {
nextSyncCommitteeZeroAcc = nextSyncCommitteeZeroAcc.add(input.nextSyncCommitteeHash.bytes[i].value);
}
nextSyncCommitteeZeroAcc.assertNotEquals(new Field(0));
// Extract the verifiedContractDepositsRoot and convert it to a Field
const verifiedContractDepositsRootField = bytes32LEToFieldProvable(input.verifiedContractDepositsRoot.bytes);
// Update contract values
this.latestHead.set(proofHead);
this.verifiedStateRoot.set(Poseidon.hashPacked(Bytes32.provable, executionStateRoot));
this.latestHeliusStoreInputHashHighByte.set(newStoreHash.highByteField);
this.latestHeliusStoreInputHashLowerBytes.set(newStoreHash.lowerBytesField);
this.latestVerifiedContractDepositsRoot.set(verifiedContractDepositsRootField);
// Dispatch + window eviction
this.dispatchAndEvict(verifiedContractDepositsRootField);
}
/**
* 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.
*/
dispatchAndEvict(depositRoot) {
const windowStart = this.windowStart.getAndRequireEquals();
const windowSize = this.windowSize.getAndRequireEquals();
const isFull = windowSize.greaterThanOrEqual(maxWindow);
// Iterate the window's actions and latch the first one as the verified
// oldest. `found` toggles true on the first iteration; subsequent
// iterations keep `value` unchanged.
const actions = this.reducer.getActions({ fromActionState: windowStart });
const oldest = this.reducer.reduce(actions, FirstActionAcc, ({ found, value }, action) => new FirstActionAcc({
found: Bool(true),
value: Provable.if(found, value, action),
}), new FirstActionAcc({ found: Bool(false), value: Field(0) }), { maxUpdatesWithActions: maxWindow, maxActionsPerUpdate: 1 });
const innerHash = singleActionInnerHash(oldest.value);
const advancedStart = advanceActionState(windowStart, innerHash);
// Conditionally advance: if full, slide the window; otherwise keep start.
this.windowStart.set(Provable.if(isFull, advancedStart, windowStart));
// If full: evict 1 + add 1 = same size. If not full: size + 1.
this.windowSize.set(Provable.if(isFull, windowSize, windowSize.add(1)));
// Dispatch AFTER reduce so the new root isn't pulled into the eviction scope.
this.reducer.dispatch(depositRoot);
}
async setUpStorage(user, vk) {
let tokenAccUpdate = AccountUpdate.createSigned(user, this.deriveTokenId());
tokenAccUpdate.account.isNew.requireEquals(Bool(true));
const storageVKHash = this.storageVKHash.getAndRequireEquals();
storageVKHash.assertEquals(vk.hash);
tokenAccUpdate.body.update.verificationKey = {
isSome: Bool(true),
value: vk,
};
tokenAccUpdate.body.update.permissions = {
isSome: Bool(true),
value: {
...Permissions.default(),
editState: Permissions.proof(),
// VK upgradability here?
setVerificationKey: Permissions.VerificationKey.impossibleDuringCurrentVersion(),
setPermissions: Permissions.proof(), //imposible?
access: Permissions.proof(),
},
};
AccountUpdate.setValue(tokenAccUpdate.update.appState[0], //NoriStorageInterface.userKeyHash
Poseidon.hash(user.toFields()));
AccountUpdate.setValue(tokenAccUpdate.update.appState[1], //NoriStorageInterface.mintedSoFar
Field(0));
}
async noriMint(merkleTreeContractDepositAttestorInput, SCRAMWitness, windowStartWitness) {
const userAddress = this.sender.getAndRequireSignature();
const tokenAddress = this.tokenBaseAddress.getAndRequireEquals();
// Calculate the deposit slot root
// This just proves that the index and value with the witness yield a root
// Aka some value exists at some index and yields a certain root
const contractDepositSlotRoot = getContractDepositSlotRootFromContractDepositAndWitness(merkleTreeContractDepositAttestorInput);
// Check membership in the action-based deposit-root window.
//
// The window start is taken as a WITNESS rather than read from on-chain
// `windowStart` state. Reading `windowStart` via getAndRequireEquals
// would pin it as a zero-tolerance precondition, and every `update`
// advances it once the window is full — so any update landing between
// proving and inclusion would reject the mint (finding 41428).
//
// The witness is fully constrained without that precondition:
// - `reduce` asserts the chain `windowStartWitness -> ... ->
// account.actionState` matches the actions it folds, so an
// off-chain or stale-garbage witness cannot pass.
// - `maxUpdatesWithActions: maxWindow` bounds the witness from below:
// a start older than the true window head yields more than
// maxWindow actions and the fold cannot reach `account.actionState`.
// A newer start only shrinks the considered set to a safe subset.
// The only on-chain precondition left is `account.actionState`, which
// tolerates the last 5 action states — giving ~5 updates of slack.
const actions = this.reducer.getActions({ fromActionState: windowStartWitness });
const depositInWindow = this.reducer.reduce(actions, Bool, (found, action) => found.or(action.equals(contractDepositSlotRoot)), Bool(false), { maxUpdatesWithActions: maxWindow });
depositInWindow.assertTrue('The provided contract deposit and witness are not in the stored window of verified contract deposits root, and thus cannot be used to mint.');
// Bytes32FieldPair
// Extract out the contract deposit credential and the tokens locked from the merkle merkleTreeContractDepositAttestorInput as fields
const { totalLocked: totalLockedBridgeUnits, codeChallenge: codeChallengeSCRAM, } = extractCodeChallengeAndTotalLocked(merkleTreeContractDepositAttestorInput);
// Verify the code challenge
const { signature, message } = SCRAMWitness;
verifyCodeChallenge(codeChallengeSCRAM, signature, userAddress, message);
// Construct storage interface
const controllerTokenId = this.deriveTokenId();
let storage = new NoriStorageInterface(userAddress, controllerTokenId);
// Require the storage account already exists (setUpStorage was called).
// Without this precondition, reading appState below can fail out of range.
storage.account.isNew.requireEquals(Bool(false));
// Defence-in-depth: confirm this storage was set up for the minting user.
// setUpStorage already binds user -> userKeyHash.
storage.userKeyHash
.getAndRequireEquals()
.assertEquals(Poseidon.hash(userAddress.toFields()));
// Derive amount to mint based of the total locked so far.
const amountToMint = await storage.increaseMintedAmount(totalLockedBridgeUnits);
Provable.log(amountToMint, 'amount to mint');
let token = new FungibleToken(tokenAddress);
this.mintLock.set(Bool(false));
Provable.log(UInt64.fromFields(amountToMint.toFields()), 'UInt64.fromFields(amountToMint.toFields())');
// Mint!
await token.mint(userAddress, UInt64.fromFields(amountToMint.toFields()));
}
/**
* @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.
*/
async alignedLock(amountToBurn, receiver) {
const userAddress = this.sender.getAndRequireSignature();
const tokenAddress = this.tokenBaseAddress.getAndRequireEquals();
const controllerTokenId = this.deriveTokenId();
amountToBurn.assertGreaterThan(minBridgeBurnAmount, 'Amount to burn must be greater than MIN_BRIDGE_AMOUNT');
// maintain Storage
let storage = new NoriStorageInterface(userAddress, controllerTokenId);
// Require the storage account already exists (same reasoning as noriMint).
storage.account.isNew.requireEquals(Bool(false));
// record amount to be burned and capture the new cumulative burnedSoFar
const newBurnedSoFar = await storage.addBurnGetCumulative(amountToBurn, receiver);
// burn it
let token = new FungibleToken(tokenAddress);
await token.burn(userAddress, UInt64.fromFields(amountToBurn.toFields()));
this.emitEvent('Burn', new BurnEvent({
from: userAddress,
amount: UInt64.fromFields(amountToBurn.toFields()),
burnedSoFar: UInt64.fromFields(newBurnedSoFar.toFields()),
receiverEth: receiver,
}));
}
async ensureAdminSignature() {
const admin = await Provable.witnessAsync(PublicKey, async () => {
let pk = await this.adminPublicKey.fetch();
assert(pk !== undefined, 'could not fetch admin public key');
return pk;
});
this.adminPublicKey.requireEquals(admin);
return AccountUpdate.createSigned(admin);
}
/**
* Update the verification key.
* Required if proof-conversion vk has to change.
* May be required for any changes with ethVerify() like EthInput type
*/
async updateVerificationKey(vk) {
await this.ensureAdminSignature();
this.account.verificationKey.set(vk);
}
async updateNoriHeliosProgramPi0(newPi0) {
await this.ensureAdminSignature();
this.noriHeliosProgramPi0.set(newPi0);
}
async updateProofConversionPO2(newPO2) {
await this.ensureAdminSignature();
this.proofConversionPO2.set(newPO2);
}
// we need it in case Helios changes it's store structure
async updateStoreHash(newStoreHash) {
await this.ensureAdminSignature();
this.latestHeliusStoreInputHashHighByte.set(newStoreHash.highByteField);
this.latestHeliusStoreInputHashLowerBytes.set(newStoreHash.lowerBytesField);
}
// -----------------------------------------------------------------------
// adminSetDepositRoot — TEST-ONLY, DO NOT ENABLE IN PRODUCTION.
//
// Admin-gated convenience method that dispatches a deposit root directly
// into the rolling window, bypassing the SP1 consensus MPT transition
// proof verification performed by `update()`. It exists purely so that
// local / lightnet integration tests can exercise `noriMint()` and the
// window-rotation logic without having to construct (and verify) a real
// proof for every synthetic deposit they want to seed.
//
// Production builds MUST keep this commented out: a deployed bridge with
// this method live would let the admin key inject arbitrary deposit
// roots — i.e. mint tokens against deposits that never happened on
// Ethereum. Re-enable ONLY when running the bridge against a local /
// lightnet network for testing, and re-comment before any production
// deployment. The matching worker helper in
// `workers/tokenBridgeTester/worker.ts` and the corresponding `.skip`-ed
// tests must be uncommented in lockstep.
//
// @method async adminSetDepositRoot(depositRoot: Field) {
// await this.ensureAdminSignature();
// this.dispatchAndEvict(depositRoot);
// }
// -----------------------------------------------------------------------
/**
* 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.
*/
async canMint(_accountUpdate) {
this.mintLock.requireEquals(Bool(false));
this.mintLock.set(Bool(true));
return Bool(true);
}
async canChangeAdmin(_admin) {
await this.ensureAdminSignature();
return Bool(false);
}
async canPause() {
await this.ensureAdminSignature();
return Bool(true);
}
async canResume() {
await this.ensureAdminSignature();
return Bool(true);
}
async canChangeVerificationKey(_vk) {
await this.ensureAdminSignature();
return Bool(true);
}
}
__decorate([
state(PublicKey),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "adminPublicKey", void 0);
__decorate([
state(PublicKey),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "tokenBaseAddress", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "storageVKHash", void 0);
__decorate([
state(Bool),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "mintLock", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "verifiedStateRoot", void 0);
__decorate([
state(UInt64),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "latestHead", void 0);
__decorate([
state(FrC),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "noriHeliosProgramPi0", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "proofConversionPO2", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "genesisRoot", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "latestHeliusStoreInputHashHighByte", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "latestHeliusStoreInputHashLowerBytes", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "latestVerifiedContractDepositsRoot", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "ethTokenBridgeAddress", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "windowStart", void 0);
__decorate([
state(Field),
__metadata("design:type", Object)
], NoriTokenBridge.prototype, "windowSize", void 0);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [EthInput, NodeProofLeft]),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "update", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [PublicKey, VerificationKey]),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "setUpStorage", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [MerkleTreeContractDepositAttestorInput,
SCRAMWitness,
Field]),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "noriMint", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Field,
Field]),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "alignedLock", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [VerificationKey]),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "updateVerificationKey", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [FrC]),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "updateNoriHeliosProgramPi0", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Field]),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "updateProofConversionPO2", null);
__decorate([
method,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Bytes32FieldPair]),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "updateStoreHash", null);
__decorate([
method.returns(Bool),
__metadata("design:type", Function),
__metadata("design:paramtypes", [AccountUpdate]),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "canMint", null);
__decorate([
method.returns(Bool),
__metadata("design:type", Function),
__metadata("design:paramtypes", [PublicKey]),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "canChangeAdmin", null);
__decorate([
method.returns(Bool),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "canPause", null);
__decorate([
method.returns(Bool),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "canResume", null);
__decorate([
method.returns(Bool),
__metadata("design:type", Function),
__metadata("design:paramtypes", [VerificationKey]),
__metadata("design:returntype", Promise)
], NoriTokenBridge.prototype, "canChangeVerificationKey", null);
//# sourceMappingURL=NoriTokenBridge.js.map