Skip to content

fix(wallet): hold reservation on ambiguous x402 settlement - #129

Open
GentechLabs wants to merge 1 commit into
BlockRunAI:mainfrom
GentechLabs:fix/x402-ambiguous-settlement-hold
Open

fix(wallet): hold reservation on ambiguous x402 settlement#129
GentechLabs wants to merge 1 commit into
BlockRunAI:mainfrom
GentechLabs:fix/x402-ambiguous-settlement-hold

Conversation

@GentechLabs

Copy link
Copy Markdown

Closes #128

Problem

x402 is fire-and-forget per request — there's no real "hold" on-chain. postWithPayment puts a single 30s AbortController across the whole handshake, and the signed, paid request shares that same signal. If the budget expires during the paid request, the fetch aborts and throws. The caller's finally then released the reservation unconditionally (modal.ts release in the finally block).

But an aborted paid request may already have been received and settled on-chain. In that case the money is gone yet the reservation is released anyway, so totalReserved() under-counts and the next hold() sees headroom that doesn't exist.

Fix

postWithPayment now tracks whether it actually dispatched the signed request (paidRequestDispatched) and surfaces a settlementAmbiguous flag on the abort/error path. Each Modal paid handler (create, exec, status, terminate) then:

  • Holds the reservation (invalidates the balance cache so the next hold refetches the true on-chain balance) when the outcome is ambiguous — erring tight, never loose.
  • Releases normally only on a definitive outcome.

A genuinely-absent spend self-heals at the next balance refetch. This mirrors the hpp-io/x402-mcp-bridge v0.1.15 pattern cited in the issue.

Verification

  • npm run build passes.
  • New regression test in test/local.mjs asserts the ambiguity flag is surfaced and all four Modal paid handlers route ambiguous settlements to a hold (not a release). Test passes.
  • Full npm test has no new failures (the pre-existing failures are unrelated environment/CLI/session tests, none touch modal.ts).

Note

This fills the same class of local-accounting drift the reservation module already guards against on the concurrency path — now also covered on the ambiguous-failure path. Happy to adjust if the maintainer prefers a different default.

x402 is fire-and-forget per request. If the shared 30s budget (or parent
abort signal) expires mid-handshake after the signed, paid request is
dispatched, the server may already have settled the payment on-chain —
even though we never saw the response.

Previously the caller's finally block released the reservation
unconditionally on this ambiguous-failure path, so totalReserved()
under-counted and the next hold() saw headroom that didn't exist.

Now postWithPayment tracks whether it dispatched the signed request and
surfaces settlementAmbiguous on abort. Each Modal paid handler (create,
exec, status, terminate) keeps the reservation held and invalidates the
balance cache when the outcome is ambiguous — erring tight, never loose.
A genuinely-absent spend self-heals on the next balance refetch.

Ref BlockRunAI#128
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.

walletReservation releases on an aborted x402 payment — but an aborted payment may still have settled

1 participant