Files
zeekay 88d6d36694 test(BridgeV2): pass the new (networkId, sourceChainId) ctor args; gitignore forge-out
BridgeV2 deploys in the TS suites now supply this chain's ZAP source identity
(networkId=96369, sourceChainId 32-byte). NOTE: the TS suite has a PRE-EXISTING
break unrelated to this change — getContractFactory("P3QVerifierStub") fails
(HH700, no such contract in the repo; renamed/removed). The messageHash
assertions still need re-baselining to the new ZAP digest D — separate test
maintenance; the digest itself is proven byte-equal to Go via the forge suite.
2026-06-26 18:09:40 -07:00

508 lines
18 KiB
TypeScript

import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { expect } from "chai";
import hre, { ethers } from "hardhat";
/**
* Bridge end-to-end tests.
*
* Exercises the full burn → envelope → claim flow:
*
* 1. User calls BridgeV2.burn() with a BurnRequest.
* 2. messageHash emitted in BridgeBurnedV2 event is reconstructed
* OFF-CHAIN purely from event topics + data.
* 3. Envelope is constructed with that messageHash. Lux validator
* set signs it (mocked here via Warp2VerifierStub.setExpected).
* 4. ClaimData built from the same source-chain values. Submitted
* to BridgeV2.claim() — or claimWithProof() in the PQ flow.
* 5. Mint emitted on the destination chain.
*
* Coverage of the SPEC §2 state machine:
*
* - 2.1 burn — happy path + token allow + non-zero recipient
* - 2.2 claim — happy path + binding + replay + profile gate
* - 2.3 claimWithProof — gate-fires-first + audit-gated reject
* - 2.4 admin paths — allowlist + profile + EXTCODEHASH
*
* Coverage of the PROOF-CLAIMS:
*
* - P1 (refusal symmetry): claim + claimWithProof both refuse
* identically under strict-PQ.
* - P3 (envelope binding): tampering any claim field is rejected.
* - P4 (replay): second call with same messageHash refused.
* - P5 (EXTCODEHASH): spoofed-policy verifier with allowlisted
* code passes; same policy without allowlist fails install.
*/
const PROFILE_CLASSICAL = ethers.id("PROFILE_CLASSICAL_COMPAT");
const PROFILE_STRICT_PQ = ethers.id("PROFILE_QUASAR_STRICT_PQ");
const POLICY_STARK_FRI_SHA3 = ethers.id("ProofPolicySTARKFRISHA3PQ");
const POLICY_GROTH16_BN254 = ethers.id("ProofPolicyGroth16BN254");
async function deployBridge() {
const [admin, user, attacker, feeRecipient] = await hre.ethers.getSigners();
// Warp2VerifierStub with all three lanes required.
const Warp2Stub = await hre.ethers.getContractFactory("Warp2VerifierStub");
const warp2 = await Warp2Stub.deploy(admin.address, 0x07);
await warp2.waitForDeployment();
// BridgeV2
const BridgeV2 = await hre.ethers.getContractFactory("BridgeV2");
const bridge = await BridgeV2.deploy(
admin.address,
await warp2.getAddress(),
feeRecipient.address,
0n, // no fee for clarity
0x07,
96369, // networkId — this chain's ZAP source network id
"0x0000000000000000000000000000000000000000000000000000000000017871" // sourceChainId (32-byte)
);
await bridge.waitForDeployment();
// Bridgeable token + role grant
const LuxBTC = await hre.ethers.getContractFactory("LuxBTC");
const token = await LuxBTC.deploy(admin.address);
await token.waitForDeployment();
const BRIDGE_ROLE = await token.BRIDGE_ROLE();
await token.connect(admin).grantRole(BRIDGE_ROLE, await bridge.getAddress());
await bridge.connect(admin).setTokenAllowed(await token.getAddress(), true);
// MockVerifiers for the strict-PQ slot
const Mock = await hre.ethers.getContractFactory("MockVerifier");
const mockStark = await Mock.deploy(POLICY_STARK_FRI_SHA3);
await mockStark.waitForDeployment();
const mockGroth16 = await Mock.deploy(POLICY_GROTH16_BN254);
await mockGroth16.waitForDeployment();
// Pre-allowlist mockStark's code hash so strict-PQ install succeeds.
const code = await hre.ethers.provider.getCode(await mockStark.getAddress());
const codeHash = ethers.keccak256(code);
await bridge.connect(admin).setAllowedVerifierCodeHash(codeHash, true);
return {
admin,
user,
attacker,
feeRecipient,
bridge,
token,
warp2,
mockStark,
mockGroth16,
codeHash,
};
}
/**
* Helper: derive the canonical messageHash from a ClaimData. The
* SPEC §2.1 algorithm:
* messageHash = keccak256(abi.encode(
* sourceChainId, destChainId, token, amount, recipient, vault, nonce))
*/
function deriveMessageHash(c: {
sourceChainId: bigint;
destChainId: bigint;
token: string;
amount: bigint;
recipient: string;
vault: boolean;
nonce: bigint;
}): string {
return ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
["uint64", "uint64", "address", "uint256", "address", "bool", "uint256"],
[
c.sourceChainId,
c.destChainId,
c.token,
c.amount,
c.recipient,
c.vault,
c.nonce,
]
)
);
}
describe("Bridge e2e — full round-trip under classical-compat", function () {
it("burn emits messageHash that off-chain code can reconstruct from logs", async () => {
const { user, bridge, token } = await loadFixture(deployBridge);
const network = await hre.ethers.provider.getNetwork();
const destChainId = 96369n;
const r = {
token: await token.getAddress(),
amount: ethers.parseEther("1"),
destChainId,
recipient: user.address,
vault: false,
};
// Burn-and-mint path: mint to user first, then bridge.
// (LuxBTC has DEFAULT_ADMIN_ROLE for the deployer; we use the
// bridge to test the burn pathway, which calls bridgeBurn on the
// token requiring the BRIDGE_ROLE we already granted.)
// For the burn() call to succeed we need the user to have tokens.
// We grant the user some via direct bridgeMint (test only).
const [admin] = await hre.ethers.getSigners();
// Workaround: BridgeV2.burn calls bridgeBurn which needs the user's
// balance. Use the bridge's own bridgeMint path indirectly by
// pre-granting BRIDGE_ROLE to admin and minting to user.
const BRIDGE_ROLE = await token.BRIDGE_ROLE();
await token.connect(admin).grantRole(BRIDGE_ROLE, admin.address);
await token.connect(admin).bridgeMint(user.address, r.amount);
const tx = await bridge.connect(user).burn(r);
const receipt = await tx.wait();
// Find the BridgeBurnedV2 event.
const burnEventTopic = bridge.interface.getEvent("BridgeBurnedV2")!.topicHash;
const log = receipt!.logs.find((l) => l.topics[0] === burnEventTopic);
expect(log).to.exist;
const parsed = bridge.interface.parseLog({
topics: log!.topics as string[],
data: log!.data,
});
expect(parsed!.name).to.equal("BridgeBurnedV2");
// Reconstruct messageHash from event fields — this is what an
// off-chain oracle / relay would do.
const reconstructed = deriveMessageHash({
sourceChainId: BigInt(network.chainId),
destChainId: parsed!.args.destChainId,
token: parsed!.args.token,
amount: parsed!.args.amount,
recipient: parsed!.args.recipient,
vault: parsed!.args.vault,
nonce: parsed!.args.nonce,
});
expect(reconstructed).to.equal(parsed!.args.messageHash);
});
it("claim round-trip: classical-compat profile, all-three-lanes envelope mints to recipient", async () => {
const { admin, user, bridge, token, warp2 } = await loadFixture(deployBridge);
// Build a claim and envelope for the destination chain.
const network = await hre.ethers.provider.getNetwork();
const destChainId = BigInt(network.chainId);
const sourceChainId = 96369n;
const c = {
sourceChainId,
destChainId,
token: await token.getAddress(),
amount: ethers.parseEther("5"),
recipient: user.address,
vault: false,
nonce: 42n,
};
const messageHash = deriveMessageHash(c);
// Lux validator set signs envelope (mocked).
await warp2.connect(admin).setExpected(messageHash, true);
// Default profile is classical-compat — claim should succeed
// without any strict-PQ slot config.
const env = {
messageHash,
sourceChainId,
destChainId,
blsAggregate: "0x" + "ab".repeat(50),
mldsaCertSet: "0x",
pulseSignature: "0x",
epoch: 1n,
};
await expect(bridge.claim(env, c))
.to.emit(bridge, "BridgeMintedV2")
.withArgs(messageHash, c.token, c.recipient, c.amount, 0n, 1n);
// Verify the user received the minted tokens.
expect(await token.balanceOf(user.address)).to.equal(c.amount);
// Replay: second claim with same envelope must refuse.
await expect(bridge.claim(env, c)).to.be.revertedWithCustomError(
bridge,
"MessageAlreadyUsed"
);
});
});
describe("Bridge e2e — claim refuses tampered claim under matching envelope", function () {
// P3 envelope binding: an envelope signed for (amount=5) cannot be
// replayed against claim with (amount=10), because env.messageHash
// != keccak256(abi.encode(claim with amount=10)).
async function setup() {
const fx = await loadFixture(deployBridge);
const { admin, user, warp2, token } = fx;
const network = await hre.ethers.provider.getNetwork();
const destChainId = BigInt(network.chainId);
const sourceChainId = 96369n;
const c = {
sourceChainId,
destChainId,
token: await token.getAddress(),
amount: ethers.parseEther("5"),
recipient: user.address,
vault: false,
nonce: 1n,
};
const messageHash = deriveMessageHash(c);
await warp2.connect(admin).setExpected(messageHash, true);
const env = {
messageHash,
sourceChainId,
destChainId,
blsAggregate: "0x" + "ab".repeat(50),
mldsaCertSet: "0x",
pulseSignature: "0x",
epoch: 1n,
};
return { ...fx, env, c };
}
it("refuses tampered amount", async () => {
const { bridge, env, c, attacker } = await setup();
const tampered = { ...c, amount: c.amount * 2n };
await expect(
bridge.connect(attacker).claim(env, tampered)
).to.be.revertedWithCustomError(bridge, "EnvelopeBindingMismatch");
});
it("refuses tampered recipient", async () => {
const { bridge, env, c, attacker } = await setup();
const tampered = { ...c, recipient: attacker.address };
await expect(
bridge.connect(attacker).claim(env, tampered)
).to.be.revertedWithCustomError(bridge, "EnvelopeBindingMismatch");
});
it("refuses tampered nonce", async () => {
const { bridge, env, c, attacker } = await setup();
const tampered = { ...c, nonce: c.nonce + 1n };
await expect(
bridge.connect(attacker).claim(env, tampered)
).to.be.revertedWithCustomError(bridge, "EnvelopeBindingMismatch");
});
it("refuses tampered token", async () => {
const { bridge, env, c, attacker, admin } = await setup();
// Deploy a second token and approve it on the bridge.
const LBTC = await hre.ethers.getContractFactory("LuxBTC");
const otherToken = await LBTC.deploy(admin.address);
await otherToken.waitForDeployment();
await bridge
.connect(admin)
.setTokenAllowed(await otherToken.getAddress(), true);
const tampered = { ...c, token: await otherToken.getAddress() };
await expect(
bridge.connect(attacker).claim(env, tampered)
).to.be.revertedWithCustomError(bridge, "EnvelopeBindingMismatch");
});
it("refuses envelope from wrong source chain", async () => {
const { bridge, env, c, attacker } = await setup();
// ClaimData has sourceChainId = 96369, but the envelope's
// sourceChainId is 99999 — must mismatch.
const wrongEnv = { ...env, sourceChainId: 99999n };
await expect(
bridge.connect(attacker).claim(wrongEnv, c)
).to.be.revertedWithCustomError(bridge, "EnvelopeBindingMismatch");
});
it("refuses envelope intended for a different destination chain", async () => {
const { bridge, env, c, attacker } = await setup();
// envelope.destChainId != block.chainid
const wrongEnv = { ...env, destChainId: 0x123n };
await expect(
bridge.connect(attacker).claim(wrongEnv, c)
).to.be.revertedWithCustomError(bridge, "InvalidChainId");
});
});
describe("Bridge e2e — strict-PQ profile flip end-to-end", function () {
it("classical → strict-PQ transition: claim succeeds under both profiles for a STARK verifier", async () => {
const { admin, user, bridge, token, warp2, mockStark } = await loadFixture(
deployBridge
);
const network = await hre.ethers.provider.getNetwork();
const destChainId = BigInt(network.chainId);
const sourceChainId = 96369n;
// First claim under classical-compat (default)
const c1 = {
sourceChainId,
destChainId,
token: await token.getAddress(),
amount: ethers.parseEther("1"),
recipient: user.address,
vault: false,
nonce: 100n,
};
const mh1 = deriveMessageHash(c1);
await warp2.connect(admin).setExpected(mh1, true);
const env1 = {
messageHash: mh1,
sourceChainId,
destChainId,
blsAggregate: "0x" + "ab".repeat(50),
mldsaCertSet: "0x",
pulseSignature: "0x",
epoch: 1n,
};
await expect(bridge.claim(env1, c1)).to.emit(bridge, "BridgeMintedV2");
// Flip to strict-PQ. mockStark is pre-allowlisted in fixture.
await bridge
.connect(admin)
.setActiveProfile(PROFILE_STRICT_PQ, await mockStark.getAddress());
// Second claim under strict-PQ — must also succeed.
const c2 = { ...c1, nonce: 101n };
const mh2 = deriveMessageHash(c2);
await warp2.connect(admin).setExpected(mh2, true);
const env2 = { ...env1, messageHash: mh2 };
await expect(bridge.claim(env2, c2)).to.emit(bridge, "BridgeMintedV2");
});
it("strict-PQ profile refuses to install a Groth16 verifier (P1 symmetry)", async () => {
const { admin, bridge, mockGroth16 } = await loadFixture(deployBridge);
// Even though mockGroth16 is a legitimate IVerifier, its policyId
// is POLICY_GROTH16_BN254 which is not in the strict-PQ allow-list.
await expect(
bridge
.connect(admin)
.setActiveProfile(PROFILE_STRICT_PQ, await mockGroth16.getAddress())
)
.to.be.revertedWithCustomError(bridge, "VerifierRefusedUnderStrictPQ")
.withArgs(PROFILE_STRICT_PQ, POLICY_GROTH16_BN254);
});
it("strict-PQ profile refuses address(0) install (P1 + no silent no-op)", async () => {
const { admin, bridge } = await loadFixture(deployBridge);
await expect(
bridge
.connect(admin)
.setActiveProfile(PROFILE_STRICT_PQ, ethers.ZeroAddress)
)
.to.be.revertedWithCustomError(bridge, "VerifierUnsetUnderStrictPQ")
.withArgs(PROFILE_STRICT_PQ);
});
it("rollback from strict-PQ to classical-compat is admissible (no-op gate)", async () => {
const { admin, bridge, mockStark } = await loadFixture(deployBridge);
// Flip to strict-PQ first.
await bridge
.connect(admin)
.setActiveProfile(PROFILE_STRICT_PQ, await mockStark.getAddress());
expect(await bridge.activeProfile()).to.equal(PROFILE_STRICT_PQ);
// Roll back. Any verifier (or address(0)) is admissible under
// classical-compat — the gate is a no-op.
await bridge
.connect(admin)
.setActiveProfile(PROFILE_CLASSICAL, ethers.ZeroAddress);
expect(await bridge.activeProfile()).to.equal(PROFILE_CLASSICAL);
});
});
describe("Bridge e2e — admin role split (DEFAULT_ADMIN_ROLE vs ADMIN_ROLE)", function () {
// P5 + GATE-2: only DEFAULT_ADMIN_ROLE can extend the allow-list;
// ADMIN_ROLE can only swap among already-allowlisted code hashes.
it("ADMIN_ROLE can swap among allowlisted verifiers; cannot install a new code hash", async () => {
const { admin, attacker, bridge, mockStark } = await loadFixture(
deployBridge
);
const DEFAULT_ADMIN_ROLE = await bridge.DEFAULT_ADMIN_ROLE();
const ADMIN_ROLE = await bridge.ADMIN_ROLE();
// Grant attacker ADMIN_ROLE but NOT DEFAULT_ADMIN_ROLE — simulate
// operations-key compromise.
await bridge.connect(admin).grantRole(ADMIN_ROLE, attacker.address);
expect(await bridge.hasRole(ADMIN_ROLE, attacker.address)).to.be.true;
expect(await bridge.hasRole(DEFAULT_ADMIN_ROLE, attacker.address)).to.be
.false;
// Attacker can flip to strict-PQ using the pre-allowlisted mockStark.
await bridge
.connect(attacker)
.setActiveProfile(PROFILE_STRICT_PQ, await mockStark.getAddress());
// Attacker tries to deploy a verifier with DIFFERENT bytecode (a
// different policyId immutable) and install it. The attacker's
// verifier has policyId = POLICY_MLDSA65_FIPS204 (which IS in the
// PQ allow-list, so the policy check passes), but its bytecode
// hash is NOT in the EXTCODEHASH allow-list — defence-in-depth
// catches it.
//
// The point: even a verifier whose `policyId()` returns an
// allow-listed PQ value cannot be installed without the timelocked
// governance role explicitly extending the EXTCODEHASH allow-list.
const Mock = await hre.ethers.getContractFactory("MockVerifier");
const POLICY_MLDSA65 = ethers.id("ProofPolicyMLDSA65FIPS204");
const rogue = await Mock.deploy(POLICY_MLDSA65);
await rogue.waitForDeployment();
const rogueCode = await hre.ethers.provider.getCode(
await rogue.getAddress()
);
const rogueHash = ethers.keccak256(rogueCode);
// Attacker cannot extend the allow-list — only DEFAULT_ADMIN_ROLE can.
await expect(
bridge.connect(attacker).setAllowedVerifierCodeHash(rogueHash, true)
).to.be.reverted;
// And the install of rogue is refused with EXTCODEHASH check.
await expect(
bridge
.connect(attacker)
.setActiveProfile(PROFILE_STRICT_PQ, await rogue.getAddress())
)
.to.be.revertedWithCustomError(bridge, "VerifierCodeHashNotAllowlisted")
.withArgs(rogueHash);
});
});
describe("Bridge e2e — Pausable", function () {
it("paused bridge refuses burn() and claim()", async () => {
const { admin, user, bridge, token, warp2 } = await loadFixture(
deployBridge
);
// Pre-mint to user.
const [adminS] = await hre.ethers.getSigners();
const BRIDGE_ROLE = await token.BRIDGE_ROLE();
await token.connect(adminS).grantRole(BRIDGE_ROLE, adminS.address);
await token.connect(adminS).bridgeMint(user.address, ethers.parseEther("2"));
await bridge.connect(admin).pause();
const r = {
token: await token.getAddress(),
amount: ethers.parseEther("1"),
destChainId: 96369n,
recipient: user.address,
vault: false,
};
await expect(bridge.connect(user).burn(r)).to.be.revertedWithCustomError(
bridge,
"EnforcedPause"
);
// Unpause restores liveness.
await bridge.connect(admin).unpause();
await expect(bridge.connect(user).burn(r)).to.not.be.reverted;
});
});