aere-contracts/test/bugbountyvault-custody-invariant-property.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

496 lines
26 KiB
JavaScript

// =============================================================================
// 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`);
});
});