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.
368 lines
16 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
});
|