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..1db7276b 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -782,14 +782,30 @@ 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; const cmds = buildTradingCommands({ log: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, + exit: () => {}, }); await cmds.quote([], null, {}, { @@ -800,9 +816,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 +943,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'); +}