From faa247f434826e83e7bdb7b29b933c11eee1cb29 Mon Sep 17 00:00:00 2001 From: bdj Date: Wed, 17 Jun 2026 14:45:43 -0700 Subject: [PATCH 1/3] feat(payments): settle the metered actual at session close (atxp up-to) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/paymentSessionMcp.test.ts | 55 +++++++++++++++++++ .../atxp-server/src/paymentSession.test.ts | 35 ++++++++++++ packages/atxp-server/src/paymentSession.ts | 25 +++++---- packages/atxp-server/src/protocol.test.ts | 40 ++++++++++++++ packages/atxp-server/src/protocol.ts | 29 ++++++++-- 5 files changed, 169 insertions(+), 15 deletions(-) diff --git a/packages/atxp-express/src/paymentSessionMcp.test.ts b/packages/atxp-express/src/paymentSessionMcp.test.ts index 8860711..9de4e52 100644 --- a/packages/atxp-express/src/paymentSessionMcp.test.ts +++ b/packages/atxp-express/src/paymentSessionMcp.test.ts @@ -123,6 +123,61 @@ describe('settlement fires through a real McpServer + StreamableHTTPServerTransp expect(String(settleCalls()[0][0])).toContain('/settle/atxp'); }); + it('settles the summed actual ($0.003) — not the cap ($0.01) — when a tool charges 3x $0.001', async () => { + // ATXP credential carrying options with the authorized cap ($0.01). The + // express path falls back to the credential's options for the settle body, + // and deriveCap reads options[].amount → cap $0.01. + const meteredCredential = JSON.stringify({ + sourceAccountId: 'atxp_acct_test123', + sourceAccountToken: 'tok_abc', + options: [{ network: 'base', currency: 'USDC', address: '0xdest', amount: '0.01' }], + }); + + const router = atxpExpress(TH.config({ + oAuthClient: TH.oAuthClient({ introspectResult: TH.tokenData({ active: true, sub: 'test-user' }) }), + })); + const app = express(); + app.use(express.json()); + app.use(router); + app.post('/', async (req: Request, res: Response) => { + const server = new McpServer({ name: 'test', version: '1.0.0' }, { capabilities: { logging: {} } }); + server.registerTool( + 'metered-tool', + { description: 'meters 3x $0.001', inputSchema: { message: z.string().optional() } }, + async (): Promise => { + // 3 charges of $0.001 each → summed actual $0.003 < cap $0.01. + await requirePayment({ price: BigNumber(0.001) }); + await requirePayment({ price: BigNumber(0.001) }); + await requirePayment({ price: BigNumber(0.001) }); + return { content: [{ type: 'text', text: 'metered' }] }; + }, + ); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + res.on('close', () => { transport.close(); server.close(); }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + }); + + const response = await request(app) + .post('/') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .set('Authorization', 'Bearer test-access-token') + .set('X-ATXP-PAYMENT', meteredCredential) + .send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'metered-tool', arguments: {} } }); + + expect(response.status).toBe(200); + + // Exactly one settle, to /settle/atxp. + expect(settleCalls()).toHaveLength(1); + expect(String(settleCalls()[0][0])).toContain('/settle/atxp'); + + // The settled amount is the SUMMED ACTUAL ($0.003), not the cap ($0.01). + const settleBody = JSON.parse((settleCalls()[0][1] as { body: string }).body); + expect(settleBody.options).toHaveLength(1); + expect(settleBody.options[0].amount).toBe('0.003'); + }); + it('does NOT settle through the transport when the tool charges nothing', async () => { const router = atxpExpress(TH.config({ oAuthClient: TH.oAuthClient({ introspectResult: TH.tokenData({ active: true, sub: 'test-user' }) }), diff --git a/packages/atxp-server/src/paymentSession.test.ts b/packages/atxp-server/src/paymentSession.test.ts index 3bd5b4e..d60a82d 100644 --- a/packages/atxp-server/src/paymentSession.test.ts +++ b/packages/atxp-server/src/paymentSession.test.ts @@ -118,4 +118,39 @@ describe('settlePaymentSession', () => { expect(settleSpy).toHaveBeenCalledTimes(1); expect(session.settled).toBe(true); }); + + // "up-to" semantics: settle the accumulated actual (spent), not the cap. + it('settles the accumulated actual (spent) as actualAmount', async () => { + const settleSpy = vi.spyOn(ProtocolSettlement.prototype, 'settle').mockResolvedValue({ txHash: '0xabc', settledAmount: '0.003' }); + // Cap $0.01 from the credential options; charge 3x $0.001 → spent $0.003 < cap. + const credential = JSON.stringify({ sourceAccountId: 'atxp_acct_1', options: [{ amount: '0.01' }] }); + const session = new PaymentSessionState('atxp', credential, {}, logger); + session.charge(BigNumber(0.001)); + session.charge(BigNumber(0.001)); + session.charge(BigNumber(0.001)); + expect(session.spent.toNumber()).toBeCloseTo(0.003); + expect(session.cap.toNumber()).toBe(0.01); + + await settlePaymentSession(session, 'https://auth.atxp.ai', 'base:dest', undefined, logger); + + expect(settleSpy).toHaveBeenCalledTimes(1); + // 4th positional arg is actualAmount; it must equal spent ($0.003 < cap). + const actualAmount = settleSpy.mock.calls[0][3] as BigNumber; + expect(actualAmount.toString()).toBe(session.spent.toString()); + expect(actualAmount.toNumber()).toBeCloseTo(0.003); + }); + + it('settles an actual equal to the cap for a single charge (unchanged one-shot path)', async () => { + const settleSpy = vi.spyOn(ProtocolSettlement.prototype, 'settle').mockResolvedValue({ txHash: '0xabc', settledAmount: '0.01' }); + const credential = JSON.stringify({ sourceAccountId: 'atxp_acct_1', options: [{ amount: '0.01' }] }); + const session = new PaymentSessionState('atxp', credential, {}, logger); + session.charge(BigNumber(0.01)); + + await settlePaymentSession(session, 'https://auth.atxp.ai', 'base:dest', undefined, logger); + + const actualAmount = settleSpy.mock.calls[0][3] as BigNumber; + // spent === price === cap, so the settled actual equals the cap. + expect(actualAmount.toNumber()).toBe(0.01); + expect(actualAmount.toNumber()).toBe(session.cap.toNumber()); + }); }); diff --git a/packages/atxp-server/src/paymentSession.ts b/packages/atxp-server/src/paymentSession.ts index cefa4af..e915d5e 100644 --- a/packages/atxp-server/src/paymentSession.ts +++ b/packages/atxp-server/src/paymentSession.ts @@ -13,16 +13,15 @@ import type { DetectedCredential } from "./atxpContext.js"; */ export interface PaymentSession { /** - * Authorized amount derived from the credential. + * Authorized amount derived from the credential — the ceiling for charges. * - * Guard only: settlement currently settles the credential's own amount (see - * settlePaymentSession), NOT `spent`, so the cap does not bound the settled - * amount — it merely rejects local charges that would exceed it. Settling - * `spent` up to the cap arrives with the `upto` scheme (streaming-payment- - * sessions design doc: - * https://github.com/circuitandchisel/accounts/blob/main/docs/STREAMING_PAYMENT_SESSIONS.md). - * For ATXP credentials carrying no amount, the cap is Infinity so the - * single-charge path always works. + * "up-to" semantics: settlement settles the accumulated `spent` (≤ cap), not + * the cap itself (see settlePaymentSession). The cap bounds local charges, + * rejecting any that would exceed it. For a single requirePayment(price), + * `spent === price === cap`, so the settled amount equals the credential's + * amount. For ATXP credentials carrying no amount, the cap is Infinity so the + * single-charge path always works. (streaming-payment-sessions design doc: + * https://github.com/circuitandchisel/accounts/blob/main/docs/STREAMING_PAYMENT_SESSIONS.md) */ readonly cap: BigNumber; /** Accumulated charges recorded against this session. */ @@ -39,8 +38,8 @@ const USDC_ATOMIC = 1e6; * * Best-effort: if the amount cannot be parsed reliably for a protocol, returns * Infinity and logs a warning so the single-charge path always works. - * Settlement settles the credential's own amount; the cap is a guard, not the - * source of the settled amount. + * Settlement settles the accumulated `spent` (≤ cap); the cap is the ceiling + * that bounds local charges, not directly the settled amount. */ function deriveCap( protocol: PaymentProtocol, @@ -165,6 +164,10 @@ export async function settlePaymentSession( session.protocol, session.credential, session.context, + // "up-to" semantics: settle the accumulated actual (≤ cap), not the cap. + // For a single requirePayment(price), spent === price === cap, so this is + // identical to settling the credential's amount. + session.spent, ); logger.info(`Settled ${session.protocol} at session close: txHash=${result.txHash ?? ''}, amount=${result.settledAmount}`); } catch (error) { diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index f5b114f..7de31fd 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BigNumber } from 'bignumber.js'; import { detectProtocol, ProtocolSettlement } from './protocol.js'; describe('detectProtocol', () => { @@ -248,6 +249,45 @@ describe('ProtocolSettlement', () => { ); }); + it('overrides ATXP options[].amount with the metered actualAmount', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ txHash: '0xupto', settledAmount: '0.003' }), + }); + + // Authorized cap is $0.01 (the option amount); the metered actual is $0.003. + await settlement.settle( + 'atxp', + 'atxp-jwt-token', + { options: [{ network: 'base', currency: 'USDC', address: '0x123', amount: '0.01' }] }, + BigNumber(0.003), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + // Every option carries the actual ($0.003), NOT the cap ($0.01). + expect(body.options).toHaveLength(1); + expect(body.options[0].amount).toBe('0.003'); + // Other option fields are preserved. + expect(body.options[0].network).toBe('base'); + expect(body.options[0].address).toBe('0x123'); + }); + + it('settles the credential/context amount unchanged when actualAmount is omitted', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ txHash: '0xnoop', settledAmount: '0.01' }), + }); + + await settlement.settle( + 'atxp', + 'atxp-jwt-token', + { options: [{ network: 'base', currency: 'USDC', address: '0x123', amount: '0.01' }] }, + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.options[0].amount).toBe('0.01'); + }); + it('should call /settle/mpp with standard MPP credential', async () => { mockFetch.mockResolvedValue({ ok: true, diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index 758f2b1..5305ab6 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -1,3 +1,4 @@ +import { BigNumber } from "bignumber.js"; import { AuthorizationServerUrl, FetchLike, Logger, type PaymentProtocol } from "@atxp/common"; // Re-export from common so consumers of @atxp/server get the same type export type { PaymentProtocol } from "@atxp/common"; @@ -286,12 +287,17 @@ export class ProtocolSettlement { /** * Settle a payment at request end. * Calls auth `/settle/{protocol}` to finalize the payment. + * + * `actualAmount` (optional) carries the metered "up-to" amount actually spent + * (≤ the authorized cap). When provided, the ATXP settle body charges this + * amount instead of the cap baked into the credential/context. When omitted, + * behavior is exactly as before (settle the credential's own amount). */ - async settle(protocol: PaymentProtocol, credential: string, context?: SettlementContext): Promise { + async settle(protocol: PaymentProtocol, credential: string, context?: SettlementContext, actualAmount?: BigNumber): Promise { const url = new URL(`/settle/${protocol}`, this.authServer); this.logger.debug(`Settling ${protocol} credential at ${url}`); - const body = this.buildRequestBody(protocol, credential, context); + const body = this.buildRequestBody(protocol, credential, context, actualAmount); const response = await this.fetchFn(url.toString(), { method: 'POST', @@ -312,8 +318,14 @@ export class ProtocolSettlement { /** * Build the protocol-specific request body for verify/settle calls. + * + * `actualAmount` (optional, ATXP only this phase) is the metered "up-to" + * amount actually spent. When provided, it overrides the ATXP options[].amount + * so /pay charges the actual instead of the authorized cap. x402 and mpp ignore + * it for now — their "up-to" mappings (x402 settlementOverrides.amount, mpp + * voucher amount) land in their own phases. */ - private buildRequestBody(protocol: PaymentProtocol, credential: string, context?: SettlementContext): unknown { + private buildRequestBody(protocol: PaymentProtocol, credential: string, context?: SettlementContext, actualAmount?: BigNumber): unknown { if (protocol === 'x402') { // X402: auth expects { payload, paymentRequirements } // The credential is the base64-encoded PAYMENT-SIGNATURE header containing the payload. @@ -389,7 +401,16 @@ export class ProtocolSettlement { // Prefer context options (from requirePayment's pricing config) over the // credential's options. The server has accurate destination chain info; // the credential may have stale or "unknown" network values from accounts. - const options = context?.options ?? parsed.options ?? []; + let options = (context?.options ?? parsed.options ?? []) as Array>; + + // "up-to" semantics: when an actual metered amount is supplied, settle that + // (≤ the authorized cap) instead of the cap baked into the option. This is + // what makes /pay charge the actual. A decimal USDC string matches the + // human-readable amount format ATXP options already use. + if (actualAmount) { + const actual = actualAmount.toString(); + options = options.map(opt => ({ ...opt, amount: actual })); + } return { sourceAccountId: parsed.sourceAccountId ?? context?.sourceAccountId, From 02f0444d48f2448fcdcb7cfb2c3425784c47337b Mon Sep 17 00:00:00 2001 From: bdj Date: Wed, 17 Jun 2026 15:33:21 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(payments):=20address=20PR=20#181=20revi?= =?UTF-8?q?ew=20=E2=80=94=20actual-amount=20serialization=20+=20invariant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../atxp-server/src/paymentSession.test.ts | 20 +++++++++++-- packages/atxp-server/src/paymentSession.ts | 10 ++++--- packages/atxp-server/src/protocol.test.ts | 29 +++++++++++++++++++ packages/atxp-server/src/protocol.ts | 15 ++++++++-- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/atxp-server/src/paymentSession.test.ts b/packages/atxp-server/src/paymentSession.test.ts index d60a82d..621612f 100644 --- a/packages/atxp-server/src/paymentSession.test.ts +++ b/packages/atxp-server/src/paymentSession.test.ts @@ -140,7 +140,7 @@ describe('settlePaymentSession', () => { expect(actualAmount.toNumber()).toBeCloseTo(0.003); }); - it('settles an actual equal to the cap for a single charge (unchanged one-shot path)', async () => { + it('settles an actual equal to the cap when the single charge equals the cap (price >= minimumPayment)', async () => { const settleSpy = vi.spyOn(ProtocolSettlement.prototype, 'settle').mockResolvedValue({ txHash: '0xabc', settledAmount: '0.01' }); const credential = JSON.stringify({ sourceAccountId: 'atxp_acct_1', options: [{ amount: '0.01' }] }); const session = new PaymentSessionState('atxp', credential, {}, logger); @@ -149,8 +149,24 @@ describe('settlePaymentSession', () => { await settlePaymentSession(session, 'https://auth.atxp.ai', 'base:dest', undefined, logger); const actualAmount = settleSpy.mock.calls[0][3] as BigNumber; - // spent === price === cap, so the settled actual equals the cap. + // Here the single charge happens to equal the cap, so actual === cap. expect(actualAmount.toNumber()).toBe(0.01); expect(actualAmount.toNumber()).toBe(session.cap.toNumber()); }); + + it('settles the metered price (< cap) for a single charge when the cap was inflated by minimumPayment', async () => { + const settleSpy = vi.spyOn(ProtocolSettlement.prototype, 'settle').mockResolvedValue({ txHash: '0xabc', settledAmount: '0.001' }); + // Cap $0.01 (the challenge amount = max(minimumPayment, price)); the tool's + // actual price is $0.001. A single charge settles the price, NOT the cap — + // "up-to" for a one-shot call. Guards against regressing to cap-settling. + const credential = JSON.stringify({ sourceAccountId: 'atxp_acct_1', options: [{ amount: '0.01' }] }); + const session = new PaymentSessionState('atxp', credential, {}, logger); + session.charge(BigNumber(0.001)); + + await settlePaymentSession(session, 'https://auth.atxp.ai', 'base:dest', undefined, logger); + + const actualAmount = settleSpy.mock.calls[0][3] as BigNumber; + expect(actualAmount.toNumber()).toBeCloseTo(0.001); + expect(actualAmount.isLessThan(session.cap)).toBe(true); + }); }); diff --git a/packages/atxp-server/src/paymentSession.ts b/packages/atxp-server/src/paymentSession.ts index e915d5e..3b19181 100644 --- a/packages/atxp-server/src/paymentSession.ts +++ b/packages/atxp-server/src/paymentSession.ts @@ -17,10 +17,12 @@ export interface PaymentSession { * * "up-to" semantics: settlement settles the accumulated `spent` (≤ cap), not * the cap itself (see settlePaymentSession). The cap bounds local charges, - * rejecting any that would exceed it. For a single requirePayment(price), - * `spent === price === cap`, so the settled amount equals the credential's - * amount. For ATXP credentials carrying no amount, the cap is Infinity so the - * single-charge path always works. (streaming-payment-sessions design doc: + * rejecting any that would exceed it. Each `requirePayment(price)` charges + * `price`, so `spent` is the sum of prices (≤ cap) — note `spent < cap` even + * for a single charge when the cap was inflated by the server's + * `minimumPayment` (cap = max(minimumPayment, price)). For ATXP credentials + * carrying no amount, the cap is Infinity so the single-charge path always + * works. (streaming-payment-sessions design doc: * https://github.com/circuitandchisel/accounts/blob/main/docs/STREAMING_PAYMENT_SESSIONS.md) */ readonly cap: BigNumber; diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index 7de31fd..e335236 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -288,6 +288,35 @@ describe('ProtocolSettlement', () => { expect(body.options[0].amount).toBe('0.01'); }); + it('serializes a sub-1e-6 actualAmount as a decimal string, never exponential notation', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ txHash: '0xtiny', settledAmount: '0.0000003' }), + }); + + // BigNumber.toString() would emit "3e-7" (EXPONENTIAL_AT default), which + // /pay cannot parse as a decimal USDC string. toFixed() keeps it decimal. + await settlement.settle( + 'atxp', + 'atxp-jwt-token', + { options: [{ network: 'base', currency: 'USDC', address: '0x123', amount: '0.01' }] }, + BigNumber('0.0000003'), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.options[0].amount).toBe('0.0000003'); + expect(body.options[0].amount).not.toContain('e'); + }); + + it('warns (greppable) and settles the cap when actualAmount is supplied for x402 (up-to not yet wired)', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xx402', settledAmount: '0.01' }) }); + const credential = Buffer.from(JSON.stringify({ signature: '0xabc' })).toString('base64'); + + await settlement.settle('x402', credential, { paymentRequirements: { network: 'base' } }, BigNumber(0.003)); + + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('settle_actual_dropped protocol=x402')); + }); + it('should call /settle/mpp with standard MPP credential', async () => { mockFetch.mockResolvedValue({ ok: true, diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index 5305ab6..08eec08 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -326,6 +326,15 @@ export class ProtocolSettlement { * voucher amount) land in their own phases. */ private buildRequestBody(protocol: PaymentProtocol, credential: string, context?: SettlementContext, actualAmount?: BigNumber): unknown { + // actualAmount (the metered "up-to" amount) is only wired for ATXP this phase. + // For x402/mpp the settle body still carries the credential's cap, so a + // multi-charge session over-settles relative to the metered actual until + // their up-to mappings land (x402 settlementOverrides.amount, mpp voucher). + // Emit a greppable marker so that silent overcharge is visible, not invisible. + if (actualAmount && protocol !== 'atxp') { + this.logger.warn(`settle_actual_dropped protocol=${protocol} actual=${actualAmount.toFixed()}: up-to settlement not yet wired for ${protocol}; settling the credential cap`); + } + if (protocol === 'x402') { // X402: auth expects { payload, paymentRequirements } // The credential is the base64-encoded PAYMENT-SIGNATURE header containing the payload. @@ -406,9 +415,11 @@ export class ProtocolSettlement { // "up-to" semantics: when an actual metered amount is supplied, settle that // (≤ the authorized cap) instead of the cap baked into the option. This is // what makes /pay charge the actual. A decimal USDC string matches the - // human-readable amount format ATXP options already use. + // human-readable amount format ATXP options already use. Use toFixed() (not + // toString()) so sub-1e-6 totals never serialize in exponential notation + // ("3e-7"), which /pay would fail to parse as a decimal USDC string. if (actualAmount) { - const actual = actualAmount.toString(); + const actual = actualAmount.toFixed(); options = options.map(opt => ({ ...opt, amount: actual })); } From addb000f03b9d40349369c488ec73050d116ea57 Mon Sep 17 00:00:00 2001 From: bdj Date: Wed, 17 Jun 2026 15:46:54 -0700 Subject: [PATCH 3/3] fix(payments): align stale call-site comment + error-log serialization (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 --- packages/atxp-server/src/paymentSession.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/atxp-server/src/paymentSession.ts b/packages/atxp-server/src/paymentSession.ts index 3b19181..861f07a 100644 --- a/packages/atxp-server/src/paymentSession.ts +++ b/packages/atxp-server/src/paymentSession.ts @@ -166,9 +166,10 @@ export async function settlePaymentSession( session.protocol, session.credential, session.context, - // "up-to" semantics: settle the accumulated actual (≤ cap), not the cap. - // For a single requirePayment(price), spent === price === cap, so this is - // identical to settling the credential's amount. + // "up-to" semantics: settle the accumulated actual (the sum of charged + // prices, ≤ cap), not the cap. For a single requirePayment(price), spent + // is that price — which equals the cap only when the cap wasn't inflated + // by the server's minimumPayment. session.spent, ); logger.info(`Settled ${session.protocol} at session close: txHash=${result.txHash ?? ''}, amount=${result.settledAmount}`); @@ -177,6 +178,6 @@ export async function settlePaymentSession( // served and settled=true prevents re-attempt at close. Log a greppable, // metric-able marker carrying protocol + amount so an unbilled served // request can be reconciled later. - logger.error(`settle_failed_at_close protocol=${session.protocol} amount=${session.spent.toString()}: ${error instanceof Error ? error.message : String(error)}`); + logger.error(`settle_failed_at_close protocol=${session.protocol} amount=${session.spent.toFixed()}: ${error instanceof Error ? error.message : String(error)}`); } }