Skip to content
Open
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
368 changes: 368 additions & 0 deletions src/__tests__/x402-ows.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,368 @@
/**
* Tests for OWS (Open Wallet Standard) x402 payment provider.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock the OWS SDK loader — we never want to load the real native addon in tests
const mockSdk = {
listWallets: vi.fn(),
getWallet: vi.fn(),
signTypedData: vi.fn(),
signTransaction: vi.fn(),
};

// Mock createRequire so loadOwsSdk returns our mock
vi.mock('module', async (importOriginal) => {
const original = await importOriginal();
return {
...original,
createRequire: () => (pkg) => {
if (pkg === '@open-wallet-standard/core') return mockSdk;
throw new Error(`Cannot find module '${pkg}'`);
},
};
});

// Mock x402-svm.js to avoid real RPC calls
vi.mock('../x402-svm.js', async (importOriginal) => {
const original = await importOriginal();
return {
...original,
fetchRecentBlockhash: vi.fn().mockResolvedValue('GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'),
buildUnsignedSvmTransaction: vi.fn().mockReturnValue({
messageBytes: Buffer.alloc(100),
// Simulate tx: compact-u16(2) + 64 zero bytes (facilitator) + 64 zero bytes (client) + message
txBase64: Buffer.concat([
Buffer.from([2]), // compact-u16(2)
Buffer.alloc(64), // facilitator slot 0
Buffer.alloc(64), // client slot 1
Buffer.alloc(100), // message
]).toString('base64'),
}),
};
});

import {
loadOwsSdk,
findOwsWallet,
createOwsPaymentSignatures,
_resetSdkCache,
} from '../x402-ows.js';

// --- Helpers ---

function makeEvmRequirement() {
return {
network: 'eip155:8453',
scheme: 'exact',
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
amount: '50000',
payTo: '0xRecipient',
maxTimeoutSeconds: 120,
extra: {
name: 'USD Coin',
version: '2',
chainId: 8453,
decimals: 6,
symbol: 'USDC',
},
};
}

function makeSvmRequirement() {
return {
network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
scheme: 'exact',
asset: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
amount: '50000',
payTo: '7RecipientPubkey1111111111111111111111111',
extra: {
feePayer: '9FacilitatorPubkey111111111111111111111111',
decimals: 6,
},
};
}

function make402ResponseWithHeaders(requirements) {
const encoded = Buffer.from(JSON.stringify({ accepts: requirements })).toString('base64');
const headers = {
get(name) { return name === 'payment-required' ? encoded : null; },
};
return { headers, status: 402 };
}

const FAKE_EVM_WALLET = {
name: 'test-wallet',
id: 'abc-123',
accounts: [
{ chainId: 'eip155:1', address: '0xTestEvmAddress', derivationPath: "m/44'/60'/0'/0/0" },
{ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', address: 'TestSolAddress', derivationPath: "m/44'/501'/0'/0'" },
],
createdAt: '2026-01-01T00:00:00Z',
};

// --- Tests ---

beforeEach(() => {
_resetSdkCache();
vi.clearAllMocks();
delete process.env.OWS_WALLET;
delete process.env.OWS_PASSPHRASE;
});

// ============= loadOwsSdk =============

describe('loadOwsSdk', () => {
it('returns SDK object when installed', () => {
const sdk = loadOwsSdk();
expect(sdk).toBe(mockSdk);
});

it('caches SDK after first load', () => {
const sdk1 = loadOwsSdk();
const sdk2 = loadOwsSdk();
expect(sdk1).toBe(sdk2);
});

});

// ============= findOwsWallet =============

describe('findOwsWallet', () => {
it('returns wallet from OWS_WALLET env var', () => {
process.env.OWS_WALLET = 'my-wallet';
mockSdk.getWallet.mockReturnValue(FAKE_EVM_WALLET);

const result = findOwsWallet(mockSdk);
expect(result).toEqual({
name: 'test-wallet',
evmAddress: '0xTestEvmAddress',
solanaAddress: 'TestSolAddress',
});
expect(mockSdk.getWallet).toHaveBeenCalledWith('my-wallet');
});

it('returns first wallet with EVM + Solana accounts', () => {
mockSdk.listWallets.mockReturnValue([FAKE_EVM_WALLET]);

const result = findOwsWallet(mockSdk);
expect(result).toEqual({
name: 'test-wallet',
evmAddress: '0xTestEvmAddress',
solanaAddress: 'TestSolAddress',
});
});

it('skips wallets without both EVM and Solana accounts', () => {
const evmOnly = {
name: 'evm-only',
accounts: [{ chainId: 'eip155:1', address: '0xAddr' }],
};
const solOnly = {
name: 'sol-only',
accounts: [{ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', address: 'SolAddr' }],
};
mockSdk.listWallets.mockReturnValue([evmOnly, solOnly, FAKE_EVM_WALLET]);

const result = findOwsWallet(mockSdk);
expect(result.name).toBe('test-wallet');
});

it('returns null when no wallets exist', () => {
mockSdk.listWallets.mockReturnValue([]);
const result = findOwsWallet(mockSdk);
expect(result).toBeNull();
});

it('returns null when OWS_WALLET not found', () => {
process.env.OWS_WALLET = 'nonexistent';
mockSdk.getWallet.mockImplementation(() => { throw new Error('not found'); });

const result = findOwsWallet(mockSdk);
expect(result).toBeNull();
});
});

// ============= createOwsPaymentSignatures - EVM =============

describe('createOwsPaymentSignatures (EVM)', () => {
beforeEach(() => {
mockSdk.listWallets.mockReturnValue([FAKE_EVM_WALLET]);
});

it('yields a valid base64 signature for EVM requirement', async () => {
const fakeSig = 'ab'.repeat(65); // 65 bytes = 130 hex chars (r+s+v)
mockSdk.signTypedData.mockReturnValue({ signature: fakeSig, recoveryId: 27 });

const response = make402ResponseWithHeaders([makeEvmRequirement()]);
const results = [];
for await (const item of createOwsPaymentSignatures(response, 'https://api.nansen.ai/test')) {
results.push(item);
}

expect(results).toHaveLength(1);
expect(results[0].network).toBe('eip155:8453');

// Decode and verify payload structure
const payload = JSON.parse(Buffer.from(results[0].signature, 'base64').toString());
expect(payload.x402Version).toBe(2);
expect(payload.payload.signature).toBe('0x' + fakeSig);
expect(payload.payload.authorization.from).toBe('0xTestEvmAddress');
expect(payload.payload.authorization.to).toBe('0xRecipient');
});

it('passes correct typed data to signTypedData', async () => {
mockSdk.signTypedData.mockReturnValue({ signature: 'ab'.repeat(65), recoveryId: 27 });

const response = make402ResponseWithHeaders([makeEvmRequirement()]);
for await (const _ of createOwsPaymentSignatures(response, 'https://api.nansen.ai/test')) { /* consume */ }

