Skip to content

fix(bridge): compile oversized Solana bridge routes with address lookup tables - #729

Merged
gulshngill merged 3 commits into
mainfrom
fix/api-382-solana-bridge-alt
Oct 5, 2026
Merged

gulshngill merged 3 commits into
mainfrom
fix/api-382-solana-bridge-alt

Conversation

@gulshngill

@gulshngill gulshngill commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Relay returns Solana-source bridge quotes as raw instructions plus addressLookupTableAddresses. The CLI compiles them itself in compileRawSolanaTransaction. 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:

Solana transaction too large to compile without address-lookup-table support (1482 bytes > 1232 limit). This route needs its address lookup tables resolved, which isn't supported yet.

Fix

  • Routes that fit are unchanged. The compiler still builds a static message first. If that fits, it's used as is: there's no extra RPC call, and the static safety checks still see every account.
  • Oversized routes use their lookup tables. The new fetchAddressLookupTables (src/x402-svm.js) loads the quote's tables with one getMultipleAccounts call. Each table must exist, be owned by AddressLookupTab1e1111111111111111111111111, 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.
  • buildMessageV0 takes addressLookupTables. 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.
  • The error is clearer when compression isn't enough. It now says whether the quote supplied no tables or is still too large with them.

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 assertSolanaInstructionsSafe classifies instructions the same way as before. Outcome simulation already resolves the writable accounts loaded from tables.

Testing

  • New unit tests cover the following:
    • A two-table route whose tables also list the signer and program, and share some accounts. The test checks the transaction fits, the signer and program stay static, and every instruction account resolves back to its original address in order.
    • A route that fits never fetches its tables.
    • A table that is missing, has the wrong owner or is deactivated is refused before the blockhash is fetched.
    • A table deactivated in the current slot, or whose deactivation slot is still in SlotHashes, is used.
    • SlotHashes is only read when a table has been deactivated, and a missing sysvar is refused.
    • A route still too large with its tables is refused.
    • Parser cases.
    • Errors from the RPC helper.
  • Checked the compiled message with @solana/web3.js MessageV0.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) and npm run lint pass.
  • Not yet run: a real-money e2e of an oversized Solana → Base swap-then-bridge route.

🤖 Generated with Claude Code

…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>
Comment thread src/x402-svm.js
@nansen-pr-reviewer

nansen-pr-reviewer Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

nansen-pr-reviewer summary

The 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

Severity Count
🟠 High 1
🟡 Medium 1

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 @nansen-pr-reviewer[bot] re-review.

nansen-pr-reviewer[bot]
nansen-pr-reviewer Bot previously approved these changes Oct 5, 2026

@nansen-pr-reviewer nansen-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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>
@gulshngill

Copy link
Copy Markdown
Contributor Author

@nansen-pr-reviewer[bot] Re: 🟠 High | Real lookup-table accounts are parsed with the wrong metadata layout (src/solana-tx.js). I couldn't reproduce this, so there's no code change.

Layout. parseAddressLookupTable reads the layout the address-lookup-table program writes:

  • Bytes 0..4: the u32 ProgramState discriminant (1 = LookupTable).
  • Bytes 4..12: deactivation_slot: u64.
  • Bytes 12..20: last_extended_slot: u64.
  • Byte 20: last_extended_slot_start_index: u8.
  • Bytes 21..54: authority: Option<Pubkey>.
  • Bytes 54..56: u16 padding.
  • Byte 56 onward: the addresses.

The program always reserves the full LOOKUP_TABLE_META_SIZE = 56 bytes for this header, even when authority is None. So the addresses always start at byte 56. @solana/web3.js (LookupTableMetaLayout) decodes the same way.

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 parseAddressLookupTable and AddressLookupTableAccount.deserialize:

Table Data length Entries Addresses match web3.js Deactivation slot matches
6ZA35rCYDdaeQ54oTCGfw9U3XvBx9i7WFqRq4wGvZRgz 7768 241 ✅ ✅
B8CgneZs8w8ooWm3yBqWr5934KcnNqLe2KVUHb8DgK7t 7704 239 ✅ ✅
4m25Bu6JhCFLzbPKJm8usuxr1yjJBnWNEe4u8PUgcpaU 8152 253 ✅ ✅
wFZ74kZaNYPeUagp6oCPvML8GUtDtPnE81pfzDbhrAC 7736 240 ✅ ✅

For every table, the data length minus 56 divides evenly by 32. The 8 bytes at offset 4 read as u64::MAX, which is what an active table stores there.

parseSlotHashes also decodes the live SysvarS1otHashes111111111111111111111111111 account correctly. It's owned by Sysvar1111111111111111111111111111111111111 and has 512 entries in descending order, newest first. The newest entry is one slot behind the read's context slot.

Compiled messages. I also decoded the compressed messages the compiler builds with MessageV0.deserialize + getAccountKeys({ addressLookupTableAccounts }). Every account resolves back to its original address, with the same writable and signer flags.

If the finding is about a different part of the layout, please point to the specific field or offset and I'll check it.

@gulshngill

Copy link
Copy Markdown
Contributor Author

@nansen-pr-reviewer[bot] re-review

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/trading.js
}

const unsignedSize = (message) => 1 + 64 + message.messageBytes.length; // compact-u16(1) + 1 signature slot
const preflight = buildMessageV0({ feePayer, instructions, recentBlockhash: SIZE_CHECK_BLOCKHASH });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 - 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/solana-tx.js
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 - 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@gulshngill
gulshngill merged commit 7b54c0e into main Oct 5, 2026
15 of 16 checks passed
@gulshngill
gulshngill deleted the fix/api-382-solana-bridge-alt branch October 5, 2026 17:43
@github-actions github-actions Bot mentioned this pull request Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant