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
84 changes: 84 additions & 0 deletions packages/atxp-client/src/atxpFetcher.payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,4 +314,88 @@ describe('atxpFetcher.fetch payment', () => {
}
expect(threw).toBe(true);
});

it('should use iss field as memo when present in payment request', async () => {
const f = fetchMock.createInstance();
const errTxt = CTH.paymentRequiredMessage(DEFAULT_AUTHORIZATION_SERVER, 'foo');
const errMsg = CTH.mcpToolErrorResponse({content: [{type: 'text', text: errTxt}]});

mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER)
.postOnce('https://example.com/mcp', errMsg)
.postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]});

// Mock payment request with both iss and payeeName
mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER, {});
f.getOnce(`${DEFAULT_AUTHORIZATION_SERVER}/payment-request/foo`, {
options: [{
amount: '0.01',
currency: 'USDC',
network: 'solana',
address: 'testDestination'
}],
sourceAccountId: 'solana:testSource',
destinationAccountId: 'solana:testDestination',
resource: new URL('https://example.com/resource'),
payeeName: 'Image',
iss: 'auth.atxp.ai'
});
f.putOnce(`${DEFAULT_AUTHORIZATION_SERVER}/payment-request/foo`, 200);

const paymentMaker = {
makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana', currency: 'USDC' }),
generateJWT: vi.fn().mockResolvedValue('testJWT'),
getSourceAddress: vi.fn().mockReturnValue('SolAddress123')
};
const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker]);
await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });

// Verify makePayment was called with iss as memo, not payeeName
expect(paymentMaker.makePayment).toHaveBeenCalledWith(
expect.anything(), // destinations
'auth.atxp.ai', // memo should be iss value
'foo' // paymentRequestId
);
});

it('should fall back to payeeName as memo when iss is not present in payment request', async () => {
const f = fetchMock.createInstance();
const errTxt = CTH.paymentRequiredMessage(DEFAULT_AUTHORIZATION_SERVER, 'bar');
const errMsg = CTH.mcpToolErrorResponse({content: [{type: 'text', text: errTxt}]});

mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER)
.postOnce('https://example.com/mcp', errMsg)
.postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]});

// Mock payment request with only payeeName (no iss field)
mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER, {});
f.getOnce(`${DEFAULT_AUTHORIZATION_SERVER}/payment-request/bar`, {
options: [{
amount: '0.01',
currency: 'USDC',
network: 'solana',
address: 'testDestination'
}],
sourceAccountId: 'solana:testSource',
destinationAccountId: 'solana:testDestination',
resource: new URL('https://example.com/resource'),
payeeName: 'LegacyService'
// Note: no iss field
});
f.putOnce(`${DEFAULT_AUTHORIZATION_SERVER}/payment-request/bar`, 200);

const paymentMaker = {
makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana', currency: 'USDC' }),
generateJWT: vi.fn().mockResolvedValue('testJWT'),
getSourceAddress: vi.fn().mockReturnValue('SolAddress123')
};
const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker]);
await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });

// Verify makePayment was called with payeeName as memo (backward compatibility)
expect(paymentMaker.makePayment).toHaveBeenCalledWith(
expect.anything(), // destinations
'LegacyService', // memo should be payeeName when iss not present
'bar' // paymentRequestId
);
});
});
5 changes: 3 additions & 2 deletions packages/atxp-client/src/atxpFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ export class ATXPFetcher {
resourceName: paymentRequest.payeeName ?? '',
currency: firstDest.currency,
amount: firstDest.amount,
iss: paymentRequest.payeeName ?? '',
iss: paymentRequest.iss ?? paymentRequest.payeeName ?? '',
};

// Ask for approval once for all payment attempts
Expand All @@ -246,7 +246,8 @@ export class ATXPFetcher {
for (const paymentMaker of this.account.paymentMakers) {
try {
// Pass all destinations to payment maker - it will filter and pick the one it can handle
const result = await paymentMaker.makePayment(mappedDestinations, paymentRequest.payeeName ?? '', paymentRequestId);
const memo = paymentRequest.iss ?? paymentRequest.payeeName ?? '';
const result = await paymentMaker.makePayment(mappedDestinations, memo, paymentRequestId);

if (result === null) {
this.logger.debug(`ATXP: payment maker cannot handle these destinations, trying next`);
Expand Down
1 change: 1 addition & 0 deletions packages/atxp-common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export type PaymentRequest = {
destinationAccountId: AccountId;
resource: URL;
payeeName: string | null;
iss?: string;
}

export type CustomJWTPayload = {
Expand Down