Skip to content

Potential x402 settlement gap: settlement failure is treated as success before sweep execution #1

@chenshj73

Description

@chenshj73

Potential x402 settlement gap: settlement failure is treated as success before sweep execution

Hi, I noticed a possible payment-flow issue in the current repository state. This is a conservative report based on the current code path, and I may be missing deployment-specific guards outside this repository.

Reviewed HEAD: a09e550e

What I observed

The x402 middleware verifies fields and then settlement errors from the facilitator are converted to { success: true }; the paid sweep route proceeds after the middleware.

Source scan notes:

  • scan_uncovered1000_20260604_batch31: True x402 paid sweep API. Verification fails open when strict verification is unset, EIP-3009 signature verification is effectively TODO/returns valid, and settlement is launched in the background/non-blocking before the paid sweep job is queued and returned.

Relevant code excerpts:

src/api/middleware/x402.ts:275-342

275	async function verifyPayment(
276	  payment: PaymentPayload,
277	  config: X402Config
278	): Promise<PaymentVerificationResult> {
279	  try {
280	    const { authorization } = payment.payload;
281	
282	    // Verify basic fields
283	    if (authorization.to.toLowerCase() !== config.receiverAddress.toLowerCase()) {
284	      return { valid: false, error: "Invalid receiver address" };
285	    }
286	
287	    const requiredAmount = BigInt(centsToUsdcAmount(config.amountCents));
288	    const providedAmount = BigInt(authorization.value);
289	
290	    if (providedAmount < requiredAmount) {
291	      return { valid: false, error: "Insufficient payment amount" };
292	    }
293	
294	    // Check validity window
295	    const now = Math.floor(Date.now() / 1000);
296	    const validAfter = parseInt(authorization.validAfter);
297	    const validBefore = parseInt(authorization.validBefore);
298	
299	    if (now < validAfter) {
300	      return { valid: false, error: "Payment not yet valid" };
301	    }
302	
303	    if (now > validBefore) {
304	      return { valid: false, error: "Payment expired" };
305	    }
306	
307	    // Check if nonce was already used (replay protection)
308	    const redis = getRedis();
309	    const nonceKey = `${NONCE_PREFIX}${authorization.from}:${authorization.nonce}`;
310	    const used = await redis.get(nonceKey);
311	
312	    if (used) {
313	      return { valid: false, error: "Nonce already used" };
314	    }
315	
316	    // Check for cached payment proof (already verified)
317	    const paymentHash = generatePaymentHash(payment);
318	    const cachedReceipt = await getCachedPaymentProof(paymentHash);
319	    if (cachedReceipt) {
320	      return { valid: true, txHash: cachedReceipt.txHash };
321	    }
322	
323	    // Perform on-chain verification
324	    const onChainResult = await verifyPaymentOnChain({
325	      payer: authorization.from as `0x${string}`,
326	      payee: authorization.to as `0x${string}`,
327	      amount: authorization.value,
328	      signature: payment.payload.signature,
329	      nonce: authorization.nonce,
330	      validAfter: authorization.validAfter,
331	      validBefore: authorization.validBefore,
332	    });
333	
334	    if (!onChainResult.valid) {
335	      return { valid: false, error: onChainResult.error || "On-chain verification failed" };
336	    }
337	
338	    // Mark nonce as used (expires after validBefore + buffer)
339	    const ttl = validBefore - now + 3600; // 1 hour buffer
340	    await redis.setex(nonceKey, ttl, "1");
341	
342	    return { valid: true, txHash: onChainResult.txHash };

src/api/middleware/x402.ts:352-390

352	async function settlePayment(
353	  payment: PaymentPayload,
354	  receipt: PaymentReceipt
355	): Promise<{
356	  success: boolean;
357	  txHash?: string;
358	  error?: string;
359	}> {
360	  try {
361	    // Use the x402 facilitator's /settle endpoint
362	    const facilitatorUrl = process.env.X402_FACILITATOR_URL || "https://x402.org";
363	    
364	    const settleResponse = await fetch(`${facilitatorUrl}/settle`, {
365	      method: "POST",
366	      headers: { "Content-Type": "application/json" },
367	      body: JSON.stringify({
368	        x402Version: X402_VERSION,
369	        payment,
370	        receipt,
371	      }),
372	    });
373	
374	    if (!settleResponse.ok) {
375	      const errorData = await settleResponse.json().catch(() => ({}));
376	      console.error("[x402] Settlement API error:", errorData);
377	      // Settlement failure is non-blocking - payment was verified
378	      return { success: true, txHash: receipt.txHash };
379	    }
380	
381	    const result = await settleResponse.json() as { txHash?: string };
382	    return {
383	      success: true,
384	      txHash: result.txHash || receipt.txHash,
385	    };
386	  } catch (error) {
387	    console.error("[x402] Payment settlement error:", error);
388	    // Settlement failure is non-blocking - we don't fail the request
389	    return { success: true, txHash: receipt.txHash };
390	  }

src/api/routes/sweep.ts:36-52

36	sweep.post(
37	  "/",
38	  authMiddleware,
39	  // Apply x402 payment middleware if configured ($0.10 per sweep)
40	  async (c, next) => {
41	    if (isX402Configured()) {
42	      const middleware = x402Middleware({
43	        amountCents: 10,
44	        receiverAddress: getX402ReceiverAddress(),
45	        description: "Sweep sweep execution fee ($0.10)",
46	      });
47	      return middleware(c, next);
48	    }
49	    return next();
50	  },
51	  zValidator("json", sweepRequestSchema),
52	  async (c) => {

Why this may matter

For paid API, agent, MCP, x402, AP2/UCP, or subscription-gated flows, the payment proof needs to dominate the protected release path. If verification is only structural, settlement is best-effort after release, payment fields are not rebound to server requirements, or the protected resource is reachable outside the paid route, a caller may receive the paid result without a completed payment for the intended resource, amount, asset, or recipient.

Suggested check

Consider making the paid-resource release depend on a payment state that is both verified and settled, or otherwise cryptographically bound to the current server-side requirements (resource, amount, asset, payTo, payer, nonce/idempotency key, and route/session). For intentionally asynchronous settlement, it may be worth failing closed on settlement errors or recording a durable pending state with explicit recovery/reconciliation semantics before returning the protected result.

Conservative caveat

This report is based on the repository code at the reviewed HEAD. If production uses an external gateway, webhook, facilitator, deployment setting, or middleware not present here that enforces the missing binding/settlement step before release, the practical impact may be lower.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions