@nori-zk/mina-token-bridge
Version:
Nori ethereum state settelment and nETH token bridge zkApp
852 lines (847 loc) • 48.3 kB
JavaScript
/**
* NoriTokenBridge E2E Test Suite (Lightnet)
*
* Tests the consolidated NoriTokenBridge contract against a local Lightnet Mina node.
* Requires: Lightnet running at http://localhost:8080/graphql (accountManager at :8181)
*
* 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, 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 { getNewMinaLiteNetAccountKeyPair, keyPairBase58ToKeyPair, buildSyntheticDeposit, fetchWindowStartWitness } from '../testUtils.js';
new LogPrinter('TestMinaNoriTokenBridge');
const logger = new Logger('IntegrationLightnetTest');
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 storageInterfaceVK;
let noriTokenBridgeVK;
void noriTokenBridgeVK;
let tokenBaseVK;
let allAccounts;
const examples = buildExampleProofSeriesCreateArguments();
let ethInput1;
let rawProof1;
let ethInput2;
let rawProof2;
let ethInput3;
let rawProof3;
let ethInput4;
let rawProof4;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
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 })));
}
// ---------------------------------------------------------------------------
// Suite
// ---------------------------------------------------------------------------
describe('NoriTokenBridge', () => {
beforeAll(async () => {
// Configure Lightnet
const Network = Mina.Network({
networkId: 'testnet',
mina: process.env.MINA_RPC_NETWORK_URL ?? 'http://localhost:8080/graphql',
// accountManager: process.env.MINA_ACCOUNT_MANAGER_URL ?? 'http://localhost:8181',
archive: process.env.MINA_ARCHIVE_RPC_URL ?? 'http://localhost:8282',
});
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()}
`);
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);
rawProof1 = await NodeProofLeft.fromJSON(examples[0].conversionOutputProof.proofData);
const decoded2 = decodeConsensusMptProof(examples[1].sp1PlonkProof);
ethInput2 = new EthInput(decoded2);
rawProof2 = await NodeProofLeft.fromJSON(examples[1].conversionOutputProof.proofData);
const decoded3 = decodeConsensusMptProof(examples[2].sp1PlonkProof);
ethInput3 = new EthInput(decoded3);
rawProof3 = await NodeProofLeft.fromJSON(examples[2].conversionOutputProof.proofData);
const decoded4 = decodeConsensusMptProof(examples[3].sp1PlonkProof);
ethInput4 = new EthInput(decoded4);
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,
],
});
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(33);
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(54);
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(43);
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(65);
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();
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('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);
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 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;
let allDispatchedRoots = [];
let daveTotalLocked = 0n;
let daveMintCount = 0;
beforeAll(async () => {
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.`);
// 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.
// 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 txSend({
// body: async () => {
// await noriTokenBridge.adminSetDepositRoot(aliceRoot);
// },
// sender: admin.publicKey,
// signers: [admin.privateKey],
// });
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 disabled in production — see top-of-suite note.
// 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 — 40 roots, eviction after 32
// =================================================================
describe('Window Rotation', () => {
test('window rotation: setup dave and seed prior roots', async () => {
// Reconstruct the 6 roots already dispatched by prior tests.
// 4 from update():
allDispatchedRoots.push(bytes32LEToFieldProvable(ethInput1.verifiedContractDepositsRoot.bytes));
// allDispatchedRoots.push(bytes32LEToFieldProvable(ethInput2.verifiedContractDepositsRoot.bytes));
// allDispatchedRoots.push(bytes32LEToFieldProvable(ethInput3.verifiedContractDepositsRoot.bytes));
// allDispatchedRoots.push(bytes32LEToFieldProvable(ethInput4.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 and set up storage
await txSend({
body: async () => {
AccountUpdate.fundNewAccount(dave.publicKey, 1);
await noriTokenBridge.setUpStorage(dave.publicKey, storageInterfaceVK);
},
sender: dave.publicKey,
signers: [dave.privateKey],
});
logger.log(`Dave created. ${allDispatchedRoots.length} prior roots tracked.`);
}, 1_000_000);
// Dispatch 40 roots. Mint for Dave after roots #5, #15, #25, #35.
// Roots #1-26 fill the remaining window (6 already in).
// Root #27+ triggers eviction (window size = 32).
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`, 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 txSend({
// body: async () => {
// await noriTokenBridge.adminSetDepositRoot(root);
// },
// sender: admin.publicKey,
// signers: [admin.privateKey],
// });
allDispatchedRoots.push(root);
await fetchAccount({ publicKey: noriTokenBridgeKeypair.publicKey });
// Fund token account on first mint only
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));
// adminSetDepositRoot disabled in production — see top-of-suite note.
// await txSend({
// body: async () => {
// await noriTokenBridge.adminSetDepositRoot(dummyRoot);
// },
// sender: admin.publicKey,
// signers: [admin.privateKey],
// });
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', () => {
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.lightnet.integration.spec.js.map