diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index 6c99b3b0..a8aa2a8b 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -123,6 +123,84 @@ export class ATXPFetcher { } }; + protected handleMultiDestinationPayment = async ( + paymentRequestData: PaymentRequestData, + paymentRequestUrl: string, + paymentRequestId: string + ): Promise => { + if (!paymentRequestData.destinations || paymentRequestData.destinations.length === 0) { + return false; + } + + // Try each destination in order + for (const dest of paymentRequestData.destinations) { + const paymentMaker = this.paymentMakers.get(dest.network); + if (!paymentMaker) { + this.logger.debug(`ATXP: payment network '${dest.network}' not available, trying next destination`); + continue; + } + + // Convert amount to BigNumber since it comes as a string from JSON + const amount = new BigNumber(dest.amount); + + const prospectivePayment : ProspectivePayment = { + accountId: this.accountId, + resourceUrl: paymentRequestData.resource?.toString() ?? '', + resourceName: paymentRequestData.resourceName ?? '', + network: dest.network, + currency: dest.currency, + amount: amount, + iss: paymentRequestData.iss ?? '', + }; + + if (!await this.approvePayment(prospectivePayment)){ + this.logger.info(`ATXP: payment request denied by callback function for destination on ${dest.network}`); + continue; + } + + let paymentId: string; + try { + paymentId = await paymentMaker.makePayment(amount, dest.currency, dest.address, paymentRequestData.iss); + this.logger.info(`ATXP: made payment of ${amount.toString()} ${dest.currency} on ${dest.network}: ${paymentId}`); + await this.onPayment({ payment: prospectivePayment }); + + // Submit payment to the server + const jwt = await paymentMaker.generateJWT({paymentRequestId, codeChallenge: ''}); + const response = await this.sideChannelFetch(paymentRequestUrl.toString(), { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${jwt}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + transactionId: paymentId, + network: dest.network, + currency: dest.currency + }) + }); + + this.logger.debug(`ATXP: payment was ${response.ok ? 'successfully' : 'not successfully'} PUT to ${paymentRequestUrl} : status ${response.status} ${response.statusText}`); + + if(!response.ok) { + const msg = `ATXP: payment to ${paymentRequestUrl} failed: HTTP ${response.status} ${await response.text()}`; + this.logger.info(msg); + throw new Error(msg); + } + + return true; + } catch (error: unknown) { + const typedError = error as Error; + this.logger.warn(`ATXP: payment failed on ${dest.network}: ${typedError.message}`); + await this.onPaymentFailure({ payment: prospectivePayment, error: typedError }); + // Try next destination + continue; + } + } + + this.logger.info(`ATXP: no suitable payment destination found among ${paymentRequestData.destinations.length} options`); + return false; + } + protected handlePaymentRequestError = async (paymentRequestError: McpError): Promise => { if (paymentRequestError.code !== PAYMENT_REQUIRED_ERROR_CODE) { throw new Error(`ATXP: expected payment required error (code ${PAYMENT_REQUIRED_ERROR_CODE}); got code ${paymentRequestError.code}`); @@ -145,6 +223,12 @@ export class ATXPFetcher { throw new Error(`ATXP: payment request ${paymentRequestId} not found on server ${paymentRequestUrl}`); } + // Handle multi-destination format + if (paymentRequestData.destinations && paymentRequestData.destinations.length > 0) { + return this.handleMultiDestinationPayment(paymentRequestData, paymentRequestUrl, paymentRequestId); + } + + // Handle legacy single destination format const requestedNetwork = paymentRequestData.network; if (!requestedNetwork) { throw new Error(`Payment network not provided`); @@ -228,7 +312,8 @@ export class ATXPFetcher { }, body: JSON.stringify({ transactionId: paymentId, - network: requestedNetwork + network: requestedNetwork, + currency: currency }) }); diff --git a/packages/atxp-client/src/atxpLocalAccount.ts b/packages/atxp-client/src/atxpLocalAccount.ts index 78752e2e..a142b7cf 100644 --- a/packages/atxp-client/src/atxpLocalAccount.ts +++ b/packages/atxp-client/src/atxpLocalAccount.ts @@ -41,7 +41,12 @@ export class ATXPLocalAccount implements LocalAccount { fetchFn: FetchLike = fetch as FetchLike ): Promise { // The /address endpoint uses Basic auth like other authenticated endpoints - const response = await fetchFn(`${origin}/address`, { + // For X402, we need the Ethereum/Base address with USDC currency + const url = new URL(`${origin}/address`); + url.searchParams.set('network', 'base'); // X402 operates on Base + url.searchParams.set('currency', 'USDC'); // Always USDC for X402 + + const response = await fetchFn(url.toString(), { method: 'GET', headers: { 'Authorization': toBasicAuth(token) @@ -53,18 +58,19 @@ export class ATXPLocalAccount implements LocalAccount { throw new Error(`Failed to fetch destination address: ${response.status} ${response.statusText} ${errorText}`); } - const data = await response.json() as { address?: string; chainType?: string }; + const data = await response.json() as { address?: string; network?: string; currency?: string }; const address = data.address; if (!address) { throw new Error('Address endpoint did not return an address'); } - // Check that the account is an Ethereum account (required for X402/EVM operations) - if (!data.chainType) { - throw new Error('Address endpoint did not return a chainType'); + // Check that the account is an Ethereum/Base account (required for X402/EVM operations) + const network = data.network; + if (!network) { + throw new Error('Address endpoint did not return a network'); } - if (data.chainType !== 'ethereum') { - throw new Error(`ATXPLocalAccount requires an Ethereum account, but got ${data.chainType} account`); + if (network !== 'ethereum' && network !== 'base') { + throw new Error(`ATXPLocalAccount requires an Ethereum/Base account, but got ${network} account`); } return new ATXPLocalAccount(address as Address, origin, token, fetchFn); diff --git a/packages/atxp-common/src/index.ts b/packages/atxp-common/src/index.ts index a47e1136..f80002bf 100644 --- a/packages/atxp-common/src/index.ts +++ b/packages/atxp-common/src/index.ts @@ -35,6 +35,7 @@ export { type AuthorizationServerUrl, type Currency, type Network, + type PaymentRequestDestination, type PaymentRequestData, type CustomJWTPayload, type ClientCredentials, diff --git a/packages/atxp-common/src/types.ts b/packages/atxp-common/src/types.ts index fbbababd..4fa708e5 100644 --- a/packages/atxp-common/src/types.ts +++ b/packages/atxp-common/src/types.ts @@ -22,11 +22,22 @@ export type AuthorizationServerUrl = UrlString; export type Currency = 'USDC'; export type Network = 'solana' | 'base'; -export type PaymentRequestData = { - amount: BigNumber; - currency: Currency; +export type PaymentRequestDestination = { network: Network; - destination: string; + currency: Currency; + address: string; + amount: BigNumber; +} + +export type PaymentRequestData = { + // New multi-destination format + destinations?: PaymentRequestDestination[]; + // Legacy single destination fields (for backwards compatibility) + amount?: BigNumber; + currency?: Currency; + network?: Network; + destination?: string; + // Common fields source: string; resource: URL; resourceName: string; diff --git a/packages/atxp-server/src/paymentDestination.test.ts b/packages/atxp-server/src/paymentDestination.test.ts index 15768b99..6be538bf 100644 --- a/packages/atxp-server/src/paymentDestination.test.ts +++ b/packages/atxp-server/src/paymentDestination.test.ts @@ -32,7 +32,7 @@ describe('ATXPPaymentDestination', () => { ok: true, json: async () => ({ destination: '0x1234567890123456789012345678901234567890', - chainType: 'base' + network: 'base' }) }); @@ -44,7 +44,7 @@ describe('ATXPPaymentDestination', () => { ); expect(mockFetch).toHaveBeenCalledWith( - 'https://accounts.example.com/destination?connectionToken=abc123&buyerAddress=0xbuyer&amount=100', + 'https://accounts.example.com/destination?connectionToken=abc123&buyerAddress=0xbuyer&amount=100¤cy=USDC', { method: 'GET', headers: { @@ -89,7 +89,7 @@ describe('ATXPPaymentDestination', () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ - chainType: 'base' + network: 'base' }) }); @@ -101,7 +101,7 @@ describe('ATXPPaymentDestination', () => { )).rejects.toThrow('ATXPPaymentDestination: /destination did not return destination'); }); - it('should throw an error if response is missing chainType', async () => { + it('should throw an error if response is missing network', async () => { const connectionString = 'https://accounts.example.com/?connection_token=abc123'; mockFetch.mockResolvedValue({ ok: true, @@ -115,7 +115,7 @@ describe('ATXPPaymentDestination', () => { await expect(atxpDestination.destination( { amount: new BigNumber('100'), currency: 'USDC' }, '0xbuyer' - )).rejects.toThrow('ATXPPaymentDestination: /destination did not return chainType'); + )).rejects.toThrow('ATXPPaymentDestination: /destination did not return network'); }); it('should handle decimal amounts correctly', async () => { @@ -124,7 +124,7 @@ describe('ATXPPaymentDestination', () => { ok: true, json: async () => ({ destination: '0x1234567890123456789012345678901234567890', - chainType: 'base' + network: 'base' }) }); @@ -136,18 +136,18 @@ describe('ATXPPaymentDestination', () => { ); expect(mockFetch).toHaveBeenCalledWith( - 'https://accounts.example.com/destination?connectionToken=abc123&buyerAddress=0xbuyer&amount=0.01', + 'https://accounts.example.com/destination?connectionToken=abc123&buyerAddress=0xbuyer&amount=0.01¤cy=USDC', expect.any(Object) ); }); - it('should map ethereum chainType to base network', async () => { + it('should map ethereum network to base network', async () => { const connectionString = 'https://accounts.example.com/?connection_token=abc123'; mockFetch.mockResolvedValue({ ok: true, json: async () => ({ destination: '0x1234567890123456789012345678901234567890', - chainType: 'ethereum' // This should be mapped to 'base' + network: 'ethereum' // This should be mapped to 'base' }) }); @@ -164,13 +164,13 @@ describe('ATXPPaymentDestination', () => { }); }); - it('should handle solana chainType correctly', async () => { + it('should handle solana network correctly', async () => { const connectionString = 'https://accounts.example.com/?connection_token=abc123'; mockFetch.mockResolvedValue({ ok: true, json: async () => ({ destination: 'SolanaAddress123456789', - chainType: 'solana' + network: 'solana' }) }); @@ -209,7 +209,7 @@ describe('ATXPPaymentDestination', () => { ok: true, json: async () => ({ destination: '0x1234567890123456789012345678901234567890', - chainType: 'base' + network: 'base' }) }); @@ -224,8 +224,8 @@ describe('ATXPPaymentDestination', () => { ); expect(mockLogger.debug).toHaveBeenCalledWith('Getting payment destination for buyer: 0xbuyer, amount: 100 USDC'); - expect(mockLogger.debug).toHaveBeenCalledWith('Making request to: https://accounts.example.com/destination?connectionToken=abc123&buyerAddress=0xbuyer&amount=100'); - expect(mockLogger.debug).toHaveBeenCalledWith('Successfully got payment destination: 0x1234567890123456789012345678901234567890 on base (chainType: base)'); + expect(mockLogger.debug).toHaveBeenCalledWith('Making request to: https://accounts.example.com/destination?connectionToken=abc123&buyerAddress=0xbuyer&amount=100¤cy=USDC'); + expect(mockLogger.debug).toHaveBeenCalledWith('Successfully got payment destination: 0x1234567890123456789012345678901234567890 on base'); }); it('should log errors when API request fails', async () => { @@ -269,7 +269,7 @@ describe('ATXPPaymentDestination', () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ - chainType: 'base' + network: 'base' }) }); @@ -286,7 +286,7 @@ describe('ATXPPaymentDestination', () => { expect(mockLogger.error).toHaveBeenCalledWith('/destination did not return destination'); }); - it('should log errors when response is missing chainType', async () => { + it('should log errors when response is missing network', async () => { const connectionString = 'https://accounts.example.com/?connection_token=abc123'; const mockLogger: Logger = { debug: vi.fn(), @@ -313,8 +313,8 @@ describe('ATXPPaymentDestination', () => { )).rejects.toThrow(); expect(mockLogger.debug).toHaveBeenCalledWith('Getting payment destination for buyer: 0xbuyer, amount: 100 USDC'); - expect(mockLogger.debug).toHaveBeenCalledWith('Making request to: https://accounts.example.com/destination?connectionToken=abc123&buyerAddress=0xbuyer&amount=100'); - expect(mockLogger.error).toHaveBeenCalledWith('/destination did not return chainType'); + expect(mockLogger.debug).toHaveBeenCalledWith('Making request to: https://accounts.example.com/destination?connectionToken=abc123&buyerAddress=0xbuyer&amount=100¤cy=USDC'); + expect(mockLogger.error).toHaveBeenCalledWith('/destination did not return network'); }); }); }); \ No newline at end of file diff --git a/packages/atxp-server/src/paymentDestination.ts b/packages/atxp-server/src/paymentDestination.ts index 410f493d..4adb49c3 100644 --- a/packages/atxp-server/src/paymentDestination.ts +++ b/packages/atxp-server/src/paymentDestination.ts @@ -13,6 +13,8 @@ export type PaymentAddress = { export interface PaymentDestination { destination(fundingAmount: FundingAmount, buyerAddress: string): Promise; + // New method for getting multiple destinations + destinations?(fundingAmount: FundingAmount, buyerAddress: string): Promise; } export class ChainPaymentDestination implements PaymentDestination { @@ -27,6 +29,12 @@ export class ChainPaymentDestination implements PaymentDestination { network: this.network }; } + + async destinations(fundingAmount: FundingAmount, buyerAddress: string): Promise { + // Return single destination as array for backwards compatibility + const dest = await this.destination(fundingAmount, buyerAddress); + return [dest]; + } } function parseConnectionString(connectionString: string): { origin: UrlString; token: string } { @@ -55,11 +63,15 @@ export class ATXPPaymentDestination implements PaymentDestination { async destination(fundingAmount: FundingAmount, buyerAddress: string): Promise { this.logger.debug(`Getting payment destination for buyer: ${buyerAddress}, amount: ${fundingAmount.amount.toString()} ${fundingAmount.currency}`); - + const url = new URL(`${this.accountServerURL}/destination`); url.searchParams.set('connectionToken', this.token); url.searchParams.set('buyerAddress', buyerAddress); url.searchParams.set('amount', fundingAmount.amount.toString()); + // Add currency parameter if provided + if (fundingAmount.currency) { + url.searchParams.set('currency', fundingAmount.currency); + } this.logger.debug(`Making request to: ${url.toString()}`); @@ -76,20 +88,22 @@ export class ATXPPaymentDestination implements PaymentDestination { throw new Error(`ATXPPaymentDestination: /destination failed: ${response.status} ${response.statusText} ${text}`); } - const json = await response.json() as { destination?: string; chainType?: string }; + const json = await response.json() as { destination?: string; network?: string; currency?: string }; if (!json?.destination) { this.logger.error('/destination did not return destination'); throw new Error('ATXPPaymentDestination: /destination did not return destination'); } - if (!json?.chainType) { - this.logger.error('/destination did not return chainType'); - throw new Error('ATXPPaymentDestination: /destination did not return chainType'); + + const networkFromResponse = json.network; + if (!networkFromResponse) { + this.logger.error('/destination did not return network'); + throw new Error('ATXPPaymentDestination: /destination did not return network'); } - // Map chainType to expected network values - // The accounts service returns 'ethereum' for Base wallets, but the payment system expects 'base' + // Map network values if needed + // The accounts service might return 'ethereum' for Base wallets, but the payment system expects 'base' let network: Network; - switch (json.chainType) { + switch (networkFromResponse) { case 'ethereum': network = 'base'; // Base is an Ethereum L2 break; @@ -100,14 +114,88 @@ export class ATXPPaymentDestination implements PaymentDestination { network = 'solana'; break; default: - this.logger.warn(`Unknown chainType: ${json.chainType}, defaulting to base`); + this.logger.warn(`Unknown network: ${networkFromResponse}, defaulting to base`); network = 'base'; } - this.logger.debug(`Successfully got payment destination: ${json.destination} on ${network} (chainType: ${json.chainType})`); + this.logger.debug(`Successfully got payment destination: ${json.destination} on ${network}`); return { destination: json.destination, network }; } + + async destinations(fundingAmount: FundingAmount, buyerAddress: string): Promise { + this.logger.debug(`Getting payment destinations for buyer: ${buyerAddress}, amount: ${fundingAmount.amount.toString()} ${fundingAmount.currency}`); + + const url = new URL(`${this.accountServerURL}/addresses`); + + // Add currency parameter if provided + if (fundingAmount.currency) { + url.searchParams.set('currency', fundingAmount.currency); + } + + // Use Basic auth with the token, like ATXPLocalAccount does + const authHeader = `Basic ${Buffer.from(`${this.token}:`).toString('base64')}`; + + this.logger.debug(`Making request to: ${url.toString()}`); + + const response = await this.fetchFn(url.toString(), { + method: 'GET', + headers: { + 'Authorization': authHeader, + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + const text = await response.text(); + this.logger.error(`/addresses failed: ${response.status} ${response.statusText} ${text}`); + throw new Error(`ATXPPaymentDestination: /addresses failed: ${response.status} ${response.statusText} ${text}`); + } + + const json = await response.json() as Array<{ address?: string; network?: string; currency?: string }>; + if (!Array.isArray(json) || json.length === 0) { + this.logger.error('/addresses did not return any addresses'); + throw new Error('ATXPPaymentDestination: /addresses did not return any addresses'); + } + + const addresses: PaymentAddress[] = []; + for (const item of json) { + const networkFromItem = item?.network; + if (!item?.address || !networkFromItem) { + this.logger.warn('Skipping invalid address entry'); + continue; + } + + // Map network values if needed + let network: Network; + switch (networkFromItem) { + case 'ethereum': + network = 'base'; // Base is an Ethereum L2 + break; + case 'base': + network = 'base'; // Already correct + break; + case 'solana': + network = 'solana'; + break; + default: + this.logger.warn(`Unknown network: ${networkFromItem}, skipping`); + continue; + } + + addresses.push({ + destination: item.address, + network + }); + } + + if (addresses.length === 0) { + throw new Error('ATXPPaymentDestination: no valid addresses returned'); + } + + this.logger.debug(`Successfully got ${addresses.length} payment destinations`); + return addresses; + } } \ No newline at end of file diff --git a/packages/atxp-server/src/paymentServer.ts b/packages/atxp-server/src/paymentServer.ts index 4a2875cc..105e751c 100644 --- a/packages/atxp-server/src/paymentServer.ts +++ b/packages/atxp-server/src/paymentServer.ts @@ -1,6 +1,5 @@ import { PaymentServer, ChargeResponse, Charge } from "./types.js"; -import { Network, Currency, AuthorizationServerUrl, FetchLike, Logger, PaymentRequestData } from "@atxp/common"; -import BigNumber from "bignumber.js"; +import { AuthorizationServerUrl, FetchLike, Logger, PaymentRequestData } from "@atxp/common"; /** * ATXP Payment Server implementation @@ -22,10 +21,8 @@ export class ATXPPaymentServer implements PaymentServer { private readonly fetchFn: FetchLike = fetch.bind(globalThis)) { } - charge = async({source, destination, network, currency, amount}: - {source: string, destination: string, network: Network, currency: Currency, amount: BigNumber}): Promise => { - const body = {source, destination, network, currency, amount}; - const chargeResponse = await this.makeRequest('POST', '/charge', body); + charge = async(chargeRequest: Charge): Promise => { + const chargeResponse = await this.makeRequest('POST', '/charge', chargeRequest); const json = await chargeResponse.json() as PaymentRequestData | null; if (chargeResponse.status === 200) { return {success: true, requiredPayment: null}; diff --git a/packages/atxp-server/src/requirePayment.test.ts b/packages/atxp-server/src/requirePayment.test.ts index 641fbcf9..3646b374 100644 --- a/packages/atxp-server/src/requirePayment.test.ts +++ b/packages/atxp-server/src/requirePayment.test.ts @@ -20,10 +20,12 @@ describe('requirePayment', () => { await withATXPContext(config, new URL('https://example.com'), TH.tokenCheck(), async () => { await expect(requirePayment({price: BigNumber(0.01)})).resolves.not.toThrow(); expect(paymentServer.charge).toHaveBeenCalledWith({ - amount: BigNumber(0.01), - currency: config.currency, - network: 'base', - destination: TH.DESTINATION, + destinations: [{ + network: 'base', + currency: config.currency, + address: TH.DESTINATION, + amount: BigNumber(0.01) + }], source: 'test-user', payeeName: config.payeeName, }); @@ -51,10 +53,12 @@ describe('requirePayment', () => { } catch (err: any) { expect(err.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); expect(paymentServer.createPaymentRequest).toHaveBeenCalledWith({ - amount: BigNumber(0.01), - currency: config.currency, - network: 'base', - destination: TH.DESTINATION, + destinations: [{ + network: 'base', + currency: config.currency, + address: TH.DESTINATION, + amount: BigNumber(0.01) + }], source: 'test-user', payeeName: config.payeeName, }); diff --git a/packages/atxp-server/src/requirePayment.ts b/packages/atxp-server/src/requirePayment.ts index bb59b40a..b55aca82 100644 --- a/packages/atxp-server/src/requirePayment.ts +++ b/packages/atxp-server/src/requirePayment.ts @@ -1,5 +1,62 @@ -import { RequirePaymentConfig, paymentRequiredError } from "@atxp/common"; +import { RequirePaymentConfig, paymentRequiredError, Currency } from "@atxp/common"; import { getATXPConfig, atxpAccountId } from "./atxpContext.js"; +import { PaymentAddress, FundingAmount } from "./paymentDestination.js"; +import { ATXPConfig } from "./types.js"; +import BigNumber from "bignumber.js"; + +// Cache for payment destinations to avoid repeated HTTP calls +// Key is "userId:amount:currency", value is cached destinations +const destinationCache = new Map(); + +// Cache duration: 5 minutes +const CACHE_DURATION_MS = 5 * 60 * 1000; + +/** + * Get payment destinations for a user, with caching to avoid repeated HTTP calls. + * This is an internal helper function used by requirePayment. + */ +async function getCachedPaymentDestinations( + config: ATXPConfig, + user: string, + amount: BigNumber, + currency: Currency +): Promise { + const cacheKey = `${user}:${amount.toString()}:${currency}`; + const cached = destinationCache.get(cacheKey); + const now = Date.now(); + + // Return cached destinations if still valid + if (cached && (now - cached.timestamp) < CACHE_DURATION_MS) { + config.logger.debug(`Using cached payment destinations for user ${user}`); + return cached.destinations; + } + + // Fetch fresh destinations + config.logger.debug(`Fetching payment destinations for user ${user}`); + + const fundingAmount: FundingAmount = { amount, currency }; + let paymentAddresses: PaymentAddress[]; + + if ('destinations' in config.paymentDestination && typeof config.paymentDestination.destinations === 'function') { + paymentAddresses = await config.paymentDestination.destinations(fundingAmount, user); + } else { + // Fallback to single destination wrapped in array + const singleAddress = await config.paymentDestination.destination(fundingAmount, user); + paymentAddresses = [singleAddress]; + } + + // Cache the result + destinationCache.set(cacheKey, { + destinations: paymentAddresses, + timestamp: now + }); + + config.logger.debug(`Cached ${paymentAddresses.length} payment destinations for user ${user}`); + return paymentAddresses; +} export async function requirePayment(paymentConfig: RequirePaymentConfig): Promise { const config = getATXPConfig(); @@ -12,36 +69,41 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi throw new Error('No user found'); } - const fundingAmount = { - amount: paymentConfig.price, - currency: config.currency - }; - - const paymentAddress = await config.paymentDestination.destination(fundingAmount, user); + // Get payment destinations (with caching) + const paymentAddresses = await getCachedPaymentDestinations( + config, + user, + paymentConfig.price, + config.currency + ); + // Always use multi-destination format const charge = { - amount: paymentConfig.price, - currency: config.currency, - network: paymentAddress.network, - destination: paymentAddress.destination, + destinations: paymentAddresses.map(addr => ({ + network: addr.network, + currency: config.currency, + address: addr.destination, + amount: paymentConfig.price // Each destination gets the full amount + })), source: user, payeeName: config.payeeName, }; - config.logger.debug(`Charging amount ${charge.amount}, destination ${charge.destination}, source ${charge.source}`); + config.logger.debug(`Charging ${paymentConfig.price} to ${charge.destinations.length} destinations for source ${user}`); + const chargeResponse = await config.paymentServer.charge(charge); if (chargeResponse.success) { - config.logger.info(`Charged ${charge.amount} for source ${charge.source}`); + config.logger.info(`Charged ${paymentConfig.price} for source ${user}`); return; } const existingPaymentId = await paymentConfig.getExistingPaymentId?.(); if (existingPaymentId) { config.logger.info(`Found existing payment ID ${existingPaymentId}`); - throw paymentRequiredError(config.server, existingPaymentId, charge.amount) + throw paymentRequiredError(config.server, existingPaymentId, paymentConfig.price) } const paymentId = await config.paymentServer.createPaymentRequest(charge); config.logger.info(`Created payment request ${paymentId}`); - throw paymentRequiredError(config.server, paymentId, charge.amount); + throw paymentRequiredError(config.server, paymentId, paymentConfig.price); } diff --git a/packages/atxp-server/src/types.ts b/packages/atxp-server/src/types.ts index bcce963a..ea5cc5c2 100644 --- a/packages/atxp-server/src/types.ts +++ b/packages/atxp-server/src/types.ts @@ -18,7 +18,7 @@ export type McpOperationPattern = McpOperation | '*' | `${McpMethod}:*`; export type RefundErrors = boolean | 'nonMcpOnly'; // When the server is talking to the ATXP Authorization Server, it doesn't need to provide -// the resource or resourceName - those are already known by the AS, and +// the resource or resourceName - those are already known by the AS, and // we shouldn't trust the RS to self-report them export type Charge = Omit;