@nori-zk/mina-token-bridge
Version:
Nori ethereum state settelment and nETH token bridge zkApp
823 lines • 46.9 kB
JavaScript
/**
* 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