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.
83 changes: 70 additions & 13 deletions src/__tests__/trading.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -790,14 +790,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 = [];
Comment thread
arein marked this conversation as resolved.
let exitCalled = false;
const cmds = buildTradingCommands({
log: (msg) => logs.push(msg),
exit: () => { exitCalled = true; },
exit: () => {},
});

await cmds.quote([], null, {}, {
Expand All @@ -808,9 +824,14 @@ 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);
// Should have requested the Solana address specifically
expect(wcTrading.getWalletConnectAddress).toHaveBeenCalledWith('solana');

global.fetch = originalFetch;
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -935,26 +956,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
Comment thread
arein marked this conversation as resolved.
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 @@ -526,13 +526,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
145 changes: 145 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,70 @@ 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('rejects Solana devnet/testnet accounts (mainnet only)', async () => {
mockExecFile(JSON.stringify({
connected: true,
accounts: [
{ chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', address: 'DevnetAddr123' },
{ chain: 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', address: 'TestnetAddr456' },
],
}));

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 +330,83 @@ describe('sendApprovalViaWalletConnect', () => {
expect(payload.gas).toBe('0x186a0'); // 100000 in hex
});
});

// ============= sendSolanaTransactionViaWalletConnect =============

describe('sendSolanaTransactionViaWalletConnect', () => {
Comment thread
arein marked this conversation as resolved.
Comment thread
arein marked this conversation as resolved.
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('parses multi-line JSON output from walletconnect', async () => {
const multiLineJson = 'Connecting to wallet...\n{\n "signedTransaction": "5K4Ld..."\n}';
mockExecFile(multiLineJson);

const result = await sendSolanaTransactionViaWalletConnect('3Bxs3z...');
expect(result).toEqual({ signedTransaction: '5K4Ld...' });
});

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),
);
});
});
Loading