Reading this one with a single question — what does a buyer see when the payment lands and the answer does not come back? — and the first thing to say is that the retry loop already gets right what almost nobody does.
retry_headers["X-PAYMENT"] = encoded_sig # signed ONCE, outside the loop
backoffs = [2, 3, 5, 8, 10]
for attempt in range(max_attempts):
resp = _send(retry_headers) # the SAME header, re-presented
if resp.status_code != 402: break
_time.sleep(backoffs[attempt])
payment-agent/tools.py, _pay_and_retry (:285)
The signature is built once and re-presented rather than re-minted, and the comment names exactly why — "the previous on-chain tx may still be settling and the facilitator rejects until the nonce clears." Most x402 clients I have read call their payment builder again on each 402, mint a fresh nonce, and pay a second time. This one does not, and the reasoning is written down. Worth saying before the report.
The report: what the function returns when the loop gives up
After five attempts and ~28 seconds, if the seller is still answering 402:
ProcessPayment has already run. Money has, by the function's own comment, quite possibly moved. But the value handed back is a bare 402 — structurally identical to the 402 that means "you have not paid", and it carries nothing that says a payment was attempted: no transaction hash, no payment id, no flag.
The single call site (:574) passes it straight through as ("response", resp), and the consumer above that is a language model whose entire documented contract for a 402 is pay. The README describes the plugin path as intercepting each HTTP 402 and running ProcessPayment automatically. So the exhausted-retry return value is a 402 arriving at a component that answers 402 by paying — and the second _pay_and_retry invocation builds a new signature, because encoded_sig is local to the call.
The re-presentation discipline is correct within one invocation and absent across invocations, and the boundary between them is a 28-second timeout — which is exactly the window a slow settlement occupies.
Why the timeout does not bound it
The backoff totals 28s. An EIP-3009 authorization is presentable until validBefore; a Solana blockhash is good for roughly two minutes. So the first payment can still land after the loop has given up, while a second is being signed. Two valid authorizations, two distinct replay identities, one purchase — and every guard behaves correctly, because at the protocol boundary they genuinely are two payments.
What would close it
- Do not return a bare 402 after
ProcessPayment has run. Return something the caller cannot confuse with "unpaid" — an exception, or a result carrying the payment id and an explicit unresolved status. The rule a production x402 operator arrived at independently last week states it well: "a receiver that cannot determine a settlement outcome has no business naming a price for it" (x402-foundation/x402#3438). The same applies to a client library reporting to its own caller.
- Carry the signature across invocations for one logical purchase, keyed on the request, so a retry at the agent level re-presents rather than re-signs. The in-loop discipline is already the right shape; it just needs to outlive the function.
- Say in the tool result that a payment was attempted, so the model has something truthful to tell the user other than "payment required".
On scope
Your disclaimer is explicit and I am not treating this as a production claim — it says plainly this is not production-ready, runs on test networks, and needs a reliability review before real use. This is filed because the retry shape is the part a reader is most likely to copy, and because the surrounding code shows this failure mode was already understood: the in-loop re-presentation and its comment are the evidence.
Related upstream: #3437 proposes the receiver-side obligation, and #3438 covers the client half — that a payer's intent to retry is not expressible in the bytes, so re-signing has to be made harmless rather than assumed away.
The battery I use is MIT and ships with a mutation control, so it can be watched failing on known-broken input before a pass is trusted: https://github.com/aurumflux20/hostile-facilitator/releases/tag/v0.1.2 — it counts distinct settlements at the facilitator rather than comparing response bodies. Nothing needed from me, and nothing to buy.
Reading this one with a single question — what does a buyer see when the payment lands and the answer does not come back? — and the first thing to say is that the retry loop already gets right what almost nobody does.
payment-agent/tools.py,_pay_and_retry(:285)The signature is built once and re-presented rather than re-minted, and the comment names exactly why — "the previous on-chain tx may still be settling and the facilitator rejects until the nonce clears." Most x402 clients I have read call their payment builder again on each 402, mint a fresh nonce, and pay a second time. This one does not, and the reasoning is written down. Worth saying before the report.
The report: what the function returns when the loop gives up
After five attempts and ~28 seconds, if the seller is still answering 402:
ProcessPaymenthas already run. Money has, by the function's own comment, quite possibly moved. But the value handed back is a bare402— structurally identical to the 402 that means "you have not paid", and it carries nothing that says a payment was attempted: no transaction hash, no payment id, no flag.The single call site (
:574) passes it straight through as("response", resp), and the consumer above that is a language model whose entire documented contract for a 402 is pay. The README describes the plugin path as intercepting each HTTP 402 and runningProcessPaymentautomatically. So the exhausted-retry return value is a 402 arriving at a component that answers 402 by paying — and the second_pay_and_retryinvocation builds a new signature, becauseencoded_sigis local to the call.The re-presentation discipline is correct within one invocation and absent across invocations, and the boundary between them is a 28-second timeout — which is exactly the window a slow settlement occupies.
Why the timeout does not bound it
The backoff totals 28s. An EIP-3009 authorization is presentable until
validBefore; a Solana blockhash is good for roughly two minutes. So the first payment can still land after the loop has given up, while a second is being signed. Two valid authorizations, two distinct replay identities, one purchase — and every guard behaves correctly, because at the protocol boundary they genuinely are two payments.What would close it
ProcessPaymenthas run. Return something the caller cannot confuse with "unpaid" — an exception, or a result carrying the payment id and an explicitunresolvedstatus. The rule a production x402 operator arrived at independently last week states it well: "a receiver that cannot determine a settlement outcome has no business naming a price for it" (x402-foundation/x402#3438). The same applies to a client library reporting to its own caller.On scope
Your disclaimer is explicit and I am not treating this as a production claim — it says plainly this is not production-ready, runs on test networks, and needs a reliability review before real use. This is filed because the retry shape is the part a reader is most likely to copy, and because the surrounding code shows this failure mode was already understood: the in-loop re-presentation and its comment are the evidence.
Related upstream: #3437 proposes the receiver-side obligation, and #3438 covers the client half — that a payer's intent to retry is not expressible in the bytes, so re-signing has to be made harmless rather than assumed away.
The battery I use is MIT and ships with a mutation control, so it can be watched failing on known-broken input before a pass is trusted: https://github.com/aurumflux20/hostile-facilitator/releases/tag/v0.1.2 — it counts distinct settlements at the facilitator rather than comparing response bodies. Nothing needed from me, and nothing to buy.