Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/solana-bridge-lookup-tables.md
Original file line number Diff line number Diff line change
@@ -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.
73 changes: 72 additions & 1 deletion src/__tests__/solana-tx.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { 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) {
Expand Down Expand Up @@ -142,3 +142,74 @@ 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 a never-deactivated table in order', () => {
const addresses = [generateSolanaWallet().address, generateSolanaWallet().address];
expect(parseAddressLookupTable(tableData(addresses))).toEqual({ deactivationSlot: null, addresses });
});

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', () => {
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/);
});
});

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);
});
});
181 changes: 177 additions & 4 deletions src/__tests__/trading.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: [{
Expand All @@ -8111,6 +8112,178 @@ 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'] };
}

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)
// 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'
? { context: { slot: CURRENT_SLOT }, value: body.params[0].map((a) => all[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)).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 () => {
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/);
});
});
});
43 changes: 43 additions & 0 deletions src/__tests__/x402-svm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
buildUnsignedSvmTransaction,
createSvmPaymentPayload,
fetchRecentBlockhash,
fetchAddressLookupTables,
} from '../x402-svm.js';
import { CHAIN_RPCS } from '../rpc-urls.js';

Expand Down Expand Up @@ -339,3 +340,45 @@ 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('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()]))
.rejects.toThrow(/returned an unexpected account list/);
});
});
Loading
Loading