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/solana-walletconnect.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 68 additions & 13 deletions src/__tests__/trading.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {}, {
Expand All @@ -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();
});

Expand Down Expand Up @@ -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();
});
});

Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/transfer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
124 changes: 124 additions & 0 deletions src/__tests__/walletconnect-trading.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { execFile } from 'child_process';
import {
getWalletConnectAddress,
sendTransactionViaWalletConnect,
sendSolanaTransactionViaWalletConnect,
sendApprovalViaWalletConnect,
} from '../walletconnect-trading.js';

Expand Down Expand Up @@ -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 =============
Expand Down Expand Up @@ -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),
);
});
});
54 changes: 35 additions & 19 deletions src/trading.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =============

Expand Down Expand Up @@ -786,7 +786,7 @@ OPTIONS:
--from <symbol|address> Input token (symbol like SOL, USDC or address)
--to <symbol|address> Output token (symbol like USDC, ETH or address)
--amount <units> Amount in BASE UNITS (e.g. lamports, wei)
--wallet <name> Wallet name (default: default wallet). Use "walletconnect" or "wc" for WalletConnect (EVM only).
--wallet <name> Wallet name (default: default wallet). Use "walletconnect" or "wc" for WalletConnect.
--slippage <pct> Slippage as decimal (e.g. 0.03 for 3%). Default: 0.03
--auto-slippage Enable auto slippage calculation
--max-auto-slippage <pct> Max auto slippage when auto-slippage enabled
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/transfer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
Loading