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.
283 lines
13 KiB
JavaScript
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");
|
|
});
|
|
});
|