Skip to content
98 changes: 98 additions & 0 deletions script/initial-distribution/src/abiChecker.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
11 changes: 8 additions & 3 deletions script/initial-distribution/src/abiChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type AbiEntry = ReadonlyArray<{
readonly outputs?: ReadonlyArray<unknown>;
}>;

function abiItemSignature(item: {
export function abiItemSignature(item: {
type: string;
name?: string;
inputs?: unknown[];
Expand All @@ -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[] }[];
Expand Down Expand Up @@ -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;
Comment thread
pkoch marked this conversation as resolved.
throw new Error(
[
"Missing artifact(s). Run `forge build` from the repo root.",
Expand All @@ -114,6 +118,7 @@ export function assertAbisMatchArtifacts(abis: {
...missing.map((e) => `- ${e.label}`),
].join("\n"),
);
}

const abiByLabel = {
CCA: abis.ccaAbi,
Expand Down
60 changes: 60 additions & 0 deletions script/initial-distribution/src/batch.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 2 additions & 2 deletions script/initial-distribution/src/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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()
Expand Down
72 changes: 3 additions & 69 deletions script/initial-distribution/src/cca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -234,72 +232,8 @@ function executeEntry(entry: DisbursementEntry): Promise<Hex> {
}

// ── 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 ────────
//
Expand Down
Loading