Skip to content

feat(payments): settle the metered actual at session close (ATXP up-to) — Phase 2#181

Merged
badjer merged 3 commits into
mainfrom
feat/payment-session-phase2-atxp-upto
Jun 17, 2026
Merged

feat(payments): settle the metered actual at session close (ATXP up-to) — Phase 2#181
badjer merged 3 commits into
mainfrom
feat/payment-session-phase2-atxp-upto

Conversation

@badjer

@badjer badjer commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Phase 2 — ATXP up-to (settle the metered actual)

Second phase of the streaming-payment-sessions work (Phase 1: #177). Makes session close settle the accumulated actual (session.spent) instead of the credential's full cap — the "up-to" semantics: authorize a cap, meter locally, settle what was actually spent.

What changes

  • ProtocolSettlement.settle(protocol, credential, context?, actualAmount?) — additive optional actualAmount. For ATXP it overrides the settle body's options[].amount, so auth /pay charges the actual (≤ the authorized cap) instead of the cap baked into the credential. x402 (settlementOverrides.amount) and mpp (voucher amount) get their mappings in their own phases.
  • settlePaymentSession passes session.spent as actualAmount.

What does NOT change

  • requirePayment({price}) and the resource-server API are identical. It charges the open session locally (zero network per charge); N calls now settle their sum at close.
  • No accounts/auth changes. The cap already comes from the challenge amount (max(minimumPayment, price)), and /pay already permits a charge ≤ spend_limit.
  • Backwards-compatible. For a single requirePayment(price), spent === price === cap, so the settled amount equals Phase 1's. No mode flag.

Verification

  • @atxp/server: 210 tests green (new: settle passes actualAmount === spent; multi-charge sums under cap; single-charge unchanged; cap-exceed rejects).
  • @atxp/express: 55 green (new integration test: real McpServer + transport, 3× requirePayment($0.001) → one /settle/atxp carrying the summed $0.003).
  • Live E2E (Base mainnet): a tool charging 3 × $0.001 against a $0.01-cap ATXP credential settled $0.003 (the metered actual) in a single on-chain /pay at close — txHash 0x5507…, confirmed within the settle window, no settle_unconfirmed/failed markers. Phase 1 would have settled the $0.01 cap.

Design: docs/STREAMING_PAYMENT_SESSIONS.md (accounts), Phase 2.

🤖 Generated with Claude Code

badjer and others added 2 commits June 17, 2026 14:45
At session close, settle the accumulated `session.spent` (the metered
"up-to" actual) instead of the credential's full cap. `ProtocolSettlement.settle`
gains an optional `actualAmount` that, for ATXP, overrides the settle body's
`options[].amount` so auth `/pay` charges the actual (<= the authorized cap)
rather than the cap baked into the credential.

The resource-server API is unchanged: `requirePayment({price})` keeps charging
the open session locally with zero network per charge, and N calls now settle
their sum at close. Backwards-compatible — for a single `requirePayment(price)`,
`spent === price === cap`, so the settled amount is identical to before. ATXP
only this phase; x402 (`settlementOverrides.amount`) and mpp (voucher amount)
get their actualAmount mappings in their own phases. No accounts/auth changes:
the cap already comes from the challenge amount and `/pay` already permits a
charge <= `spend_limit`.

Verified end-to-end on Base mainnet: a tool charging 3 x $0.001 against a
$0.01-cap ATXP credential settles $0.003 (the actual) in a single on-chain /pay
at close.

Design: docs/STREAMING_PAYMENT_SESSIONS.md (accounts), Phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… invariant

Three fixes from review:

1. (bug) Serialize the settled actual with BigNumber.toFixed(), not toString().
   toString() emits exponential notation below 1e-6 ("3e-7"), which auth /pay
   cannot parse as a decimal USDC string. Low blast radius (1e-6 is USDC's
   smallest unit) but free and strictly safer; regression test added.

2. Correct the "spent === price === cap" invariant: a single requirePayment
   still has spent < cap when the cap was inflated by the server's
   minimumPayment (cap = max(minimumPayment, price)). Softened the comment and
   added a single-charge test asserting actualAmount === price < cap — the case
   that actually demonstrates "up-to" for a one-shot call.

3. x402/mpp ignore actualAmount by design this phase, so a multi-charge session
   over-settles (settles the cap). Emit a greppable `settle_actual_dropped`
   warn so that interim overcharge is visible rather than silent, until their
   up-to mappings land.

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 review — addressed all three in 02f0444.

1. Exponential serialization (real bug) — fixed: actualAmount.toFixed() instead of .toString(), so sub-1e-6 totals never serialize as "3e-7". Regression test asserts 0.0000003"0.0000003" and contains no e.

2. Inaccurate spent === price === cap invariant — corrected. The comment now states spent is the sum of prices (≤ cap), and notes spent < cap even for a single charge when minimumPayment inflated the cap. Renamed the misleading one-shot test and added a new single-charge test with cap > price asserting actualAmount === price and < cap — the case that actually demonstrates up-to for a one-shot call and guards against regressing to cap-settling.

3. x402/mpp silent overcharge — added a greppable settle_actual_dropped protocol=… actual=… logger.warn in buildRequestBody whenever actualAmount is supplied for a non-ATXP protocol, so the interim overcharge is visible until those phases wire their up-to mappings. Test asserts the marker fires for x402.

Minors: left as-is per your assessment (the >6-decimal precision passthrough is pre-existing and shared with the challenge path; the array cast is practically unreachable).

Suites green: atxp-server 213, atxp-express 55, tsc clean. No re-E2E — for the tested $0.003, toFixed() yields the identical "0.003"; the fix only changes sub-1e-6 values.

…n (PR #181)

Re-review follow-ups:
- Remove the duplicate "spent === price === cap" claim at the settle call site;
  it contradicted the corrected cap doc comment in the same file.
- Use session.spent.toFixed() in the settle_failed_at_close log marker, matching
  the toFixed() fix applied to the settled amount (no exponential notation for
  sub-1e-6 values).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@badjer badjer merged commit 868f331 into main Jun 17, 2026
1 check passed
@badjer badjer deleted the feat/payment-session-phase2-atxp-upto branch June 17, 2026 22:53
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