feat(payments): relocate settlement to session close (phase 1)#177
Merged
Conversation
Introduce an implicit, request-scoped PaymentSession and move payment settlement off the inbound express middleware and onto session close at the end of the response. - @atxp/server: new PaymentSession (cap/spent/charge); ALS context holds the session; requirePayment charges it locally and falls back to paymentServer.charge only when no session is open (preserves Cloudflare + direct-caller behavior). Settlement happens once at close, idempotently. - @atxp/express: middleware stashes the credential and opens the session instead of settling eagerly; settle is awaited inside the existing res.end interception (before the response finishes), so it stays observable and runs after the route's requirePayment charges accumulate. - Fixed amounts only; cap is best-effort per protocol. No auth/accounts changes. Backwards-compatible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- deriveCap: branch MPP amount scaling on challenge.method — Tempo uses human-readable decimals (no /1e6), Solana uses micro-units (/1e6); unknown method falls back to decimal to avoid under-scaling the cap and falsely re-challenging already-paid requests. (blocking #1) - settle-at-close no longer depends on AsyncLocalStorage at res.end: the express middleware captures the session + server/destination/appName/logger in a closure built inside withATXPContext, so SSE/abort/timeout paths (where res.end fires outside the ALS chain) can't silently skip billing. Adds an integration test against a real McpServer + StreamableHTTPServerTransport. (blocking #2) - settle-failure logs a greppable `settle_failed_at_close protocol=… amount=…` marker; adds an express test that a failed close-time settle still returns 200 and logs. (no retry/outbox yet — tracked as follow-up) (#3) - close-time settle bounded by a 10s timeout so a hung auth can't stall the client; finalizeEnd guards against double origEnd; corrected the buffered-vs-SSE comment; documented the cap as a forward-looking (Phase-1-inert) guard. (minors) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
Author
|
Thanks for the thorough review — addressed in Blocking
Should-fix
Minors: added a 10s timeout on the close-time settle (hung auth can't stall the client); Test count: |
- Tighten the ALS-independence guard: add a test that ends the response from OUTSIDE the withATXPContext run (where getStore() is null) and asserts settle still fires. Verified it fails against the old getStore()-based close and passes against the captured-closure — so it actually guards the #2 regression. - Distinguish the close-time timeout marker (`settle_unconfirmed_at_close`, warn) from a hard settle failure (`settle_failed_at_close`, error): the in-flight settle isn't aborted on timeout and may still complete, so reconciliation must not treat a timeout as definitely-unbilled. - Hoist the duplicate destination getAccountId() — resolved once and reused for the settlement context and the close-time settle closure. - Comment hygiene: drop project-timeline ("Phase 1") framing in favor of stated invariants; anchor forward-looking notes to tracking issues + the design doc (cap → upto work; Cloudflare TODO → #179; retry/outbox → #178). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
Author
|
Re-review addressed in
Out-of-scope items filed: #179 (Cloudflare onto session model), #180 (dedupe hygiene), accounts#800 ( Tests: server 206, express 54, cloudflare 23. |
A legitimate ATXP IOU->USDC `/pay` (two on-chain transfers + Base confirmations) runs ~14.5s end-to-end — longer than the 10s timeout, which made every IOU settle log a spurious `settle_unconfirmed_at_close` despite succeeding. 30s sits well above observed on-chain latency so the marker flags genuinely stuck settles, while still bounding a hung auth server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 1 of the streaming payment-sessions plan
First phase of the streaming/per-token payment work (design:
accounts/docs/STREAMING_PAYMENT_SESSIONS.md). This phase relocates payment settlement from the inbound middleware to sessioncloseat the end of the response, introducing an implicit, request-scopedPaymentSession. Fixed amounts only — noupto/partial settle yet. No auth/accounts changes. Backwards-compatible.Why
The middleware currently settles a payment credential eagerly on the inbound request (
atxpExpress.ts), before any route code runs. Streaming/per-token metering (later phases) requires settling the actual amount after the work is done. This phase moves the settle toclosewithout changing amounts, as a safe, behavior-preserving step.What changed
@atxp/serverPaymentSession(cap/spent/charge(): boolean) +paymentSession()accessor, stored in the ALS context (paymentSession.ts,atxpContext.ts).requirePaymentcharges the implicit session locally when one is open; falls back topaymentServer.chargewhen no session is open (preserves Cloudflare + direct callers). On cap-exceed / no-credential it still throws the omni-challenge — unchanged.close(closePaymentSession→ProtocolSettlement.settle), idempotent.@atxp/expressclose()(settle) is awaited inside the existingres.endinterception (before the response finishes), so it stays observable/awaited and runs after the route'srequirePaymentcharges accumulate.@atxp/cloudflare: left on its existing path with a// TODO(phase-1)note (it has no settlement path today; therequirePaymentfallback preserves its behavior).Tests
@atxp/server: 203/204 pass (the 1 failure —atxpContexttoken-exp— is pre-existing onmain, unrelated to this change).@atxp/express: 50/50.@atxp/cloudflare: 23/23.closeafter the route (ordering['route','settle']), one settlement for multiplerequirePaymentcalls, no inbound/settle, idempotent close.Verified live (local stack)
accounts + auth +
dev:resource+dev:cli: the credential→settle flow shows the middleware opening the session, the route loggingCharged … to session, andPOST /settle/{protocol}firing atcloseon the retry (never inbound).Known Phase-1 tradeoff
Settle-at-close means a settle failure is logged but does not block the already-served response (no mid-request re-challenge possible once served). Acceptable for fixed amounts; the reservation/no-debt hardening is a later phase.
🤖 Generated with Claude Code
Live on-chain verification (records #4 —
/settlepays the destination)Full local stack (accounts + auth +
dev:resource+dev:cli), ATXP on Base mainnet, real 0.001 USDC:Charged 0.001 to session …→Settled atxp at session close: txHash=0x3646…7015e4, amount=0.001POST /settle/atxp→ATXP settle: success {settledAmount:"0.001", chain:"base", destinationAccountId:…DBejk…, transactionId:0x3646…}/pay: IOU→USDC via sponsor (tx0x7e49…) → USDC transfer to destination confirmed (tx0x6c16…) →payment completed 0x3646…→POST /pay 200So
/settlemoved 0.001 to the destination on-chain (destination credited; payer's IOU converted + spent once) —/chargewas internal ledger bookkeeping, and dropping it when a session is open does not change who gets paid. Balance-delta confirmed, not just call ordering.Test status
atxp-server206/206,atxp-express54/54 (incl. the ALS-detached settle guard, verified to fail against the oldgetStore()close),atxp-cloudflare23/23. (The earlier "203/204" was a local artifact — theatxpContextexptest is time-sensitive and passes on a clean run.)Review fixes landed in
9da28ce(round 1) andb4059ee(round 2). Follow-ups: #178 (retry/outbox), #179 (Cloudflare onto session model), #180 (dedupe hygiene); accounts#800 (/authorizeall-chains resilience).