From 11ac056580a802b2a149e2833e821a2eb5283d47 Mon Sep 17 00:00:00 2001 From: gulshngill Date: Tue, 6 Oct 2026 00:27:40 +0800 Subject: [PATCH 1/3] fix(bridge): compile oversized Solana bridge routes with address lookup tables Relay returns Solana-source bridge quotes as raw instructions plus a list of address-lookup-table addresses, and the CLI compiles them itself. It kept every account static, so a route that needs a source-chain swap before the bridge deposit went over the 1232-byte limit and was refused before signing. Routes that fit stay fully static, with no extra RPC call. A route that does not fit now has its lookup tables fetched with getMultipleAccounts. Each table must exist, be owned by the lookup-table program and still be active. buildMessageV0 then moves non-signer, non-program accounts into table lookups, in the account order the runtime uses to resolve them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/solana-bridge-lookup-tables.md | 5 + src/__tests__/solana-tx.test.js | 32 ++++- src/__tests__/trading.test.js | 144 +++++++++++++++++++++- src/__tests__/x402-svm.test.js | 31 +++++ src/solana-tx.js | 37 ++++++ src/trading.js | 45 +++++-- src/x402-svm.js | 139 +++++++++++++++++++-- 7 files changed, 408 insertions(+), 25 deletions(-) create mode 100644 .changeset/solana-bridge-lookup-tables.md diff --git a/.changeset/solana-bridge-lookup-tables.md b/.changeset/solana-bridge-lookup-tables.md new file mode 100644 index 00000000..eb3bf5ad --- /dev/null +++ b/.changeset/solana-bridge-lookup-tables.md @@ -0,0 +1,5 @@ +--- +"nansen-cli": patch +--- + +Solana-source bridge routes that are too large to fit in a transaction without address lookup tables (for example a token swap ahead of the bridge deposit) now compile and sign. The CLI fetches the route's lookup tables, checks that each one is active and on-chain, and uses them to compress accounts. Routes that already fit are compiled as before. diff --git a/src/__tests__/solana-tx.test.js b/src/__tests__/solana-tx.test.js index 896e7a02..3f7e9fe3 100644 --- a/src/__tests__/solana-tx.test.js +++ b/src/__tests__/solana-tx.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseTransactionMessage, resolveStaticAccount } from '../solana-tx.js'; +import { parseAddressLookupTable, parseTransactionMessage, resolveStaticAccount } from '../solana-tx.js'; import { base58Decode, base58Encode, generateSolanaWallet } from '../wallet.js'; function encodeCompactU16(value) { @@ -142,3 +142,33 @@ describe('parseTransactionMessage', () => { expect(base58Encode(base58Decode(parsed.staticAccountKeys[0]))).toBe(wallet.address); }); }); + +describe('parseAddressLookupTable', () => { + const tableData = (addresses, { typeIndex = 1, deactivationSlot = 0xffffffffffffffffn } = {}) => { + const meta = Buffer.alloc(56); + meta.writeUInt32LE(typeIndex, 0); + meta.writeBigUInt64LE(deactivationSlot, 4); + return Buffer.concat([meta, ...addresses.map((a) => base58Decode(a))]); + }; + + it('returns the addresses of an active table in order', () => { + const addresses = [generateSolanaWallet().address, generateSolanaWallet().address]; + expect(parseAddressLookupTable(tableData(addresses))).toEqual({ isActive: true, addresses }); + }); + + it('reports a deactivated table as inactive', () => { + expect(parseAddressLookupTable(tableData([], { deactivationSlot: 42n })).isActive).toBe(false); + }); + + it('rejects truncated data, a partial address and an uninitialized table', () => { + const address = generateSolanaWallet().address; + expect(() => parseAddressLookupTable(Buffer.alloc(40))).toThrow(/unexpected account data length/); + expect(() => parseAddressLookupTable(tableData([address]).subarray(0, 80))).toThrow(/unexpected account data length/); + expect(() => parseAddressLookupTable(tableData([address], { typeIndex: 0 }))).toThrow(/not an initialized lookup table/); + }); + + it('rejects a table with more addresses than a one-byte index can reach', () => { + const data = Buffer.concat([tableData([]), Buffer.alloc(257 * 32)]); + expect(() => parseAddressLookupTable(data)).toThrow(/more than 256 addresses/); + }); +}); diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index 96fc6d8e..645f29c1 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -68,6 +68,7 @@ import { exportWallet, } from '../wallet.js'; import * as wcTrading from '../walletconnect-trading.js'; +import { parseTransactionMessage } from '../solana-tx.js'; let originalHome; let tempDir; @@ -8097,11 +8098,11 @@ describe('Relay Solana-source bridge: raw-instruction transaction shape', () => .rejects.toThrow(/requires 2 signatures/); }); - it('rejects a transaction too large to compile without address lookup tables', async () => { + it('rejects a transaction too large to compile when the quote supplies no 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. + // limit: with no lookup tables to compress accounts, an oversized route must + // throw, not silently build an unsignable transaction. const bigData = 'ab'.repeat(1300); // 1300 bytes, valid hex const oversized = { instructions: [{ @@ -8111,6 +8112,141 @@ describe('Relay Solana-source bridge: raw-instruction transaction shape', () => }], }; await expect(compileRawSolanaTransaction(oversized, 'http://unused', async () => signer)) - .rejects.toThrow(/too large to compile without address-lookup-table support/); + .rejects.toThrow(/too large to compile \(\d+ bytes > 1232 limit\) and the quote supplied no address lookup tables/); + }); + + describe('address lookup tables', () => { + const ALT_PROGRAM = 'AddressLookupTab1e1111111111111111111111111'; + const U64_MAX = 0xffffffffffffffffn; + const randomPubkey = () => base58Encode(crypto.randomBytes(32)); + + function lookupTableAccount(addresses, { deactivationSlot = U64_MAX, owner = ALT_PROGRAM } = {}) { + const meta = Buffer.alloc(56); + meta.writeUInt32LE(1, 0); + meta.writeBigUInt64LE(deactivationSlot, 4); + const data = Buffer.concat([meta, ...addresses.map((a) => base58Decode(a))]); + return { owner, lamports: 1, executable: false, data: [data.toString('base64'), 'base64'] }; + } + + // Serves getMultipleAccounts from `accounts` (address → account, missing → null) + // and getLatestBlockhash from `blockhash`; records every request body. + function stubSolanaRpc(accounts, blockhash = randomPubkey()) { + const requests = []; + vi.stubGlobal('fetch', vi.fn().mockImplementation((_url, opts) => { + const body = JSON.parse(opts.body); + requests.push(body); + const result = body.method === 'getMultipleAccounts' + ? { value: body.params[0].map((a) => accounts[a] ?? null) } + : { value: { blockhash } }; + return Promise.resolve({ ok: true, json: () => Promise.resolve({ result }) }); + })); + return requests; + } + + // A swap-then-bridge shaped route: one signer, one program, and enough + // non-signer accounts that keeping them all static overflows 1232 bytes. + function largeRoute(signer, { writable, readonly, tables }) { + return { + instructions: [{ + keys: [ + { pubkey: signer, isSigner: true, isWritable: true }, + ...writable.map((pubkey) => ({ pubkey, isSigner: false, isWritable: true })), + ...readonly.map((pubkey) => ({ pubkey, isSigner: false, isWritable: false })), + ], + programId: tables.programId, + data: 'deadbeef', + }], + addressLookupTableAddresses: tables.addresses, + }; + } + + it('compresses a too-large route through its tables, resolving every account back to the original', async () => { + const signer = generateSolanaWallet().address; + const programId = randomPubkey(); + const writable = Array.from({ length: 20 }, randomPubkey); + const readonly = Array.from({ length: 20 }, randomPubkey); + const tableA = randomPubkey(); + const tableB = randomPubkey(); + // Table A: the signer and program (must stay static), all writable accounts + // and the first five readonly ones. Table B: every readonly account, so + // the five shared with A must be loaded from A only. + const tableAEntries = [signer, programId, ...writable, ...readonly.slice(0, 5)]; + const tableBEntries = [...readonly].reverse(); + const requests = stubSolanaRpc({ + [tableA]: lookupTableAccount(tableAEntries), + [tableB]: lookupTableAccount(tableBEntries), + }); + const route = largeRoute(signer, { writable, readonly, tables: { programId, addresses: [tableA, tableB] } }); + + const txBase64 = await compileRawSolanaTransaction(route, 'http://unused', async () => signer); + expect(Buffer.from(txBase64, 'base64').length).toBeLessThanOrEqual(1232); + + const parsed = parseTransactionMessage(txBase64); + expect(parsed.staticAccountKeys).toEqual([signer, programId]); + expect(parsed.header).toEqual({ numRequiredSignatures: 1, numReadonlySignedAccounts: 0, numReadonlyUnsignedAccounts: 1 }); + const [lookupA, lookupB] = parsed.addressTableLookups; + expect(lookupA.lookupTableAddress).toBe(tableA); + expect(lookupB.lookupTableAddress).toBe(tableB); + expect(lookupA.writableIndexes.map((i) => tableAEntries[i])).toEqual(writable); + expect(lookupA.readonlyIndexes.map((i) => tableAEntries[i])).toEqual(readonly.slice(0, 5)); + expect(lookupB.writableIndexes).toEqual([]); + expect(lookupB.readonlyIndexes.map((i) => tableBEntries[i]).sort()).toEqual(readonly.slice(5).sort()); + + // The runtime's account order: static keys, then each table's writable + // loads, then each table's readonly loads. + const resolved = [ + ...parsed.staticAccountKeys, + ...lookupA.writableIndexes.map((i) => tableAEntries[i]), + ...lookupB.writableIndexes.map((i) => tableBEntries[i]), + ...lookupA.readonlyIndexes.map((i) => tableAEntries[i]), + ...lookupB.readonlyIndexes.map((i) => tableBEntries[i]), + ]; + const [ix] = parsed.instructions; + expect(resolved[ix.programIdIndex]).toBe(programId); + expect(ix.accountIndexes.map((i) => resolved[i])).toEqual([signer, ...writable, ...readonly]); + + const tableRequest = requests.find((r) => r.method === 'getMultipleAccounts'); + expect(tableRequest.params).toEqual([[tableA, tableB], { encoding: 'base64', commitment: 'confirmed' }]); + }); + + it('keeps a route that already fits fully static and never fetches its tables', async () => { + const signer = generateSolanaWallet().address; + const other = randomPubkey(); + const table = randomPubkey(); + const requests = stubSolanaRpc({ [table]: lookupTableAccount([other]) }); + const route = largeRoute(signer, { writable: [other], readonly: [], tables: { programId: randomPubkey(), addresses: [table] } }); + + const parsed = parseTransactionMessage(await compileRawSolanaTransaction(route, 'http://unused', async () => signer)); + expect(parsed.addressTableLookups).toEqual([]); + expect(parsed.staticAccountKeys).toContain(other); + expect(requests.map((r) => r.method)).toEqual(['getLatestBlockhash']); + }); + + it.each([ + ['missing', () => undefined, /not found on-chain/], + ['not owned by the lookup-table program', (entries) => lookupTableAccount(entries, { owner: randomPubkey() }), /is not an address lookup table/], + ['deactivated', (entries) => lookupTableAccount(entries, { deactivationSlot: 123n }), /has been deactivated/], + ])('refuses a table that is %s, before fetching a blockhash', async (_label, makeAccount, error) => { + const signer = generateSolanaWallet().address; + const writable = Array.from({ length: 40 }, randomPubkey); + const table = randomPubkey(); + const requests = stubSolanaRpc({ [table]: makeAccount(writable) }); + const route = largeRoute(signer, { writable, readonly: [], tables: { programId: randomPubkey(), addresses: [table] } }); + + await expect(compileRawSolanaTransaction(route, 'http://unused', async () => signer)).rejects.toThrow(error); + expect(requests.map((r) => r.method)).toEqual(['getMultipleAccounts']); + }); + + it('rejects a route that is still too large after compression', async () => { + const signer = generateSolanaWallet().address; + const writable = Array.from({ length: 40 }, randomPubkey); + const table = randomPubkey(); + // The table holds none of the route's accounts, so nothing compresses. + stubSolanaRpc({ [table]: lookupTableAccount([randomPubkey()]) }); + const route = largeRoute(signer, { writable, readonly: [], tables: { programId: randomPubkey(), addresses: [table] } }); + + await expect(compileRawSolanaTransaction(route, 'http://unused', async () => signer)) + .rejects.toThrow(/too large to compile \(\d+ bytes > 1232 limit\) even with its 1 address lookup table/); + }); }); }); diff --git a/src/__tests__/x402-svm.test.js b/src/__tests__/x402-svm.test.js index aefc9ed5..bad7bb71 100644 --- a/src/__tests__/x402-svm.test.js +++ b/src/__tests__/x402-svm.test.js @@ -12,6 +12,7 @@ import { buildUnsignedSvmTransaction, createSvmPaymentPayload, fetchRecentBlockhash, + fetchAddressLookupTables, } from '../x402-svm.js'; import { CHAIN_RPCS } from '../rpc-urls.js'; @@ -339,3 +340,33 @@ describe('fetchRecentBlockhash', () => { expect(caught.message).not.toMatch(/did not respond/); }); }); + +describe('fetchAddressLookupTables', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const rpcReturns = (body, { ok = true, status = 200 } = {}) => + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok, status, json: async () => body })); + const tableAddress = () => base58Encode(crypto.randomBytes(32)); + + it('rejects an invalid RPC URL without echoing it', async () => { + await expect(fetchAddressLookupTables('not-a-url?api-key=secret', [tableAddress()])) + .rejects.toThrow(/^Invalid Solana RPC URL: expected a full http/); + }); + + it('surfaces HTTP and JSON-RPC errors as actionable failures', async () => { + rpcReturns({}, { ok: false, status: 503 }); + await expect(fetchAddressLookupTables('http://unused', [tableAddress()])) + .rejects.toThrow(/Solana RPC returned HTTP 503 while fetching address lookup tables/); + rpcReturns({ error: { code: 429, message: 'rate limited' } }); + await expect(fetchAddressLookupTables('http://unused', [tableAddress()])) + .rejects.toThrow(/Solana RPC failed \(rate limited\) while fetching address lookup tables/); + }); + + it('rejects an account list that does not line up with the requested tables', async () => { + rpcReturns({ result: { value: [] } }); + await expect(fetchAddressLookupTables('http://unused', [tableAddress()])) + .rejects.toThrow(/returned an unexpected account list/); + }); +}); diff --git a/src/solana-tx.js b/src/solana-tx.js index ca982a4e..18a6b92a 100644 --- a/src/solana-tx.js +++ b/src/solana-tx.js @@ -151,3 +151,40 @@ export function resolveStaticAccount(parsed, index) { if (index < parsed.staticAccountKeys.length) return parsed.staticAccountKeys[index]; return null; } + +// Owner of every address-lookup-table account. +export const ADDRESS_LOOKUP_TABLE_PROGRAM = 'AddressLookupTab1e1111111111111111111111111'; +// LookupTableMeta: typeIndex u32, deactivationSlot u64, lastExtendedSlot u64, +// lastExtendedSlotStartIndex u8, authority Option, padding. The +// 32-byte addresses start right after it. +const LOOKUP_TABLE_META_SIZE = 56; +const LOOKUP_TABLE_TYPE_INDEX = 1; +// Lookups index a table with a single byte. +const LOOKUP_TABLE_MAX_ADDRESSES = 256; +// A table that has never been deactivated stores u64::MAX here. +const LOOKUP_TABLE_ACTIVE_SLOT = 0xffffffffffffffffn; + +/** + * Parse the raw data of an address-lookup-table account into its addresses. + * Throws on anything that isn't an initialized table, so a truncated or + * foreign account can't be read as a short or shifted address list. + */ +export function parseAddressLookupTable(data) { + if (data.length < LOOKUP_TABLE_META_SIZE || (data.length - LOOKUP_TABLE_META_SIZE) % 32 !== 0) { + throw new Error('Malformed address lookup table: unexpected account data length'); + } + if ((data.length - LOOKUP_TABLE_META_SIZE) / 32 > LOOKUP_TABLE_MAX_ADDRESSES) { + throw new Error(`Malformed address lookup table: more than ${LOOKUP_TABLE_MAX_ADDRESSES} addresses`); + } + if (data.readUInt32LE(0) !== LOOKUP_TABLE_TYPE_INDEX) { + throw new Error('Malformed address lookup table: account is not an initialized lookup table'); + } + const addresses = []; + for (let offset = LOOKUP_TABLE_META_SIZE; offset < data.length; offset += 32) { + addresses.push(base58Encode(data.subarray(offset, offset + 32))); + } + return { + isActive: data.readBigUInt64LE(4) === LOOKUP_TABLE_ACTIVE_SLOT, + addresses, + }; +} diff --git a/src/trading.js b/src/trading.js index 18b17501..8dae29cc 100644 --- a/src/trading.js +++ b/src/trading.js @@ -11,7 +11,7 @@ import fs from 'fs'; import path from 'path'; import { base58Encode, exportWallet, getWalletConfig, showWallet, listWallets } from './wallet.js'; import { base58Decode, encodeCompactU16 } from './transfer.js'; -import { buildMessageV0, fetchRecentBlockhash } from './x402-svm.js'; +import { buildMessageV0, fetchAddressLookupTables, 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'; @@ -749,10 +749,13 @@ function decodeInstructionData(hex) { * — 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. + * Accounts stay static whenever the transaction fits Solana's packet limit that way, so + * the common case needs no extra RPC call and the safety checks see every account. Only + * a route too large to fit (e.g. a source-chain swap ahead of the bridge deposit) has its + * addressLookupTableAddresses fetched and used to compress accounts. A table only ever + * replaces an account with a reference to that same address, and lookup-table entries + * are immutable once written, so compression doesn't change what the instructions touch. + * Throws instead of 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 @@ -785,6 +788,7 @@ export async function compileRawSolanaTransaction(transaction, rpcUrl, getExpect ); } + const unsignedSize = (message) => 1 + 64 + message.messageBytes.length; // compact-u16(1) + 1 signature slot const preflight = buildMessageV0({ feePayer, instructions, recentBlockhash: SIZE_CHECK_BLOCKHASH }); if (preflight.numRequiredSignatures !== 1) { throw new Error( @@ -792,17 +796,32 @@ export async function compileRawSolanaTransaction(transaction, rpcUrl, getExpect `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.` - ); + + let addressLookupTables = []; + let size = unsignedSize(preflight); + if (size > SOLANA_MAX_TX_SIZE) { + const hint = transaction.addressLookupTableAddresses; + const tableAddresses = [...new Set(Array.isArray(hint) ? hint : [])]; + if (tableAddresses.length === 0) { + throw new Error( + `Solana transaction too large to compile (${size} bytes > ${SOLANA_MAX_TX_SIZE} limit) and ` + + `the quote supplied no address lookup tables to compress it. Get a new quote.` + ); + } + addressLookupTables = await fetchAddressLookupTables(rpcUrl, tableAddresses); + size = unsignedSize(buildMessageV0({ + feePayer, instructions, recentBlockhash: SIZE_CHECK_BLOCKHASH, addressLookupTables, + })); + if (size > SOLANA_MAX_TX_SIZE) { + throw new Error( + `Solana transaction too large to compile (${size} bytes > ${SOLANA_MAX_TX_SIZE} limit) even ` + + `with its ${tableAddresses.length} address lookup table(s). Get a new quote.` + ); + } } const recentBlockhash = await fetchRecentBlockhash(rpcUrl); - const { messageBytes } = buildMessageV0({ feePayer, instructions, recentBlockhash }); + const { messageBytes } = buildMessageV0({ feePayer, instructions, recentBlockhash, addressLookupTables }); const unsignedTx = Buffer.concat([encodeCompactU16(1), Buffer.alloc(64), messageBytes]); return unsignedTx.toString('base64'); } diff --git a/src/x402-svm.js b/src/x402-svm.js index 62aa0f5a..54e71760 100644 --- a/src/x402-svm.js +++ b/src/x402-svm.js @@ -9,6 +9,7 @@ import { encodeCompactU16, deriveATA as _deriveATA } from './transfer.js'; import { resolvePaymentAmount, resolvePayTo } from './x402-policy.js'; import { SOLANA_MAINNET_NETWORK } from './x402-tokens.js'; import { CHAIN_RPCS } from './rpc-urls.js'; +import { ADDRESS_LOOKUP_TABLE_PROGRAM, parseAddressLookupTable } from './solana-tx.js'; // ============= Constants ============= @@ -50,8 +51,13 @@ export function deriveATA(ownerBase58, mintBase58, tokenProgramBase58 = TOKEN_PR * Returns numRequiredSignatures alongside the bytes. Each caller writes a * fixed number of signature slots and asserts this value against it, since a * header that disagrees with the slot count is rejected at broadcast. + * + * addressLookupTables ([{ key, addresses }], resolved on-chain by the caller) + * moves accounts out of the static key list into table lookups. Only accounts + * that are neither signers nor invoked as a program are eligible — the runtime + * requires both to be static keys — so the fee payer always stays static. */ -export function buildMessageV0({ feePayer, instructions, recentBlockhash, accounts: _accounts }) { +export function buildMessageV0({ feePayer, instructions, recentBlockhash, addressLookupTables = [] }) { // All unique accounts in order: feePayer first, then signers, then rest const accountMap = new Map(); const feePayerKey = feePayer; @@ -75,10 +81,37 @@ export function buildMessageV0({ feePayer, instructions, recentBlockhash, accoun } } + // Pull lookup-eligible accounts out of the static list, table by table. An + // account found in more than one table is loaded from the first. + const invokedPrograms = new Set(instructions.map(ix => ix.programId)); + const lookups = []; + const loadedWritable = []; + const loadedReadonly = []; + const loaded = new Set(); + for (const table of addressLookupTables) { + const writableIndexes = []; + const readonlyIndexes = []; + table.addresses.forEach((address, index) => { + const meta = accountMap.get(address); + if (!meta || meta.isSigner || invokedPrograms.has(address) || loaded.has(address)) return; + loaded.add(address); + if (meta.isWritable) { + writableIndexes.push(index); + loadedWritable.push(address); + } else { + readonlyIndexes.push(index); + loadedReadonly.push(address); + } + }); + if (writableIndexes.length || readonlyIndexes.length) { + lookups.push({ key: table.key, writableIndexes, readonlyIndexes }); + } + } + // Sort: signers+writable, signers+readonly, non-signer+writable, non-signer+readonly // feePayer always at index 0 const sortedKeys = [feePayerKey]; - const rest = [...accountMap.entries()].filter(([k]) => k !== feePayerKey); + const rest = [...accountMap.entries()].filter(([k]) => k !== feePayerKey && !loaded.has(k)); // Signer+writable for (const [k, v] of rest) if (v.isSigner && v.isWritable) sortedKeys.push(k); @@ -104,9 +137,15 @@ export function buildMessageV0({ feePayer, instructions, recentBlockhash, accoun } } - // Build the account keys index + // Build the account keys index: static keys, then every table's writable + // loads, then every table's readonly loads — the order the runtime resolves. + const allKeys = [...sortedKeys, ...loadedWritable, ...loadedReadonly]; + // Instruction account indexes are single bytes. + if (allKeys.length > 256) { + throw new Error(`Solana transaction references ${allKeys.length} accounts; a message can index at most 256.`); + } const keyIndex = new Map(); - sortedKeys.forEach((k, i) => keyIndex.set(k, i)); + allKeys.forEach((k, i) => keyIndex.set(k, i)); // Compile instructions const compiledInstructions = instructions.map(ix => { @@ -146,8 +185,15 @@ export function buildMessageV0({ feePayer, instructions, recentBlockhash, accoun parts.push(ix.data); } - // Address table lookups (empty — all accounts referenced statically above) - parts.push(encodeCompactU16(0)); + // Address table lookups + parts.push(encodeCompactU16(lookups.length)); + for (const lookup of lookups) { + parts.push(base58DecodePubkey(lookup.key)); + parts.push(encodeCompactU16(lookup.writableIndexes.length)); + parts.push(Buffer.from(lookup.writableIndexes)); + parts.push(encodeCompactU16(lookup.readonlyIndexes.length)); + parts.push(Buffer.from(lookup.readonlyIndexes)); + } return { messageBytes: Buffer.concat(parts), numRequiredSignatures }; } @@ -252,7 +298,6 @@ export function buildUnsignedSvmTransaction( feePayer: feePayerStr, instructions, recentBlockhash, - accounts: null, }); // The header must agree with the signature slots written below. A // server-supplied feePayer equal to the paying wallet collapses the two @@ -423,6 +468,86 @@ export async function fetchRecentBlockhash(rpcUrl = CHAIN_RPCS.solana) { } } +/** + * Fetch and parse address-lookup-table accounts, in the order given. + * + * Throws unless every address is an active table owned by the lookup-table + * program: a quote that names a missing, foreign or deactivated table can't be + * compiled into a transaction the runtime will accept. + */ +export async function fetchAddressLookupTables(rpcUrl, addresses) { + if (!isHttpUrl(rpcUrl)) { + throw new Error( + 'Invalid Solana RPC URL: expected a full http:// or https:// URL. Check NANSEN_SOLANA_RPC.' + ); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), SOLANA_RPC_TIMEOUT_MS); + const rpcError = (detail, cause) => new Error( + `Solana RPC ${detail} while fetching address lookup tables. Retry or configure a different RPC endpoint.`, + cause ? { cause } : undefined + ); + + const timeoutError = (cause) => rpcError(`did not respond within ${SOLANA_RPC_TIMEOUT_MS / 1000}s`, cause); + + let data; + try { + let response; + try { + response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getMultipleAccounts', + params: [addresses, { encoding: 'base64', commitment: 'confirmed' }], + }), + signal: controller.signal, + }); + } catch (err) { + if (controller.signal.aborted) throw timeoutError(err); + throw rpcError('was unavailable', err); + } + if (!response.ok) throw rpcError(`returned HTTP ${response.status}`); + try { + data = await response.json(); + } catch (err) { + if (controller.signal.aborted) throw timeoutError(err); + throw rpcError('returned an invalid response', err); + } + } finally { + clearTimeout(timer); + } + + if (data?.error) { + throw rpcError(`failed (${String(data.error.message ?? data.error.code ?? 'unknown RPC error')})`); + } + const accounts = data?.result?.value; + // Each table is matched to its address by position, so a short or padded + // array would pair a table with the wrong address. + if (!Array.isArray(accounts) || accounts.length !== addresses.length) { + throw rpcError('returned an unexpected account list'); + } + + return accounts.map((account, i) => { + const key = addresses[i]; + if (!account) { + throw new Error(`Address lookup table ${key} not found on-chain. Get a new quote.`); + } + if (account.owner !== ADDRESS_LOOKUP_TABLE_PROGRAM) { + throw new Error(`Account ${key} is not an address lookup table. Get a new quote.`); + } + const raw = Array.isArray(account.data) ? account.data[0] : account.data; + const table = parseAddressLookupTable(Buffer.from(raw ?? '', 'base64')); + if (!table.isActive) { + throw new Error(`Address lookup table ${key} has been deactivated. Get a new quote.`); + } + return { key, addresses: table.addresses }; + }); +} + /** * Get RPC URL for a Solana network identifier. */ From 6353b23edf48d61ef53b583cb6993ca12ed720fc Mon Sep 17 00:00:00 2001 From: gulshngill Date: Tue, 6 Oct 2026 01:10:08 +0800 Subject: [PATCH 2/3] fix(bridge): keep using lookup tables that are only deactivating Deactivating a lookup table doesn't disable it at once. The runtime keeps resolving it while its deactivation slot is the current slot or is still in the SlotHashes sysvar, and only then treats it as deactivated. The compiler refused every table whose deactivation slot wasn't u64::MAX, so it rejected quotes whose tables were still usable. Mirror the runtime's status rule instead. When a table has a deactivation slot, read the SlotHashes sysvar and the slot it was read at. A deactivation slot after that slot also counts as usable, because the two reads can come from nodes at different heights. Never-deactivated tables still need no extra RPC call. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/__tests__/solana-tx.test.js | 51 +++++++++++++++++++++--- src/__tests__/trading.test.js | 43 ++++++++++++++++++-- src/__tests__/x402-svm.test.js | 12 ++++++ src/solana-tx.js | 44 +++++++++++++++++++-- src/x402-svm.js | 69 ++++++++++++++++++++++++--------- 5 files changed, 189 insertions(+), 30 deletions(-) diff --git a/src/__tests__/solana-tx.test.js b/src/__tests__/solana-tx.test.js index 3f7e9fe3..a2947c1b 100644 --- a/src/__tests__/solana-tx.test.js +++ b/src/__tests__/solana-tx.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseAddressLookupTable, parseTransactionMessage, resolveStaticAccount } from '../solana-tx.js'; +import { isLookupTableUsable, parseAddressLookupTable, parseSlotHashes, parseTransactionMessage, resolveStaticAccount } from '../solana-tx.js'; import { base58Decode, base58Encode, generateSolanaWallet } from '../wallet.js'; function encodeCompactU16(value) { @@ -151,13 +151,13 @@ describe('parseAddressLookupTable', () => { return Buffer.concat([meta, ...addresses.map((a) => base58Decode(a))]); }; - it('returns the addresses of an active table in order', () => { + it('returns the addresses of a never-deactivated table in order', () => { const addresses = [generateSolanaWallet().address, generateSolanaWallet().address]; - expect(parseAddressLookupTable(tableData(addresses))).toEqual({ isActive: true, addresses }); + expect(parseAddressLookupTable(tableData(addresses))).toEqual({ deactivationSlot: null, addresses }); }); - it('reports a deactivated table as inactive', () => { - expect(parseAddressLookupTable(tableData([], { deactivationSlot: 42n })).isActive).toBe(false); + it('returns the deactivation slot of a deactivated table', () => { + expect(parseAddressLookupTable(tableData([], { deactivationSlot: 42n })).deactivationSlot).toBe(42n); }); it('rejects truncated data, a partial address and an uninitialized table', () => { @@ -172,3 +172,44 @@ describe('parseAddressLookupTable', () => { expect(() => parseAddressLookupTable(data)).toThrow(/more than 256 addresses/); }); }); + +describe('parseSlotHashes', () => { + const slotHashesData = (slots) => { + const data = Buffer.alloc(8 + slots.length * 40); + data.writeBigUInt64LE(BigInt(slots.length), 0); + slots.forEach((slot, i) => data.writeBigUInt64LE(slot, 8 + i * 40)); + return data; + }; + + it('returns the slots in order', () => { + expect(parseSlotHashes(slotHashesData([100n, 99n, 97n]))).toEqual([100n, 99n, 97n]); + }); + + it('rejects data shorter than its declared count', () => { + expect(() => parseSlotHashes(slotHashesData([100n, 99n]).subarray(0, 60))).toThrow(/unexpected account data length/); + expect(() => parseSlotHashes(Buffer.alloc(4))).toThrow(/unexpected account data length/); + }); +}); + +describe('isLookupTableUsable', () => { + const slotHashes = [999n, 998n, 996n]; + + it('treats a never-deactivated table as usable', () => { + expect(isLookupTableUsable(null, 1000n, [])).toBe(true); + }); + + it('treats a table deactivating in the current slot or still in SlotHashes as usable', () => { + expect(isLookupTableUsable(1000n, 1000n, slotHashes)).toBe(true); + expect(isLookupTableUsable(996n, 1000n, slotHashes)).toBe(true); + }); + + it('treats a deactivation slot past the observed slot as usable', () => { + expect(isLookupTableUsable(1005n, 1000n, slotHashes)).toBe(true); + }); + + it('treats a table whose deactivation slot has left SlotHashes as deactivated', () => { + expect(isLookupTableUsable(400n, 1000n, slotHashes)).toBe(false); + // A skipped slot is never in SlotHashes. + expect(isLookupTableUsable(997n, 1000n, slotHashes)).toBe(false); + }); +}); diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index 645f29c1..847c7159 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -8128,15 +8128,26 @@ describe('Relay Solana-source bridge: raw-instruction transaction shape', () => return { owner, lamports: 1, executable: false, data: [data.toString('base64'), 'base64'] }; } + const CURRENT_SLOT = 1000; + // SlotHashes as the RPC sees it at CURRENT_SLOT: recent slots, with 997 skipped. + function slotHashesAccount(slots = [999n, 998n, 996n]) { + const data = Buffer.alloc(8 + slots.length * 40); + data.writeBigUInt64LE(BigInt(slots.length), 0); + slots.forEach((slot, i) => data.writeBigUInt64LE(slot, 8 + i * 40)); + return { owner: 'Sysvar1111111111111111111111111111111111111', lamports: 1, executable: false, data: [data.toString('base64'), 'base64'] }; + } + // Serves getMultipleAccounts from `accounts` (address → account, missing → null) - // and getLatestBlockhash from `blockhash`; records every request body. + // plus the SlotHashes sysvar, and getLatestBlockhash from `blockhash`; + // records every request body. function stubSolanaRpc(accounts, blockhash = randomPubkey()) { const requests = []; + const all = { SysvarS1otHashes111111111111111111111111111: slotHashesAccount(), ...accounts }; vi.stubGlobal('fetch', vi.fn().mockImplementation((_url, opts) => { const body = JSON.parse(opts.body); requests.push(body); const result = body.method === 'getMultipleAccounts' - ? { value: body.params[0].map((a) => accounts[a] ?? null) } + ? { context: { slot: CURRENT_SLOT }, value: body.params[0].map((a) => all[a] ?? null) } : { value: { blockhash } }; return Promise.resolve({ ok: true, json: () => Promise.resolve({ result }) }); })); @@ -8234,7 +8245,33 @@ describe('Relay Solana-source bridge: raw-instruction transaction shape', () => const route = largeRoute(signer, { writable, readonly: [], tables: { programId: randomPubkey(), addresses: [table] } }); await expect(compileRawSolanaTransaction(route, 'http://unused', async () => signer)).rejects.toThrow(error); - expect(requests.map((r) => r.method)).toEqual(['getMultipleAccounts']); + expect(requests.map((r) => r.method)).not.toContain('getLatestBlockhash'); + }); + + it.each([ + ['deactivated in the current slot', 1000n], + ['still in SlotHashes', 996n], + ])('uses a deactivating table that is %s', async (_label, deactivationSlot) => { + const signer = generateSolanaWallet().address; + const writable = Array.from({ length: 40 }, randomPubkey); + const table = randomPubkey(); + const requests = stubSolanaRpc({ [table]: lookupTableAccount(writable, { deactivationSlot }) }); + const route = largeRoute(signer, { writable, readonly: [], tables: { programId: randomPubkey(), addresses: [table] } }); + + const parsed = parseTransactionMessage(await compileRawSolanaTransaction(route, 'http://unused', async () => signer)); + expect(parsed.addressTableLookups.map((l) => l.lookupTableAddress)).toEqual([table]); + expect(requests[1].params[0]).toEqual(['SysvarS1otHashes111111111111111111111111111']); + }); + + it('only reads SlotHashes when a table has been deactivated', async () => { + const signer = generateSolanaWallet().address; + const writable = Array.from({ length: 40 }, randomPubkey); + const table = randomPubkey(); + const requests = stubSolanaRpc({ [table]: lookupTableAccount(writable) }); + const route = largeRoute(signer, { writable, readonly: [], tables: { programId: randomPubkey(), addresses: [table] } }); + + await compileRawSolanaTransaction(route, 'http://unused', async () => signer); + expect(requests.map((r) => r.method)).toEqual(['getMultipleAccounts', 'getLatestBlockhash']); }); it('rejects a route that is still too large after compression', async () => { diff --git a/src/__tests__/x402-svm.test.js b/src/__tests__/x402-svm.test.js index bad7bb71..032ab7ef 100644 --- a/src/__tests__/x402-svm.test.js +++ b/src/__tests__/x402-svm.test.js @@ -364,6 +364,18 @@ describe('fetchAddressLookupTables', () => { .rejects.toThrow(/Solana RPC failed \(rate limited\) while fetching address lookup tables/); }); + it('refuses a deactivated table when the RPC returns no SlotHashes sysvar to check it against', async () => { + const meta = Buffer.alloc(56); + meta.writeUInt32LE(1, 0); + meta.writeBigUInt64LE(500n, 4); + const table = { owner: 'AddressLookupTab1e1111111111111111111111111', data: [meta.toString('base64'), 'base64'] }; + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ result: { context: { slot: 1000 }, value: [table] } }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ result: { context: { slot: 1000 }, value: [null] } }) })); + await expect(fetchAddressLookupTables('http://unused', [tableAddress()])) + .rejects.toThrow(/no usable SlotHashes sysvar/); + }); + it('rejects an account list that does not line up with the requested tables', async () => { rpcReturns({ result: { value: [] } }); await expect(fetchAddressLookupTables('http://unused', [tableAddress()])) diff --git a/src/solana-tx.js b/src/solana-tx.js index 18a6b92a..bca6a48f 100644 --- a/src/solana-tx.js +++ b/src/solana-tx.js @@ -163,11 +163,15 @@ const LOOKUP_TABLE_TYPE_INDEX = 1; const LOOKUP_TABLE_MAX_ADDRESSES = 256; // A table that has never been deactivated stores u64::MAX here. const LOOKUP_TABLE_ACTIVE_SLOT = 0xffffffffffffffffn; +// The SlotHashes sysvar: the recent slots a deactivating table stays usable for. +export const SLOT_HASHES_SYSVAR = 'SysvarS1otHashes111111111111111111111111111'; +export const SYSVAR_PROGRAM = 'Sysvar1111111111111111111111111111111111111'; /** - * Parse the raw data of an address-lookup-table account into its addresses. - * Throws on anything that isn't an initialized table, so a truncated or - * foreign account can't be read as a short or shifted address list. + * Parse the raw data of an address-lookup-table account into its addresses and + * its deactivation slot (null when it has never been deactivated). Throws on + * anything that isn't an initialized table, so a truncated or foreign account + * can't be read as a short or shifted address list. */ export function parseAddressLookupTable(data) { if (data.length < LOOKUP_TABLE_META_SIZE || (data.length - LOOKUP_TABLE_META_SIZE) % 32 !== 0) { @@ -183,8 +187,40 @@ export function parseAddressLookupTable(data) { for (let offset = LOOKUP_TABLE_META_SIZE; offset < data.length; offset += 32) { addresses.push(base58Encode(data.subarray(offset, offset + 32))); } + const deactivationSlot = data.readBigUInt64LE(4); return { - isActive: data.readBigUInt64LE(4) === LOOKUP_TABLE_ACTIVE_SLOT, + deactivationSlot: deactivationSlot === LOOKUP_TABLE_ACTIVE_SLOT ? null : deactivationSlot, addresses, }; } + +/** + * Parse the SlotHashes sysvar (u64 count, then count × (u64 slot, 32-byte + * hash)) into its slots. + */ +export function parseSlotHashes(data) { + if (data.length < 8) throw new Error('Malformed SlotHashes sysvar: unexpected account data length'); + const count = data.readBigUInt64LE(0); + if (8n + count * 40n > BigInt(data.length)) { + throw new Error('Malformed SlotHashes sysvar: unexpected account data length'); + } + const slots = []; + for (let i = 0; i < Number(count); i++) slots.push(data.readBigUInt64LE(8 + i * 40)); + return slots; +} + +/** + * Whether the runtime still resolves addresses through a table, mirroring its + * LookupTableMeta status rule. Deactivating a table doesn't disable it at + * once: it stays usable while its deactivation slot is the current slot or is + * still in SlotHashes, and only then becomes deactivated. + * + * A deactivation slot past `currentSlot` also counts as usable. Our two RPC + * reads can come from nodes at different heights, and a slot we haven't seen + * yet can't have left SlotHashes. + */ +export function isLookupTableUsable(deactivationSlot, currentSlot, slotHashes) { + if (deactivationSlot === null) return true; + if (deactivationSlot >= currentSlot) return true; + return slotHashes.includes(deactivationSlot); +} diff --git a/src/x402-svm.js b/src/x402-svm.js index 54e71760..e25cd445 100644 --- a/src/x402-svm.js +++ b/src/x402-svm.js @@ -9,7 +9,14 @@ import { encodeCompactU16, deriveATA as _deriveATA } from './transfer.js'; import { resolvePaymentAmount, resolvePayTo } from './x402-policy.js'; import { SOLANA_MAINNET_NETWORK } from './x402-tokens.js'; import { CHAIN_RPCS } from './rpc-urls.js'; -import { ADDRESS_LOOKUP_TABLE_PROGRAM, parseAddressLookupTable } from './solana-tx.js'; +import { + ADDRESS_LOOKUP_TABLE_PROGRAM, + SLOT_HASHES_SYSVAR, + SYSVAR_PROGRAM, + isLookupTableUsable, + parseAddressLookupTable, + parseSlotHashes, +} from './solana-tx.js'; // ============= Constants ============= @@ -469,13 +476,10 @@ export async function fetchRecentBlockhash(rpcUrl = CHAIN_RPCS.solana) { } /** - * Fetch and parse address-lookup-table accounts, in the order given. - * - * Throws unless every address is an active table owned by the lookup-table - * program: a quote that names a missing, foreign or deactivated table can't be - * compiled into a transaction the runtime will accept. + * getMultipleAccounts (base64) with the shared timeout and actionable errors. + * Returns the accounts in request order and the slot the RPC read them at. */ -export async function fetchAddressLookupTables(rpcUrl, addresses) { +async function getMultipleAccountsBase64(rpcUrl, addresses, purpose) { if (!isHttpUrl(rpcUrl)) { throw new Error( 'Invalid Solana RPC URL: expected a full http:// or https:// URL. Check NANSEN_SOLANA_RPC.' @@ -485,10 +489,9 @@ export async function fetchAddressLookupTables(rpcUrl, addresses) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), SOLANA_RPC_TIMEOUT_MS); const rpcError = (detail, cause) => new Error( - `Solana RPC ${detail} while fetching address lookup tables. Retry or configure a different RPC endpoint.`, + `Solana RPC ${detail} while fetching ${purpose}. Retry or configure a different RPC endpoint.`, cause ? { cause } : undefined ); - const timeoutError = (cause) => rpcError(`did not respond within ${SOLANA_RPC_TIMEOUT_MS / 1000}s`, cause); let data; @@ -525,13 +528,31 @@ export async function fetchAddressLookupTables(rpcUrl, addresses) { throw rpcError(`failed (${String(data.error.message ?? data.error.code ?? 'unknown RPC error')})`); } const accounts = data?.result?.value; - // Each table is matched to its address by position, so a short or padded - // array would pair a table with the wrong address. + // Each account is matched to its address by position, so a short or padded + // array would pair an account with the wrong address. if (!Array.isArray(accounts) || accounts.length !== addresses.length) { throw rpcError('returned an unexpected account list'); } + return { accounts, slot: data.result.context?.slot }; +} + +const accountBytes = (account) => + Buffer.from((Array.isArray(account.data) ? account.data[0] : account.data) ?? '', 'base64'); - return accounts.map((account, i) => { +/** + * Fetch and parse address-lookup-table accounts, in the order given. + * + * Throws unless every address is a table owned by the lookup-table program + * that the runtime will still resolve: a quote that names a missing, foreign or + * deactivated table can't be compiled into a transaction the runtime accepts. + * A table that is only deactivating is still usable (see isLookupTableUsable); + * telling the two apart needs the SlotHashes sysvar, which is fetched only when + * some table has a deactivation slot. + */ +export async function fetchAddressLookupTables(rpcUrl, addresses) { + const { accounts } = await getMultipleAccountsBase64(rpcUrl, addresses, 'address lookup tables'); + + const tables = accounts.map((account, i) => { const key = addresses[i]; if (!account) { throw new Error(`Address lookup table ${key} not found on-chain. Get a new quote.`); @@ -539,13 +560,25 @@ export async function fetchAddressLookupTables(rpcUrl, addresses) { if (account.owner !== ADDRESS_LOOKUP_TABLE_PROGRAM) { throw new Error(`Account ${key} is not an address lookup table. Get a new quote.`); } - const raw = Array.isArray(account.data) ? account.data[0] : account.data; - const table = parseAddressLookupTable(Buffer.from(raw ?? '', 'base64')); - if (!table.isActive) { - throw new Error(`Address lookup table ${key} has been deactivated. Get a new quote.`); - } - return { key, addresses: table.addresses }; + return { key, ...parseAddressLookupTable(accountBytes(account)) }; }); + + if (tables.some((t) => t.deactivationSlot !== null)) { + const { accounts: [sysvar], slot } = await getMultipleAccountsBase64(rpcUrl, [SLOT_HASHES_SYSVAR], 'recent slot hashes'); + if (!sysvar || sysvar.owner !== SYSVAR_PROGRAM || !Number.isSafeInteger(slot)) { + throw new Error( + 'Solana RPC returned no usable SlotHashes sysvar, so a deactivating address lookup table cannot be checked. Retry or configure a different RPC endpoint.' + ); + } + const slotHashes = parseSlotHashes(accountBytes(sysvar)); + for (const table of tables) { + if (!isLookupTableUsable(table.deactivationSlot, BigInt(slot), slotHashes)) { + throw new Error(`Address lookup table ${table.key} has been deactivated. Get a new quote.`); + } + } + } + + return tables.map(({ key, addresses: tableAddresses }) => ({ key, addresses: tableAddresses })); } /** From 767bd2b6cf86d893b4c4cbf91ec465f36f53e6a9 Mon Sep 17 00:00:00 2001 From: gulshngill Date: Tue, 6 Oct 2026 01:26:04 +0800 Subject: [PATCH 3/3] docs(solana-tx): note the lookup-table header is always 56 bytes Co-Authored-By: Claude Opus 5.5 (1M context) --- src/solana-tx.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/solana-tx.js b/src/solana-tx.js index bca6a48f..18696805 100644 --- a/src/solana-tx.js +++ b/src/solana-tx.js @@ -157,6 +157,7 @@ export const ADDRESS_LOOKUP_TABLE_PROGRAM = 'AddressLookupTab1e11111111111111111 // LookupTableMeta: typeIndex u32, deactivationSlot u64, lastExtendedSlot u64, // lastExtendedSlotStartIndex u8, authority Option, padding. The // 32-byte addresses start right after it. +// The program always reserves all 56 bytes, even when authority is None. const LOOKUP_TABLE_META_SIZE = 56; const LOOKUP_TABLE_TYPE_INDEX = 1; // Lookups index a table with a single byte.