Skip to content
Draft
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/fix-solana-relay-bridge-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nansen-cli": patch
---

Fix `trade execute` crashing on Solana-source bridge quotes from the Relay aggregator, which return raw uncompiled instructions instead of a ready-to-sign transaction. These are now compiled client-side before signing.
254 changes: 254 additions & 0 deletions src/__tests__/trading.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
toBuffer,
signLegacyTransaction,
signSolanaTransaction,
normalizeSolanaTransaction,
compileRawSolanaTransaction,
signEvmTransaction,
buildApprovalTransaction,
approvalAmountForSwap,
Expand Down Expand Up @@ -472,6 +474,180 @@ describe('signSolanaTransaction', () => {
});
});

describe('normalizeSolanaTransaction (Relay raw-instruction shape)', () => {
const FAKE_BLOCKHASH = generateSolanaWallet().address; // any valid base58 32-byte value

function stubBlockhashRpc() {
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve({
json: () => Promise.resolve({ result: { value: { blockhash: FAKE_BLOCKHASH } } }),
})));
}

afterEach(() => {
vi.unstubAllGlobals();
});

it('passes a Jupiter base64 string through unchanged, without any RPC call', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('should not be called')));
const result = await normalizeSolanaTransaction('am9zZS10ZXN0', 'https://fake-rpc');
expect(result).toBe('am9zZS10ZXN0');
});

it('normalizes an OKX {data} object, without any RPC call', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('should not be called')));
const txBytes = Buffer.concat([Buffer.from([0x01]), Buffer.alloc(64), Buffer.from('okx')]);
const result = await normalizeSolanaTransaction({ data: base58Encode(txBytes) }, 'https://fake-rpc');
expect(result).toBe(txBytes.toString('base64'));
});

it('compiles Relay\'s raw {instructions, addressLookupTableAddresses} shape into a signable, verifiably-signed transaction', async () => {
stubBlockhashRpc();

// Shape captured from a real `nansen trade quote --chain solana --to-chain base
// --aggregator relay` response: instruction accounts are keyed `keys` (not
// `accounts`), and `data` is hex-encoded (not base58/base64).
const signerWallet = generateSolanaWallet();
const otherAccount = generateSolanaWallet().address;
const programId = generateSolanaWallet().address;
const relayTransaction = {
instructions: [{
keys: [
{ pubkey: signerWallet.address, isSigner: true, isWritable: true },
{ pubkey: otherAccount, isSigner: false, isWritable: true },
],
programId,
data: 'deadbeef',
}],
addressLookupTableAddresses: ['Hm9fUgcn7qwDaiNTFiGh6pNtVATgnaRcmK6Bbx6EMZfP'],
};

const resultBase64 = await normalizeSolanaTransaction(relayTransaction, 'https://fake-rpc', async () => signerWallet.address);
const txBytes = Buffer.from(resultBase64, 'base64');

// [compact-u16 sigCount][sigCount * 64 zero bytes][message]
const { value: sigCount, size: sigCountSize } = readCompactU16(txBytes, 0);
expect(sigCount).toBe(1); // only `signerWallet` is a signer
const sigSlot = txBytes.subarray(sigCountSize, sigCountSize + 64);
expect(sigSlot.every(b => b === 0)).toBe(true); // unsigned — ready for signSolanaTransaction

const message = txBytes.subarray(sigCountSize + sigCount * 64);
expect(message[0]).toBe(0x80); // v0 message version prefix
expect(message[1]).toBe(1); // numRequiredSignatures
// feePayer (signer) is always account index 0
expect(message.subarray(4 + 1, 4 + 1 + 32).toString('hex')).toBe(base58Decode(signerWallet.address).toString('hex'));

// Sign with the ACTUAL signer's key and verify the signature validates against
// the account placed at slot 0 — not just that signing didn't throw.
const signedBase64 = signSolanaTransaction(resultBase64, signerWallet.privateKey);
const signedBytes = Buffer.from(signedBase64, 'base64');
const signature = signedBytes.subarray(sigCountSize, sigCountSize + 64);
const seed = Buffer.from(signerWallet.privateKey.slice(0, 64), 'hex');
const privKey = crypto.createPrivateKey({
key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]),
format: 'der',
type: 'pkcs8',
});
expect(crypto.verify(null, message, crypto.createPublicKey(privKey), signature)).toBe(true);
});