expect(mockSdk.signTypedData).toHaveBeenCalledWith(
'test-wallet',
'evm',
expect.any(String), // JSON typed data
null, // no passphrase
);

const typedData = JSON.parse(mockSdk.signTypedData.mock.calls[0][2]);
expect(typedData.primaryType).toBe('TransferWithAuthorization');
expect(typedData.domain.chainId).toBe(8453);
expect(typedData.message.from).toBe('0xTestEvmAddress');
});

it('uses OWS_PASSPHRASE when set', async () => {
process.env.OWS_PASSPHRASE = 'my-secret';
mockSdk.signTypedData.mockReturnValue({ signature: 'ab'.repeat(65), recoveryId: 27 });

const response = make402ResponseWithHeaders([makeEvmRequirement()]);
for await (const _ of createOwsPaymentSignatures(response, 'https://example.com')) { /* consume */ }

expect(mockSdk.signTypedData).toHaveBeenCalledWith(
'test-wallet',
'evm',
expect.any(String),
'my-secret',
);
});

it('continues to next requirement on signing failure', async () => {
mockSdk.signTypedData
.mockImplementationOnce(() => { throw new Error('signing failed'); })
.mockReturnValueOnce({ signature: 'ab'.repeat(65), recoveryId: 27 });

const evmReq1 = { ...makeEvmRequirement(), network: 'eip155:8453' };
const evmReq2 = { ...makeEvmRequirement(), network: 'eip155:1' };
const response = make402ResponseWithHeaders([evmReq1, evmReq2]);

const results = [];
for await (const item of createOwsPaymentSignatures(response, 'https://example.com')) {
results.push(item);
}

expect(results).toHaveLength(1);
expect(results[0].network).toBe('eip155:1');
});
});

// ============= createOwsPaymentSignatures - Solana =============

