UNPKG

@nori-zk/mina-token-bridge

Version:

Nori ethereum state settelment and nETH token bridge zkApp

823 lines 46.9 kB
/** * NoriTokenBridge Integration Test Suite * * Tests the consolidated NoriTokenBridge contract using a LocalBlockchain * (proofsEnabled: false) for fast, network-free execution. * * Tests run against an in-memory Mina LocalBlockchain. * No running Lightnet node required. * * Test sequence (order-dependent, shared state): * 1. Deploy contracts * 2. test update() — Ethereum state transitions (series of 4 blocks) * 3. test setUpStorage() — per-user storage initialisation * 4. test noriMint() — token minting * 5. Admin operation tests */ import { Logger, LogPrinter } from 'esm-iso-logger'; import { AccountUpdate, Bool, fetchAccount, Field, Mina, Poseidon, PrivateKey, Reducer, UInt64, 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, NodeProofLeft, decodeConsensusMptProof, Bytes32, Bytes32FieldPair, bytes32LEToFieldProvable, extractEthTokenBridgeAddressFromSP1Proof, extractGenesisRootFromSP1Proof, bridgeHeadNoriSP1HeliosProgramPi0, proofConversionSP1ToPlonkPO2, } from '@nori-zk/o1js-zk-utils'; import { FrC } from '@nori-zk/proof-conversion/min'; import { buildExampleProofSeriesCreateArguments } from '../constructExampleProofs.js'; import { buildSyntheticDeposit, txSend, fetchAccounts, fetchWindowStartWitness } from './testUtils.js'; import { maxWindow } from '../NoriTokenBridge.const.js'; new LogPrinter('TestMinaNoriTokenBridge'); const logger = new Logger('IntegrationLocalBlockchainTest'); // --------------------------------------------------------------------------- // Shared test state (populated in beforeAll) // --------------------------------------------------------------------------- let deployer; let admin; let alice; let tokenBaseKeypair; let tokenBase; let noriTokenBridgeKeypair; let noriTokenBridge; let storageInterfaceVK; let noriTokenBridgeVK; let tokenBaseVK; void noriTokenBridgeVK; let allAccounts; const examples = buildExampleProofSeriesCreateArguments(); let ethInput1; let rawProof1; let ethInput2; let rawProof2; let ethInput3; let rawProof3; let ethInput4; let rawProof4; // --------------------------------------------------------------------------- // Window rotation config // --------------------------------------------------------------------------- /** Dispatch maxWindow + 5 roots to exercise 5 evictions. */ const windowRotationCount = maxWindow + 5; let dave; let daveTotalLocked = 0n; let daveMintCount = 0; // --------------------------------------------------------------------------- // Deposit-root window helpers (reusable for client code) // --------------------------------------------------------------------------- /** * Fetch the deposit-root actions currently in the contract's active window. * Reads `windowStart` from on-chain state and fetches actions from that * action-state hash forward to the current tip. * Returns a flat array of Field values in dispatch order. */ async function fetchWindowRoots(bridge) { await fetchAccount({ publicKey: bridge.address }); const windowStart = bridge.windowStart.get(); const actionBatches = await bridge.reducer.fetchActions({ fromActionState: windowStart, }); return actionBatches.flat(); } /** * Fetch ALL dispatched deposit-root actions from genesis. * Useful for debugging / full history, but prefer `fetchWindowRoots` * for normal operation. */ async function fetchAllDispatchedRoots(bridge) { const actionBatches = await bridge.reducer.fetchActions({ fromActionState: Reducer.initialActionState, }); return actionBatches.flat(); } /** * Dispatch a deposit root via adminSetDepositRoot. * * NOTE: `adminSetDepositRoot` is commented out on the production contract * (see `contracts/mina/src/NoriTokenBridge.ts`). The body of this helper * is therefore disabled — and the tests that rely on it are `.skip`-ed * below. To re-enable for local / lightnet testing, uncomment the call * below in lockstep with the contract method and the worker shim. */ async function dispatchRoot(root) { void root; // await txSend({ // body: async () => { // await noriTokenBridge.adminSetDepositRoot(root); // }, // sender: admin.publicKey, // signers: [admin.privateKey], // }); await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); } // --------------------------------------------------------------------------- // Suite // --------------------------------------------------------------------------- describe('NoriTokenBridge', () => { beforeAll(async () => { // Configure LocalBlockchain (proofsEnabled: false for fast execution) const Local = await Mina.LocalBlockchain({ proofsEnabled: true }); Mina.setActiveInstance(Local); deployer = { publicKey: Local.testAccounts[0], privateKey: Local.testAccounts[0].key, }; admin = { publicKey: Local.testAccounts[1], privateKey: Local.testAccounts[1].key, }; alice = { publicKey: Local.testAccounts[2], privateKey: Local.testAccounts[2].key, }; dave = { publicKey: Local.testAccounts[3], privateKey: Local.testAccounts[3].key, }; tokenBaseKeypair = PrivateKey.randomKeypair(); noriTokenBridgeKeypair = PrivateKey.randomKeypair(); tokenBase = new FungibleToken(tokenBaseKeypair.publicKey); noriTokenBridge = new NoriTokenBridge(noriTokenBridgeKeypair.publicKey); allAccounts = [ deployer.publicKey, admin.publicKey, alice.publicKey, tokenBaseKeypair.publicKey, noriTokenBridgeKeypair.publicKey, ]; logger.log(` deployer ${deployer.publicKey.toBase58()} admin ${admin.publicKey.toBase58()} alice ${alice.publicKey.toBase58()} tokenBase ${tokenBaseKeypair.publicKey.toBase58()} noriTokenBridge ${noriTokenBridgeKeypair.publicKey.toBase58()} `); const analysis = await NoriTokenBridge.analyzeMethods(); for (const [name, data] of Object.entries(analysis)) { console.log(`${name}: ${data.rows} rows`); } // Compile in dependency order. logger.log('Compiling NoriStorageInterface...'); storageInterfaceVK = (await NoriStorageInterface.compile()).verificationKey; logger.log('Compiling FungibleToken...'); tokenBaseVK = (await FungibleToken.compile()).verificationKey; logger.log('Compiling NoriTokenBridge...'); noriTokenBridgeVK = (await NoriTokenBridge.compile()).verificationKey; logger.log('All contracts compiled.'); // Decode example proofs using common helpers logger.log('Decoding test example proofs...'); const decoded1 = decodeConsensusMptProof(examples[0].sp1PlonkProof); ethInput1 = new EthInput(decoded1); // eslint-disable-next-line @typescript-eslint/no-explicit-any rawProof1 = await NodeProofLeft.fromJSON(examples[0].conversionOutputProof.proofData); const decoded2 = decodeConsensusMptProof(examples[1].sp1PlonkProof); ethInput2 = new EthInput(decoded2); // eslint-disable-next-line @typescript-eslint/no-explicit-any rawProof2 = await NodeProofLeft.fromJSON(examples[1].conversionOutputProof.proofData); const decoded3 = decodeConsensusMptProof(examples[2].sp1PlonkProof); ethInput3 = new EthInput(decoded3); // eslint-disable-next-line @typescript-eslint/no-explicit-any rawProof3 = await NodeProofLeft.fromJSON(examples[2].conversionOutputProof.proofData); const decoded4 = decodeConsensusMptProof(examples[3].sp1PlonkProof); ethInput4 = new EthInput(decoded4); // eslint-disable-next-line @typescript-eslint/no-explicit-any rawProof4 = await NodeProofLeft.fromJSON(examples[3].conversionOutputProof.proofData); logger.log('All example proofs decoded.'); }, 1_000_000); beforeEach(async () => { await fetchAccounts(allAccounts); }); // ======================================================================= // Deployment // ======================================================================= describe('Deployment', () => { test('should deploy NoriTokenBridge and FungibleToken', async () => { 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.'); const onchainAdmin = await noriTokenBridge.adminPublicKey.fetch(); assert.equal(onchainAdmin.toBase58(), admin.publicKey.toBase58(), 'adminPublicKey mismatch'); const onchainTokenBase = await noriTokenBridge.tokenBaseAddress.fetch(); assert.equal(onchainTokenBase.toBase58(), tokenBaseKeypair.publicKey.toBase58(), 'tokenBaseAddress mismatch'); const onchainStorageVKHash = await noriTokenBridge.storageVKHash.fetch(); assert.equal(onchainStorageVKHash.toBigInt(), storageInterfaceVK.hash.toBigInt(), 'storageVKHash mismatch'); const mintLock = await noriTokenBridge.mintLock.fetch(); assert.equal(mintLock.toBoolean(), true, 'mintLock should be true after deploy'); const latestHead = await noriTokenBridge.latestHead.fetch(); assert.equal(latestHead.toBigInt(), 0n, 'latestHead should start at 0'); const highByte = await noriTokenBridge.latestHeliusStoreInputHashHighByte.fetch(); const lowerBytes = await noriTokenBridge.latestHeliusStoreInputHashLowerBytes.fetch(); assert.equal(highByte.toBigInt(), initialStoreHash.highByteField.toBigInt(), 'initial store hash high byte mismatch'); assert.equal(lowerBytes.toBigInt(), initialStoreHash.lowerBytesField.toBigInt(), 'initial store hash lower bytes mismatch'); const onchainDecimals = await tokenBase.decimals.fetch(); assert.equal(onchainDecimals.toBigInt(), 6n, 'token decimals mismatch'); logger.log('Deployment verified.'); }, 1_000_000); }); // ======================================================================= // updateNoriHeliosProgramPi0() / updateProofConversionPO2() — on-chain integrity params // ======================================================================= describe('updateNoriHeliosProgramPi0() / updateProofConversionPO2()', () => { describe('Happy Path', () => { test('should set noriHeliosProgramPi0 with admin key', async () => { const pi0 = FrC.from(bridgeHeadNoriSP1HeliosProgramPi0); await txSend({ body: async () => { await noriTokenBridge.updateNoriHeliosProgramPi0(pi0); }, sender: admin.publicKey, signers: [admin.privateKey], }); await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); const onchain = await noriTokenBridge.noriHeliosProgramPi0.fetch(); FrC.from(onchain).assertEquals(pi0, 'noriHeliosProgramPi0 mismatch'); logger.log('noriHeliosProgramPi0 set successfully.'); }, 1_000_000); test('should set both pi0 and po2 in a single transaction', async () => { const pi0 = FrC.from(bridgeHeadNoriSP1HeliosProgramPi0); const po2 = Field.from(proofConversionSP1ToPlonkPO2); await txSend({ body: async () => { await noriTokenBridge.updateNoriHeliosProgramPi0(pi0); await noriTokenBridge.updateProofConversionPO2(po2); }, sender: admin.publicKey, signers: [admin.privateKey], }); await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); const onchainPi0 = await noriTokenBridge.noriHeliosProgramPi0.fetch(); FrC.from(onchainPi0).assertEquals(pi0, 'noriHeliosProgramPi0 mismatch'); const onchainPo2 = await noriTokenBridge.proofConversionPO2.fetch(); assert.equal(onchainPo2.toBigInt(), po2.toBigInt(), 'proofConversionPO2 mismatch'); logger.log('Both pi0 and po2 set in single transaction.'); }, 1_000_000); test('should set proofConversionPO2 with admin key', async () => { const po2 = Field.from(proofConversionSP1ToPlonkPO2); await txSend({ body: async () => { await noriTokenBridge.updateProofConversionPO2(po2); }, sender: admin.publicKey, signers: [admin.privateKey], }); await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); const onchain = await noriTokenBridge.proofConversionPO2.fetch(); assert.equal(onchain.toBigInt(), po2.toBigInt(), 'proofConversionPO2 mismatch'); logger.log('proofConversionPO2 set successfully.'); }, 1_000_000); }); describe('Negative Tests', () => { test('should REJECT updateNoriHeliosProgramPi0 by arbitrary user', async () => { const pi0 = FrC.from(bridgeHeadNoriSP1HeliosProgramPi0); await assert.rejects(() => txSend({ body: async () => { await noriTokenBridge.updateNoriHeliosProgramPi0(pi0); }, sender: alice.publicKey, signers: [alice.privateKey], })); }, 1_000_000); test('should REJECT updateProofConversionPO2 by arbitrary user', async () => { const po2 = Field.from(proofConversionSP1ToPlonkPO2); await assert.rejects(() => txSend({ body: async () => { await noriTokenBridge.updateProofConversionPO2(po2); }, sender: alice.publicKey, signers: [alice.privateKey], })); }, 1_000_000); test('should REJECT updateNoriHeliosProgramPi0 by deployer (not admin)', async () => { const pi0 = FrC.from(bridgeHeadNoriSP1HeliosProgramPi0); await assert.rejects(() => txSend({ body: async () => { await noriTokenBridge.updateNoriHeliosProgramPi0(pi0); }, sender: deployer.publicKey, signers: [deployer.privateKey], })); }, 1_000_000); test('should REJECT updateProofConversionPO2 by deployer (not admin)', async () => { const po2 = Field.from(proofConversionSP1ToPlonkPO2); await assert.rejects(() => txSend({ body: async () => { await noriTokenBridge.updateProofConversionPO2(po2); }, sender: deployer.publicKey, signers: [deployer.privateKey], })); }, 1_000_000); }); }); // ======================================================================= // update() — Ethereum state verification // ======================================================================= describe('update()', () => { describe('Happy Path', () => { test('should accept the first SP1 proof and advance latestHead (block 1)', async () => { const headBefore = await noriTokenBridge.latestHead.fetch(); const poBefore = await noriTokenBridge.proofConversionPO2.fetch(); logger.log(`Before block 1: latestHead=${headBefore}, proofConversionPO2=${poBefore}`); await txSend({ body: async () => { await noriTokenBridge.update(ethInput1, rawProof1); }, sender: deployer.publicKey, signers: [deployer.privateKey], }); await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); const headAfter = await noriTokenBridge.latestHead.fetch(); assert.ok(headAfter.greaterThan(headBefore).toBoolean(), `latestHead must advance: was ${headBefore}, now ${headAfter}`); assert.equal(headAfter.toBigInt(), ethInput1.outputSlot.toBigInt(), 'latestHead must equal proof outputSlot'); const expectedPair = Bytes32FieldPair.fromBytes32(ethInput1.outputStoreHash); const hb = await noriTokenBridge.latestHeliusStoreInputHashHighByte.fetch(); const lb = await noriTokenBridge.latestHeliusStoreInputHashLowerBytes.fetch(); assert.equal(hb.toBigInt(), expectedPair.highByteField.toBigInt(), 'store hash high byte'); assert.equal(lb.toBigInt(), expectedPair.lowerBytesField.toBigInt(), 'store hash lower bytes'); logger.log(`latestHead advanced to slot ${headAfter} (block 1)`); }, 1_000_000); test('should accept block 2 (consecutive from block 1)', async () => { await txSend({ body: async () => { await noriTokenBridge.update(ethInput2, rawProof2); }, sender: deployer.publicKey, signers: [deployer.privateKey], }); await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); const head = await noriTokenBridge.latestHead.fetch(); assert.equal(head.toBigInt(), ethInput2.outputSlot.toBigInt(), 'latestHead after block 2'); logger.log(`latestHead advanced to slot ${head} (block 2)`); }, 1_000_000); test('should accept block 3 (consecutive from block 2)', async () => { await txSend({ body: async () => { await noriTokenBridge.update(ethInput3, rawProof3); }, sender: deployer.publicKey, signers: [deployer.privateKey], }); await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); const head = await noriTokenBridge.latestHead.fetch(); assert.equal(head.toBigInt(), ethInput3.outputSlot.toBigInt(), 'latestHead after block 3'); logger.log(`latestHead advanced to slot ${head} (block 3)`); }, 1_000_000); test('should accept block 4 (consecutive from block 3)', async () => { await txSend({ body: async () => { await noriTokenBridge.update(ethInput4, rawProof4); }, sender: deployer.publicKey, signers: [deployer.privateKey], }); await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); const head = await noriTokenBridge.latestHead.fetch(); assert.equal(head.toBigInt(), ethInput4.outputSlot.toBigInt(), 'latestHead after block 4'); logger.log(`latestHead advanced to slot ${head} (block 4)`); }, 1_000_000); test('verifiedStateRoot should equal Poseidon(executionStateRoot) from last proof', async () => { await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); const onchain = await noriTokenBridge.verifiedStateRoot.fetch(); const expected = Poseidon.hashPacked(Bytes32.provable, ethInput4.executionStateRoot); assert.equal(onchain.toBigInt(), expected.toBigInt(), 'verifiedStateRoot must equal Poseidon(executionStateRoot)'); }, 1_000_000); test('latestVerifiedContractDepositsRoot should match last proof output', async () => { await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); // const hb = await noriTokenBridge.latestVerifiedContractDepositsRootHighByte.fetch(); // const lb = await noriTokenBridge.latestVerifiedContractDepositsRootLowerBytes.fetch(); // const expected = Bytes32FieldPair.fromBytes32(ethInput4.verifiedContractDepositsRoot); // assert.equal(hb.toBigInt(), expected.highByteField.toBigInt(), 'deposits root high byte'); // assert.equal(lb.toBigInt(), expected.lowerBytesField.toBigInt(), 'deposits root lower bytes'); const latestVerifiedContractDepositsRoot = await noriTokenBridge.latestVerifiedContractDepositsRoot.fetch(); const expected = bytes32LEToFieldProvable(ethInput4.verifiedContractDepositsRoot.bytes); assert.equal(latestVerifiedContractDepositsRoot.toBigInt(), expected.toBigInt(), 'deposits root'); }, 1_000_000); }); describe('Negative Tests', () => { test('should REJECT replay of old proof (slot not greater than current)', async () => { await assert.rejects(() => txSend({ body: async () => { await noriTokenBridge.update(ethInput1, rawProof1); }, sender: deployer.publicKey, signers: [deployer.privateKey], }), 'Replay of old proof must fail'); }, 1_000_000); test('should REJECT out-of-order proof (store hash chain broken)', async () => { await assert.rejects(() => txSend({ body: async () => { await noriTokenBridge.update(ethInput2, rawProof2); }, sender: deployer.publicKey, signers: [deployer.privateKey], }), 'Out-of-order proof (store hash mismatch) must fail'); }, 1_000_000); }); }); // ======================================================================= // setUpStorage() — Per-user storage initialisation // ======================================================================= describe('setUpStorage()', () => { describe('Happy Path', () => { test('should initialise storage for Alice', async () => { await txSend({ body: async () => { AccountUpdate.fundNewAccount(alice.publicKey, 1); await noriTokenBridge.setUpStorage(alice.publicKey, storageInterfaceVK); }, sender: alice.publicKey, signers: [alice.privateKey], }); const storage = new NoriStorageInterface(alice.publicKey, noriTokenBridge.deriveTokenId()); const userKeyHash = await storage.userKeyHash.fetch(); assert.equal(userKeyHash.toBigInt(), Poseidon.hash(alice.publicKey.toFields()).toBigInt(), 'userKeyHash must be Poseidon(alicePublicKey)'); const mintedSoFar = await storage.mintedSoFar.fetch(); assert.equal(mintedSoFar.toBigInt(), 0n, 'mintedSoFar must start at 0'); }, 1_000_000); }); describe('Negative Tests', () => { test('should REJECT duplicate storage setup for Alice', async () => { await assert.rejects(() => txSend({ body: async () => { await noriTokenBridge.setUpStorage(alice.publicKey, storageInterfaceVK); }, sender: alice.publicKey, signers: [alice.privateKey], }), 'Duplicate setUpStorage must fail'); }, 1_000_000); test('should REJECT storage setup with wrong VK (hash mismatch)', async () => { const bob = PrivateKey.randomKeypair(); await assert.rejects(() => txSend({ body: async () => { AccountUpdate.fundNewAccount(deployer.publicKey, 1); await noriTokenBridge.setUpStorage(bob.publicKey, tokenBaseVK); }, sender: deployer.publicKey, signers: [deployer.privateKey, bob.privateKey], }), 'Wrong VK in setUpStorage must fail'); }, 1_000_000); test('should REJECT direct mintedSoFar manipulation without a valid proof', async () => { const storage = new NoriStorageInterface(alice.publicKey, noriTokenBridge.deriveTokenId()); const before = await storage.mintedSoFar.fetch(); await txSend({ body: async () => { const tokenAccUpdate = AccountUpdate.createSigned(alice.publicKey, noriTokenBridge.deriveTokenId()); AccountUpdate.setValue(tokenAccUpdate.update.appState[1], // NoriStorageInterface.mintedSoFar Field(9_999_999)); tokenBase.approve(tokenAccUpdate); }, sender: alice.publicKey, signers: [alice.privateKey, tokenBaseKeypair.privateKey], }); const after = await storage.mintedSoFar.fetch(); assert.equal(after.toBigInt(), before.toBigInt(), 'mintedSoFar must not change without a valid proof'); }, 1_000_000); }); }); // ======================================================================= // noriMint() — Token minting // ======================================================================= // ----------------------------------------------------------------------- // describe.skip('noriMint()'): the entire suite is gated on // `adminSetDepositRoot`, which is commented out on the production // contract for safety (see `contracts/mina/src/NoriTokenBridge.ts`). // The test-only method exists so we can seed deposit roots directly // into the rolling window without generating a full SP1 proof for // each synthetic deposit. To re-run these tests locally, uncomment // the contract method, the worker shim, and the call sites below. // ----------------------------------------------------------------------- describe('noriMint()', () => { let aliceDepositAttestationInput; let aliceSCRAMWitness; const aliceScramMsg = 'NoriZK'; beforeAll(async () => { const result = buildSyntheticDeposit(alice.privateKey, aliceScramMsg, 200n); aliceDepositAttestationInput = result.merkleInput; aliceSCRAMWitness = result.scramWitness; logger.log(`Alice synthetic deposit built.`); // Seed Alice's deposit root into the contract's rolling window // via the admin-gated adminSetDepositRoot method, so the // deposit-root assertion in noriMint() passes. const aliceRoot = getContractDepositSlotRootFromContractDepositAndWitness(aliceDepositAttestationInput); logger.log(`Seeding Alice's deposit root into contract window: ${aliceRoot}`); void aliceRoot; // await txSend({ // body: async () => { // await noriTokenBridge.adminSetDepositRoot(aliceRoot); // }, // sender: admin.publicKey, // signers: [admin.privateKey], // }); logger.log('Alice deposit root dispatched to contract.'); await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); logger.log('Deposit root seeded into contract window for Alice.'); }, 1_000_000); describe('Happy Path', () => { test('should mint 2 bridge units for Alice on first deposit', async () => { const windowStartWitness = await fetchWindowStartWitness(noriTokenBridge); await txSend({ body: async () => { AccountUpdate.fundNewAccount(alice.publicKey, 1); await noriTokenBridge.noriMint(aliceDepositAttestationInput, aliceSCRAMWitness, windowStartWitness); }, sender: alice.publicKey, signers: [alice.privateKey], }); await fetchAccount({ publicKey: alice.publicKey, tokenId: tokenBase.deriveTokenId(), }); const balance = await tokenBase.getBalanceOf(alice.publicKey); assert.equal(balance.toBigInt(), 200n, 'Alice should hold 200 bridge units'); const storage = new NoriStorageInterface(alice.publicKey, noriTokenBridge.deriveTokenId()); const mintedSoFar = await storage.mintedSoFar.fetch(); assert.equal(mintedSoFar.toBigInt(), 200n, 'mintedSoFar should record 200 bridge units'); logger.log(`Alice minted ${balance} bridge units successfully.`); }, 1_000_000); test('should mint 3 additional bridge units for Alice on second deposit (totalLocked=5)', async () => { // Build a new synthetic deposit with a higher cumulative totalLocked. // Same SCRAM key+message → same codeChallenge, but different value → different root. const { merkleInput: aliceDeposit2, scramWitness: aliceSCRAM2 } = buildSyntheticDeposit(alice.privateKey, aliceScramMsg, 500n); // Seed the new deposit root into the window const aliceRoot2 = getContractDepositSlotRootFromContractDepositAndWitness(aliceDeposit2); void aliceRoot2; // adminSetDepositRoot is commented out on the production contract; // see top-of-suite note. Skipped tests do not execute this body. // await txSend({ // body: async () => { // await noriTokenBridge.adminSetDepositRoot(aliceRoot2); // }, // sender: admin.publicKey, // signers: [admin.privateKey], // }); await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); // Mint — contract computes amountToMint = totalLocked(500) - mintedSoFar(200) = 300 const windowStartWitness = await fetchWindowStartWitness(noriTokenBridge); await txSend({ body: async () => { await noriTokenBridge.noriMint(aliceDeposit2, aliceSCRAM2, windowStartWitness); }, sender: alice.publicKey, signers: [alice.privateKey], }); await fetchAccount({ publicKey: alice.publicKey, tokenId: tokenBase.deriveTokenId(), }); const balance = await tokenBase.getBalanceOf(alice.publicKey); assert.equal(balance.toBigInt(), 500n, 'Alice should hold 500 bridge units after second mint'); const storage = new NoriStorageInterface(alice.publicKey, noriTokenBridge.deriveTokenId()); const mintedSoFar = await storage.mintedSoFar.fetch(); assert.equal(mintedSoFar.toBigInt(), 500n, 'mintedSoFar should record 500 bridge units'); logger.log('Alice minted 300 additional bridge units (total=500).'); }, 1_000_000); }); // ================================================================= // Window rotation — windowRotationCount roots, eviction after maxWindow // ================================================================= describe('Window Rotation', () => { test('window rotation: setup dave', async () => { await txSend({ body: async () => { AccountUpdate.fundNewAccount(dave.publicKey, 1); await noriTokenBridge.setUpStorage(dave.publicKey, storageInterfaceVK); }, sender: dave.publicKey, signers: [dave.privateKey], }); const roots = await fetchWindowRoots(noriTokenBridge); logger.log(`Dave created. ${roots.length} roots in active window.`); }, 1_000_000); // Dispatch windowRotationCount roots. // Mint for Dave every 10th root and on the last root. for (let i = 1; i <= windowRotationCount; i++) { const shouldMint = i % 10 === 5 || i === windowRotationCount; if (shouldMint) { test(`window rotation root #${i}: dispatch + mint for Dave`, async () => { daveTotalLocked += 100n; const { merkleInput, scramWitness } = buildSyntheticDeposit(dave.privateKey, 'NoriZK', daveTotalLocked); const root = getContractDepositSlotRootFromContractDepositAndWitness(merkleInput); await dispatchRoot(root); const windowStartWitness = await fetchWindowStartWitness(noriTokenBridge); const isFirstMint = daveMintCount === 0; await txSend({ body: async () => { if (isFirstMint) AccountUpdate.fundNewAccount(dave.publicKey, 1); await noriTokenBridge.noriMint(merkleInput, scramWitness, windowStartWitness); }, sender: dave.publicKey, signers: [dave.privateKey], }); await fetchAccount({ publicKey: dave.publicKey, tokenId: tokenBase.deriveTokenId(), }); const balance = await tokenBase.getBalanceOf(dave.publicKey); assert.equal(balance.toBigInt(), daveTotalLocked, `Dave balance should be ${daveTotalLocked}`); const storage = new NoriStorageInterface(dave.publicKey, noriTokenBridge.deriveTokenId()); const mintedSoFar = await storage.mintedSoFar.fetch(); assert.equal(mintedSoFar.toBigInt(), daveTotalLocked, `Dave mintedSoFar should be ${daveTotalLocked}`); daveMintCount++; logger.log(`Window rotation root #${i}: Dave minted (totalLocked=${daveTotalLocked})`); }, 1_000_000); } else { test(`window rotation root #${i}: dispatch deposit root`, async () => { const dummyRoot = Field(1000000n + BigInt(i)); await dispatchRoot(dummyRoot); const windowRoots = await fetchWindowRoots(noriTokenBridge); logger.log(`Window rotation root #${i} dispatched (windowSize=${windowRoots.length})`); }, 1_000_000); } } test('window should be capped at maxWindow', async () => { await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const windowSize = (await noriTokenBridge.windowSize.fetch()); assert.equal(windowSize.toBigInt(), BigInt(maxWindow), `Window size should be capped at ${maxWindow}`); const windowRoots = await fetchWindowRoots(noriTokenBridge); const allRoots = await fetchAllDispatchedRoots(noriTokenBridge); logger.log(`Window rotation complete. windowSize=${windowSize}, windowRoots=${windowRoots.length}, totalDispatched=${allRoots.length}.`); }, 1_000_000); }); // End Window Rotation describe('Negative Tests', () => { test('should REJECT double-mint with the same deposit (zero new amount)', async () => { const windowStartWitness = await fetchWindowStartWitness(noriTokenBridge); await assert.rejects(() => txSend({ body: async () => { await noriTokenBridge.noriMint(aliceDepositAttestationInput, aliceSCRAMWitness, windowStartWitness); }, sender: alice.publicKey, signers: [alice.privateKey], }), 'Double-mint with same deposit must fail'); }, 1_000_000); test('should REJECT mint when totalLocked < 1 bridge unit', async () => { const bob = PrivateKey.randomKeypair(); const { merkleInput: bobDepositAttestationInput, scramWitness: bobSCRAMWitness } = buildSyntheticDeposit(bob.privateKey, 'NoriZK', 0n); await txSend({ body: async () => { AccountUpdate.fundNewAccount(deployer.publicKey, 1); await noriTokenBridge.setUpStorage(bob.publicKey, storageInterfaceVK); }, sender: deployer.publicKey, signers: [deployer.privateKey, bob.privateKey], }); const windowStartWitness = await fetchWindowStartWitness(noriTokenBridge); await assert.rejects(() => txSend({ body: async () => { AccountUpdate.fundNewAccount(deployer.publicKey, 1); await noriTokenBridge.noriMint(bobDepositAttestationInput, bobSCRAMWitness, windowStartWitness); }, sender: bob.publicKey, signers: [bob.privateKey], }), 'Mint with totalLocked < 1 bridge unit must fail'); }, 1_000_000); test('should REJECT mint with wrong SCRAM witness', async () => { const wrongKey = PrivateKey.random(); const { scramWitness: wrongSCRAMWitness } = buildSyntheticDeposit(wrongKey, 'NoriZK-Wrong', 2n); const windowStartWitness = await fetchWindowStartWitness(noriTokenBridge); await assert.rejects(() => txSend({ body: async () => { await noriTokenBridge.noriMint(aliceDepositAttestationInput, wrongSCRAMWitness, windowStartWitness); }, sender: alice.publicKey, signers: [alice.privateKey], }), 'Wrong SCRAM witness must fail'); }, 1_000_000); test('should REJECT mint without storage setup (storage.account.isNew must be false)', async () => { const charlie = PrivateKey.randomKeypair(); const { merkleInput: charlieDepositAttestationInput, scramWitness: charlieSCRAMWitness } = buildSyntheticDeposit(charlie.privateKey, 'NoriZK-Charlie', 2n); const windowStartWitness = await fetchWindowStartWitness(noriTokenBridge); await assert.rejects(() => txSend({ body: async () => { AccountUpdate.fundNewAccount(charlie.publicKey, 1); await noriTokenBridge.noriMint(charlieDepositAttestationInput, charlieSCRAMWitness, windowStartWitness); }, sender: charlie.publicKey, signers: [charlie.privateKey], }), 'Minting without storage setup must fail'); }, 1_000_000); test('should REJECT cross-user SCRAM attack (wrong sender cannot claim Alice deposit)', async () => { const eve = PrivateKey.randomKeypair(); await txSend({ body: async () => { AccountUpdate.fundNewAccount(deployer.publicKey, 1); await noriTokenBridge.setUpStorage(eve.publicKey, storageInterfaceVK); }, sender: deployer.publicKey, signers: [deployer.privateKey, eve.privateKey], }); const windowStartWitness = await fetchWindowStartWitness(noriTokenBridge); await assert.rejects(() => txSend({ body: async () => { AccountUpdate.fundNewAccount(eve.publicKey, 1); await noriTokenBridge.noriMint(aliceDepositAttestationInput, aliceSCRAMWitness, windowStartWitness); }, sender: eve.publicKey, signers: [eve.privateKey], }), 'Cross-user SCRAM attack must fail'); }, 1_000_000); test('should REJECT direct FungibleToken.mint() call (bypassing NoriTokenBridge)', async () => { await assert.rejects(() => txSend({ body: async () => { await tokenBase.mint(alice.publicKey, UInt64.from(100)); }, sender: alice.publicKey, signers: [ alice.privateKey, tokenBaseKeypair.privateKey, noriTokenBridgeKeypair.privateKey, ], }), 'Direct FungibleToken.mint() must fail (canMint guards via mintLock)'); }, 1_000_000); }); }); // ======================================================================= // Admin operations // ======================================================================= // describe('Admin operations', () => { // test('updateStoreHash() should succeed with admin signature', async () => { // const newBytes = new Array(32).fill(0).map((_, i) => i % 256); // const newStoreHash = Bytes32FieldPair.fromBytes32(Bytes32.from(newBytes)); // await txSend({ // body: async () => { // await noriTokenBridge.updateStoreHash(newStoreHash); // }, // sender: admin.publicKey, // signers: [admin.privateKey], // }); // await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey }); // const hb = await noriTokenBridge.latestHeliusStoreInputHashHighByte.fetch(); // const lb = await noriTokenBridge.latestHeliusStoreInputHashLowerBytes.fetch(); // assert.equal(hb.toBigInt(), newStoreHash.highByteField.toBigInt(), 'high byte after updateStoreHash'); // assert.equal(lb.toBigInt(), newStoreHash.lowerBytesField.toBigInt(), 'lower bytes after updateStoreHash'); // }, 1_000_000); // test('updateStoreHash() should REJECT without admin signature', async () => { // const newStoreHash = Bytes32FieldPair.fromBytes32(Bytes32.from(new Array(32).fill(99))); // await assert.rejects( // () => // txSend({ // body: async () => { // await noriTokenBridge.updateStoreHash(newStoreHash); // }, // sender: alice.publicKey, // signers: [alice.privateKey], // }), // 'updateStoreHash() without admin must fail' // ); // }, 1_000_000); // test('updateVerificationKey() should succeed with admin signature', async () => { // const freshVK = (await NoriTokenBridge.compile()).verificationKey; // await txSend({ // body: async () => { // await noriTokenBridge.updateVerificationKey(freshVK); // }, // sender: admin.publicKey, // signers: [admin.privateKey], // }); // logger.log('updateVerificationKey() succeeded.'); // }, 1_000_000); // test('updateVerificationKey() should REJECT without admin signature', async () => { // const freshVK = (await NoriTokenBridge.compile()).verificationKey; // await assert.rejects( // () => // txSend({ // body: async () => { // await noriTokenBridge.updateVerificationKey(freshVK); // }, // sender: alice.publicKey, // signers: [alice.privateKey], // }), // 'updateVerificationKey() without admin must fail' // ); // }, 1_000_000); // }); }); //# sourceMappingURL=NoriTokenBridge.full.local.integration.spec.js.map