From 041e10d8450874b074e57635a6bfdadbba06c19a Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 13:45:22 +0000 Subject: [PATCH 1/2] [#1304] Add TWAP minimum samples validation Finalize script now requires >=5 daily price samples for TWAP (throws with clear message if insufficient). Warns for 5-6 samples (expected 7). Operator override via AIRDROP_FINALIZE_ALLOW_PARTIAL_TWAP=1 for emergency partial data. Closes #1304 Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 ++-- package.json | 2 +- scripts/airdrop-finalize.ts | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed88443..5bc95d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.41.0", + "version": "1.41.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.41.0", + "version": "1.41.1", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index eebae34..17ce46e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.41.0", + "version": "1.41.1", "private": true, "workspaces": [ "packages/*" diff --git a/scripts/airdrop-finalize.ts b/scripts/airdrop-finalize.ts index 0a82174..b5fb4dc 100644 --- a/scripts/airdrop-finalize.ts +++ b/scripts/airdrop-finalize.ts @@ -47,6 +47,20 @@ async function computeTwap(): Promise { if (error) throw new Error(`Failed to fetch daily prices: ${error.message}`); if (!data || data.length === 0) throw new Error("No daily price entries found for TWAP window"); + const EXPECTED_SAMPLES = 7; + const MINIMUM_SAMPLES = 5; + const OVERRIDE_ENV = "AIRDROP_FINALIZE_ALLOW_PARTIAL_TWAP"; + + if (data.length < MINIMUM_SAMPLES && process.env[OVERRIDE_ENV] !== "1") { + throw new Error( + `TWAP requires >=${MINIMUM_SAMPLES} daily samples, got ${data.length}. ` + + `Investigate pl_daily_prices cron coverage OR set ${OVERRIDE_ENV}=1 to proceed with partial data.`, + ); + } + if (data.length < EXPECTED_SAMPLES) { + console.warn(`Warning: TWAP using ${data.length} samples (expected ${EXPECTED_SAMPLES}). Verify pl_daily_prices cron coverage.`); + } + const sum = data.reduce((acc, row) => acc + Number(row.mcap_usd), 0); const twap = sum / data.length; console.log(`TWAP (${data.length} days): $${twap.toLocaleString()}`); From 97c85297966a0bd3f6def9d6b6921cec2407a1fd Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 13:47:44 +0000 Subject: [PATCH 2/2] [#1304] Extract TWAP validation helper with 7 boundary tests Extract validateTwapSamples to lib/airdrop/twap.ts for testability. 7 tests: 0 samples rejects, 1/4 rejects without override, 1 with override succeeds, 5/6 warn, 7 clean. Finalize script uses helper. Override env documented in airdrop-contracts.md settlement section. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/airdrop-contracts.md | 12 ++++++++++ lib/airdrop/twap.test.ts | 45 +++++++++++++++++++++++++++++++++++++ lib/airdrop/twap.ts | 27 ++++++++++++++++++++++ scripts/airdrop-finalize.ts | 21 ++++++----------- 4 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 lib/airdrop/twap.test.ts create mode 100644 lib/airdrop/twap.ts diff --git a/docs/airdrop-contracts.md b/docs/airdrop-contracts.md index 0e337a4..8e6ef98 100644 --- a/docs/airdrop-contracts.md +++ b/docs/airdrop-contracts.md @@ -43,3 +43,15 @@ forge script contracts/script/DeployMerkleClaim.s.sol \ --broadcast \ --private-key $DEPLOYER_PRIVATE_KEY ``` + +## Settlement (finalize) + +```bash +npx tsx scripts/airdrop-finalize.ts [--dry-run] +``` + +Emergency override for partial TWAP data (<5 daily price samples): + +```bash +AIRDROP_FINALIZE_ALLOW_PARTIAL_TWAP=1 npx tsx scripts/airdrop-finalize.ts +``` diff --git a/lib/airdrop/twap.test.ts b/lib/airdrop/twap.test.ts new file mode 100644 index 0000000..a092316 --- /dev/null +++ b/lib/airdrop/twap.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { validateTwapSamples } from "./twap"; + +describe("validateTwapSamples", () => { + it("rejects 0 samples", () => { + const result = validateTwapSamples(0, false); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("No daily price entries"); + }); + + it("rejects 1 sample without override", () => { + const result = validateTwapSamples(1, false); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("requires >=5"); + }); + + it("rejects 4 samples without override", () => { + const result = validateTwapSamples(4, false); + expect(result.ok).toBe(false); + }); + + it("allows 1 sample with override", () => { + const result = validateTwapSamples(1, true); + expect(result.ok).toBe(true); + if (result.ok) expect(result.warning).toBeDefined(); + }); + + it("allows 5 samples with warning", () => { + const result = validateTwapSamples(5, false); + expect(result.ok).toBe(true); + if (result.ok) expect(result.warning).toContain("5 samples"); + }); + + it("allows 6 samples with warning", () => { + const result = validateTwapSamples(6, false); + expect(result.ok).toBe(true); + if (result.ok) expect(result.warning).toContain("6 samples"); + }); + + it("allows 7 samples cleanly (no warning)", () => { + const result = validateTwapSamples(7, false); + expect(result.ok).toBe(true); + if (result.ok) expect(result.warning).toBeUndefined(); + }); +}); diff --git a/lib/airdrop/twap.ts b/lib/airdrop/twap.ts new file mode 100644 index 0000000..b4e1213 --- /dev/null +++ b/lib/airdrop/twap.ts @@ -0,0 +1,27 @@ +export const EXPECTED_SAMPLES = 7; +export const MINIMUM_SAMPLES = 5; +export const OVERRIDE_ENV = "AIRDROP_FINALIZE_ALLOW_PARTIAL_TWAP"; + +export function validateTwapSamples( + count: number, + overrideEnabled: boolean, +): { ok: true; warning?: string } | { ok: false; error: string } { + if (count === 0) { + return { ok: false, error: "No daily price entries found for TWAP window" }; + } + if (count < MINIMUM_SAMPLES && !overrideEnabled) { + return { + ok: false, + error: + `TWAP requires >=${MINIMUM_SAMPLES} daily samples, got ${count}. ` + + `Investigate pl_daily_prices cron coverage OR set ${OVERRIDE_ENV}=1 to proceed with partial data.`, + }; + } + if (count < EXPECTED_SAMPLES) { + return { + ok: true, + warning: `TWAP using ${count} samples (expected ${EXPECTED_SAMPLES}). Verify pl_daily_prices cron coverage.`, + }; + } + return { ok: true }; +} diff --git a/scripts/airdrop-finalize.ts b/scripts/airdrop-finalize.ts index b5fb4dc..58b4165 100644 --- a/scripts/airdrop-finalize.ts +++ b/scripts/airdrop-finalize.ts @@ -20,6 +20,7 @@ import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; import { writeFileSync } from "fs"; import { getAirdropConfig } from "../lib/airdrop/config"; import { weightedSpendQuery } from "../lib/airdrop/sql"; +import { validateTwapSamples, OVERRIDE_ENV } from "../lib/airdrop/twap"; const dryRun = process.argv.includes("--dry-run"); @@ -45,21 +46,13 @@ async function computeTwap(): Promise { .lte("recorded_at", endDate.toISOString().slice(0, 10)); if (error) throw new Error(`Failed to fetch daily prices: ${error.message}`); - if (!data || data.length === 0) throw new Error("No daily price entries found for TWAP window"); - const EXPECTED_SAMPLES = 7; - const MINIMUM_SAMPLES = 5; - const OVERRIDE_ENV = "AIRDROP_FINALIZE_ALLOW_PARTIAL_TWAP"; - - if (data.length < MINIMUM_SAMPLES && process.env[OVERRIDE_ENV] !== "1") { - throw new Error( - `TWAP requires >=${MINIMUM_SAMPLES} daily samples, got ${data.length}. ` + - `Investigate pl_daily_prices cron coverage OR set ${OVERRIDE_ENV}=1 to proceed with partial data.`, - ); - } - if (data.length < EXPECTED_SAMPLES) { - console.warn(`Warning: TWAP using ${data.length} samples (expected ${EXPECTED_SAMPLES}). Verify pl_daily_prices cron coverage.`); - } + const validation = validateTwapSamples( + data?.length ?? 0, + process.env[OVERRIDE_ENV] === "1", + ); + if (!validation.ok) throw new Error(validation.error); + if (validation.warning) console.warn(`Warning: ${validation.warning}`); const sum = data.reduce((acc, row) => acc + Number(row.mcap_usd), 0); const twap = sum / data.length;