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

794 lines
34 KiB
JavaScript

const { expect } = require("chai");
const { ethers } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");
const abi = ethers.AbiCoder.defaultAbiCoder();
const Q = (s) => ethers.keccak256(ethers.toUtf8Bytes(s));
/* ----------------------------- merkle helpers ----------------------------- */
// Leaf = keccak256(bytes.concat(keccak256(abi.encode(account, total)))) — OZ
// StandardMerkleTree encoding. Internal nodes use sorted-pair hashing so the
// on-chain OZ MerkleProof.verify matches.
function leafHash(account, total) {
const inner = ethers.keccak256(abi.encode(["address", "uint256"], [account, total]));
return ethers.keccak256(inner);
}
function hashPair(a, b) {
return BigInt(a) < BigInt(b)
? ethers.keccak256(ethers.concat([a, b]))
: ethers.keccak256(ethers.concat([b, a]));
}
function buildLayers(leaves) {
const layers = [leaves.slice()];
while (layers[layers.length - 1].length > 1) {
const prev = layers[layers.length - 1];
const next = [];
for (let i = 0; i < prev.length; i += 2) {
if (i + 1 < prev.length) next.push(hashPair(prev[i], prev[i + 1]));
else next.push(prev[i]); // promote odd node
}
layers.push(next);
}
return layers;
}
function getProof(layers, index) {
const proof = [];
let idx = index;
for (let l = 0; l < layers.length - 1; l++) {
const layer = layers[l];
const sib = idx ^ 1;
if (sib < layer.length) proof.push(layer[sib]);
idx = Math.floor(idx / 2);
}
return proof;
}
function makeTree(entries) {
// entries: [{account, total}] -> {root, proofFor(account)}
const leaves = entries.map((e) => leafHash(e.account, e.total));
const layers = buildLayers(leaves);
const root = layers[layers.length - 1][0];
const indexOf = {};
entries.forEach((e, i) => (indexOf[e.account.toLowerCase()] = i));
return {
root,
proofFor: (account) => getProof(layers, indexOf[account.toLowerCase()]),
};
}
/* ------------------------- EIP-712 ticket signing ------------------------- */
async function signTicket(signer, attestorAddr, chainId, ticket) {
const domain = {
name: "AereQuestAttestor",
version: "1",
chainId,
verifyingContract: attestorAddr,
};
const types = {
QuestTicket: [
{ name: "account", type: "address" },
{ name: "questId", type: "bytes32" },
{ name: "nonce", type: "uint256" },
{ name: "validUntil", type: "uint64" },
],
};
return signer.signTypedData(domain, types, ticket);
}
/* ================================= TESTS ================================== */
describe("AERE Season 1 — Points Ledger", () => {
let ledger, owner, attestor, alice, bob;
beforeEach(async () => {
[owner, attestor, alice, bob] = await ethers.getSigners();
ledger = await (await ethers.getContractFactory("AerePointsLedger")).deploy();
await ledger.setAttestor(attestor.address, true);
});
it("credits only via an authorized attestor", async () => {
await ledger.connect(attestor).credit(alice.address, 100, Q("create-passkey"));
expect(await ledger.balanceOf(alice.address)).to.equal(100);
expect(await ledger.totalSupply()).to.equal(100);
await expect(
ledger.connect(bob).credit(alice.address, 100, Q("x"))
).to.be.revertedWithCustomError(ledger, "NotAttestor");
});
it("is NON-transferable: transfer/transferFrom/approve REVERT", async () => {
await ledger.connect(attestor).credit(alice.address, 100, Q("q"));
await expect(ledger.connect(alice).transfer(bob.address, 1)).to.be.revertedWithCustomError(
ledger,
"NotTransferable"
);
await expect(
ledger.connect(alice).transferFrom(alice.address, bob.address, 1)
).to.be.revertedWithCustomError(ledger, "NotTransferable");
await expect(ledger.connect(alice).approve(bob.address, 1)).to.be.revertedWithCustomError(
ledger,
"NotTransferable"
);
expect(await ledger.allowance(alice.address, bob.address)).to.equal(0);
});
it("has no burn / redeem / conversion path (credit-only)", async () => {
// The ABI must not expose any burn/redeem/withdraw/mint-to-self function.
const fns = Object.keys(ledger.interface.fragments
? {}
: {}); // placeholder; explicit checks below
const names = ledger.interface.fragments
.filter((f) => f.type === "function")
.map((f) => f.name);
for (const bad of ["burn", "redeem", "withdraw", "mint", "convert", "claim"]) {
expect(names).to.not.include(bad);
}
});
it("rejects zero account / zero amount", async () => {
await expect(
ledger.connect(attestor).credit(ethers.ZeroAddress, 1, Q("q"))
).to.be.revertedWithCustomError(ledger, "ZeroAccount");
await expect(
ledger.connect(attestor).credit(alice.address, 0, Q("q"))
).to.be.revertedWithCustomError(ledger, "ZeroAmount");
});
});
describe("AERE Season 1 — Quest Registry", () => {
let reg, owner, alice;
beforeEach(async () => {
[owner, alice] = await ethers.getSigners();
reg = await (await ethers.getContractFactory("AereQuestRegistry")).deploy();
});
it("creates and reads a quest; only owner", async () => {
const now = await time.latest();
await reg.createQuest(Q("follow-x"), 10, now, now + 1000, 10, false);
const q = await reg.getQuest(Q("follow-x"));
expect(q.weight).to.equal(10);
expect(q.active).to.equal(true);
expect(await reg.isOpen(Q("follow-x"), now + 500)).to.equal(true);
expect(await reg.isOpen(Q("follow-x"), now + 2000)).to.equal(false);
await expect(
reg.connect(alice).createQuest(Q("z"), 1, now, 0, 1, false)
).to.be.revertedWith("Ownable: caller is not the owner");
});
it("enforces validation and duplicate protection", async () => {
const now = await time.latest();
await expect(reg.createQuest(Q("a"), 0, now, 0, 5, false)).to.be.revertedWithCustomError(reg, "BadWeight");
await expect(reg.createQuest(Q("a"), 10, now, 0, 5, false)).to.be.revertedWithCustomError(reg, "BadWeight"); // cap<weight
await expect(reg.createQuest(Q("a"), 10, now, now - 1, 10, false)).to.be.revertedWithCustomError(reg, "BadWindow");
await reg.createQuest(Q("a"), 10, now, 0, 10, false);
await expect(reg.createQuest(Q("a"), 10, now, 0, 10, false)).to.be.revertedWithCustomError(reg, "QuestExists");
});
it("is tunable: update + active toggle without redeploy", async () => {
const now = await time.latest();
await reg.createQuest(Q("a"), 10, now, 0, 10, false);
await reg.updateQuest(Q("a"), 20, now, 0, 40, true);
const q = await reg.getQuest(Q("a"));
expect(q.weight).to.equal(20);
expect(q.perUserCap).to.equal(40);
expect(q.requiresVerifiedHuman).to.equal(true);
await reg.setQuestActive(Q("a"), false);
expect(await reg.isOpen(Q("a"), now)).to.equal(false);
});
});
describe("AERE Season 1 — Quest Attestor (EIP-712 one-shot)", () => {
let ledger, reg, attestor, humanity, owner, signer, alice, bob, relayer;
let chainId;
beforeEach(async () => {
[owner, signer, alice, bob, relayer] = await ethers.getSigners();
chainId = (await ethers.provider.getNetwork()).chainId;
ledger = await (await ethers.getContractFactory("AerePointsLedger")).deploy();
reg = await (await ethers.getContractFactory("AereQuestRegistry")).deploy();
attestor = await (await ethers.getContractFactory("AereQuestAttestor")).deploy(
await ledger.getAddress(),
await reg.getAddress(),
signer.address
);
humanity = await (await ethers.getContractFactory("MockHumanityOracle")).deploy();
await ledger.setAttestor(await attestor.getAddress(), true);
const now = await time.latest();
// open quest, weight 10, cap 10 (one-shot), no human required
await reg.createQuest(Q("follow-x"), 10, now, now + 100000, 10, false);
// repeatable quest, weight 10, cap 30 (up to 3 completions)
await reg.createQuest(Q("daily"), 10, now, now + 100000, 30, false);
// human-gated quest
await reg.createQuest(Q("kyc"), 25, now, now + 100000, 25, true);
});
it("credits the ledger exactly once per (account,questId,nonce)", async () => {
const validUntil = (await time.latest()) + 3600;
const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
account: alice.address,
questId: Q("follow-x"),
nonce: 1,
validUntil,
});
// anyone (relayer) may submit
await attestor.connect(relayer).redeem(alice.address, Q("follow-x"), 1, validUntil, sig);
expect(await ledger.balanceOf(alice.address)).to.equal(10);
expect(await attestor.distinctQuestCount(alice.address)).to.equal(1);
// replay reverts
await expect(
attestor.connect(relayer).redeem(alice.address, Q("follow-x"), 1, validUntil, sig)
).to.be.revertedWithCustomError(attestor, "TicketAlreadyUsed");
});
it("rejects a ticket from an unauthorized signer", async () => {
const validUntil = (await time.latest()) + 3600;
const badSig = await signTicket(bob, await attestor.getAddress(), chainId, {
account: alice.address,
questId: Q("follow-x"),
nonce: 2,
validUntil,
});
await expect(
attestor.redeem(alice.address, Q("follow-x"), 2, validUntil, badSig)
).to.be.revertedWithCustomError(attestor, "BadSigner");
});
it("rejects an expired ticket", async () => {
const validUntil = (await time.latest()) + 100;
const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
account: alice.address,
questId: Q("follow-x"),
nonce: 3,
validUntil,
});
await time.increase(200);
await expect(
attestor.redeem(alice.address, Q("follow-x"), 3, validUntil, sig)
).to.be.revertedWithCustomError(attestor, "TicketExpired");
});
it("enforces the quest window (closed / inactive)", async () => {
const now = await time.latest();
await reg.createQuest(Q("future"), 10, now + 5000, now + 100000, 10, false);
const validUntil = now + 100000;
const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
account: alice.address,
questId: Q("future"),
nonce: 1,
validUntil,
});
await expect(
attestor.redeem(alice.address, Q("future"), 1, validUntil, sig)
).to.be.revertedWithCustomError(attestor, "QuestClosed");
// deactivate an open quest -> closed
await reg.setQuestActive(Q("follow-x"), false);
const sig2 = await signTicket(signer, await attestor.getAddress(), chainId, {
account: alice.address,
questId: Q("follow-x"),
nonce: 9,
validUntil,
});
await expect(
attestor.redeem(alice.address, Q("follow-x"), 9, validUntil, sig2)
).to.be.revertedWithCustomError(attestor, "QuestClosed");
});
it("enforces the per-user cap across multiple nonces", async () => {
const validUntil = (await time.latest()) + 100000;
// daily cap 30 -> 3 completions ok, 4th reverts
for (let n = 1; n <= 3; n++) {
const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
account: alice.address,
questId: Q("daily"),
nonce: n,
validUntil,
});
await attestor.redeem(alice.address, Q("daily"), n, validUntil, sig);
}
expect(await ledger.balanceOf(alice.address)).to.equal(30);
expect(await attestor.distinctQuestCount(alice.address)).to.equal(1); // same quest
const sig4 = await signTicket(signer, await attestor.getAddress(), chainId, {
account: alice.address,
questId: Q("daily"),
nonce: 4,
validUntil,
});
await expect(
attestor.redeem(alice.address, Q("daily"), 4, validUntil, sig4)
).to.be.revertedWithCustomError(attestor, "PerUserCapReached");
});
it("enforces requiresVerifiedHuman via the humanity oracle", async () => {
const validUntil = (await time.latest()) + 100000;
const mk = async (nonce) =>
signTicket(signer, await attestor.getAddress(), chainId, {
account: alice.address,
questId: Q("kyc"),
nonce,
validUntil,
});
// oracle unset -> revert
let sig = await mk(1);
await expect(attestor.redeem(alice.address, Q("kyc"), 1, validUntil, sig)).to.be.revertedWithCustomError(
attestor,
"HumanityOracleUnset"
);
// set oracle, alice not human -> revert
await attestor.setHumanityOracle(await humanity.getAddress());
sig = await mk(1);
await expect(attestor.redeem(alice.address, Q("kyc"), 1, validUntil, sig)).to.be.revertedWithCustomError(
attestor,
"NotVerifiedHuman"
);
// mark human -> ok
await humanity.set(alice.address, true);
sig = await mk(1);
await attestor.redeem(alice.address, Q("kyc"), 1, validUntil, sig);
expect(await ledger.balanceOf(alice.address)).to.equal(25);
});
it("tracks distinct quest count across different quests", async () => {
const validUntil = (await time.latest()) + 100000;
for (const [q, n] of [[Q("follow-x"), 1], [Q("daily"), 1]]) {
const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
account: alice.address,
questId: q,
nonce: n,
validUntil,
});
await attestor.redeem(alice.address, q, n, validUntil, sig);
}
expect(await attestor.distinctQuestCount(alice.address)).to.equal(2);
});
});
describe("AERE Season 1 — Check-In (soulbound First Flight)", () => {
let checkin, owner, alice, bob;
beforeEach(async () => {
[owner, alice, bob] = await ethers.getSigners();
checkin = await (await ethers.getContractFactory("AereQuestCheckIn")).deploy();
});
it("mints once per account and is queryable", async () => {
await checkin.connect(alice).checkIn();
expect(await checkin.hasCheckedIn(alice.address)).to.equal(true);
expect(await checkin.balanceOf(alice.address)).to.equal(1);
const tokenId = BigInt(alice.address);
expect(await checkin.ownerOf(tokenId)).to.equal(alice.address);
expect(await checkin.totalCheckedIn()).to.equal(1);
await expect(checkin.connect(alice).checkIn()).to.be.revertedWithCustomError(
checkin,
"AlreadyCheckedIn"
);
});
it("is soulbound: all transfer/approval entrypoints REVERT", async () => {
await checkin.connect(alice).checkIn();
const tokenId = BigInt(alice.address);
await expect(
checkin.connect(alice).transferFrom(alice.address, bob.address, tokenId)
).to.be.revertedWithCustomError(checkin, "Soulbound");
await expect(
checkin.connect(alice)["safeTransferFrom(address,address,uint256)"](alice.address, bob.address, tokenId)
).to.be.revertedWithCustomError(checkin, "Soulbound");
await expect(checkin.connect(alice).approve(bob.address, tokenId)).to.be.revertedWithCustomError(
checkin,
"Soulbound"
);
await expect(
checkin.connect(alice).setApprovalForAll(bob.address, true)
).to.be.revertedWithCustomError(checkin, "Soulbound");
});
it("supports ERC-721 interface ids and returns tokenURI", async () => {
await checkin.connect(alice).checkIn();
expect(await checkin.supportsInterface("0x80ac58cd")).to.equal(true); // ERC721
expect(await checkin.supportsInterface("0x5b5e139f")).to.equal(true); // metadata
const uri = await checkin.tokenURI(BigInt(alice.address));
expect(uri).to.contain("data:application/json;base64,");
});
});
describe("AERE Season 1 — Referral Registry", () => {
let ledger, reg, attestor, checkin, referral, passkey, humanity;
let owner, signer, referrer, invitee, other;
let chainId;
async function creditQuest(account, questId, nonce) {
const validUntil = (await time.latest()) + 1000000;
const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
account,
questId,
nonce,
validUntil,
});
await attestor.redeem(account, questId, nonce, validUntil, sig);
}
beforeEach(async () => {
[owner, signer, referrer, invitee, other] = await ethers.getSigners();
chainId = (await ethers.provider.getNetwork()).chainId;
ledger = await (await ethers.getContractFactory("AerePointsLedger")).deploy();
reg = await (await ethers.getContractFactory("AereQuestRegistry")).deploy();
attestor = await (await ethers.getContractFactory("AereQuestAttestor")).deploy(
await ledger.getAddress(),
await reg.getAddress(),
signer.address
);
checkin = await (await ethers.getContractFactory("AereQuestCheckIn")).deploy();
referral = await (await ethers.getContractFactory("AereReferralRegistry")).deploy(
await ledger.getAddress(),
await checkin.getAddress(),
await attestor.getAddress()
);
passkey = await (await ethers.getContractFactory("MockAccountFlag")).deploy();
await referral.setPasskeyFlag(await passkey.getAddress());
// authorize both the attestor and the referral registry to credit
await ledger.setAttestor(await attestor.getAddress(), true);
await ledger.setAttestor(await referral.getAddress(), true);
const now = await time.latest();
await reg.createQuest(Q("q1"), 10, now, now + 1000000, 10, false);
await reg.createQuest(Q("q2"), 10, now, now + 1000000, 10, false);
});
async function activate(account) {
await passkey.set(account.address, true);
await checkin.connect(account).checkIn();
await creditQuest(account.address, Q("q1"), 1);
await creditQuest(account.address, Q("q2"), 1);
}
it("binds codes/referrers and rejects self-referral & duplicates", async () => {
await referral.connect(referrer).registerCode(Q("CODE"));
await expect(referral.connect(other).registerCode(Q("CODE"))).to.be.revertedWithCustomError(
referral,
"CodeTaken"
);
await expect(referral.connect(referrer).bindReferrer(Q("CODE"))).to.be.revertedWithCustomError(
referral,
"SelfReferral"
);
await referral.connect(invitee).bindReferrer(Q("CODE"));
expect(await referral.referredBy(invitee.address)).to.equal(referrer.address);
await expect(referral.connect(invitee).bindReferrer(Q("CODE"))).to.be.revertedWithCustomError(
referral,
"AlreadyBound"
);
});
it("pays the referrer ONLY on invitee activation", async () => {
await referral.connect(referrer).registerCode(Q("CODE"));
await referral.connect(invitee).bindReferrer(Q("CODE"));
// not activated yet -> revert
await expect(referral.claimReferral(invitee.address)).to.be.revertedWithCustomError(
referral,
"NotActivated"
);
// partial activation: passkey + checkin but only 1 quest -> still not activated
await passkey.set(invitee.address, true);
await checkin.connect(invitee).checkIn();
await creditQuest(invitee.address, Q("q1"), 1);
expect(await referral.isActivated(invitee.address)).to.equal(false);
await expect(referral.claimReferral(invitee.address)).to.be.revertedWithCustomError(
referral,
"NotActivated"
);
// second distinct quest completes activation
await creditQuest(invitee.address, Q("q2"), 1);
expect(await referral.isActivated(invitee.address)).to.equal(true);
const before = await ledger.balanceOf(referrer.address);
await referral.claimReferral(invitee.address);
const after = await ledger.balanceOf(referrer.address);
expect(after - before).to.equal(50n); // baseReward
// double-claim reverts
await expect(referral.claimReferral(invitee.address)).to.be.revertedWithCustomError(
referral,
"AlreadyCredited"
);
});
it("applies diminishing returns and the tier-raised cap", async () => {
await referral.connect(referrer).registerCode(Q("CODE"));
// tighten params so the cap binds quickly:
// baseReward 50, halvingEvery 2, minReward 5, tierSize 3, capBase 90, capStep 60
await referral.setReferralParams(2, 50, 2, 5, 3, 90, 60);
const signers = await ethers.getSigners();
// use a batch of fresh invitees
const invitees = signers.slice(5, 12);
const rewards = [];
for (let i = 0; i < invitees.length; i++) {
const inv = invitees[i];
await referral.connect(inv).bindReferrer(Q("CODE"));
await activate(inv);
const before = await ledger.balanceOf(referrer.address);
await referral.claimReferral(inv.address);
const after = await ledger.balanceOf(referrer.address);
rewards.push(after - before);
}
// diminishing: n=0,1 ->50 ; n=2,3 ->25 ; n=4,5 ->12 ; n=6 ->6 (50>>k, floor 5)
// but capped by tier: tier=n/3 -> cap=90 + tier*60. Cumulative earned clamps.
// Check monotonic non-increasing per-referral reward within a tier and cap respected.
const stats = await referral.referralStats(referrer.address);
const totalEarned = rewards.reduce((a, b) => a + b, 0n);
expect(stats.pointsEarned).to.equal(totalEarned);
// cap for the final count must be >= earned
expect(stats.currentCap).to.be.gte(totalEarned);
// first reward is the full base, a later one is strictly smaller (diminished or clamped)
expect(rewards[0]).to.equal(50n);
expect(rewards[rewards.length - 1]).to.be.lt(50n);
});
it("clamps a single reward to the remaining tier cap", async () => {
await referral.connect(referrer).registerCode(Q("CODE"));
// cap base 30, so the very first reward (base 50) clamps to 30
await referral.setReferralParams(2, 50, 10, 5, 100, 30, 100);
await referral.connect(invitee).bindReferrer(Q("CODE"));
await activate(invitee);
const before = await ledger.balanceOf(referrer.address);
await referral.claimReferral(invitee.address);
const after = await ledger.balanceOf(referrer.address);
expect(after - before).to.equal(30n); // clamped to cap
});
it("returns false activation when passkey oracle unset", async () => {
const fresh = await (await ethers.getContractFactory("AereReferralRegistry")).deploy(
await ledger.getAddress(),
await checkin.getAddress(),
await attestor.getAddress()
);
expect(await fresh.isActivated(invitee.address)).to.equal(false);
});
// FINDING 5 (info): referral code squatting / front-running.
// Codes are non-semantic, first-come, and front-runnable BY DESIGN in this
// version: registerCode lets anyone claim any unused bytes32 with no reservation
// for branded strings. There is no fund loss (AP is non-transferable and has no
// monetary value) and no branded-code reservation is in scope for this version.
// This test characterizes and pins that accepted behavior. It is documentation
// that lives as an assertion: a squatter can take a "branded" code before the
// brand owner, and the brand owner is then blocked with CodeTaken. If a future
// version ever adds owner-gated reservation or sender-derived codes, this test
// is the intended tripwire to force an explicit, reviewed change.
it("codes are first-come and front-runnable by design (FINDING 5, accepted)", async () => {
const BRANDED = Q("aere-official"); // a string a brand would want
// A squatter (here: `other`) front-runs and claims the branded code first.
await referral.connect(other).registerCode(BRANDED);
expect(await referral.codeOwner(BRANDED)).to.equal(other.address);
// The party who "should" own the brand cannot take it: first-come wins.
await expect(
referral.connect(referrer).registerCode(BRANDED)
).to.be.revertedWithCustomError(referral, "CodeTaken");
// Codes are non-semantic: the contract attaches no meaning to the bytes32,
// and an invitee who binds the branded code is credited to the squatter,
// exactly as with any other code. No special-casing exists (or is intended).
await referral.connect(invitee).bindReferrer(BRANDED);
expect(await referral.referredBy(invitee.address)).to.equal(other.address);
});
});
describe("AERE Season 1 — Reward Distributor (SECURITY-CRITICAL)", () => {
let dist, waere, saere, sink, owner, alice, bob, carol, relayer;
const E = ethers.parseEther;
beforeEach(async () => {
[owner, alice, bob, carol, relayer] = await ethers.getSigners();
waere = await (await ethers.getContractFactory("WAERE")).deploy();
saere = await (await ethers.getContractFactory("MockSAERE")).deploy(await waere.getAddress());
sink = await (await ethers.getContractFactory("MockSink")).deploy();
dist = await (await ethers.getContractFactory("AereRewardDistributor")).deploy(
await waere.getAddress(),
await saere.getAddress(),
await sink.getAddress()
);
});
it("deploys UNFUNDED and holds 0 AERE", async () => {
expect(await ethers.provider.getBalance(await dist.getAddress())).to.equal(0);
expect(await dist.merkleRoot()).to.equal(ethers.ZeroHash);
expect(await dist.claimsStarted()).to.equal(false);
});
it("valid Merkle proof claims with correct cliff+linear vesting math", async () => {
const entries = [
{ account: alice.address, total: E("100") },
{ account: bob.address, total: E("50") },
{ account: carol.address, total: E("25") },
];
const tree = makeTree(entries);
const now = await time.latest();
const cliff = 100;
const duration = 1000;
const expiry = now + 10_000_000;
await dist.publishRoot(tree.root, cliff, duration, expiry, false);
// fund with native AERE (simulating the founder's LATER funding tx)
await owner.sendTransaction({ to: await dist.getAddress(), value: E("175") });
const proofA = tree.proofFor(alice.address);
// before cliff -> nothing to claim
await expect(dist.claim(alice.address, E("100"), proofA)).to.be.revertedWithCustomError(
dist,
"NothingToClaim"
);
// mine the claim at EXACTLY start + duration/2 (500s). vested = 100*500/1000 = 50.
const start = Number(await dist.rootPublishedAt());
await time.setNextBlockTimestamp(start + 500);
const balBefore = await ethers.provider.getBalance(alice.address);
await dist.connect(relayer).claim(alice.address, E("100"), proofA); // relayer submits, alice receives
const balAfter = await ethers.provider.getBalance(alice.address);
// alice pays no gas (relayer did); received exactly 50
expect(balAfter - balBefore).to.equal(E("50"));
expect(await dist.claimsStarted()).to.equal(true);
// mine the next claim past full vest -> claim the remainder (exactly 50)
await time.setNextBlockTimestamp(start + duration + 10);
const b2 = await ethers.provider.getBalance(alice.address);
await dist.connect(relayer).claim(alice.address, E("100"), proofA);
const b3 = await ethers.provider.getBalance(alice.address);
expect(b3 - b2).to.equal(E("50"));
expect(await dist.claimed(alice.address)).to.equal(E("100"));
// fully vested, nothing left
await expect(dist.claim(alice.address, E("100"), proofA)).to.be.revertedWithCustomError(
dist,
"NothingToClaim"
);
});
it("rejects a bad proof / wrong amount", async () => {
const entries = [{ account: alice.address, total: E("100") }, { account: bob.address, total: E("50") }];
const tree = makeTree(entries);
const now = await time.latest();
await dist.publishRoot(tree.root, 0, 1000, now + 10_000_000, false);
await owner.sendTransaction({ to: await dist.getAddress(), value: E("150") });
const proofA = tree.proofFor(alice.address);
// wrong amount with alice's proof
await expect(dist.claim(alice.address, E("999"), proofA)).to.be.revertedWithCustomError(dist, "BadProof");
// bob's proof used for alice
await expect(dist.claim(alice.address, E("100"), tree.proofFor(bob.address))).to.be.revertedWithCustomError(
dist,
"BadProof"
);
});
it("EXCLUDED address cannot claim (sybil revocation)", async () => {
const entries = [{ account: alice.address, total: E("100") }, { account: bob.address, total: E("50") }];
const tree = makeTree(entries);
const now = await time.latest();
await dist.publishRoot(tree.root, 0, 10, now + 10_000_000, false);
await owner.sendTransaction({ to: await dist.getAddress(), value: E("150") });
await dist.setExcluded(alice.address, true);
await time.increase(20);
await expect(dist.claim(alice.address, E("100"), tree.proofFor(alice.address))).to.be.revertedWithCustomError(
dist,
"IsExcluded"
);
// bob unaffected
await dist.claim(bob.address, E("50"), tree.proofFor(bob.address));
expect(await dist.claimed(bob.address)).to.equal(E("50"));
});
it("double-claim of the same vested tranche reverts (NothingToClaim)", async () => {
const entries = [{ account: alice.address, total: E("100") }];
const tree = makeTree(entries);
const now = await time.latest();
await dist.publishRoot(tree.root, 0, 10, now + 10_000_000, false);
await owner.sendTransaction({ to: await dist.getAddress(), value: E("100") });
await time.increase(20); // fully vested
await dist.claim(alice.address, E("100"), tree.proofFor(alice.address));
await expect(dist.claim(alice.address, E("100"), tree.proofFor(alice.address))).to.be.revertedWithCustomError(
dist,
"NothingToClaim"
);
});
it("clawback of unclaimed only AFTER expiry; to owner or sink", async () => {
const entries = [{ account: alice.address, total: E("100") }];
const tree = makeTree(entries);
const now = await time.latest();
const expiry = now + 5000;
await dist.publishRoot(tree.root, 0, 1000, expiry, false);
await owner.sendTransaction({ to: await dist.getAddress(), value: E("100") });
// too early
await expect(dist.clawbackUnclaimed(false)).to.be.revertedWithCustomError(dist, "ClawbackTooEarly");
// alice claims part
await time.increaseTo(now + 500);
await dist.claim(alice.address, E("100"), tree.proofFor(alice.address));
// past expiry -> clawback remainder to sink
await time.increaseTo(expiry + 1);
const sinkBefore = await ethers.provider.getBalance(await sink.getAddress());
const remaining = await ethers.provider.getBalance(await dist.getAddress());
await dist.clawbackUnclaimed(true);
const sinkAfter = await ethers.provider.getBalance(await sink.getAddress());
expect(sinkAfter - sinkBefore).to.equal(remaining);
expect(await ethers.provider.getBalance(await dist.getAddress())).to.equal(0);
});
it("sAERE-delivery path: mints sAERE shares to the claimant, no liquid AERE", async () => {
const entries = [{ account: alice.address, total: E("100") }];
const tree = makeTree(entries);
const now = await time.latest();
await dist.publishRoot(tree.root, 0, 10, now + 10_000_000, true); // deliverAsSAERE
await owner.sendTransaction({ to: await dist.getAddress(), value: E("100") });
await time.increase(20); // fully vested
const aereBefore = await ethers.provider.getBalance(alice.address);
await dist.connect(relayer).claim(alice.address, E("100"), tree.proofFor(alice.address));
const aereAfter = await ethers.provider.getBalance(alice.address);
// alice received sAERE shares, not liquid AERE
expect(aereAfter).to.equal(aereBefore); // relayer paid gas, alice got no native
const shares = await saere.balanceOf(alice.address);
expect(shares).to.be.gt(0);
// the vault holds the 100 WAERE now
expect(await waere.balanceOf(await saere.getAddress())).to.equal(E("100"));
// distributor holds no residual WAERE
expect(await waere.balanceOf(await dist.getAddress())).to.equal(0);
// redeemable value ~ 100 WAERE
expect(await saere.previewRedeem(shares)).to.be.closeTo(E("100"), E("0.001"));
});
it("emergency PAUSE blocks claims; unpause restores", async () => {
const entries = [{ account: alice.address, total: E("100") }];
const tree = makeTree(entries);
const now = await time.latest();
await dist.publishRoot(tree.root, 0, 10, now + 10_000_000, false);
await owner.sendTransaction({ to: await dist.getAddress(), value: E("100") });
await time.increase(20);
await dist.pause();
await expect(dist.claim(alice.address, E("100"), tree.proofFor(alice.address))).to.be.revertedWith(
"Pausable: paused"
);
await dist.unpause();
await dist.claim(alice.address, E("100"), tree.proofFor(alice.address));
expect(await dist.claimed(alice.address)).to.equal(E("100"));
});
it("root is re-publishable before first claim, frozen after", async () => {
const t1 = makeTree([{ account: alice.address, total: E("100") }]);
const now = await time.latest();
await dist.publishRoot(t1.root, 0, 1000, now + 10_000_000, false);
// re-publish (fix) allowed pre-claim
const t2 = makeTree([{ account: alice.address, total: E("80") }, { account: bob.address, total: E("20") }]);
await dist.publishRoot(t2.root, 0, 10, now + 10_000_000, false);
await owner.sendTransaction({ to: await dist.getAddress(), value: E("100") });
await time.increase(20);
await dist.claim(alice.address, E("80"), t2.proofFor(alice.address));
// now frozen
await expect(dist.publishRoot(t1.root, 0, 1000, now + 10_000_000, false)).to.be.revertedWithCustomError(
dist,
"RootFrozen"
);
});
it("publishRoot input validation", async () => {
const t = makeTree([{ account: alice.address, total: E("1") }]);
const now = await time.latest();
await expect(dist.publishRoot(ethers.ZeroHash, 0, 10, now + 1000, false)).to.be.revertedWithCustomError(dist, "ZeroRoot");
await expect(dist.publishRoot(t.root, 10, 5, now + 1000, false)).to.be.revertedWithCustomError(dist, "BadVesting"); // cliff>duration
await expect(dist.publishRoot(t.root, 0, 0, now + 1000, false)).to.be.revertedWithCustomError(dist, "BadVesting"); // zero duration
await expect(dist.publishRoot(t.root, 0, 1000, now + 500, false)).to.be.revertedWithCustomError(dist, "BadExpiry"); // expiry<start+duration
});
it("only owner can publish / exclude / pause / clawback", async () => {
const t = makeTree([{ account: alice.address, total: E("1") }]);
const now = await time.latest();
await expect(dist.connect(alice).publishRoot(t.root, 0, 10, now + 1000, false)).to.be.revertedWith(
"Ownable: caller is not the owner"
);
await expect(dist.connect(alice).setExcluded(bob.address, true)).to.be.revertedWith(
"Ownable: caller is not the owner"
);
await expect(dist.connect(alice).pause()).to.be.revertedWith("Ownable: caller is not the owner");
await expect(dist.connect(alice).clawbackUnclaimed(false)).to.be.revertedWith(
"Ownable: caller is not the owner"
);
});
});