From 04374053b1cb3e143509b9012769d2f3ac2f69ec Mon Sep 17 00:00:00 2001 From: Derek Rein Date: Tue, 3 Mar 2026 08:51:07 +0700 Subject: [PATCH 1/2] feat: add Solana WalletConnect support for trading Enable Solana wallets (Phantom, Jupiter, Solflare) to sign DEX swap transactions via WalletConnect v2. Removes the "EVM only" restriction from trading commands while keeping transfers EVM-only for now. Changes: - getWalletConnectAddress() accepts chainType filter (evm/solana) - New sendSolanaTransactionViaWalletConnect() for Solana tx signing - Handles both signedTransaction and raw signature WC responses - Ed25519 signature length validation (64 bytes) - Case-sensitive address comparison for Solana (vs toLowerCase for EVM) - Extract parseWcJson helper to deduplicate JSON parsing Co-Authored-By: Claude Opus 4.6 --- .changeset/solana-walletconnect.md | 5 + src/__tests__/trading.test.js | 78 ++++++++++-- src/__tests__/transfer.test.js | 4 +- src/__tests__/walletconnect-trading.test.js | 124 ++++++++++++++++++++ src/trading.js | 54 ++++++--- src/transfer.js | 2 +- src/walletconnect-trading.js | 67 +++++++++-- 7 files changed, 292 insertions(+), 42 deletions(-) create mode 100644 .changeset/solana-walletconnect.md diff --git a/.changeset/solana-walletconnect.md b/.changeset/solana-walletconnect.md new file mode 100644 index 00000000..3d82a1e0 --- /dev/null +++ b/.changeset/solana-walletconnect.md @@ -0,0 +1,5 @@ +--- +"nansen-cli": minor +--- + +Add Solana WalletConnect support for trading (quote and execute). Solana wallets like Phantom and Solflare can now sign DEX swap transactions via WalletConnect v2. diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index a026092b..4f95950b 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -782,8 +782,25 @@ describe('WalletConnect quote support', () => { expect(loaded.signerType).toBe('local'); }); - it('should reject Solana + walletconnect for quote', async () => { - vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue('0x742d35Cc6bF4F3f4e0e3a8DD7e37ff4e4Be4E4B4'); + it('should allow Solana + walletconnect for quote', async () => { + vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue('9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'); + + // Mock global fetch for the quote API call + const originalFetch = global.fetch; + global.fetch = vi.fn(async () => ({ + ok: true, + json: () => Promise.resolve({ + success: true, + quotes: [{ + aggregator: 'jupiter', + inputMint: 'So11111111111111111111111111111111111111112', + outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + inAmount: '1000000000', + outAmount: '150000000', + transaction: 'AQAAAA==', + }], + }), + })); const logs = []; let exitCalled = false; @@ -800,9 +817,12 @@ describe('WalletConnect quote support', () => { wallet: 'walletconnect', }); - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('WalletConnect is only supported for EVM chains'))).toBe(true); + // Should NOT have rejected — it should have proceeded to fetch a quote + expect(logs.some(l => l.includes('WalletConnect is only supported for EVM chains'))).toBe(false); + // Wallet address should show the Solana address + expect(logs.some(l => l.includes('9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'))).toBe(true); + global.fetch = originalFetch; vi.restoreAllMocks(); }); @@ -924,26 +944,62 @@ describe('WalletConnect execute support', () => { vi.restoreAllMocks(); }); - it('should reject Solana + walletconnect for execute', async () => { + it('should allow Solana + walletconnect for execute', async () => { + vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue('9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'); + vi.spyOn(wcTrading, 'sendSolanaTransactionViaWalletConnect').mockResolvedValue({ signedTransaction: '5K4Ld...' }); + + // Mock global fetch for executeTransaction API call + const originalFetch = global.fetch; + global.fetch = vi.fn(async () => ({ + ok: true, + json: () => Promise.resolve({ + status: 'Success', + txHash: '5K4LdSignedTx...', + }), + })); + + // Build a minimal valid Solana transaction (1 sig slot, minimal message) + // CompactU16(1) = [0x01], then 64 zero bytes for the signature, then message bytes + const sigCount = Buffer.from([0x01]); + const emptySig = Buffer.alloc(64); + const messageBytes = Buffer.from([ + 0x01, 0x00, 0x01, // header: 1 signer, 0 readonly signed, 1 readonly unsigned + 0x02, // 2 account keys + ...Buffer.alloc(32), // account key 1 + ...Buffer.alloc(32), // account key 2 + ...Buffer.alloc(32), // recent blockhash + 0x01, // 1 instruction + 0x01, // program ID index + 0x01, 0x00, // 1 account index: [0] + 0x04, 0x02, 0x00, 0x00, 0x00, // data: transfer instruction + ]); + const txBytes = Buffer.concat([sigCount, emptySig, messageBytes]); + const txBase64 = txBytes.toString('base64'); + const quoteId = saveQuote({ success: true, quotes: [{ - aggregator: 'test', - transaction: 'AQAAAA==', // base64 Solana tx + aggregator: 'jupiter', + transaction: txBase64, }], }, 'solana', 'walletconnect'); const logs = []; - let exitCalled = false; const cmds = buildTradingCommands({ log: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, + exit: () => {}, }); + delete process.env.NANSEN_WALLET_PASSWORD; + await cmds.execute([], null, {}, { quote: quoteId }); - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('WalletConnect is only supported for EVM chains'))).toBe(true); + // Should have used WalletConnect path + expect(logs.some(l => l.includes('WalletConnect'))).toBe(true); + expect(logs.some(l => l.includes('WalletConnect is only supported for EVM chains'))).toBe(false); + + global.fetch = originalFetch; + vi.restoreAllMocks(); }); }); diff --git a/src/__tests__/transfer.test.js b/src/__tests__/transfer.test.js index 880268a9..ddd5fe69 100644 --- a/src/__tests__/transfer.test.js +++ b/src/__tests__/transfer.test.js @@ -518,13 +518,13 @@ describe('sendTokens via WalletConnect', () => { vi.clearAllMocks(); }); - test('rejects Solana + walletconnect', async () => { + test('rejects Solana + walletconnect for transfers with clear message', async () => { await expect(sendTokens({ to: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', amount: '0.5', chain: 'solana', walletconnect: true, - })).rejects.toThrow('WalletConnect is only supported for EVM chains'); + })).rejects.toThrow('WalletConnect Solana transfers are not yet supported. Use a local wallet for Solana transfers.'); }); test('errors when no WalletConnect session', async () => { diff --git a/src/__tests__/walletconnect-trading.test.js b/src/__tests__/walletconnect-trading.test.js index 746e81cf..de0999a4 100644 --- a/src/__tests__/walletconnect-trading.test.js +++ b/src/__tests__/walletconnect-trading.test.js @@ -16,6 +16,7 @@ import { execFile } from 'child_process'; import { getWalletConnectAddress, sendTransactionViaWalletConnect, + sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect, } from '../walletconnect-trading.js'; @@ -76,6 +77,57 @@ describe('getWalletConnectAddress', () => { const address = await getWalletConnectAddress(); expect(address).toBeNull(); }); + + it('returns Solana address when chainType is solana', async () => { + mockExecFile(JSON.stringify({ + connected: true, + accounts: [ + { chain: 'eip155:1', address: '0x742d35Cc6bF4F3f4e0e3a8DD7e37ff4e4Be4E4B4' }, + { chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM' }, + ], + })); + + const address = await getWalletConnectAddress('solana'); + expect(address).toBe('9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'); + }); + + it('returns EVM address when chainType is evm', async () => { + mockExecFile(JSON.stringify({ + connected: true, + accounts: [ + { chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM' }, + { chain: 'eip155:1', address: '0x742d35Cc6bF4F3f4e0e3a8DD7e37ff4e4Be4E4B4' }, + ], + })); + + const address = await getWalletConnectAddress('evm'); + expect(address).toBe('0x742d35Cc6bF4F3f4e0e3a8DD7e37ff4e4Be4E4B4'); + }); + + it('returns null when chainType is solana but no Solana account', async () => { + mockExecFile(JSON.stringify({ + connected: true, + accounts: [ + { chain: 'eip155:1', address: '0x742d35Cc6bF4F3f4e0e3a8DD7e37ff4e4Be4E4B4' }, + ], + })); + + const address = await getWalletConnectAddress('solana'); + expect(address).toBeNull(); + }); + + it('returns first address when no chainType (backward compat)', async () => { + mockExecFile(JSON.stringify({ + connected: true, + accounts: [ + { chain: 'eip155:1', address: '0x742d35Cc6bF4F3f4e0e3a8DD7e37ff4e4Be4E4B4' }, + { chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM' }, + ], + })); + + const address = await getWalletConnectAddress(); + expect(address).toBe('0x742d35Cc6bF4F3f4e0e3a8DD7e37ff4e4Be4E4B4'); + }); }); // ============= sendTransactionViaWalletConnect ============= @@ -265,3 +317,75 @@ describe('sendApprovalViaWalletConnect', () => { expect(payload.gas).toBe('0x186a0'); // 100000 in hex }); }); + +// ============= sendSolanaTransactionViaWalletConnect ============= + +describe('sendSolanaTransactionViaWalletConnect', () => { + it('sends correct payload and returns signedTransaction', async () => { + mockExecFile(JSON.stringify({ signedTransaction: '5K4Ld...' })); + + const result = await sendSolanaTransactionViaWalletConnect('3Bxs3z...'); + + expect(result).toEqual({ signedTransaction: '5K4Ld...' }); + + // Verify the command arguments + expect(execFile).toHaveBeenCalledWith( + 'walletconnect', + ['send-transaction', expect.any(String)], + expect.objectContaining({ timeout: 120000 }), + expect.any(Function), + ); + + // Verify the JSON payload + const payload = JSON.parse(execFile.mock.calls[0][1][1]); + expect(payload.transaction).toBe('3Bxs3z...'); + expect(payload.chainId).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + }); + + it('returns signature when wallet returns signature only', async () => { + mockExecFile(JSON.stringify({ signature: '4vJ9...' })); + + const result = await sendSolanaTransactionViaWalletConnect('3Bxs3z...'); + + expect(result).toEqual({ signature: '4vJ9...' }); + }); + + it('returns signedTransaction when wallet returns transaction field', async () => { + mockExecFile(JSON.stringify({ transaction: '5abc...' })); + + const result = await sendSolanaTransactionViaWalletConnect('3Bxs3z...'); + + expect(result).toEqual({ signedTransaction: '5abc...' }); + }); + + it('throws on timeout', async () => { + mockExecFile('', new Error('Command timed out')); + + await expect(sendSolanaTransactionViaWalletConnect('3Bxs3z...')).rejects.toThrow('Command timed out'); + }); + + it('throws when no JSON output', async () => { + mockExecFile('Some non-JSON output'); + + await expect(sendSolanaTransactionViaWalletConnect('3Bxs3z...')).rejects.toThrow('No JSON output'); + }); + + it('throws when unexpected response', async () => { + mockExecFile(JSON.stringify({ unexpected: true })); + + await expect(sendSolanaTransactionViaWalletConnect('3Bxs3z...')).rejects.toThrow('Unexpected response'); + }); + + it('uses custom timeout', async () => { + mockExecFile(JSON.stringify({ signedTransaction: '5K4Ld...' })); + + await sendSolanaTransactionViaWalletConnect('3Bxs3z...', 60000); + + expect(execFile).toHaveBeenCalledWith( + 'walletconnect', + expect.any(Array), + expect.objectContaining({ timeout: 60000 }), + expect.any(Function), + ); + }); +}); diff --git a/src/trading.js b/src/trading.js index 91e5e2bb..38665da6 100644 --- a/src/trading.js +++ b/src/trading.js @@ -8,10 +8,10 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -import { exportWallet, getDefaultAddress, showWallet, listWallets } from './wallet.js'; +import { base58Encode, exportWallet, getDefaultAddress, showWallet, listWallets } from './wallet.js'; import { base58Decode } from './transfer.js'; import { keccak256, signSecp256k1, rlpEncode } from './crypto.js'; -import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js'; +import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js'; // ============= Constants ============= @@ -786,7 +786,7 @@ OPTIONS: --from Input token (symbol like SOL, USDC or address) --to Output token (symbol like USDC, ETH or address) --amount Amount in BASE UNITS (e.g. lamports, wei) - --wallet Wallet name (default: default wallet). Use "walletconnect" or "wc" for WalletConnect (EVM only). + --wallet Wallet name (default: default wallet). Use "walletconnect" or "wc" for WalletConnect. --slippage Slippage as decimal (e.g. 0.03 for 3%). Default: 0.03 --auto-slippage Enable auto slippage calculation --max-auto-slippage Max auto slippage when auto-slippage enabled @@ -816,12 +816,7 @@ EXAMPLES: let walletAddress; if (isWalletConnect) { - if (chainType !== 'evm') { - log('WalletConnect is only supported for EVM chains'); - exit(1); - return; - } - walletAddress = await getWalletConnectAddress(); + walletAddress = await getWalletConnectAddress(chainType); if (!walletAddress) { log('No WalletConnect session active. Run: walletconnect connect'); exit(1); @@ -973,12 +968,7 @@ EXAMPLES: exported = exportWallet(effectiveWalletName, password); } else { // Verify WalletConnect session is still active and address matches quote - if (chainType !== 'evm') { - log('WalletConnect is only supported for EVM chains'); - exit(1); - return; - } - const wcAddress = await getWalletConnectAddress(); + const wcAddress = await getWalletConnectAddress(chainType); if (!wcAddress) { log('No WalletConnect session active. Run: walletconnect connect'); exit(1); @@ -987,7 +977,9 @@ EXAMPLES: // Check address matches the one used during quoting const quoteWallet = quoteData.response?.quotes?.[0]?.transaction?.from || quoteData.response?.metadata?.userWalletAddress; - if (quoteWallet && wcAddress.toLowerCase() !== quoteWallet.toLowerCase()) { + if (quoteWallet && (chainType === 'solana' + ? wcAddress !== quoteWallet + : wcAddress.toLowerCase() !== quoteWallet.toLowerCase())) { log(`Connected wallet (${wcAddress}) doesn't match quote. Get a new quote with --wallet walletconnect`); exit(1); return; @@ -1027,13 +1019,37 @@ EXAMPLES: if (typeof txBase64 === 'object' && txBase64.data) { txBase64 = base58Decode(txBase64.data).toString('base64'); } - log(' Signing Solana transaction...'); - signedTransaction = signSolanaTransaction(txBase64, exported.solana.privateKey); + + if (isWalletConnect) { + // Solana via WalletConnect: convert base64 → base58 for WC protocol + log(' Signing Solana transaction via WalletConnect...'); + const txBase58 = base58Encode(Buffer.from(txBase64, 'base64')); + const wcResult = await sendSolanaTransactionViaWalletConnect(txBase58); + + if (wcResult.signedTransaction) { + signedTransaction = base58Decode(wcResult.signedTransaction).toString('base64'); + } else if (wcResult.signature) { + // Wallet returned raw Ed25519 sig → inject into unsigned tx + const sigBytes = base58Decode(wcResult.signature); + if (sigBytes.length !== 64) { + throw new Error(`Invalid Ed25519 signature length: expected 64 bytes, got ${sigBytes.length}`); + } + const txBytes = Buffer.from(txBase64, 'base64'); + const { size: sigCountSize } = readCompactU16(txBytes, 0); + sigBytes.copy(txBytes, sigCountSize); + signedTransaction = txBytes.toString('base64'); + } else { + throw new Error('WalletConnect returned neither signedTransaction nor signature'); + } + } else { + log(' Signing Solana transaction...'); + signedTransaction = signSolanaTransaction(txBase64, exported.solana.privateKey); + } requestId = currentQuote.metadata?.requestId; } else if (isWalletConnect) { // EVM via WalletConnect: wallet signs and may broadcast - const wcAddress = await getWalletConnectAddress(); + const wcAddress = await getWalletConnectAddress(chainType); const isNative = isNativeToken(currentQuote.inputMint); // Validate transaction.value (same checks as local wallet) diff --git a/src/transfer.js b/src/transfer.js index 2f6b760a..0e9cfd4e 100644 --- a/src/transfer.js +++ b/src/transfer.js @@ -597,7 +597,7 @@ export async function sendTokens({ to, amount, chain, token = null, wallet = nul if (walletconnect) { if (chain === 'solana') { - throw new Error('WalletConnect is only supported for EVM chains'); + throw new Error('WalletConnect Solana transfers are not yet supported. Use a local wallet for Solana transfers.'); } return sendTokensViaWalletConnect({ to, amount, chain, token, max, dryRun }); } diff --git a/src/walletconnect-trading.js b/src/walletconnect-trading.js index 095085eb..5110ecd6 100644 --- a/src/walletconnect-trading.js +++ b/src/walletconnect-trading.js @@ -5,21 +5,48 @@ * (hardware wallets, mobile wallets) instead of local key storage. * Uses the walletconnect CLI binary (subprocess-based, same as x402). * - * EVM only — Solana via WalletConnect is not supported. + * Supports EVM chains and Solana (trading only). */ import { wcExec } from './walletconnect-exec.js'; +const SOLANA_MAINNET_CHAIN = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + +/** + * Extract the first JSON line from walletconnect CLI output. + * The CLI may print status messages before the JSON result. + */ +function parseWcJson(output) { + const jsonLine = output.split('\n').find(line => line.startsWith('{')); + if (!jsonLine) throw new Error('No JSON output from walletconnect send-transaction'); + return JSON.parse(jsonLine); +} + /** * Get the address of the connected WalletConnect wallet. * Returns the first account address, or null if not connected / binary missing. + * + * @param {string} [chainType] - Optional: 'evm' or 'solana'. Filters accounts by chain prefix. + * No arg = first account (backward compat). */ -export async function getWalletConnectAddress() { +export async function getWalletConnectAddress(chainType) { try { const output = await wcExec('walletconnect', ['whoami', '--json'], 3000); const data = JSON.parse(output); if (data.connected === false) return null; - return data.accounts?.[0]?.address || null; + const accounts = data.accounts || []; + if (!accounts.length) return null; + + if (chainType === 'solana') { + const solAccount = accounts.find(a => a.chain?.startsWith('solana:')); + return solAccount?.address || null; + } + if (chainType === 'evm') { + const evmAccount = accounts.find(a => a.chain?.startsWith('eip155:')); + return evmAccount?.address || null; + } + // No filter — return first account address (backward compat) + return accounts[0]?.address || null; } catch { return null; } @@ -50,13 +77,8 @@ export async function sendTransactionViaWalletConnect(txData, timeoutMs = 120000 }; const output = await wcExec('walletconnect', ['send-transaction', JSON.stringify(payload)], timeoutMs); + const result = parseWcJson(output); - // walletconnect may print status messages before the JSON line — extract JSON only - const jsonLine = output.split('\n').find(line => line.startsWith('{')); - if (!jsonLine) throw new Error('No JSON output from walletconnect send-transaction'); - const result = JSON.parse(jsonLine); - - // The CLI returns { transactionHash: "0x..." } if (result.transactionHash) return { txHash: result.transactionHash }; if (result.txHash) return { txHash: result.txHash }; if (result.signedTransaction) return { signedTransaction: result.signedTransaction }; @@ -89,3 +111,30 @@ export async function sendApprovalViaWalletConnect(tokenAddress, spenderAddress, chainId, }); } + +/** + * Sign a Solana transaction via WalletConnect. + * + * The wallet signs the transaction and returns either: + * - { signedTransaction: "" } — full signed transaction + * - { signature: "" } — raw Ed25519 signature only + * + * @param {string} txBase58 - Base58-encoded Solana transaction + * @param {number} [timeoutMs=120000] - Timeout for user approval + * @returns {{ signedTransaction?: string, signature?: string }} + */ +export async function sendSolanaTransactionViaWalletConnect(txBase58, timeoutMs = 120000) { + const payload = { + transaction: txBase58, + chainId: SOLANA_MAINNET_CHAIN, + }; + + const output = await wcExec('walletconnect', ['send-transaction', JSON.stringify(payload)], timeoutMs); + const result = parseWcJson(output); + + if (result.signedTransaction) return { signedTransaction: result.signedTransaction }; + if (result.signature) return { signature: result.signature }; + if (result.transaction) return { signedTransaction: result.transaction }; + + throw new Error('Unexpected response from walletconnect Solana sign'); +} From 6b85a734cb81ea0a825114525c0a6e12be428698 Mon Sep 17 00:00:00 2001 From: Derek Rein Date: Tue, 3 Mar 2026 09:07:15 +0700 Subject: [PATCH 2/2] fix: remove unused exitCalled variable in Solana WC quote test Fixes lint error: 'exitCalled' is assigned a value but never used. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/trading.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index 4f95950b..1db7276b 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -803,10 +803,9 @@ describe('WalletConnect quote support', () => { })); const logs = []; - let exitCalled = false; const cmds = buildTradingCommands({ log: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, + exit: () => {}, }); await cmds.quote([], null, {}, {