@@ -57,6 +57,39 @@ import { privateKeyToAccount } from 'viem/accounts'
5757import { decodePaymentHeader , buildTransferAuthorizationMessage } from './eip3009'
5858import type { SignedAuthorization } from './eip3009'
5959
60+ // ─── Replay Protection (settleOnChain: false only) ─────────────────────────
61+
62+ /**
63+ * In-memory nonce dedup for off-chain-only payments.
64+ * When settleOnChain: false, the USDC contract doesn't track the nonce,
65+ * so we must prevent replay attacks ourselves.
66+ *
67+ * Map key: nonce (hex string)
68+ * Map value: expiration timestamp (ms)
69+ * Cleanup runs every 60s to prevent memory growth.
70+ */
71+ const usedNonces = new Map < string , number > ( )
72+
73+ function cleanupExpiredNonces ( ) : void {
74+ const now = Date . now ( )
75+ for ( const [ nonce , expiresAt ] of usedNonces ) {
76+ if ( expiresAt < now ) usedNonces . delete ( nonce )
77+ }
78+ }
79+ setInterval ( cleanupExpiredNonces , 60_000 )
80+
81+ function isNonceUsed ( nonce : string ) : boolean {
82+ return usedNonces . has ( nonce )
83+ }
84+
85+ function markNonceUsed ( nonce : string , validBeforeMs : number ) : void {
86+ // Cap TTL to 5 minutes to prevent memory bloat from misconfigured validBefore
87+ const ttl = Math . min ( validBeforeMs - Date . now ( ) , 5 * 60 * 1000 )
88+ if ( ttl > 0 ) {
89+ usedNonces . set ( nonce , Date . now ( ) + ttl )
90+ }
91+ }
92+
6093// ─── Arc Testnet Chain Definition (server-safe, no process.env at module level) ──
6194
6295/**
@@ -203,10 +236,11 @@ export function withX402(
203236 // ── Step 4: Verify the payment ──
204237 const verification = await verifyPayment ( signedAuth , config )
205238 if ( ! verification . valid ) {
239+ console . warn ( `[x402] Verification failed: ${ verification . reason } ` )
206240 return new Response (
207241 JSON . stringify ( {
208242 error : 'Payment verification failed' ,
209- detail : verification . reason ,
243+ detail : 'Payment signature verification failed' ,
210244 code : 'PAYMENT_VERIFICATION_FAILED' ,
211245 } ) ,
212246 {
@@ -226,7 +260,7 @@ export function withX402(
226260 return new Response (
227261 JSON . stringify ( {
228262 error : 'Payment settlement failed' ,
229- detail : message ,
263+ detail : 'Settlement transaction failed' ,
230264 code : 'SETTLEMENT_FAILED' ,
231265 } ) ,
232266 {
@@ -235,6 +269,23 @@ export function withX402(
235269 }
236270 )
237271 }
272+ } else {
273+ // Off-chain only: check nonce dedup to prevent replay
274+ const nonceHex = signedAuth . nonce . toLowerCase ( )
275+ if ( isNonceUsed ( nonceHex ) ) {
276+ return new Response (
277+ JSON . stringify ( {
278+ error : 'Payment already used' ,
279+ detail : 'This payment has already been redeemed' ,
280+ code : 'PAYMENT_REPLAY_DETECTED' ,
281+ } ) ,
282+ {
283+ status : 402 ,
284+ headers : { 'Content-Type' : 'application/json' } ,
285+ }
286+ )
287+ }
288+ markNonceUsed ( nonceHex , Number ( signedAuth . validBefore ) * 1000 )
238289 }
239290
240291 // ── Step 6: Payment verified/settled! Call the actual handler ──
0 commit comments