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
3 changes: 3 additions & 0 deletions packages/atxp-client/src/mppProtocolHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 10 additions & 2 deletions packages/atxp-client/src/protocolHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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' },
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions packages/atxp-client/src/x402ProtocolHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface X402Challenge {
/** v2 adds resource info and extensions */
resource?: { url: string; description?: string; mimeType?: string };
extensions?: Record<string, unknown>;
paymentRequestId?: string;
}

/**
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions packages/atxp-express/src/atxpExpress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
sendOAuthMetadataNode,
detectProtocol,
getPendingPaymentChallenge,
setPaymentRequestId,
type PaymentProtocol,
type ATXPConfig,
type TokenCheck,
Expand Down Expand Up @@ -51,6 +52,7 @@
// 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,
Expand Down Expand Up @@ -134,9 +136,13 @@
// as paymentRequirements instead of regenerating from server config.
// For MPP/ATXP: credentials are self-contained, no extra context needed.
const context: Record<string, unknown> = {
...(detected.paymentRequestId && { paymentRequestId: detected.paymentRequestId }),
...(sourceAccountId && { sourceAccountId }),
destinationAccountId,
};
if (detected.paymentRequestId) {
setPaymentRequestId(detected.paymentRequestId);
}

if (detected.protocol === 'x402') {
const parsed = parseCredentialBase64(detected.credential);
Expand Down Expand Up @@ -224,11 +230,11 @@
res.writeHead = function writeHeadDeferred(this: Response, ...args: any[]): any {
deferredWriteHead = args;
return this;
} as any;

Check warning on line 233 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type

function flushWriteHead(self: Response): void {
if (!deferredWriteHead) return;
(origWriteHead as any).apply(self, deferredWriteHead);

Check warning on line 237 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
deferredWriteHead = null;
}

Expand All @@ -237,8 +243,8 @@
res.write = function writeWithPaymentRewrite(this: Response, ...args: any[]): any {
flushWriteHead(this);
args[0] = rewriteChunk(args[0]);
return (origWrite as any).apply(this, args);

Check warning on line 246 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
} as any;

Check warning on line 247 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type

// Hook res.end for non-SSE (enableJsonResponse) responses.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -266,12 +272,12 @@
}
}
}
(origWriteHead as any).apply(this, deferredWriteHead);

Check warning on line 275 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
deferredWriteHead = null;
}

return (origEnd as any).apply(this, args);

Check warning on line 279 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
} as any;

Check warning on line 280 in packages/atxp-express/src/atxpExpress.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
}

/**
Expand Down
19 changes: 19 additions & 0 deletions packages/atxp-server/src/atxpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/atxp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export {
getATXPResource,
atxpAccountId,
atxpToken,
getPaymentRequestId,
setPaymentRequestId,
withATXPContext,
getDetectedCredential,
setDetectedCredential,
Expand Down Expand Up @@ -124,4 +126,3 @@ export {
export {
ATXPAccount
} from '@atxp/common';

21 changes: 20 additions & 1 deletion packages/atxp-server/src/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
15 changes: 12 additions & 3 deletions packages/atxp-server/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type MppChallengeData = {
export type CredentialDetection = {
protocol: PaymentProtocol;
credential: string;
paymentRequestId?: string;
};

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 <credential> 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;
Expand Down Expand Up @@ -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 }),
};
Expand All @@ -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 }),
};
Expand All @@ -388,6 +396,7 @@ export class ProtocolSettlement {
destinationAccountId: this.destinationAccountId ?? context?.destinationAccountId,
sourceAccountToken: parsed.sourceAccountToken ?? credential,
options,
...(context?.paymentRequestId && { paymentRequestId: context.paymentRequestId }),
};
}
}
4 changes: 3 additions & 1 deletion packages/atxp-server/src/requirePayment.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 = {
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/atxp-server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export type RefundErrors = boolean | 'nonMcpOnly';
export type Charge = Pick<PaymentRequest, 'options' | 'sourceAccountId' | 'destinationAccountId' | 'payeeName'> & {
// 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 = {
Expand Down
Loading