Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 75 additions & 49 deletions src/middleware/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -131,6 +139,11 @@ function classifyPaymentError(error: unknown, settleResult?: Partial<SettlementR
return { code: X402_ERROR_CODES.INSUFFICIENT_FUNDS, message: "Insufficient funds in wallet", httpStatus: 402 };
}

// Detect relay-side nonce conflict specifically (retryable) — before broad "nonce" check
if (isNonceConflict(combined)) {
return { code: X402_ERROR_CODES.UNEXPECTED_SETTLE_ERROR, message: "Relay nonce conflict, please retry", httpStatus: 502, retryAfter: 2 };
}

if (combined.includes("expired") || combined.includes("nonce")) {
return { code: X402_ERROR_CODES.INVALID_TRANSACTION_STATE, message: "Payment expired, please sign a new payment", httpStatus: 402 };
}
Expand Down Expand Up @@ -364,65 +377,78 @@ export function x402Middleware(
network: networkV2,
});

let settleResult: SettlementResponseV2;
try {
settleResult = await verifier.settle(paymentPayload, {
paymentRequirements,
});

log.debug("Settle result", { ...settleResult });
} catch (error) {
const errorStr = String(error);
log.error("Payment settlement exception", { error: errorStr });

const classified = classifyPaymentError(error);
if (classified.retryAfter) {
c.header("Retry-After", String(classified.retryAfter));
}
const MAX_SETTLE_RETRIES = 2;
const SETTLE_RETRY_BASE_MS = 1000;

return c.json(
{
error: classified.message,
code: classified.code,
asset,
network: networkV2,
resource: c.req.path,
details: {
exceptionMessage: errorStr,
},
},
classified.httpStatus as 400 | 402 | 500 | 502 | 503
);
}
let settleResult: SettlementResponseV2 | undefined;

if (!settleResult.success) {
log.error("Payment settlement failed", { ...settleResult });
for (let attempt = 0; attempt <= MAX_SETTLE_RETRIES; attempt++) {
try {
settleResult = await verifier.settle(paymentPayload, { paymentRequirements });
log.debug("Settle result", { ...settleResult, attempt });

if (!settleResult.success) {
if (isNonceConflict(settleResult.errorReason || "") && attempt < MAX_SETTLE_RETRIES) {
const delay = SETTLE_RETRY_BASE_MS * Math.pow(2, attempt);
log.warn("Relay nonce conflict, retrying settlement", { attempt: attempt + 1, maxRetries: MAX_SETTLE_RETRIES, delayMs: delay });
await new Promise((r) => 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 },
Expand Down