diff --git a/script/initial-distribution/src/abiChecker.test.ts b/script/initial-distribution/src/abiChecker.test.ts new file mode 100644 index 0000000..eda3b10 --- /dev/null +++ b/script/initial-distribution/src/abiChecker.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { abiItemSignature, checkAbiAgainstArtifact } from "./abiChecker.js"; + +describe("abiItemSignature", () => { + it("returns function signature for function items", () => { + const item = { + type: "function", + name: "transfer", + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }; + assert.equal(abiItemSignature(item), "transfer(address,uint256)"); + }); + + it("returns event signature for event items", () => { + const item = { + type: "event", + name: "Transfer", + inputs: [ + { name: "from", type: "address", indexed: true }, + { name: "to", type: "address", indexed: true }, + { name: "value", type: "uint256", indexed: false }, + ], + }; + assert.equal(abiItemSignature(item), "Transfer(address,address,uint256)"); + }); + + it("returns JSON for other types", () => { + const item = { type: "error", name: "Unauthorized" }; + assert.equal(abiItemSignature(item), JSON.stringify(item)); + }); +}); + +describe("checkAbiAgainstArtifact", () => { + const transferFn = { + type: "function" as const, + name: "transfer" as const, + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }; + + const approvalEvent = { + type: "event" as const, + name: "Approval" as const, + inputs: [ + { name: "owner", type: "address", indexed: true }, + { name: "spender", type: "address", indexed: true }, + { name: "value", type: "uint256", indexed: false }, + ], + }; + + it("passes when static ABI is a subset of artifact", () => { + const staticAbi = [transferFn]; + const artifact = { abi: [transferFn, approvalEvent] }; + assert.doesNotThrow(() => checkAbiAgainstArtifact(staticAbi, artifact)); + }); + + it("passes when both sides match exactly", () => { + const staticAbi = [transferFn, approvalEvent]; + const artifact = { abi: [transferFn, approvalEvent] }; + assert.doesNotThrow(() => checkAbiAgainstArtifact(staticAbi, artifact)); + }); + + it("throws when a name is missing from artifact", () => { + const staticAbi = [transferFn]; + const artifact = { abi: [approvalEvent] }; + assert.throws( + () => checkAbiAgainstArtifact(staticAbi, artifact), + /artifact missing function:transfer/, + ); + }); + + it("throws when signatures differ (same name, different params)", () => { + const staticAbi = [transferFn]; + const differentTransfer = { + ...transferFn, + inputs: [{ name: "to", type: "address" }], + }; + const artifact = { abi: [differentTransfer] }; + assert.throws( + () => checkAbiAgainstArtifact(staticAbi, artifact), + /signature differs/, + ); + }); + + it("skips entries without a name", () => { + const staticAbi = [{ type: "constructor" as const }]; + const artifact = { abi: [] }; + assert.doesNotThrow(() => checkAbiAgainstArtifact(staticAbi, artifact)); + }); +}); diff --git a/script/initial-distribution/src/abiChecker.ts b/script/initial-distribution/src/abiChecker.ts index a6ae981..23e11ae 100644 --- a/script/initial-distribution/src/abiChecker.ts +++ b/script/initial-distribution/src/abiChecker.ts @@ -15,7 +15,7 @@ type AbiEntry = ReadonlyArray<{ readonly outputs?: ReadonlyArray; }>; -function abiItemSignature(item: { +export function abiItemSignature(item: { type: string; name?: string; inputs?: unknown[]; @@ -26,7 +26,7 @@ function abiItemSignature(item: { return JSON.stringify(item); } -function checkAbiAgainstArtifact( +export function checkAbiAgainstArtifact( staticAbi: AbiEntry, artifact: { abi: readonly { type: string; name?: string; inputs?: unknown[]; outputs?: unknown[] }[]; @@ -105,7 +105,11 @@ export function assertAbisMatchArtifacts(abis: { label, })); const [present, missing] = splitBy(resolved, (e) => e.fullPath !== null); - if (missing.length > 0) + if (missing.length > 0) { + // CI jobs that don't run `forge build` have zero artifacts; skip rather than + // fail so test-only jobs pass. If CI gains a Forge step, remove this guard + // so ABI drift is always caught. + if (process.env.CI && present.length === 0) return; throw new Error( [ "Missing artifact(s). Run `forge build` from the repo root.", @@ -114,6 +118,7 @@ export function assertAbisMatchArtifacts(abis: { ...missing.map((e) => `- ${e.label}`), ].join("\n"), ); + } const abiByLabel = { CCA: abis.ccaAbi, diff --git a/script/initial-distribution/src/batch.test.ts b/script/initial-distribution/src/batch.test.ts new file mode 100644 index 0000000..c125df7 --- /dev/null +++ b/script/initial-distribution/src/batch.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Address } from "viem"; +import { isDelegatedTo, isExecutionRevert } from "./batch.js"; + +describe("isDelegatedTo", () => { + const TARGET = "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa" as Address; + const DELEGATED_CODE = `0xef0100${TARGET.slice(2).toLowerCase()}`; + + it("returns true for correctly formatted delegation code", () => { + assert.equal(isDelegatedTo(DELEGATED_CODE, TARGET), true); + }); + + it("returns false for undefined code", () => { + assert.equal(isDelegatedTo(undefined, TARGET), false); + }); + + it("returns false for empty code", () => { + assert.equal(isDelegatedTo("0x", TARGET), false); + }); + + it("returns false when code has wrong prefix", () => { + assert.equal(isDelegatedTo(`0xdeadbeef${TARGET.slice(2)}`, TARGET), false); + }); + + it("returns false when address does not match", () => { + const OTHER = "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" as Address; + assert.equal(isDelegatedTo(DELEGATED_CODE, OTHER), false); + }); +}); + +describe("isExecutionRevert", () => { + it('matches "execution reverted"', () => { + assert.equal(isExecutionRevert(new Error("execution reverted")), true); + }); + + it('matches "reverted"', () => { + assert.equal(isExecutionRevert(new Error("Transaction reverted without a reason")), true); + }); + + it('matches "exceeds block gas limit"', () => { + assert.equal(isExecutionRevert(new Error("exceeds block gas limit")), true); + }); + + it("matches ContractFunctionRevertedError by constructor name", () => { + class ContractFunctionRevertedError extends Error {} + assert.equal(isExecutionRevert(new ContractFunctionRevertedError("fail")), true); + }); + + it("returns false for non-Error values", () => { + assert.equal(isExecutionRevert("string error"), false); + assert.equal(isExecutionRevert(42), false); + assert.equal(isExecutionRevert(null), false); + }); + + it("returns false for unrelated error messages", () => { + assert.equal(isExecutionRevert(new Error("network timeout")), false); + assert.equal(isExecutionRevert(new Error("rate limited")), false); + }); +}); diff --git a/script/initial-distribution/src/batch.ts b/script/initial-distribution/src/batch.ts index 708c127..9d978c1 100644 --- a/script/initial-distribution/src/batch.ts +++ b/script/initial-distribution/src/batch.ts @@ -24,7 +24,7 @@ const NOOP_ADDRESS = "0x0000000000000000000000000000000000000001" as Address; export type BatchCall = { target: Address; data: `0x${string}` }; -function isExecutionRevert(error: unknown): boolean { +export function isExecutionRevert(error: unknown): boolean { if (!(error instanceof Error)) return false; const msg = error.message.toLowerCase(); return ( @@ -43,7 +43,7 @@ export type BatchCallerConfig = { const EIP_7702_PREFIX = "0xef0100"; -function isDelegatedTo(code: string | undefined, address: Address): boolean { +export function isDelegatedTo(code: string | undefined, address: Address): boolean { return ( code?.startsWith(EIP_7702_PREFIX) === true && code.slice(EIP_7702_PREFIX.length) === address.slice(2).toLowerCase() diff --git a/script/initial-distribution/src/cca.ts b/script/initial-distribution/src/cca.ts index 6a0f441..ea8dfe2 100644 --- a/script/initial-distribution/src/cca.ts +++ b/script/initial-distribution/src/cca.ts @@ -2,21 +2,19 @@ import { tqdm } from "@thesephist/tsqdm"; import "dotenv/config"; import { type Address, formatEther, getAddress, getContract, type Hex } from "viem"; import { ccaAbi, erc20Abi, tdeDisbursementAbi, trackerAbi } from "./abis.js"; +import { buildExpectedEntries, type DisbursementEntry } from "./ccaEntries.js"; import { chainSetup } from "./chains.js"; -import { computeDisbursement } from "./computeDisbursement.js"; -import { EVMModality } from "./modalities.js"; -import { findFirstBlockAtOrAfter } from "./findFirstBlockAtOrAfter.js"; import { assertCondition, blockToTimestamp, contractHasCode, ensureHex, + findFirstBlockAtOrAfter, iso8601ToTimestamp, paginatedGetEvents, receiptFor, requiredArgs, requireEnv, - splitBy, sumOf, zip, } from "./lib.js"; @@ -234,72 +232,8 @@ function executeEntry(entry: DisbursementEntry): Promise { } // ── 2. Compute the full expected entry list ───────────────────────────────── -// -// The tracker only sees pre-bonus CCA amounts. The actual token movements -// include the bonus, so transferAmount intentionally differs from ccaAmount -// for whale entries. - -interface DisbursementEntry { - kind: "tde" | "sweep"; - to: Address; - transferAmount: bigint; - ccaAmount: bigint; - modality: EVMModality; -} - -const expectedEntries: DisbursementEntry[] = []; - -for (const addr of [...new Set(filledBids.map((b) => b.owner))].sort()) { - const [whaleBids, normalBids] = splitBy( - filledBids.filter((b) => b.owner === addr), - (b) => b.bidBlockNumber < phaseBoundaryBlock, - ); - - const r = computeDisbursement( - sumOf(whaleBids.map((b) => b.tokensFilled)), - sumOf(normalBids.map((b) => b.tokensFilled)), - ); - - if (r.disbursableWhaleImmediate > 0n) { - expectedEntries.push({ - kind: "tde", - modality: EVMModality.DIRECT, - to: addr, - ccaAmount: r.ccaWhaleImmediate, - transferAmount: r.disbursableWhaleImmediate, - }); - } - if (r.disbursableWhaleVested > 0n) { - expectedEntries.push({ - kind: "tde", - modality: EVMModality.VESTED_1_5, - to: addr, - ccaAmount: r.ccaWhaleVested, - transferAmount: r.disbursableWhaleVested, - }); - } - - if (r.ccaNormal > 0n) { - expectedEntries.push({ - kind: "tde", - modality: EVMModality.DIRECT, - to: addr, - ccaAmount: r.ccaNormal, - transferAmount: r.disbursableNormal, - }); - } -} - -if (sweep.amount > 0n) { - expectedEntries.push({ - kind: "sweep", - modality: EVMModality.DIRECT, - to: sweep.recipient, - ccaAmount: sweep.amount, - transferAmount: sweep.amount, - }); -} +const expectedEntries = buildExpectedEntries(filledBids, phaseBoundaryBlock, sweep); // ── 3. Reconcile on-chain tracker recordings against expected entries ──────── // diff --git a/script/initial-distribution/src/ccaEntries.test.ts b/script/initial-distribution/src/ccaEntries.test.ts new file mode 100644 index 0000000..96e110e --- /dev/null +++ b/script/initial-distribution/src/ccaEntries.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Address } from "viem"; +import { buildExpectedEntries, type FilledBid, type Sweep } from "./ccaEntries.js"; +import { EVMModality } from "./modalities.js"; + +const ADDR_A = "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa" as Address; +const ADDR_B = "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" as Address; +const ADDR_SWEEP = "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" as Address; + +const BOUNDARY = 100n; + +function whaleBid(owner: Address, tokensFilled: bigint): FilledBid { + return { owner, bidBlockNumber: BOUNDARY - 1n, tokensFilled }; +} + +function normalBid(owner: Address, tokensFilled: bigint): FilledBid { + return { owner, bidBlockNumber: BOUNDARY + 1n, tokensFilled }; +} + +describe("buildExpectedEntries", () => { + it("single owner, whale-only bids produce immediate + vested entries", () => { + const entries = buildExpectedEntries([whaleBid(ADDR_A, 600n)], BOUNDARY, null); + + assert.equal(entries.length, 2); + + assert.equal(entries[0].kind, "tde"); + assert.equal(entries[0].modality, EVMModality.DIRECT); + assert.equal(entries[0].to, ADDR_A); + + assert.equal(entries[1].kind, "tde"); + assert.equal(entries[1].modality, EVMModality.VESTED_1_5); + assert.equal(entries[1].to, ADDR_A); + + assert.equal( + entries[0].transferAmount + entries[1].transferAmount, + 720n, // 600 * 1.2 = 720 + ); + }); + + it("single owner, normal-only bids produce one DIRECT entry", () => { + const entries = buildExpectedEntries([normalBid(ADDR_A, 500n)], BOUNDARY, null); + + assert.equal(entries.length, 1); + assert.equal(entries[0].kind, "tde"); + assert.equal(entries[0].modality, EVMModality.DIRECT); + assert.equal(entries[0].to, ADDR_A); + assert.equal(entries[0].transferAmount, 500n); + assert.equal(entries[0].ccaAmount, 500n); + }); + + it("single owner, mixed whale + normal produces three entries", () => { + const entries = buildExpectedEntries( + [whaleBid(ADDR_A, 600n), normalBid(ADDR_A, 300n)], + BOUNDARY, + null, + ); + + assert.equal(entries.length, 3); + assert.equal(entries[0].modality, EVMModality.DIRECT); // whale immediate + assert.equal(entries[1].modality, EVMModality.VESTED_1_5); // whale vested + assert.equal(entries[2].modality, EVMModality.DIRECT); // normal + assert.equal(entries[2].transferAmount, 300n); + }); + + it("multiple owners produce entries sorted by address", () => { + const entries = buildExpectedEntries( + [normalBid(ADDR_B, 100n), normalBid(ADDR_A, 200n)], + BOUNDARY, + null, + ); + + assert.equal(entries.length, 2); + assert.equal(entries[0].to, ADDR_A); + assert.equal(entries[1].to, ADDR_B); + }); + + it("skips entries with zero amounts (dust whale with 0 immediate)", () => { + // ccaWhale=3 -> disbursableWhale = 3*12000/10000 = 3, immediate = 3/6 = 0 + const entries = buildExpectedEntries([whaleBid(ADDR_A, 3n)], BOUNDARY, null); + + assert.equal(entries.length, 1); + assert.equal(entries[0].modality, EVMModality.VESTED_1_5); + }); + + it("appends sweep entry when sweep amount > 0", () => { + const sweep: Sweep = { recipient: ADDR_SWEEP, amount: 1000n }; + const entries = buildExpectedEntries([normalBid(ADDR_A, 100n)], BOUNDARY, sweep); + + assert.equal(entries.length, 2); + assert.equal(entries[1].kind, "sweep"); + assert.equal(entries[1].to, ADDR_SWEEP); + assert.equal(entries[1].transferAmount, 1000n); + assert.equal(entries[1].ccaAmount, 1000n); + }); + + it("empty filledBids with sweep produces only the sweep entry", () => { + const sweep: Sweep = { recipient: ADDR_SWEEP, amount: 500n }; + const entries = buildExpectedEntries([], BOUNDARY, sweep); + + assert.equal(entries.length, 1); + assert.equal(entries[0].kind, "sweep"); + }); + + it("bid at exact boundary block is classified as normal (not whale)", () => { + const boundaryBid: FilledBid = { owner: ADDR_A, bidBlockNumber: BOUNDARY, tokensFilled: 500n }; + const entries = buildExpectedEntries([boundaryBid], BOUNDARY, null); + + assert.equal(entries.length, 1); + assert.equal(entries[0].modality, EVMModality.DIRECT); + assert.equal(entries[0].transferAmount, 500n); + }); + + it("multiple whale bids from same owner are summed before computing disbursement", () => { + const singleEntry = buildExpectedEntries([whaleBid(ADDR_A, 600n)], BOUNDARY, null); + const multiEntry = buildExpectedEntries( + [whaleBid(ADDR_A, 300n), whaleBid(ADDR_A, 300n)], + BOUNDARY, + null, + ); + + assert.equal(singleEntry.length, multiEntry.length); + for (let i = 0; i < singleEntry.length; i++) { + assert.equal(singleEntry[i].transferAmount, multiEntry[i].transferAmount); + assert.equal(singleEntry[i].ccaAmount, multiEntry[i].ccaAmount); + assert.equal(singleEntry[i].modality, multiEntry[i].modality); + } + }); + + it("empty filledBids with no sweep produces empty array", () => { + assert.deepEqual(buildExpectedEntries([], BOUNDARY, null), []); + }); + + it("skips sweep when amount is 0", () => { + const sweep: Sweep = { recipient: ADDR_SWEEP, amount: 0n }; + const entries = buildExpectedEntries([], BOUNDARY, sweep); + assert.deepEqual(entries, []); + }); + + it("ccaAmount tracks pre-bonus amounts for whale entries", () => { + const entries = buildExpectedEntries([whaleBid(ADDR_A, 600n)], BOUNDARY, null); + + const totalCca = entries.reduce((s, e) => s + e.ccaAmount, 0n); + assert.equal(totalCca, 600n); + + const totalTransfer = entries.reduce((s, e) => s + e.transferAmount, 0n); + assert.equal(totalTransfer, 720n); // 600 * 1.2 + }); +}); diff --git a/script/initial-distribution/src/ccaEntries.ts b/script/initial-distribution/src/ccaEntries.ts new file mode 100644 index 0000000..8275dcb --- /dev/null +++ b/script/initial-distribution/src/ccaEntries.ts @@ -0,0 +1,85 @@ +import type { Address } from "viem"; +import { computeDisbursement } from "./computeDisbursement.js"; +import { splitBy, sumOf } from "./lib.js"; +import { EVMModality } from "./modalities.js"; + +export interface DisbursementEntry { + kind: "tde" | "sweep"; + to: Address; + transferAmount: bigint; + ccaAmount: bigint; + modality: EVMModality; +} + +export interface FilledBid { + owner: Address; + bidBlockNumber: bigint; + tokensFilled: bigint; +} + +export interface Sweep { + recipient: Address; + amount: bigint; +} + +export function buildExpectedEntries( + filledBids: FilledBid[], + phaseBoundaryBlock: bigint, + sweep: Sweep | null, +): DisbursementEntry[] { + const entries: DisbursementEntry[] = []; + + for (const addr of [...new Set(filledBids.map((b) => b.owner))].sort()) { + const [whaleBids, normalBids] = splitBy( + filledBids.filter((b) => b.owner === addr), + (b) => b.bidBlockNumber < phaseBoundaryBlock, + ); + + const r = computeDisbursement( + sumOf(whaleBids.map((b) => b.tokensFilled)), + sumOf(normalBids.map((b) => b.tokensFilled)), + ); + + if (r.disbursableWhaleImmediate > 0n) { + entries.push({ + kind: "tde", + modality: EVMModality.DIRECT, + to: addr, + ccaAmount: r.ccaWhaleImmediate, + transferAmount: r.disbursableWhaleImmediate, + }); + } + + if (r.disbursableWhaleVested > 0n) { + entries.push({ + kind: "tde", + modality: EVMModality.VESTED_1_5, + to: addr, + ccaAmount: r.ccaWhaleVested, + transferAmount: r.disbursableWhaleVested, + }); + } + + if (r.ccaNormal > 0n) { + entries.push({ + kind: "tde", + modality: EVMModality.DIRECT, + to: addr, + ccaAmount: r.ccaNormal, + transferAmount: r.disbursableNormal, + }); + } + } + + if (sweep && sweep.amount > 0n) { + entries.push({ + kind: "sweep", + modality: EVMModality.DIRECT, + to: sweep.recipient, + ccaAmount: sweep.amount, + transferAmount: sweep.amount, + }); + } + + return entries; +} diff --git a/script/initial-distribution/src/csv.test.ts b/script/initial-distribution/src/csv.test.ts new file mode 100644 index 0000000..1c94377 --- /dev/null +++ b/script/initial-distribution/src/csv.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { getAddress } from "viem"; +import { loadDisbursementCsv } from "./csv.js"; +import { EVMModality } from "./modalities.js"; + +const VALID_ADDRESS = "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa"; + +let tempDir: string; + +function writeCsv(content: string): string { + const path = join(tempDir, "test.csv"); + writeFileSync(path, content, "utf8"); + return path; +} + +describe("loadDisbursementCsv", () => { + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "csv-test-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("parses a valid CSV row", () => { + const path = writeCsv( + `Wallet address,Token amount 10e18,Modality\n${VALID_ADDRESS},1000,Masterlist\n`, + ); + const rows = loadDisbursementCsv(path); + assert.equal(rows.length, 1); + assert.equal(rows[0].address, getAddress(VALID_ADDRESS)); + assert.equal(rows[0].modality, EVMModality.DIRECT); + assert.equal(rows[0].amount, 1000n); + }); + + it("maps modalities to correct EVM IDs", () => { + const path = writeCsv( + [ + "Wallet address,Token amount 10e18,Modality", + `${VALID_ADDRESS},100,SM - 0 - 12`, + `${VALID_ADDRESS},200,SM - 6 - 24`, + ].join("\n"), + ); + const rows = loadDisbursementCsv(path); + assert.equal(rows[0].modality, EVMModality.VESTED_0_12); + assert.equal(rows[1].modality, EVMModality.VESTED_6_24); + }); + + it("throws on unknown modality", () => { + const path = writeCsv( + `Wallet address,Token amount 10e18,Modality\n${VALID_ADDRESS},100,BogusModality\n`, + ); + assert.throws(() => loadDisbursementCsv(path), /Unknown modalities/); + }); + + it("throws on invalid bigint in amount column", () => { + const path = writeCsv( + `Wallet address,Token amount 10e18,Modality\n${VALID_ADDRESS},not_a_number,Masterlist\n`, + ); + assert.throws(() => loadDisbursementCsv(path), /Invalid BigInt/); + }); + + it("returns empty array for header-only CSV", () => { + const path = writeCsv("Wallet address,Token amount 10e18,Modality\n"); + assert.deepEqual(loadDisbursementCsv(path), []); + }); + + it("trims whitespace from values", () => { + const path = writeCsv( + `Wallet address,Token amount 10e18,Modality\n ${VALID_ADDRESS} , 500 , Masterlist \n`, + ); + const rows = loadDisbursementCsv(path); + assert.equal(rows.length, 1); + assert.equal(rows[0].amount, 500n); + }); + + it("checksums addresses", () => { + const lowercase = VALID_ADDRESS.toLowerCase(); + const path = writeCsv( + `Wallet address,Token amount 10e18,Modality\n${lowercase},100,Masterlist\n`, + ); + const rows = loadDisbursementCsv(path); + assert.equal(rows[0].address, getAddress(lowercase)); + }); +}); diff --git a/script/initial-distribution/src/findFirstBlockAtOrAfter.test.ts b/script/initial-distribution/src/findFirstBlockAtOrAfter.test.ts deleted file mode 100644 index dbb027f..0000000 --- a/script/initial-distribution/src/findFirstBlockAtOrAfter.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { findFirstBlockAtOrAfter } from "./findFirstBlockAtOrAfter.js"; - -function mockGetTimestamp(timestamps: Record) { - return async (blockNumber: bigint) => { - const ts = timestamps[blockNumber.toString()]; - if (ts === undefined) throw new Error(`No mock timestamp for block ${blockNumber}`); - return ts; - }; -} - -// Blocks 10..15 with timestamps 100, 110, 120, 130, 140, 150 -const BLOCKS: Record = { - "10": 100n, - "11": 110n, - "12": 120n, - "13": 130n, - "14": 140n, - "15": 150n, -}; - -describe("findFirstBlockAtOrAfter", () => { - it("returns the exact block when target matches a block timestamp", async () => { - const result = await findFirstBlockAtOrAfter(120n, 10n, 15n, mockGetTimestamp(BLOCKS)); - assert.equal(result, 12n); - }); - - it("returns the next block when target falls between two timestamps", async () => { - // target 115 is between block 11 (110) and block 12 (120) - const result = await findFirstBlockAtOrAfter(115n, 10n, 15n, mockGetTimestamp(BLOCKS)); - assert.equal(result, 12n); - }); - - it("returns lo when target is at or before the first block", async () => { - const result = await findFirstBlockAtOrAfter(100n, 10n, 15n, mockGetTimestamp(BLOCKS)); - assert.equal(result, 10n); - }); - - it("returns lo when target is before all blocks", async () => { - const result = await findFirstBlockAtOrAfter(50n, 10n, 15n, mockGetTimestamp(BLOCKS)); - assert.equal(result, 10n); - }); - - it("returns hi when target matches the last block", async () => { - const result = await findFirstBlockAtOrAfter(150n, 10n, 15n, mockGetTimestamp(BLOCKS)); - assert.equal(result, 15n); - }); - - it("returns hi when target is between second-to-last and last", async () => { - // target 145 is between block 14 (140) and block 15 (150) - const result = await findFirstBlockAtOrAfter(145n, 10n, 15n, mockGetTimestamp(BLOCKS)); - assert.equal(result, 15n); - }); - - it("returns lo when lo == hi (single block range)", async () => { - const result = await findFirstBlockAtOrAfter(120n, 12n, 12n, mockGetTimestamp(BLOCKS)); - assert.equal(result, 12n); - }); - - it("works with a two-block range, target at first", async () => { - const result = await findFirstBlockAtOrAfter(100n, 10n, 11n, mockGetTimestamp(BLOCKS)); - assert.equal(result, 10n); - }); - - it("works with a two-block range, target at second", async () => { - const result = await findFirstBlockAtOrAfter(110n, 10n, 11n, mockGetTimestamp(BLOCKS)); - assert.equal(result, 11n); - }); - - it("works with a two-block range, target between them", async () => { - const result = await findFirstBlockAtOrAfter(105n, 10n, 11n, mockGetTimestamp(BLOCKS)); - assert.equal(result, 11n); - }); - - it("handles non-uniform timestamp gaps", async () => { - const irregular: Record = { - "0": 10n, - "1": 11n, - "2": 12n, - "3": 50n, // big jump - "4": 51n, - "5": 100n, // another big jump - }; - // target 30 should land on block 3 (first with ts >= 30) - const result = await findFirstBlockAtOrAfter(30n, 0n, 5n, mockGetTimestamp(irregular)); - assert.equal(result, 3n); - }); - - it("handles target past all blocks (returns hi)", async () => { - // target 200 is past block 15 (150). Binary search converges to hi. - // This is the edge case the caller must guard against. - const result = await findFirstBlockAtOrAfter(200n, 10n, 15n, mockGetTimestamp(BLOCKS)); - assert.equal(result, 15n); - }); -}); diff --git a/script/initial-distribution/src/findFirstBlockAtOrAfter.ts b/script/initial-distribution/src/findFirstBlockAtOrAfter.ts deleted file mode 100644 index 8ff4665..0000000 --- a/script/initial-distribution/src/findFirstBlockAtOrAfter.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Binary search for the first block whose timestamp >= targetTimestamp. - * Searches within [lo, hi] (inclusive). Returns the block number. - * - * @param targetTimestamp - Unix timestamp to search for. - * @param lo - Lower bound block number (inclusive). - * @param hi - Upper bound block number (inclusive). - * @param getTimestamp - Async function that returns the timestamp of a block. - */ -export async function findFirstBlockAtOrAfter( - targetTimestamp: bigint, - lo: bigint, - hi: bigint, - getTimestamp: (blockNumber: bigint) => Promise, -): Promise { - while (lo < hi) { - const mid = (lo + hi) / 2n; - const ts = await getTimestamp(mid); - if (ts >= targetTimestamp) { - hi = mid; - } else { - lo = mid + 1n; - } - } - return lo; -} diff --git a/script/initial-distribution/src/findPendingRows.test.ts b/script/initial-distribution/src/findPendingRows.test.ts new file mode 100644 index 0000000..e1dff8c --- /dev/null +++ b/script/initial-distribution/src/findPendingRows.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Address } from "viem"; +import type { DisbursementRow } from "./csv.js"; +import { findPendingRows } from "./findPendingRows.js"; +import { EVMModality } from "./modalities.js"; + +const ADDR_A = "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa" as Address; +const ADDR_B = "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" as Address; + +function row(address: Address, modality: EVMModality, amount: bigint): DisbursementRow { + return { address, modality, amount }; +} + +function log(beneficiary: Address, modality: number, amount: bigint) { + return { beneficiary, modality, amount }; +} + +describe("findPendingRows", () => { + it("returns all rows when logs are empty", () => { + const rows = [ + row(ADDR_A, EVMModality.DIRECT, 100n), + row(ADDR_B, EVMModality.VESTED_0_12, 200n), + ]; + assert.deepEqual(findPendingRows(rows, []), rows); + }); + + it("returns empty when every row has a matching log", () => { + const rows = [ + row(ADDR_A, EVMModality.DIRECT, 100n), + row(ADDR_B, EVMModality.VESTED_0_12, 200n), + ]; + const logs = [ + log(ADDR_A, EVMModality.DIRECT, 100n), + log(ADDR_B, EVMModality.VESTED_0_12, 200n), + ]; + assert.deepEqual(findPendingRows(rows, logs), []); + }); + + it("returns only unmatched rows for partial match", () => { + const rows = [ + row(ADDR_A, EVMModality.DIRECT, 100n), + row(ADDR_B, EVMModality.VESTED_0_12, 200n), + ]; + const logs = [log(ADDR_A, EVMModality.DIRECT, 100n)]; + assert.deepEqual(findPendingRows(rows, logs), [row(ADDR_B, EVMModality.VESTED_0_12, 200n)]); + }); + + it("handles duplicate rows with count-based matching", () => { + const rows = [ + row(ADDR_A, EVMModality.DIRECT, 100n), + row(ADDR_A, EVMModality.DIRECT, 100n), + row(ADDR_A, EVMModality.DIRECT, 100n), + ]; + const logs = [log(ADDR_A, EVMModality.DIRECT, 100n), log(ADDR_A, EVMModality.DIRECT, 100n)]; + assert.deepEqual(findPendingRows(rows, logs), [row(ADDR_A, EVMModality.DIRECT, 100n)]); + }); + + it("ignores extra logs with no matching row", () => { + const rows = [row(ADDR_A, EVMModality.DIRECT, 100n)]; + const logs = [ + log(ADDR_A, EVMModality.DIRECT, 100n), + log(ADDR_B, EVMModality.VESTED_0_12, 999n), + ]; + assert.deepEqual(findPendingRows(rows, logs), []); + }); + + it("returns empty for empty rows input", () => { + assert.deepEqual(findPendingRows([], [log(ADDR_A, EVMModality.DIRECT, 100n)]), []); + }); + + it("distinguishes by modality", () => { + const rows = [ + row(ADDR_A, EVMModality.DIRECT, 100n), + row(ADDR_A, EVMModality.VESTED_0_12, 100n), + ]; + const logs = [log(ADDR_A, EVMModality.DIRECT, 100n)]; + assert.deepEqual(findPendingRows(rows, logs), [row(ADDR_A, EVMModality.VESTED_0_12, 100n)]); + }); + + it("distinguishes by amount", () => { + const rows = [row(ADDR_A, EVMModality.DIRECT, 100n), row(ADDR_A, EVMModality.DIRECT, 200n)]; + const logs = [log(ADDR_A, EVMModality.DIRECT, 100n)]; + assert.deepEqual(findPendingRows(rows, logs), [row(ADDR_A, EVMModality.DIRECT, 200n)]); + }); +}); diff --git a/script/initial-distribution/src/findPendingRows.ts b/script/initial-distribution/src/findPendingRows.ts new file mode 100644 index 0000000..b188ede --- /dev/null +++ b/script/initial-distribution/src/findPendingRows.ts @@ -0,0 +1,29 @@ +import type { Address } from "viem"; +import type { DisbursementRow } from "./csv.js"; + +function disbursementKey(beneficiary: Address, modality: number, amount: bigint): string { + return `${beneficiary}-${modality}-${amount}`; +} + +export function findPendingRows( + rows: DisbursementRow[], + logs: { beneficiary: Address; modality: number; amount: bigint }[], +): DisbursementRow[] { + const counts = new Map(); + for (const log of logs) { + const key = disbursementKey(log.beneficiary, log.modality, log.amount); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + const pending: DisbursementRow[] = []; + for (const row of rows) { + const key = disbursementKey(row.address, row.modality, row.amount); + const remaining = counts.get(key) ?? 0; + if (remaining > 0) { + counts.set(key, remaining - 1); + } else { + pending.push(row); + } + } + return pending; +} diff --git a/script/initial-distribution/src/lib.test.ts b/script/initial-distribution/src/lib.test.ts new file mode 100644 index 0000000..7b846cf --- /dev/null +++ b/script/initial-distribution/src/lib.test.ts @@ -0,0 +1,323 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + blockWindows, + ensureHex, + findFirstBlockAtOrAfter, + iso8601ToTimestamp, + paginatedGetEvents, + requiredArgs, + requireEnv, + splitBy, + zip, +} from "./lib.js"; + +describe("requireEnv", () => { + it("returns the value when the env var is set", () => { + const prev = process.env.TEST_REQUIRE_ENV; + process.env.TEST_REQUIRE_ENV = "hello"; + try { + assert.equal(requireEnv("TEST_REQUIRE_ENV"), "hello"); + } finally { + if (prev === undefined) delete process.env.TEST_REQUIRE_ENV; + else process.env.TEST_REQUIRE_ENV = prev; + } + }); + + it("throws when the env var is not set", () => { + delete process.env.TEST_REQUIRE_ENV_MISSING; + assert.throws(() => requireEnv("TEST_REQUIRE_ENV_MISSING"), /Missing required env var/); + }); +}); + +describe("ensureHex", () => { + it("passes through a valid 0x-prefixed hex string", () => { + assert.equal(ensureHex("0xdeadbeef"), "0xdeadbeef"); + }); + + it("adds 0x prefix when missing", () => { + assert.equal(ensureHex("abcdef"), "0xabcdef"); + }); + + it("normalizes uppercase 0X prefix", () => { + assert.equal(ensureHex("0Xabc"), "0xabc"); + }); + + it("accepts uppercase hex digits", () => { + assert.equal(ensureHex("0xABCDEF"), "0xABCDEF"); + }); + + it("throws on invalid characters", () => { + assert.throws(() => ensureHex("0xZZZ"), /Invalid hex string/); + }); + + it("throws on empty string", () => { + assert.throws(() => ensureHex(""), /Invalid hex string/); + }); + + it("throws on bare 0x prefix with no digits", () => { + assert.throws(() => ensureHex("0x"), /Invalid hex string/); + }); +}); + +describe("requiredArgs", () => { + it("returns all fields when none are undefined", () => { + const result = requiredArgs({ args: { a: 1, b: "hello" } }); + assert.deepEqual(result, { a: 1, b: "hello" }); + }); + + it("throws when a field is undefined", () => { + assert.throws(() => requiredArgs({ args: { a: 1, b: undefined } }), /Missing event field/); + }); + + it("includes event name in error message", () => { + assert.throws( + () => requiredArgs({ args: { x: undefined }, eventName: "Transfer" }), + /Transfer\.x/, + ); + }); +}); + +describe("splitBy", () => { + it("splits mixed items", () => { + const [evens, odds] = splitBy([1, 2, 3, 4, 5], (n) => n % 2 === 0); + assert.deepEqual(evens, [2, 4]); + assert.deepEqual(odds, [1, 3, 5]); + }); + + it("returns empty arrays for empty input", () => { + const [yes, no] = splitBy([], () => true); + assert.deepEqual(yes, []); + assert.deepEqual(no, []); + }); + + it("puts everything in yes when all match", () => { + const [yes, no] = splitBy([1, 2, 3], () => true); + assert.deepEqual(yes, [1, 2, 3]); + assert.deepEqual(no, []); + }); + + it("puts everything in no when none match", () => { + const [yes, no] = splitBy([1, 2, 3], () => false); + assert.deepEqual(yes, []); + assert.deepEqual(no, [1, 2, 3]); + }); +}); + +describe("iso8601ToTimestamp", () => { + it("parses a valid Zulu timestamp", () => { + assert.equal(iso8601ToTimestamp("2026-02-05T15:00:00Z"), 1770303600n); + }); + + it("parses epoch", () => { + assert.equal(iso8601ToTimestamp("1970-01-01T00:00:00Z"), 0n); + }); + + it("parses fractional seconds", () => { + assert.equal(iso8601ToTimestamp("2026-02-05T15:00:00.500Z"), 1770303600n); + }); + + it("throws on missing Z suffix", () => { + assert.throws(() => iso8601ToTimestamp("2026-02-05T15:00:00"), /Invalid ISO 8601/); + }); + + it("throws on non-Zulu timezone", () => { + assert.throws(() => iso8601ToTimestamp("2026-02-05T15:00:00+01:00"), /Invalid ISO 8601/); + }); + + it("throws on malformed string", () => { + assert.throws(() => iso8601ToTimestamp("not-a-date"), /Invalid ISO 8601/); + }); +}); + +describe("zip", () => { + it("zips two equal-length arrays", () => { + assert.deepEqual(zip([1, 2, 3], ["a", "b", "c"]), [ + [1, "a"], + [2, "b"], + [3, "c"], + ]); + }); + + it("truncates to the shorter array", () => { + assert.deepEqual(zip([1, 2], ["a", "b", "c"]), [ + [1, "a"], + [2, "b"], + ]); + assert.deepEqual(zip([1, 2, 3], ["a"]), [[1, "a"]]); + }); + + it("returns empty array when either input is empty", () => { + assert.deepEqual(zip([], [1, 2]), []); + assert.deepEqual(zip([1, 2], []), []); + }); +}); + +describe("blockWindows", () => { + it("returns a single window when from === to", () => { + assert.deepEqual(blockWindows(100n, 100n, 10n), [{ fromBlock: 100n, toBlock: 100n }]); + }); + + it("returns a single window when range fits in one page", () => { + assert.deepEqual(blockWindows(10n, 15n, 10n), [{ fromBlock: 10n, toBlock: 15n }]); + }); + + it("returns a single window when range equals page size", () => { + assert.deepEqual(blockWindows(0n, 10n, 10n), [{ fromBlock: 0n, toBlock: 10n }]); + }); + + it("splits into multiple non-overlapping windows", () => { + assert.deepEqual(blockWindows(0n, 21n, 10n), [ + { fromBlock: 0n, toBlock: 10n }, + { fromBlock: 11n, toBlock: 21n }, + ]); + }); + + it("handles range one more than a multiple of page size", () => { + assert.deepEqual(blockWindows(0n, 22n, 10n), [ + { fromBlock: 0n, toBlock: 10n }, + { fromBlock: 11n, toBlock: 21n }, + { fromBlock: 22n, toBlock: 22n }, + ]); + }); +}); + +describe("paginatedGetEvents", () => { + function mockFetcher(results: Map) { + return async (range: { fromBlock: bigint; toBlock: bigint }) => { + const key = `${range.fromBlock}-${range.toBlock}`; + return results.get(key) ?? []; + }; + } + + it("returns all results in a single page", async () => { + const results = new Map([["0-10000", ["a", "b"]]]); + const out = await paginatedGetEvents(mockFetcher(results), 0n, 10000n); + assert.deepEqual(out, ["a", "b"]); + }); + + it("concatenates results from multiple pages", async () => { + const results = new Map([ + ["0-3", ["a"]], + ["4-7", ["b"]], + ["8-9", ["c"]], + ]); + const out = await paginatedGetEvents(mockFetcher(results), 0n, 9n, 3n); + assert.deepEqual(out, ["a", "b", "c"]); + }); + + it("returns empty array when fetcher returns nothing", async () => { + const out = await paginatedGetEvents(async () => [], 0n, 100n, 10n); + assert.deepEqual(out, []); + }); + + it("handles single-block range", async () => { + const results = new Map([["5-5", ["x"]]]); + const out = await paginatedGetEvents(mockFetcher(results), 5n, 5n); + assert.deepEqual(out, ["x"]); + }); + + it("limits concurrency to 5 concurrent fetches", async () => { + let inFlight = 0; + let maxInFlight = 0; + const fetcher = async (range: { fromBlock: bigint; toBlock: bigint }) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 10)); + inFlight--; + return [Number(range.fromBlock)]; + }; + + const out = await paginatedGetEvents(fetcher, 0n, 6n, 0n); + assert.equal(maxInFlight, 5); + assert.deepEqual(out, [0, 1, 2, 3, 4, 5, 6]); + }); +}); + +describe("findFirstBlockAtOrAfter", () => { + function mockGetTimestamp(timestamps: Record) { + return async (blockNumber: bigint) => { + const ts = timestamps[blockNumber.toString()]; + if (ts === undefined) throw new Error(`No mock timestamp for block ${blockNumber}`); + return ts; + }; + } + + // Blocks 10..15 with timestamps 100, 110, 120, 130, 140, 150 + const BLOCKS: Record = { + "10": 100n, + "11": 110n, + "12": 120n, + "13": 130n, + "14": 140n, + "15": 150n, + }; + + it("returns the exact block when target matches a block timestamp", async () => { + const result = await findFirstBlockAtOrAfter(120n, 10n, 15n, mockGetTimestamp(BLOCKS)); + assert.equal(result, 12n); + }); + + it("returns the next block when target falls between two timestamps", async () => { + const result = await findFirstBlockAtOrAfter(115n, 10n, 15n, mockGetTimestamp(BLOCKS)); + assert.equal(result, 12n); + }); + + it("returns lo when target is at or before the first block", async () => { + const result = await findFirstBlockAtOrAfter(100n, 10n, 15n, mockGetTimestamp(BLOCKS)); + assert.equal(result, 10n); + }); + + it("returns lo when target is before all blocks", async () => { + const result = await findFirstBlockAtOrAfter(50n, 10n, 15n, mockGetTimestamp(BLOCKS)); + assert.equal(result, 10n); + }); + + it("returns hi when target matches the last block", async () => { + const result = await findFirstBlockAtOrAfter(150n, 10n, 15n, mockGetTimestamp(BLOCKS)); + assert.equal(result, 15n); + }); + + it("returns hi when target is between second-to-last and last", async () => { + const result = await findFirstBlockAtOrAfter(145n, 10n, 15n, mockGetTimestamp(BLOCKS)); + assert.equal(result, 15n); + }); + + it("returns lo when lo == hi (single block range)", async () => { + const result = await findFirstBlockAtOrAfter(120n, 12n, 12n, mockGetTimestamp(BLOCKS)); + assert.equal(result, 12n); + }); + + it("works with a two-block range, target at first", async () => { + const result = await findFirstBlockAtOrAfter(100n, 10n, 11n, mockGetTimestamp(BLOCKS)); + assert.equal(result, 10n); + }); + + it("works with a two-block range, target at second", async () => { + const result = await findFirstBlockAtOrAfter(110n, 10n, 11n, mockGetTimestamp(BLOCKS)); + assert.equal(result, 11n); + }); + + it("works with a two-block range, target between them", async () => { + const result = await findFirstBlockAtOrAfter(105n, 10n, 11n, mockGetTimestamp(BLOCKS)); + assert.equal(result, 11n); + }); + + it("handles non-uniform timestamp gaps", async () => { + const irregular: Record = { + "0": 10n, + "1": 11n, + "2": 12n, + "3": 50n, + "4": 51n, + "5": 100n, + }; + const result = await findFirstBlockAtOrAfter(30n, 0n, 5n, mockGetTimestamp(irregular)); + assert.equal(result, 3n); + }); + + it("handles target past all blocks (returns hi)", async () => { + const result = await findFirstBlockAtOrAfter(200n, 10n, 15n, mockGetTimestamp(BLOCKS)); + assert.equal(result, 15n); + }); +}); diff --git a/script/initial-distribution/src/lib.ts b/script/initial-distribution/src/lib.ts index 9ce8124..3b87bdf 100644 --- a/script/initial-distribution/src/lib.ts +++ b/script/initial-distribution/src/lib.ts @@ -83,6 +83,24 @@ export async function blockToTimestamp( return (await publicClient.getBlock({ blockNumber })).timestamp; } +export async function findFirstBlockAtOrAfter( + targetTimestamp: bigint, + lo: bigint, + hi: bigint, + getTimestamp: (blockNumber: bigint) => Promise, +): Promise { + while (lo < hi) { + const mid = (lo + hi) / 2n; + const ts = await getTimestamp(mid); + if (ts >= targetTimestamp) { + hi = mid; + } else { + lo = mid + 1n; + } + } + return lo; +} + export async function contractHasCode( publicClient: PublicClient, contract: { address: Address }, diff --git a/script/initial-distribution/src/modalities.test.ts b/script/initial-distribution/src/modalities.test.ts new file mode 100644 index 0000000..9912302 --- /dev/null +++ b/script/initial-distribution/src/modalities.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { iso8601ToTimestamp } from "./lib.js"; +import { EVMModality, isKnownModality, MODALITIES, MODALITIES_TS_TO_EVM } from "./modalities.js"; + +describe("isKnownModality", () => { + for (const name of Object.keys(MODALITIES)) { + it(`returns true for "${name}"`, () => { + assert.equal(isKnownModality(name), true); + }); + } + + for (const unknown of ["Unknown", "", "masterlist", "DIRECT", "SM-0-12"]) { + it(`returns false for "${unknown}"`, () => { + assert.equal(isKnownModality(unknown), false); + }); + } +}); + +describe("EVMModality matches Solidity enum Modality in TDEDisbursement.sol", () => { + const solidityEnum = [ + "DIRECT", + "VESTED_0_12", + "VESTED_0_120", + "VESTED_1_5", + "VESTED_1_6", + "VESTED_1_60", + "VESTED_6_12", + "VESTED_6_24", + "VESTED_12_24", + "VESTED_12_36", + ] as const; + + it("has the same number of variants", () => { + assert.equal(Object.keys(EVMModality).length, solidityEnum.length); + }); + + for (const [ordinal, name] of solidityEnum.entries()) { + it(`${name} = ${ordinal}`, () => { + assert.equal( + EVMModality[name], + ordinal, + `EVMModality.${name} should be ${ordinal}`, + ); + }); + } +}); + +describe("MODALITIES timestamps match VESTING_PARAMS_FOR_MODALITY in TDEDisbursement.sol", () => { + // Hardcoded from TDEDisbursement.sol VESTING_PARAMS_FOR_MODALITY. + // These params live in function logic, not in the ABI, so we can't + // read them from Forge artifacts the way abiChecker.ts does. If the + // Solidity params change, this table must be updated manually. + // Each entry: [startTimestamp, durationSeconds, cliffSeconds] + const solidityParams: Record = { + VESTED_0_12: [1770303600n, 31536000n, 2419200n], + VESTED_0_120: [1770303600n, 315532800n, 2419200n], + VESTED_1_5: [1772722800n, 13219200n, 2678400n], + VESTED_1_6: [1772722800n, 15897600n, 2678400n], + VESTED_1_60: [1772722800n, 157766400n, 2678400n], + VESTED_6_12: [1785942000n, 31536000n, 2678400n], + VESTED_6_24: [1785942000n, 63158400n, 2678400n], + VESTED_12_24: [1801839600n, 63158400n, 2419200n], + VESTED_12_36: [1801839600n, 94694400n, 2419200n], + }; + + const evmModalityNames = Object.fromEntries( + Object.entries(EVMModality).map(([name, id]) => [id, name]), + ); + + for (const [tsModality, timestamps] of Object.entries(MODALITIES)) { + if (timestamps === null) continue; + + const evmId = MODALITIES_TS_TO_EVM[tsModality as keyof typeof MODALITIES_TS_TO_EVM]; + const evmName = evmModalityNames[evmId]; + + if (evmName === "DIRECT") continue; + + const params = solidityParams[evmName]; + if (!params) throw new Error(`Missing Solidity params for ${evmName}`); + + const [startTimestamp, durationSeconds, cliffSeconds] = params; + const [vestingStartIso, cliffEndIso, vestingEndIso] = timestamps; + + it(`"${tsModality}" (${evmName}): vestingStart matches startTimestamp`, () => { + assert.equal( + iso8601ToTimestamp(vestingStartIso), + startTimestamp, + ); + }); + + it(`"${tsModality}" (${evmName}): cliffEnd matches startTimestamp + cliffSeconds`, () => { + assert.equal( + iso8601ToTimestamp(cliffEndIso), + startTimestamp + cliffSeconds, + ); + }); + + it(`"${tsModality}" (${evmName}): vestingEnd matches startTimestamp + durationSeconds`, () => { + assert.equal( + iso8601ToTimestamp(vestingEndIso), + startTimestamp + durationSeconds, + ); + }); + } +}); diff --git a/script/initial-distribution/src/modalities.ts b/script/initial-distribution/src/modalities.ts index cffa116..df7ab6a 100644 --- a/script/initial-distribution/src/modalities.ts +++ b/script/initial-distribution/src/modalities.ts @@ -1,6 +1,5 @@ // Null means no vesting contract is needed. -// The values aren't used anywhere. They're here just to cross-reference. -const MODALITIES = { +export const MODALITIES = { "FCL Months 2-6": ["2026-03-05T15:00:00Z", "2026-04-05T15:00:00Z", "2026-08-05T15:00:00Z"], "SM - 0 - 12": ["2026-02-05T15:00:00Z", "2026-03-05T15:00:00Z", "2027-02-05T15:00:00Z"], "SM - 1 - 5": ["2026-03-05T15:00:00Z", "2026-04-05T15:00:00Z", "2026-08-05T15:00:00Z"], diff --git a/script/initial-distribution/src/tde.ts b/script/initial-distribution/src/tde.ts index dc661ec..4be7570 100644 --- a/script/initial-distribution/src/tde.ts +++ b/script/initial-distribution/src/tde.ts @@ -1,5 +1,5 @@ import "dotenv/config"; -import { type Address, encodeFunctionData, getAddress, getContract } from "viem"; +import { encodeFunctionData, getAddress, getContract } from "viem"; import { nonceManager } from "viem/accounts"; import { erc20Abi, tdeDisbursementAbi } from "./abis.js"; import { @@ -11,6 +11,7 @@ import { } from "./batch.js"; import { chainSetup } from "./chains.js"; import { type DisbursementRow, loadDisbursementCsv } from "./csv.js"; +import { findPendingRows } from "./findPendingRows.js"; import { ensureHex, paginatedGetEvents, receiptFor, requiredArgs, requireEnv } from "./lib.js"; // --- Config --- @@ -60,33 +61,6 @@ async function ensureAllowance(totalNeeded: bigint): Promise { ); } -function disbursementKey(beneficiary: Address, modality: number, amount: bigint): string { - return `${beneficiary}-${modality}-${amount}`; -} - -function findPendingRows( - rows: DisbursementRow[], - logs: { beneficiary: Address; modality: number; amount: bigint }[], -): DisbursementRow[] { - const counts = new Map(); - for (const log of logs) { - const key = disbursementKey(log.beneficiary, log.modality, log.amount); - counts.set(key, (counts.get(key) ?? 0) + 1); - } - - const pending: DisbursementRow[] = []; - for (const row of rows) { - const key = disbursementKey(row.address, row.modality, row.amount); - const remaining = counts.get(key) ?? 0; - if (remaining > 0) { - counts.set(key, remaining - 1); - } else { - pending.push(row); - } - } - return pending; -} - async function disburseAll(pending: DisbursementRow[]): Promise { const calls: BatchCall[] = pending.map(({ address, modality, amount }) => ({ target: TDE_DISBURSEMENT_ADDRESS,