From 3e4dae57172d1eeb8d51b61944c40deb73b07666 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 07:44:33 +0000 Subject: [PATCH 1/2] =?UTF-8?q?[#1248]=20Rewrite=20airdrop-finalize.ts=20p?= =?UTF-8?q?er=20=C2=A75.2=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume weighted_spend RPC (T2.4b shared helper) for per-wallet shares. Sub-Bronze exits with full-burn log, no Merkle deploy. Zero-total exits cleanly. Normal case: Merkle tree + proofs + per-wallet CSV. --dry-run flag skips DB writes. No claimDeadline computation (operator decision at T6.2). Closes #1248 Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 +- package.json | 2 +- scripts/airdrop-finalize.ts | 245 +++++++++++++----------------------- 3 files changed, 92 insertions(+), 159 deletions(-) diff --git a/package-lock.json b/package-lock.json index cc72ced8..f38f9c1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.34.0", + "version": "1.34.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.34.0", + "version": "1.34.1", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 4b9265f5..2c86e178 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.34.0", + "version": "1.34.1", "private": true, "workspaces": [ "packages/*" diff --git a/scripts/airdrop-finalize.ts b/scripts/airdrop-finalize.ts index dda656fb..4e77b273 100644 --- a/scripts/airdrop-finalize.ts +++ b/scripts/airdrop-finalize.ts @@ -1,15 +1,15 @@ #!/usr/bin/env npx tsx /** - * Airdrop finalize script (#893) + * Airdrop finalize script (v5) * * 1. Compute 7-day TWAP from pl_daily_prices - * 2. Determine milestone tier - * 3. Calculate per-user distribution + * 2. Determine milestone tier → released_pool + * 3. Call weighted_spend helper for per-wallet shares * 4. Generate Merkle tree + proofs - * 5. Output root hash + proof JSON for claim contract deployment + * 5. Output root hash for T6.2 contract deploy * * Usage: - * npx tsx scripts/airdrop-finalize.ts + * npx tsx scripts/airdrop-finalize.ts [--dry-run] * * Requires: NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY */ @@ -18,7 +18,9 @@ import { createClient } from "@supabase/supabase-js"; import { parseUnits } from "viem"; import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; import { writeFileSync } from "fs"; -import { AIRDROP_CONFIG } from "../lib/airdrop/config"; +import { getAirdropConfig } from "../lib/airdrop/config"; + +const dryRun = process.argv.includes("--dry-run"); const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; @@ -29,13 +31,10 @@ if (!SUPABASE_URL || !SUPABASE_KEY) { } const supabase = createClient(SUPABASE_URL, SUPABASE_KEY); - -// --------------------------------------------------------------------------- -// Step 1: TWAP Calculation -// --------------------------------------------------------------------------- +const config = getAirdropConfig(); async function computeTwap(): Promise { - const endDate = AIRDROP_CONFIG.CAMPAIGN_END; + const endDate = config.CAMPAIGN_END; const startDate = new Date(endDate.getTime() - 7 * 86400000); const { data, error } = await supabase @@ -44,159 +43,96 @@ async function computeTwap(): Promise { .gte("recorded_at", startDate.toISOString().slice(0, 10)) .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"); - } + 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 sum = data.reduce((acc, row) => acc + Number(row.mcap_usd), 0); const twap = sum / data.length; - console.log(`TWAP (${data.length} days): $${twap.toLocaleString()}`); return twap; } -// --------------------------------------------------------------------------- -// Step 2: Milestone Determination -// --------------------------------------------------------------------------- - function determineMilestone(twapMcap: number): { tier: string; pct: number } { - const { MILESTONES } = AIRDROP_CONFIG; - - if (twapMcap >= MILESTONES.DIAMOND.mcap) { - return { tier: "\uD83D\uDC8E Diamond", pct: MILESTONES.DIAMOND.pct }; - } - if (twapMcap >= MILESTONES.GOLD.mcap) { - return { tier: "\uD83E\uDD47 Gold", pct: MILESTONES.GOLD.pct }; - } - if (twapMcap >= MILESTONES.SILVER.mcap) { - return { tier: "\uD83E\uDD48 Silver", pct: MILESTONES.SILVER.pct }; - } - if (twapMcap >= MILESTONES.BRONZE.mcap) { - return { tier: "\uD83E\uDD49 Bronze", pct: MILESTONES.BRONZE.pct }; - } + const { MILESTONES } = config; + if (twapMcap >= MILESTONES.DIAMOND.mcap) return { tier: "Diamond", pct: MILESTONES.DIAMOND.pct }; + if (twapMcap >= MILESTONES.GOLD.mcap) return { tier: "Gold", pct: MILESTONES.GOLD.pct }; + if (twapMcap >= MILESTONES.SILVER.mcap) return { tier: "Silver", pct: MILESTONES.SILVER.pct }; + if (twapMcap >= MILESTONES.BRONZE.mcap) return { tier: "Bronze", pct: MILESTONES.BRONZE.pct }; return { tier: "None", pct: 0 }; } -// --------------------------------------------------------------------------- -// Step 3: Distribution Calculation -// --------------------------------------------------------------------------- - -async function computeDistribution( - milestonePct: number, -): Promise<{ address: string; amount: bigint }[]> { - const { data, error } = await supabase - .from("pl_points") - .select("address, points"); - - if (error) { - throw new Error(`Failed to fetch points: ${error.message}`); - } - - if (!data || data.length === 0) { - console.warn("No points found — distribution is empty"); - return []; - } - - // Aggregate points by address - const pointsByAddress = new Map(); - let totalPoints = 0; - for (const row of data) { - const addr = row.address.toLowerCase(); - pointsByAddress.set(addr, (pointsByAddress.get(addr) ?? 0) + row.points); - totalPoints += row.points; - } - - if (totalPoints === 0) { - console.warn("Total points is zero — distribution is empty"); - return []; - } - - const poolAmount = AIRDROP_CONFIG.POOL_AMOUNT; - const distributablePlot = poolAmount * (milestonePct / 100); +async function fetchWeightedSpend() { + const { data, error } = await supabase.rpc("weighted_spend", { + p_campaign_start: config.CAMPAIGN_START.toISOString(), + p_campaign_end: config.CAMPAIGN_END.toISOString(), + p_min_referral_threshold: config.MIN_REFERRAL_THRESHOLD, + p_multiplier_per_ref: config.REFERRAL_MULTIPLIER_PER_REF, + p_multiplier_cap: config.REFERRAL_MULTIPLIER_CAP, + }); + + if (error) throw new Error(`weighted_spend RPC failed: ${error.message}`); + return (data ?? []) as Array<{ + address: string; + weighted_spend: number; + community_total: number; + }>; +} - console.log(`Pool: ${poolAmount} PLOT, Milestone: ${milestonePct}%, Distributable: ${distributablePlot} PLOT`); - console.log(`Participants: ${pointsByAddress.size}, Total points: ${totalPoints}`); +function computeDistribution( + rows: Array<{ address: string; weighted_spend: number; community_total: number }>, + releasedPool: number, +): { address: string; amount: bigint }[] { + const communityTotal = Number(rows[0]?.community_total ?? 0); + if (communityTotal <= 0) return []; - // Exact total in wei - const totalWei = parseUnits(distributablePlot.toString(), 18); + const totalWei = parseUnits(releasedPool.toString(), 18); + const totalBig = BigInt(Math.round(communityTotal * 1e6)); - // Compute floor amounts and track remainders for largest-remainder allocation - // Pure bigint arithmetic to avoid Number precision loss - const totalPointsBig = BigInt(Math.round(totalPoints * 1e6)); - const entries: { address: string; floor: bigint; remainderBig: bigint }[] = []; + const entries: { address: string; floor: bigint; remainder: bigint }[] = []; let floorSum = BigInt(0); - for (const [addr, pts] of pointsByAddress) { - const ptsBig = BigInt(Math.round(pts * 1e6)); - // floor = (totalWei * pts) / totalPoints (integer division) - const floor = (totalWei * ptsBig) / totalPointsBig; - // remainder = (totalWei * pts) % totalPoints (for sorting) - const remainderBig = (totalWei * ptsBig) % totalPointsBig; - entries.push({ address: addr, floor, remainderBig }); + for (const row of rows) { + const ws = Number(row.weighted_spend); + if (ws <= 0) continue; + const wsBig = BigInt(Math.round(ws * 1e6)); + const floor = (totalWei * wsBig) / totalBig; + const remainder = (totalWei * wsBig) % totalBig; + entries.push({ address: row.address, floor, remainder }); floorSum += floor; } - // Distribute leftover wei to entries with largest remainders let leftover = totalWei - floorSum; - entries.sort((a, b) => (b.remainderBig > a.remainderBig ? 1 : b.remainderBig < a.remainderBig ? -1 : 0)); + entries.sort((a, b) => (b.remainder > a.remainder ? 1 : b.remainder < a.remainder ? -1 : 0)); for (const entry of entries) { if (leftover <= BigInt(0)) break; entry.floor += BigInt(1); leftover -= BigInt(1); } - const distribution: { address: string; amount: bigint }[] = entries - .filter((e) => e.floor > BigInt(0)) - .map((e) => ({ address: e.address, amount: e.floor })); - - // Sort by amount descending for easier verification - distribution.sort((a, b) => (b.amount > a.amount ? 1 : b.amount < a.amount ? -1 : 0)); - - return distribution; + return entries + .filter(e => e.floor > BigInt(0)) + .map(e => ({ address: e.address, amount: e.floor })) + .sort((a, b) => (b.amount > a.amount ? 1 : b.amount < a.amount ? -1 : 0)); } -// --------------------------------------------------------------------------- -// Step 4: Merkle Tree Generation -// --------------------------------------------------------------------------- - function generateMerkleTree(distribution: { address: string; amount: bigint }[]) { - if (distribution.length === 0) { - throw new Error("Cannot generate Merkle tree from empty distribution"); - } - - // StandardMerkleTree expects [address, uint256] leaf encoding - const values = distribution.map((d) => [d.address, d.amount.toString()]); + const values = distribution.map(d => [d.address, d.amount.toString()]); const tree = StandardMerkleTree.of(values, ["address", "uint256"]); - console.log(`Merkle root: ${tree.root}`); - - // Build proof map const proofs: Record = {}; for (const [i, v] of tree.entries()) { - proofs[v[0] as string] = { - amount: v[1] as string, - proof: tree.getProof(i), - }; + proofs[v[0] as string] = { amount: v[1] as string, proof: tree.getProof(i) }; } - return { root: tree.root, proofs, tree }; + return { root: tree.root, proofs }; } -// --------------------------------------------------------------------------- -// Step 5: Store Results -// --------------------------------------------------------------------------- - async function storeProofs( proofs: Record, root: string, tier: string, twap: number, ) { - // Write to JSON file for deployment const output = { generatedAt: new Date().toISOString(), twapMcap: twap, @@ -206,11 +142,14 @@ async function storeProofs( proofs, }; - const outputPath = "scripts/airdrop-proofs.json"; - writeFileSync(outputPath, JSON.stringify(output, null, 2)); - console.log(`Proofs written to ${outputPath}`); + writeFileSync("scripts/airdrop-proofs.json", JSON.stringify(output, null, 2)); + console.log("Proofs written to scripts/airdrop-proofs.json"); + + if (dryRun) { + console.log("[DRY RUN] Skipping DB writes"); + return; + } - // Also store proofs in DB for the claim API const entries = Object.entries(proofs).map(([address, { amount, proof }]) => ({ address, amount, @@ -218,68 +157,62 @@ async function storeProofs( merkle_root: root, })); - // Insert in batches of 100 for (let i = 0; i < entries.length; i += 100) { const batch = entries.slice(i, i + 100); - const { error } = await supabase.from("pl_airdrop_proofs").upsert(batch, { - onConflict: "address", - }); - if (error) { - console.error(`Failed to store proofs batch ${i}: ${error.message}`); - } + const { error } = await supabase.from("pl_airdrop_proofs").upsert(batch, { onConflict: "address" }); + if (error) console.error(`Failed to store proofs batch ${i}: ${error.message}`); } console.log(`${entries.length} proofs stored in pl_airdrop_proofs`); } -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - async function main() { - console.log("=== PLOT Airdrop Finalization ===\n"); + console.log(`=== PLOT Airdrop Finalization${dryRun ? " [DRY RUN]" : ""} ===\n`); - // Step 1: TWAP const twap = await computeTwap(); - - // Step 2: Milestone const { tier, pct } = determineMilestone(twap); console.log(`\nMilestone: ${tier} (${pct}%)`); if (pct === 0) { - console.log("\nNo milestone reached — all PLOT will be burned."); - console.log("Burn address: 0x000000000000000000000000000000000000dEaD"); - console.log(`Burn amount: ${AIRDROP_CONFIG.POOL_AMOUNT} PLOT`); + console.log("\n100% burn, 0 eligible. Sub-Bronze FDV — no Merkle deploy needed."); + console.log(`Burn amount: ${config.POOL_AMOUNT} PLOT`); + return; + } + + const releasedPool = config.POOL_AMOUNT * (pct / 100); + console.log(`Released pool: ${releasedPool.toLocaleString()} PLOT`); + + const rows = await fetchWeightedSpend(); + if (rows.length === 0) { + console.log("\nZero total weighted spend — no activated wallets bought. Full burn."); return; } - // Step 3: Distribution - const distribution = await computeDistribution(pct); + const distribution = computeDistribution(rows, releasedPool); if (distribution.length === 0) { - console.log("\nNo eligible participants."); + console.log("\nNo eligible participants after distribution. Full burn."); return; } - // Step 4: Merkle tree + console.log(`\naddress,plot_share_wei`); + for (const d of distribution) { + console.log(`${d.address},${d.amount.toString()}`); + } + const { root, proofs } = generateMerkleTree(distribution); + console.log(`\nMerkle root: ${root}`); - // Step 5: Store await storeProofs(proofs, root, tier, twap); - // Summary const burnPct = 100 - pct; - const burnAmount = AIRDROP_CONFIG.POOL_AMOUNT * (burnPct / 100); + const burnAmount = config.POOL_AMOUNT * (burnPct / 100); console.log("\n=== Summary ==="); console.log(`TWAP MCap: $${twap.toLocaleString()}`); console.log(`Milestone: ${tier} (${pct}% distributed)`); - console.log(`Distribute: ${(AIRDROP_CONFIG.POOL_AMOUNT * pct / 100).toLocaleString()} PLOT to ${distribution.length} addresses`); + console.log(`Distribute: ${releasedPool.toLocaleString()} PLOT to ${distribution.length} addresses`); console.log(`Burn: ${burnAmount.toLocaleString()} PLOT (${burnPct}%)`); console.log(`Merkle root: ${root}`); - console.log(`\nNext steps:`); - console.log(`1. Deploy MerkleClaim contract with root: ${root}`); - console.log(`2. Unlock PLOT from Mint Club Locker`); - console.log(`3. Transfer ${(AIRDROP_CONFIG.POOL_AMOUNT * pct / 100).toLocaleString()} PLOT to MerkleClaim contract`); - console.log(`4. Send ${burnAmount.toLocaleString()} PLOT to 0x000000000000000000000000000000000000dEaD`); + console.log(`\nNext: deploy MerkleClaim contract with this root.`); } main().catch((err) => { From 9501023435a201f6389f9d851e244a1d534d16e0 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 07:47:38 +0000 Subject: [PATCH 2/2] [#1248] Use shared helper params + fail-closed on proof batch errors Derive RPC params from weightedSpendQuery(config) instead of constructing inline. Proof batch upsert now throws on error (fail-closed) instead of logging and continuing. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/airdrop-finalize.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/scripts/airdrop-finalize.ts b/scripts/airdrop-finalize.ts index 4e77b273..0a82174f 100644 --- a/scripts/airdrop-finalize.ts +++ b/scripts/airdrop-finalize.ts @@ -19,6 +19,7 @@ import { parseUnits } from "viem"; import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; import { writeFileSync } from "fs"; import { getAirdropConfig } from "../lib/airdrop/config"; +import { weightedSpendQuery } from "../lib/airdrop/sql"; const dryRun = process.argv.includes("--dry-run"); @@ -62,12 +63,15 @@ function determineMilestone(twapMcap: number): { tier: string; pct: number } { } async function fetchWeightedSpend() { + const { params } = weightedSpendQuery(config); + const [campStart, campEnd, minThreshold, perRef, cap] = params; + const { data, error } = await supabase.rpc("weighted_spend", { - p_campaign_start: config.CAMPAIGN_START.toISOString(), - p_campaign_end: config.CAMPAIGN_END.toISOString(), - p_min_referral_threshold: config.MIN_REFERRAL_THRESHOLD, - p_multiplier_per_ref: config.REFERRAL_MULTIPLIER_PER_REF, - p_multiplier_cap: config.REFERRAL_MULTIPLIER_CAP, + p_campaign_start: String(campStart), + p_campaign_end: String(campEnd), + p_min_referral_threshold: Number(minThreshold), + p_multiplier_per_ref: Number(perRef), + p_multiplier_cap: Number(cap), }); if (error) throw new Error(`weighted_spend RPC failed: ${error.message}`); @@ -160,7 +164,7 @@ async function storeProofs( for (let i = 0; i < entries.length; i += 100) { const batch = entries.slice(i, i + 100); const { error } = await supabase.from("pl_airdrop_proofs").upsert(batch, { onConflict: "address" }); - if (error) console.error(`Failed to store proofs batch ${i}: ${error.message}`); + if (error) throw new Error(`Failed to store proofs batch ${i}: ${error.message}`); } console.log(`${entries.length} proofs stored in pl_airdrop_proofs`);