Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/atxp-express/src/paymentSessionMcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CallToolResult> => {
// 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' }) }),
Expand Down
51 changes: 51 additions & 0 deletions packages/atxp-server/src/paymentSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
30 changes: 18 additions & 12 deletions packages/atxp-server/src/paymentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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,
Expand Down Expand Up @@ -165,13 +166,18 @@ 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 ?? '<already-settled>'}, amount=${result.settledAmount}`);
} catch (error) {
// No retry/outbox yet (tracked in atxp-dev/sdk#178): the request is already
// 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)}`);
}
}
69 changes: 69 additions & 0 deletions packages/atxp-server/src/protocol.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 36 additions & 4 deletions packages/atxp-server/src/protocol.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<SettleResult> {
async settle(protocol: PaymentProtocol, credential: string, context?: SettlementContext, actualAmount?: BigNumber): Promise<SettleResult> {
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',
Expand All @@ -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.
Expand Down Expand Up @@ -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<Record<string, unknown>>;

// "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,
Expand Down
Loading