skip to content
Skill Issue Dev | Dax the Dev
search
Part of series: x402 attack surface

x402 Vector 1: settlement race condition

Print view

Sections

The cleanest vulnerability in the x402 protocol — also the one that’s easiest to fix and the one most likely to bite production deployments. This post walks through the settlement race in detail, gives a PoC layout, and lists the mitigations that’d close it.

Post 2 of the SOLMAL series on x402.

The bug

The x402 settlement flow has two server-side calls:

  1. POST /verify { tx, expected } — facilitator returns 200 if the partially-signed tx is well-formed and pays the right amount.
  2. POST /settle { tx } — facilitator co-signs as feePayer and submits the tx to Solana.

In every reference implementation I’ve seen, these are independent HTTP handlers with no shared lock. A signed PAYMENT-SIGNATURE can be submitted to /settle more than once. The facilitator will:

  • Re-co-sign with feePayer (idempotent — same tx hash).
  • Re-submit to Solana RPC.

Solana itself deduplicates — the second submission of an already-confirmed tx returns AlreadyProcessed. Fine. But there’s a window between the client submitting tx_a and Solana confirming tx_a during which the same client signature on a different transaction (tx_b, with a different blockhash or a different recipient ATA) can also be settled. The client paid once; the merchant believed they were paid; the underlying ledger says otherwise.

Three concrete scenarios

Scenario 1: parallel facilitator submission

If a network has multiple facilitators (Coinbase plus third parties), the client can:

  1. Build PAYMENT-SIGNATURE.
  2. POST to facilitator A’s /settle.
  3. POST the same payload to facilitator B’s /settle 50ms later.
  4. Both facilitators submit. The first to land on Solana wins; the loser sees AlreadyProcessed.

Result: only one tx settles on-chain, but both facilitators consumed gas, and both believed they had successfully settled the payment (depending on RPC client timing). Some facilitator implementations cache /settle responses by request hash; others cache by tx signature; others don’t cache. The cache discrepancy is the monetisable bit.

Scenario 2: client-side bypass

  1. Client receives PAYMENT-REQUIRED with feePayer=facilitator.
  2. Client builds PAYMENT-SIGNATURE.
  3. Client also builds a different tx with the same client signature but a different recipient ATA — say, a second ATA the client controls.
  4. Client submits the second tx directly to Solana RPC.
  5. Client submits PAYMENT-SIGNATURE to /settle.

If Solana confirms the second tx first (because the facilitator’s RPC is in a different region with higher latency), the merchant’s real settlement fails. The client never paid the merchant; the client paid themselves. The merchant might still grant access if they don’t watch the on-chain confirmation tightly.

Scenario 3: rapid replay

Submit the same PAYMENT-SIGNATURE to the same facilitator 50 times in 1 second. If the facilitator’s /settle handler doesn’t lock-and-dedupe on the request payload hash, every call submits to Solana. 49 of 50 will fail with AlreadyProcessed, but during the racing window some may compute against stale state and reach unexpected outcomes (rent reclaims, ATA-init double-fee, etc.).

PoC structure

The repo contains a Rust harness in research/race-spammer/:

// Pseudocode — see repo for the runnable version.
async fn race_test(facilitator: &Url, client: &Keypair) -> RaceResult {
    let req = build_payment_request(client);
    let sig = build_payment_signature(&req, client);

    let handles: Vec<_> = (0..50).map(|_| {
        let url = facilitator.clone();
        let s   = sig.clone();
        tokio::spawn(async move {
            settle(&url, s).await
        })
    }).collect();

    futures::future::join_all(handles).await
}

50 parallel /settle calls. Count: how many got HTTP 200? How many led to a confirmed Solana tx? How many cost the facilitator gas?

Mitigations

The fix is small but it does have to be coded:

  1. Atomic verify+settle. Combine the two endpoints, or have /settle re-run verify under a lock keyed by the tx signature.
  2. Per-signature dedup. Cache settled tx signatures in Redis / KV with TTL = blockhash validity (~60s) + safety margin. Reject duplicate /settle calls with HTTP 409.
  3. Confirmation polling. /settle should not return until the tx is confirmed (level=processed minimum, ideally confirmed). Currently most implementations return on RPC submit, not on confirmation.
  4. Per-client rate limit on /settle. Even with dedup, a malicious client can create N distinct signatures. Limit per-IP and per-client-key.

Of these, (2) is the easy win. KV cache keyed by signature, TTL of 90 seconds. Stops scenarios 1 and 3 dead.

What this means for x402 deployments

If you’re operating an x402 facilitator: implement (2) before going live. The TTL needs to be longer than blockhash validity to cover the late-replay edge case. Use Cloudflare Workers KV, AWS DynamoDB, Redis — anything with sub-100ms eventual consistency.

If you’re a merchant integrating x402: don’t grant content access until the facilitator’s /settle returns AND your own RPC poll confirms the tx. The current spec lets merchants act on the facilitator’s word; the spec needs an explicit “signature confirmed at slot S” field, and merchants need to poll until they see that slot ≤ current_slot - 32 (final).

Bibliography

  • Dax911/x402_mal SOLMAL.md — full threat model
  • Solana Foundation. Transaction confirmation levels.
  • Coinbase Developer Platform. x402 specification.

Previous: Series intro ← · Next: Partial-signing instruction injection →

Hire me — book a 30-min call $ book →