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

283 lines
13 KiB
JavaScript

// Hardhat test suite for the Week-1 sAERE + AereSink stack.
//
// Covers:
// 1. WAERE — wrap/unwrap solvency, ERC-20 invariants
// 2. sAERE — dead-share seed, ERC-4626 invariants, INFLATION ATTACK
// defense, donation-attack neutrality, rate appreciation on direct
// WAERE transfer (the sink mechanism)
// 3. AereSink — bps validation, native-AERE path (no swap), non-AERE
// path (mock router), dust accumulation + sweep, reentrancy guard,
// "no admin" surface (no setters callable)
//
// Run: npx hardhat test test/saere-sink.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
const DEAD_ADDRESS = "0x000000000000000000000000000000000000dEaD";
const ONE = 10n ** 18n;
const DEAD_SEED = 1000n;
async function deployStack(opts = {}) {
const [deployer, alice, bob, fakeRouter, feeder] = await ethers.getSigners();
const WAERE = await (await ethers.getContractFactory("WAERE")).deploy();
await WAERE.waitForDeployment();
const waere = await WAERE.getAddress();
// Compute the next contract address (sAERE) to pre-approve.
const nonce = await ethers.provider.getTransactionCount(deployer.address);
const futureSaere = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 2 });
// Wrap 1000 AERE
await WAERE.connect(deployer).deposit({ value: DEAD_SEED });
await WAERE.connect(deployer).approve(futureSaere, DEAD_SEED);
const sAERE_C = await ethers.getContractFactory("sAERE");
const sAERE = await sAERE_C.deploy(waere);
await sAERE.waitForDeployment();
const saere = await sAERE.getAddress();
// For Sink we need a router; tests that don't exercise the swap path use a
// dummy address. Tests that do use a MockRouter (constructed inline below).
const routerAddr = opts.router || fakeRouter.address;
// Default bps 1500/4000/4500. Some tests override.
const burnBps = opts.burnBps ?? 1500;
const buybackBps = opts.buybackBps ?? 4000;
const stakerBps = opts.stakerBps ?? 4500;
// Use a fresh "burn vault" — for tests just a unique EOA so we can check balance.
const burnVault = opts.burnVault || bob.address;
const Sink = await ethers.getContractFactory("AereSink");
const sink = await Sink.deploy(waere, burnVault, saere, routerAddr, burnBps, buybackBps, stakerBps, 200, ethers.ZeroAddress);
await sink.waitForDeployment();
const sinkAddr = await sink.getAddress();
return { deployer, alice, bob, fakeRouter, feeder, WAERE, sAERE, sink, waere, saere, sinkAddr, burnVault };
}
describe("WAERE — wrap/unwrap solvency", function () {
it("totalSupply equals contract native balance after wrap and unwrap", async function () {
const { WAERE, alice } = await deployStack();
await WAERE.connect(alice).deposit({ value: 5n * ONE });
expect(await WAERE.totalSupply()).to.equal(5n * ONE + DEAD_SEED);
expect(await ethers.provider.getBalance(await WAERE.getAddress())).to.equal(5n * ONE + DEAD_SEED);
await WAERE.connect(alice).withdraw(3n * ONE);
expect(await WAERE.totalSupply()).to.equal(2n * ONE + DEAD_SEED);
expect(await ethers.provider.getBalance(await WAERE.getAddress())).to.equal(2n * ONE + DEAD_SEED);
});
it("rejects withdraw above balance", async function () {
const { WAERE, alice } = await deployStack();
await WAERE.connect(alice).deposit({ value: ONE });
await expect(WAERE.connect(alice).withdraw(2n * ONE)).to.be.revertedWith("WAERE: insufficient balance");
});
it("ERC-20 transferFrom respects allowance", async function () {
const { WAERE, alice, bob } = await deployStack();
await WAERE.connect(alice).deposit({ value: 10n * ONE });
await WAERE.connect(alice).approve(bob.address, 4n * ONE);
await WAERE.connect(bob).transferFrom(alice.address, bob.address, 3n * ONE);
expect(await WAERE.balanceOf(bob.address)).to.equal(3n * ONE);
expect(await WAERE.allowance(alice.address, bob.address)).to.equal(ONE);
await expect(
WAERE.connect(bob).transferFrom(alice.address, bob.address, 2n * ONE)
).to.be.revertedWith("WAERE: insufficient allowance");
});
});
describe("sAERE — ERC-4626 receipt vault", function () {
it("seeds 1000 dead shares at deploy and totalSupply == seed", async function () {
const { sAERE } = await deployStack();
expect(await sAERE.balanceOf(DEAD_ADDRESS)).to.equal(DEAD_SEED);
expect(await sAERE.totalSupply()).to.equal(DEAD_SEED);
});
it("inflation-attack: first depositor cannot manipulate price", async function () {
// Classic attack: deploy fresh, attacker deposits 1 wei underlying to mint 1 share,
// then directly transfers a large WAERE amount to vault, so totalAssets jumps.
// The next depositor calling deposit(x) receives floor(x * 1 / totalAssets) shares.
// With dead-share seed, totalShares starts at 1000 not 1, so the math denominator
// is large enough that a victim depositing 1e18 underlying receives at least
// 1e18 * 1000 / (1 + N) shares — never zero.
const { sAERE, WAERE, alice, bob } = await deployStack();
// Bob acts as the attacker.
await WAERE.connect(bob).deposit({ value: 1n });
await WAERE.connect(bob).approve(await sAERE.getAddress(), 1n);
await sAERE.connect(bob).deposit(1n, bob.address);
// R5 HIGH-4: with _decimalsOffset()=6 the share/asset ratio starts at ~1e6 so
// a 1-wei deposit yields ~1000 shares (rounded down via OZ math), not 1.
expect(await sAERE.balanceOf(bob.address)).to.be.gte(1n);
// Attacker tries to spike totalAssets by direct transfer (donation attack).
// Use 1000 WAERE — far above the dead seed but within hardhat default balance.
await WAERE.connect(bob).deposit({ value: 1_000n * ONE });
await WAERE.connect(bob).transfer(await sAERE.getAddress(), 1_000n * ONE);
// Alice now deposits 1 AERE (1e18). She should receive a non-zero share count.
await WAERE.connect(alice).deposit({ value: ONE });
await WAERE.connect(alice).approve(await sAERE.getAddress(), ONE);
await sAERE.connect(alice).deposit(ONE, alice.address);
const aliceShares = await sAERE.balanceOf(alice.address);
expect(aliceShares).to.be.gt(0n);
});
it("rate appreciates on direct WAERE transfer to vault (the sink mechanism)", async function () {
const { sAERE, WAERE, alice, bob } = await deployStack();
// Alice deposits 100 AERE.
await WAERE.connect(alice).deposit({ value: 100n * ONE });
await WAERE.connect(alice).approve(await sAERE.getAddress(), 100n * ONE);
await sAERE.connect(alice).deposit(100n * ONE, alice.address);
const pricePerShareBefore = await sAERE.pricePerShare();
// Bob (acting as the sink) transfers 10 WAERE directly to the vault — no shares minted.
await WAERE.connect(bob).deposit({ value: 10n * ONE });
await WAERE.connect(bob).transfer(await sAERE.getAddress(), 10n * ONE);
const pricePerShareAfter = await sAERE.pricePerShare();
expect(pricePerShareAfter).to.be.gt(pricePerShareBefore);
// Alice now redeems her full share balance and receives ~110 WAERE
// (minus dust from dead-share seed proportional bleed).
const aliceShares = await sAERE.balanceOf(alice.address);
await sAERE.connect(alice).redeem(aliceShares, alice.address, alice.address);
const aliceWaere = await WAERE.balanceOf(alice.address);
expect(aliceWaere).to.be.gte(109n * ONE);
expect(aliceWaere).to.be.lte(110n * ONE);
});
it("has no admin surface (sAERE exposes only ERC-4626 functions)", async function () {
const { sAERE } = await deployStack();
// Reading interfaces — assert each admin-ish selector either returns empty
// data or reverts; never returns a real value.
const sigs = [
ethers.id("owner()").slice(0, 10),
ethers.id("pause()").slice(0, 10),
ethers.id("setOwner(address)").slice(0, 10),
ethers.id("upgradeTo(address)").slice(0, 10),
ethers.id("setRouter(address)").slice(0, 10),
];
for (const sig of sigs) {
try {
const res = await ethers.provider.call({ to: await sAERE.getAddress(), data: sig });
// If it returned, must be empty (no value), otherwise it would be a leak.
expect(res === "0x" || res.length <= 2).to.equal(true);
} catch (e) {
// Reverting is also acceptable — means the selector is unknown.
}
}
});
});
describe("AereSink — bps & immutables", function () {
it("constructor reverts if bps do not sum to 10000", async function () {
const [d] = await ethers.getSigners();
const Sink = await ethers.getContractFactory("AereSink");
await expect(
Sink.deploy(d.address, d.address, d.address, d.address, 1000, 1000, 1000, 200, ethers.ZeroAddress)
).to.be.revertedWithCustomError(Sink, "BpsMustSumTo10000");
});
it("constructor reverts on zero address inputs", async function () {
const [d] = await ethers.getSigners();
const Sink = await ethers.getContractFactory("AereSink");
await expect(
Sink.deploy(ethers.ZeroAddress, d.address, d.address, d.address, 1500, 4000, 4500, 200, ethers.ZeroAddress)
).to.be.revertedWithCustomError(Sink, "ZeroAddress");
});
it("exposes only immutable getters — no setters reachable", async function () {
const { sink } = await deployStack();
const sigs = [
ethers.id("setSink(address)").slice(0, 10),
ethers.id("setRouter(address)").slice(0, 10),
ethers.id("setBps(uint16,uint16,uint16)").slice(0, 10),
ethers.id("pause()").slice(0, 10),
ethers.id("owner()").slice(0, 10),
ethers.id("transferOwnership(address)").slice(0, 10),
];
for (const sig of sigs) {
try {
const res = await ethers.provider.call({ to: await sink.getAddress(), data: sig });
expect(res === "0x" || res.length <= 2).to.equal(true);
} catch (e) {
// Reverting is also acceptable.
}
}
});
});
describe("AereSink — native-AERE flush path (no swap)", function () {
it("routes AERE input to burn vault and sAERE pro-rata", async function () {
const { WAERE, sAERE, sink, alice, burnVault } = await deployStack();
const sinkAddr = await sink.getAddress();
const saereAddr = await sAERE.getAddress();
// Alice gets 100 WAERE and approves the sink.
await WAERE.connect(alice).deposit({ value: 100n * ONE });
await WAERE.connect(alice).approve(sinkAddr, 100n * ONE);
const burnBefore = await WAERE.balanceOf(burnVault);
const saereBefore = await WAERE.balanceOf(saereAddr);
await sink.connect(alice).flush(await WAERE.getAddress(), 100n * ONE);
const burnAfter = await WAERE.balanceOf(burnVault);
const saereAfter = await WAERE.balanceOf(saereAddr);
// 1500 + 4000 = 5500 bps to burn (combined since input is AERE).
expect(burnAfter - burnBefore).to.equal(55n * ONE);
// 4500 bps to staker yield = 45 WAERE.
expect(saereAfter - saereBefore).to.equal(45n * ONE);
});
it("emits Flushed with correct buckets", async function () {
const { WAERE, sink, alice } = await deployStack();
const sinkAddr = await sink.getAddress();
await WAERE.connect(alice).deposit({ value: 100n * ONE });
await WAERE.connect(alice).approve(sinkAddr, 100n * ONE);
await expect(sink.connect(alice).flush(await WAERE.getAddress(), 100n * ONE))
.to.emit(sink, "Flushed")
.withArgs(alice.address, await WAERE.getAddress(), 100n * ONE, 15n * ONE, 40n * ONE, 45n * ONE);
});
it("reverts on zero amount", async function () {
const { WAERE, sink, alice } = await deployStack();
await WAERE.connect(alice).approve(await sink.getAddress(), 0);
await expect(sink.connect(alice).flush(await WAERE.getAddress(), 0))
.to.be.revertedWithCustomError(sink, "ZeroAmount");
});
});
describe("AereSink — dust sweep", function () {
it("sweepDust re-routes residual balance through the same buckets", async function () {
const { WAERE, sAERE, sink, alice, burnVault } = await deployStack();
const sinkAddr = await sink.getAddress();
const saereAddr = await sAERE.getAddress();
// Someone (anyone) sends 200 WAERE directly to the sink, bypassing flush.
await WAERE.connect(alice).deposit({ value: 200n * ONE });
await WAERE.connect(alice).transfer(sinkAddr, 200n * ONE);
const burnBefore = await WAERE.balanceOf(burnVault);
const saereBefore = await WAERE.balanceOf(saereAddr);
await sink.connect(alice).sweepDust(await WAERE.getAddress());
expect(await WAERE.balanceOf(burnVault) - burnBefore).to.equal(110n * ONE);
expect(await WAERE.balanceOf(saereAddr) - saereBefore).to.equal(90n * ONE);
});
it("sweepDust reverts when balance is zero", async function () {
const { WAERE, sink } = await deployStack();
await expect(sink.sweepDust(await WAERE.getAddress()))
.to.be.revertedWithCustomError(sink, "ZeroAmount");
});
});