From 9943d1c0432c700e4456614869e4f3107e74f748 Mon Sep 17 00:00:00 2001 From: bdj Date: Wed, 17 Jun 2026 17:05:48 -0700 Subject: [PATCH 1/3] feat(x402): advertise upto + send settlementOverrides for metered settle - omniChallenge buildX402Requirements advertises scheme=upto for EVM (Base) x402 options (SVM stays exact; Solana upto not implemented). The deployed SDK version thus selects exact vs upto. - ProtocolSettlement.buildRequestBody adds settlementOverrides.amount (atomic micro-USDC from the metered actualAmount) for x402, so settle charges the actual (<= the Permit2 cap). settlePaymentSession passes session.spent as actualAmount. - x402Wrapper / baseAccount select UptoEvmScheme vs ExactEvmScheme from the chosen accept's scheme (self-custody client paths). Co-Authored-By: Claude Opus 4.8 --- packages/atxp-base/src/baseAccount.ts | 8 +- .../atxp-server/src/omniChallenge.test.ts | 21 +++- packages/atxp-server/src/omniChallenge.ts | 6 +- packages/atxp-server/src/protocol.test.ts | 48 ++++++++- packages/atxp-server/src/protocol.ts | 32 +++--- packages/atxp-x402/src/x402Wrapper.test.ts | 97 +++++++++++++++++++ packages/atxp-x402/src/x402Wrapper.ts | 19 +++- 7 files changed, 209 insertions(+), 22 deletions(-) diff --git a/packages/atxp-base/src/baseAccount.ts b/packages/atxp-base/src/baseAccount.ts index 0c7b159..391fa7b 100644 --- a/packages/atxp-base/src/baseAccount.ts +++ b/packages/atxp-base/src/baseAccount.ts @@ -6,6 +6,7 @@ import { BasePaymentMaker } from './basePaymentMaker.js'; import { createWalletClient, http, WalletClient, LocalAccount } from 'viem'; import { base } from 'viem/chains'; import { ExactEvmScheme, toClientEvmSigner } from '@x402/evm'; +import { UptoEvmScheme } from '@x402/evm/upto/client'; import { x402HTTPClient, x402Client } from '@x402/core/client'; export class BaseAccount implements Account { @@ -96,7 +97,12 @@ export class BaseAccount implements Account { // encodePaymentSignatureHeader) is duplicated in x402Wrapper.ts. Extract a shared helper // once both packages can import from a common location that depends on @x402/core + @x402/evm. const signer = toClientEvmSigner(this.getLocalAccount()); - const scheme = new ExactEvmScheme(signer); + // 'upto' caps the Permit2 at the advertised amount and settles the actual; + // 'exact' transfers the advertised amount. The upto scheme requires + // reqs.extra.facilitatorAddress (the only address allowed to settle). + const scheme = reqs.scheme === 'upto' + ? new UptoEvmScheme(signer) + : new ExactEvmScheme(signer); const client = new x402Client(); // v2 uses CAIP-2 network IDs ("eip155:8453") client.register(reqs.network as `${string}:${string}`, scheme); diff --git a/packages/atxp-server/src/omniChallenge.test.ts b/packages/atxp-server/src/omniChallenge.test.ts index 6f2a331..bfd0ada 100644 --- a/packages/atxp-server/src/omniChallenge.test.ts +++ b/packages/atxp-server/src/omniChallenge.test.ts @@ -31,7 +31,7 @@ describe('omniChallenge', () => { expect(result.x402Version).toBe(2); expect(result.accepts).toHaveLength(1); expect(result.accepts[0]).toMatchObject({ - scheme: 'exact', + scheme: 'upto', network: 'eip155:8453', amount: '10000', // 0.01 * 1e6 resource: 'https://example.com/api', @@ -42,6 +42,25 @@ describe('omniChallenge', () => { }); }); + it('emits scheme=upto for EVM (Base) options and scheme=exact for SVM (Solana)', () => { + const options = [ + { network: 'base', currency: 'USDC', address: '0xAddr1', amount: new BigNumber('0.01') }, + { network: 'solana', currency: 'USDC', address: '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV', amount: new BigNumber('0.02') }, + ]; + + const result = buildX402Requirements({ + options, + resource: 'https://example.com', + payeeName: 'Multi-chain Server', + }); + + // EVM advertises upto (settle-the-actual); SVM upto is not implemented yet. + expect(result.accepts[0].network).toBe('eip155:8453'); + expect(result.accepts[0].scheme).toBe('upto'); + expect(result.accepts[1].network).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + expect(result.accepts[1].scheme).toBe('exact'); + }); + it('should include both EVM and Solana X402 options', () => { const options = [ { network: 'base', currency: 'USDC', address: '0xAddr1', amount: new BigNumber('0.01') }, diff --git a/packages/atxp-server/src/omniChallenge.ts b/packages/atxp-server/src/omniChallenge.ts index b339707..65e07e9 100644 --- a/packages/atxp-server/src/omniChallenge.ts +++ b/packages/atxp-server/src/omniChallenge.ts @@ -35,8 +35,12 @@ export function buildX402Requirements(args: { ); const accepts: X402PaymentOption[] = [ + // EVM (Base) advertises 'upto': the payer signs a Permit2 capped at `amount`, + // the session meters locally, and settlement charges the actual ≤ cap via + // settlementOverrides.amount. See docs/STREAMING_PAYMENT_SESSIONS.md. + // SVM stays 'exact' — Solana upto is not implemented yet. ...evmOptions.map(option => ({ - scheme: 'exact' as const, + scheme: 'upto' as const, network: CAIP2_NETWORKS[option.network] || option.network, amount: option.amount.times(1e6).toFixed(0), resource: args.resource, diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index e335236..55a010b 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -308,13 +308,55 @@ describe('ProtocolSettlement', () => { 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' }) }); + it('adds settlementOverrides.amount (atomic micro-USDC) for x402 when actualAmount is supplied', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xx402', settledAmount: '3000' }) }); const credential = Buffer.from(JSON.stringify({ signature: '0xabc' })).toString('base64'); + // Authorized cap is the credential's Permit2 amount; the metered actual is $0.003. await settlement.settle('x402', credential, { paymentRequirements: { network: 'base' } }, BigNumber(0.003)); - expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('settle_actual_dropped protocol=x402')); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + // 0.003 USDC → 3000 micro-USDC, integer string, no exponential notation. + expect(body.settlementOverrides).toEqual({ amount: '3000' }); + expect(body.settlementOverrides.amount).not.toContain('e'); + expect(body.settlementOverrides.amount).not.toContain('.'); + // No longer dropped for x402. + expect(mockLogger.warn).not.toHaveBeenCalledWith(expect.stringContaining('settle_actual_dropped protocol=x402')); + }); + + it('omits settlementOverrides for x402 when actualAmount is not supplied', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xx402', settledAmount: '10000' }) }); + const credential = Buffer.from(JSON.stringify({ signature: '0xabc' })).toString('base64'); + + await settlement.settle('x402', credential, { paymentRequirements: { network: 'base' } }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.settlementOverrides).toBeUndefined(); + }); + + it('serializes a sub-1e-6 x402 actualAmount as an integer micro-USDC string, never exponential', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xtiny', settledAmount: '0' }) }); + const credential = Buffer.from(JSON.stringify({ signature: '0xabc' })).toString('base64'); + + // 0.0000003 USDC × 1e6 = 0.3 micro-USDC → toFixed(0) floors to "0". + await settlement.settle('x402', credential, { paymentRequirements: { network: 'base' } }, BigNumber('0.0000003')); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.settlementOverrides.amount).toBe('0'); + expect(body.settlementOverrides.amount).not.toContain('e'); + }); + + it('still warns (greppable) and drops actualAmount for mpp (up-to not yet wired)', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xmpp', settledAmount: '10000' }) }); + const mppCredential = Buffer.from(JSON.stringify({ + challenge: { id: 'ch', method: 'tempo', intent: 'charge', request: { amount: '10000' } }, + payload: { type: 'transaction', signature: '0xtx' }, + source: {}, + })).toString('base64url'); + + await settlement.settle('mpp', mppCredential, undefined, BigNumber(0.003)); + + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('settle_actual_dropped protocol=mpp')); }); it('should call /settle/mpp with standard MPP credential', async () => { diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index 08eec08..b595ead 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -319,19 +319,19 @@ 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. + * `actualAmount` (optional) is the metered "up-to" amount actually spent. + * When provided it settles the actual (≤ the authorized cap) instead of the cap: + * - ATXP: overrides options[].amount (decimal USDC string). + * - x402: adds settlementOverrides.amount (atomic micro-USDC string) so the + * facilitator settles the actual against the Permit2 cap. + * mpp still drops it (its voucher up-to mapping lands in a later phase). + * See docs/STREAMING_PAYMENT_SESSIONS.md. */ 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') { + // mpp has no up-to mapping yet, so the settle body still carries the + // credential's cap and a multi-charge session over-settles relative to the + // metered actual. Emit a greppable marker so the overcharge is visible. + if (actualAmount && protocol === 'mpp') { this.logger.warn(`settle_actual_dropped protocol=${protocol} actual=${actualAmount.toFixed()}: up-to settlement not yet wired for ${protocol}; settling the credential cap`); } @@ -371,9 +371,19 @@ export class ProtocolSettlement { } } + // "up-to" semantics: settle the metered actual (≤ the Permit2 cap) by + // passing settlementOverrides.amount in atomic micro-USDC. toFixed(0) keeps + // it an integer string with no decimals or exponential notation. The + // facilitator caps it at the signed permitted amount, so this is safe even + // if a caller miscomputes. Cap-only settlement (no actualAmount) omits it. + const settlementOverrides = actualAmount + ? { settlementOverrides: { amount: actualAmount.times(1e6).toFixed(0) } } + : {}; + return { payload, paymentRequirements: requirements, + ...settlementOverrides, ...(context?.paymentRequestId && { paymentRequestId: context.paymentRequestId }), ...(context?.sourceAccountId && { sourceAccountId: context.sourceAccountId }), ...(this.destinationAccountId && { destinationAccountId: this.destinationAccountId }), diff --git a/packages/atxp-x402/src/x402Wrapper.test.ts b/packages/atxp-x402/src/x402Wrapper.test.ts index c7d4e25..a4f1929 100644 --- a/packages/atxp-x402/src/x402Wrapper.test.ts +++ b/packages/atxp-x402/src/x402Wrapper.test.ts @@ -3,10 +3,15 @@ import { wrapWithX402 } from './x402Wrapper.js'; import { ATXPAccount, ATXPLocalAccount } from '@atxp/client'; import { ConsoleLogger, LogLevel } from '@atxp/common'; +// Records which EVM scheme class the wrapper constructed, so tests can assert +// upto vs exact selection without reaching into real signing. +const schemeConstructions: string[] = []; + // Mock @x402/evm to avoid actual EVM signing vi.mock('@x402/evm', () => { class MockExactEvmScheme { scheme = 'exact'; + constructor() { schemeConstructions.push('exact'); } createPaymentPayload = vi.fn().mockResolvedValue({ payload: { signature: '0xmocked' }, }); @@ -17,6 +22,18 @@ vi.mock('@x402/evm', () => { }; }); +// Mock the upto client subpath so the wrapper can construct UptoEvmScheme. +vi.mock('@x402/evm/upto/client', () => { + class MockUptoEvmScheme { + scheme = 'upto'; + constructor() { schemeConstructions.push('upto'); } + createPaymentPayload = vi.fn().mockResolvedValue({ + payload: { signature: '0xmocked' }, + }); + } + return { UptoEvmScheme: MockUptoEvmScheme }; +}); + // Mock @x402/core/client to avoid real x402 client logic vi.mock('@x402/core/client', () => { const mockCreatePaymentPayload = vi.fn().mockResolvedValue({ @@ -82,6 +99,7 @@ describe('wrapWithX402', () => { beforeEach(() => { // Reset mocks vi.clearAllMocks(); + schemeConstructions.length = 0; // Create a mock ATXPAccount instance using the proper connection string format mockAccount = new ATXPAccount('https://test.com?connection_token=test-token&account_id=atxp:test-account'); @@ -411,6 +429,85 @@ describe('wrapWithX402', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); + it('constructs UptoEvmScheme when the selected accept is upto', async () => { + const x402Challenge = { + x402Version: 2, + accepts: [ + { + network: 'eip155:8453', + scheme: 'upto', + payTo: '0xrecipient', + amount: '1000000', + description: 'Test payment', + extra: { facilitatorAddress: '0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002' }, + }, + ], + }; + + const first402Response = new Response(JSON.stringify(x402Challenge), { + status: 402, + headers: { 'Content-Type': 'application/json' }, + }); + const successResponse = new Response('Success', { status: 200 }); + + mockFetch + .mockResolvedValueOnce(first402Response) + .mockResolvedValueOnce(successResponse); + + const wrappedFetch = wrapWithX402({ + mcpServer: 'https://example.com/mcp', + account: mockAccount, + fetchFn: mockFetch, + logger: mockLogger, + approvePayment: mockApprovePayment, + }); + + const result = await wrappedFetch('https://example.com/api'); + + expect(result.status).toBe(200); + expect(schemeConstructions).toContain('upto'); + expect(schemeConstructions).not.toContain('exact'); + }); + + it('constructs ExactEvmScheme when the selected accept is exact', async () => { + const x402Challenge = { + x402Version: 2, + accepts: [ + { + network: 'eip155:8453', + scheme: 'exact', + payTo: '0xrecipient', + amount: '1000000', + description: 'Test payment', + }, + ], + }; + + const first402Response = new Response(JSON.stringify(x402Challenge), { + status: 402, + headers: { 'Content-Type': 'application/json' }, + }); + const successResponse = new Response('Success', { status: 200 }); + + mockFetch + .mockResolvedValueOnce(first402Response) + .mockResolvedValueOnce(successResponse); + + const wrappedFetch = wrapWithX402({ + mcpServer: 'https://example.com/mcp', + account: mockAccount, + fetchFn: mockFetch, + logger: mockLogger, + approvePayment: mockApprovePayment, + }); + + const result = await wrappedFetch('https://example.com/api'); + + expect(result.status).toBe(200); + expect(schemeConstructions).toContain('exact'); + expect(schemeConstructions).not.toContain('upto'); + }); + it('should handle no suitable payment option', async () => { // X402 challenge with empty accepts array. // NOTE: The inline selector always picks accepts[0] regardless of scheme, diff --git a/packages/atxp-x402/src/x402Wrapper.ts b/packages/atxp-x402/src/x402Wrapper.ts index 108d6ff..9e292ab 100644 --- a/packages/atxp-x402/src/x402Wrapper.ts +++ b/packages/atxp-x402/src/x402Wrapper.ts @@ -3,6 +3,7 @@ import { BaseAccount } from '@atxp/base'; import { FetchLike } from '@atxp/common'; import { BigNumber } from 'bignumber.js'; import { ExactEvmScheme, toClientEvmSigner } from '@x402/evm'; +import { UptoEvmScheme } from '@x402/evm/upto/client'; import { x402HTTPClient, x402Client } from '@x402/core/client'; import { LocalAccount } from 'viem'; @@ -98,11 +99,13 @@ export const wrapWithX402: FetchWrapper = (config: ClientArgs): FetchLike => { }); } - // Select the best payment requirements (prefer exact scheme on any base-like network) + // Select the best payment requirements. Prefer a settle-the-actual scheme + // (upto), then exact, then whatever's first — both EVM schemes are supported. const accepts = paymentChallenge.accepts as Array>; - const selectedPaymentRequirements = accepts.find( - (a) => a.scheme === 'exact' - ) ?? accepts[0]; + const selectedPaymentRequirements = + accepts.find((a) => a.scheme === 'upto') ?? + accepts.find((a) => a.scheme === 'exact') ?? + accepts[0]; if (!selectedPaymentRequirements) { log.info('No suitable X402 payment option found'); @@ -204,7 +207,13 @@ export const wrapWithX402: FetchWrapper = (config: ClientArgs): FetchLike => { // once both packages can import from a common location that depends on @x402/core + @x402/evm. log.debug('Creating X402 payment payload with signer'); const evmSigner = toClientEvmSigner(signer); - const scheme = new ExactEvmScheme(evmSigner); + // 'upto' caps the Permit2 at the advertised amount and settles the actual; + // 'exact' transfers the advertised amount. The upto scheme requires + // paymentRequirements.extra.facilitatorAddress (the only address allowed + // to settle) — the server's challenge must advertise it. + const scheme = selectedPaymentRequirements.scheme === 'upto' + ? new UptoEvmScheme(evmSigner) + : new ExactEvmScheme(evmSigner); const x402ClientInstance = new x402Client(); // v2 uses CAIP-2 network IDs ("eip155:8453") From 960919f1ba01d0acae4a362a22e56afe93c43a67 Mon Sep 17 00:00:00 2001 From: bdj Date: Thu, 18 Jun 2026 09:52:04 -0700 Subject: [PATCH 2/3] feat(x402): advertise both schemes with facilitatorAddress; scheme-gate overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for x402 up-to: - buildX402Requirements advertises BOTH 'exact' and 'upto' per EVM chain (upto only when a facilitatorAddress is available), so non-accounts clients keep an exact fallback and accounts picks the scheme. upto accepts carry extra.facilitatorAddress, fetched once from auth's GET /x402/supported (cached). Dedupe accepts by (scheme, network) so a chain surfaced twice isn't advertised twice. - buildRequestBody adds settlementOverrides ONLY for the upto scheme (exact/EIP-3009 commits the signature to a fixed value; overriding it mismatches the signature and the facilitator rejects it). Clamp the override to the cap. - Revert the self-custody paths (x402Wrapper, baseAccount) to exact-only — they can't complete upto (no facilitator approval); the accounts-mediated path is the only upto path. Co-Authored-By: Claude Opus 4.8 --- packages/atxp-base/src/baseAccount.ts | 11 +- packages/atxp-server/src/index.ts | 1 + .../atxp-server/src/omniChallenge.test.ts | 139 ++++++++++++++++-- packages/atxp-server/src/omniChallenge.ts | 127 +++++++++++++--- packages/atxp-server/src/protocol.test.ts | 78 +++++++++- packages/atxp-server/src/protocol.ts | 26 +++- .../atxp-server/src/requirePayment.test.ts | 12 +- packages/atxp-server/src/requirePayment.ts | 14 +- packages/atxp-x402/src/x402Wrapper.test.ts | 15 +- packages/atxp-x402/src/x402Wrapper.ts | 17 +-- 10 files changed, 374 insertions(+), 66 deletions(-) diff --git a/packages/atxp-base/src/baseAccount.ts b/packages/atxp-base/src/baseAccount.ts index 391fa7b..bfbe62e 100644 --- a/packages/atxp-base/src/baseAccount.ts +++ b/packages/atxp-base/src/baseAccount.ts @@ -6,7 +6,6 @@ import { BasePaymentMaker } from './basePaymentMaker.js'; import { createWalletClient, http, WalletClient, LocalAccount } from 'viem'; import { base } from 'viem/chains'; import { ExactEvmScheme, toClientEvmSigner } from '@x402/evm'; -import { UptoEvmScheme } from '@x402/evm/upto/client'; import { x402HTTPClient, x402Client } from '@x402/core/client'; export class BaseAccount implements Account { @@ -97,12 +96,10 @@ export class BaseAccount implements Account { // encodePaymentSignatureHeader) is duplicated in x402Wrapper.ts. Extract a shared helper // once both packages can import from a common location that depends on @x402/core + @x402/evm. const signer = toClientEvmSigner(this.getLocalAccount()); - // 'upto' caps the Permit2 at the advertised amount and settles the actual; - // 'exact' transfers the advertised amount. The upto scheme requires - // reqs.extra.facilitatorAddress (the only address allowed to settle). - const scheme = reqs.scheme === 'upto' - ? new UptoEvmScheme(signer) - : new ExactEvmScheme(signer); + // This self-custody EOA path can only complete `exact` (EIP-3009 transfer + // authorization). It has no Permit2 approval and no facilitatorAddress to + // sign an `upto` permit; the accounts-mediated path is the only upto path. + const scheme = new ExactEvmScheme(signer); const client = new x402Client(); // v2 uses CAIP-2 network IDs ("eip155:8453") client.register(reqs.network as `${string}:${string}`, scheme); diff --git a/packages/atxp-server/src/index.ts b/packages/atxp-server/src/index.ts index fe2307a..324be19 100644 --- a/packages/atxp-server/src/index.ts +++ b/packages/atxp-server/src/index.ts @@ -124,6 +124,7 @@ export { sourcesToOptions, buildPaymentOptions, buildAuthorizeParamsFromSources, + fetchUptoFacilitatorAddresses, } from './omniChallenge.js'; // Opaque identity for MPP Authorization: Payment ↔ OAuth Bearer coexistence diff --git a/packages/atxp-server/src/omniChallenge.test.ts b/packages/atxp-server/src/omniChallenge.test.ts index bfd0ada..bb431c1 100644 --- a/packages/atxp-server/src/omniChallenge.test.ts +++ b/packages/atxp-server/src/omniChallenge.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { BigNumber } from 'bignumber.js'; import { buildX402Requirements, @@ -11,6 +11,7 @@ import { buildOmniChallenge, buildPaymentOptions, buildAuthorizeParamsFromSources, + fetchUptoFacilitatorAddresses, } from './omniChallenge.js'; import { PAYMENT_REQUIRED_PREAMBLE, PAYMENT_REQUIRED_ERROR_CODE } from '@atxp/common'; import { parseMPPHeader } from '@atxp/mpp'; @@ -20,8 +21,14 @@ describe('omniChallenge', () => { { network: 'base', currency: 'USDC', address: '0xDestination', amount: new BigNumber('0.01') }, ]; + // CAIP-2 → upto facilitator address map (from GET /x402/supported). + const FACILITATORS = { + 'eip155:8453': '0x7720030000000000000000000000000000000000', + 'eip155:84532': '0x14fDa00000000000000000000000000000000000', + }; + describe('buildX402Requirements', () => { - it('should build valid X402 payment requirements', () => { + it('should build valid X402 payment requirements (exact accept)', () => { const result = buildX402Requirements({ options: defaultOptions, resource: 'https://example.com/api', @@ -29,9 +36,10 @@ describe('omniChallenge', () => { }); expect(result.x402Version).toBe(2); + // Without a facilitator address, only the exact accept is advertised. expect(result.accepts).toHaveLength(1); expect(result.accepts[0]).toMatchObject({ - scheme: 'upto', + scheme: 'exact', network: 'eip155:8453', amount: '10000', // 0.01 * 1e6 resource: 'https://example.com/api', @@ -42,7 +50,41 @@ describe('omniChallenge', () => { }); }); - it('emits scheme=upto for EVM (Base) options and scheme=exact for SVM (Solana)', () => { + it('advertises BOTH exact and upto for EVM when a facilitatorAddress is known', () => { + const result = buildX402Requirements({ + options: defaultOptions, + resource: 'https://example.com/api', + payeeName: 'Test Server', + facilitatorAddresses: FACILITATORS, + }); + + // exact first, then upto — both for the same Base network. + expect(result.accepts).toHaveLength(2); + expect(result.accepts[0].scheme).toBe('exact'); + expect(result.accepts[0].extra).toEqual({ name: 'USD Coin', version: '2' }); + expect(result.accepts[1].scheme).toBe('upto'); + expect(result.accepts[1].network).toBe('eip155:8453'); + // upto carries the facilitator address (the only address allowed to settle). + expect(result.accepts[1].extra).toEqual({ + name: 'USD Coin', + version: '2', + facilitatorAddress: FACILITATORS['eip155:8453'], + }); + }); + + it('omits upto (exact only) for a network with no facilitatorAddress', () => { + const result = buildX402Requirements({ + options: defaultOptions, + resource: 'https://example.com/api', + payeeName: 'Test Server', + facilitatorAddresses: { 'eip155:84532': FACILITATORS['eip155:84532'] }, // wrong network + }); + + expect(result.accepts).toHaveLength(1); + expect(result.accepts[0].scheme).toBe('exact'); + }); + + it('emits exact (+upto when facilitator known) for EVM and exact-only for SVM (Solana)', () => { const options = [ { network: 'base', currency: 'USDC', address: '0xAddr1', amount: new BigNumber('0.01') }, { network: 'solana', currency: 'USDC', address: '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV', amount: new BigNumber('0.02') }, @@ -52,13 +94,16 @@ describe('omniChallenge', () => { options, resource: 'https://example.com', payeeName: 'Multi-chain Server', + facilitatorAddresses: FACILITATORS, }); - // EVM advertises upto (settle-the-actual); SVM upto is not implemented yet. + // EVM: exact + upto; SVM: exact only (Solana upto not implemented). expect(result.accepts[0].network).toBe('eip155:8453'); - expect(result.accepts[0].scheme).toBe('upto'); - expect(result.accepts[1].network).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); - expect(result.accepts[1].scheme).toBe('exact'); + expect(result.accepts[0].scheme).toBe('exact'); + expect(result.accepts[1].network).toBe('eip155:8453'); + expect(result.accepts[1].scheme).toBe('upto'); + expect(result.accepts[2].network).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + expect(result.accepts[2].scheme).toBe('exact'); }); it('should include both EVM and Solana X402 options', () => { @@ -74,11 +119,14 @@ describe('omniChallenge', () => { payeeName: 'Multi-chain Server', }); - // EVM options first, then Solana - expect(result.accepts).toHaveLength(3); + // The same chain appears twice (two Base addresses). Dedupe keeps the first + // per (scheme, network) — a destination has one receiving address per chain — + // so the second Base option (0xAddr2) is dropped. No facilitatorAddresses were + // passed, so only exact accepts: one Base, one Solana. + expect(result.accepts).toHaveLength(2); expect(result.accepts[0].payTo).toBe('0xAddr1'); - expect(result.accepts[1].payTo).toBe('0xAddr2'); - expect(result.accepts[2].payTo).toBe('7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV'); + expect(result.accepts[0].network).toBe('eip155:8453'); + expect(result.accepts[1].payTo).toBe('7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV'); }); it('should include feePayer in extra for Solana X402 options', () => { @@ -628,5 +676,72 @@ describe('omniChallenge', () => { expect(result.paymentRequirements!.accepts[0].payTo).toBe('0xBaseOnly'); expect(result.challenges).toEqual([]); }); + + it('threads facilitatorAddresses through to the upto accept', () => { + const result = buildAuthorizeParamsFromSources({ + amount: new BigNumber('0.10'), + sources: [{ chain: 'base', address: '0xBaseAddr' }], + facilitatorAddresses: { 'eip155:8453': '0x7720030000000000000000000000000000000000' }, + }); + + const accepts = result.paymentRequirements!.accepts; + expect(accepts.map(a => a.scheme)).toEqual(['exact', 'upto']); + expect(accepts[1].extra).toMatchObject({ facilitatorAddress: '0x7720030000000000000000000000000000000000' }); + }); + }); + + describe('fetchUptoFacilitatorAddresses', () => { + function okResponse(body: unknown) { + return { ok: true, json: async () => body } as unknown as Response; + } + + it('fetches GET /x402/supported and returns the flat network → address map', async () => { + const map = { 'eip155:8453': '0x7720030000000000000000000000000000000000' }; + const fetchFn = vi.fn().mockResolvedValue(okResponse(map)); + + const result = await fetchUptoFacilitatorAddresses('https://auth1.test', fetchFn as any); + + expect(result).toEqual(map); + expect(fetchFn).toHaveBeenCalledWith('https://auth1.test/x402/supported'); + }); + + it('caches the result per URL (fetches at most once)', async () => { + const map = { 'eip155:8453': '0xabc0000000000000000000000000000000000000' }; + const fetchFn = vi.fn().mockResolvedValue(okResponse(map)); + + await fetchUptoFacilitatorAddresses('https://auth2.test', fetchFn as any); + await fetchUptoFacilitatorAddresses('https://auth2.test', fetchFn as any); + + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it('returns {} and does not throw when the fetch fails', async () => { + const fetchFn = vi.fn().mockRejectedValue(new Error('network down')); + const warn = vi.fn(); + + const result = await fetchUptoFacilitatorAddresses('https://auth3.test', fetchFn as any, { warn }); + + expect(result).toEqual({}); + expect(warn).toHaveBeenCalled(); + }); + + it('returns {} on a non-OK response', async () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 503 } as unknown as Response); + + const result = await fetchUptoFacilitatorAddresses('https://auth4.test', fetchFn as any); + + expect(result).toEqual({}); + }); + + it('does not cache an empty result (allows a later retry)', async () => { + const map = { 'eip155:8453': '0xdef0000000000000000000000000000000000000' }; + const fetchFn = vi.fn() + .mockRejectedValueOnce(new Error('down')) + .mockResolvedValue(okResponse(map)); + + expect(await fetchUptoFacilitatorAddresses('https://auth5.test', fetchFn as any)).toEqual({}); + expect(await fetchUptoFacilitatorAddresses('https://auth5.test', fetchFn as any)).toEqual(map); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/packages/atxp-server/src/omniChallenge.ts b/packages/atxp-server/src/omniChallenge.ts index 65e07e9..0d7f997 100644 --- a/packages/atxp-server/src/omniChallenge.ts +++ b/packages/atxp-server/src/omniChallenge.ts @@ -1,5 +1,5 @@ import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import { PAYMENT_REQUIRED_PREAMBLE, PAYMENT_REQUIRED_ERROR_CODE, AuthorizationServerUrl, USDC_ADDRESSES, CAIP2_NETWORKS } from "@atxp/common"; +import { PAYMENT_REQUIRED_PREAMBLE, PAYMENT_REQUIRED_ERROR_CODE, AuthorizationServerUrl, USDC_ADDRESSES, CAIP2_NETWORKS, FetchLike } from "@atxp/common"; import { BigNumber } from "bignumber.js"; import type { OmniChallenge, X402PaymentRequirements, AtxpMcpChallengeData, MppChallengeData, X402PaymentOption } from "./protocol.js"; @@ -17,15 +17,76 @@ const SOLANA_FEE_PAYERS: Record = { solana_devnet: 'Hc3sdEAsCGQcpgfivywog9uwtk8gUBUZgsxdME1EJy88', }; +/** + * Map of CAIP-2 network → upto facilitator address, cached per auth server URL + * for the process lifetime. The promise (not the value) is cached so concurrent + * first callers share one fetch; an empty/failed result is not cached so a later + * call can retry. + */ +const _facilitatorAddressCache = new Map>>(); + +/** + * Fetch the upto facilitator addresses from the auth server's GET /x402/supported + * endpoint. The response is a flat CAIP-2 network → address map. Fetched at most + * once per process per auth server URL. On failure, returns {} (so the upto accept + * is simply omitted) and does not cache, allowing a later retry. + */ +export async function fetchUptoFacilitatorAddresses( + authServerUrl: AuthorizationServerUrl | string, + fetchFn: FetchLike = fetch.bind(globalThis), + logger?: { warn: (msg: string) => void }, +): Promise> { + const url = new URL('/x402/supported', authServerUrl).toString(); + const cached = _facilitatorAddressCache.get(url); + if (cached) return cached; + + const pending = (async () => { + try { + const response = await fetchFn(url); + if (!response.ok) { + logger?.warn(`fetchUptoFacilitatorAddresses: ${url} returned ${response.status}; advertising exact only`); + return {}; + } + const body = await response.json() as Record; + return (body && typeof body === 'object') ? body : {}; + } catch (error) { + logger?.warn(`fetchUptoFacilitatorAddresses: failed to fetch ${url}: ${error}; advertising exact only`); + return {}; + } + })(); + + _facilitatorAddressCache.set(url, pending); + const result = await pending; + // Don't cache an empty map — let a later call retry once the facilitator is up. + if (Object.keys(result).length === 0) { + _facilitatorAddressCache.delete(url); + } + return result; +} + /** * Build X402 payment requirements from charge options. - * Returns EVM (Base) and SVM (Solana) options. The accepts array is ordered - * EVM-first — clients that don't have a chain preference use the first option. + * + * Each EVM (Base) network advertises BOTH schemes so any client can pay: + * - 'exact': transfer the advertised amount (EIP-3009). Always advertised. + * - 'upto': sign a Permit2 capped at `amount`, meter locally, settle the actual + * ≤ cap via settlementOverrides.amount. Only advertised when we have a + * facilitatorAddress for that network (the only address allowed to settle the + * permit), supplied via `facilitatorAddresses` (from GET /x402/supported). + * Without one, the upto accept would be unusable, so it's omitted. + * SVM (Solana) stays 'exact' only — Solana upto is not implemented yet. + * See docs/STREAMING_PAYMENT_SESSIONS.md. + * + * The accepts array is ordered EVM-first — clients without a chain preference use + * the first option. */ export function buildX402Requirements(args: { options: Array<{ network: string; currency: string; address: string; amount: BigNumber }>; resource: string; payeeName: string; + /** CAIP-2 network → upto facilitator address. When absent for a network, only + * the exact accept is advertised for it. */ + facilitatorAddresses?: Record; }): X402PaymentRequirements { const evmOptions = args.options.filter(o => X402_EVM_NETWORKS.has(o.network) && o.address.startsWith('0x') @@ -33,15 +94,14 @@ export function buildX402Requirements(args: { const svmOptions = args.options.filter(o => X402_SVM_NETWORKS.has(o.network) && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(o.address) ); + const facilitatorAddresses = args.facilitatorAddresses ?? {}; - const accepts: X402PaymentOption[] = [ - // EVM (Base) advertises 'upto': the payer signs a Permit2 capped at `amount`, - // the session meters locally, and settlement charges the actual ≤ cap via - // settlementOverrides.amount. See docs/STREAMING_PAYMENT_SESSIONS.md. - // SVM stays 'exact' — Solana upto is not implemented yet. - ...evmOptions.map(option => ({ - scheme: 'upto' as const, - network: CAIP2_NETWORKS[option.network] || option.network, + const accepts: X402PaymentOption[] = []; + + for (const option of evmOptions) { + const caip2 = CAIP2_NETWORKS[option.network] || option.network; + const base = { + network: caip2, amount: option.amount.times(1e6).toFixed(0), resource: args.resource, description: args.payeeName, @@ -49,10 +109,19 @@ export function buildX402Requirements(args: { payTo: option.address, maxTimeoutSeconds: 300, asset: USDC_ADDRESSES[option.network] || USDC_ADDRESSES['base'], - extra: { name: 'USD Coin', version: '2' }, - })), - ...svmOptions.map(option => ({ - scheme: 'exact' as const, + }; + // Always advertise exact (the EIP-712 domain lives in extra, as today). + accepts.push({ ...base, scheme: 'exact', extra: { name: 'USD Coin', version: '2' } }); + // Advertise upto only when we have a facilitator address to pin in the permit. + const facilitatorAddress = facilitatorAddresses[caip2]; + if (facilitatorAddress) { + accepts.push({ ...base, scheme: 'upto', extra: { name: 'USD Coin', version: '2', facilitatorAddress } }); + } + } + + for (const option of svmOptions) { + accepts.push({ + scheme: 'exact', network: CAIP2_NETWORKS[option.network] || option.network, amount: option.amount.times(1e6).toFixed(0), resource: args.resource, @@ -62,12 +131,25 @@ export function buildX402Requirements(args: { maxTimeoutSeconds: 300, asset: USDC_ADDRESSES[option.network] || USDC_ADDRESSES['solana'], extra: { feePayer: SOLANA_FEE_PAYERS[option.network] || SOLANA_FEE_PAYERS['solana'] }, - })), - ]; + }); + } + + // Dedupe by scheme + resolved (CAIP-2) network: fetchAllSources can surface the + // same chain more than once (e.g. the destination's address plus a same-chain + // entry from getSources, sometimes with a different label/address), which would + // otherwise advertise duplicate exact/upto accepts. Keep the first per (scheme, + // network) — that's the destination's primary option for the chain. + const seenAccept = new Set(); + const dedupedAccepts = accepts.filter(a => { + const key = `${a.scheme}:${a.network}`; + if (seenAccept.has(key)) return false; + seenAccept.add(key); + return true; + }); return { x402Version: 2, - accepts, + accepts: dedupedAccepts, }; } @@ -293,6 +375,8 @@ export function buildOmniChallenge(args: { payeeName: string; /** Unique challenge ID for MPP (e.g. payment request ID) */ mppChallengeId?: string; + /** CAIP-2 network → upto facilitator address (from GET /x402/supported). */ + facilitatorAddresses?: Record; }): OmniChallenge { const mpp = args.mppChallengeId ? buildMppChallenges({ id: args.mppChallengeId, options: args.options, resource: args.resource }) @@ -304,6 +388,7 @@ export function buildOmniChallenge(args: { options: args.options, resource: args.resource, payeeName: args.payeeName, + facilitatorAddresses: args.facilitatorAddresses, }), ...(mpp && { mpp }), }; @@ -341,6 +426,9 @@ export function buildPaymentOptions(args: { payeeName?: string; /** Challenge ID for MPP (auto-generated if not provided) */ challengeId?: string; + /** CAIP-2 network → upto facilitator address (from GET /x402/supported). + * When absent for a network, only the exact x402 accept is advertised. */ + facilitatorAddresses?: Record; }): { x402: X402PaymentRequirements; mpp: MppChallengeData[] | null; @@ -355,6 +443,7 @@ export function buildPaymentOptions(args: { options, resource: args.resource ?? '', payeeName: args.payeeName ?? '', + facilitatorAddresses: args.facilitatorAddresses, }), mpp: buildMppChallenges({ id: challengeId, options, resource: args.resource }), options, @@ -378,6 +467,8 @@ export function buildAuthorizeParamsFromSources(args: { resource?: string; payeeName?: string; challengeId?: string; + /** CAIP-2 network → upto facilitator address (from GET /x402/supported). */ + facilitatorAddresses?: Record; }): { /** X402: full accepts array — accounts picks chain via ff:x402-chain flag. */ paymentRequirements?: X402PaymentRequirements; diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index 55a010b..feaf757 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -313,7 +313,7 @@ describe('ProtocolSettlement', () => { const credential = Buffer.from(JSON.stringify({ signature: '0xabc' })).toString('base64'); // Authorized cap is the credential's Permit2 amount; the metered actual is $0.003. - await settlement.settle('x402', credential, { paymentRequirements: { network: 'base' } }, BigNumber(0.003)); + await settlement.settle('x402', credential, { paymentRequirements: { scheme: 'upto', network: 'base' } }, BigNumber(0.003)); const body = JSON.parse(mockFetch.mock.calls[0][1].body); // 0.003 USDC → 3000 micro-USDC, integer string, no exponential notation. @@ -334,18 +334,90 @@ describe('ProtocolSettlement', () => { expect(body.settlementOverrides).toBeUndefined(); }); + it('omits settlementOverrides for the exact scheme even when actualAmount is supplied', async () => { + // exact/EIP-3009 commits the signature to a specific value; overriding the + // amount would mismatch the signed authorization and the facilitator rejects it. + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xexact', settledAmount: '10000' }) }); + const credential = Buffer.from(JSON.stringify({ signature: '0xabc' })).toString('base64'); + + await settlement.settle('x402', credential, { paymentRequirements: { scheme: 'exact', network: 'base', amount: '10000' } }, BigNumber(0.003)); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.settlementOverrides).toBeUndefined(); + }); + it('serializes a sub-1e-6 x402 actualAmount as an integer micro-USDC string, never exponential', async () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xtiny', settledAmount: '0' }) }); const credential = Buffer.from(JSON.stringify({ signature: '0xabc' })).toString('base64'); - // 0.0000003 USDC × 1e6 = 0.3 micro-USDC → toFixed(0) floors to "0". - await settlement.settle('x402', credential, { paymentRequirements: { network: 'base' } }, BigNumber('0.0000003')); + // 0.0000003 USDC × 1e6 = 0.3 micro-USDC → toFixed(0) rounds (HALF_UP) to "0". + await settlement.settle('x402', credential, { paymentRequirements: { scheme: 'upto', network: 'base' } }, BigNumber('0.0000003')); const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.settlementOverrides.amount).toBe('0'); expect(body.settlementOverrides.amount).not.toContain('e'); }); + it('clamps a meter overshoot to the cap (Permit2 reverts when requested > permitted)', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xx402', settledAmount: '10000' }) }); + const credential = Buffer.from(JSON.stringify({ signature: '0xabc' })).toString('base64'); + + // Cap is the selected requirement's amount (10000 µUSDC = $0.01). The metered + // actual ($0.02 = 20000 µUSDC) overshoots → settle the cap, not the overshoot. + await settlement.settle( + 'x402', + credential, + { paymentRequirements: { scheme: 'upto', network: 'eip155:8453', amount: '10000' } }, + BigNumber('0.02'), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.settlementOverrides).toEqual({ amount: '10000' }); + }); + + it('settles the actual (not the cap) when the metered amount is under the cap', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xx402', settledAmount: '3000' }) }); + const credential = Buffer.from(JSON.stringify({ signature: '0xabc' })).toString('base64'); + + // Cap $0.01 (10000), actual $0.003 (3000) → settle the actual. + await settlement.settle( + 'x402', + credential, + { paymentRequirements: { scheme: 'upto', network: 'eip155:8453', amount: '10000' } }, + BigNumber('0.003'), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.settlementOverrides).toEqual({ amount: '3000' }); + }); + + it('clamps against the cap selected from a full accepts array (matching credential network)', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xx402', settledAmount: '10000' }) }); + // Credential payload embeds accepted.network so the matching accept is selected. + const credential = Buffer.from(JSON.stringify({ + signature: '0xabc', + accepted: { network: 'eip155:8453' }, + })).toString('base64'); + + await settlement.settle( + 'x402', + credential, + { + paymentRequirements: { + x402Version: 2, + accepts: [ + { scheme: 'upto', network: 'eip155:8453', amount: '10000' }, + { scheme: 'exact', network: 'eip155:84532', amount: '5000' }, + ], + }, + }, + BigNumber('0.05'), // 50000 µUSDC overshoot → clamp to the 10000 cap. + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.settlementOverrides).toEqual({ amount: '10000' }); + }); + it('still warns (greppable) and drops actualAmount for mpp (up-to not yet wired)', async () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xmpp', settledAmount: '10000' }) }); const mppCredential = Buffer.from(JSON.stringify({ diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index b595ead..1bc9360 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -372,13 +372,25 @@ export class ProtocolSettlement { } // "up-to" semantics: settle the metered actual (≤ the Permit2 cap) by - // passing settlementOverrides.amount in atomic micro-USDC. toFixed(0) keeps - // it an integer string with no decimals or exponential notation. The - // facilitator caps it at the signed permitted amount, so this is safe even - // if a caller miscomputes. Cap-only settlement (no actualAmount) omits it. - const settlementOverrides = actualAmount - ? { settlementOverrides: { amount: actualAmount.times(1e6).toFixed(0) } } - : {}; + // passing settlementOverrides.amount in atomic micro-USDC. ONLY for the + // 'upto' scheme: 'exact'/EIP-3009 commits the signature to a specific value, + // so overriding the amount makes it mismatch the signed authorization and the + // facilitator rejects it (400). Clamp to the cap: Permit2 reverts the whole + // settle when the requested amount exceeds the permitted amount, so a meter + // overshoot must collect the cap, not revert. The cap is the selected + // requirement's `amount` (already atomic µUSDC); when absent we pass the + // actual through. toFixed(0) (ROUND_HALF_UP, not floor) keeps it an integer + // string with no decimals/exponential. Cap-only settlement (no actualAmount, + // or exact scheme) omits it. + const isUptoScheme = (requirements as { scheme?: unknown } | undefined)?.scheme === 'upto'; + let settlementOverrides = {}; + if (actualAmount && isUptoScheme) { + const actualAtomic = new BigNumber(actualAmount.times(1e6).toFixed(0)); + const capRaw = (requirements as { amount?: unknown } | undefined)?.amount; + const cap = (typeof capRaw === 'string' || typeof capRaw === 'number') ? new BigNumber(capRaw) : undefined; + const settleAtomic = cap && cap.isFinite() ? BigNumber.min(actualAtomic, cap) : actualAtomic; + settlementOverrides = { settlementOverrides: { amount: settleAtomic.toFixed(0) } }; + } return { payload, diff --git a/packages/atxp-server/src/requirePayment.test.ts b/packages/atxp-server/src/requirePayment.test.ts index 67b5247..1164c0f 100644 --- a/packages/atxp-server/src/requirePayment.test.ts +++ b/packages/atxp-server/src/requirePayment.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { requirePayment } from './index.js'; import * as TH from './serverTestHelpers.js'; import { BigNumber } from 'bignumber.js'; @@ -7,6 +7,16 @@ import { PAYMENT_REQUIRED_ERROR_CODE } from '@atxp/common'; import { ProtocolSettlement } from './protocol.js'; describe('requirePayment', () => { + // The omni-challenge build path fetches GET /x402/supported (upto facilitator + // addresses). Stub it so tests don't make real network calls; an empty map + // means the challenge advertises x402 exact only (the existing expectations). + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }))); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('should pass if there is money', async () => { const paymentServer = TH.paymentServer({charge: vi.fn().mockResolvedValue(true)}); const config = TH.config({ paymentServer }); diff --git a/packages/atxp-server/src/requirePayment.ts b/packages/atxp-server/src/requirePayment.ts index deb8af8..16ada2f 100644 --- a/packages/atxp-server/src/requirePayment.ts +++ b/packages/atxp-server/src/requirePayment.ts @@ -1,7 +1,7 @@ import { RequirePaymentConfig, extractNetworkFromAccountId, extractAddressFromAccountId, Network, AuthorizationServerUrl } from "@atxp/common"; import { BigNumber } from "bignumber.js"; import { getATXPConfig, atxpAccountId, atxpToken, getPaymentRequestId, setPendingPaymentChallenge, paymentSession } from "./atxpContext.js"; -import { buildPaymentOptions, omniChallengeMcpError } from "./omniChallenge.js"; +import { buildPaymentOptions, omniChallengeMcpError, fetchUptoFacilitatorAddresses } from "./omniChallenge.js"; import { getATXPResource } from "./atxpContext.js"; import { signOpaqueIdentity } from "./opaqueIdentity.js"; @@ -74,7 +74,7 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi if (existingPaymentId) { config.logger.info(`Found existing payment ID ${existingPaymentId}`); const sources = await fetchAllSources(config, destinationNetwork, destinationAddress); - throw buildOmniError(config, existingPaymentId, paymentAmount, sources); + throw await buildOmniError(config, existingPaymentId, paymentAmount, sources); } // Fetch all destination chain addresses for the omni-challenge. @@ -99,7 +99,7 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi config.logger.debug(`Creating payment request with sourceAccountId: ${user}, destinationAccountId: ${charge.destinationAccountId}`); const paymentId = await config.paymentServer.createPaymentRequest(paymentRequest); config.logger.info(`Created payment request ${paymentId}`); - throw buildOmniError(config, paymentId, paymentAmount, allSources); + throw await buildOmniError(config, paymentId, paymentAmount, allSources); } /** @@ -134,7 +134,7 @@ async function fetchAllSources( * Uses buildPaymentOptions (shared with buildAuthorizeParamsFromSources) to * ensure consistent challenge generation across MCP servers and LLM callers. */ -function buildOmniError( +async function buildOmniError( config: { server: AuthorizationServerUrl; logger: import("@atxp/common").Logger }, paymentId: string, paymentAmount: BigNumber, @@ -142,12 +142,18 @@ function buildOmniError( ) { const resource = getATXPResource()?.toString() ?? ''; + // Fetch (once per process) the upto facilitator addresses so the x402 challenge + // can advertise the upto accept for networks the facilitator supports. On + // failure this returns {} and only the exact accept is advertised. + const facilitatorAddresses = await fetchUptoFacilitatorAddresses(config.server, undefined, config.logger); + const payment = buildPaymentOptions({ amount: paymentAmount, sources, resource, payeeName: '', challengeId: paymentId, + facilitatorAddresses, }); if (payment.x402.accepts.length === 0 && sources.length > 0) { diff --git a/packages/atxp-x402/src/x402Wrapper.test.ts b/packages/atxp-x402/src/x402Wrapper.test.ts index a4f1929..24948e7 100644 --- a/packages/atxp-x402/src/x402Wrapper.test.ts +++ b/packages/atxp-x402/src/x402Wrapper.test.ts @@ -429,10 +429,19 @@ describe('wrapWithX402', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); - it('constructs UptoEvmScheme when the selected accept is upto', async () => { + it('always selects exact (never upto) even when both schemes are advertised', async () => { + // This self-custody path can't complete upto (no Permit2 approval, no + // facilitator). When the server advertises both, it must pick exact. const x402Challenge = { x402Version: 2, accepts: [ + { + network: 'eip155:8453', + scheme: 'exact', + payTo: '0xrecipient', + amount: '1000000', + description: 'Test payment', + }, { network: 'eip155:8453', scheme: 'upto', @@ -465,8 +474,8 @@ describe('wrapWithX402', () => { const result = await wrappedFetch('https://example.com/api'); expect(result.status).toBe(200); - expect(schemeConstructions).toContain('upto'); - expect(schemeConstructions).not.toContain('exact'); + expect(schemeConstructions).toContain('exact'); + expect(schemeConstructions).not.toContain('upto'); }); it('constructs ExactEvmScheme when the selected accept is exact', async () => { diff --git a/packages/atxp-x402/src/x402Wrapper.ts b/packages/atxp-x402/src/x402Wrapper.ts index 9e292ab..dd80811 100644 --- a/packages/atxp-x402/src/x402Wrapper.ts +++ b/packages/atxp-x402/src/x402Wrapper.ts @@ -3,7 +3,6 @@ import { BaseAccount } from '@atxp/base'; import { FetchLike } from '@atxp/common'; import { BigNumber } from 'bignumber.js'; import { ExactEvmScheme, toClientEvmSigner } from '@x402/evm'; -import { UptoEvmScheme } from '@x402/evm/upto/client'; import { x402HTTPClient, x402Client } from '@x402/core/client'; import { LocalAccount } from 'viem'; @@ -99,11 +98,12 @@ export const wrapWithX402: FetchWrapper = (config: ClientArgs): FetchLike => { }); } - // Select the best payment requirements. Prefer a settle-the-actual scheme - // (upto), then exact, then whatever's first — both EVM schemes are supported. + // This self-custody / local-signing path can only complete `exact`: the + // EOA has no Permit2 approval and the challenge carries no facilitatorAddress + // for an `upto` permit. The accounts-mediated path is the only upto path. + // Prefer the exact accept, falling back to the first option. const accepts = paymentChallenge.accepts as Array>; const selectedPaymentRequirements = - accepts.find((a) => a.scheme === 'upto') ?? accepts.find((a) => a.scheme === 'exact') ?? accepts[0]; @@ -207,13 +207,8 @@ export const wrapWithX402: FetchWrapper = (config: ClientArgs): FetchLike => { // once both packages can import from a common location that depends on @x402/core + @x402/evm. log.debug('Creating X402 payment payload with signer'); const evmSigner = toClientEvmSigner(signer); - // 'upto' caps the Permit2 at the advertised amount and settles the actual; - // 'exact' transfers the advertised amount. The upto scheme requires - // paymentRequirements.extra.facilitatorAddress (the only address allowed - // to settle) — the server's challenge must advertise it. - const scheme = selectedPaymentRequirements.scheme === 'upto' - ? new UptoEvmScheme(evmSigner) - : new ExactEvmScheme(evmSigner); + // Self-custody EOA signs an exact EIP-3009 transfer authorization. + const scheme = new ExactEvmScheme(evmSigner); const x402ClientInstance = new x402Client(); // v2 uses CAIP-2 network IDs ("eip155:8453") From 52d5d9748c7d7a9b79d102d09b6bd245fe01ccfd Mon Sep 17 00:00:00 2001 From: bdj Date: Thu, 18 Jun 2026 10:32:54 -0700 Subject: [PATCH 3/3] fix(x402): TTL on facilitator-address cache; match accept by scheme in settle Re-review: - Bound the facilitator-address cache with a 10-minute TTL (was process-lifetime). The CDP settle address rotates; a long-lived process would advertise a stale witness and silently revert every upto settle until restart. - buildRequestBody's full-accepts branch matched by network only; the challenge now carries both exact and upto per network, so match by scheme too (else a network match returns exact-first and drops the override). Added a same-network test. - Dropped the stale "EVM payloads don't embed accepted" comment (createPaymentPayload does embed accepted for v2). Co-Authored-By: Claude Opus 4.8 --- packages/atxp-server/src/omniChallenge.ts | 22 +++++++++-------- packages/atxp-server/src/protocol.test.ts | 29 +++++++++++++++++++++++ packages/atxp-server/src/protocol.ts | 13 +++++++--- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/atxp-server/src/omniChallenge.ts b/packages/atxp-server/src/omniChallenge.ts index 0d7f997..73a176f 100644 --- a/packages/atxp-server/src/omniChallenge.ts +++ b/packages/atxp-server/src/omniChallenge.ts @@ -18,18 +18,20 @@ const SOLANA_FEE_PAYERS: Record = { }; /** - * Map of CAIP-2 network → upto facilitator address, cached per auth server URL - * for the process lifetime. The promise (not the value) is cached so concurrent - * first callers share one fetch; an empty/failed result is not cached so a later - * call can retry. + * Map of CAIP-2 network → upto facilitator address, cached per auth server URL with + * a bounded TTL. The promise (not the value) is cached so concurrent first callers + * share one fetch; an empty/failed result is not cached so a later call can retry. + * The TTL bounds staleness if the facilitator's settle address rotates while a + * long-lived resource-server process is up (a stale witness would revert settles). */ -const _facilitatorAddressCache = new Map>>(); +const _FACILITATOR_ADDRESS_TTL_MS = 10 * 60 * 1000; +const _facilitatorAddressCache = new Map> }>(); /** * Fetch the upto facilitator addresses from the auth server's GET /x402/supported - * endpoint. The response is a flat CAIP-2 network → address map. Fetched at most - * once per process per auth server URL. On failure, returns {} (so the upto accept - * is simply omitted) and does not cache, allowing a later retry. + * endpoint. The response is a flat CAIP-2 network → address map. Cached per auth + * server URL for up to TTL. On failure, returns {} (so the upto accept is simply + * omitted) and does not cache, allowing a later retry. */ export async function fetchUptoFacilitatorAddresses( authServerUrl: AuthorizationServerUrl | string, @@ -38,7 +40,7 @@ export async function fetchUptoFacilitatorAddresses( ): Promise> { const url = new URL('/x402/supported', authServerUrl).toString(); const cached = _facilitatorAddressCache.get(url); - if (cached) return cached; + if (cached && Date.now() - cached.at < _FACILITATOR_ADDRESS_TTL_MS) return cached.value; const pending = (async () => { try { @@ -55,7 +57,7 @@ export async function fetchUptoFacilitatorAddresses( } })(); - _facilitatorAddressCache.set(url, pending); + _facilitatorAddressCache.set(url, { at: Date.now(), value: pending }); const result = await pending; // Don't cache an empty map — let a later call retry once the facilitator is up. if (Object.keys(result).length === 0) { diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index feaf757..50a9c60 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -418,6 +418,35 @@ describe('ProtocolSettlement', () => { expect(body.settlementOverrides).toEqual({ amount: '10000' }); }); + it('selects the accept matching the credential scheme when one network has both exact and upto', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xx402', settledAmount: '3000' }) }); + // The real challenge advertises exact AND upto for the same network (exact first). + // The credential is upto, so selection must match by scheme — not return exact-first. + const credential = Buffer.from(JSON.stringify({ + signature: '0xabc', + accepted: { network: 'eip155:8453', scheme: 'upto' }, + })).toString('base64'); + + await settlement.settle( + 'x402', + credential, + { + paymentRequirements: { + x402Version: 2, + accepts: [ + { scheme: 'exact', network: 'eip155:8453', amount: '10000' }, + { scheme: 'upto', network: 'eip155:8453', amount: '10000' }, + ], + }, + }, + BigNumber('0.003'), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.paymentRequirements.scheme).toBe('upto'); + expect(body.settlementOverrides).toEqual({ amount: '3000' }); + }); + it('still warns (greppable) and drops actualAmount for mpp (up-to not yet wired)', async () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ txHash: '0xmpp', settledAmount: '10000' }) }); const mppCredential = Buffer.from(JSON.stringify({ diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index 1bc9360..539d315 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -353,16 +353,23 @@ export class ProtocolSettlement { // authorize route — SVM payloads have solana: network, EVM have eip155:). // If the parsed payload includes an accepted object, use its network directly. const payloadObj = payload as Record | null; - const acceptedNetwork = (payloadObj?.accepted as Record | undefined)?.network as string | undefined; + const accepted = payloadObj?.accepted as Record | undefined; + const acceptedNetwork = accepted?.network as string | undefined; + const acceptedScheme = accepted?.scheme as string | undefined; if (acceptedNetwork) { - const match = accepts.find(a => a.network === acceptedNetwork); + // Match on network AND scheme: the challenge advertises both 'exact' and + // 'upto' per EVM network, so a network-only match could return the wrong + // scheme (e.g. exact when the credential is upto) and drop the override. + const match = accepts.find(a => a.network === acceptedNetwork && (acceptedScheme == null || a.scheme === acceptedScheme)) + ?? accepts.find(a => a.network === acceptedNetwork); if (!match) { this.logger.warn(`ProtocolSettlement: credential network ${acceptedNetwork} not in accepts [${accepts.map(a => a.network).join(', ')}], using first accept`); } requirements = match ?? accepts[0]; } else { - // Fallback for EVM payloads which don't embed accepted (x402HTTPClient format) + // No `accepted` on the payload (raw/older credential formats): fall back to + // the first EVM accept. const evmMatch = accepts.find(a => a.network?.startsWith('eip155')); if (!evmMatch) { this.logger.warn(`ProtocolSettlement: no EVM accept found in [${accepts.map(a => a.network).join(', ')}], using first accept`);