// ============================================================================= // SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE // AereBugBountyVault (canonical mainnet 0x253fDCb248649396CBDaD320F81869A570d69cD3, // 5% max single payout per sdk-js/src/addresses.ts). // // Source under test: contracts/security/AereBugBountyVault.sol. Bounty token is // a standard ERC-20 (WAERE on mainnet); here a MockERC20Lending stands in, plus // a MockReentrantBountyToken for the token-callback re-entry probe. // // TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry, `forge` is // not installed; the repo builds through hardhat with OZ 4.9.6 + viaIR). Every // random choice comes from mulberry32(seed); the base seed is printed at suite // start and pinnable via FUZZ_SEED. On any break the failing // (campaign, step, actor, action, values) is embedded in the assertion message // for a minimal repro. // // SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the // unrelated ML-DSA PQC KAT tests): // npx hardhat test test/bugbountyvault-custody-invariant-property.test.js // No deploy to any live node, no target-contract-logic change, one new test file // plus one minimal re-entry mock. // // INVARIANTS FUZZED (exactly the AereBugBountyVault custody brief): // * CUSTODY: pay() is the ONLY value-out path and is gated by onlyTriager. No // non-triager address can move funds (pay / proposeTriager / cancel / accept // all revert for non-triagers). There is no arbitrary-recipient unprivileged // withdraw. // * BALANCE CONSERVATION: token.balanceOf(vault) == cumulative inflow (fund + // direct donations) minus cumulative legitimate payouts, EXACTLY, after every // step. // * CAP: every successful payout amount <= MAX_PAYOUT_BPS/10000 of the balance // at pay time, and therefore <= balance (no over-draw, no underflow). Any // amount above the cap reverts and moves nothing. // * NO OVER-DRAW: cumulative payouts never exceed cumulative inflow (balance // stays >= 0), and payoutNonce == number of successful pays. // * ROTATION: triager rotation honours the 14-day timelock (acceptTriager // reverts before earliest); only the current triager can propose/accept/ // cancel; after acceptance custody transfers cleanly. // * NO RE-ENTRANCY DRAIN: a malicious bounty token that re-enters pay()/fund() // from within transfer()/transferFrom() cannot cause a second payout; the // ReentrancyGuard reverts the nested call. // // No em-dashes anywhere in this file. // ============================================================================= const { expect } = require("chai"); const { ethers, network } = require("hardhat"); const { time } = require("@nomicfoundation/hardhat-network-helpers"); /* ------------------------------- seeded PRNG ------------------------------ */ function mulberry32(a) { return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const BASE_SEED = process.env.FUZZ_SEED ? Number(process.env.FUZZ_SEED) : 0xB0117A11; function rngFor(tag, campaign) { let h = BASE_SEED ^ (campaign * 0x9e3779b1); for (let i = 0; i < tag.length; i++) h = (Math.imul(h, 31) + tag.charCodeAt(i)) | 0; return mulberry32(h >>> 0); } const ri = (rng, min, max) => min + Math.floor(rng() * (max - min + 1)); // inclusive const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)]; const chance = (rng, p) => rng() < p; /* ------------------------------- misc helpers ----------------------------- */ const E = (n) => ethers.parseEther(String(n)); const MAXU = ethers.MaxUint256; const TIMELOCK = 14 * 24 * 60 * 60; // TRIAGER_TIMELOCK = 14 days // Live vault uses 5% (500 bps). Fuzz across several cap regimes incl. extremes. const BPS_SET = [1, 500, 2000, 5000, 9999, 10000]; // Random token amount: dust, small, and large so the integer cap math is stressed. function rndAmount(rng) { const k = rng(); if (k < 0.2) return BigInt(ri(rng, 1, 1_000_000)); // 1 wei .. 1e6 wei if (k < 0.6) return E(ri(rng, 1, 500)); const num = E(ri(rng, 1, 100_000)); return num / BigInt(ri(rng, 1, 997)); } const N = { CAMPAIGNS: Number(process.env.BBV_CAMPAIGNS || 8), STEPS: Number(process.env.BBV_STEPS || 70), }; // ============================================================================= describe("INVARIANT: AereBugBountyVault custody / cap / conservation", function () { this.timeout(0); let signers, tokenF, vaultF, reentF; before(async function () { signers = await ethers.getSigners(); tokenF = await ethers.getContractFactory("MockERC20Lending"); vaultF = await ethers.getContractFactory("AereBugBountyVault"); reentF = await ethers.getContractFactory("MockReentrantBountyToken"); console.log( ` [bbv] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} campaigns` ); }); async function deployStack(bps, triager) { const token = await tokenF.deploy("Wrapped AERE", "WAERE", 18); await token.waitForDeployment(); const vault = await vaultF.deploy(await token.getAddress(), triager.address, bps); await vault.waitForDeployment(); return { token, vault, vaultAddr: await vault.getAddress(), tokenAddr: await token.getAddress() }; } // Full invariant battery. `state` carries cumulative accounting. async function assertInvariants(ctx, token, vault, vaultAddr, funders, state) { // ---- BALANCE CONSERVATION: real ERC-20 balance == inflow - outflow ---- const bal = await token.balanceOf(vaultAddr); const expBal = state.inflow - state.outflow; expect(bal, `[bbv CONSERVE] balance ${bal} != inflow ${state.inflow} - outflow ${state.outflow} ${ctx}`) .to.equal(expBal); expect(await vault.balance(), `[bbv BALANCE-VIEW] balance() disagrees with token ${ctx}`).to.equal(bal); // ---- NO OVER-DRAW: never paid out more than ever came in (balance >= 0) ---- expect(state.outflow, `[bbv OVERDRAW] outflow ${state.outflow} > inflow ${state.inflow} ${ctx}`) .to.be.lte(state.inflow); expect(bal, `[bbv NONNEG] negative-looking balance ${ctx}`).to.be.gte(0n); // ---- payoutNonce == number of successful pays ---- expect(await vault.payoutNonce(), `[bbv NONCE] payoutNonce != successful-pay count ${ctx}`) .to.equal(BigInt(state.payCount)); // ---- CAP VIEW: singlePayoutCap() == floor(bal * bps / 10000) <= bal ---- const cap = await vault.singlePayoutCap(); const expCap = (bal * BigInt(state.bps)) / 10_000n; expect(cap, `[bbv CAP-VIEW] singlePayoutCap != floor(bal*bps/1e4) ${ctx}`).to.equal(expCap); expect(cap, `[bbv CAP-LE-BAL] cap ${cap} > balance ${bal} ${ctx}`).to.be.lte(bal); // ---- CUSTODY: triager is exactly the tracked owner-of-custody ---- expect(await vault.triager(), `[bbv CUSTODY-TRIAGER] triager drifted ${ctx}`).to.equal(state.triager.address); } // Non-triager can NEVER move funds or touch rotation. Checked frequently. async function assertNoUnprivilegedAccess(ctx, vault, token, vaultAddr, outsider, state) { const bal = await token.balanceOf(vaultAddr); // pay: arbitrary-recipient drain attempt by a non-triager. await expect( vault.connect(outsider).pay(ethers.ZeroHash, outsider.address, bal > 0n ? bal : 1n), `[bbv CUSTODY-PAY] non-triager pay did not revert ${ctx}` ).to.be.revertedWithCustomError(vault, "NotTriager"); // rotation surface must also be triager-gated. await expect( vault.connect(outsider).proposeTriager(outsider.address), `[bbv CUSTODY-PROPOSE] non-triager proposeTriager did not revert ${ctx}` ).to.be.revertedWithCustomError(vault, "NotTriager"); await expect( vault.connect(outsider).acceptTriager(), `[bbv CUSTODY-ACCEPT] non-triager acceptTriager did not revert ${ctx}` ).to.be.revertedWithCustomError(vault, "NotTriager"); await expect( vault.connect(outsider).cancelTriagerProposal(), `[bbv CUSTODY-CANCEL] non-triager cancelTriagerProposal did not revert ${ctx}` ).to.be.revertedWithCustomError(vault, "NotTriager"); // Nothing moved. expect(await token.balanceOf(vaultAddr), `[bbv CUSTODY-NOMOVE] outsider changed balance ${ctx}`).to.equal(bal); } // ------------------------------------------------------------------------- it("general interleaving: custody, conservation, cap, over-draw, rotation", async function () { let steps = 0, funds = 0, donations = 0, paysOk = 0, paysCapReverted = 0, proposals = 0, accepts = 0, cancels = 0, custodyChecks = 0; for (let c = 0; c < N.CAMPAIGNS; c++) { const rng = rngFor("gen", c); const bps = pick(rng, BPS_SET); // Rotating cast: signer[1] is the initial triager; a pool of funders and // outsiders; a set of candidate triagers for rotation. let triager = signers[1]; const funders = [signers[2], signers[3], signers[4], signers[5]]; const outsiders = [signers[6], signers[7], signers[8]]; const candidates = [signers[10], signers[11], signers[12]]; const { token, vault, vaultAddr } = await deployStack(bps, triager); const state = { inflow: 0n, outflow: 0n, payCount: 0, bps, triager }; // Fund every funder generously and pre-approve the vault. for (const f of funders) { await (await token.mint(f.address, E("100000000000"))).wait(); await (await token.connect(f).approve(vaultAddr, MAXU)).wait(); } // Donor for direct (non-fund) transfers, exercising accounting robustness. const donor = signers[9]; await (await token.mint(donor.address, E("100000000000"))).wait(); await assertInvariants(`c${c} init bps=${bps}`, token, vault, vaultAddr, funders, state); await assertNoUnprivilegedAccess(`c${c} init`, vault, token, vaultAddr, pick(rng, outsiders), state); custodyChecks++; for (let s = 0; s < N.STEPS; s++) { const action = pick(rng, [ "fund", "fund", "fund", "donate", "payOk", "payOk", "payOverCap", "payZero", "payToZero", "proposeTriager", "acceptTriager", "cancelProposal", "advance", "custodyProbe", ]); const ctx = `c${c} s${s} bps=${bps} action=${action}`; try { if (action === "fund") { const f = pick(rng, funders); const amt = rndAmount(rng); const before = await token.balanceOf(vaultAddr); await (await vault.connect(f).fund(amt)).wait(); const delta = (await token.balanceOf(vaultAddr)) - before; expect(delta, `[bbv FUND-DELTA] fund moved != amount ${ctx}`).to.equal(amt); state.inflow += delta; funds++; } else if (action === "donate") { // Raw ERC-20 transfer straight to the vault (bypasses fund()). The // conservation invariant must still hold: it is tracked as inflow. const amt = rndAmount(rng); const before = await token.balanceOf(vaultAddr); await (await token.connect(donor).transfer(vaultAddr, amt)).wait(); state.inflow += (await token.balanceOf(vaultAddr)) - before; donations++; } else if (action === "payOk") { // A legitimate payout AT or below the cap by the current triager. const cap = await vault.singlePayoutCap(); if (cap === 0n) { steps++; continue; } const amt = chance(rng, 0.4) ? cap : (BigInt(ri(rng, 1, 1_000_000)) * cap) / 1_000_000n; const pay = amt === 0n ? 1n : amt; if (pay > cap) { steps++; continue; } const reporter = pick(rng, outsiders); // arbitrary reporter chosen by triager (allowed) const balBefore = await token.balanceOf(vaultAddr); const rptBefore = await token.balanceOf(reporter.address); const nonceBefore = await vault.payoutNonce(); await (await vault.connect(state.triager).pay( ethers.id(`finding-${c}-${s}`), reporter.address, pay )).wait(); const moved = balBefore - (await token.balanceOf(vaultAddr)); // CAP holds for the executed payout, and exactly `pay` left the vault to the reporter. expect(moved, `[bbv PAY-EXACT] moved ${moved} != requested ${pay} ${ctx}`).to.equal(pay); expect(moved, `[bbv PAY-CAP] payout ${moved} > cap ${cap} ${ctx}`).to.be.lte(cap); expect((await token.balanceOf(reporter.address)) - rptBefore, `[bbv PAY-RECIPIENT] reporter did not receive exactly the payout ${ctx}`).to.equal(pay); expect(await vault.payoutNonce(), `[bbv PAY-NONCE-INC] nonce did not ++ ${ctx}`) .to.equal(nonceBefore + 1n); state.outflow += moved; state.payCount++; paysOk++; } else if (action === "payOverCap") { // Anything strictly above the cap must revert and move nothing. const cap = await vault.singlePayoutCap(); const amt = cap + BigInt(ri(rng, 1, 1_000_000)); const reporter = pick(rng, outsiders); const balBefore = await token.balanceOf(vaultAddr); await expect( vault.connect(state.triager).pay(ethers.id(`over-${c}-${s}`), reporter.address, amt), `[bbv PAY-OVERCAP] over-cap pay did not revert ${ctx} amt=${amt} cap=${cap}` ).to.be.revertedWithCustomError(vault, "AmountExceedsCap"); expect(await token.balanceOf(vaultAddr), `[bbv PAY-OVERCAP-NOMOVE] ${ctx}`).to.equal(balBefore); paysCapReverted++; } else if (action === "payZero") { await expect( vault.connect(state.triager).pay(ethers.ZeroHash, pick(rng, outsiders).address, 0), `[bbv PAY-ZERO] zero-amount pay did not revert ${ctx}` ).to.be.revertedWithCustomError(vault, "ZeroAmount"); } else if (action === "payToZero") { const cap = await vault.singlePayoutCap(); const amt = cap > 0n ? cap : 1n; await expect( vault.connect(state.triager).pay(ethers.ZeroHash, ethers.ZeroAddress, amt), `[bbv PAY-ZEROADDR] payout to zero address did not revert ${ctx}` ).to.be.revertedWithCustomError(vault, "ZeroAddress"); } else if (action === "proposeTriager") { const cand = pick(rng, candidates); await (await vault.connect(state.triager).proposeTriager(cand.address)).wait(); state.pending = cand; state.earliest = BigInt((await ethers.provider.getBlock("latest")).timestamp) + BigInt(TIMELOCK); proposals++; } else if (action === "acceptTriager") { if (!state.pending) { await expect( vault.connect(state.triager).acceptTriager(), `[bbv ACCEPT-NOPENDING] accept with no proposal did not revert ${ctx}` ).to.be.revertedWithCustomError(vault, "NoPendingProposal"); steps++; continue; } const now = BigInt((await ethers.provider.getBlock("latest")).timestamp); if (now < state.earliest) { // TIMELOCK: acceptance before the 14-day window must revert. await expect( vault.connect(state.triager).acceptTriager(), `[bbv TIMELOCK] early accept did not revert ${ctx} now=${now} earliest=${state.earliest}` ).to.be.revertedWithCustomError(vault, "TimelockNotElapsed"); } else { await (await vault.connect(state.triager).acceptTriager()).wait(); state.triager = state.pending; state.pending = null; state.earliest = 0n; accepts++; } } else if (action === "cancelProposal") { if (!state.pending) { await expect( vault.connect(state.triager).cancelTriagerProposal(), `[bbv CANCEL-NOPENDING] cancel with no proposal did not revert ${ctx}` ).to.be.revertedWithCustomError(vault, "NoPendingProposal"); } else { await (await vault.connect(state.triager).cancelTriagerProposal()).wait(); state.pending = null; state.earliest = 0n; cancels++; } } else if (action === "advance") { // Sometimes long enough to cross the 14-day timelock. const secs = chance(rng, 0.4) ? ri(rng, TIMELOCK, TIMELOCK + 3 * 24 * 60 * 60) : ri(rng, 1, 5 * 24 * 60 * 60); await time.increase(secs); await network.provider.send("evm_mine"); } else if (action === "custodyProbe") { await assertNoUnprivilegedAccess(ctx, vault, token, vaultAddr, pick(rng, outsiders), state); custodyChecks++; } } catch (e) { if (/bbv [A-Z]/.test(String(e.message))) throw e; // never swallow an invariant break // Legal reverts on edge amounts are fine; battery still runs on unchanged state. } await assertInvariants(ctx, token, vault, vaultAddr, funders, state); steps++; } // Finale custody probe: no outsider can extract the remaining balance. await assertNoUnprivilegedAccess(`c${c} finale`, vault, token, vaultAddr, pick(rng, outsiders), state); custodyChecks++; await assertInvariants(`c${c} finale`, token, vault, vaultAddr, funders, state); } console.log( ` [bbv gen] steps=${steps} fund=${funds} donate=${donations} payOk=${paysOk} ` + `overCapRev=${paysCapReverted} propose=${proposals} accept=${accepts} cancel=${cancels} custodyProbes=${custodyChecks}` ); expect(steps).to.be.greaterThan(400); expect(funds + donations).to.be.greaterThan(60); expect(paysOk).to.be.greaterThan(20); expect(paysCapReverted).to.be.greaterThan(20); expect(proposals).to.be.greaterThan(10); // rotation surface genuinely exercised expect(accepts + cancels).to.be.greaterThan(0); expect(custodyChecks).to.be.greaterThan(40); }); // ------------------------------------------------------------------------- // ROTATION / TIMELOCK, deterministic: acceptance is gated by the 14-day // window, only the current triager can drive it, and custody transfers cleanly. it("triager rotation honours the 14-day timelock and transfers custody cleanly", async function () { const triager = signers[1]; const nextTriager = signers[10]; const outsider = signers[6]; const reporter = signers[7]; const { token, vault, vaultAddr } = await deployStack(500, triager); await (await token.mint(triager.address, E(1000))).wait(); await (await token.connect(triager).approve(vaultAddr, MAXU)).wait(); await (await vault.connect(triager).fund(E(1000))).wait(); // Only the current triager may propose. await expect(vault.connect(outsider).proposeTriager(nextTriager.address)) .to.be.revertedWithCustomError(vault, "NotTriager"); await (await vault.connect(triager).proposeTriager(nextTriager.address)).wait(); expect(await vault.pendingTriager()).to.equal(nextTriager.address); // Accept BEFORE the window elapses must revert (13 days < 14). await time.increase(13 * 24 * 60 * 60); await network.provider.send("evm_mine"); await expect(vault.connect(triager).acceptTriager()) .to.be.revertedWithCustomError(vault, "TimelockNotElapsed"); // The proposed (not-yet-accepted) triager also cannot accept early or ever // drive rotation while not the current triager. await expect(vault.connect(nextTriager).acceptTriager()) .to.be.revertedWithCustomError(vault, "NotTriager"); // Cross the 14-day boundary; now acceptance succeeds and custody moves. await time.increase(2 * 24 * 60 * 60); await network.provider.send("evm_mine"); await (await vault.connect(triager).acceptTriager()).wait(); expect(await vault.triager()).to.equal(nextTriager.address); expect(await vault.pendingTriager()).to.equal(ethers.ZeroAddress); // Old triager has lost custody: it can no longer pay. await expect(vault.connect(triager).pay(ethers.id("x"), reporter.address, 1)) .to.be.revertedWithCustomError(vault, "NotTriager"); // New triager can pay within cap. const cap = await vault.singlePayoutCap(); const before = await token.balanceOf(vaultAddr); await (await vault.connect(nextTriager).pay(ethers.id("y"), reporter.address, cap)).wait(); expect(before - (await token.balanceOf(vaultAddr))).to.equal(cap); expect(await vault.payoutNonce()).to.equal(1n); console.log(` [bbv rotation] timelock enforced; custody transferred; old key locked out`); }); // ------------------------------------------------------------------------- // Cumulative-drain check: even a fully-cooperating (or compromised) triager // can only ever move out what came in. Over MANY max-cap payouts the vault // trends toward dust but conservation + non-negativity always hold, and the // per-call 5% cap means it is never emptied in a single call. it("drain resistance: repeated max-cap payouts never over-draw, never single-call empty", async function () { const rng = rngFor("drain", 0); const triager = signers[1]; const reporter = signers[6]; const { token, vault, vaultAddr } = await deployStack(500, triager); // live 5% await (await token.mint(triager.address, E("1000000"))).wait(); await (await token.connect(triager).approve(vaultAddr, MAXU)).wait(); const state = { inflow: 0n, outflow: 0n, payCount: 0, bps: 500, triager }; await (await vault.connect(triager).fund(E(1000))).wait(); state.inflow += E(1000); let maxCapPays = 0, singleCallEmptied = 0; for (let i = 0; i < 200; i++) { const balBefore = await token.balanceOf(vaultAddr); const cap = await vault.singlePayoutCap(); if (cap === 0n) break; // dust: 5% of a tiny balance floors to 0 await (await vault.connect(triager).pay(ethers.id(`drain-${i}`), reporter.address, cap)).wait(); const moved = balBefore - (await token.balanceOf(vaultAddr)); expect(moved, `[bbv DRAIN-CAP] moved ${moved} exceeded cap ${cap} at i=${i}`).to.be.lte(cap); // A single max payout is at most 5% -> it can never zero a non-dust balance. if (balBefore > 20n && (await token.balanceOf(vaultAddr)) === 0n) singleCallEmptied++; state.outflow += moved; state.payCount++; maxCapPays++; await assertInvariants(`drain i${i}`, token, vault, vaultAddr, [], state); } expect(state.outflow, "[bbv DRAIN-CONSERVE] paid out more than funded").to.be.lte(state.inflow); expect(singleCallEmptied, "[bbv DRAIN-SINGLECALL] a single 5% payout emptied a non-dust vault").to.equal(0); expect(maxCapPays).to.be.greaterThan(50); console.log(` [bbv drain] maxCapPays=${maxCapPays} finalBal=${await token.balanceOf(vaultAddr)} outflow=${state.outflow}`); }); // ------------------------------------------------------------------------- // RE-ENTRANCY: a malicious bounty token whose transfer() re-enters pay() // cannot cause a second payout. The ReentrancyGuard reverts the nested call; // exactly one payout of the requested amount leaves the vault. it("no re-entrancy drain: token-callback re-entry into pay() is blocked by the guard", async function () { const funder = signers[2]; const reporter = signers[6]; const rtoken = await reentF.deploy(); await rtoken.waitForDeployment(); // The malicious TOKEN is itself the triager, so its nested re-entry passes // onlyTriager and genuinely reaches the ReentrancyGuard (onlyTriager runs // before nonReentrant, so any other caller would revert on onlyTriager first). const vault = await vaultF.deploy(await rtoken.getAddress(), await rtoken.getAddress(), 5000); // 50% cap await vault.waitForDeployment(); const vaultAddr = await vault.getAddress(); await (await rtoken.setVault(vaultAddr)).wait(); // Fund the vault to 100 tokens via the permissionless fund() path (unarmed). await (await rtoken.mint(funder.address, E(1000))).wait(); await (await rtoken.connect(funder).approve(vaultAddr, MAXU)).wait(); await (await vault.connect(funder).fund(E(100))).wait(); expect(await rtoken.balanceOf(vaultAddr)).to.equal(E(100)); // Arm the token to re-enter pay() from inside transfer(); drive the OUTER // pay() through the token (the triager) so the nested pay() is authorised. await (await rtoken.arm(true, false)).wait(); const bal0 = await rtoken.balanceOf(vaultAddr); const rpt0 = await rtoken.balanceOf(reporter.address); await (await rtoken.triggerPay(ethers.id("legit"), reporter.address, E(10))).wait(); // The nested pay() must have been attempted AND reverted by the guard. expect(await rtoken.reentryAttempted(), "[bbv REENTRY] token callback never fired").to.equal(true); expect(await rtoken.reentryReverted(), "[bbv REENTRY] nested pay() was NOT reverted by the guard").to.equal(true); expect(await rtoken.lastRevert(), "[bbv REENTRY] wrong revert reason").to.equal("ReentrancyGuard: reentrant call"); // Exactly ONE payout of 10 happened (no double-spend via re-entry). expect(bal0 - (await rtoken.balanceOf(vaultAddr)), "[bbv REENTRY] more than one payout left the vault").to.equal(E(10)); expect((await rtoken.balanceOf(reporter.address)) - rpt0, "[bbv REENTRY] reporter got != one payout").to.equal(E(10)); expect(await vault.payoutNonce(), "[bbv REENTRY] nonce shows more than one pay").to.equal(1n); // Same shared guard also blocks a nested fund() re-entered from within an // outer pay()'s transfer() (nonReentrant reverts before transferFrom runs). await (await rtoken.arm(true, true)).wait(); const balF0 = await rtoken.balanceOf(vaultAddr); await (await rtoken.triggerPay(ethers.id("legit2"), reporter.address, E(10))).wait(); expect(await rtoken.reentryReverted(), "[bbv REENTRY-FUND] nested fund() not reverted").to.equal(true); expect(await rtoken.lastRevert(), "[bbv REENTRY-FUND] wrong revert reason").to.equal("ReentrancyGuard: reentrant call"); // Only the single legitimate 10-token payout left; the nested fund() added nothing. expect(balF0 - (await rtoken.balanceOf(vaultAddr)), "[bbv REENTRY-FUND] balance delta != one payout").to.equal(E(10)); expect(await vault.payoutNonce(), "[bbv REENTRY-FUND] extra pay slipped in").to.equal(2n); console.log(` [bbv reentry] pay-guard + fund-guard both held; single payout only`); }); });