diff --git a/.changeset/x402-missing-wallet-key.md b/.changeset/x402-missing-wallet-key.md new file mode 100644 index 00000000..cc9ddc71 --- /dev/null +++ b/.changeset/x402-missing-wallet-key.md @@ -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. diff --git a/src/__tests__/x402-svm.test.js b/src/__tests__/x402-svm.test.js index b014e7ca..aefc9ed5 100644 --- a/src/__tests__/x402-svm.test.js +++ b/src/__tests__/x402-svm.test.js @@ -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/); }); diff --git a/src/__tests__/x402.test.js b/src/__tests__/x402.test.js index 63293847..3aec32ae 100644 --- a/src/__tests__/x402.test.js +++ b/src/__tests__/x402.test.js @@ -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 diff --git a/src/x402-svm.js b/src/x402-svm.js index af2d3f8f..62aa0f5a 100644 --- a/src/x402-svm.js +++ b/src/x402-svm.js @@ -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.', ); } diff --git a/src/x402.js b/src/x402.js index f5acd695..a884a708 100644 --- a/src/x402.js +++ b/src/x402.js @@ -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 ).', + ); + return null; + } + if (isEvmNetwork(requirement.network)) { if ((requirement.extra || {}).assetTransferMethod === 'permit2-exact') { const resolvedAmount = resolvePaymentAmount(requirement);