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..621612f 100644 --- a/packages/atxp-server/src/paymentSession.test.ts +++ b/packages/atxp-server/src/paymentSession.test.ts @@ -118,4 +118,55 @@ 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 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); + 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; + // 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 cefa4af..861f07a 100644 --- a/packages/atxp-server/src/paymentSession.ts +++ b/packages/atxp-server/src/paymentSession.ts @@ -13,16 +13,17 @@ 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. 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; /** Accumulated charges recorded against this session. */ @@ -39,8 +40,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 +166,11 @@ export async function settlePaymentSession( session.protocol, session.credential, session.context, + // "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}`); } catch (error) { @@ -172,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)}`); } } diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index f5b114f..e335236 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,74 @@ 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('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 758f2b1..08eec08 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,23 @@ 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 { + // 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. @@ -389,7 +410,18 @@ 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. 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.toFixed(); + options = options.map(opt => ({ ...opt, amount: actual })); + } return { sourceAccountId: parsed.sourceAccountId ?? context?.sourceAccountId,