diff --git a/packages/atxp-express/src/atxpExpress.ts b/packages/atxp-express/src/atxpExpress.ts index 0e4722c..2dff2b6 100644 --- a/packages/atxp-express/src/atxpExpress.ts +++ b/packages/atxp-express/src/atxpExpress.ts @@ -12,35 +12,15 @@ import { sendProtectedResourceMetadataNode, sendOAuthMetadataNode, detectProtocol, - ProtocolSettlement, + setDetectedCredential, type PaymentProtocol, type ATXPConfig, - type SettlementContext, } from "@atxp/server"; export function atxpExpress(args: ATXPArgs): Router { const config = buildServerConfig(args); const router = Router(); - // Lazy-init ProtocolSettlement with destinationAccountId (requires async resolution). - // Cache the promise (not the result) to avoid a race where concurrent requests - // both see _settlement === null and kick off parallel getAccountId() calls. - let _settlementPromise: Promise | null = null; - async function getSettlement(): Promise { - if (!_settlementPromise) { - _settlementPromise = (async () => { - let destinationAccountId: string | undefined; - try { - destinationAccountId = await config.destination.getAccountId(); - } catch { - config.logger.warn('Could not resolve destinationAccountId for ProtocolSettlement'); - } - return new ProtocolSettlement(config.server, config.logger, fetch.bind(globalThis), destinationAccountId); - })(); - } - return _settlementPromise; - } - const atxpMiddleware = async (req: Request, res: Response, next: NextFunction) => { try { const logger = config.logger; @@ -61,35 +41,19 @@ export function atxpExpress(args: ATXPArgs): Router { const mcpRequests = await parseMcpRequestsNode(config, requestUrl, req, req.body); logger.debug(`${mcpRequests.length} MCP requests found in request`); - // Detect payment credentials BEFORE the MCP/non-MCP branch. - // This allows X402/MPP/ATXP credentials to work on both MCP and non-MCP requests. + // Detect payment credentials from request headers. + // The credential is stored in ATXP context for requirePayment() to settle + // with full pricing context (amount, options, destination). const detected = detectProtocol({ 'x-atxp-payment': req.headers['x-atxp-payment'] as string | undefined, 'x-payment': req.headers['x-payment'] as string | undefined, 'authorization': req.headers['authorization'] as string | undefined, }); - if (detected) { - // Settle at request start: validate → credit ledger → proceed - const settlement = await getSettlement(); - const settled = await settleAtRequestStart(config, settlement, req, res, detected.protocol, detected.credential); - if (!settled) return; // settle returned an error response - - if (mcpRequests.length > 0) { - // MCP request with credential: run MCP handler (requirePayment will charge from credited ledger) - logger.debug('Request started with protocol credential - MCP flow'); - const tokenCheck = await checkTokenNode(config, resource, req); - if (sendOAuthChallenge(res, tokenCheck)) return; - return withATXPContext(config, resource, tokenCheck, next); - } else { - // Non-MCP request with credential: just proceed - next(); - return; - } - } - - // No credential detected — normal flow if (mcpRequests.length === 0) { + // Non-MCP request with credential: for REST APIs, the route handler + // is responsible for checking payment (not requirePayment). + // TODO: Support settle-in-handler for non-MCP REST APIs. next(); return; } @@ -106,7 +70,21 @@ export function atxpExpress(args: ATXPArgs): Router { return; } - return withATXPContext(config, resource, tokenCheck, next); + // Set up ATXP context, then store detected credential if present. + // requirePayment() will find it via getDetectedCredential() and settle + // before charging, using the pricing context it has (amount, options). + return withATXPContext(config, resource, tokenCheck, () => { + if (detected) { + const sourceAccountId = resolveIdentitySync(config, req, detected.protocol, detected.credential); + setDetectedCredential({ + protocol: detected.protocol, + credential: detected.credential, + sourceAccountId, + }); + logger.info(`Stored ${detected.protocol} credential in context for requirePayment`); + } + return next(); + }); } catch (error) { config.logger.error(`Critical error in atxp middleware - returning HTTP 500. Error: ${error instanceof Error ? error.message : String(error)}`); config.logger.debug(JSON.stringify(error, null, 2)); @@ -119,36 +97,31 @@ export function atxpExpress(args: ATXPArgs): Router { } /** - * Resolve the user's identity from the request. + * Synchronous identity resolution from request headers/credential. * * Priority: - * 1. OAuth Bearer token → extract `sub` claim (preferred) - * 2. Wallet address from payment credential (fallback for non-OAuth clients) + * 1. ATXP credential sourceAccountId + * 2. MPP credential source DID + * 3. X402: not available until after settlement */ -async function resolveIdentity( +function resolveIdentitySync( config: ATXPConfig, req: Request, protocol: PaymentProtocol, credential: string, -): Promise { - const logger = config.logger; - - // Try OAuth Bearer token first (works when Authorization header isn't used by the payment protocol) - const authHeader = req.headers['authorization']; - if (authHeader?.startsWith('Bearer ')) { +): string | undefined { + if (protocol === 'atxp') { try { - const resource = getResource(config, new URL(req.url, req.protocol + '://' + req.host), req.headers); - const tokenCheck = await checkTokenNode(config, resource, req); - if (tokenCheck.data?.sub) { - logger.debug(`Resolved identity from OAuth token: ${tokenCheck.data.sub}`); - return tokenCheck.data.sub; + let parsed: Record; + try { + parsed = JSON.parse(Buffer.from(credential, 'base64').toString()); + } catch { + parsed = JSON.parse(credential); } - } catch (error) { - logger.warn(`Failed to resolve identity from OAuth token, falling back to credential: ${error instanceof Error ? error.message : String(error)}`); - } + if (parsed.sourceAccountId) return parsed.sourceAccountId as string; + } catch { /* not parseable */ } } - // Fallback: extract identity from the MPP credential's source field if (protocol === 'mpp') { try { let parsed: Record; @@ -164,89 +137,11 @@ async function resolveIdentity( const address = parts[4]; if (chainId && address) { const network = chainId === '4217' ? 'tempo' : chainId === '42431' ? 'tempo_moderato' : `eip155:${chainId}`; - const identity = `${network}:${address}`; - logger.debug(`Resolved identity from MPP credential source DID: ${identity}`); - return identity; + return `${network}:${address}`; } } - } catch { - // Not parseable — no identity - } - } - - // ATXP: identity comes from the credential's sourceAccountId field - if (protocol === 'atxp') { - try { - let parsed: Record; - try { - parsed = JSON.parse(Buffer.from(credential, 'base64').toString()); - } catch { - parsed = JSON.parse(credential); - } - if (parsed.sourceAccountId) { - logger.debug(`Resolved identity from ATXP credential: ${parsed.sourceAccountId}`); - return parsed.sourceAccountId as string; - } - } catch { - // Not parseable - } + } catch { /* not parseable */ } } return undefined; } - -/** - * Settle a payment credential at the START of a request. - * - * Calls auth /settle/{protocol} which: - * 1. Validates the credential - * 2. Credits the local balance ledger immediately - * 3. Fires on-chain settlement async - * - * After this returns true, the ledger has been credited and requirePayment() - * will be able to charge from it. - * - * Returns true if settlement succeeded (request should continue), - * false if it failed (error response already sent). - * - * NOTE: Settle-at-start means the payment is committed before the MCP handler runs. - * If the MCP handler fails after settlement, the user paid for nothing. - * This is the inverse of the old settle-on-finish problem (user gets resource for free - * if settlement fails). Settle-at-start is preferred because: - * 1. Pre-signed credentials (X402 Permit2, MPP signed tx) will settle regardless - * 2. The ledger credit is for future requests too, not just this one - * 3. A refund mechanism can be added later; preventing free resource access is harder - */ -async function settleAtRequestStart( - config: ATXPConfig, - settlement: ProtocolSettlement, - req: Request, - res: Response, - protocol: PaymentProtocol, - credential: string, -): Promise { - const logger = config.logger; - logger.info(`Settling ${protocol} credential at request start`); - - const sourceAccountId = await resolveIdentity(config, req, protocol, credential); - if (sourceAccountId) { - logger.debug(`Resolved identity for ${protocol} settlement: ${sourceAccountId}`); - } - - const context: SettlementContext = { - ...(sourceAccountId && { sourceAccountId }), - }; - - try { - const result = await settlement.settle(protocol, credential, context); - logger.info(`${protocol} settle-at-start succeeded: txHash=${result.txHash}, amount=${result.settledAmount}`); - return true; - } catch (error) { - logger.warn(`${protocol} settle-at-start failed: ${error instanceof Error ? error.message : String(error)}`); - res.status(402).json({ - error: 'settlement_failed', - error_description: `${protocol} credential settlement failed`, - }); - return false; - } -} diff --git a/packages/atxp-express/src/omniChallenge.test.ts b/packages/atxp-express/src/omniChallenge.test.ts index e8d479c..971e461 100644 --- a/packages/atxp-express/src/omniChallenge.test.ts +++ b/packages/atxp-express/src/omniChallenge.test.ts @@ -1,30 +1,27 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { atxpExpress } from './atxpExpress.js'; import * as TH from '@atxp/server/serverTestHelpers'; +import { getDetectedCredential, type DetectedCredential } from '@atxp/server'; import express from 'express'; import request from 'supertest'; -// Mock global fetch for ProtocolSettlement calls +// Mock global fetch — middleware no longer calls settle, so fetch should not be called const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); -describe('settle-at-start Express middleware', () => { +describe('credential detection Express middleware', () => { beforeEach(() => { mockFetch.mockReset(); }); - describe('credential detected → settle at request start', () => { - it('should settle X402 credential before the handler runs', async () => { + describe('credential detected → stored in context, handler proceeds', () => { + it('should proceed to handler without settling for X402 credential', async () => { const callOrder: string[] = []; - mockFetch.mockImplementation(async (url: string | URL) => { - const urlStr = url.toString(); - if (urlStr.includes('/settle/x402')) { - callOrder.push('settle'); - return { ok: true, json: async () => ({ txHash: '0xabc', settledAmount: '10000' }) }; - } - return { ok: false, status: 404, text: async () => 'Not found' }; + // fetch should NOT be called — middleware no longer settles + mockFetch.mockImplementation(async () => { + throw new Error('fetch should not be called — middleware does not settle'); }); const router = atxpExpress(TH.config({ @@ -45,11 +42,11 @@ describe('settle-at-start Express middleware', () => { expect(response.status).toBe(200); expect(response.body).toEqual({ data: 'protected resource' }); - // settle happened before handler - expect(callOrder).toEqual(['settle', 'handler']); + // handler runs, no settle + expect(callOrder).toEqual(['handler']); }); - it('should settle MPP credential before the handler runs', async () => { + it('should proceed to handler without settling for MPP credential', async () => { const callOrder: string[] = []; const mppCredential = { @@ -59,13 +56,8 @@ describe('settle-at-start Express middleware', () => { }; const encodedCredential = Buffer.from(JSON.stringify(mppCredential)).toString('base64'); - mockFetch.mockImplementation(async (url: string | URL) => { - const urlStr = url.toString(); - if (urlStr.includes('/settle/mpp')) { - callOrder.push('settle'); - return { ok: true, json: async () => ({ txHash: '0xmpp', settledAmount: '10000' }) }; - } - return { ok: false, status: 404, text: async () => 'Not found' }; + mockFetch.mockImplementation(async () => { + throw new Error('fetch should not be called — middleware does not settle'); }); const router = atxpExpress(TH.config({ @@ -85,10 +77,10 @@ describe('settle-at-start Express middleware', () => { .set('Authorization', `Payment ${encodedCredential}`); expect(response.status).toBe(200); - expect(callOrder).toEqual(['settle', 'handler']); + expect(callOrder).toEqual(['handler']); }); - it('should settle ATXP credential before the handler runs', async () => { + it('should proceed to handler without settling for ATXP credential', async () => { const callOrder: string[] = []; const atxpCredential = JSON.stringify({ @@ -96,13 +88,8 @@ describe('settle-at-start Express middleware', () => { sourceAccountToken: 'tok_abc', }); - mockFetch.mockImplementation(async (url: string | URL) => { - const urlStr = url.toString(); - if (urlStr.includes('/settle/atxp')) { - callOrder.push('settle'); - return { ok: true, json: async () => ({ txHash: 'atxp_tx', settledAmount: '5000' }) }; - } - return { ok: false, status: 404, text: async () => 'Not found' }; + mockFetch.mockImplementation(async () => { + throw new Error('fetch should not be called — middleware does not settle'); }); const router = atxpExpress(TH.config({ @@ -122,20 +109,16 @@ describe('settle-at-start Express middleware', () => { .set('X-ATXP-PAYMENT', atxpCredential); expect(response.status).toBe(200); - expect(callOrder).toEqual(['settle', 'handler']); + expect(callOrder).toEqual(['handler']); }); }); - describe('settlement failure → 402 and handler does not run', () => { - it('should return 402 when X402 settlement fails', async () => { + describe('credential present → handler proceeds (no 402 from middleware)', () => { + it('should return 200 even with a bad X402 credential', async () => { let handlerCalled = false; - mockFetch.mockImplementation(async (url: string | URL) => { - const urlStr = url.toString(); - if (urlStr.includes('/settle/x402')) { - return { ok: false, status: 400, text: async () => 'Settlement failed' }; - } - return { ok: false, status: 404, text: async () => 'Not found' }; + mockFetch.mockImplementation(async () => { + throw new Error('fetch should not be called — middleware does not settle'); }); const router = atxpExpress(TH.config({ @@ -147,27 +130,22 @@ describe('settle-at-start Express middleware', () => { app.use(router); app.get('/resource', (_req, res) => { handlerCalled = true; - res.json({ data: 'should not reach' }); + res.json({ data: 'handler reached' }); }); const response = await request(app) .get('/resource') .set('X-PAYMENT', 'bad-x402-credential'); - expect(response.status).toBe(402); - expect(response.body.error).toBe('settlement_failed'); - expect(handlerCalled).toBe(false); + expect(response.status).toBe(200); + expect(handlerCalled).toBe(true); }); - it('should return 402 when MPP settlement fails', async () => { + it('should return 200 even with a bad MPP credential', async () => { let handlerCalled = false; - mockFetch.mockImplementation(async (url: string | URL) => { - const urlStr = url.toString(); - if (urlStr.includes('/settle/mpp')) { - return { ok: false, status: 400, text: async () => 'MPP settlement failed' }; - } - return { ok: false, status: 404, text: async () => 'Not found' }; + mockFetch.mockImplementation(async () => { + throw new Error('fetch should not be called — middleware does not settle'); }); const router = atxpExpress(TH.config({ @@ -188,9 +166,8 @@ describe('settle-at-start Express middleware', () => { .get('/resource') .set('Authorization', `Payment ${mppCredential}`); - expect(response.status).toBe(402); - expect(response.body.error).toBe('settlement_failed'); - expect(handlerCalled).toBe(false); + expect(response.status).toBe(200); + expect(handlerCalled).toBe(true); }); }); @@ -252,18 +229,14 @@ describe('settle-at-start Express middleware', () => { }); }); - describe('identity resolution for settlement', () => { - it('should resolve identity from MPP credential and pass sourceAccountId to settle', async () => { - let settleBody: Record = {}; - - mockFetch.mockImplementation(async (url: string | URL, init?: RequestInit) => { - const urlStr = url.toString(); - if (urlStr.includes('/settle/mpp')) { - if (init?.body) settleBody = JSON.parse(init.body as string); - return { ok: true, json: async () => ({ txHash: '0xmpp', settledAmount: '1.00' }) }; - } - return { ok: false, status: 404, text: async () => 'Not found' }; - }); + describe('identity resolution from credential (MCP requests)', () => { + it('should store MPP credential with sourceAccountId resolved from DID', async () => { + // MPP uses Authorization: Payment which conflicts with OAuth Bearer on + // MCP requests. For non-MCP requests the middleware detects the credential + // but doesn't enter withATXPContext (no ATXP context to store into). + // requirePayment() handles this at charge time for MCP. Here we verify the + // middleware detects MPP and the handler proceeds without error. + let storedCredential: DetectedCredential | null = null; const mppCredential = { challenge: { id: 'ch_123', method: 'tempo', intent: 'charge', request: { amount: '10000' } }, @@ -279,27 +252,24 @@ describe('settle-at-start Express middleware', () => { const app = express(); app.use(express.json()); app.use(router); - app.get('/resource', (_req, res) => res.json({ ok: true })); + app.get('/resource', (_req, res) => { + storedCredential = getDetectedCredential(); + res.json({ ok: true }); + }); const response = await request(app) .get('/resource') .set('Authorization', `Payment ${encodedCredential}`); expect(response.status).toBe(200); - expect(settleBody.sourceAccountId).toBe('tempo:0xWalletAddr'); + // Non-MCP path: no ATXP context, so credential is not stored. + // The middleware detected it (detectProtocol returns mpp), but + // setDetectedCredential only runs inside withATXPContext (MCP path). + expect(storedCredential).toBeNull(); }); - it('should resolve identity from ATXP raw JSON credential', async () => { - let settleBody: Record = {}; - - mockFetch.mockImplementation(async (url: string | URL, init?: RequestInit) => { - const urlStr = url.toString(); - if (urlStr.includes('/settle/atxp')) { - if (init?.body) settleBody = JSON.parse(init.body as string); - return { ok: true, json: async () => ({ txHash: 'atxp_tx', settledAmount: '5000' }) }; - } - return { ok: false, status: 404, text: async () => 'Not found' }; - }); + it('should store ATXP credential with sourceAccountId from raw JSON', async () => { + let storedCredential: DetectedCredential | null = null; // Raw JSON (not base64-encoded) const atxpCredential = JSON.stringify({ @@ -314,27 +284,26 @@ describe('settle-at-start Express middleware', () => { const app = express(); app.use(express.json()); app.use(router); - app.get('/resource', (_req, res) => res.json({ ok: true })); + app.post('/', (req, res) => { + storedCredential = getDetectedCredential(); + res.json({ ok: true }); + }); const response = await request(app) - .get('/resource') - .set('X-ATXP-PAYMENT', atxpCredential); + .post('/') + .set('Content-Type', 'application/json') + .set('X-ATXP-PAYMENT', atxpCredential) + .set('Authorization', 'Bearer test-token') + .send(TH.mcpToolRequest()); expect(response.status).toBe(200); - expect(settleBody.sourceAccountId).toBe('atxp_acct_raw123'); + expect(storedCredential).not.toBeNull(); + expect(storedCredential!.protocol).toBe('atxp'); + expect(storedCredential!.sourceAccountId).toBe('atxp_acct_raw123'); }); - it('should resolve identity from ATXP base64-encoded credential', async () => { - let settleBody: Record = {}; - - mockFetch.mockImplementation(async (url: string | URL, init?: RequestInit) => { - const urlStr = url.toString(); - if (urlStr.includes('/settle/atxp')) { - if (init?.body) settleBody = JSON.parse(init.body as string); - return { ok: true, json: async () => ({ txHash: 'atxp_tx', settledAmount: '5000' }) }; - } - return { ok: false, status: 404, text: async () => 'Not found' }; - }); + it('should store ATXP credential with sourceAccountId from base64-encoded JSON', async () => { + let storedCredential: DetectedCredential | null = null; // Base64-encoded JSON const atxpCredential = Buffer.from(JSON.stringify({ @@ -349,14 +318,22 @@ describe('settle-at-start Express middleware', () => { const app = express(); app.use(express.json()); app.use(router); - app.get('/resource', (_req, res) => res.json({ ok: true })); + app.post('/', (req, res) => { + storedCredential = getDetectedCredential(); + res.json({ ok: true }); + }); const response = await request(app) - .get('/resource') - .set('X-ATXP-PAYMENT', atxpCredential); + .post('/') + .set('Content-Type', 'application/json') + .set('X-ATXP-PAYMENT', atxpCredential) + .set('Authorization', 'Bearer test-token') + .send(TH.mcpToolRequest()); expect(response.status).toBe(200); - expect(settleBody.sourceAccountId).toBe('atxp_acct_b64_456'); + expect(storedCredential).not.toBeNull(); + expect(storedCredential!.protocol).toBe('atxp'); + expect(storedCredential!.sourceAccountId).toBe('atxp_acct_b64_456'); }); }); }); diff --git a/packages/atxp-server/src/atxpContext.ts b/packages/atxp-server/src/atxpContext.ts index f3ff5df..dc36ab9 100644 --- a/packages/atxp-server/src/atxpContext.ts +++ b/packages/atxp-server/src/atxpContext.ts @@ -1,14 +1,27 @@ -import { TokenData, AccountId } from "@atxp/common"; +import { TokenData, AccountId, type PaymentProtocol } from "@atxp/common"; import { ATXPConfig, TokenCheck } from "./types.js"; import { AsyncLocalStorage } from "async_hooks"; const contextStorage = new AsyncLocalStorage(); +/** + * Payment credential detected by the middleware from request headers. + * Stored in context so requirePayment can settle it with full pricing context. + */ +export type DetectedCredential = { + protocol: PaymentProtocol; + credential: string; + /** User identity resolved from OAuth token or credential source */ + sourceAccountId?: string; +}; + type ATXPContext = { token: string | null; tokenData: TokenData | null; config: ATXPConfig; resource: URL; + /** Payment credential from retry request (X-PAYMENT, X-ATXP-PAYMENT, etc.) */ + detectedCredential?: DetectedCredential; } export function getATXPConfig(): ATXPConfig | null { @@ -33,6 +46,25 @@ export function atxpToken(): string | null { return context?.token ?? null; } +/** + * Get the payment credential detected by middleware on this request. + * Returns null if this is not a retry with a payment credential. + */ +export function getDetectedCredential(): DetectedCredential | null { + const context = contextStorage.getStore(); + return context?.detectedCredential ?? null; +} + +/** + * Store a payment credential in the ATXP context (called by middleware). + */ +export function setDetectedCredential(credential: DetectedCredential): void { + const context = contextStorage.getStore(); + if (context) { + context.detectedCredential = credential; + } +} + // Helper function to run code within a user context export async function withATXPContext(config: ATXPConfig, resource: URL, tokenInfo: Pick | null, next: () => void): Promise { config.logger.debug(`Setting user context to ${tokenInfo?.data?.sub ?? 'null'}`); diff --git a/packages/atxp-server/src/index.ts b/packages/atxp-server/src/index.ts index 8d02d06..7131f09 100644 --- a/packages/atxp-server/src/index.ts +++ b/packages/atxp-server/src/index.ts @@ -24,7 +24,10 @@ export { getATXPResource, atxpAccountId, atxpToken, - withATXPContext + withATXPContext, + getDetectedCredential, + setDetectedCredential, + type DetectedCredential, } from './atxpContext.js'; // Core platform-agnostic business logic (no I/O dependencies) diff --git a/packages/atxp-server/src/omniChallenge.ts b/packages/atxp-server/src/omniChallenge.ts index 8dabfef..3ff4e91 100644 --- a/packages/atxp-server/src/omniChallenge.ts +++ b/packages/atxp-server/src/omniChallenge.ts @@ -23,16 +23,28 @@ export function buildX402Requirements(args: { const chainOptions = args.options.filter(o => X402_NETWORKS.has(o.network) && o.address.startsWith('0x') ); + // USDC contract addresses per network (for X402 asset field). + // Source: https://developers.circle.com/stablecoins/usdc-on-main-networks + const USDC_ASSETS: Record = { + base: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + base_sepolia: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + }; + + // Note on testnet: network is normalized to 'base' for the X402 spec field, but the + // asset address uses the original option.network (e.g. 'base_sepolia') to select the + // correct USDC contract. On testnet this means network='base' with a sepolia USDC + // address — the X402 facilitator uses the asset contract address as source of truth + // for chain resolution, not the network name. const accepts: X402PaymentOption[] = chainOptions.map(option => ({ scheme: 'exact', - // X402 spec normalizes testnet networks to their mainnet names in challenges. - // base_sepolia → base so the client knows which chain family to use. network: option.network === 'base' || option.network === 'base_sepolia' ? 'base' : option.network, - maxAmountRequired: option.amount.times(1e6).toFixed(0), // Convert to smallest unit (USDC has 6 decimals) + maxAmountRequired: option.amount.times(1e6).toFixed(0), resource: args.resource, description: args.payeeName, + mimeType: 'application/json', payTo: option.address, maxTimeoutSeconds: 300, + asset: USDC_ASSETS[option.network] || USDC_ASSETS['base'], })); return { diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index 96e4394..aea2588 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -43,6 +43,7 @@ export type X402PaymentOption = { mimeType?: string; payTo: string; maxTimeoutSeconds?: number; + asset?: string; extra?: Record; }; @@ -215,9 +216,21 @@ export class ProtocolSettlement { // If not valid base64 JSON, pass as-is (auth will validate) payload = { raw: credential }; } + + // paymentRequirements from context may be a full X402PaymentRequirements object + // ({x402Version, accepts: [...]}) from buildX402Requirements. Auth expects a single + // requirement object. We extract the first accept because the server currently has + // one Base address for X402 — multi-option selection would require correlating the + // credential back to the specific accept option the client chose. + let requirements = context?.paymentRequirements; + if (requirements && typeof requirements === 'object' && 'accepts' in (requirements as Record)) { + const x402Reqs = requirements as { accepts?: unknown[] }; + requirements = x402Reqs.accepts?.[0] ?? requirements; + } + return { payload, - paymentRequirements: context?.paymentRequirements, + paymentRequirements: requirements, ...(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 5f9edd2..da6ee13 100644 --- a/packages/atxp-server/src/requirePayment.test.ts +++ b/packages/atxp-server/src/requirePayment.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect, vi } from 'vitest'; import { requirePayment } from './index.js'; import * as TH from './serverTestHelpers.js'; import { BigNumber } from 'bignumber.js'; -import { withATXPContext } from './atxpContext.js'; +import { withATXPContext, setDetectedCredential } from './atxpContext.js'; import { OMNI_PAYMENT_ERROR_CODE } from '@atxp/common'; +import { McpError } from '@modelcontextprotocol/sdk/types.js'; +import { ProtocolSettlement } from './protocol.js'; describe('requirePayment', () => { it('should pass if there is money', async () => { @@ -339,4 +341,69 @@ describe('requirePayment', () => { }); }); + describe('settlement of detected credentials', () => { + it('should settle credential and succeed when charge passes after settlement', async () => { + const mockSettle = vi.fn().mockResolvedValue({ txHash: '0xabc', settledAmount: '10000' }); + vi.spyOn(ProtocolSettlement.prototype, 'settle').mockImplementation(mockSettle); + + const paymentServer = TH.paymentServer({ charge: vi.fn().mockResolvedValue(true) }); + const config = TH.config({ paymentServer }); + + await withATXPContext(config, new URL('https://example.com'), TH.tokenCheck(), async () => { + setDetectedCredential({ protocol: 'x402', credential: 'dGVzdA==', sourceAccountId: 'base:0x123' }); + await expect(requirePayment({ price: BigNumber(0.01) })).resolves.not.toThrow(); + expect(mockSettle).toHaveBeenCalledWith('x402', 'dGVzdA==', expect.objectContaining({ + destinationAccountId: `base:${TH.DESTINATION}`, + })); + expect(paymentServer.charge).toHaveBeenCalled(); + }); + + vi.restoreAllMocks(); + }); + + it('should throw McpError when settlement fails instead of falling through silently', async () => { + const mockSettle = vi.fn().mockRejectedValue(new Error('on-chain tx reverted')); + vi.spyOn(ProtocolSettlement.prototype, 'settle').mockImplementation(mockSettle); + + const paymentServer = TH.paymentServer({ charge: vi.fn().mockResolvedValue(false) }); + const config = TH.config({ paymentServer }); + + await withATXPContext(config, new URL('https://example.com'), TH.tokenCheck(), async () => { + setDetectedCredential({ protocol: 'x402', credential: 'dGVzdA==', sourceAccountId: 'base:0x123' }); + try { + await requirePayment({ price: BigNumber(0.01) }); + expect.fail('should have thrown'); + } catch (err: unknown) { + // Should be an McpError with settlement failure details, NOT an omni-challenge + expect(err).toBeInstanceOf(McpError); + const mcpErr = err as McpError; + expect(mcpErr.code).toBe(-32000); + expect(mcpErr.message).toContain('Payment settlement failed for x402'); + expect((mcpErr.data as Record)?.reason).toBe('on-chain tx reverted'); + // charge() should NOT have been called — we threw before reaching it + expect(paymentServer.charge).not.toHaveBeenCalled(); + } + }); + + vi.restoreAllMocks(); + }); + + it('should skip settlement and proceed to normal charge when no credential detected', async () => { + const mockSettle = vi.fn(); + vi.spyOn(ProtocolSettlement.prototype, 'settle').mockImplementation(mockSettle); + + const paymentServer = TH.paymentServer({ charge: vi.fn().mockResolvedValue(true) }); + const config = TH.config({ paymentServer }); + + await withATXPContext(config, new URL('https://example.com'), TH.tokenCheck(), async () => { + // No setDetectedCredential call — no credential on this request + await expect(requirePayment({ price: BigNumber(0.01) })).resolves.not.toThrow(); + expect(mockSettle).not.toHaveBeenCalled(); + expect(paymentServer.charge).toHaveBeenCalled(); + }); + + vi.restoreAllMocks(); + }); + }); + }); \ No newline at end of file diff --git a/packages/atxp-server/src/requirePayment.ts b/packages/atxp-server/src/requirePayment.ts index 958bee4..daf7fee 100644 --- a/packages/atxp-server/src/requirePayment.ts +++ b/packages/atxp-server/src/requirePayment.ts @@ -1,8 +1,10 @@ -import { RequirePaymentConfig, extractNetworkFromAccountId, extractAddressFromAccountId, Network } from "@atxp/common"; +import { RequirePaymentConfig, extractNetworkFromAccountId, extractAddressFromAccountId, Network, AuthorizationServerUrl } from "@atxp/common"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; import { BigNumber } from "bignumber.js"; -import { getATXPConfig, atxpAccountId, atxpToken } from "./atxpContext.js"; +import { getATXPConfig, atxpAccountId, atxpToken, getDetectedCredential } from "./atxpContext.js"; import { buildX402Requirements, buildMppChallenge, omniChallengeMcpError } from "./omniChallenge.js"; import { getATXPResource } from "./atxpContext.js"; +import { ProtocolSettlement, type SettlementContext } from "./protocol.js"; export async function requirePayment(paymentConfig: RequirePaymentConfig): Promise { const config = getATXPConfig(); @@ -34,15 +36,22 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi network: destinationNetwork, currency: config.currency, address: destinationAddress, - amount: paymentConfig.price // Option gets the requested amount for charge + amount: paymentConfig.price }], sourceAccountId: user, destinationAccountId: destinationAccountId, payeeName: config.payeeName, - // Include token for on-demand charging via AccountsOnDemandChargeStrategy ...(token && { sourceAccountToken: token }), }; + // If a payment credential was detected on this request (retry after challenge), + // settle it now. We have the full pricing context to generate requirements. + const detectedCredential = getDetectedCredential(); + if (detectedCredential) { + await settleDetectedCredential(config, detectedCredential, charge, destinationAccountId, paymentAmount); + // After settlement, the ledger should be credited. Fall through to charge below. + } + config.logger.debug(`Charging ${paymentConfig.price} to ${charge.options.length} options for source ${user}`); const chargeSucceeded = await config.paymentServer.charge(charge); @@ -54,29 +63,23 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi const existingPaymentId = await paymentConfig.getExistingPaymentId?.(); if (existingPaymentId) { config.logger.info(`Found existing payment ID ${existingPaymentId}`); - // Use the base charge options (before source expansion) for the omni-challenge throw buildOmniError(config, existingPaymentId, paymentAmount, charge.options); } // For createPaymentRequest, use the minimumPayment if configured - // Fetch account sources to provide backwards compatibility with old clients - // that expect multiple payment options (base, solana, world, etc.) const options = [{ network: destinationNetwork, currency: config.currency, address: destinationAddress, - amount: paymentAmount // Use minimumPayment or requested amount + amount: paymentAmount }]; try { - // TODO: Remove this once pre-v0.8.0 clients are no longer supported - 0.8.0 only needs 'atxp' const sources = await config.destination.getSources(); config.logger.debug(`Fetched ${sources.length} sources for destination account`); - - // Add each source as an alternative payment option for (const source of sources) { options.push({ - network: source.chain as Network, // Chain and Network have compatible values + network: source.chain as Network, currency: config.currency, address: source.address, amount: paymentAmount @@ -85,7 +88,6 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi config.logger.debug(`Payment request will include ${options.length} total options`); } catch (error) { config.logger.warn(`Failed to fetch account sources, will use ATXP option only: ${error}`); - // Continue with just the ATXP option if sources fetch fails } const paymentRequest = { @@ -101,12 +103,94 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi throw buildOmniError(config, paymentId, paymentAmount, options); } +/** + * Settle a payment credential that was detected on this retry request. + * + * This runs inside requirePayment because it has the pricing context needed + * to generate protocol-specific settlement data: + * - X402: regenerates paymentRequirements from charge options (same as the challenge) + * - ATXP: passes sourceAccountToken and payment options + * - MPP: passes credential directly (self-contained) + * + * After settlement, the auth service credits the local ledger, so the + * subsequent charge() call will succeed. + */ +async function settleDetectedCredential( + config: NonNullable>, + detected: NonNullable>, + charge: { options: Array<{ network: string; currency: string; address: string; amount: BigNumber }>; sourceAccountId: string; destinationAccountId: string }, + destinationAccountId: string, + paymentAmount: BigNumber, +): Promise { + const { protocol, credential, sourceAccountId } = detected; + config.logger.info(`Settling ${protocol} credential in requirePayment (has pricing context)`); + + // ProtocolSettlement is instantiated per-request. This is intentional — the class + // is lightweight (stores config references only, no connections or heavy init). + // Caching would require threading persistent state through requirePayment's + // stateless call chain, for negligible benefit. + const settlement = new ProtocolSettlement( + config.server, + config.logger, + fetch.bind(globalThis), + destinationAccountId, + ); + + // Build settlement context with identity and protocol-specific data + const context: SettlementContext = { + ...(sourceAccountId && { sourceAccountId }), + destinationAccountId, + options: charge.options, + }; + + // For X402, regenerate the paymentRequirements from the destination's + // real chain addresses (not the ATXP account ID). This is the standard X402 + // pattern — the server generates requirements from its own config. + if (protocol === 'x402') { + const resource = getATXPResource()?.toString() ?? ''; + // Fetch destination's chain addresses (base, solana, etc.) + let x402Options = charge.options; + try { + const sources = await config.destination.getSources(); + x402Options = sources.map(s => ({ + network: s.chain as string, + currency: config.currency, + address: s.address, + amount: paymentAmount, + })); + } catch (err) { + config.logger.warn(`Failed to fetch destination sources for X402 settle: ${err}`); + } + const x402Requirements = buildX402Requirements({ + options: x402Options, + resource, + payeeName: config.payeeName, + }); + if (x402Requirements.accepts.length === 0) { + config.logger.warn('X402 settle: no compatible payment options after filtering'); + } + context.paymentRequirements = x402Requirements; + } + + try { + const result = await settlement.settle(protocol, credential, context); + config.logger.info(`${protocol} settlement succeeded: txHash=${result.txHash}, amount=${result.settledAmount}`); + } catch (error) { + // Settlement failed — the credential was invalid or the on-chain tx failed. + // Throw an explicit error so the client knows its credential was rejected, + // rather than silently falling through to charge (which would fail with a + // confusing insufficient_balance + new challenge). + const reason = error instanceof Error ? error.message : String(error); + config.logger.error(`${protocol} settlement failed: ${reason}`); + throw new McpError(-32000, `Payment settlement failed for ${protocol}`, { protocol, reason }); + } +} + /** * Build an omni-challenge MCP error that includes ATXP-MCP + X402 + MPP data. - * This enables clients to detect and respond to any supported protocol. */ function buildOmniError( - config: { server: import("@atxp/common").AuthorizationServerUrl; logger: import("@atxp/common").Logger }, + config: { server: AuthorizationServerUrl; logger: import("@atxp/common").Logger }, paymentId: string, paymentAmount: BigNumber, options: Array<{ network: string; currency: string; address: string; amount: BigNumber }>, @@ -120,10 +204,9 @@ function buildOmniError( }); if (x402Requirements.accepts.length === 0 && options.length > 0) { - config.logger.warn(`buildX402Requirements filtered all ${options.length} options — no X402-compatible networks (base/base_sepolia with 0x address). X402 clients will not see any payment options.`); + config.logger.warn(`buildX402Requirements filtered all ${options.length} options — no X402-compatible networks. X402 clients will not see any payment options.`); } - // Include MPP challenge if any option is on Tempo const mppChallenge = buildMppChallenge({ id: paymentId, options }); return omniChallengeMcpError(