From dcc93545e9cccb43c25a8c6d17b0f134749c4310 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 10:12:49 +0000 Subject: [PATCH 1/5] =?UTF-8?q?[#1255]=20Refactor=20ClaimPanel=20=E2=86=92?= =?UTF-8?q?=20ClaimCard=20with=20v5=20breakdown=20+=20deadline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClaimCard handles 3 settlement variants: normal (proof + breakdown + claim), final-burn (burn tx link), not-eligible. Normal variant: breakdown from /projection (spend × multiplier), claim amount from /proof (authoritative), on-chain claimDeadline countdown with post-deadline disable. ClaimPanel becomes thin wrapper around ClaimCard mode="normal". Closes #1255 Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 +- package.json | 2 +- src/components/airdrop/ClaimCard.tsx | 222 +++++++++++++++++++++--- src/components/airdrop/ClaimPanel.tsx | 236 +------------------------- 4 files changed, 208 insertions(+), 256 deletions(-) diff --git a/package-lock.json b/package-lock.json index cd975b41..94d9056f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.39.0", + "version": "1.40.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.39.0", + "version": "1.40.0", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index f6ecd8c7..ea6f3a64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.39.0", + "version": "1.40.0", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/airdrop/ClaimCard.tsx b/src/components/airdrop/ClaimCard.tsx index 29ace462..eddea24c 100644 --- a/src/components/airdrop/ClaimCard.tsx +++ b/src/components/airdrop/ClaimCard.tsx @@ -1,5 +1,20 @@ "use client"; +import { useState, useEffect } from "react"; +import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { useQuery } from "@tanstack/react-query"; +import { formatUnits } from "viem"; +import { EXPLORER_URL } from "../../../lib/contracts/constants"; + +const MERKLE_CLAIM_ADDRESS = (process.env.NEXT_PUBLIC_MERKLE_CLAIM_ADDRESS ?? "") as `0x${string}`; +const FINAL_BURN_TX = process.env.NEXT_PUBLIC_AIRDROP_FINAL_BURN_TX ?? null; + +const MERKLE_CLAIM_ABI = [ + { type: "function", name: "claim", stateMutability: "nonpayable", inputs: [{ name: "amount", type: "uint256" }, { name: "proof", type: "bytes32[]" }], outputs: [] }, + { type: "function", name: "claimed", stateMutability: "view", inputs: [{ name: "", type: "address" }], outputs: [{ name: "", type: "bool" }] }, + { type: "function", name: "claimDeadline", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint256" }] }, +] as const; + interface ClaimCardProps { mode: "normal" | "final-burn"; finalState?: "sub_bronze" | "zero_recipient" | null; @@ -7,34 +22,203 @@ interface ClaimCardProps { export function ClaimCard({ mode, finalState }: ClaimCardProps) { if (mode === "final-burn") { - const message = finalState === "sub_bronze" - ? "Campaign ended below Bronze milestone. All tokens burned." - : finalState === "zero_recipient" - ? "No eligible recipients. All tokens burned." - : "Campaign ended. Watch for Season 2."; + return ; + } + return ; +} + +function FinalBurnCard({ finalState }: { finalState?: "sub_bronze" | "zero_recipient" | null }) { + const message = finalState === "sub_bronze" + ? "Campaign ended below Bronze milestone. All tokens burned." + : finalState === "zero_recipient" + ? "No eligible recipients. All tokens burned." + : "Campaign ended. Watch for Season 2."; + + return ( +
+

Campaign Complete

+

{message}

+ {FINAL_BURN_TX && ( + + View burn transaction + + )} +
+ ); +} + +function NormalClaimCard() { + const { address, isConnected } = useAccount(); + if (!isConnected || !address) { + return ( +
+

Connect your wallet to check your claim.

+
+ ); + } + + return ; +} + +function ClaimCardInner({ address }: { address: string }) { + const [txHash, setTxHash] = useState<`0x${string}` | null>(null); + const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); + + useEffect(() => { + const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000); + return () => clearInterval(id); + }, []); + + const { data: proofData, isLoading: proofLoading } = useQuery<{ + eligible: boolean; + amount: string | null; + proof: string[] | null; + }>({ + queryKey: ["airdrop-proof", address], + queryFn: async () => { + const res = await fetch(`/api/airdrop/proof?address=${address.toLowerCase()}`); + if (!res.ok) throw new Error("Failed to fetch proof"); + return res.json(); + }, + staleTime: Infinity, + }); + + const { data: projection } = useQuery<{ + buy_volume: number; + qualified_refs: number; + has_fc_bonus: boolean; + multiplier: number; + weighted_spend: number; + community_total: number; + }>({ + queryKey: ["airdrop-projection-claim", address], + queryFn: async () => { + const res = await fetch(`/api/airdrop/projection?address=${address.toLowerCase()}`); + if (!res.ok) return null; + return res.json(); + }, + staleTime: 60_000, + }); + + const { data: deadline } = useReadContract({ + address: MERKLE_CLAIM_ADDRESS || undefined, + abi: MERKLE_CLAIM_ABI, + functionName: "claimDeadline", + query: { enabled: !!MERKLE_CLAIM_ADDRESS }, + }); + + const { data: hasClaimed } = useReadContract({ + address: MERKLE_CLAIM_ADDRESS || undefined, + abi: MERKLE_CLAIM_ABI, + functionName: "claimed", + args: [address as `0x${string}`], + query: { enabled: !!proofData?.eligible && !!MERKLE_CLAIM_ADDRESS }, + }); + + const { writeContract, isPending: isClaiming } = useWriteContract(); + const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({ hash: txHash ?? undefined }); + + if (proofLoading) { return (
+

Checking claim eligibility...

+
+ ); + } + + if (!proofData?.eligible || !proofData.amount || !proofData.proof) { + return ( +

Campaign Complete

-

{message}

- {process.env.NEXT_PUBLIC_AIRDROP_FINAL_BURN_TX && ( - - View burn transaction - - )} +

You weren't eligible for this campaign.

); } + const amountFormatted = formatUnits(BigInt(proofData.amount), 18); + const amountDisplay = Number(amountFormatted).toLocaleString(undefined, { maximumFractionDigits: 2 }); + const alreadyClaimed = hasClaimed === true || isConfirmed; + + const deadlineTs = deadline ? Number(deadline) : null; + const pastDeadline = deadlineTs ? now > deadlineTs : false; + const timeLeft = deadlineTs && !pastDeadline ? deadlineTs - now : 0; + const daysLeft = Math.floor(timeLeft / 86400); + const hoursLeft = Math.floor((timeLeft % 86400) / 3600); + + const handleClaim = () => { + writeContract( + { + address: MERKLE_CLAIM_ADDRESS as `0x${string}`, + abi: MERKLE_CLAIM_ABI, + functionName: "claim", + args: [BigInt(proofData.amount!), proofData.proof! as `0x${string}`[]], + }, + { onSuccess: (hash) => setTxHash(hash) }, + ); + }; + return ( -
-

Claim Your PLOT

-

The campaign has ended. Claim your share below.

+
+

Claim Your PLOT

+ + {projection && ( +
+
Breakdown
+
+
{projection.buy_volume.toLocaleString()} PLOT spent × {projection.multiplier.toFixed(1)}× multiplier
+
+ ({projection.qualified_refs} refs{projection.has_fc_bonus ? " + FC bonus" : ""}) +
+
= {projection.weighted_spend.toLocaleString()} weighted spend
+
+
+ )} + +
+
+
Your claim
+
{amountDisplay} PLOT
+
+ + {deadlineTs && !pastDeadline && ( +
+ Claim window: {daysLeft}d {hoursLeft}h remaining +
+ )} + + {pastDeadline && !alreadyClaimed && ( +
Claim window closed.
+ )} + + {alreadyClaimed ? ( + + ) : ( + + )} +
); } diff --git a/src/components/airdrop/ClaimPanel.tsx b/src/components/airdrop/ClaimPanel.tsx index 7a7e4eef..b8e4e51a 100644 --- a/src/components/airdrop/ClaimPanel.tsx +++ b/src/components/airdrop/ClaimPanel.tsx @@ -1,239 +1,7 @@ "use client"; -import { useState } from "react"; -import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi"; -import { useQuery } from "@tanstack/react-query"; -import { formatUnits } from "viem"; -import { formatUsdValue } from "../../../lib/usd-price"; -import { EXPLORER_URL } from "../../../lib/contracts/constants"; - -const MERKLE_CLAIM_ADDRESS = (process.env.NEXT_PUBLIC_MERKLE_CLAIM_ADDRESS ?? "0x0000000000000000000000000000000000000000") as `0x${string}`; - -const MERKLE_CLAIM_ABI = [ - { - type: "function", - name: "claim", - stateMutability: "nonpayable", - inputs: [ - { name: "amount", type: "uint256" }, - { name: "proof", type: "bytes32[]" }, - ], - outputs: [], - }, - { - type: "function", - name: "claimed", - stateMutability: "view", - inputs: [{ name: "", type: "address" }], - outputs: [{ name: "", type: "bool" }], - }, -] as const; - -interface ResultsData { - finalized: boolean; - milestone: string; - distributedPct: number; - distributedPlot: number; - burnedPlot: number; - recipients: number; -} - -const BURN_TX = process.env.NEXT_PUBLIC_BURN_TX ?? null; - -function CampaignResults() { - const { data } = useQuery({ - queryKey: ["airdrop-results"], - queryFn: async () => { - const res = await fetch("/api/airdrop/results"); - if (!res.ok) throw new Error("Failed to fetch results"); - return res.json(); - }, - staleTime: Infinity, - }); - - if (!data || !data.finalized) return null; - - return ( -
-
- CAMPAIGN COMPLETE -
-
-
- Milestone achieved:{" "} - - {data.milestone === "None" ? "None — full burn" : `${data.milestone} (${data.distributedPct}%)`} - -
-
- Distributed:{" "} - {data.distributedPlot.toLocaleString()} PLOT - to {data.recipients} recipients -
-
- Burned:{" "} - {data.burnedPlot.toLocaleString()} PLOT -
- {BURN_TX && ( - - View burn transaction - - )} -
-
- ); -} - -interface ProofData { - eligible: boolean; - amount: string | null; - proof: string[] | null; - merkleRoot: string | null; -} - -interface StatusData { - latestPriceUsd: number | null; -} +import { ClaimCard } from "./ClaimCard"; export function ClaimPanel() { - const { address, isConnected } = useAccount(); - - return ( - <> - - {!isConnected || !address ? ( -
-

Connect your wallet to check your claim.

-
- ) : ( - - )} - - ); -} - -function ClaimPanelInner({ address }: { address: string }) { - const [txHash, setTxHash] = useState<`0x${string}` | null>(null); - - // Fetch proof from API - const { data: proofData, isLoading } = useQuery({ - queryKey: ["airdrop-proof", address], - queryFn: async () => { - const res = await fetch(`/api/airdrop/proof?address=${address.toLowerCase()}`); - if (!res.ok) throw new Error("Failed to fetch proof"); - return res.json(); - }, - staleTime: Infinity, - }); - - // Fetch price for USD display - const { data: statusData } = useQuery({ - queryKey: ["airdrop-status"], - queryFn: async () => { - const res = await fetch("/api/airdrop/status"); - if (!res.ok) throw new Error("Failed to fetch status"); - return res.json(); - }, - staleTime: 60_000, - }); - - // Check on-chain claimed status - const { data: hasClaimed } = useReadContract({ - address: MERKLE_CLAIM_ADDRESS, - abi: MERKLE_CLAIM_ABI, - functionName: "claimed", - args: [address as `0x${string}`], - query: { - enabled: !!proofData?.eligible, - }, - }); - - // Write contract for claim - const { writeContract, isPending: isClaiming } = useWriteContract(); - - // Wait for tx confirmation - const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({ - hash: txHash ?? undefined, - }); - - if (isLoading || !proofData) { - return ( -
-
Checking claim eligibility...
-
- ); - } - - // Not eligible - if (!proofData.eligible || !proofData.amount || !proofData.proof) { - return ( -
-
Campaign Complete
-

