apps/web/app/api/claims/route.ts
Verifies a claim signature and queues it. Shown whole, as it was in the repository when this site was built. Line numbers link: add #L12 to the address.
apps/web/app/api/claims/route.ts66 lines
1import { sql } from 'drizzle-orm';2import { isSolAddress, SOL_SIGNATURE_RE } from '@solary/core/pump';3import { nonceError } from '@/app/api/launch/_lib/nonce';4import { claimMessage } from '@/components/rewards/claimMessage';5import { clientIp, error, json, rateLimit } from '@/lib/http';6import { db } from '@/lib/queries/common';7import { MIN_CLAIM_LAMPORTS, latestClaim, walletRewards } from '@/lib/queries/rewards';8import { verifyEd25519 } from '@/lib/solana/verify';9 10export const runtime = 'nodejs';11export const dynamic = 'force-dynamic';12 13/** The wallet's latest claim request (the Rewards page polls this while one is queued). */14export async function GET(req: Request) {15 const wallet = new URL(req.url).searchParams.get('wallet')?.trim() ?? '';16 if (!isSolAddress(wallet)) return error(400, 'wallet: not a Solana address');17 if (!rateLimit(`claims-get:${clientIp(req)}`, 60, 60_000)) return error(429, 'slow down');18 try {19 return json({ claim: await latestClaim(wallet) });20 } catch {21 return error(503, 'claims are unavailable right now');22 }23}24 25/**26 * Ask for an immediate payout. Body: { wallet, nonce, signature } where signature is the base58 ed25519 signature27 * of `Solary claim\nWallet: <wallet>\nNonce: <nonce>` by the wallet itself. The keeper pays everything the wallet's28 * NFTs are owed, across coins, on its next pass. One queued request per wallet.29 */30export async function POST(req: Request) {31 if (!rateLimit(`claims:${clientIp(req)}`, 10, 60_000)) return error(429, 'slow down');32 const body = (await req.json().catch(() => null)) as { wallet?: unknown; nonce?: unknown; signature?: unknown } | null;33 if (!body || typeof body !== 'object') return error(400, 'bad json');34 const wallet = typeof body.wallet === 'string' ? body.wallet.trim() : '';35 const nonce = typeof body.nonce === 'string' ? body.nonce.trim() : '';36 const signature = typeof body.signature === 'string' ? body.signature.trim() : '';37 if (!isSolAddress(wallet)) return error(400, 'wallet: not a Solana address');38 const bad = nonceError(nonce);39 if (bad) return error(400, bad.replace('start the launch again', 'sign again'));40 if (!SOL_SIGNATURE_RE.test(signature)) return error(400, 'signature: expected base58');41 if (!verifyEd25519(new TextEncoder().encode(claimMessage(wallet, nonce)), signature, wallet)) {42 return error(401, 'signature does not match this wallet');43 }44 45 try {46 // what a claim could pay now (re-uses the Magic Eden lookup the page just made, no new request)47 const rewards = await walletRewards(wallet, { live: false });48 if (rewards.totals.claimableLamports < MIN_CLAIM_LAMPORTS) {49 return error(400, 'nothing to claim yet: the minimum is 0.0001 SOL');50 }51 const result = await db().transaction(async (tx) => {52 await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`solary-claim:${wallet}`}))`);53 const open = (await tx.execute(sql`SELECT id FROM claim_requests WHERE owner = ${wallet} AND status = 'queued' LIMIT 1`)) as unknown as Array<{ id: string }>;54 if (open.length) return 'queued' as const;55 const ins = (await tx.execute(sql`56 INSERT INTO claim_requests (owner, signature, nonce) VALUES (${wallet}, ${signature}, ${nonce})57 ON CONFLICT (nonce) DO NOTHING RETURNING id`)) as unknown as Array<{ id: string }>;58 return ins.length ? ('ok' as const) : ('reused' as const);59 });60 if (result === 'reused') return error(409, 'this signature was already used; sign again');61 const claim = await latestClaim(wallet);62 return json({ claim, alreadyQueued: result === 'queued' }, 0, { status: result === 'ok' ? 201 : 200 });63 } catch {64 return error(503, 'claims are unavailable right now');65 }66}