From 1eb51bc22bcf626919739f3b05b24f1a238f4c76 Mon Sep 17 00:00:00 2001 From: bdj Date: Sat, 20 Sep 2025 15:46:42 -0700 Subject: [PATCH 1/8] First pass of sdk plumbing --- packages/atxp-client/src/atxpFetcher.ts | 84 ++++++++++++++++++- packages/atxp-common/src/index.ts | 1 + packages/atxp-common/src/types.ts | 19 ++++- .../atxp-server/src/paymentDestination.ts | 77 ++++++++++++++++- packages/atxp-server/src/paymentServer.ts | 6 +- packages/atxp-server/src/requirePayment.ts | 27 ++++-- packages/atxp-server/src/types.ts | 2 +- 7 files changed, 198 insertions(+), 18 deletions(-) diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index 6c99b3b0..0b9f9c9f 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -123,6 +123,81 @@ 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; + } + + const prospectivePayment : ProspectivePayment = { + accountId: this.accountId, + resourceUrl: paymentRequestData.resource?.toString() ?? '', + resourceName: paymentRequestData.resourceName ?? '', + network: dest.network, + currency: dest.currency, + amount: dest.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(dest.amount, dest.currency, dest.address, paymentRequestData.iss); + this.logger.info(`ATXP: made payment of ${dest.amount} ${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 +220,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 +309,8 @@ export class ATXPFetcher { }, body: JSON.stringify({ transactionId: paymentId, - network: requestedNetwork + network: requestedNetwork, + currency: currency }) }); 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.ts b/packages/atxp-server/src/paymentDestination.ts index 410f493d..a77f6d22 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,7 +63,7 @@ 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); @@ -110,4 +118,71 @@ export class ATXPPaymentDestination implements PaymentDestination { 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`); + url.searchParams.set('connectionToken', this.token); + url.searchParams.set('buyerAddress', buyerAddress); + url.searchParams.set('amount', fundingAmount.amount.toString()); + + this.logger.debug(`Making request to: ${url.toString()}`); + + const response = await this.fetchFn(url.toString(), { + method: 'GET', + headers: { + '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<{ destination?: string; chainType?: string }>; + if (!Array.isArray(json) || json.length === 0) { + this.logger.error('/addresses did not return any destinations'); + throw new Error('ATXPPaymentDestination: /addresses did not return any destinations'); + } + + const addresses: PaymentAddress[] = []; + for (const item of json) { + if (!item?.destination || !item?.chainType) { + this.logger.warn('Skipping invalid address entry'); + continue; + } + + // Map chainType to expected network values + let network: Network; + switch (item.chainType) { + 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 chainType: ${item.chainType}, skipping`); + continue; + } + + addresses.push({ + destination: item.destination, + 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..4aa30451 100644 --- a/packages/atxp-server/src/paymentServer.ts +++ b/packages/atxp-server/src/paymentServer.ts @@ -22,10 +22,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.ts b/packages/atxp-server/src/requirePayment.ts index bb59b40a..7d1be252 100644 --- a/packages/atxp-server/src/requirePayment.ts +++ b/packages/atxp-server/src/requirePayment.ts @@ -1,5 +1,6 @@ import { RequirePaymentConfig, paymentRequiredError } from "@atxp/common"; import { getATXPConfig, atxpAccountId } from "./atxpContext.js"; +import { PaymentAddress } from "./paymentDestination.js"; export async function requirePayment(paymentConfig: RequirePaymentConfig): Promise { const config = getATXPConfig(); @@ -17,21 +18,33 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi currency: config.currency }; - const paymentAddress = await config.paymentDestination.destination(fundingAmount, user); + // Get payment destinations - use destinations() if available, otherwise wrap destination() in array + 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]; + } + // 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; } 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; From 3c7c8118c085a76a7a79ca28fb7646b4205b9b6d Mon Sep 17 00:00:00 2001 From: bdj Date: Sat, 20 Sep 2025 16:00:58 -0700 Subject: [PATCH 2/8] Consistent auth for /addresses, cache destinations() in requirePayment --- .../atxp-server/src/paymentDestination.ts | 7 +- packages/atxp-server/src/requirePayment.ts | 81 +++++++++++++++---- 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/packages/atxp-server/src/paymentDestination.ts b/packages/atxp-server/src/paymentDestination.ts index a77f6d22..a1ef8655 100644 --- a/packages/atxp-server/src/paymentDestination.ts +++ b/packages/atxp-server/src/paymentDestination.ts @@ -123,15 +123,16 @@ export class ATXPPaymentDestination implements PaymentDestination { this.logger.debug(`Getting payment destinations for buyer: ${buyerAddress}, amount: ${fundingAmount.amount.toString()} ${fundingAmount.currency}`); const url = new URL(`${this.accountServerURL}/addresses`); - url.searchParams.set('connectionToken', this.token); - url.searchParams.set('buyerAddress', buyerAddress); - url.searchParams.set('amount', fundingAmount.amount.toString()); + + // 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', }, }); diff --git a/packages/atxp-server/src/requirePayment.ts b/packages/atxp-server/src/requirePayment.ts index 7d1be252..eea24b36 100644 --- a/packages/atxp-server/src/requirePayment.ts +++ b/packages/atxp-server/src/requirePayment.ts @@ -1,25 +1,45 @@ -import { RequirePaymentConfig, paymentRequiredError } from "@atxp/common"; +import { RequirePaymentConfig, paymentRequiredError, Currency } from "@atxp/common"; import { getATXPConfig, atxpAccountId } from "./atxpContext.js"; -import { PaymentAddress } from "./paymentDestination.js"; +import { PaymentAddress, FundingAmount } from "./paymentDestination.js"; +import { ATXPConfig } from "./types.js"; +import BigNumber from "bignumber.js"; -export async function requirePayment(paymentConfig: RequirePaymentConfig): Promise { - const config = getATXPConfig(); - if (!config) { - throw new Error('No config found'); - } - const user = atxpAccountId(); - if (!user) { - config.logger.error('No user found'); - throw new Error('No user found'); +// 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; } - const fundingAmount = { - amount: paymentConfig.price, - currency: config.currency - }; + // Fetch fresh destinations + config.logger.debug(`Fetching payment destinations for user ${user}`); - // Get payment destinations - use destinations() if available, otherwise wrap destination() in array + 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 { @@ -28,6 +48,35 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi 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(); + if (!config) { + throw new Error('No config found'); + } + const user = atxpAccountId(); + if (!user) { + config.logger.error('No user found'); + throw new Error('No user found'); + } + + // Get payment destinations (with caching) + const paymentAddresses = await getCachedPaymentDestinations( + config, + user, + paymentConfig.price, + config.currency + ); + // Always use multi-destination format const charge = { destinations: paymentAddresses.map(addr => ({ From 79c39a35ba7f331bf17b55b2adf4123a09f262c1 Mon Sep 17 00:00:00 2001 From: bdj Date: Sat, 20 Sep 2025 16:53:55 -0700 Subject: [PATCH 3/8] typecheck/lint --- packages/atxp-server/src/paymentServer.ts | 3 +-- packages/atxp-server/src/requirePayment.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/atxp-server/src/paymentServer.ts b/packages/atxp-server/src/paymentServer.ts index 4aa30451..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 diff --git a/packages/atxp-server/src/requirePayment.ts b/packages/atxp-server/src/requirePayment.ts index eea24b36..b55aca82 100644 --- a/packages/atxp-server/src/requirePayment.ts +++ b/packages/atxp-server/src/requirePayment.ts @@ -100,10 +100,10 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi 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); } From c26e31cebbbc4692a880be62523f8cfd978192a1 Mon Sep 17 00:00:00 2001 From: bdj Date: Sat, 20 Sep 2025 16:57:17 -0700 Subject: [PATCH 4/8] Test --- .../atxp-server/src/requirePayment.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) 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, }); From 249a53222c8d0f074baf249865ab91ff9bb22f01 Mon Sep 17 00:00:00 2001 From: bdj Date: Sat, 20 Sep 2025 17:12:29 -0700 Subject: [PATCH 5/8] destination->address in /addresses --- packages/atxp-server/src/paymentDestination.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/atxp-server/src/paymentDestination.ts b/packages/atxp-server/src/paymentDestination.ts index a1ef8655..92011075 100644 --- a/packages/atxp-server/src/paymentDestination.ts +++ b/packages/atxp-server/src/paymentDestination.ts @@ -143,15 +143,15 @@ export class ATXPPaymentDestination implements PaymentDestination { throw new Error(`ATXPPaymentDestination: /addresses failed: ${response.status} ${response.statusText} ${text}`); } - const json = await response.json() as Array<{ destination?: string; chainType?: string }>; + const json = await response.json() as Array<{ address?: string; chainType?: string }>; if (!Array.isArray(json) || json.length === 0) { - this.logger.error('/addresses did not return any destinations'); - throw new Error('ATXPPaymentDestination: /addresses did not return any destinations'); + 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) { - if (!item?.destination || !item?.chainType) { + if (!item?.address || !item?.chainType) { this.logger.warn('Skipping invalid address entry'); continue; } @@ -174,7 +174,7 @@ export class ATXPPaymentDestination implements PaymentDestination { } addresses.push({ - destination: item.destination, + destination: item.address, network }); } From bd5b5484e8bc6d65bfe9a3ed782b2a00f318134a Mon Sep 17 00:00:00 2001 From: bdj Date: Sat, 20 Sep 2025 19:32:31 -0700 Subject: [PATCH 6/8] Properly parse BigNumber amounts --- packages/atxp-client/src/atxpFetcher.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index 0b9f9c9f..a8aa2a8b 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -140,13 +140,16 @@ export class ATXPFetcher { 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: dest.amount, + amount: amount, iss: paymentRequestData.iss ?? '', }; @@ -157,8 +160,8 @@ export class ATXPFetcher { let paymentId: string; try { - paymentId = await paymentMaker.makePayment(dest.amount, dest.currency, dest.address, paymentRequestData.iss); - this.logger.info(`ATXP: made payment of ${dest.amount} ${dest.currency} on ${dest.network}: ${paymentId}`); + 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 From b95fcb0ff16121a714ab1306aaaa403639c54062 Mon Sep 17 00:00:00 2001 From: bdj Date: Sat, 20 Sep 2025 21:13:37 -0700 Subject: [PATCH 7/8] chainType->network for accounts; pass currency --- packages/atxp-client/src/atxpLocalAccount.ts | 20 ++++++---- .../atxp-server/src/paymentDestination.ts | 40 ++++++++++++------- 2 files changed, 39 insertions(+), 21 deletions(-) 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-server/src/paymentDestination.ts b/packages/atxp-server/src/paymentDestination.ts index 92011075..4adb49c3 100644 --- a/packages/atxp-server/src/paymentDestination.ts +++ b/packages/atxp-server/src/paymentDestination.ts @@ -68,6 +68,10 @@ export class ATXPPaymentDestination implements PaymentDestination { 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()}`); @@ -84,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; @@ -108,11 +114,11 @@ 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 @@ -124,6 +130,11 @@ export class ATXPPaymentDestination implements PaymentDestination { 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')}`; @@ -143,7 +154,7 @@ export class ATXPPaymentDestination implements PaymentDestination { throw new Error(`ATXPPaymentDestination: /addresses failed: ${response.status} ${response.statusText} ${text}`); } - const json = await response.json() as Array<{ address?: string; chainType?: string }>; + 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'); @@ -151,14 +162,15 @@ export class ATXPPaymentDestination implements PaymentDestination { const addresses: PaymentAddress[] = []; for (const item of json) { - if (!item?.address || !item?.chainType) { + const networkFromItem = item?.network; + if (!item?.address || !networkFromItem) { this.logger.warn('Skipping invalid address entry'); continue; } - // Map chainType to expected network values + // Map network values if needed let network: Network; - switch (item.chainType) { + switch (networkFromItem) { case 'ethereum': network = 'base'; // Base is an Ethereum L2 break; @@ -169,7 +181,7 @@ export class ATXPPaymentDestination implements PaymentDestination { network = 'solana'; break; default: - this.logger.warn(`Unknown chainType: ${item.chainType}, skipping`); + this.logger.warn(`Unknown network: ${networkFromItem}, skipping`); continue; } From 36b537c3e8ba3e33d3077a4cddb6769e3500f3f8 Mon Sep 17 00:00:00 2001 From: bdj Date: Sat, 20 Sep 2025 21:27:08 -0700 Subject: [PATCH 8/8] tests --- .../src/paymentDestination.test.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) 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