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
5 changes: 5 additions & 0 deletions .changeset/x402-missing-wallet-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nansen-cli": patch
---

x402 payments skip an option whose chain has no key in the wallet with a clear reason (for example `wallet "main" has no Solana key`) instead of a raw JavaScript error, and the self-sponsored Solana `feePayer` error no longer points only at Base as the alternative.
2 changes: 1 addition & 1 deletion src/__tests__/x402-svm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ describe('buildUnsignedSvmTransaction', () => {
it('refuses a feePayer equal to the paying wallet instead of building a malformed transaction', () => {
const selfPay = { ...requirements, extra: { feePayer: wallet.address } };
expect(() => buildUnsignedSvmTransaction(selfPay, wallet.address, blockhash))
.toThrow(/feePayer is the paying wallet .*Another payment option will be tried/);
.toThrow(/feePayer is the paying wallet .*Another payment option will be tried; if none succeeds, pay on another network/);
expect(() => createSvmPaymentPayload(selfPay, wallet.privateKey, wallet.address, 'https://r', blockhash))
.toThrow(/feePayer is the paying wallet/);
});
Expand Down
62 changes: 62 additions & 0 deletions src/__tests__/x402.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,68 @@ describe('createPaymentSignatures — policy guard integration', () => {
vi.doUnmock('../x402-evm.js');
});

// A wallet without a Solana key used to reach the SVM builder and surface a
// raw TypeError on the skip line.
it('12d. skips a Solana option with a clear reason when the wallet has no Solana key', async () => {
vi.doMock('../wallet.js', () => ({
listWallets: () => ({
defaultWallet: 'test',
wallets: [{ name: 'test', evm: '0xFakeAddress' }],
}),
exportWallet: () => ({
name: 'test',
evm: FAKE_EXPORTED.evm,
solana: { address: undefined, privateKey: null },
}),
getWalletConfig: () => ({ passwordHash: null }),
}));

// Cheaper than the EVM option, so it is tried first.
const solanaOption = {
scheme: 'exact',
network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
asset: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
amount: '5000',
payTo: 'FakeSolanaPayTo',
extra: { feePayer: 'FakeFacilitator' },
maxTimeoutSeconds: 120,
};
const evm = makeRequirement(10_000n);

const createEvmSpy = vi.fn().mockReturnValue('fake-sig-ok');
const createSvmSpy = vi.fn();
vi.doMock('../x402-evm.js', () => ({
createEvmPaymentPayload: createEvmSpy,
isEvmNetwork: (n) => n.startsWith('eip155:'),
PERMIT2_ADDRESS: '0x000000000022D473030F116dDEE9F6B43aC78BA3',
}));
vi.doMock('../x402-svm.js', () => ({
createSvmPaymentPayload: createSvmSpy,
isSvmNetwork: (n) => n.startsWith('solana:'),
fetchRecentBlockhash: vi.fn(),
getSolanaRpcUrl: vi.fn(),
}));

const { createPaymentSignatures } = await import('../x402.js');
const payload = { accepts: [evm, solanaOption] };
const header = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
const response = { headers: { get: (k) => (k === 'payment-required' ? header : null) } };

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

expect(results).toEqual([{ signature: 'fake-sig-ok', network: 'eip155:8453', asset: evm.asset }]);
expect(createSvmSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringMatching(/^\[x402\] Skipping solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp option: wallet "test" has no Solana key\. .*nansen wallet create/),
);

vi.doUnmock('../x402-evm.js');
vi.doUnmock('../x402-svm.js');
});

it('13. permit2-exact preflight checks allowance against resolvePaymentAmount, not raw empty amount', async () => {
// Regression: hasPermit2Allowance must be called with the guard's resolved
// amount, not requirement.amount directly — otherwise amount: "" coerces to
Expand Down
2 changes: 1 addition & 1 deletion src/x402-svm.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export function buildUnsignedSvmTransaction(
: `its transaction requires ${numRequiredSignatures} signatures instead of ${X402_SVM_SIGNER_COUNT}`;
throw new Error(
`Cannot pay this x402 Solana option: ${cause}. ` +
'Another payment option will be tried; if none succeeds, pay on Base or report this payment option to Nansen.',
'Another payment option will be tried; if none succeeds, pay on another network or report this payment option to Nansen.',
);
}

Expand Down
12 changes: 12 additions & 0 deletions src/x402.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ async function buildPaymentForRequirement(requirement, exported, url) {
return null;
}

// exportWallet returns a null key for a chain the wallet file doesn't hold.
// Skip that option with a clear reason instead of failing on the missing key.
const rail = isEvmNetwork(requirement.network) ? 'evm' : 'solana';
if (!exported[rail]?.privateKey || !exported[rail]?.address) {
const chain = rail === 'evm' ? 'EVM' : 'Solana';
console.error(
`[x402] Skipping ${requirement.network} option: wallet "${exported.name}" has no ${chain} key. ` +
'Create a wallet with both keys (nansen wallet create) and make it the default (nansen wallet default <name>).',
);
return null;
}

if (isEvmNetwork(requirement.network)) {
if ((requirement.extra || {}).assetTransferMethod === 'permit2-exact') {
const resolvedAmount = resolvePaymentAmount(requirement);
Expand Down
Loading