describe('createOwsPaymentSignatures (Solana)', () => {
beforeEach(() => {
mockSdk.listWallets.mockReturnValue([FAKE_EVM_WALLET]);
});

it('yields a valid base64 signature for Solana requirement', async () => {
const fakeSig = 'cd'.repeat(64); // 64 bytes ed25519 signature
mockSdk.signTransaction.mockReturnValue({ signature: fakeSig, recoveryId: null });

const response = make402ResponseWithHeaders([makeSvmRequirement()]);
const results = [];
for await (const item of createOwsPaymentSignatures(response, 'https://api.nansen.ai/test')) {
results.push(item);
}

expect(results).toHaveLength(1);
expect(results[0].network).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp');

// Decode and verify payload
const payload = JSON.parse(Buffer.from(results[0].signature, 'base64').toString());
expect(payload.x402Version).toBe(2);
expect(payload.payload.transaction).toBeDefined();
});

it('places client signature in slot 1 (not slot 0)', async () => {
const fakeSig = 'cd'.repeat(64);
mockSdk.signTransaction.mockReturnValue({ signature: fakeSig, recoveryId: null });

const response = make402ResponseWithHeaders([makeSvmRequirement()]);
for await (const item of createOwsPaymentSignatures(response, 'https://example.com')) {
const payload = JSON.parse(Buffer.from(item.signature, 'base64').toString());
const txBytes = Buffer.from(payload.payload.transaction, 'base64');

// Slot 0 (facilitator) should remain zeros
const slot0 = txBytes.subarray(1, 1 + 64);
expect(slot0.every(b => b === 0)).toBe(true);

// Slot 1 (client) should contain our signature
const slot1 = txBytes.subarray(1 + 64, 1 + 128);
expect(slot1.toString('hex')).toBe(fakeSig);
}
});

it('passes correct tx hex to signTransaction', async () => {
mockSdk.signTransaction.mockReturnValue({ signature: 'cd'.repeat(64), recoveryId: null });

const response = make402ResponseWithHeaders([makeSvmRequirement()]);
for await (const _ of createOwsPaymentSignatures(response, 'https://example.com')) { /* consume */ }

expect(mockSdk.signTransaction).toHaveBeenCalledWith(
'test-wallet',
'solana',
expect.any(String), // hex-encoded tx
null, // no passphrase
);
});
});

// ============= createOwsPaymentSignatures - Priority & Fallthrough =============

describe('createOwsPaymentSignatures (priority)', () => {
beforeEach(() => {
mockSdk.listWallets.mockReturnValue([FAKE_EVM_WALLET]);
});

it('yields EVM before Solana when both are available', async () => {
mockSdk.signTypedData.mockReturnValue({ signature: 'ab'.repeat(65), recoveryId: 27 });
mockSdk.signTransaction.mockReturnValue({ signature: 'cd'.repeat(64), recoveryId: null });

const response = make402ResponseWithHeaders([makeSvmRequirement(), makeEvmRequirement()]);
const networks = [];
for await (const { network } of createOwsPaymentSignatures(response, 'https://example.com')) {
networks.push(network);
}

expect(networks[0]).toBe('eip155:8453');
expect(networks[1]).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp');
});

it('yields nothing when no wallets found', async () => {
mockSdk.listWallets.mockReturnValue([]);

const response = make402ResponseWithHeaders([makeEvmRequirement()]);
const results = [];
for await (const item of createOwsPaymentSignatures(response, 'https://example.com')) {
results.push(item);
}
expect(results).toHaveLength(0);
});

it('yields nothing when requirements are empty', async () => {
const response = make402ResponseWithHeaders([]);
const results = [];
for await (const item of createOwsPaymentSignatures(response, 'https://example.com')) {
results.push(item);
}
expect(results).toHaveLength(0);
});
});
11 changes: 10 additions & 1 deletion src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,16 @@ export class NansenAPI {
if (result !== null) return result;
// This payment option was rejected, try next
}
} catch { /* local wallet unavailable, try WalletConnect */ }
} catch { /* local wallet unavailable, try OWS */ }

// 1.5. Try OWS wallet (Open Wallet Standard)
try {
const { createOwsPaymentSignatures } = await import('./x402-ows.js');
for await (const { signature, network } of createOwsPaymentSignatures(response, url)) {
const result = await this._x402Retry(signature, 'OWS wallet', network, url, body, options);
if (result !== null) return result;
}
} catch { /* OWS unavailable, try WalletConnect */ }

// 2. Fall back to WalletConnect (walletconnect-x402.js)
{
Expand Down
Loading
Loading