You did not earn any PLOT in this campaign.

-
- ); - } - - const amountFormatted = formatUnits(BigInt(proofData.amount), 18); - const amountDisplay = Number(amountFormatted).toLocaleString(undefined, { maximumFractionDigits: 2 }); - const price = statusData?.latestPriceUsd ?? null; - const usdValue = price ? formatUsdValue(Number(amountFormatted) * price) : null; - - const alreadyClaimed = hasClaimed === true || isConfirmed; - - const handleClaim = () => { - writeContract( - { - address: MERKLE_CLAIM_ADDRESS, - abi: MERKLE_CLAIM_ABI, - functionName: "claim", - args: [BigInt(proofData.amount!), proofData.proof! as `0x${string}`[]], - }, - { - onSuccess: (hash) => setTxHash(hash), - }, - ); - }; - - return ( -
-
Claim Your PLOT
- -
-
-
You earned
-
{usdValue || `${amountDisplay} PLOT`}
- {usdValue &&
({amountDisplay} PLOT)
} -
- - {alreadyClaimed ? ( - - ) : ( - - )} -
-
- ); + return ; } From d165bd1a91b9227c98594252ecd70efdcaf6bf31 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 10:14:34 +0000 Subject: [PATCH 2/5] [#1255] Add >1% divergence guard for projection vs proof mismatch When /projection implied amount diverges >1% from /proof amount, breakdown is marked stale/unavailable instead of showing potentially misleading numbers. Claim button remains actionable with /proof amount (authoritative). Normal case shows full breakdown. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/airdrop/ClaimCard.tsx | 40 +++++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/components/airdrop/ClaimCard.tsx b/src/components/airdrop/ClaimCard.tsx index eddea24c..e265379f 100644 --- a/src/components/airdrop/ClaimCard.tsx +++ b/src/components/airdrop/ClaimCard.tsx @@ -167,18 +167,38 @@ function ClaimCardInner({ address }: { address: string }) {

Claim Your PLOT

- {projection && ( -
-
Breakdown
-
-
{projection.buy_volume.toLocaleString()} PLOT spent × {projection.multiplier.toFixed(1)}× multiplier
-
- ({projection.qualified_refs} refs{projection.has_fc_bonus ? " + FC bonus" : ""}) + {projection && (() => { + const proofAmountNum = Number(amountFormatted); + const projectedShare = projection.community_total > 0 + ? projection.weighted_spend / projection.community_total + : 0; + const impliedAmount = projectedShare > 0 ? projectedShare : 0; + const delta = proofAmountNum > 0 && impliedAmount > 0 + ? Math.abs(proofAmountNum - impliedAmount) / proofAmountNum + : 0; + const isStale = delta > 0.01 && impliedAmount > 0; + + return ( +
+
+ Breakdown {isStale && (stale — may not reflect final settlement)}
-
= {projection.weighted_spend.toLocaleString()} weighted spend
+ {!isStale ? ( +
+
{projection.buy_volume.toLocaleString()} PLOT spent × {projection.multiplier.toFixed(1)}× multiplier
+
+ ({projection.qualified_refs} refs{projection.has_fc_bonus ? " + FC bonus" : ""}) +
+
= {projection.weighted_spend.toLocaleString()} weighted spend
+
+ ) : ( +
+ Breakdown unavailable — claim amount below is from the finalized settlement. +
+ )}
-
- )} + ); + })()}
From 4ace3c40cb4e72481f4852444a99beb97c3a8b1b Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 10:16:31 +0000 Subject: [PATCH 3/5] [#1255] Fix divergence guard: explanatory breakdown + authoritative claim Remove broken unit-mismatch comparison. Breakdown always shown as explanatory ("How your share was calculated") with clear note that final claim amount is from the on-chain settlement. Claim button always driven by /proof.amount (authoritative). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/airdrop/ClaimCard.tsx | 41 ++++++++-------------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/src/components/airdrop/ClaimCard.tsx b/src/components/airdrop/ClaimCard.tsx index e265379f..1bb1f610 100644 --- a/src/components/airdrop/ClaimCard.tsx +++ b/src/components/airdrop/ClaimCard.tsx @@ -167,38 +167,19 @@ function ClaimCardInner({ address }: { address: string }) {

Claim Your PLOT

- {projection && (() => { - const proofAmountNum = Number(amountFormatted); - const projectedShare = projection.community_total > 0 - ? projection.weighted_spend / projection.community_total - : 0; - const impliedAmount = projectedShare > 0 ? projectedShare : 0; - const delta = proofAmountNum > 0 && impliedAmount > 0 - ? Math.abs(proofAmountNum - impliedAmount) / proofAmountNum - : 0; - const isStale = delta > 0.01 && impliedAmount > 0; - - return ( -
-
- Breakdown {isStale && (stale — may not reflect final settlement)} + {projection && ( +
+
How your share was calculated
+
+
{projection.buy_volume.toLocaleString()} PLOT spent × {projection.multiplier.toFixed(1)}× multiplier
+
+ ({projection.qualified_refs} refs{projection.has_fc_bonus ? " + FC bonus" : ""})
- {!isStale ? ( -
-
{projection.buy_volume.toLocaleString()} PLOT spent × {projection.multiplier.toFixed(1)}× multiplier
-
- ({projection.qualified_refs} refs{projection.has_fc_bonus ? " + FC bonus" : ""}) -
-
= {projection.weighted_spend.toLocaleString()} weighted spend
-
- ) : ( -
- Breakdown unavailable — claim amount below is from the finalized settlement. -
- )} +
= {projection.weighted_spend.toLocaleString()} weighted spend
- ); - })()} +
Final claim amount below is from the on-chain settlement.
+
+ )}
From 7852e3406cb78e9582682e186f440ed1ea3909b0 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 10:17:45 +0000 Subject: [PATCH 4/5] [#1255] Label breakdown as live/non-final per RE1 requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breakdown header now reads "Live contribution breakdown (may not reflect final settlement)" — explicitly non-final. Claim amount from /proof remains the only finalized value. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/airdrop/ClaimCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/airdrop/ClaimCard.tsx b/src/components/airdrop/ClaimCard.tsx index 1bb1f610..c38476bb 100644 --- a/src/components/airdrop/ClaimCard.tsx +++ b/src/components/airdrop/ClaimCard.tsx @@ -169,7 +169,7 @@ function ClaimCardInner({ address }: { address: string }) { {projection && (
-
How your share was calculated
+
Live contribution breakdown (may not reflect final settlement)
{projection.buy_volume.toLocaleString()} PLOT spent × {projection.multiplier.toFixed(1)}× multiplier
From 4ad7ab328214c5f353cba0fb925fe6113595ad58 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 10:26:47 +0000 Subject: [PATCH 5/5] [#1255] Fix lint: replace unescaped apostrophe Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/airdrop/ClaimCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/airdrop/ClaimCard.tsx b/src/components/airdrop/ClaimCard.tsx index c38476bb..9bc5edfc 100644 --- a/src/components/airdrop/ClaimCard.tsx +++ b/src/components/airdrop/ClaimCard.tsx @@ -136,7 +136,7 @@ function ClaimCardInner({ address }: { address: string }) { return (

Campaign Complete

-

You weren't eligible for this campaign.

+

You were not eligible for this campaign.

); }