it('decodes 0x-prefixed instruction data the same as bare hex', async () => {
stubBlockhashRpc();
const signerWallet = generateSolanaWallet();
const programId = generateSolanaWallet().address;
const baseInstruction = { keys: [{ pubkey: signerWallet.address, isSigner: true, isWritable: true }], programId };

const bareResult = await compileRawSolanaTransaction(
{ instructions: [{ ...baseInstruction, data: 'deadbeef' }] }, 'https://fake-rpc', async () => signerWallet.address
);
const prefixedResult = await compileRawSolanaTransaction(
{ instructions: [{ ...baseInstruction, data: '0xdeadbeef' }] }, 'https://fake-rpc', async () => signerWallet.address
);
expect(prefixedResult).toBe(bareResult);
});

it('compiles an instruction with no data field instead of crashing (some instructions legitimately carry none)', async () => {
stubBlockhashRpc();
const signerWallet = generateSolanaWallet();
const programId = generateSolanaWallet().address;
const relayTransaction = {
instructions: [{ keys: [{ pubkey: signerWallet.address, isSigner: true, isWritable: true }], programId }],
};
const resultBase64 = await compileRawSolanaTransaction(relayTransaction, 'https://fake-rpc', async () => signerWallet.address);
expect(() => signSolanaTransaction(resultBase64, signerWallet.privateKey)).not.toThrow();
});

it('throws a clear error instead of compiling an oversized transaction, without fetching a blockhash', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('should not fetch a blockhash for a request that will be rejected')));
const signerWallet = generateSolanaWallet();
const programId = generateSolanaWallet().address;
// Enough distinct large instructions to blow past the 1232-byte packet limit.
const instructions = Array.from({ length: 20 }, (_, i) => ({
keys: [
{ pubkey: signerWallet.address, isSigner: true, isWritable: true },
{ pubkey: generateSolanaWallet().address, isSigner: false, isWritable: true },
],
programId,
data: Buffer.alloc(64, i).toString('hex'),
}));

await expect(compileRawSolanaTransaction({ instructions }, 'https://fake-rpc', async () => signerWallet.address))
.rejects.toThrow(/too large to compile/);
});

it('throws when no instruction carries a signer account', async () => {
const programId = generateSolanaWallet().address;
const relayTransaction = {
instructions: [{
keys: [{ pubkey: generateSolanaWallet().address, isSigner: false, isWritable: true }],
programId,
data: '00',
}],
};
await expect(normalizeSolanaTransaction(relayTransaction, 'https://fake-rpc', async () => generateSolanaWallet().address))
.rejects.toThrow(/no signer account found/);
});

it('throws when the instructions\' declared signer does not match the wallet about to sign', async () => {
const signer = generateSolanaWallet().address;
const otherWallet = generateSolanaWallet().address;
const programId = generateSolanaWallet().address;
const relayTransaction = {
instructions: [{ keys: [{ pubkey: signer, isSigner: true, isWritable: true }], programId, data: '00' }],
};
await expect(normalizeSolanaTransaction(relayTransaction, 'https://fake-rpc', async () => otherWallet))
.rejects.toThrow(/doesn't match the wallet executing this trade/);
});

it('throws when the instructions require more than one signature', async () => {
const signerWallet = generateSolanaWallet();
const secondSigner = generateSolanaWallet().address;
const programId = generateSolanaWallet().address;
const relayTransaction = {
instructions: [{
keys: [
{ pubkey: signerWallet.address, isSigner: true, isWritable: true },
{ pubkey: secondSigner, isSigner: true, isWritable: false },
],
programId,
data: '00',
}],
};
await expect(normalizeSolanaTransaction(relayTransaction, 'https://fake-rpc', async () => signerWallet.address))
.rejects.toThrow(/requires 2 signatures/);
});

it('throws a clear error instead of a raw TypeError when an instruction is missing "keys"', async () => {
const relayTransaction = { instructions: [{ programId: generateSolanaWallet().address, data: '00' }] };
await expect(normalizeSolanaTransaction(relayTransaction, 'https://fake-rpc', async () => generateSolanaWallet().address))
.rejects.toThrow(/missing its "keys"/);
});

it('throws on an unrecognized transaction shape', async () => {
await expect(normalizeSolanaTransaction({ foo: 'bar' }, 'https://fake-rpc'))
.rejects.toThrow(/Unrecognized Solana transaction format/);
});
});

