// 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"); }); });