diff --git a/packages/atxp-base/src/baseAccount.ts b/packages/atxp-base/src/baseAccount.ts index 0c7b159..bfbe62e 100644 --- a/packages/atxp-base/src/baseAccount.ts +++ b/packages/atxp-base/src/baseAccount.ts @@ -96,6 +96,9 @@ 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()); + // 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") 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 6f2a331..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,6 +36,7 @@ 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: 'exact', @@ -42,6 +50,62 @@ describe('omniChallenge', () => { }); }); + 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') }, + ]; + + const result = buildX402Requirements({ + options, + resource: 'https://example.com', + payeeName: 'Multi-chain Server', + facilitatorAddresses: FACILITATORS, + }); + + // EVM: exact + upto; SVM: exact only (Solana upto not implemented). + expect(result.accepts[0].network).toBe('eip155:8453'); + 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', () => { const options = [ { network: 'base', currency: 'USDC', address: '0xAddr1', amount: new BigNumber('0.01') }, @@ -55,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', () => { @@ -609,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 b339707..73a176f 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,78 @@ const SOLANA_FEE_PAYERS: Record = { solana_devnet: 'Hc3sdEAsCGQcpgfivywog9uwtk8gUBUZgsxdME1EJy88', }; +/** + * 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 _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. 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, + 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 && Date.now() - cached.at < _FACILITATOR_ADDRESS_TTL_MS) return cached.value; + + 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, { 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) { + _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,11 +96,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[] = [ - ...evmOptions.map(option => ({ - scheme: 'exact' 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, @@ -45,10 +111,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, @@ -58,12 +133,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, }; } @@ -289,6 +377,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 }) @@ -300,6 +390,7 @@ export function buildOmniChallenge(args: { options: args.options, resource: args.resource, payeeName: args.payeeName, + facilitatorAddresses: args.facilitatorAddresses, }), ...(mpp && { mpp }), }; @@ -337,6 +428,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; @@ -351,6 +445,7 @@ export function buildPaymentOptions(args: { options, resource: args.resource ?? '', payeeName: args.payeeName ?? '', + facilitatorAddresses: args.facilitatorAddresses, }), mpp: buildMppChallenges({ id: challengeId, options, resource: args.resource }), options, @@ -374,6 +469,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 e335236..50a9c60 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -308,13 +308,156 @@ 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'); - await settlement.settle('x402', credential, { paymentRequirements: { network: 'base' } }, BigNumber(0.003)); + // Authorized cap is the credential's Permit2 amount; the metered actual is $0.003. + await settlement.settle('x402', credential, { paymentRequirements: { scheme: 'upto', 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('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) 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('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({ + 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..539d315 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`); } @@ -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`); @@ -371,9 +378,31 @@ export class ProtocolSettlement { } } + // "up-to" semantics: settle the metered actual (≤ the Permit2 cap) by + // 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, paymentRequirements: requirements, + ...settlementOverrides, ...(context?.paymentRequestId && { paymentRequestId: context.paymentRequestId }), ...(context?.sourceAccountId && { sourceAccountId: context.sourceAccountId }), ...(this.destinationAccountId && { destinationAccountId: this.destinationAccountId }), 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 c7d4e25..24948e7 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,94 @@ describe('wrapWithX402', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); + 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', + 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('exact'); + expect(schemeConstructions).not.toContain('upto'); + }); + + 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..dd80811 100644 --- a/packages/atxp-x402/src/x402Wrapper.ts +++ b/packages/atxp-x402/src/x402Wrapper.ts @@ -98,11 +98,14 @@ export const wrapWithX402: FetchWrapper = (config: ClientArgs): FetchLike => { }); } - // Select the best payment requirements (prefer exact scheme on any base-like network) + // 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 === 'exact' - ) ?? accepts[0]; + const selectedPaymentRequirements = + accepts.find((a) => a.scheme === 'exact') ?? + accepts[0]; if (!selectedPaymentRequirements) { log.info('No suitable X402 payment option found'); @@ -204,6 +207,7 @@ 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); + // Self-custody EOA signs an exact EIP-3009 transfer authorization. const scheme = new ExactEvmScheme(evmSigner); const x402ClientInstance = new x402Client();