Reading through the wallet accounting in src/wallet/reservation.ts and src/tools/modal.ts — the reservation layer does the right thing for the parallel-batch case it was built for. One edge looks unhandled.
postWithPayment puts a single 30s AbortController across the whole handshake, and the signed payment request shares that same signal (modal.ts L197 for the probe, L204 for the paid request). If the budget expires during the paid request, the fetch aborts and throws. The caller's finally then releases the reservation unconditionally (modal.ts L460-461).
But as the header comment in reservation.ts notes, x402 is fire-and-forget per request — an aborted request may already have been received and settled on-chain. In that case the money is gone and the reservation is released anyway, so totalReserved() under-counts and the next hold() sees headroom that doesn't exist.
Not a double-spend, and low severity — it's the same class of local-accounting drift the module already guards against, just on the ambiguous-failure path rather than the concurrency one.
Worth noting there's recent precedent: hpp-io/x402-mcp-bridge hit this exact shape and shipped v0.1.15 last week — on an ambiguous settle outcome they now hold the reservation rather than release it, so the cap can only ever err tight, never loose, and a genuinely-absent spend self-heals at the next ledger reset. Erring toward "spent" seems like the right default here too.
Happy to send a PR if useful — the change is small: distinguish an aborted/ambiguous failure from a definitive one before releasing in the finally.
Reading through the wallet accounting in
src/wallet/reservation.tsandsrc/tools/modal.ts— the reservation layer does the right thing for the parallel-batch case it was built for. One edge looks unhandled.postWithPaymentputs a single 30sAbortControlleracross the whole handshake, and the signed payment request shares that same signal (modal.tsL197 for the probe, L204 for the paid request). If the budget expires during the paid request, the fetch aborts and throws. The caller'sfinallythen releases the reservation unconditionally (modal.tsL460-461).But as the header comment in
reservation.tsnotes, x402 is fire-and-forget per request — an aborted request may already have been received and settled on-chain. In that case the money is gone and the reservation is released anyway, sototalReserved()under-counts and the nexthold()sees headroom that doesn't exist.Not a double-spend, and low severity — it's the same class of local-accounting drift the module already guards against, just on the ambiguous-failure path rather than the concurrency one.
Worth noting there's recent precedent:
hpp-io/x402-mcp-bridgehit this exact shape and shipped v0.1.15 last week — on an ambiguous settle outcome they now hold the reservation rather than release it, so the cap can only ever err tight, never loose, and a genuinely-absent spend self-heals at the next ledger reset. Erring toward "spent" seems like the right default here too.Happy to send a PR if useful — the change is small: distinguish an aborted/ambiguous failure from a definitive one before releasing in the
finally.