// ============= EVM Transaction Signing =============

describe('signLegacyTransaction', () => {
Expand Down Expand Up @@ -3226,6 +3402,84 @@ describe('Swap target validation blocks a poisoned quote (security hardening)',
});
});

describe('Relay Solana-source bridge: raw-instruction transaction shape', () => {
it('execute compiles and signs a Relay raw {instructions} quote instead of crashing', async () => {
createWallet('default', 'testpass');
process.env.NANSEN_WALLET_PASSWORD = 'testpass';
const wallet = showWallet('default');

const executeBodies = [];
const FAKE_BLOCKHASH = generateSolanaWallet().address;
vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => {
const urlStr = typeof url === 'string' ? url : url.toString();
const body = opts?.body ? (() => { try { return JSON.parse(opts.body); } catch { return {}; } })() : {};
if (body.method === 'getLatestBlockhash') {
return Promise.resolve({ json: () => Promise.resolve({ result: { value: { blockhash: FAKE_BLOCKHASH } } }) });
}
if (urlStr.includes('trading-api') && urlStr.endsWith('/execute')) {
executeBodies.push(body);
return Promise.resolve({
ok: true,
text: () => Promise.resolve(JSON.stringify({ status: 'Success', signature: 'SolSig', chainType: 'solana', broadcaster: 'relay' })),
});
}
if (urlStr.includes('/bridge/status')) {
return Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({ status: 'DONE', receiving: { status: 'DONE', txHash: 'destTx' } })),
});
}
return Promise.resolve({ ok: true, text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: 1, result: null })) });
}));

const otherAccount = generateSolanaWallet().address;
const programId = generateSolanaWallet().address;
const quoteId = saveQuote({
success: true,
metadata: { quoteId: 'backend-relay-quote-id' },
quotes: [{
aggregator: 'relay',
inputMint: '11111111111111111111111111111111',
outputMint: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
inAmount: '1000000000',
outAmount: '180000000',
approvalAddress: '',
transaction: {
instructions: [{
keys: [
{ pubkey: wallet.solana, isSigner: true, isWritable: true },
{ pubkey: otherAccount, isSigner: false, isWritable: true },
],
programId,
data: 'deadbeef',
}],
addressLookupTableAddresses: ['Hm9fUgcn7qwDaiNTFiGh6pNtVATgnaRcmK6Bbx6EMZfP'],
},
metadata: {
requestId: 'relay-req-raw-ix',
isCrossChain: true,
bridgeTool: 'relay',
},
}],
}, 'solana', 'local', null, 'base');

const logs = [];
const cmds = buildTradingCommands({ log: (m) => logs.push(m), exit: () => {} });
try { await cmds.execute([], null, {}, { quote: quoteId }); } catch { /* bridge polling may fail in test, that's fine */ }

expect(executeBodies.length).toBeGreaterThanOrEqual(1);
// The signed transaction the API actually receives should decode to a
// non-empty (filled) signature — proof it was compiled AND signed, not
// just passed through opaquely.
const signedTx = Buffer.from(executeBodies[0].signedTransaction, 'base64');
expect(signedTx.subarray(1, 65).every(b => b === 0)).toBe(false);

delete process.env.NANSEN_WALLET_PASSWORD;
vi.unstubAllGlobals();
});
});

describe('Relay aggregator: --gasless flag dispatch', () => {
it('forwards aggregator/gasless/steps/requestId to /execute when gasless flag is set', async () => {
createWallet('default', 'testpass');
Expand Down
Loading
Loading