diff --git a/.changeset/fix-solana-relay-bridge-signing.md b/.changeset/fix-solana-relay-bridge-signing.md new file mode 100644 index 00000000..1ac01d0f --- /dev/null +++ b/.changeset/fix-solana-relay-bridge-signing.md @@ -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. diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index 158e2f9f..a9f12fc9 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -43,6 +43,8 @@ import { simulateEvmCall, verifySwapOutcome, verifySolanaSwapOutcome, + compileRawSolanaTransaction, + normalizeSolanaTransaction, getBridgeStatus, pollBridgeStatus, saveTxRecord, @@ -6386,3 +6388,169 @@ describe('Solana exactOut ceiling — requires an explicit --max-input', () => { global.fetch = origFetch; }); }); + +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 inputMint = '11111111111111111111111111111111'; + const outputMint = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'; + const inAmount = '1000000000'; + const quoteId = saveQuote({ + success: true, + metadata: { quoteId: 'backend-relay-quote-id' }, + quotes: [{ + aggregator: 'relay', + inputMint, + outputMint, + inAmount, + 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', { + swapMode: 'exactIn', + request: solanaIntent({ + walletAddress: wallet.solana, + fromToken: inputMint, + toToken: outputMint, + amount: inAmount, + maxInputAmount: inAmount, + toChain: '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(); + }); + + it('rejects an instruction with malformed hex data instead of silently truncating it', async () => { + const signer = generateSolanaWallet().address; + const badQuote = (data) => ({ + instructions: [{ + keys: [{ pubkey: signer, isSigner: true, isWritable: true }], + programId: generateSolanaWallet().address, + data, + }], + }); + // Odd-length and non-hex both drop/mangle bytes under Buffer.from(_, 'hex'). + // Throws during instruction decode, before any RPC blockhash fetch. + await expect(compileRawSolanaTransaction(badQuote('abc'), 'http://unused', async () => signer)) + .rejects.toThrow(/not valid hex/); + await expect(compileRawSolanaTransaction(badQuote('deadXY'), 'http://unused', async () => signer)) + .rejects.toThrow(/not valid hex/); + }); + + it('normalize dispatches the raw-instructions shape even when a data field is also present', async () => { + const signer = generateSolanaWallet().address; + // A hypothetical future shape carrying BOTH instructions and data: the + // Relay compiler must win, not the OKX base58-decode branch. base58Decode + // would throw on this non-base58 data, so reaching it at all is the failure. + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ result: { value: { blockhash: generateSolanaWallet().address } } }), + })); + const mixed = { + data: 'not-base58-@@@', + instructions: [{ + keys: [{ pubkey: signer, isSigner: true, isWritable: true }], + programId: generateSolanaWallet().address, + data: 'deadbeef', + }], + }; + const b64 = await normalizeSolanaTransaction(mixed, 'http://unused', async () => signer); + expect(Buffer.from(b64, 'base64').length).toBeGreaterThan(0); + vi.unstubAllGlobals(); + }); + + it('rejects an instruction set that requires more than one signature', async () => { + const signer = generateSolanaWallet().address; + const coSigner = generateSolanaWallet().address; + // Two distinct declared signers: only the wallet's own single signature can + // ever be provided (slot 0), so this must fail closed before any RPC call + // rather than leave the co-signer's slot empty and fail opaquely on-chain. + const twoSigner = { + instructions: [{ + keys: [ + { pubkey: signer, isSigner: true, isWritable: true }, + { pubkey: coSigner, isSigner: true, isWritable: true }, + ], + programId: generateSolanaWallet().address, + data: 'deadbeef', + }], + }; + await expect(compileRawSolanaTransaction(twoSigner, 'http://unused', async () => signer)) + .rejects.toThrow(/requires 2 signatures/); + }); + + it('rejects a transaction too large to compile without address lookup tables', async () => { + const signer = generateSolanaWallet().address; + // A single instruction whose data alone blows past Solana's 1232-byte packet + // limit: skipping ALTs is only valid while the static tx still fits, so an + // oversized route must throw, not silently build an unsignable transaction. + const bigData = 'ab'.repeat(1300); // 1300 bytes, valid hex + const oversized = { + instructions: [{ + keys: [{ pubkey: signer, isSigner: true, isWritable: true }], + programId: generateSolanaWallet().address, + data: bigData, + }], + }; + await expect(compileRawSolanaTransaction(oversized, 'http://unused', async () => signer)) + .rejects.toThrow(/too large to compile without address-lookup-table support/); + }); +}); diff --git a/src/trading.js b/src/trading.js index a1b35b0b..8808f881 100644 --- a/src/trading.js +++ b/src/trading.js @@ -9,7 +9,8 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { base58Encode, exportWallet, getWalletConfig, showWallet, listWallets } from './wallet.js'; -import { base58Decode } from './transfer.js'; +import { base58Decode, encodeCompactU16 } from './transfer.js'; +import { buildMessageV0, fetchRecentBlockhash } from './x402-svm.js'; import { keccak256, signSecp256k1, rlpEncode } from './crypto.js'; import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js'; import { retrievePassword } from './keychain.js'; @@ -25,6 +26,8 @@ import { packageVersion, CommandError, telemetryHeaders, loadConfig } from './ap const TRADING_API_URL = process.env.NANSEN_TRADING_API_URL || 'https://trading-api.nansen.ai'; const CLIENT_USER_AGENT = `nansen-cli/${packageVersion}`; +// Solana's max transaction wire size (IPv6 MTU minus headers). +const SOLANA_MAX_TX_SIZE = 1232; const CHAIN_MAP = { solana: { index: '501', type: 'solana', chainId: 501, name: 'Solana', explorer: 'https://solscan.io/tx/', lifiChainId: '1151111081099710' }, @@ -509,6 +512,107 @@ export function signSolanaTransaction(transactionBase64, privateKeyHex) { return signedTx.toString('base64'); } +// Any valid base58 32-byte value works here — recentBlockhash is fixed-size +// regardless of its actual value, so this is exact for a size-only preflight +// and lets the signer/signature-count checks below run before the real +// blockhash fetch (no wasted RPC round trip on a request we're going to reject). +const SIZE_CHECK_BLOCKHASH = '11111111111111111111111111111111'; + +function decodeInstructionData(hex) { + if (hex == null || hex === '') return Buffer.alloc(0); // some instructions legitimately carry no data + const body = hex.startsWith('0x') ? hex.slice(2) : hex; + // Buffer.from(str, 'hex') silently drops a trailing odd nibble and stops at + // the first non-hex character, so it would decode malformed data into a + // plausible-but-wrong instruction that then gets signed. Reject instead. + if (body.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(body)) { + throw new Error(`Cannot compile Solana transaction: instruction data is not valid hex ("${hex}")`); + } + return Buffer.from(body, 'hex'); +} + +/** + * Compile a raw, uncompiled Solana transaction — {instructions, addressLookupTableAddresses} + * — into a signable base64 VersionedTransaction. Some aggregators (Relay's Solana-source + * bridge quotes) return this shape instead of a ready-to-sign serialized transaction. + * + * Every account is kept static; the address-lookup-table hint is a size optimization, + * not a correctness requirement, so skipping it is valid as long as the compiled + * transaction still fits Solana's packet limit. Full lookup-table compilation is + * unimplemented — throws instead of silently building an oversized/invalid transaction. + * + * getExpectedSigner is an async thunk resolving to the address of the wallet that is + * about to sign. The transaction only ever gets a single signature written into slot 0 + * (see signSolanaTransaction / the WalletConnect injection path), so the instructions' + * own declared signer must both be unambiguous (exactly one signer) and match that + * wallet — otherwise the transaction would silently sign the wrong account or leave a + * required signature slot empty, failing on-chain with an opaque error. + */ +export async function compileRawSolanaTransaction(transaction, rpcUrl, getExpectedSigner) { + const instructions = transaction.instructions.map(ix => { + if (!Array.isArray(ix.keys)) { + throw new Error('Cannot compile Solana transaction: instruction is missing its "keys" accounts list'); + } + return { programId: ix.programId, accounts: ix.keys, data: decodeInstructionData(ix.data) }; + }); + + const feePayer = instructions.flatMap(ix => ix.accounts).find(a => a.isSigner)?.pubkey; + if (!feePayer) { + throw new Error('Cannot compile Solana transaction: no signer account found in instructions'); + } + + const expectedSigner = await getExpectedSigner(); + if (!expectedSigner) { + throw new Error('Cannot compile Solana transaction: wallet address unavailable to verify the signer'); + } + if (feePayer !== expectedSigner) { + throw new Error( + `Solana transaction signer (${feePayer}) doesn't match the wallet executing this trade ` + + `(${expectedSigner}). Refusing to sign — get a new quote.` + ); + } + + const preflight = buildMessageV0({ feePayer, instructions, recentBlockhash: SIZE_CHECK_BLOCKHASH }); + if (preflight.numRequiredSignatures !== 1) { + throw new Error( + `Cannot compile Solana transaction: requires ${preflight.numRequiredSignatures} signatures, ` + + `but only the wallet's own signature can be provided.` + ); + } + const unsignedSize = 1 + 64 + preflight.messageBytes.length; // compact-u16(1) + 1 signature slot + if (unsignedSize > SOLANA_MAX_TX_SIZE) { + throw new Error( + `Solana transaction too large to compile without address-lookup-table support ` + + `(${unsignedSize} bytes > ${SOLANA_MAX_TX_SIZE} limit). This route needs its ` + + `address lookup tables resolved, which isn't supported yet.` + ); + } + + const recentBlockhash = await fetchRecentBlockhash(rpcUrl); + const { messageBytes } = buildMessageV0({ feePayer, instructions, recentBlockhash }); + const unsignedTx = Buffer.concat([encodeCompactU16(1), Buffer.alloc(64), messageBytes]); + return unsignedTx.toString('base64'); +} + +/** + * Normalize a Solana quote's `transaction` field to a base64-encoded, ready-to-sign + * VersionedTransaction. Three shapes seen across aggregators: Jupiter (already base64), + * OKX ({data: base58}), and Relay bridge quotes (raw uncompiled + * {instructions, addressLookupTableAddresses} — compiled client-side). + * + * getExpectedSigner (only consulted for the Relay shape) is an async thunk resolving to + * the signing wallet's address — see compileRawSolanaTransaction. + */ +export async function normalizeSolanaTransaction(transaction, rpcUrl, getExpectedSigner) { + if (typeof transaction === 'string') return transaction; // Jupiter: already base64 + // Dispatch most-specific shape first. Only Relay carries `instructions` and + // only OKX carries `data`; checking `instructions` ahead of the bare + // `data` truthiness test keeps a future Relay shape that also had a `data` + // field from being mis-routed into the OKX base58 decode. + if (Array.isArray(transaction.instructions)) return compileRawSolanaTransaction(transaction, rpcUrl, getExpectedSigner); + if (transaction.data) return base58Decode(transaction.data).toString('base64'); // OKX: base58 serialized tx + throw new Error('Unrecognized Solana transaction format in quote'); +} + /** * Sign an EVM transaction from quote data. * @@ -2311,10 +2415,6 @@ EXAMPLES: if (chainType === 'solana' && isPrivy) { // Solana via Privy: sign the serialized transaction - let txBase64 = currentQuote.transaction; - if (typeof txBase64 === 'object' && txBase64.data) { - txBase64 = base58Decode(txBase64.data).toString('base64'); - } const solWalletId = quoteData.privyWalletIds?.solana; if (!solWalletId) throw new Error('No Solana Privy wallet ID in quote'); const walletResult = await privyClient.getWallet(solWalletId); @@ -2328,6 +2428,11 @@ EXAMPLES: throw new Error('Could not resolve the Solana Privy wallet address; cannot confirm the quote was built for this wallet. Refusing to sign.'); } + // Solana: transaction is a base64 string (Jupiter), an object with a + // base58-encoded `data` field (OKX), or raw uncompiled instructions + // (Relay bridge quotes). Normalize to base64. + const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => walletAddress); + // Validate the persisted request/quote metadata (token pair, amounts, // signer) before signing the aggregator's serialized transaction. assertCompleteSolanaRequestIntent(quoteData.request); @@ -2626,15 +2731,13 @@ EXAMPLES: // below binds the metadata (token pair, amounts, signer), and // assertSolanaInstructionsSafe statically inspects the tx's own // instructions before signing. - // Solana: transaction is either a base64 string (Jupiter) or an object - // with a base58-encoded `data` field (OKX). Normalize to base64. - let txBase64 = currentQuote.transaction; - if (typeof txBase64 === 'object' && txBase64.data) { - txBase64 = base58Decode(txBase64.data).toString('base64'); - } + // Solana: transaction is a base64 string (Jupiter), an object with a + // base58-encoded `data` field (OKX), or raw uncompiled instructions + // (Relay bridge quotes). Normalize to base64. - // Resolve the signer for this sub-path so the intent-binding check - // below can confirm the quote was built for this exact wallet. + // Resolve the signer first — both the Relay-shape compiler (which needs + // an expected signer for its fee-payer check) and the intent-binding + // check below use this exact same address. let solanaWalletAddress; if (isWalletConnect) { solanaWalletAddress = await getWalletConnectAddress(chainType); @@ -2651,6 +2754,8 @@ EXAMPLES: } } + const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => solanaWalletAddress); + // Validate the persisted request/quote metadata (token pair, amounts, // signer) before signing the opaque Solana transaction. assertCompleteSolanaRequestIntent(quoteData.request); diff --git a/src/x402-svm.js b/src/x402-svm.js index 9e889022..87c5bcfe 100644 --- a/src/x402-svm.js +++ b/src/x402-svm.js @@ -32,9 +32,13 @@ export function deriveATA(ownerBase58, mintBase58, tokenProgramBase58 = TOKEN_PR /** * Build a Solana MessageV0 from accounts and instructions. - * Simplified builder for x402 payment transactions. + * feePayer is always placed at account index 0, forced signer+writable, + * regardless of whether an instruction references it directly. + * Returns numRequiredSignatures alongside the bytes since it's read back out + * of the header to size the signature-placeholder slots of the wrapping + * unsigned transaction (see callers). */ -function buildMessageV0({ feePayer, instructions, recentBlockhash, accounts: _accounts }) { +export function buildMessageV0({ feePayer, instructions, recentBlockhash, accounts: _accounts }) { // All unique accounts in order: feePayer first, then signers, then rest const accountMap = new Map(); const feePayerKey = feePayer; @@ -129,10 +133,10 @@ function buildMessageV0({ feePayer, instructions, recentBlockhash, accounts: _ac parts.push(ix.data); } - // Address table lookups (empty for our use case) + // Address table lookups (empty — all accounts referenced statically above) parts.push(encodeCompactU16(0)); - return Buffer.concat(parts); + return { messageBytes: Buffer.concat(parts), numRequiredSignatures }; } // ============= Ed25519 Signing ============= @@ -231,7 +235,7 @@ export function buildUnsignedSvmTransaction( }, ]; - const messageBytes = buildMessageV0({ + const { messageBytes } = buildMessageV0({ feePayer: feePayerStr, instructions, recentBlockhash,