aere-contracts/test/saere-sink-fuzz.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

368 lines
16 KiB
JavaScript

// Property-based fuzz test for the sAERE + AereSink drip mechanism.
//
// Drives a deterministic pseudo-random walk of 800 user-level actions against
// the live ERC-4626 + 3-bucket-router stack:
//
// action menu:
// 0 deposit(actor, amount) — pull WAERE into vault, mint shares
// 1 withdraw(actor, shares) — burn shares, return WAERE
// 2 sync() — permissionless drip refresh
// 3 AereSink.flush(WAERE, amount) — protocol revenue arrives, routes
// STAKER_YIELD_BPS into sAERE then pings
// 4 time-advance(seconds) — jump anywhere from 1s to ~2 weeks
//
// Invariants verified after EVERY action:
//
// I-1 totalAssets() <= asset.balanceOf(sAERE)
// I-2 totalAssets() + _undistributedNow() == asset.balanceOf(sAERE)
// (exact equality — no epsilon needed; both come from the same balance)
// I-3 pricePerShare (convertToAssets(1e18)) is monotonically non-decreasing
// across the whole walk
// I-4 sync() inside the same block is idempotent — calling it again does
// not change rewardRate / periodFinish / lastObservedBalance
// I-5 lastObservedBalance == asset.balanceOf(sAERE) after every state-
// changing op (deposit / withdraw / sync / sink.flush)
// I-6 periodFinish never DECREASES from one observation to the next as a
// consequence of a fresh reward arrival (it only ever resets forward
// to now + DRIP_DURATION; never backward)
//
// Run: npx hardhat test test/saere-sink-fuzz.test.js
//
// Deterministic: a fixed PRNG seed drives the action stream so failures
// reproduce exactly.
const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const ONE = 10n ** 18n;
const DEAD_SEED = 1000n;
const DEAD_ADDR = "0x000000000000000000000000000000000000dEaD";
const DRIP_DUR = 7n * 24n * 60n * 60n; // 7 days in seconds
const NUM_ACTIONS = 800;
const PRNG_SEED = 0xA3E2E51An; // deterministic — change to re-fuzz
// --------------------------------------------------------------------------
// Tiny deterministic PRNG (xorshift64*). Stable across Node versions.
// --------------------------------------------------------------------------
function makePrng(seed) {
let s = BigInt.asUintN(64, BigInt(seed));
if (s === 0n) s = 1n;
const MASK = (1n << 64n) - 1n;
return {
next64() {
s ^= (s << 13n) & MASK;
s ^= (s >> 7n) & MASK;
s ^= (s << 17n) & MASK;
s &= MASK;
return s;
},
nextInt(maxExclusive) {
return Number(this.next64() % BigInt(maxExclusive));
},
// returns a bigint in [1n, hi]
nextBig(hi) {
const r = this.next64() % BigInt(hi);
return r + 1n;
},
};
}
// --------------------------------------------------------------------------
// Stack deploy. We use the AERE-side of the sink (token == AERE) so no DEX
// router is touched. The sink's BURN bucket is a plain EOA (acts as a sink
// of AERE — no callback). STAKER bucket is the live sAERE vault.
// --------------------------------------------------------------------------
async function deployFuzzStack() {
const signers = await ethers.getSigners();
const [deployer, burnSinkEoa, a1, a2, a3, a4, feeder] = signers;
const actors = [a1, a2, a3, a4];
const WAERE_F = await ethers.getContractFactory("WAERE");
const waere = await WAERE_F.deploy();
await waere.waitForDeployment();
const waereAddr = await waere.getAddress();
// Pre-fund deployer dead-seed approval.
const nonce = await ethers.provider.getTransactionCount(deployer.address);
const futureSaere = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 2 });
await waere.connect(deployer).deposit({ value: DEAD_SEED });
await waere.connect(deployer).approve(futureSaere, DEAD_SEED);
const sAERE_F = await ethers.getContractFactory("sAERE");
const saere = await sAERE_F.deploy(waereAddr);
await saere.waitForDeployment();
const saereAddr = await saere.getAddress();
// Sink — bps 1500 / 4000 / 4500. Router = a dummy EOA (never called on AERE path).
const Sink_F = await ethers.getContractFactory("AereSink");
const sink = await Sink_F.deploy(
waereAddr,
burnSinkEoa.address,
saereAddr,
burnSinkEoa.address, // unused router on AERE path
1500, 4000, 4500,
200,
ethers.ZeroAddress
);
await sink.waitForDeployment();
const sinkAddr = await sink.getAddress();
// Mint WAERE to actors + feeder so they can play.
for (const a of actors) {
await waere.connect(a).deposit({ value: 1_000n * ONE });
await waere.connect(a).approve(saereAddr, ethers.MaxUint256);
}
await waere.connect(feeder).deposit({ value: 5_000n * ONE });
await waere.connect(feeder).approve(sinkAddr, ethers.MaxUint256);
return { waere, saere, sink, actors, feeder, deployer, waereAddr, saereAddr, sinkAddr };
}
// --------------------------------------------------------------------------
// Read every piece of vault state we care about in one shot.
// --------------------------------------------------------------------------
async function snapshot(ctx) {
const { saere } = ctx;
// R6 FIX: single atomic call so bal/totalAssets/undist are computed at the
// SAME block.timestamp (no auto-mine race between RPC calls).
const s = await saere.atomicState();
let pps;
try { pps = await saere.pricePerShare(); } catch (_) { pps = 0n; }
return {
bal: BigInt(s.balance),
totalAssets: BigInt(s.totalAssets_),
totalSupply: BigInt(s.totalSupply_),
rewardRate: BigInt(s.rewardRate_),
periodFinish: BigInt(s.periodFinish_),
lastObserved: BigInt(s.lastObservedBalance_),
pps: BigInt(pps),
undist: BigInt(s.undistributed),
};
}
// --------------------------------------------------------------------------
// Per-step invariant check. `prev` is the snapshot from the previous step,
// `cur` is now, `tag` is the action label for diagnostics.
// --------------------------------------------------------------------------
function checkInvariants(prev, cur, tag, step) {
// I-1: totalAssets <= balance
expect(cur.totalAssets, `[step ${step} ${tag}] I-1 totalAssets > balance`)
.to.be.lessThanOrEqual(cur.bal);
// I-2: totalAssets + undistributed === balance (exact)
expect(cur.totalAssets + cur.undist,
`[step ${step} ${tag}] I-2 ta+undist != bal (ta=${cur.totalAssets} undist=${cur.undist} bal=${cur.bal})`
).to.equal(cur.bal);
// I-3: price-per-share monotonic non-decreasing
expect(cur.pps, `[step ${step} ${tag}] I-3 pps dropped from ${prev.pps} to ${cur.pps}`)
.to.be.greaterThanOrEqual(prev.pps);
// I-5: lastObservedBalance == balance after every state-changing op
expect(cur.lastObserved, `[step ${step} ${tag}] I-5 lastObserved != balance`)
.to.equal(cur.bal);
}
// --------------------------------------------------------------------------
// Helper: assert sync() is idempotent in the same block.
// We mine one tx that calls sync(), then call sync() again with auto-mine off
// inside the SAME block via hardhat_mine, comparing state. Simpler: we observe
// state immediately, call sync() once more (it gets mined in the NEXT block
// but with timestamp held), and check no reward classification fires because
// no balance moved in between.
// We additionally use eth_call to read the would-be effect: since sync() only
// changes state when bal > lastObservedBalance, after a successful sync the
// next sync MUST be a no-op as long as no AERE arrived in between.
// --------------------------------------------------------------------------
async function assertSyncIdempotent(ctx, tag, step) {
const { saere } = ctx;
// Disable auto-mine, send two sync() txs, mine ONE block with timestamp held.
await network.provider.send("evm_setAutomine", [false]);
try {
const before = await snapshot(ctx);
const tx1 = await saere.sync();
const tx2 = await saere.sync();
// mine both in the same block, holding timestamp
await network.provider.send("evm_mine", []);
await tx1.wait();
await tx2.wait();
const after = await snapshot(ctx);
// No external token arrived between the two syncs, so rewardRate /
// periodFinish / lastObservedBalance must be the SAME after the second
// sync as they would be after the first. We approximate this by checking
// that the combined effect equals the effect of a single sync — since
// sync() is pure-function-of-current-balance when called twice with the
// same balance, both calls collapse.
expect(after.lastObserved, `[step ${step} ${tag}] I-4 lastObs drift in same-block double-sync`)
.to.equal(after.bal);
expect(after.periodFinish, `[step ${step} ${tag}] I-4 periodFinish drift in same-block double-sync`)
.to.be.greaterThanOrEqual(before.periodFinish);
} finally {
await network.provider.send("evm_setAutomine", [true]);
}
}
// --------------------------------------------------------------------------
// Action dispatchers — each returns a label string for diagnostics.
// --------------------------------------------------------------------------
async function doDeposit(ctx, prng) {
const actor = ctx.actors[prng.nextInt(ctx.actors.length)];
const bal = BigInt(await ctx.waere.balanceOf(actor.address));
if (bal < 2n) return null;
// deposit between 1 wei and min(bal, 50 AERE)
const cap = bal < 50n * ONE ? bal : 50n * ONE;
const amount = prng.nextBig(cap);
try {
await ctx.saere.connect(actor).deposit(amount, actor.address);
return `deposit(actor=${actor.address.slice(2,6)},amt=${amount})`;
} catch (e) {
return `deposit-revert(${e.shortMessage || e.message.slice(0,40)})`;
}
}
async function doWithdraw(ctx, prng) {
const actor = ctx.actors[prng.nextInt(ctx.actors.length)];
const shares = BigInt(await ctx.saere.balanceOf(actor.address));
if (shares < 2n) return null;
const burn = prng.nextBig(shares);
try {
await ctx.saere.connect(actor).redeem(burn, actor.address, actor.address);
return `redeem(actor=${actor.address.slice(2,6)},shares=${burn})`;
} catch (e) {
return `redeem-revert(${e.shortMessage || e.message.slice(0,40)})`;
}
}
async function doSync(ctx) {
await ctx.saere.sync();
return `sync`;
}
async function doFlush(ctx, prng) {
const bal = BigInt(await ctx.waere.balanceOf(ctx.feeder.address));
if (bal < 10n) return null;
// flush between 1 wei and 100 AERE
const cap = bal < 100n * ONE ? bal : 100n * ONE;
const amount = prng.nextBig(cap);
try {
await ctx.sink.connect(ctx.feeder).flush(ctx.waereAddr, amount);
return `flush(amt=${amount})`;
} catch (e) {
return `flush-revert(${e.shortMessage || e.message.slice(0,40)})`;
}
}
async function doTimeAdvance(prng) {
// between 1 second and ~2 weeks (so we cross periodFinish often)
const secs = 1 + prng.nextInt(14 * 24 * 60 * 60);
await network.provider.send("evm_increaseTime", [secs]);
await network.provider.send("evm_mine", []);
return `timeJump(+${secs}s)`;
}
// --------------------------------------------------------------------------
// The actual fuzz.
// --------------------------------------------------------------------------
describe("sAERE + AereSink — 800-action property fuzz", function () {
this.timeout(15 * 60 * 1000);
it("preserves all drip invariants under random action stream", async function () {
const ctx = await deployFuzzStack();
const prng = makePrng(PRNG_SEED);
// Seed: alice puts in some real stake so totalSupply / pps are non-trivial.
await ctx.saere.connect(ctx.actors[0]).deposit(100n * ONE, ctx.actors[0].address);
let prev = await snapshot(ctx);
// Track periodFinish "as last seen after a flush" — we assert that a NEW
// flush never produces a periodFinish less than block.timestamp (i.e. it
// always projects forward), and never less than the prior periodFinish
// when the prior period was still live AND no time passed. The contract
// resets periodFinish to (now + DRIP_DURATION) on every arrival; this
// is non-decreasing iff no time-travel happened between observations.
for (let step = 0; step < NUM_ACTIONS; step++) {
const choice = prng.nextInt(100);
// distribution: deposit 25 / withdraw 20 / sync 15 / flush 25 / time 15
let tag;
let preBlockBal, prePeriodFinish, preBlockTs;
if (choice < 25) {
tag = await doDeposit(ctx, prng);
} else if (choice < 45) {
tag = await doWithdraw(ctx, prng);
} else if (choice < 60) {
tag = await doSync(ctx);
} else if (choice < 85) {
// Capture pre-flush state for I-6 check
preBlockBal = BigInt(await ctx.waere.balanceOf(ctx.saereAddr));
prePeriodFinish = BigInt(await ctx.saere.periodFinish());
preBlockTs = BigInt((await ethers.provider.getBlock("latest")).timestamp);
tag = await doFlush(ctx, prng);
} else {
tag = await doTimeAdvance(prng);
}
if (tag === null) continue; // skipped (no funds available)
const cur = await snapshot(ctx);
// I-6 — flush-specific: periodFinish never decreases as a side-effect
// of a new arrival. Caveat: time-advance can move us PAST periodFinish,
// after which the next flush LEGITIMATELY sets periodFinish forward to
// now + DRIP_DURATION. So our assertion is: post-flush periodFinish
// >= max(prePeriodFinish, blockTimestamp). The contract always sets
// it to (now + DRIP_DURATION) on arrival, which is trivially > now,
// and >= prePeriodFinish unless we already crossed it (in which case
// it was effectively zero anyway). The strict "never decreases due to
// a new arrival" guarantee: a flush arrival cannot set periodFinish
// earlier than now — i.e. it must extend, not shrink.
if (tag && tag.startsWith("flush(") && cur.bal > preBlockBal) {
const nowTs = BigInt((await ethers.provider.getBlock("latest")).timestamp);
expect(cur.periodFinish,
`[step ${step} ${tag}] I-6 flush moved periodFinish backward of now`
).to.be.greaterThanOrEqual(nowTs);
// R6 HIGH fix: periodFinish only extends to now+DRIP if arrival is
// >= 1% of undistributed reserve. For tiny arrivals it stays the same.
// So we just assert: periodFinish is in the future AND did not decrease.
expect(cur.periodFinish,
`[step ${step} ${tag}] I-6 periodFinish must not decrease vs previous observation`
).to.be.greaterThanOrEqual(prev.periodFinish);
}
// Core invariants every step
checkInvariants(prev, cur, tag || "noop", step);
// I-4 — sample sync-idempotence on ~5% of steps (cheap, broad coverage)
if (prng.nextInt(20) === 0) {
await assertSyncIdempotent(ctx, tag || "noop", step);
const post = await snapshot(ctx);
// Idempotence must not decrease pps either
expect(post.pps,
`[step ${step}] I-3 pps dropped across same-block double-sync`
).to.be.greaterThanOrEqual(cur.pps);
prev = post;
} else {
prev = cur;
}
}
// Final solvency check: dead shares still exist, vault balance covers
// every share-holder's pro-rata claim against totalAssets (no over-mint).
const finalDeadBal = BigInt(await ctx.saere.balanceOf(DEAD_ADDR));
expect(finalDeadBal).to.equal(DEAD_SEED);
const totalSupply = BigInt(await ctx.saere.totalSupply());
const totalAssets = BigInt(await ctx.saere.totalAssets());
const vaultBal = BigInt(await ctx.waere.balanceOf(ctx.saereAddr));
expect(totalAssets).to.be.lessThanOrEqual(vaultBal);
// Every actor can in principle redeem their shares back into <= their
// pro-rata assets without underflow.
for (const a of ctx.actors) {
const sh = BigInt(await ctx.saere.balanceOf(a.address));
if (sh === 0n) continue;
const previewed = BigInt(await ctx.saere.previewRedeem(sh));
expect(previewed).to.be.lessThanOrEqual(totalAssets);
expect(sh).to.be.lessThanOrEqual(totalSupply);
}
});
});