UNPKG

@nori-zk/mina-token-bridge

Version:

Nori ethereum state settelment and nETH token bridge zkApp

216 lines 11.2 kB
/** * NoriTokenBridge — in-flight mint invalidation fix, green proof (Finding 41428, Lightnet) * * This lightnet test demonstrates the fix: with `noriMint` taking the window * start as a WITNESS (no on-chain `windowStart` precondition), a mint proven * before an `update` is still accepted after that update — the only remaining * shared precondition is `actionState`, which tolerates the last ~4 updates. * * Runs with maxWindow = 2 so filling the window is only a couple of real txs. * * Requires: Lightnet at http://localhost:8080/graphql (accountManager :8181, * archive :8282) and the test-only `adminSetDepositRoot` method enabled. */ import { Logger, LogPrinter } from 'esm-iso-logger'; import { AccountUpdate, Bool, Cache, fetchAccount, Field, Mina, PrivateKey, UInt8, } from 'o1js'; import assert from 'node:assert'; import { FungibleToken } from '../TokenBase.js'; import { NoriStorageInterface } from '../NoriStorageInterface.js'; import { NoriTokenBridge } from '../NoriTokenBridge.js'; import { getContractDepositSlotRootFromContractDepositAndWitness } from '../depositAttestation.js'; import { EthInput, decodeConsensusMptProof, Bytes32FieldPair, extractEthTokenBridgeAddressFromSP1Proof, extractGenesisRootFromSP1Proof, bridgeHeadNoriSP1HeliosProgramPi0, proofConversionSP1ToPlonkPO2, } from '@nori-zk/o1js-zk-utils'; import { FrC } from '@nori-zk/proof-conversion/min'; import { buildExampleProofSeriesCreateArguments } from '../constructExampleProofs.js'; import { getNewMinaLiteNetAccountKeyPair, keyPairBase58ToKeyPair, buildSyntheticDeposit, } from './testUtils.js'; import { maxWindow } from '../NoriTokenBridge.const.js'; new LogPrinter('TestMinaNoriTokenBridgeInflightMintLightnet'); const logger = new Logger('InflightMintLightnetRegression'); const fee = Number(process.env.MINA_TX_FEE ?? 0.1) * 1e9; let deployer; let admin; let mallory; let tokenBaseKeypair; let tokenBase; let noriTokenBridgeKeypair; let noriTokenBridge; let storageInterfaceVK; let allAccounts; async function txSend({ body, sender, signers, fee: txFee = fee, }) { const tx = await Mina.transaction({ sender, fee: txFee }, body); await tx.prove(); tx.sign(signers); const pendingTx = await tx.send(); return pendingTx.wait(); } async function fetchAccounts(addrs) { await Promise.all(addrs.map((addr) => fetchAccount({ publicKey: addr }))); } /** Fetch the deposit roots currently in the window (replays from windowStart). */ async function fetchWindowRoots() { const windowStart = await noriTokenBridge.windowStart.fetch(); if (windowStart === undefined) throw new Error('could not fetch windowStart'); const actionBatches = await noriTokenBridge.reducer.fetchActions({ fromActionState: windowStart, }); return actionBatches.flat(); } /** The real oldest action to evict: Field(0) until full, else the first window root. * NOTE (4279a fix): oldestAction is no longer caller-supplied; derived in-circuit. */ async function honestOldest() { const windowRoots = await fetchWindowRoots(); if (windowRoots.length < maxWindow) return Field(0); return windowRoots[0]; } /** Dispatch a deposit root via adminSetDepositRoot, passing the real oldest action. * NOTE (4279a fix): oldestAction is no longer caller-supplied; derived in-circuit. */ async function adminDispatch(root) { const oldest = await honestOldest(); void root; void oldest; // adminSetDepositRoot is commented out on the production contract; this // suite is `describe.skip`-ed. Re-enable in lockstep with the contract // method to reproduce against lightnet. // await txSend({ // body: async () => { // // NOTE (4279a fix): oldest param removed; derived in-circuit. // await noriTokenBridge.adminSetDepositRoot(root); // }, // sender: admin.publicKey, // signers: [admin.privateKey], // }); await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); } describe.skip('NoriTokenBridge — in-flight mint invalidation (lightnet green proof)', () => { beforeAll(async () => { const Network = Mina.Network({ networkId: 'testnet', mina: process.env.MINA_RPC_NETWORK_URL ?? 'http://localhost:8080/graphql', archive: process.env.MINA_ARCHIVE_RPC_URL ?? 'http://localhost:8282', }); Mina.setActiveInstance(Network); deployer = keyPairBase58ToKeyPair(await getNewMinaLiteNetAccountKeyPair()); admin = keyPairBase58ToKeyPair(await getNewMinaLiteNetAccountKeyPair()); mallory = keyPairBase58ToKeyPair(await getNewMinaLiteNetAccountKeyPair()); tokenBaseKeypair = PrivateKey.randomKeypair(); noriTokenBridgeKeypair = PrivateKey.randomKeypair(); tokenBase = new FungibleToken(tokenBaseKeypair.publicKey); noriTokenBridge = new NoriTokenBridge(noriTokenBridgeKeypair.publicKey); allAccounts = [ deployer.publicKey, admin.publicKey, mallory.publicKey, tokenBaseKeypair.publicKey, noriTokenBridgeKeypair.publicKey, ]; logger.log('Compiling NoriStorageInterface...'); storageInterfaceVK = (await NoriStorageInterface.compile({ cache: Cache.None })).verificationKey; logger.log('Compiling FungibleToken...'); await FungibleToken.compile({ cache: Cache.None }); logger.log('Compiling NoriTokenBridge...'); await NoriTokenBridge.compile({ cache: Cache.None }); logger.log('All contracts compiled.'); const examples = buildExampleProofSeriesCreateArguments(); const ethInput1 = new EthInput(decodeConsensusMptProof(examples[0].sp1PlonkProof)); const initialStoreHash = Bytes32FieldPair.fromBytes32(ethInput1.inputStoreHash); const ethTokenBridgeAddress = extractEthTokenBridgeAddressFromSP1Proof(examples[0]); const genesisRoot = extractGenesisRootFromSP1Proof(examples[0]); await txSend({ body: async () => { AccountUpdate.fundNewAccount(deployer.publicKey, 3); await noriTokenBridge.deploy({ adminPublicKey: admin.publicKey, tokenBaseAddress: tokenBaseKeypair.publicKey, storageVKHash: storageInterfaceVK.hash, newStoreHash: initialStoreHash, ethTokenBridgeAddress, noriHeliosProgramPi0: FrC.from(bridgeHeadNoriSP1HeliosProgramPi0), proofConversionPO2: Field.from(proofConversionSP1ToPlonkPO2), genesisRoot, }); await tokenBase.deploy({ symbol: 'nETH', src: 'https://github.com/2nori/nori-bridge-sdk', allowUpdates: true, }); await tokenBase.initialize(noriTokenBridgeKeypair.publicKey, UInt8.from(6), Bool(false)); }, sender: deployer.publicKey, signers: [ deployer.privateKey, noriTokenBridgeKeypair.privateKey, tokenBaseKeypair.privateKey, ], }); logger.log('Contracts deployed.'); }, 3_000_000); beforeEach(async () => { await fetchAccounts(allAccounts); }); test('a mint proven before an update is still accepted after that update', async () => { // Fill the window to maxWindow with dummies. for (let i = 0; i < maxWindow; i++) { await adminDispatch(Field(4000000n + BigInt(i))); } logger.log(`Window filled to maxWindow=${maxWindow}.`); // Seed mallory's deposit as the newest window member (evicts the oldest dummy). const totalLocked = 250n; const { merkleInput, scramWitness } = buildSyntheticDeposit(mallory.privateKey, 'NoriZK', totalLocked); const depositRoot = getContractDepositSlotRootFromContractDepositAndWitness(merkleInput); await txSend({ body: async () => { AccountUpdate.fundNewAccount(deployer.publicKey, 1); await noriTokenBridge.setUpStorage(mallory.publicKey, storageInterfaceVK); }, sender: deployer.publicKey, signers: [deployer.privateKey, mallory.privateKey], }); await adminDispatch(depositRoot); // Snapshot the window start the user would witness when building their proof. const windowStartWitness = await noriTokenBridge.windowStart.fetch(); if (windowStartWitness === undefined) throw new Error('could not fetch windowStart'); logger.log(`Mint proof will witness windowStart = ${windowStartWitness.toString()}.`); // Build AND prove the mint now (as a user would), passing the witnessed window start. await fetchAccounts(allAccounts); const mintTx = await Mina.transaction({ sender: mallory.publicKey, fee }, async () => { AccountUpdate.fundNewAccount(mallory.publicKey, 1); await noriTokenBridge.noriMint(merkleInput, scramWitness, windowStartWitness); }); await mintTx.prove(); mintTx.sign([mallory.privateKey]); logger.log('Mint proven and signed (held, not yet sent).'); // An `update` lands before the mint is included: one dispatch slides windowStart. await adminDispatch(Field(5000000n)); const windowStartAfter = await noriTokenBridge.windowStart.fetch(); if (windowStartAfter === undefined) throw new Error('could not fetch windowStart'); assert.notEqual(windowStartAfter.toBigInt(), windowStartWitness.toBigInt(), 'sanity: the intervening update must have moved windowStart'); logger.log(`Intervening update applied: windowStart ${windowStartWitness.toString()} -> ${windowStartAfter.toString()} ` + `(stale for the held mint).`); // Send the already-proven mint. With the witness fix and only one // intervening update (within the 5-slot actionState tolerance), it is // accepted — on the old contract the stale windowStart precondition // would have rejected it. let mintErr; try { const pending = await mintTx.send(); await pending.wait(); } catch (e) { mintErr = e; logger.log(`mint rejected: ${mintErr.message}`); } assert.ok(!mintErr, `in-flight mint must not be rejected by an intervening update: ${mintErr?.message ?? ''}`); logger.log('GREEN: in-flight mint ACCEPTED despite the intervening update (stale windowStart no longer pins).'); await fetchAccount({ publicKey: mallory.publicKey, tokenId: tokenBase.deriveTokenId(), }); const balance = await tokenBase.getBalanceOf(mallory.publicKey); assert.equal(balance.toBigInt(), totalLocked, 'user must receive minted tokens despite the intervening update'); logger.log(`mallory balance = ${balance.toBigInt()} (expected ${totalLocked}).`); }, 3_000_000); }); //# sourceMappingURL=41428.inflightMint.lightnet.integration.spec.js.map