diff --git a/packages/atxp-client/src/atxpFetcher.payment.test.ts b/packages/atxp-client/src/atxpFetcher.payment.test.ts index 6b7f29d..789b154 100644 --- a/packages/atxp-client/src/atxpFetcher.payment.test.ts +++ b/packages/atxp-client/src/atxpFetcher.payment.test.ts @@ -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 + ); + }); }); diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index b7138a5..ec6320d 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -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 @@ -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`); diff --git a/packages/atxp-common/src/types.ts b/packages/atxp-common/src/types.ts index 6e796c4..8232576 100644 --- a/packages/atxp-common/src/types.ts +++ b/packages/atxp-common/src/types.ts @@ -85,6 +85,7 @@ export type PaymentRequest = { destinationAccountId: AccountId; resource: URL; payeeName: string | null; + iss?: string; } export type CustomJWTPayload = {