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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,4 @@ dist-cjs
.DS_Store
bundle-analysis/
.npmrc.publish
CLAUDE.local.md
15 changes: 13 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions packages/atxp-base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"dist"
],
"scripts": {
"build": "rollup -c",
"build": "rollup -c && rm -rf dist/node_modules dist/_virtual",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix",
Expand All @@ -37,7 +37,8 @@
"@atxp/client": "0.10.13",
"@atxp/common": "0.10.13",
"bignumber.js": "^9.3.0",
"viem": "^2.34.0"
"viem": "^2.34.0",
"x402": "^0.3.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
Expand Down
3 changes: 2 additions & 1 deletion packages/atxp-base/src/baseAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { privateKeyToAccount, PrivateKeyAccount } from 'viem/accounts';
import { BasePaymentMaker } from './basePaymentMaker.js';
import { createWalletClient, http, WalletClient, LocalAccount } from 'viem';
import { base } from 'viem/chains';
import { createPaymentHeader } from 'x402/client';

export class BaseAccount implements Account {
readonly usesAccountsAuthorize = false;
private _accountId: AccountId;
paymentMakers: PaymentMaker[];
private walletClient: WalletClient;
Expand Down Expand Up @@ -83,7 +85,6 @@ export class BaseAccount implements Account {

switch (protocol) {
case 'x402': {
const { createPaymentHeader } = await import('x402/client');
if (!params.paymentRequirements) {
throw new Error('BaseAccount: x402 authorize requires paymentRequirements');
}
Expand Down
1 change: 1 addition & 0 deletions packages/atxp-base/src/baseAppAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const DEFAULT_ALLOWANCE = 10n;
const DEFAULT_PERIOD_IN_DAYS = 7;

export class BaseAppAccount implements Account {
readonly usesAccountsAuthorize = false;
private _accountId: AccountId;
paymentMakers: PaymentMaker[];
private mainWalletAddress?: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/atxp-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"dist"
],
"scripts": {
"build": "rollup -c",
"build": "rollup -c && rm -rf dist/node_modules dist/_virtual",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix",
Expand Down
189 changes: 189 additions & 0 deletions packages/atxp-client/src/atxpAccountHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { describe, it, expect, vi } from 'vitest';
import { ATXPAccountHandler } from './atxpAccountHandler.js';
import type { ProtocolConfig } from './protocolHandler.js';
import type { Account } from '@atxp/common';

function createMockAccount(overrides?: Partial<Account>): Account {
return {
getAccountId: vi.fn().mockResolvedValue('atxp:test-account'),
paymentMakers: [],
usesAccountsAuthorize: true,
getSources: vi.fn().mockResolvedValue([]),
createSpendPermission: vi.fn().mockResolvedValue(null),
authorize: vi.fn().mockResolvedValue({ protocol: 'atxp', credential: '{"token":"abc"}' }),
...overrides,
} as unknown as Account;
}

function createMockConfig(overrides?: Partial<ProtocolConfig>): ProtocolConfig {
return {
account: createMockAccount(),
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
fetchFn: vi.fn().mockResolvedValue(new Response('ok', { status: 200 })),
approvePayment: vi.fn().mockResolvedValue(true),
onPayment: vi.fn(),
onPaymentFailure: vi.fn(),
...overrides,
};
}

function make402Response(body: Record<string, unknown>): Response {
return new Response(JSON.stringify(body), {
status: 402,
headers: { 'Content-Type': 'application/json' },
});
}

describe('ATXPAccountHandler', () => {
const handler = new ATXPAccountHandler();

describe('canHandle', () => {
it('returns true for 402 responses', async () => {
const response = new Response('', { status: 402 });
expect(await handler.canHandle(response)).toBe(true);
});

it('returns false for 200 responses', async () => {
const response = new Response('ok', { status: 200 });
expect(await handler.canHandle(response)).toBe(false);
});
});

describe('handlePaymentChallenge', () => {
it('delegates to account.authorize() and retries with payment header', async () => {
const authorize = vi.fn().mockResolvedValue({ protocol: 'atxp', credential: '{"token":"abc"}' });
const account = createMockAccount({ authorize });
const retryResponse = new Response('paid', { status: 200 });
const fetchFn = vi.fn().mockResolvedValue(retryResponse);
const config = createMockConfig({ account, fetchFn });

const response = make402Response({ chargeAmount: '0.01' });
const result = await handler.handlePaymentChallenge(
response,
{ url: 'https://example.com/api' },
config,
);

expect(authorize).toHaveBeenCalledTimes(1);
expect(authorize).toHaveBeenCalledWith(
expect.objectContaining({
protocols: ['atxp', 'x402', 'mpp'],
amount: expect.anything(),
}),
);
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(result).toBe(retryResponse);
});

it('returns null when authorize throws', async () => {
const authorize = vi.fn().mockRejectedValue(new Error('auth failed'));
const account = createMockAccount({ authorize });
const config = createMockConfig({ account });

const response = make402Response({ chargeAmount: '0.01' });
const result = await handler.handlePaymentChallenge(
response,
{ url: 'https://example.com/api' },
config,
);

expect(result).toBeNull();
expect(config.logger.error).toHaveBeenCalledWith(
expect.stringContaining('authorize failed'),
);
});

it('returns null when no amount in challenge data', async () => {
const config = createMockConfig();

// Challenge with no chargeAmount, no x402, no mpp
const response = make402Response({ someOtherField: 'value' });
const result = await handler.handlePaymentChallenge(
response,
{ url: 'https://example.com/api' },
config,
);

expect(result).toBeNull();
expect(config.logger.error).toHaveBeenCalledWith(
expect.stringContaining('no amount in challenge data'),
);
});
});

describe('buildAuthorizeParams (via handlePaymentChallenge)', () => {
it('extracts destination from x402 accepts, skipping network=atxp', async () => {
const authorize = vi.fn().mockResolvedValue({ protocol: 'x402', credential: 'x402-cred' });
const account = createMockAccount({ authorize });
const fetchFn = vi.fn().mockResolvedValue(new Response('ok', { status: 200 }));
const config = createMockConfig({ account, fetchFn });

const response = make402Response({
chargeAmount: '1000000',
x402: {
accepts: [
{ network: 'atxp', payTo: 'atxp_acct_123' },
{ network: 'base', payTo: '0xDEST', maxAmountRequired: '1000000' },
],
},
});

await handler.handlePaymentChallenge(
response,
{ url: 'https://example.com/api' },
config,
);

expect(authorize).toHaveBeenCalledWith(
expect.objectContaining({
destination: '0xDEST',
paymentRequirements: expect.objectContaining({
network: 'base',
payTo: '0xDEST',
}),
}),
);
});

it('fetches payment request when no x402 data provides a destination', async () => {
const authorize = vi.fn().mockResolvedValue({ protocol: 'atxp', credential: '{}' });
const account = createMockAccount({ authorize });

// Mock fetchFn: the first call (for the payment request) returns options;
// the second call (for the retry) returns 200.
const prResponse = new Response(JSON.stringify({
options: [{ address: '0xFromPR', network: 'base', amount: '500000' }],
}), { status: 200 });
const retryResponse = new Response('ok', { status: 200 });
const fetchFn = vi.fn()
.mockResolvedValueOnce(prResponse)
.mockResolvedValueOnce(retryResponse);

const config = createMockConfig({ account, fetchFn });

const response = make402Response({
chargeAmount: '0.50',
paymentRequestUrl: 'https://auth.atxp.ai/payment-request/pr_123',
});

await handler.handlePaymentChallenge(
response,
{ url: 'https://example.com/api' },
config,
);

// First fetchFn call is the payment request fetch
expect(fetchFn).toHaveBeenCalledWith('https://auth.atxp.ai/payment-request/pr_123');
expect(authorize).toHaveBeenCalledWith(
expect.objectContaining({
destination: '0xFromPR',
}),
);
});
});
});
Loading
Loading