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

491 lines
19 KiB
TypeScript

import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { expect } from "chai";
import hre, { ethers } from "hardhat";
import { execSync } from "child_process";
import path from "path";
import { promises as fs } from "fs";
/**
* BridgeV2 — strict-PQ profile gate wiring + P3Q proof slot.
*
* Three wirings under test:
* 1. `claim` calls `StrictPQProfileGate.refuseUnderStrictPQ` when an
* `activeVerifier` is configured.
* 2. `claimWithProof` calls the gate then the P3Q verifier.
* 3. The default `P3QVerifierStub` always rejects (audit gate).
*
* The verifier verifies; the gate decides admissibility. Decomplected.
*/
const PROFILE_CLASSICAL = ethers.id("PROFILE_CLASSICAL_COMPAT");
const PROFILE_STRICT_PQ = ethers.id("PROFILE_QUASAR_STRICT_PQ");
const POLICY_GROTH16_BN254 = ethers.id("ProofPolicyGroth16BN254");
const POLICY_STARK_FRI_SHA3 = ethers.id("ProofPolicySTARKFRISHA3PQ");
async function deployFixture() {
const [admin, user] = await hre.ethers.getSigners();
// Stub Warp 2.0 verifier — accepts envelopes registered with setExpected.
const Warp2Stub = await hre.ethers.getContractFactory("Warp2VerifierStub");
const warp2 = await Warp2Stub.deploy(admin.address, 0x01); // bit0=requireBeam
await warp2.waitForDeployment();
const BridgeV2 = await hre.ethers.getContractFactory("BridgeV2");
const bridge = await BridgeV2.deploy(
admin.address,
await warp2.getAddress(),
admin.address, // feeRecipient
0n, // feeRate
0x01, // minPolicyFlags
96369, // networkId — this chain's ZAP source network id
"0x0000000000000000000000000000000000000000000000000000000000017871" // sourceChainId (32-byte)
);
await bridge.waitForDeployment();
// Token
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);
// P3Q stub — always rejects.
const P3QStub = await hre.ethers.getContractFactory("P3QVerifierStub");
const p3qStub = await P3QStub.deploy();
await p3qStub.waitForDeployment();
// Mock IVerifier for the ZK-claim slot exercises.
const Mock = await hre.ethers.getContractFactory("MockVerifier");
const mockGroth16 = await Mock.deploy(POLICY_GROTH16_BN254);
await mockGroth16.waitForDeployment();
const mockStark = await Mock.deploy(POLICY_STARK_FRI_SHA3);
await mockStark.waitForDeployment();
// Build a valid envelope + claim.
const network = await hre.ethers.provider.getNetwork();
const destChainId = Number(network.chainId);
const sourceChainId = 96369;
const amount = ethers.parseEther("1");
const nonce = 0n;
const c = {
sourceChainId: BigInt(sourceChainId),
destChainId: BigInt(destChainId),
token: await token.getAddress(),
amount,
recipient: user.address,
vault: false,
nonce,
};
const messageHash = 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,
]
)
);
const env = {
messageHash,
sourceChainId: c.sourceChainId,
destChainId: c.destChainId,
blsAggregate: "0x" + "ab".repeat(50), // non-empty
mldsaCertSet: "0x",
pulseSignature: "0x",
epoch: 1n,
};
await warp2.connect(admin).setExpected(messageHash, true);
// Section C (Red round 5): the strict-PQ slots are now also gated by
// an EXTCODEHASH allow-list controlled by DEFAULT_ADMIN_ROLE. The
// existing strict-PQ happy-path tests still need to install
// `mockStark` and `p3qStub`, so pre-allow their code hashes from the
// deployment fixture. `admin` here holds both DEFAULT_ADMIN_ROLE and
// ADMIN_ROLE (test config), mirroring a freshly-deployed bridge
// where the timelock has not yet rotated keys.
const ext = async (addr: string): Promise<string> =>
hre.ethers.provider.getCode(addr).then((code) => hre.ethers.keccak256(code));
await bridge.connect(admin).setAllowedVerifierCodeHash(
await ext(await mockStark.getAddress()),
true
);
await bridge.connect(admin).setAllowedVerifierCodeHash(
await ext(await p3qStub.getAddress()),
true
);
return {
admin,
user,
bridge,
token,
warp2,
p3qStub,
mockGroth16,
mockStark,
env,
c,
ext,
};
}
describe("BridgeV2 — strict-PQ profile gate wiring", function () {
it("(a) claim succeeds under classical-compat with any verifier in slot", async () => {
const { admin, bridge, mockGroth16, env, c } = await loadFixture(deployFixture);
// Default profile is classical-compat. Plug a Groth16 verifier into the
// ZK-claim slot — the gate must NOT fire under classical-compat.
await bridge.connect(admin).setActiveProfile(
PROFILE_CLASSICAL,
await mockGroth16.getAddress()
);
await expect(bridge.claim(env, c)).to.emit(bridge, "BridgeMintedV2");
});
it("(b) setActiveProfile refuses non-PQ verifier at install time under strict-PQ", async () => {
const { admin, bridge, mockGroth16 } = await loadFixture(deployFixture);
// Install-time refusal: a Groth16 verifier cannot be installed under
// strict-PQ at all. The previous behaviour deferred the check to
// `claim`, which let `address(0)`-no-op state slip in between install
// and claim. Allow-list gating closes that window.
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("(c) claim succeeds under strict-PQ when verifier policyId = STARK_FRI_SHA3", async () => {
const { admin, bridge, mockStark, env, c } = await loadFixture(deployFixture);
await bridge.connect(admin).setActiveProfile(
PROFILE_STRICT_PQ,
await mockStark.getAddress()
);
await expect(bridge.claim(env, c)).to.emit(bridge, "BridgeMintedV2");
});
it("setActiveProfile rejects unknown profile id", async () => {
const { admin, bridge, mockStark } = await loadFixture(deployFixture);
const bogus = ethers.id("PROFILE_BOGUS");
await expect(
bridge.connect(admin).setActiveProfile(bogus, await mockStark.getAddress())
)
.to.be.revertedWithCustomError(bridge, "UnknownProfile")
.withArgs(bogus);
});
});
describe("BridgeV2 — claimWithProof / P3Q audit gate", function () {
it("reverts when no P3Q verifier is configured", async () => {
const { bridge, env, c } = await loadFixture(deployFixture);
await expect(bridge.claimWithProof(env, c, "0x"))
.to.be.revertedWithCustomError(bridge, "P3QVerifierUnset");
});
it("(d) claimWithProof reverts because P3QVerifierStub always rejects", async () => {
const { admin, bridge, p3qStub, env, c } = await loadFixture(deployFixture);
await bridge.connect(admin).setP3QVerifier(await p3qStub.getAddress());
// The stub also emits AuditGatedReject before returning false. The
// bridge then reverts with P3QProofRejected.
await expect(bridge.claimWithProof(env, c, "0x"))
.to.be.revertedWithCustomError(bridge, "P3QProofRejected");
});
it("(e) setP3QVerifier refuses non-PQ verifier at install time", async () => {
const { admin, bridge } = await loadFixture(deployFixture);
// Defence in depth: setP3QVerifier itself enforces the STARK/FRI
// policy allow-list. A NonPQP3QStub returning POLICY_GROTH16_BN254
// cannot even be installed in the P3Q slot — the previous behaviour
// accepted the install and only refused at claim time, which let an
// admin (or compromised key) point the slot at a non-PQ backend that
// could mint without a real PQ proof check.
const NonPQ = await hre.ethers.getContractFactory("NonPQP3QStub");
const nonpq = await NonPQ.deploy();
await nonpq.waitForDeployment();
await expect(
bridge.connect(admin).setP3QVerifier(await nonpq.getAddress())
)
.to.be.revertedWithCustomError(bridge, "VerifierRefusedUnderStrictPQ")
.withArgs(PROFILE_STRICT_PQ, POLICY_GROTH16_BN254);
});
it("(f) setActiveProfile refuses address(0) verifier under strict-PQ", async () => {
const { admin, bridge } = await loadFixture(deployFixture);
await expect(
bridge.connect(admin).setActiveProfile(
PROFILE_STRICT_PQ,
ethers.ZeroAddress
)
)
.to.be.revertedWithCustomError(bridge, "VerifierUnsetUnderStrictPQ")
.withArgs(PROFILE_STRICT_PQ);
});
it("(g) setActiveProfile refuses spoofed-policy verifier under strict-PQ", async () => {
const { admin, bridge } = await loadFixture(deployFixture);
// Spoofed policy: a verifier that returns a bytes32 not in the
// deny-list AND not in the allow-list. The OLD deny-list gate would
// pass it; the NEW allow-list gate refuses it.
const Mock = await hre.ethers.getContractFactory("MockVerifier");
const spoofedPolicy = ethers.id("ProofPolicyBogusUnknown");
const spoofed = await Mock.deploy(spoofedPolicy);
await spoofed.waitForDeployment();
await expect(
bridge.connect(admin).setActiveProfile(
PROFILE_STRICT_PQ,
await spoofed.getAddress()
)
)
.to.be.revertedWithCustomError(bridge, "VerifierRefusedUnderStrictPQ")
.withArgs(PROFILE_STRICT_PQ, spoofedPolicy);
});
it("(h) setP3QVerifier refuses address(0)? — no: address(0) clears the slot", async () => {
const { admin, bridge, env, c } = await loadFixture(deployFixture);
// address(0) for the P3Q slot is permitted and explicitly clears the
// slot — subsequent claimWithProof calls revert with P3QVerifierUnset.
// This is the fail-CLOSED semantic for that path (no proof verifier
// installed ⇒ no claim possible), which is the opposite of the
// strict-PQ profile slot where address(0) is a silent no-op.
await bridge.connect(admin).setP3QVerifier(ethers.ZeroAddress);
await expect(bridge.claimWithProof(env, c, "0x"))
.to.be.revertedWithCustomError(bridge, "P3QVerifierUnset");
});
});
describe("BridgeV2 — EXTCODEHASH allow-list (Section C, Red round 5)", function () {
// Section C residual: a spoofed verifier whose `policyId()` returns
// POLICY_STARK_FRI_SHA3_PQ but whose `verify()` returns true for any
// input would pass the policy-id check alone. The code-hash gate
// refuses to install ANY verifier whose deployed bytecode is not
// pre-allowed by DEFAULT_ADMIN_ROLE.
it("(i) setActiveProfile refuses STARK-policy verifier with non-allowlisted code", async () => {
const { admin, bridge } = await loadFixture(deployFixture);
// Build a fresh MockVerifier with POLICY_STARK_FRI_SHA3 — its
// immutable field embeds the policy id into the runtime bytecode.
// Same policy ID, but the test fixture did NOT pre-allow this
// exact deployment's code (we deploy a *separate* mock here whose
// code hash matches the fixture's `mockStark` ONLY because the
// immutable bakes in identically). To assert "not-allowlisted",
// we revoke the allow first.
const Mock = await hre.ethers.getContractFactory("MockVerifier");
const m = await Mock.deploy(POLICY_STARK_FRI_SHA3);
await m.waitForDeployment();
// Compute the code hash and revoke any pre-allow.
const code = await hre.ethers.provider.getCode(await m.getAddress());
const ch = hre.ethers.keccak256(code);
await bridge.connect(admin).setAllowedVerifierCodeHash(ch, false);
await expect(
bridge.connect(admin).setActiveProfile(
PROFILE_STRICT_PQ,
await m.getAddress()
)
)
.to.be.revertedWithCustomError(bridge, "VerifierCodeHashNotAllowlisted")
.withArgs(ch);
});
it("(j) setActiveProfile accepts STARK-policy verifier with allowlisted code", async () => {
const { admin, bridge, mockStark } = await loadFixture(deployFixture);
// Fixture pre-allowed mockStark's code hash — install must succeed.
await expect(
bridge.connect(admin).setActiveProfile(
PROFILE_STRICT_PQ,
await mockStark.getAddress()
)
).to.emit(bridge, "ProfileUpdated");
});
it("(k) setP3QVerifier refuses STARK-policy verifier with non-allowlisted code", async () => {
const { admin, bridge, p3qStub } = await loadFixture(deployFixture);
const code = await hre.ethers.provider.getCode(await p3qStub.getAddress());
const ch = hre.ethers.keccak256(code);
await bridge.connect(admin).setAllowedVerifierCodeHash(ch, false);
await expect(
bridge.connect(admin).setP3QVerifier(await p3qStub.getAddress())
)
.to.be.revertedWithCustomError(bridge, "VerifierCodeHashNotAllowlisted")
.withArgs(ch);
});
it("(l) DEFAULT_ADMIN_ROLE can extend the allow-list", async () => {
const { admin, bridge } = await loadFixture(deployFixture);
const Mock = await hre.ethers.getContractFactory("MockVerifier");
const fresh = await Mock.deploy(POLICY_STARK_FRI_SHA3);
await fresh.waitForDeployment();
const code = await hre.ethers.provider.getCode(await fresh.getAddress());
const ch = hre.ethers.keccak256(code);
// Revoke (in case fixture's pre-allow collided — immutable bakes
// policy id into bytecode, so deterministic). Then re-allow and
// assert install succeeds.
await bridge.connect(admin).setAllowedVerifierCodeHash(ch, false);
await expect(
bridge.connect(admin).setActiveProfile(
PROFILE_STRICT_PQ,
await fresh.getAddress()
)
).to.be.revertedWithCustomError(bridge, "VerifierCodeHashNotAllowlisted");
// DEFAULT_ADMIN_ROLE adds the code hash.
await expect(
bridge.connect(admin).setAllowedVerifierCodeHash(ch, true)
)
.to.emit(bridge, "VerifierCodeHashAllowed")
.withArgs(ch, true);
// Now install succeeds.
await expect(
bridge.connect(admin).setActiveProfile(
PROFILE_STRICT_PQ,
await fresh.getAddress()
)
).to.emit(bridge, "ProfileUpdated");
});
it("(m) non-DEFAULT_ADMIN account cannot extend the allow-list", async () => {
const { user, bridge } = await loadFixture(deployFixture);
const ch = ethers.id("any-bytecode-hash");
// `user` is the second signer in the fixture — does NOT hold
// DEFAULT_ADMIN_ROLE. OZ AccessControl reverts with
// AccessControlUnauthorizedAccount on v5.
await expect(
bridge.connect(user).setAllowedVerifierCodeHash(ch, true)
).to.be.reverted;
});
it("(n) classical-compat profile install is NOT gated by the allow-list", async () => {
const { admin, bridge, mockGroth16 } = await loadFixture(deployFixture);
// mockGroth16's code hash is NOT pre-allowed. Under classical-compat
// the EXTCODEHASH gate must NOT fire — the test asserts the profile
// gate stays scoped to strict-PQ only (decomplected).
await expect(
bridge.connect(admin).setActiveProfile(
PROFILE_CLASSICAL,
await mockGroth16.getAddress()
)
).to.emit(bridge, "ProfileUpdated");
});
});
describe("BridgeV2 — production artifact set snapshot", function () {
// Red HIGH #8: a spoofed `MockVerifier(POLICY_STARK_FRI_SHA3_PQ)` that
// accepts any input MUST NOT ship in production deployment artifacts.
// The fix moved test-only Solidity out of `paths.sources` into
// `test-contracts/` and gated re-inclusion behind
// `HARDHAT_INCLUDE_TEST_CONTRACTS` / `npm_lifecycle_event === "test"`.
//
// This test exercises a clean production compile in an isolated
// working directory (so the main artifact tree is not clobbered) and
// asserts the set of contract names that result. Mock/Stub/Harness
// contracts must NOT appear in the production artifact set.
it("clean production compile excludes MockVerifier, NonPQP3QStub, harnesses", async function () {
this.timeout(180_000);
const cwd = path.resolve(__dirname, "..");
const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
// Config MUST live inside the project root (cwd) — Hardhat rejects
// sources outside the project, and "project" is rooted at the
// directory containing the config. Sandbox dirs (artifacts/cache)
// still live in a stamped subdir to avoid clobbering main outputs.
const sandboxRoot = path.join(cwd, ".artifact-snapshot");
const sandbox = path.join(sandboxRoot, stamp);
const artifactsDir = path.join(sandbox, "artifacts");
const cacheDir = path.join(sandbox, "cache");
await fs.mkdir(sandbox, { recursive: true });
const sandboxConfig = path.join(cwd, `.hardhat.snapshot.${stamp}.config.cjs`);
const cfgBody =
"module.exports = {\n" +
" solidity: { compilers: [\n" +
" { version: '0.8.20', settings: { optimizer: { enabled: true, runs: 200 } } },\n" +
" { version: '0.8.24', settings: { optimizer: { enabled: true, runs: 200 }, evmVersion: 'cancun' } }\n" +
" ] },\n" +
" paths: {\n" +
" sources: './contracts',\n" +
` cache: ${JSON.stringify(cacheDir)},\n` +
` artifacts: ${JSON.stringify(artifactsDir)}\n` +
" }\n" +
"};\n";
await fs.writeFile(sandboxConfig, cfgBody);
// Compile in a child process; clear env that would re-include test
// contracts so the snapshot reflects the production codepath.
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.HARDHAT_INCLUDE_TEST_CONTRACTS;
delete env.npm_lifecycle_event;
try {
execSync(`npx hardhat --config ${sandboxConfig} compile --force`, {
cwd,
env,
stdio: "pipe",
});
// Walk the production artifact tree and collect contract names.
async function collectNames(dir: string, acc: string[]): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === "build-info") continue;
await collectNames(p, acc);
} else if (e.name.endsWith(".json") && !e.name.endsWith(".dbg.json")) {
acc.push(e.name.replace(/\.json$/, ""));
}
}
}
const names: string[] = [];
await collectNames(artifactsDir, names);
// Hard refusals: anything test-only must not appear.
const banned = ["MockVerifier", "NonPQP3QStub", "StrictPQProfileGateHarness"];
for (const b of banned) {
expect(names, `test-only contract '${b}' leaked into production artifacts`).to.not.include(b);
}
// Sanity: real production contracts MUST still be present.
const required = ["BridgeV2", "Bridge", "ERC20B", "Warp2VerifierStub"];
for (const r of required) {
expect(names, `production contract '${r}' missing from artifact set`).to.include(r);
}
} finally {
await fs.rm(sandbox, { recursive: true, force: true });
await fs.rm(sandboxConfig, { force: true });
}
});
});