Skip to content

fix(x402): select payment asset instead of hardcoding accepts[0] (#613)#622

Merged
biwasxyz merged 1 commit into
mainfrom
fix/613-x402-asset-selection
Jul 20, 2026
Merged

fix(x402): select payment asset instead of hardcoding accepts[0] (#613)#622
biwasxyz merged 1 commit into
mainfrom
fix/613-x402-asset-selection

Conversation

@biwasxyz

Copy link
Copy Markdown
Collaborator

Fixes #613.

The reported bugs are real, and there is a third one underneath them

detectTokenType returned 'STX' for any unrecognized asset. The interceptor then took the first accepts[] entry and branched on that type. On arc0btc's live 402 header:

accepts[0] = { asset: "SP120...usdcx", amount: "29000000", network: "stacks:1" }

selection took USDCx → detectTokenType classified it 'STX' → the else branch ran makeSTXTokenTransfer({ recipient: payTo, amount: 29000000n }).

That signs and broadcasts 29 real STX as a native transfer to satisfy an invoice denominated in 29 USDCx. Wrong asset, funds gone, endpoint never credits it. The "29 STX" display bug in the issue is the same root cause surfacing harmlessly; this is it surfacing expensively.

Changes

  • detectTokenType identifies STX positively (bare STX, or CAIP-19 native/slip44:5773) and returns 'unsupported' for anything that is not STX or sBTC. Unknown assets are refused, never guessed into a transfer.
  • selectPaymentOption replaces accepts[0] / find(startsWith("stacks:")). Honors a requested asset; otherwise takes the first entry this client can actually sign, in the server's own order. On a mismatch it throws listing the accepted assets — it never substitutes a different asset than the one asked for.
  • asset parameter on probe_x402_endpoint and execute_x402_endpoint, matching on symbol or full contract id, case-insensitively. callWith echoes it so executing a quote pays what was quoted.
  • Probe returns the full accepts[] with a payable flag per asset, and the message text is derived from the same asset the payment block reports — the two can no longer disagree.
  • formatPaymentAmount renders unknown tokens as 29000000 USDCx (base units). Decimals are unknown for third-party tokens, so it does not invent a scaled figure.
  • checkSufficientBalance refuses unsupported assets instead of falling through to the STX balance check.

Selection is deliberately not balance-aware, despite the issue suggesting it. Auto-picking by wallet contents would let a server reordering its manifest change which asset leaves the wallet without the caller ever naming it. Explicit asset + first-payable default is predictable; the probe now surfaces every option so the caller can choose.

Verification

Live against arc0btc (probe only, no payment):

asset result
(default) sBTC 45385the sBTC-only wallet case from the bounty, previously blocked
sBTC sBTC 45385
STX STX 159911365
USDCx Error: not supported, lists accepted assets
DOGE Error: does not accept "DOGE", lists accepted assets

542 tests pass. New tests use the real arc0btc accepts[] (USDCx first, so the ordering that caused this is exercised). Confirmed the new tests fail against the pre-fix source — 11 failures, including expected '29 STX' not to match /STX/.

Behavior change

Endpoints offering only unsupported assets now fail with a clear error where they previously "succeeded" by mis-sending STX. That is the point of the fix, but it is a visible change for anyone who was unknowingly relying on the broken path.

detectTokenType returned 'STX' for any unrecognized asset. Combined with the
interceptor taking the first accepts[] entry, a USDCx-denominated 402
(asset SP120...usdcx, amount 29000000) selected USDCx, classified it as STX,
and fell into the makeSTXTokenTransfer branch — signing a real 29 STX native
transfer for an invoice the endpoint would never credit. The same fallback
made the probe render "29 STX" while payment.asset said USDCx.

- detectTokenType identifies STX positively and returns 'unsupported' for
  anything that is not STX or sBTC, so unknown assets are refused not guessed
- selectPaymentOption picks the requested asset, else the first payable entry
  in the server's order; errors list the accepted assets rather than
  substituting a different one
- Add an `asset` param to probe_x402_endpoint and execute_x402_endpoint, and
  echo it through callWith so executing a quote pays what was quoted
- Probe returns accepts[] with a payable flag per asset, and the message text
  is derived from the same asset the payment block reports
- formatPaymentAmount renders unknown tokens in base units with their symbol
  instead of scaling by 1e6 and labelling them STX
- checkSufficientBalance refuses unsupported assets instead of validating STX

Selection is deliberately not balance-aware: picking by wallet contents would
let a manifest reorder change which asset leaves the wallet.

Two existing tests asserted the unknown-asset -> STX fallback as intended
behavior and are updated; the substring-guard test keeps its intent.
@biwasxyz
biwasxyz merged commit 491e7bc into main Jul 20, 2026
5 checks passed
@biwasxyz
biwasxyz deleted the fix/613-x402-asset-selection branch July 20, 2026 10:15
@biwasxyz

Copy link
Copy Markdown
Collaborator Author

Correction to the severity framing in the PR description above, verified after merge.

The description says the pre-fix code "signs and broadcasts 29 real STX". It does not. The per-payment cap check runs before signing, and because the USDCx amount (29000000) was misread as µSTX it was measured against X402_MAX_USTX_PER_PAYMENT (default 1 STX = 1000000) and rejected. Running the pre-fix commit against the live endpoint:

Error: x402 payment of 29000000 uSTX exceeds the per-payment cap of 1000000 uSTX.

So no funds were at risk on arc0btc, and there is no evidence of any loss having occurred.

The wrong-asset routing was still real, but the exposure is narrower than stated: an unknown token whose base-unit amount is below the cap (≤ 1000000) would have passed the check and been sent as a native STX transfer — e.g. a 6-decimal token priced at 0.5 units → 500000 base units → 0.5 STX actually sent. Bounded at 1 STX per payment by default, and further bounded across payments by the session/daily spend limiter.

The fix and its justification are unchanged: an asset the client cannot sign must be refused, not silently rerouted onto the STX rail. Only the blast-radius claim was overstated.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

probe_x402_endpoint hardcodes first accepts[] asset — blocks sBTC-only wallets on arc0btc multi-asset endpoint (mrczypx01 bounty)

1 participant