Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion packages/atxp-client/src/atxpFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,84 @@ export class ATXPFetcher {
}
};

protected handleMultiDestinationPayment = async (
paymentRequestData: PaymentRequestData,
paymentRequestUrl: string,
paymentRequestId: string
): Promise<boolean> => {
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<boolean> => {
if (paymentRequestError.code !== PAYMENT_REQUIRED_ERROR_CODE) {
throw new Error(`ATXP: expected payment required error (code ${PAYMENT_REQUIRED_ERROR_CODE}); got code ${paymentRequestError.code}`);
Expand All @@ -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`);
Expand Down Expand Up @@ -228,7 +312,8 @@ export class ATXPFetcher {
},
body: JSON.stringify({
transactionId: paymentId,
network: requestedNetwork
network: requestedNetwork,
currency: currency
})
});

Expand Down
20 changes: 13 additions & 7 deletions packages/atxp-client/src/atxpLocalAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ export class ATXPLocalAccount implements LocalAccount {
fetchFn: FetchLike = fetch as FetchLike
): Promise<ATXPLocalAccount> {
// 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)
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/atxp-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export {
type AuthorizationServerUrl,
type Currency,
type Network,
type PaymentRequestDestination,
type PaymentRequestData,
type CustomJWTPayload,
type ClientCredentials,
Expand Down
19 changes: 15 additions & 4 deletions packages/atxp-common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 18 additions & 18 deletions packages/atxp-server/src/paymentDestination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('ATXPPaymentDestination', () => {
ok: true,
json: async () => ({
destination: '0x1234567890123456789012345678901234567890',
chainType: 'base'
network: 'base'
})
});

Expand All @@ -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&currency=USDC',
{
method: 'GET',
headers: {
Expand Down Expand Up @@ -89,7 +89,7 @@ describe('ATXPPaymentDestination', () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
chainType: 'base'
network: 'base'
})
});

Expand All @@ -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,
Expand All @@ -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 () => {
Expand All @@ -124,7 +124,7 @@ describe('ATXPPaymentDestination', () => {
ok: true,
json: async () => ({
destination: '0x1234567890123456789012345678901234567890',
chainType: 'base'
network: 'base'
})
});

Expand All @@ -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&currency=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'
})
});

Expand All @@ -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'
})
});

Expand Down Expand Up @@ -209,7 +209,7 @@ describe('ATXPPaymentDestination', () => {
ok: true,
json: async () => ({
destination: '0x1234567890123456789012345678901234567890',
chainType: 'base'
network: 'base'
})
});

Expand All @@ -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&currency=USDC');
expect(mockLogger.debug).toHaveBeenCalledWith('Successfully got payment destination: 0x1234567890123456789012345678901234567890 on base');
});

it('should log errors when API request fails', async () => {
Expand Down Expand Up @@ -269,7 +269,7 @@ describe('ATXPPaymentDestination', () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
chainType: 'base'
network: 'base'
})
});

Expand All @@ -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(),
Expand All @@ -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&currency=USDC');
expect(mockLogger.error).toHaveBeenCalledWith('/destination did not return network');
});
});
});
Loading