Skip to content

feat(payments): relocate settlement to session close (phase 1)#177

Merged
badjer merged 4 commits into
mainfrom
feat/payment-session-phase-1
Jun 17, 2026
Merged

feat(payments): relocate settlement to session close (phase 1)#177
badjer merged 4 commits into
mainfrom
feat/payment-session-phase-1

Conversation

@badjer

@badjer badjer commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

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 session close at the end of the response, introducing an implicit, request-scoped PaymentSession. Fixed amounts only — no upto/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 to close without changing amounts, as a safe, behavior-preserving step.

What changed

  • @atxp/server
    • New PaymentSession (cap / spent / charge(): boolean) + paymentSession() accessor, stored in the ALS context (paymentSession.ts, atxpContext.ts).
    • requirePayment charges the implicit session locally when one is open; falls back to paymentServer.charge when no session is open (preserves Cloudflare + direct callers). On cap-exceed / no-credential it still throws the omni-challenge — unchanged.
    • Settlement happens once at close (closePaymentSessionProtocolSettlement.settle), idempotent.
  • @atxp/express
    • Middleware no longer settles eagerly; it stashes the credential and opens the session.
    • close() (settle) is awaited inside the existing res.end interception (before the response finishes), so it stays observable/awaited and runs after the route's requirePayment charges accumulate.
  • @atxp/cloudflare: left on its existing path with a // TODO(phase-1) note (it has no settlement path today; the requirePayment fallback preserves its behavior).

Tests

  • @atxp/server: 203/204 pass (the 1 failure — atxpContext token-exp— is pre-existing on main, unrelated to this change).
  • @atxp/express: 50/50. @atxp/cloudflare: 23/23.
  • New/updated tests assert the contract: settle fires from close after the route (ordering ['route','settle']), one settlement for multiple requirePayment calls, 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 logging Charged … to session, and POST /settle/{protocol} firing at close on 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/settle pays the destination)

Full local stack (accounts + auth + dev:resource + dev:cli), ATXP on Base mainnet, real 0.001 USDC:

  • resource: Charged 0.001 to session …Settled atxp at session close: txHash=0x3646…7015e4, amount=0.001
  • auth: POST /settle/atxpATXP settle: success {settledAmount:"0.001", chain:"base", destinationAccountId:…DBejk…, transactionId:0x3646…}
  • accounts /pay: IOU→USDC via sponsor (tx 0x7e49…) → USDC transfer to destination confirmed (tx 0x6c16…) → payment completed 0x3646…POST /pay 200

So /settle moved 0.001 to the destination on-chain (destination credited; payer's IOU converted + spent once) — /charge was 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-server 206/206, atxp-express 54/54 (incl. the ALS-detached settle guard, verified to fail against the old getStore() close), atxp-cloudflare 23/23. (The earlier "203/204" was a local artifact — the atxpContext exp test is time-sensitive and passes on a clean run.)

Review fixes landed in 9da28ce (round 1) and b4059ee (round 2). Follow-ups: #178 (retry/outbox), #179 (Cloudflare onto session model), #180 (dedupe hygiene); accounts#800 (/authorize all-chains resilience).

badjer and others added 2 commits June 17, 2026 09:30
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>
@badjer

badjer commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — addressed in 9da28ce.

Blocking

  • Use ATXP account for dev:cli and dev:resource #1 deriveCap Tempo mis-scaling: deriveCap now branches on challenge.method — Solana → /1e6 (micro-units), Tempo → as-is (decimal); unknown method falls back to decimal to avoid under-scaling. Added Tempo + unknown-method cap tests.
  • [security] npm audit fix #2 settle depends on ALS at res.end: the middleware now captures the session + server/destination/appName/logger in a closure built inside withATXPContext and passes that bound settle to the res.end hook, so settlement no longer depends on getStore() at res.end (SSE/abort/timeout safe). Added paymentSessionMcp.test.ts — an integration test against a real McpServer + StreamableHTTPServerTransport asserting /settle/atxp fires once at close through the transport.

Should-fix

Minors: added a 10s timeout on the close-time settle (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.

Test count: atxp-server 206/206, atxp-express 53/53. The atxpContext exp test passed here — it's time-sensitive; my earlier "203/204" was a local artifact.

- 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>
@badjer

badjer commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Re-review addressed in b4059ee.

  • [security] npm audit fix #2 test now actually guards the regression. Added a test that ends the response from outside the withATXPContext run (where getStore() is null). I verified it the way you asked: with the captured-closure code it passes; reverting onBeforeEnd to the old getStore()-based closePaymentSession makes it fail (expected [] to have a length of 1 but got +0 — settle skipped). So it fails against the bug it guards.
  • Use the correct audience when creating a token #3 timeout false-positive. Split the markers: hard failure logs settle_failed_at_close (error, from settlePaymentSession); the close-time timeout logs settle_unconfirmed_at_close (warn) and notes the in-flight settle may still complete — so reconciliation won't treat a late-but-successful settle as unbilled.
  • Introduce BaseAccount #4 balance evidence pasted into the PR description (destination credited on-chain via /settle, tx 0x3646…; payer IOU converted + spent once).
  • Minors: hoisted the duplicate getAccountId() (resolved once, reused); dropped the "Phase 1" comment framing in favor of stated invariants, anchored to Payment sessions: retry/outbox for close-time settle failures #178/Bring Cloudflare adapter onto the payment-session (settle-at-close) model #179 + the design doc.

Out-of-scope items filed: #179 (Cloudflare onto session model), #180 (dedupe hygiene), accounts#800 (/authorize all-chains resilience), #178 (retry/outbox).

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>
@badjer badjer merged commit 5787827 into main Jun 17, 2026
1 check passed
@badjer badjer deleted the feat/payment-session-phase-1 branch June 17, 2026 20:55
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.

1 participant