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.
496 lines
26 KiB
JavaScript
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`);
|
|
});
|
|
});
|