@nori-zk/mina-token-bridge
Version:
Nori ethereum state settelment and nETH token bridge zkApp
639 lines • 41.7 kB
JavaScript
/**
* NoriTokenBridge Worker-driven Full E2E Test Suite (Lightnet)
*
* Mirrors the full integration spec single-thread/NoriTokenBridge.full.lightnet.integration.spec.ts —
* happy path, negative tests, and the 40-root window rotation block —
* but drives every contract interaction through a single TokenBridgeTester
* instance. Deployment + every subsequent op go through the same worker.
*
* Only two tests fall back to a direct txSend because they *intentionally*
* bypass the normal contract entrypoints:
* - direct mintedSoFar appState manipulation
* - direct FungibleToken.mint() (bypassing NoriTokenBridge)
*
* One tester instance handles every user — senderPrivateKey is passed per
* call, so there's no per-user worker setup.
*/
import { Logger, LogPrinter } from 'esm-iso-logger';
import { AccountUpdate, fetchAccount, Field, Mina, Poseidon, PrivateKey, UInt64, } 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, Bytes20, Bytes32, Bytes32FieldPair, bytes32LEToFieldProvable, 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 { getTokenBridgeTester } from '../workers/tokenBridgeTester/node/parent.js';
new LogPrinter('TestMinaNoriTokenBridgeWorkerFull');
const logger = new Logger('WorkerFullIntegrationLightnetTest');
const fee = Number(process.env.MINA_TX_FEE ?? 0.1) * 1e9;
// ---------------------------------------------------------------------------
// Shared test state (populated in beforeAll)
// ---------------------------------------------------------------------------
let deployer;
let admin;
let alice;
let tokenBaseKeypair;
let tokenBase;
let noriTokenBridgeKeypair;
let noriTokenBridge;
let allAccounts;
// Single worker drives every contract interaction. Lifecycle:
// - ensureTester() spawns lazily — safe to call from any hook.
// - afterEach terminates and clears the liveness flag so the next test gets a
// fresh instance (o1js leaks memory across proof computations otherwise).
// Tester is non-undefined inside test bodies because beforeEach guarantees it.
let tester;
let testerAlive = false;
// When set, afterEach skips killTester. Used inside negative-test describes
// where assert.rejects tests don't mutate on-chain state — reusing the worker
// across them avoids a recompile per test. The describe's afterAll resets the
// flag and kills the worker.
let keepWorker = false;
// Compiled VKs (safe form) — produced by tester.compile()
let storageInterfaceVerificationKeySafe;
let tokenBaseVerificationKeySafe;
let storageInterfaceVKHashField;
// Network options — captured so the tester can be spawned again after termination.
let networkOptions;
const examples = buildExampleProofSeriesCreateArguments();
let ethInput1;
let ethInput2;
let ethInput3;
let ethInput4;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Spawn a fresh tester worker: wire Mina network + compile circuits.
async function spawnTester() {
const TesterWorker = getTokenBridgeTester();
const instance = new TesterWorker();
await instance.minaSetup(networkOptions);
const compiled = await instance.compile();
return { instance, compiled };
}
// Idempotent: spawns a tester only if one isn't already live. VK metadata is
// re-captured on every spawn (deterministic — same circuits → same VKs).
async function ensureTester() {
if (testerAlive)
return;
const spawned = await spawnTester();
tester = spawned.instance;
testerAlive = true;
storageInterfaceVerificationKeySafe =
spawned.compiled.noriStorageInterfaceVerificationKeySafe;
tokenBaseVerificationKeySafe =
spawned.compiled.fungibleTokenVerificationKeySafe;
storageInterfaceVKHashField = new Field(BigInt(storageInterfaceVerificationKeySafe.hashStr));
}
async function killTester() {
if (!testerAlive)
return;
tester.signalTerminate();
testerAlive = false;
await new Promise((resolve) => setTimeout(() => resolve(null), 2000));
}
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 })));
}
// Convert an in-memory MerkleTreeContractDepositAttestorInput into the JSON
// form expected by tester.mint().
function merkleInputToJson(input) {
const len = Number(input.path.length.toBigInt());
const path = input.path.array
.slice(0, len)
.map((f) => f.toBigInt().toString());
return {
depositIndex: Number(input.index.toBigInt()),
despositSlotRaw: {
slot_key_code_challenge: '0x' + input.value.codeChallenge.toHex(),
value: '0x' + input.value.value.toHex(),
},
path,
};
}
// ---------------------------------------------------------------------------
// Suite
// ---------------------------------------------------------------------------
describe('NoriTokenBridge (Worker-driven, full)', () => {
beforeAll(async () => {
networkOptions = {
networkId: 'testnet',
mina: process.env.MINA_RPC_NETWORK_URL ??
'http://localhost:8080/graphql',
archive: process.env.MINA_ARCHIVE_RPC_URL ??
'http://localhost:8282',
};
const Network = Mina.Network(networkOptions);
Mina.setActiveInstance(Network);
deployer = keyPairBase58ToKeyPair(await getNewMinaLiteNetAccountKeyPair());
admin = keyPairBase58ToKeyPair(await getNewMinaLiteNetAccountKeyPair());
alice = keyPairBase58ToKeyPair(await getNewMinaLiteNetAccountKeyPair());
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()}
`);
await ensureTester();
// Decode example proofs (EthInput only — NodeProofLeft is reconstructed
// inside tester.update from examples[i].conversionOutputProof.proofData).
logger.log('Decoding test example EthInputs...');
ethInput1 = new EthInput(decodeConsensusMptProof(examples[0].sp1PlonkProof));
ethInput2 = new EthInput(decodeConsensusMptProof(examples[1].sp1PlonkProof));
ethInput3 = new EthInput(decodeConsensusMptProof(examples[2].sp1PlonkProof));
ethInput4 = new EthInput(decodeConsensusMptProof(examples[3].sp1PlonkProof));
logger.log('Example EthInputs decoded.');
}, 1_000_000);
beforeEach(async () => {
await ensureTester();
await fetchAccounts(allAccounts);
});
afterEach(async () => {
if (keepWorker)
return;
await killTester();
});
// =======================================================================
// Deployment — via tester
// =======================================================================
describe('Deployment', () => {
test('should deploy NoriTokenBridge and FungibleToken via worker', async () => {
const inputStoreHashHex = ethInput1.inputStoreHash.toHex();
const decoded = decodeConsensusMptProof(examples[0].sp1PlonkProof);
const ethTokenBridgeAddressHex = new Bytes20(decoded.contractAddress.bytes).toHex();
const genesisRootHex = decoded.genesisRoot.toHex();
await tester.deployContracts(deployer.privateKey.toBase58(), admin.publicKey.toBase58(), noriTokenBridgeKeypair.privateKey.toBase58(), tokenBaseKeypair.privateKey.toBase58(), inputStoreHashHex, ethTokenBridgeAddressHex, genesisRootHex, storageInterfaceVerificationKeySafe, bridgeHeadNoriSP1HeliosProgramPi0, proofConversionSP1ToPlonkPO2, fee, {
symbol: 'nETH',
decimals: 6,
allowUpdates: true,
startPaused: false,
});
await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey });
const initialStoreHash = Bytes32FieldPair.fromBytes32(ethInput1.inputStoreHash);
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(), storageInterfaceVKHashField.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');
await fetchAccount({ publicKey: tokenBaseKeypair.publicKey });
const onchainDecimals = await tokenBase.decimals.fetch();
assert.equal(onchainDecimals.toBigInt(), 6n, 'token decimals mismatch');
logger.log('Worker-driven deployment verified.');
}, 1_000_000);
});
// =======================================================================
// updateNoriHeliosProgramPi0() / updateProofConversionPO2()
// Worker exposes single-setter AND combined updateIntegrityParams.
// =======================================================================
describe('updateNoriHeliosProgramPi0() / updateProofConversionPO2()', () => {
describe('Happy Path', () => {
test('should set noriHeliosProgramPi0 with admin key (worker)', async () => {
const pi0 = bridgeHeadNoriSP1HeliosProgramPi0;
await tester.updateNoriHeliosProgramPi0(admin.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), pi0, fee);
await fetchAccount({
publicKey: noriTokenBridgeKeypair.publicKey,
});
const onchain = await noriTokenBridge.noriHeliosProgramPi0.fetch();
FrC.from(onchain).assertEquals(FrC.from(pi0), 'noriHeliosProgramPi0 mismatch');
logger.log('noriHeliosProgramPi0 set successfully.');
}, 1_000_000);
test('should set both pi0 and po2 in a single transaction (worker)', async () => {
const pi0 = bridgeHeadNoriSP1HeliosProgramPi0;
const po2 = proofConversionSP1ToPlonkPO2;
await tester.updateIntegrityParams(admin.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), pi0, po2, fee);
await fetchAccount({
publicKey: noriTokenBridgeKeypair.publicKey,
});
const onchainPi0 = await noriTokenBridge.noriHeliosProgramPi0.fetch();
FrC.from(onchainPi0).assertEquals(FrC.from(pi0), 'noriHeliosProgramPi0 mismatch');
const onchainPo2 = await noriTokenBridge.proofConversionPO2.fetch();
assert.equal(onchainPo2.toString(), po2, 'proofConversionPO2 mismatch');
logger.log('Both pi0 and po2 set via worker.');
}, 1_000_000);
test('should set proofConversionPO2 with admin key (worker)', async () => {
const po2 = proofConversionSP1ToPlonkPO2;
await tester.updateProofConversionPO2(admin.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), po2, fee);
await fetchAccount({
publicKey: noriTokenBridgeKeypair.publicKey,
});
const onchain = await noriTokenBridge.proofConversionPO2.fetch();
assert.equal(onchain.toString(), po2, 'proofConversionPO2 mismatch');
logger.log('proofConversionPO2 set successfully.');
}, 1_000_000);
});
describe('Negative Tests', () => {
beforeAll(() => { keepWorker = true; });
afterAll(async () => {
keepWorker = false;
await killTester();
});
test('should REJECT updateIntegrityParams by arbitrary user (worker, alice)', async () => {
const pi0 = bridgeHeadNoriSP1HeliosProgramPi0;
const po2 = proofConversionSP1ToPlonkPO2;
await assert.rejects(() => tester.updateIntegrityParams(alice.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), pi0, po2, fee), 'updateIntegrityParams by alice must fail');
}, 1_000_000);
test('should REJECT updateIntegrityParams by deployer (not admin) (worker)', async () => {
const pi0 = bridgeHeadNoriSP1HeliosProgramPi0;
const po2 = proofConversionSP1ToPlonkPO2;
await assert.rejects(() => tester.updateIntegrityParams(deployer.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), pi0, po2, fee), 'updateIntegrityParams by deployer must fail');
}, 1_000_000);
test('should REJECT updateNoriHeliosProgramPi0 by arbitrary user (worker)', async () => {
await assert.rejects(() => tester.updateNoriHeliosProgramPi0(alice.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), FrC.from(33).toBigInt().toString(), fee));
}, 1_000_000);
test('should REJECT updateProofConversionPO2 by arbitrary user (worker)', async () => {
await assert.rejects(() => tester.updateProofConversionPO2(alice.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), '54', fee));
}, 1_000_000);
test('should REJECT updateNoriHeliosProgramPi0 by deployer (not admin) (worker)', async () => {
await assert.rejects(() => tester.updateNoriHeliosProgramPi0(deployer.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), FrC.from(43).toBigInt().toString(), fee));
}, 1_000_000);
test('should REJECT updateProofConversionPO2 by deployer (not admin) (worker)', async () => {
await assert.rejects(() => tester.updateProofConversionPO2(deployer.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), '65', fee));
}, 1_000_000);
});
});
// =======================================================================
// update() — Ethereum state verification (worker)
// =======================================================================
describe('update()', () => {
describe('Happy Path', () => {
test('should accept the first SP1 proof and advance latestHead (block 1) (worker)', async () => {
const headBefore = await noriTokenBridge.latestHead.fetch();
await tester.update(deployer.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), examples[0].sp1PlonkProof, examples[0].conversionOutputProof.proofData, fee);
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) (worker)', async () => {
await tester.update(deployer.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), examples[1].sp1PlonkProof, examples[1].conversionOutputProof.proofData, fee);
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) (worker)', async () => {
await tester.update(deployer.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), examples[2].sp1PlonkProof, examples[2].conversionOutputProof.proofData, fee);
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) (worker)', async () => {
await tester.update(deployer.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), examples[3].sp1PlonkProof, examples[3].conversionOutputProof.proofData, fee);
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 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', () => {
beforeAll(() => { keepWorker = true; });
afterAll(async () => {
keepWorker = false;
await killTester();
});
test('should REJECT replay of old proof (slot not greater than current) (worker)', async () => {
await assert.rejects(() => tester.update(deployer.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), examples[0].sp1PlonkProof, examples[0].conversionOutputProof.proofData, fee), 'Replay of old proof must fail');
}, 1_000_000);
test('should REJECT out-of-order proof (store hash chain broken) (worker)', async () => {
await assert.rejects(() => tester.update(deployer.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), examples[1].sp1PlonkProof, examples[1].conversionOutputProof.proofData, fee), '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 (worker)', async () => {
await tester.setUpStorage(alice.privateKey.toBase58(), alice.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), storageInterfaceVerificationKeySafe, fee);
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', () => {
beforeAll(() => { keepWorker = true; });
afterAll(async () => {
keepWorker = false;
await killTester();
});
test('should REJECT duplicate storage setup for Alice (worker)', async () => {
await assert.rejects(() => tester.setUpStorage(alice.privateKey.toBase58(), alice.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), storageInterfaceVerificationKeySafe, fee), 'Duplicate setUpStorage must fail');
}, 1_000_000);
test('should REJECT storage setup with wrong VK (hash mismatch) (worker)', async () => {
const bob = PrivateKey.randomKeypair();
await assert.rejects(() => tester.setUpStorage(deployer.privateKey.toBase58(), bob.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), tokenBaseVerificationKeySafe, fee), 'Wrong VK in setUpStorage must fail');
}, 1_000_000);
test('should REJECT direct mintedSoFar manipulation without a valid proof (direct)', 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 against lightnet,
// uncomment the contract method, the worker shim
// (`workers/tokenBridgeTester/worker.ts`), and the call sites below.
// -----------------------------------------------------------------------
describe.skip('noriMint()', () => {
let aliceDepositAttestationInput;
let aliceSCRAMWitness;
const aliceScramMsg = 'NoriZK';
let dave;
const allDispatchedRoots = [];
let daveTotalLocked = 0n;
let daveMintCount = 0;
beforeAll(async () => {
await ensureTester();
dave = keyPairBase58ToKeyPair(await getNewMinaLiteNetAccountKeyPair());
allAccounts.push(dave.publicKey);
const result = buildSyntheticDeposit(alice.privateKey, aliceScramMsg, 200n);
aliceDepositAttestationInput = result.merkleInput;
aliceSCRAMWitness = result.scramWitness;
logger.log(`Alice synthetic deposit built.`);
await fetchAccounts(allAccounts);
// Seed Alice's deposit root into the contract's rolling window via
// the admin-gated adminSetDepositRoot method (through the tester worker).
// adminSetDepositRoot is commented out on the production contract;
// the body of this beforeAll is therefore neutralised and the
// suite is `.skip`-ed (see top-of-suite note).
const aliceRoot = getContractDepositSlotRootFromContractDepositAndWitness(aliceDepositAttestationInput);
void aliceRoot;
// await tester.adminSetDepositRoot(
// admin.privateKey.toBase58(),
// noriTokenBridgeKeypair.publicKey.toBase58(),
// aliceRoot.toBigInt().toString(),
// fee
// );
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 (worker)', async () => {
const merkleInputJson = merkleInputToJson(aliceDepositAttestationInput);
const signatureSCRAMBase58 = aliceSCRAMWitness.signature.toBase58();
await tester.mint(alice.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), merkleInputJson, aliceScramMsg, signatureSCRAMBase58,
/* fundNewAccount */ true, fee);
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 (worker).`);
}, 1_000_000);
test('should mint 3 additional bridge units for Alice on second deposit (totalLocked=5) (worker)', async () => {
const { merkleInput: aliceDeposit2, scramWitness: aliceSCRAM2, } = buildSyntheticDeposit(alice.privateKey, aliceScramMsg, 500n);
const aliceRoot2 = getContractDepositSlotRootFromContractDepositAndWitness(aliceDeposit2);
void aliceRoot2;
// adminSetDepositRoot disabled in production — see top-of-suite note.
// await tester.adminSetDepositRoot(
// admin.privateKey.toBase58(),
// noriTokenBridgeKeypair.publicKey.toBase58(),
// aliceRoot2.toBigInt().toString(),
// fee
// );
await fetchAccount({
publicKey: noriTokenBridgeKeypair.publicKey,
});
// Mint via worker — contract computes amountToMint = 500 - 200 = 300.
await tester.mint(alice.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), merkleInputToJson(aliceDeposit2), aliceScramMsg, aliceSCRAM2.signature.toBase58(),
/* fundNewAccount */ false, fee);
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) via worker.');
}, 1_000_000);
});
// =================================================================
// Window rotation — 40 roots, eviction after 32
// =================================================================
describe('Window Rotation', () => {
test('window rotation: setup dave (worker) and seed prior roots', async () => {
// Reconstruct the roots already dispatched by prior tests.
allDispatchedRoots.push(bytes32LEToFieldProvable(ethInput1.verifiedContractDepositsRoot.bytes));
// 1 from alice first deposit root seed:
const aliceResult1 = buildSyntheticDeposit(alice.privateKey, 'NoriZK', 200n);
allDispatchedRoots.push(getContractDepositSlotRootFromContractDepositAndWitness(aliceResult1.merkleInput));
// 1 from alice second deposit root seed:
const aliceResult2 = buildSyntheticDeposit(alice.privateKey, 'NoriZK', 500n);
allDispatchedRoots.push(getContractDepositSlotRootFromContractDepositAndWitness(aliceResult2.merkleInput));
// Create dave's storage account via the tester worker.
await tester.setUpStorage(dave.privateKey.toBase58(), dave.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), storageInterfaceVerificationKeySafe, fee);
logger.log(`Dave created. ${allDispatchedRoots.length} prior roots tracked.`);
}, 1_000_000);
// Dispatch 40 roots. Mint for Dave at iterations 5/15/25/35/40.
for (let i = 1; i <= 40; i++) {
const shouldMint = [5, 15, 25, 35, 40].includes(i);
if (shouldMint) {
test(`window rotation root #${i}: dispatch + mint for Dave (worker)`, async () => {
daveTotalLocked += 100n;
const { merkleInput, scramWitness } = buildSyntheticDeposit(dave.privateKey, 'NoriZK', daveTotalLocked);
const root = getContractDepositSlotRootFromContractDepositAndWitness(merkleInput);
// adminSetDepositRoot disabled in production — see top-of-suite note.
// await tester.adminSetDepositRoot(
// admin.privateKey.toBase58(),
// noriTokenBridgeKeypair.publicKey.toBase58(),
// root.toBigInt().toString(),
// fee
// );
allDispatchedRoots.push(root);
await fetchAccount({
publicKey: noriTokenBridgeKeypair.publicKey,
});
// Fund token account on first mint only.
const isFirstMint = daveMintCount === 0;
await tester.mint(dave.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), merkleInputToJson(merkleInput), 'NoriZK', scramWitness.signature.toBase58(),
/* fundNewAccount */ isFirstMint, fee);
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}) via worker`);
}, 1_000_000);
}
else {
test(`window rotation root #${i}: dispatch deposit root (worker)`, async () => {
const dummyRoot = Field(1000000n + BigInt(i));
// adminSetDepositRoot disabled in production — see top-of-suite note.
// await tester.adminSetDepositRoot(
// admin.privateKey.toBase58(),
// noriTokenBridgeKeypair.publicKey.toBase58(),
// dummyRoot.toBigInt().toString(),
// fee
// );
allDispatchedRoots.push(dummyRoot);
await fetchAccount({
publicKey: noriTokenBridgeKeypair.publicKey,
});
logger.log(`Window rotation root #${i} dispatched (total=${allDispatchedRoots.length}, windowSize=${Math.min(allDispatchedRoots.length, 32)})`);
}, 1_000_000);
}
}
test('window should be capped at 32', 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(), 32n, 'Window size should be capped at 32');
logger.log(`Window rotation complete. windowSize=${windowSize}.`);
}, 1_000_000);
}); // End Window Rotation
describe('Negative Tests', () => {
beforeAll(() => { keepWorker = true; });
afterAll(async () => {
keepWorker = false;
await killTester();
});
test('should REJECT double-mint with the same deposit (worker)', async () => {
await assert.rejects(() => tester.mint(alice.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), merkleInputToJson(aliceDepositAttestationInput), aliceScramMsg, aliceSCRAMWitness.signature.toBase58(),
/* fundNewAccount */ false, fee), 'Double-mint with same deposit must fail');
}, 1_000_000);
test('should REJECT mint when totalLocked < 1 bridge unit (worker)', async () => {
const bob = PrivateKey.randomKeypair();
const { merkleInput: bobDepositAttestationInput, scramWitness: bobSCRAMWitness, } = buildSyntheticDeposit(bob.privateKey, 'NoriZK', 0n);
// Set up bob's storage via the tester worker (deployer funds, bob signs).
await tester.setUpStorage(deployer.privateKey.toBase58(), bob.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), storageInterfaceVerificationKeySafe, fee);
await assert.rejects(() => tester.mint(bob.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), merkleInputToJson(bobDepositAttestationInput), 'NoriZK', bobSCRAMWitness.signature.toBase58(),
/* fundNewAccount */ true, fee), 'Mint with totalLocked < 1 bridge unit must fail');
}, 1_000_000);
test('should REJECT mint with wrong SCRAM witness (worker)', async () => {
const wrongKey = PrivateKey.random();
const { scramWitness: wrongSCRAMWitness } = buildSyntheticDeposit(wrongKey, 'NoriZK-Wrong', 2n);
await assert.rejects(() => tester.mint(alice.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), merkleInputToJson(aliceDepositAttestationInput), 'NoriZK-Wrong', wrongSCRAMWitness.signature.toBase58(),
/* fundNewAccount */ false, fee), 'Wrong SCRAM witness must fail');
}, 1_000_000);
test('should REJECT mint without storage setup (worker)', async () => {
const charlie = PrivateKey.randomKeypair();
allAccounts.push(charlie.publicKey);
await fetchAccount({ publicKey: charlie.publicKey });
const { merkleInput: charlieDepositAttestationInput, scramWitness: charlieSCRAMWitness, } = buildSyntheticDeposit(charlie.privateKey, 'NoriZK-Charlie', 2n);
await assert.rejects(() => tester.mint(charlie.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), merkleInputToJson(charlieDepositAttestationInput), 'NoriZK-Charlie', charlieSCRAMWitness.signature.toBase58(),
/* fundNewAccount */ true, fee), 'Minting without storage setup must fail');
}, 1_000_000);
test('should REJECT cross-user SCRAM attack (worker, eve cannot claim alice deposit)', async () => {
const eve = PrivateKey.randomKeypair();
// Set up eve's storage via the tester worker (deployer funds, eve signs).
await tester.setUpStorage(deployer.privateKey.toBase58(), eve.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), storageInterfaceVerificationKeySafe, fee);
await assert.rejects(() => tester.mint(eve.privateKey.toBase58(), noriTokenBridgeKeypair.publicKey.toBase58(), merkleInputToJson(aliceDepositAttestationInput), aliceScramMsg, aliceSCRAMWitness.signature.toBase58(),
/* fundNewAccount */ true, fee), 'Cross-user SCRAM attack must fail');
}, 1_000_000);
test('should REJECT direct FungibleToken.mint() call (bypassing NoriTokenBridge) (direct)', 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);
});
});
});
//# sourceMappingURL=NoriTokenBridge.full.lightnet.integration.spec.js.map