diff --git a/packages/atxp-client/src/mppProtocolHandler.ts b/packages/atxp-client/src/mppProtocolHandler.ts index 8b861b8..d18c006 100644 --- a/packages/atxp-client/src/mppProtocolHandler.ts +++ b/packages/atxp-client/src/mppProtocolHandler.ts @@ -189,6 +189,9 @@ export class MPPProtocolHandler implements ProtocolHandler { } const retryHeaders = buildPaymentHeaders(authorizeResult, originalRequest.init?.headers); + if (primaryChallenge.id) { + retryHeaders.set('X-ATXP-Payment-Request-Id', primaryChallenge.id); + } const retryInit: RequestInit = { ...originalRequest.init, headers: retryHeaders }; logger.info('MPP: retrying request with Authorization: Payment header'); diff --git a/packages/atxp-client/src/protocolHandler.test.ts b/packages/atxp-client/src/protocolHandler.test.ts index 5f2ca9a..e445d1f 100644 --- a/packages/atxp-client/src/protocolHandler.test.ts +++ b/packages/atxp-client/src/protocolHandler.test.ts @@ -98,7 +98,10 @@ describe('X402ProtocolHandler', () => { describe('canHandle', () => { it('should detect X402 challenge in 402 response', async () => { - const response = new Response(JSON.stringify(createX402Challenge()), { status: 402 }); + const response = new Response(JSON.stringify({ + ...createX402Challenge(), + paymentRequestId: 'pr_x402_retry', + }), { status: 402 }); expect(await handler.canHandle(response)).toBe(true); }); @@ -140,7 +143,10 @@ describe('X402ProtocolHandler', () => { onPaymentFailure: async () => {}, }; - const response = new Response(JSON.stringify(createX402Challenge()), { status: 402 }); + const response = new Response(JSON.stringify({ + ...createX402Challenge(), + paymentRequestId: 'pr_x402_retry', + }), { status: 402 }); const result = await handler.handlePaymentChallenge( response, { url: 'https://example.com/api' }, @@ -159,6 +165,7 @@ describe('X402ProtocolHandler', () => { const retryCall = mockFetch.mock.calls[0]; const retryHeaders = retryCall[1].headers as Headers; expect(retryHeaders.get('X-PAYMENT')).toBe('test-payment-header'); + expect(retryHeaders.get('X-ATXP-Payment-Request-Id')).toBe('pr_x402_retry'); // Verify onPayment was called expect(mockOnPayment).toHaveBeenCalled(); @@ -559,6 +566,7 @@ describe('MPPProtocolHandler', () => { const retryCall = mockFetch.mock.calls[0]; const retryHeaders = retryCall[1].headers as Headers; expect(retryHeaders.get('Authorization')).toBe('Payment mpp-credential-base64'); + expect(retryHeaders.get('X-ATXP-Payment-Request-Id')).toBe('ch_xxx'); // Verify onPayment was called expect(mockOnPayment).toHaveBeenCalled(); diff --git a/packages/atxp-client/src/x402ProtocolHandler.ts b/packages/atxp-client/src/x402ProtocolHandler.ts index 0a8f647..df24e08 100644 --- a/packages/atxp-client/src/x402ProtocolHandler.ts +++ b/packages/atxp-client/src/x402ProtocolHandler.ts @@ -26,6 +26,7 @@ interface X402Challenge { /** v2 adds resource info and extensions */ resource?: { url: string; description?: string; mimeType?: string }; extensions?: Record; + paymentRequestId?: string; } /** @@ -156,6 +157,9 @@ export class X402ProtocolHandler implements ProtocolHandler { { protocol: 'x402', credential: paymentHeader }, originalRequest.init?.headers ); + if (paymentChallenge.paymentRequestId) { + retryHeaders.set('X-ATXP-Payment-Request-Id', paymentChallenge.paymentRequestId); + } const retryInit: RequestInit = { ...originalRequest.init, headers: retryHeaders }; logger.info('X402: retrying request with X-PAYMENT header'); diff --git a/packages/atxp-express/src/atxpExpress.ts b/packages/atxp-express/src/atxpExpress.ts index 7259b91..3f683eb 100644 --- a/packages/atxp-express/src/atxpExpress.ts +++ b/packages/atxp-express/src/atxpExpress.ts @@ -13,6 +13,7 @@ import { sendOAuthMetadataNode, detectProtocol, getPendingPaymentChallenge, + setPaymentRequestId, type PaymentProtocol, type ATXPConfig, type TokenCheck, @@ -51,6 +52,7 @@ export function atxpExpress(args: ATXPArgs): Router { // with full pricing context (amount, options, destination). const detected = detectProtocol({ 'x-atxp-payment': req.headers['x-atxp-payment'] as string | undefined, + 'x-atxp-payment-request-id': req.headers['x-atxp-payment-request-id'] as string | undefined, 'payment-signature': req.headers['payment-signature'] as string | undefined, 'x-payment': req.headers['x-payment'] as string | undefined, 'authorization': req.headers['authorization'] as string | undefined, @@ -134,9 +136,13 @@ export function atxpExpress(args: ATXPArgs): Router { // as paymentRequirements instead of regenerating from server config. // For MPP/ATXP: credentials are self-contained, no extra context needed. const context: Record = { + ...(detected.paymentRequestId && { paymentRequestId: detected.paymentRequestId }), ...(sourceAccountId && { sourceAccountId }), destinationAccountId, }; + if (detected.paymentRequestId) { + setPaymentRequestId(detected.paymentRequestId); + } if (detected.protocol === 'x402') { const parsed = parseCredentialBase64(detected.credential); diff --git a/packages/atxp-server/src/atxpContext.ts b/packages/atxp-server/src/atxpContext.ts index 5442e61..2b99311 100644 --- a/packages/atxp-server/src/atxpContext.ts +++ b/packages/atxp-server/src/atxpContext.ts @@ -13,6 +13,8 @@ export type DetectedCredential = { credential: string; /** User identity resolved from OAuth token or credential source */ sourceAccountId?: string; + /** Payment request id carried from the 402 challenge retry, when available. */ + paymentRequestId?: string; }; /** @@ -33,6 +35,8 @@ type ATXPContext = { resource: URL; /** Payment credential from retry request (X-PAYMENT, X-ATXP-PAYMENT, etc.) */ detectedCredential?: DetectedCredential; + /** Stable lifecycle id for the current paid retry, shared by settle and charge. */ + paymentRequestId?: string; /** Payment challenge pending response rewrite (set by omniChallengeMcpError) */ pendingPaymentChallenge?: PendingPaymentChallenge; } @@ -75,6 +79,21 @@ export function setDetectedCredential(credential: DetectedCredential): void { const context = contextStorage.getStore(); if (context) { context.detectedCredential = credential; + if (credential.paymentRequestId) { + context.paymentRequestId = credential.paymentRequestId; + } + } +} + +export function getPaymentRequestId(): string | null { + const context = contextStorage.getStore(); + return context?.paymentRequestId ?? null; +} + +export function setPaymentRequestId(paymentRequestId: string): void { + const context = contextStorage.getStore(); + if (context) { + context.paymentRequestId = paymentRequestId; } } diff --git a/packages/atxp-server/src/index.ts b/packages/atxp-server/src/index.ts index 0a7cdd2..d646f81 100644 --- a/packages/atxp-server/src/index.ts +++ b/packages/atxp-server/src/index.ts @@ -24,6 +24,8 @@ export { getATXPResource, atxpAccountId, atxpToken, + getPaymentRequestId, + setPaymentRequestId, withATXPContext, getDetectedCredential, setDetectedCredential, @@ -124,4 +126,3 @@ export { export { ATXPAccount } from '@atxp/common'; - diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index 37ef919..f5b114f 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -12,6 +12,19 @@ describe('detectProtocol', () => { }); }); + it('should carry payment request id from the retry header', () => { + const result = detectProtocol({ + 'x-payment': 'some-x402-payment-credential', + 'x-atxp-payment-request-id': 'pr_123', + }); + + expect(result).toEqual({ + protocol: 'x402', + credential: 'some-x402-payment-credential', + paymentRequestId: 'pr_123', + }); + }); + it('should NOT detect Bearer JWT as ATXP (could be OAuth token)', () => { const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature123'; const result = detectProtocol({ @@ -267,10 +280,14 @@ describe('ProtocolSettlement', () => { source: 'did:pkh:eip155:4217:0xSrc', }; const credential = Buffer.from(JSON.stringify(mppCredential)).toString('base64'); - await settlement.settle('mpp', credential, { sourceAccountId: 'tempo:0xTestUser' }); + await settlement.settle('mpp', credential, { + sourceAccountId: 'tempo:0xTestUser', + paymentRequestId: 'pr_mpp_1', + }); const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.sourceAccountId).toBe('tempo:0xTestUser'); + expect(body.paymentRequestId).toBe('pr_mpp_1'); }); it('should include sourceAccountId in X402 settle when context provides it', async () => { @@ -284,10 +301,12 @@ describe('ProtocolSettlement', () => { await settlement.settle('x402', credential, { paymentRequirements: { network: 'base' }, sourceAccountId: 'base:0xTestUser', + paymentRequestId: 'pr_x402_1', }); const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.sourceAccountId).toBe('base:0xTestUser'); + expect(body.paymentRequestId).toBe('pr_x402_1'); }); it('should handle raw JSON MPP credential (not base64)', async () => { diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index 9719059..758f2b1 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -41,6 +41,7 @@ export type MppChallengeData = { export type CredentialDetection = { protocol: PaymentProtocol; credential: string; + paymentRequestId?: string; }; /** @@ -92,6 +93,8 @@ export type OmniChallenge = { export type SettlementContext = { /** X402: the original payment requirements from the challenge */ paymentRequirements?: unknown; + /** Stable id tying settlement and the follow-up charge to one payment lifecycle. */ + paymentRequestId?: string; /** Source account identifier (e.g., "base:0xABC..." from OAuth sub or wallet address). * When present, auth records the payment for this identity. */ sourceAccountId?: string; @@ -134,26 +137,29 @@ export type SettleResult = { */ export function detectProtocol(headers: { 'x-atxp-payment'?: string; + 'x-atxp-payment-request-id'?: string; 'payment-signature'?: string; 'x-payment'?: string; 'authorization'?: string; }): CredentialDetection | null { + const paymentRequestId = headers['x-atxp-payment-request-id']; + // X-ATXP-PAYMENT header indicates ATXP protocol (pull mode credential) const atxpPayment = headers['x-atxp-payment']; if (atxpPayment) { - return { protocol: 'atxp', credential: atxpPayment }; + return { protocol: 'atxp', credential: atxpPayment, ...(paymentRequestId && { paymentRequestId }) }; } // PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1) header indicates X402 protocol const paymentSig = headers['payment-signature'] || headers['x-payment']; if (paymentSig) { - return { protocol: 'x402', credential: paymentSig }; + return { protocol: 'x402', credential: paymentSig, ...(paymentRequestId && { paymentRequestId }) }; } // Authorization: Payment indicates standard MPP protocol const authHeader = headers['authorization']; if (authHeader?.startsWith('Payment ')) { - return { protocol: 'mpp', credential: authHeader.slice('Payment '.length) }; + return { protocol: 'mpp', credential: authHeader.slice('Payment '.length), ...(paymentRequestId && { paymentRequestId }) }; } return null; @@ -347,6 +353,7 @@ export class ProtocolSettlement { return { payload, paymentRequirements: requirements, + ...(context?.paymentRequestId && { paymentRequestId: context.paymentRequestId }), ...(context?.sourceAccountId && { sourceAccountId: context.sourceAccountId }), ...(this.destinationAccountId && { destinationAccountId: this.destinationAccountId }), }; @@ -362,6 +369,7 @@ export class ProtocolSettlement { } return { credential: parsedCredential, + ...(context?.paymentRequestId && { paymentRequestId: context.paymentRequestId }), ...(context?.sourceAccountId && { sourceAccountId: context.sourceAccountId }), ...(this.destinationAccountId && { destinationAccountId: this.destinationAccountId }), }; @@ -388,6 +396,7 @@ export class ProtocolSettlement { destinationAccountId: this.destinationAccountId ?? context?.destinationAccountId, sourceAccountToken: parsed.sourceAccountToken ?? credential, options, + ...(context?.paymentRequestId && { paymentRequestId: context.paymentRequestId }), }; } } diff --git a/packages/atxp-server/src/requirePayment.ts b/packages/atxp-server/src/requirePayment.ts index 93809fc..dda3277 100644 --- a/packages/atxp-server/src/requirePayment.ts +++ b/packages/atxp-server/src/requirePayment.ts @@ -1,6 +1,6 @@ import { RequirePaymentConfig, extractNetworkFromAccountId, extractAddressFromAccountId, Network, AuthorizationServerUrl } from "@atxp/common"; import { BigNumber } from "bignumber.js"; -import { getATXPConfig, atxpAccountId, atxpToken, setPendingPaymentChallenge } from "./atxpContext.js"; +import { getATXPConfig, atxpAccountId, atxpToken, getPaymentRequestId, setPendingPaymentChallenge } from "./atxpContext.js"; import { buildPaymentOptions, omniChallengeMcpError } from "./omniChallenge.js"; import { getATXPResource } from "./atxpContext.js"; import { signOpaqueIdentity } from "./opaqueIdentity.js"; @@ -28,6 +28,7 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi // Get the user's token for on-demand charging (connection_token flow) const token = atxpToken(); + const paymentRequestId = getPaymentRequestId(); // Always use multi-option format const charge = { @@ -41,6 +42,7 @@ export async function requirePayment(paymentConfig: RequirePaymentConfig): Promi destinationAccountId: destinationAccountId, payeeName: config.payeeName, ...(token && { sourceAccountToken: token }), + ...(paymentRequestId && { paymentRequestId }), }; // Settlement is handled by the middleware (atxpExpress) before route code runs. diff --git a/packages/atxp-server/src/types.ts b/packages/atxp-server/src/types.ts index 6369641..c6d7ddb 100644 --- a/packages/atxp-server/src/types.ts +++ b/packages/atxp-server/src/types.ts @@ -43,6 +43,8 @@ export type RefundErrors = boolean | 'nonMcpOnly'; export type Charge = Pick & { // User's OAuth token or connection_token for on-demand charging sourceAccountToken?: string; + /** Stable id tying /settle/* and the follow-up /charge to one user-visible payment. */ + paymentRequestId?: string; }; export type BalanceRequest = {