diff --git a/src/app/airdrop/page.tsx b/src/app/airdrop/page.tsx index 7b068cd6..ddb35592 100644 --- a/src/app/airdrop/page.tsx +++ b/src/app/airdrop/page.tsx @@ -1,9 +1,11 @@ import type { Metadata } from "next"; import { CampaignHero } from "../../components/airdrop/CampaignHero"; import { UserPoints } from "../../components/airdrop/UserPoints"; +import { ClaimPanel } from "../../components/airdrop/ClaimPanel"; import { Leaderboard } from "../../components/airdrop/Leaderboard"; import { WeeklySnapshots } from "../../components/airdrop/WeeklySnapshots"; import { MilestoneTrack } from "../../components/airdrop/MilestoneTrack"; +import { AIRDROP_CONFIG } from "../../../lib/airdrop/config"; export const metadata: Metadata = { title: "PLOT 10x Airdrop | PlotLink", @@ -11,10 +13,12 @@ export const metadata: Metadata = { }; export default function AirdropPage() { + const campaignEnded = new Date() > AIRDROP_CONFIG.CAMPAIGN_END; + return (
- + {campaignEnded ? : } diff --git a/src/app/api/airdrop/proof/route.ts b/src/app/api/airdrop/proof/route.ts new file mode 100644 index 00000000..82f52cf5 --- /dev/null +++ b/src/app/api/airdrop/proof/route.ts @@ -0,0 +1,37 @@ +/** + * Merkle proof for airdrop claim (#894) + * GET /api/airdrop/proof?address=0x... + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; + +export async function GET(req: NextRequest) { + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + const address = req.nextUrl.searchParams.get("address")?.toLowerCase(); + if (!address) { + return NextResponse.json({ error: "Missing address param" }, { status: 400 }); + } + + const { data, error } = await supabase + .from("pl_airdrop_proofs" as never) + .select("amount, proof, merkle_root") + .eq("address" as never, address) + .single() as { data: { amount: string; proof: string; merkle_root: string } | null; error: unknown }; + + if (error || !data) { + return NextResponse.json({ eligible: false, amount: null, proof: null, claimed: false }); + } + + return NextResponse.json({ + eligible: true, + amount: data.amount, + proof: JSON.parse(data.proof), + merkleRoot: data.merkle_root, + claimed: false, // On-chain claim status checked client-side via contract read + }); +} diff --git a/src/app/api/airdrop/results/route.ts b/src/app/api/airdrop/results/route.ts new file mode 100644 index 00000000..3ff2c7ef --- /dev/null +++ b/src/app/api/airdrop/results/route.ts @@ -0,0 +1,59 @@ +/** + * Finalized campaign results (#894) + * GET /api/airdrop/results + * + * Derives final distribution from pl_airdrop_proofs (written by finalize script). + */ + +import { NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; +import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config"; +import { formatUnits } from "viem"; + +export async function GET() { + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + // Read all finalized proof amounts + const { data, error } = await supabase + .from("pl_airdrop_proofs" as never) + .select("amount") as { data: { amount: string }[] | null; error: unknown }; + + if (error || !data || data.length === 0) { + return NextResponse.json({ finalized: false }); + } + + // Sum all distributed amounts (stored as wei strings) + let totalDistributedWei = BigInt(0); + for (const row of data) { + totalDistributedWei += BigInt(row.amount); + } + const distributedPlot = Number(formatUnits(totalDistributedWei, 18)); + + const poolAmount = AIRDROP_CONFIG.POOL_AMOUNT; + const burnedPlot = poolAmount - distributedPlot; + + // Determine milestone from distributed percentage + const distributedPct = (distributedPlot / poolAmount) * 100; + let milestone: string; + if (distributedPct >= AIRDROP_CONFIG.MILESTONES.GOLD.pct - 0.1) { + milestone = "Gold"; + } else if (distributedPct >= AIRDROP_CONFIG.MILESTONES.SILVER.pct - 0.1) { + milestone = "Silver"; + } else if (distributedPct >= AIRDROP_CONFIG.MILESTONES.BRONZE.pct - 0.1) { + milestone = "Bronze"; + } else { + milestone = "None"; + } + + return NextResponse.json({ + finalized: true, + milestone, + distributedPct: Math.round(distributedPct), + distributedPlot: Math.round(distributedPlot), + burnedPlot: Math.round(burnedPlot), + recipients: data.length, + }); +} diff --git a/src/components/airdrop/ClaimPanel.tsx b/src/components/airdrop/ClaimPanel.tsx new file mode 100644 index 00000000..8059c1d8 --- /dev/null +++ b/src/components/airdrop/ClaimPanel.tsx @@ -0,0 +1,239 @@ +"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; +} + +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
+
{amountDisplay} PLOT
+ {usdValue &&
({usdValue})
} +
+ + {alreadyClaimed ? ( + + ) : ( + + )} +
+
+ ); +}