Skip to content

Receipt timeout after a broadcast transferWithAuthorization becomes SettleResponse::Failure with no transaction hash — and the resulting 402 induces a fresh-nonce second charge #73

Description

@aurumflux20

Good to be back in a qntx repo (we spoke on x402-openai-python#34). A few things here are better than most facilitators I've read: verify.rs checks authorizationState before ever settling, the nonce manager resets on failure instead of drifting, and the error taxonomy in r402-core is genuinely clean. This report is about one path where that taxonomy has no room for the outcome that actually happens.

The path

Eip155ChainProvider::send_transaction (crates/r402-evm/src/chain/provider.rs) does the right two-step: broadcast, then wait for the receipt.

let pending_tx = match self.inner.send_transaction(txr).await { Ok(pending) => pending, Err(e) => { ...reset_nonce...; return Err(Transport(e)) } };
let watcher = pending_tx.with_required_confirmations(tx.confirmations).with_timeout(Some(timeout));
match watcher.get_receipt().await {
    Ok(receipt) => Ok(receipt),
    Err(e) => {
        // Receipt fetch failed (timeout or other error) - reset nonce to force requery
        self.nonce_manager.reset_nonce(from_address).await;
        Err(MetaTransactionSendError::PendingTransaction(e))
    }
}

The first arm is a definitive failure — nothing left the process. The second is not: by the time get_receipt times out, the transferWithAuthorization is in the mempool. The facilitator's receipt_timeout_secs defaults to 20 (with the note that it "must finish inside the 30 s HTTP client budget"), and Base under load routinely takes longer than that to hand back a receipt. So this arm fires on ordinary congestion, for a transfer that lands seconds later.

Both arms return Err, and from there the two indistinguishable outcomes take one road:

  • settle_payment propagates it with ? as Eip155ExactError
  • error.rs:220 maps Timeout => ErrorReason::UnexpectedSettleError
  • rpc.rs:481 builds SettleResponse::Failure { reason, message, payer: None, network, extensions }

Failure has no transaction field — only Success does. So the caller receives success:false, unexpected_settle_error and the hash of a payment that very likely landed is dropped entirely. Nobody downstream can find, reconcile, or refund it.

Why the authorizationState check doesn't cover this

It's the right guard and I'm glad it's there — but it protects against replaying the same authorization. That isn't the retry this failure induces.

Under x402, a resource server that gets success:false from /settle does not serve the resource; it returns 402 with fresh requirements. A conforming client treats that as "payment required" and signs a new authorization with a new nonce. That nonce has never been seen, so authorizationState is false, verify passes, and the facilitator settles it — a second real transferWithAuthorization for the same purchase. The first one, meanwhile, has confirmed on chain with no record on your side that it exists.

So the payer is charged twice, and the only response they ever saw for the first charge was "unexpected settle error."

One secondary thing worth checking rather than asserting: reset_nonce on the timeout arm forces a requery, and the timed-out transfer is still pending. Whether the requeried nonce collides with that in-flight transaction depends on which block tag the nonce manager queries; if it's latest, the next settlement (a different payer's) could reuse the nonce and replace the pending transfer. I haven't traced the nonce manager far enough to claim it — flagging it because the timeout arm is exactly where it would bite.

The fix — the outcome needs a name

The taxonomy is one variant short. Timeout should not be UnexpectedSettleError; it's a third state between success and failure, and the code already holds the one thing needed to make it useful — pending_tx.tx_hash().

  • In send_transaction, on the receipt-timeout arm, return a distinct variant carrying the hash (e.g. SettlementPending { tx_hash }) instead of folding it into PendingTransaction alongside definitive failures.
  • Give SettleResponse a shape for it — an ambiguous/pending result that carries transaction, rather than Failure — so a resource server can tell "your payment was rejected" from "the chain hasn't answered yet." Those must never look the same on the wire, because only the first should ever become a 402.
  • On the pending shape, the resource server should hold and reconcile rather than re-challenge: you have two authoritative reads for free — get_transaction_receipt(hash) and the authorizationState(nonce) you already call. Either resolves pending → settled/not-settled before anyone is asked to pay again.

This is the piece the current taxonomy can't express: an outcome that is terminal-for-now but not a failure. Once it has a name, everything downstream — the nonce reset, the 402 decision, the receipt the payer gets — can branch on it correctly.

Scope, honestly: this is a line-by-line read of main across facilitator and r402, not a live reproduction — I haven't driven a receipt timeout against a real facilitator and watched two transfers land. The control flow and the wire mapping are verified; the double-charge is the consequence that follows from them under x402's client behaviour.

For context on the general shape: the rule that an ambiguous outcome must stay distinct and never be collapsed into "did not happen" is §4.3 of the MCP retry-safety proposal I've been co-authoring with @YoadElkayam (https://github.com/YoadElkayam/mcp-fuse/tree/main/sep) — a working draft with no sponsor yet, not an adopted standard. Your authorizationState pre-check is already the reconciliation read it describes; this is about giving its result somewhere to go on the timeout path.

Happy to send a PR for the pending variant plus a test that times out get_receipt and asserts the hash survives to the wire — your call entirely.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions