diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index c83087f..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 */ @@ -131,6 +139,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); + 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; + } - 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 - 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 },