Repository navigation
fix(bridge): compile oversized Solana bridge routes with address lookup tables - #729
Conversation
…up 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) <noreply@anthropic.com>
nansen-pr-reviewer summaryThe change adds on-chain address lookup-table fetching and v0 message compression for oversized Solana routes, with useful ownership, initialization, and deactivation checks. However, compilation still performs a no-ALT account-count validation before applying the tables, so valid routes with more than 256 static accounts that would compress below the limit are rejected. The ALT parser also ignores the table's last-extension metadata, which can reference entries that are not yet active at the observed slot. Findings by Severity
Deterministic check: failure — Found 1 high severity finding(s) (max: 0) Risk: 4/5 (High) — raised by: complexity: 568 added lines Token usage: 76,459 input, 2,349 output, 11,046 cache read | Usage Guide Cooldown: for the next 10 minutes (counting from when this review finished), new pushes to this PR will not trigger another review — the next push after the window expires will. Need a fresh review sooner? Comment |
There was a problem hiding this comment.
✅ Auto-approved by Nansen review
Deterministic gates passed: severity thresholds met, recommendation "approve_with_comments", risk level 3/5 within threshold 3.
- The deterministic severity check passed: Found 1 finding(s) within acceptable thresholds
- Risk level 3/5 (Elevated)
- The model recommended: approve_with_comments
Approval was decided by deterministic gates, not by the model. The model's recommendation can block auto-approval but never cause it.
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) <noreply@anthropic.com>
|
@nansen-pr-reviewer[bot] Re: 🟠 High | Real lookup-table accounts are parsed with the wrong metadata layout ( Layout.
The program always reserves the full Checked against mainnet. I took four lookup tables from recent Jupiter v0 transactions and read them from a public RPC. I decoded each one with both
For every table, the data length minus 56 divides evenly by 32. The 8 bytes at offset 4 read as
Compiled messages. I also decoded the compressed messages the compiler builds with If the finding is about a different part of the layout, please point to the specific field or offset and I'll check it. |
|
@nansen-pr-reviewer[bot] re-review |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| } | ||
|
|
||
| const unsignedSize = (message) => 1 + 64 + message.messageBytes.length; // compact-u16(1) + 1 signature slot | ||
| const preflight = buildMessageV0({ feePayer, instructions, recentBlockhash: SIZE_CHECK_BLOCKHASH }); |
There was a problem hiding this comment.
🟠 - High | Preflight rejects valid ALT-compressible routes before applying lookup tables
The initial buildMessageV0 call is made without addressLookupTables, and buildMessageV0 immediately throws when the uncompressed account list exceeds 256 entries. This happens before compileRawSolanaTransaction fetches or applies the quoted tables. A route with, for example, 260 referenced accounts and 20 of them present in an ALT could produce a valid 240-account message after compression, but this implementation rejects it during the static preflight and never attempts lookup-table compilation. The account-count check should be deferred until after lookup compression, or the preflight should allow oversized intermediate account lists when ALT hints are available.
There was a problem hiding this comment.
I don't think this is a real case, so no change. Compression doesn't reduce the number of accounts a message indexes. It only moves some of them out of the static key list.
Instruction account indexes (u8) point into the combined list the runtime resolves: the static keys, then each table's writable loads, then each table's readonly loads. A loaded account still takes one of those 256 indexes. That's why the check in buildMessageV0 counts allKeys = [...sortedKeys, ...loadedWritable, ...loadedReadonly], not only the static keys. The total is the same with or without tables.
In your example, a route with 260 unique accounts still has 260 indexed accounts after 20 are moved into a lookup. That can't be encoded with or without lookup tables, so failing in the static preflight is the right result. Waiting until after compression would only fetch the tables and then fail in the same check.
Real routes are also nowhere near this limit, because the runtime refuses any transaction that locks more than 64 accounts.
| 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) { |
There was a problem hiding this comment.
🟡 - Medium | ALT entries newly extended in the current slot are treated as active
Address lookup table metadata includes lastExtendedSlot and lastExtendedSlotStartIndex; entries appended in the current slot are not active for lookup resolution until a later slot. The parser reads only the type and deactivation slot, then returns every address in the account, so buildMessageV0 can encode indexes that the runtime will not resolve. Such a quote can pass the local checks but fail when submitted, particularly for recently created or extended tables. The active address length must be derived from the extension metadata and observed slot before exposing addresses to the compiler.
There was a problem hiding this comment.
I don't think this can happen for the transactions we build, so no change.
The runtime's rule (LookupTable::get_active_addresses_len) is: if current_slot > last_extended_slot, every address is active. Otherwise only the first last_extended_slot_start_index addresses are. So entries are hidden only from a transaction that runs in the same slot as the extension.
fetchAddressLookupTables reads the tables at confirmed commitment. Any extension we can see therefore happened in a slot at or before the read slot. The transaction is compiled, signed and sent after that read, so it lands in a later slot. At that point current_slot > last_extended_slot holds, and every address we compiled against is active. Entries added after our read aren't in the data we parsed, so we never encode their indexes.
If the runtime did refuse a lookup, it would drop the transaction while loading its accounts, before anything executes. The result is a failed send, not a partial execution.
Problem
Relay returns Solana-source bridge quotes as raw instructions plus
addressLookupTableAddresses. The CLI compiles them itself incompileRawSolanaTransaction. The compiler kept every account static and ignored the lookup tables. A route with a source-chain swap ahead of the bridge deposit (for example, bridging an SPL token that the bridge can't take directly) then goes over Solana's 1232-byte limit. The CLI refused it before signing:Fix
fetchAddressLookupTables(src/x402-svm.js) loads the quote's tables with onegetMultipleAccountscall. Each table must exist, be owned byAddressLookupTab1e1111111111111111111111111, be initialized, and still be usable by the runtime. Otherwise the CLI refuses with an actionable error before fetching a blockhash. Usability follows the runtime's lookup-table status rule. A table that has never been deactivated is usable. A table that is only deactivating (its deactivation slot is the current slot or is still in the SlotHashes sysvar) is also usable. Only a table whose deactivation slot has left SlotHashes is refused. The SlotHashes sysvar is read only when some table has a deactivation slot, so the normal path is still a single RPC call.buildMessageV0takesaddressLookupTables. Accounts that are neither signers nor invoked programs move into table lookups. The runtime requires those two kinds to be static, so the fee payer and program IDs never move. Account indexes follow the runtime's order: static keys, then the writable loads from each table, then the readonly loads. An account found in more than one table is loaded from the first one. The builder now throws on more than 256 accounts. Before, an index above 255 was written as a single byte and wrapped silently.parseAddressLookupTable(src/solana-tx.js) rejects data with a truncated or ragged length, an uninitialized table, and a table with more than 256 entries.Compression doesn't change which accounts an instruction touches. A table entry only replaces an account with a reference to the same address, and lookup-table entries can't be changed once written. Program IDs and signers stay static, so
assertSolanaInstructionsSafeclassifies instructions the same way as before. Outcome simulation already resolves the writable accounts loaded from tables.Testing
@solana/web3.jsMessageV0.deserialize+getAccountKeys({ addressLookupTableAccounts })as an independent decoder. Every account resolves to its original address, and the writable and signer flags match.npm test(4533 passed) andnpm run lintpass.🤖 Generated with Claude Code