From 3644b1fe4c0855a666da6a11be77d42e9957effd Mon Sep 17 00:00:00 2001 From: Ian Alloway Date: Tue, 14 Jul 2026 14:17:24 -0400 Subject: [PATCH] fix: prevent crypto payment transaction replay --- api/verify-crypto-payment.ts | 10 ++++++--- netlify/functions/_lib/entitlements.ts | 30 +++++++++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/api/verify-crypto-payment.ts b/api/verify-crypto-payment.ts index bb70dd3..be32466 100644 --- a/api/verify-crypto-payment.ts +++ b/api/verify-crypto-payment.ts @@ -5,7 +5,7 @@ import { createEntitlementSession, entitlementSessionCookie, getEntitlementStore, - upsertCryptoEntitlement, + claimCryptoEntitlement, type AccessTier, } from "../netlify/functions/_lib/entitlements"; @@ -166,7 +166,7 @@ export default async function handler(req: RequestLike, res: ResponseLike) { res.status(503).json({ verified: false, reason: "Entitlement backend is not configured." }); return; } - const entitlement = await upsertCryptoEntitlement(store, { + const entitlement = await claimCryptoEntitlement(store, { email, walletAddress, txHash, @@ -196,7 +196,7 @@ export default async function handler(req: RequestLike, res: ResponseLike) { res.status(503).json({ verified: false, reason: "Entitlement backend is not configured." }); return; } - const entitlement = await upsertCryptoEntitlement(store, { + const entitlement = await claimCryptoEntitlement(store, { email, walletAddress, txHash, @@ -215,6 +215,10 @@ export default async function handler(req: RequestLike, res: ResponseLike) { }); } catch (error) { const message = error instanceof Error ? error.message : "Verification failed."; + if (message === "CRYPTO_TRANSACTION_ALREADY_CLAIMED") { + res.status(409).json({ verified: false, reason: "This transaction hash has already been claimed." }); + return; + } res.status(502).json({ verified: false, reason: message }); } } diff --git a/netlify/functions/_lib/entitlements.ts b/netlify/functions/_lib/entitlements.ts index 96c31bb..5bfdb48 100644 --- a/netlify/functions/_lib/entitlements.ts +++ b/netlify/functions/_lib/entitlements.ts @@ -528,7 +528,7 @@ export async function upsertStripeSubscriptionEntitlement( }); } -export async function upsertCryptoEntitlement( +export async function claimCryptoEntitlement( store: EntitlementStore, input: { email: string; @@ -539,16 +539,36 @@ export async function upsertCryptoEntitlement( }, ) { const tier = input.tier === "premium" ? "premium" : "event"; + const txHash = normalizeHash(input.txHash); + const email = normalizeEmail(input.email); + const walletAddress = normalizeWallet(input.walletAddress); + const id = `crypto:${txHash}`; + const existing = await getRecord(store, id); + + if (existing) { + const sameClaimant = + existing.email === email && + existing.walletAddress === walletAddress && + existing.tier === tier && + existing.cryptoTxHash === txHash; + + if (!sameClaimant) { + throw new Error("CRYPTO_TRANSACTION_ALREADY_CLAIMED"); + } + + return existing; + } + return upsertEntitlement(store, { - id: `crypto:${normalizeHash(input.txHash)}`, + id, tier, source: "crypto", label: input.label, status: "active", activatedAt: new Date().toISOString(), expiresAt: tier === "event" ? eventAccessExpiry() : undefined, - email: input.email, - walletAddress: input.walletAddress, - cryptoTxHash: input.txHash, + email, + walletAddress, + cryptoTxHash: txHash, }); }