From da86999985561a32ab2f842cc6e5c308f8fdfa40 Mon Sep 17 00:00:00 2001 From: moe Date: Mon, 11 May 2026 20:15:39 -0400 Subject: [PATCH 1/3] Make payment txid replay markers permanent Direct payment mode verifies confirmed sBTC transfer txids and uses REVENUE_LOG to reject repeats. The replay key previously expired after 24 hours, which let the same confirmed txid become usable again after the marker aged out. Store txid replay markers without an expiration and reject already-seen direct txids before doing external verification work. Update the premium doctor note to match the single-use behavior. Co-authored-by: Codex --- src/index.ts | 52 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 62ee302..5a921e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,6 @@ const RELAY_BASE = "https://x402-relay.aibtc.com"; const RELAY_SETTLE = `${RELAY_BASE}/settle`; const RELAY_HEALTH = `${RELAY_BASE}/health`; const HIRO_BASE = "https://api.hiro.so"; -const REPLAY_TTL_SECONDS = 60 * 60 * 24; const DIRECT_POLL_MAX_MS = 8000; const DIRECT_POLL_INTERVAL_MS = 1000; @@ -74,6 +73,21 @@ function b64decode(s: string | null): T | null { try { return JSON.parse(atob(s)); } catch { return null; } } +function normalizePaymentTxid(value: unknown): string | null { + const raw = String(value || "").trim(); + const hex = raw.startsWith("0x") ? raw.slice(2) : raw; + return /^[0-9a-f]{64}$/i.test(hex) ? "0x" + hex.toLowerCase() : null; +} + +function normalizeSubmittedTxid(payload: any): string | null { + return normalizePaymentTxid(payload?.payload?.transaction); +} + +function replayKey(txid: string): string { + const normalized = normalizePaymentTxid(txid); + return "txid:" + (normalized || txid.trim().toLowerCase()); +} + function paymentRequiredResponse(req: Request, description: string, extraBody?: Record): Response { const url = new URL(req.url); const required = buildPaymentRequired(url.toString(), description); @@ -126,12 +140,10 @@ interface DirectVerifyResult { } async function verifyDirect(payload: any): Promise { - const raw = String(payload?.payload?.transaction || "").trim(); - const txidMatch = raw.match(/^0x[0-9a-f]{64}$/i); - if (!txidMatch) { + const txid = normalizeSubmittedTxid(payload); + if (!txid) { return { success: false, reason: "invalid_txid", txid: "", raw: { hint: "payload.transaction must be 0x-prefixed 64-char hex (the txid)" } }; } - const txid = raw.toLowerCase(); const deadline = Date.now() + DIRECT_POLL_MAX_MS; let lastTx: any = null; while (Date.now() < deadline) { @@ -257,6 +269,21 @@ async function handlePremium(req: Request, env: any, slug: string, sliceFn: (dat const broadcastMode = String(payload?.accepted?.extra?.broadcast || "sponsored-relay"); + const submittedTxid = normalizeSubmittedTxid(payload); + if (broadcastMode === "direct" && submittedTxid) { + const seen = await env.REVENUE_LOG.get(replayKey(submittedTxid)); + if (seen) { + return new Response(JSON.stringify({ + x402Version: 2, + error: "replay_detected", + txid: submittedTxid, + }), { + status: 409, + headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, + }); + } + } + let outcome: { success: boolean; txid: string; payer?: string; reason?: string; held?: any; raw?: any }; if (broadcastMode === "direct") { outcome = await verifyDirect(payload); @@ -264,7 +291,8 @@ async function handlePremium(req: Request, env: any, slug: string, sliceFn: (dat outcome = await settleWithRelay(payload); } - if (!outcome.success || !outcome.txid) { + const settledTxid = normalizePaymentTxid(outcome.txid); + if (!outcome.success || !settledTxid) { const advice = outcome.held ? "Relay queue is held for your sender (nonce desync). Switch to broadcast=direct: build a non-sponsored sBTC transfer with your own STX gas, broadcast via Hiro, then submit 0x{txid} as payload.transaction." : broadcastMode === "direct" @@ -273,7 +301,7 @@ async function handlePremium(req: Request, env: any, slug: string, sliceFn: (dat return paymentRequiredResponse(req, description, { error: "settlement_failed", attempted_mode: broadcastMode, - reason: outcome.reason || "unknown", + reason: outcome.reason || (settledTxid ? "unknown" : "invalid_settlement_txid"), held: outcome.held || null, relay: broadcastMode === "sponsored-relay" ? outcome.raw : undefined, verifier: broadcastMode === "direct" ? outcome.raw : undefined, @@ -282,13 +310,13 @@ async function handlePremium(req: Request, env: any, slug: string, sliceFn: (dat }); } - const txKey = "txid:" + outcome.txid; + const txKey = replayKey(settledTxid); const seen = await env.REVENUE_LOG.get(txKey); if (seen) { return new Response(JSON.stringify({ x402Version: 2, error: "replay_detected", - txid: outcome.txid, + txid: settledTxid, }), { status: 409, headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, @@ -307,12 +335,12 @@ async function handlePremium(req: Request, env: any, slug: string, sliceFn: (dat const event = { ts: new Date().toISOString(), slug, - txid: outcome.txid, + txid: settledTxid, payer: outcome.payer || null, sats: Number(PRICE_SATS), mode: broadcastMode, }; - await env.REVENUE_LOG.put(txKey, JSON.stringify(event), { expirationTtl: REPLAY_TTL_SECONDS }); + await env.REVENUE_LOG.put(txKey, JSON.stringify(event)); const ledgerKey = "ledger:events"; const ledgerRaw = await env.REVENUE_LOG.get(ledgerKey); @@ -404,7 +432,7 @@ async function handleDoctor(_req: Request, env: any): Promise { fallback_advice: "If a sponsored-relay attempt returns 402 with held=true (relay queue desynced for your sender), retry the same call as broadcast=direct. Each call's reply includes specific advice.", revenue_ledger: ledgerStats, notes: [ - "Replay protection: each settled txid is single-use (24h TTL in KV).", + "Replay protection: each settled txid is single-use.", "All sats settle to a dedicated service wallet — separate from any operator's main wallet.", "Free, no-payment endpoints: /api/world/company, /api/world/customer, /api/world/premium/doctor.", ], From ca22909dac647e2f0e36973c1472b84076a9c9f2 Mon Sep 17 00:00:00 2001 From: moe Date: Mon, 11 May 2026 20:32:33 -0400 Subject: [PATCH 2/3] Return canonical payment txids in premium responses Use the same normalized settlement txid in the payment-response header and JSON body that is used for replay logging. This keeps relay and direct modes consistent when a relay returns mixed-case or unprefixed transaction IDs. Co-authored-by: Codex --- src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5a921e0..4440871 100644 --- a/src/index.ts +++ b/src/index.ts @@ -350,13 +350,13 @@ async function handlePremium(req: Request, env: any, slug: string, sliceFn: (dat const settlementResponse = { success: true, - transaction: outcome.txid, + transaction: settledTxid, network: NETWORK, payer: outcome.payer || "", }; return new Response(JSON.stringify({ ...slice, - payment: { txid: outcome.txid, sats: Number(PRICE_SATS), payer: outcome.payer || null, mode: broadcastMode }, + payment: { txid: settledTxid, sats: Number(PRICE_SATS), payer: outcome.payer || null, mode: broadcastMode }, }), { status: 200, headers: { From 5a1224a843aadd69708ea26a0062154b3fdf63e0 Mon Sep 17 00:00:00 2001 From: moe Date: Mon, 11 May 2026 20:58:52 -0400 Subject: [PATCH 3/3] Compact permanent replay markers Store permanent txid replay markers as a sentinel value while keeping payment details in the existing ledger. This preserves single-use txid enforcement without duplicating each event body per replay key. Co-authored-by: Codex --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 4440871..c2279b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ const RELAY_HEALTH = `${RELAY_BASE}/health`; const HIRO_BASE = "https://api.hiro.so"; const DIRECT_POLL_MAX_MS = 8000; const DIRECT_POLL_INTERVAL_MS = 1000; +const REPLAY_MARKER_VALUE = "1"; interface PaymentRequirements { scheme: string; @@ -340,7 +341,7 @@ async function handlePremium(req: Request, env: any, slug: string, sliceFn: (dat sats: Number(PRICE_SATS), mode: broadcastMode, }; - await env.REVENUE_LOG.put(txKey, JSON.stringify(event)); + await env.REVENUE_LOG.put(txKey, REPLAY_MARKER_VALUE); const ledgerKey = "ledger:events"; const ledgerRaw = await env.REVENUE_LOG.get(ledgerKey);