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