fix(x402): bind signed payment to the approved payment option - #1404
fix(x402): bind signed payment to the approved payment option#1404SashaMIT wants to merge 3 commits into
Conversation
retry_http_request_with_x402 validates the user-approved payment option against the spending limit, but then calls wrapFetchWithPayment with a plain x402Client, which signs whatever payment requirements the server returns on the retry's 402. A registered service could present a cheap option at confirmation time and demand a larger amount (or different recipient) at retry time. Pass a payment-requirements selector into the x402Client: - retry path: selector accepts only requirements matching the approved option on network/asset/payTo at an amount not exceeding the approved amount; anything else throws before signing. - direct (no-confirmation) path: selector caps the signed amount at the configured maxPaymentUsdc, matching the limit the confirmation path already enforces. Made-with: Cursor
🟡 Heimdall Review Status
|
|
Nice fix on the retry path — pinning The direct path's binding looks narrower than the retry path's, though, and I think it's narrower than intended. export function createCappedPaymentSelector(maxPaymentUsdc: number) {
const cap = parseUnits(maxPaymentUsdc.toString(), USDC_DECIMALS);
return <T extends PaymentRequirementLike>(_x402Version: number, accepts: T[]): T => {
const match = accepts.find(req => BigInt(req.maxAmountRequired ?? req.amount ?? "0") <= cap);
...
I checked whether anything upstream fills that gap and it doesn't. In Concretely: with Suggested fix: give (Disclosure: I work on PayPerByte; we run a seller/facilitator stack on x402, so payer-side spending-cap enforcement is directly relevant to what we build. Drafted with AI assistance.) |
The numeric cap alone accepted any asset whose atomic amount was within maxPaymentUsdc. Require the wallet's USDC address and an allowed network before signing on the no-confirmation path.
|
That's a sharp catch, and you're right that the numeric cap alone was not enough. The retry path already bound network/asset/payTo before signing. The direct path only compared maxAmountRequired against the USDC cap, so a live 402 could return a numerically small amount on a different token (or an unsupported network) and still get signed. I pushed a follow-up that gives Happy to adjust the helper shape if you'd rather see the walletProvider passed through directly. |
|
Verified against the pinned head (
Traced the refusal path end to end rather than trusting the throw in isolation: Extracted the exact predicate and ran it directly (not just read it) against a few adversarial fixtures: a first-bad/second-good Ran the actual suite at this head rather than eyeballing it: 9/9 in Two things worth naming precisely, neither of which is live in the shipped path:
On the helper shape: I'd lean toward keeping The honest counterpoint, since it cuts against the residual above: threading (Disclosure: I work on PayPerByte; we run a seller/facilitator stack on x402, so payer-side spending-cap enforcement is directly relevant to what we build. Drafted with AI assistance.) |
An omitted network list skipped the chain check entirely. The one call site already passed getX402Networks. Make the field required so a future caller cannot reintroduce that gap.
|
Thanks for tracing it end to end. Glad the two new tests line up with a real refusal through You're right that Agreed on |
Summary
retry_http_request_with_x402asks the user (or agent policy) to approve a specific payment option, validates that option againstmaxPaymentUsdc— and then signs whatever the server returns on the retry's 402, becausewrapFetchWithPaymentreceives a plainx402Clientwith the defaultaccepts[0]selector. The approved option is never enforced on the payload that actually gets signed.A registered service can therefore present a cheap option at confirmation time and demand a larger amount — or a different recipient — when the retry happens. The
maxPaymentUsdccheck gives false assurance because it runs against the approved option's amount, not the signed one.Fix
Pass a payment-requirements selector into the
x402Clientconstructor (the hook the x402 SDK provides for exactly this):retry_http_request_with_x402):createApprovedPaymentSelectoraccepts only requirements matching the approved option onnetwork/asset/payToat an amount not exceeding the approved amount; anything else throws before any signature is produced.http_request_with_x402, which by design skips user confirmation):createCappedPaymentSelectorrefuses requirements above the configuredmaxPaymentUsdc, so the provider's own spending limit also binds what actually gets signed there.Test plan
utils.selector.test.ts: matching requirement selected; inflated amount refused; swapped recipient refused; wrong network refused; v2pricefield handled; cap selector accepts-at/refuses-above the limittsc --noEmitclean; eslint/prettier cleanMade with Cursor