feat(payments): settle the metered actual at session close (ATXP up-to) — Phase 2#181
Conversation
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>
|
Thanks for the review — addressed all three in 02f0444. 1. Exponential serialization (real bug) — fixed: 2. Inaccurate 3. x402/mpp silent overcharge — added a greppable 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, |
…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>
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 optionalactualAmount. For ATXP it overrides the settle body'soptions[].amount, so auth/paycharges 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.settlePaymentSessionpassessession.spentasactualAmount.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.max(minimumPayment, price)), and/payalready permits a charge ≤spend_limit.requirePayment(price),spent === price === cap, so the settled amount equals Phase 1's. Nomodeflag.Verification
@atxp/server: 210 tests green (new: settle passesactualAmount === spent; multi-charge sums under cap; single-charge unchanged; cap-exceed rejects).@atxp/express: 55 green (new integration test: realMcpServer+ transport, 3×requirePayment($0.001)→ one/settle/atxpcarrying the summed$0.003).$0.001against a$0.01-cap ATXP credential settled$0.003(the metered actual) in a single on-chain/payat close —txHash 0x5507…, confirmed within the settle window, nosettle_unconfirmed/failedmarkers. Phase 1 would have settled the$0.01cap.Design:
docs/STREAMING_PAYMENT_SESSIONS.md(accounts), Phase 2.🤖 Generated with Claude Code