From 1bb2ec7429378241f2fc814ea999bffba533e681 Mon Sep 17 00:00:00 2001 From: Ian Alloway Date: Tue, 14 Jul 2026 14:17:11 -0400 Subject: [PATCH] fix: prevent crypto payment replay claims --- api/verify-crypto-payment.ts | 9 ++++ netlify/functions/_lib/entitlements.test.ts | 59 +++++++++++++++++++++ netlify/functions/_lib/entitlements.ts | 18 ++++++- 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 netlify/functions/_lib/entitlements.test.ts diff --git a/api/verify-crypto-payment.ts b/api/verify-crypto-payment.ts index bb70dd3..75c0c76 100644 --- a/api/verify-crypto-payment.ts +++ b/api/verify-crypto-payment.ts @@ -2,6 +2,7 @@ // Verifies an Ethereum tx (ETH or USDC/USDT transfer) actually paid the app // wallet before any premium access is granted. See issue #36. import { + CryptoTransactionAlreadyClaimedError, createEntitlementSession, entitlementSessionCookie, getEntitlementStore, @@ -214,6 +215,14 @@ export default async function handler(req: RequestLike, res: ResponseLike) { reason: "Transaction does not pay the app wallet the required amount.", }); } catch (error) { + if (error instanceof CryptoTransactionAlreadyClaimedError) { + res.status(409).json({ + verified: false, + reason: "This crypto transaction has already been claimed.", + }); + return; + } + const message = error instanceof Error ? error.message : "Verification failed."; res.status(502).json({ verified: false, reason: message }); } diff --git a/netlify/functions/_lib/entitlements.test.ts b/netlify/functions/_lib/entitlements.test.ts new file mode 100644 index 0000000..b81c2ff --- /dev/null +++ b/netlify/functions/_lib/entitlements.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + CryptoTransactionAlreadyClaimedError, + findBestEntitlement, + upsertCryptoEntitlement, + type EntitlementStore, +} from "./entitlements"; + +function memoryStore(): EntitlementStore { + const data = new Map(); + return { + mode: "local", + async delete(key: string) { + data.delete(key); + }, + async get(key: string) { + return (data.get(key) as T | undefined) ?? null; + }, + async set(key: string, value: unknown) { + data.set(key, value); + }, + }; +} + +describe("upsertCryptoEntitlement", () => { + it("rejects replayed crypto transaction hashes without overwriting the original claimant", async () => { + const store = memoryStore(); + const txHash = `0x${"a".repeat(64)}`; + + const original = await upsertCryptoEntitlement(store, { + email: "victim@example.com", + walletAddress: `0x${"b".repeat(40)}`, + txHash, + tier: "premium", + label: "Crypto Knowledge Vault", + }); + + await expect( + upsertCryptoEntitlement(store, { + email: "attacker@example.com", + walletAddress: `0x${"b".repeat(40)}`, + txHash, + tier: "event", + label: "Crypto Big Game Pass", + }), + ).rejects.toBeInstanceOf(CryptoTransactionAlreadyClaimedError); + + const victimEntitlement = await findBestEntitlement(store, { email: "victim@example.com" }); + const attackerEntitlement = await findBestEntitlement(store, { email: "attacker@example.com" }); + + expect(victimEntitlement).toMatchObject({ + id: original.id, + email: "victim@example.com", + tier: "premium", + cryptoTxHash: txHash, + }); + expect(attackerEntitlement).toBeNull(); + }); +}); diff --git a/netlify/functions/_lib/entitlements.ts b/netlify/functions/_lib/entitlements.ts index 96c31bb..e226419 100644 --- a/netlify/functions/_lib/entitlements.ts +++ b/netlify/functions/_lib/entitlements.ts @@ -50,6 +50,13 @@ export interface EntitlementStore { set: (key: string, value: unknown, options?: { ex?: number }) => Promise; } +export class CryptoTransactionAlreadyClaimedError extends Error { + constructor(txHash: string) { + super(`Crypto transaction has already been claimed: ${normalizeHash(txHash)}`); + this.name = "CryptoTransactionAlreadyClaimedError"; + } +} + export type EventLike = { blobs?: string; headers?: Record; @@ -538,9 +545,16 @@ export async function upsertCryptoEntitlement( label: string; }, ) { + const txHash = normalizeHash(input.txHash); + const id = `crypto:${txHash}`; + const existing = await getRecord(store, id); + if (existing) { + throw new CryptoTransactionAlreadyClaimedError(txHash); + } + const tier = input.tier === "premium" ? "premium" : "event"; return upsertEntitlement(store, { - id: `crypto:${normalizeHash(input.txHash)}`, + id, tier, source: "crypto", label: input.label, @@ -549,6 +563,6 @@ export async function upsertCryptoEntitlement( expiresAt: tier === "event" ? eventAccessExpiry() : undefined, email: input.email, walletAddress: input.walletAddress, - cryptoTxHash: input.txHash, + cryptoTxHash: txHash, }); }