From da8b1dcc4686998299c9c85ce5e7751f2ad6e86f Mon Sep 17 00:00:00 2001 From: tfibtcagent Date: Fri, 27 Mar 2026 12:12:24 +0000 Subject: [PATCH 1/2] fix(x402): retry settlement on conflicting_nonce with exponential backoff (closes #84) - classifyPaymentError: add specific check for conflicting_nonce/sender_nonce_duplicate before broad nonce match, returning HTTP 502 + Retry-After: 2 - Settlement block: wrap verifier.settle() in retry loop (max 2 retries, 1s/2s backoff) catching relay nonce conflicts before surfacing error to client - Include attempts count in error details for observability Co-Authored-By: Claude Sonnet 4.6 --- src/middleware/x402.ts | 119 +++++++++++++++++++++++++---------------- 1 file changed, 72 insertions(+), 47 deletions(-) diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index c83087f..fa6dd36 100644 --- a/src/middleware/x402.ts +++ b/src/middleware/x402.ts @@ -131,6 +131,11 @@ function classifyPaymentError(error: unknown, settleResult?: Partial setTimeout(r, delay)); + continue; + } - const classified = classifyPaymentError(settleResult.errorReason || "settlement_failed", settleResult); + // Non-retryable failure or exhausted retries + log.error("Payment settlement failed", { ...settleResult, attempt }); + const classified = classifyPaymentError(settleResult.errorReason || "settlement_failed", settleResult); + if (classified.retryAfter) { + c.header("Retry-After", String(classified.retryAfter)); + } + return c.json( + { + error: classified.message, + code: classified.code, + asset, + network: networkV2, + resource: c.req.path, + details: { errorReason: settleResult.errorReason, attempts: attempt + 1 }, + }, + classified.httpStatus as 400 | 402 | 500 | 502 | 503 + ); + } - if (classified.retryAfter) { - c.header("Retry-After", String(classified.retryAfter)); - } + // Success — exit loop + break; + } catch (error) { + const errorStr = String(error); + const isNonceConflict = + errorStr.toLowerCase().includes("conflicting_nonce") || + errorStr.toLowerCase().includes("sender_nonce_duplicate"); + + if (isNonceConflict && attempt < MAX_SETTLE_RETRIES) { + const delay = SETTLE_RETRY_BASE_MS * (attempt + 1); + log.warn("Relay nonce conflict exception, retrying", { attempt: attempt + 1, error: errorStr, delayMs: delay }); + await new Promise((r) => setTimeout(r, delay)); + continue; + } - return c.json( - { - error: classified.message, - code: classified.code, - asset, - network: networkV2, - resource: c.req.path, - details: { - errorReason: settleResult.errorReason, + log.error("Payment settlement exception", { error: errorStr, attempt }); + const classified = classifyPaymentError(error); + if (classified.retryAfter) { + c.header("Retry-After", String(classified.retryAfter)); + } + return c.json( + { + error: classified.message, + code: classified.code, + asset, + network: networkV2, + resource: c.req.path, + details: { exceptionMessage: errorStr, attempts: attempt + 1 }, }, - }, - classified.httpStatus as 400 | 402 | 500 | 502 | 503 - ); + classified.httpStatus as 400 | 402 | 500 | 502 | 503 + ); + } } // Extract payer address from settle result From b82fae85097e5cd55660375a767d24f358d79134 Mon Sep 17 00:00:00 2001 From: tfibtcagent Date: Fri, 27 Mar 2026 13:33:50 +0000 Subject: [PATCH 2/2] refactor(x402): extract isNonceConflict helper, true exponential backoff, remove non-null assertion - Extract isNonceConflict(str) helper to remove duplication across 3 call sites - Switch to Math.pow(2, attempt) for semantically correct exponential backoff - Replace settleResult! non-null assertion with settleResult | undefined and explicit undefined guard post-loop Addresses review suggestions from arc0btc on PR #89. Co-Authored-By: Claude Sonnet 4.6 --- src/middleware/x402.ts | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index fa6dd36..c464ddf 100644 --- a/src/middleware/x402.ts +++ b/src/middleware/x402.ts @@ -106,6 +106,14 @@ function getAssetV2( return `${contract.address}.${contract.name}`; } +/** + * Check if an error string indicates a relay-side nonce conflict (retryable) + */ +function isNonceConflict(str: string): boolean { + const lower = str.toLowerCase(); + return lower.includes("conflicting_nonce") || lower.includes("sender_nonce_duplicate"); +} + /** * Classify payment errors for appropriate response */ @@ -132,7 +140,7 @@ function classifyPaymentError(error: unknown, settleResult?: Partial setTimeout(r, delay)); continue; @@ -414,12 +418,8 @@ export function x402Middleware( break; } catch (error) { const errorStr = String(error); - const isNonceConflict = - errorStr.toLowerCase().includes("conflicting_nonce") || - errorStr.toLowerCase().includes("sender_nonce_duplicate"); - - if (isNonceConflict && attempt < MAX_SETTLE_RETRIES) { - const delay = SETTLE_RETRY_BASE_MS * (attempt + 1); + if (isNonceConflict(errorStr) && attempt < MAX_SETTLE_RETRIES) { + const delay = SETTLE_RETRY_BASE_MS * Math.pow(2, attempt); log.warn("Relay nonce conflict exception, retrying", { attempt: attempt + 1, error: errorStr, delayMs: delay }); await new Promise((r) => setTimeout(r, delay)); continue; @@ -445,9 +445,10 @@ export function x402Middleware( } // Extract payer address from settle result - const payerAddress = settleResult.payer; + // settleResult is always set when we reach this point (all error paths return early above) + const payerAddress = settleResult?.payer; - if (!payerAddress) { + if (!payerAddress || !settleResult) { log.error("Could not extract payer address from valid payment"); return c.json( { error: "Could not identify payer from payment", code: X402_ERROR_CODES.SENDER_MISMATCH },