diff --git a/ci_tests/src/index.ts b/ci_tests/src/index.ts index f650d6cd9..894312622 100644 --- a/ci_tests/src/index.ts +++ b/ci_tests/src/index.ts @@ -330,6 +330,15 @@ async function initSolana( }); console.log("Initialized ntt at", manager.program.programId.toString()); + // NOTE: this is a hack. The next instruction will fail if we don't wait + // here, because the address lookup table is not yet available, despite + // the transaction having been confirmed. + // Looks like a bug, but I haven't investigated further. In practice, this + // won't be an issue, becase the address lookup table will have been + // created well before anyone is trying to use it, but we might want to be + // mindful in the deploy script too. + await new Promise((resolve) => setTimeout(resolve, 200)); + await manager.registerTransceiver({ payer: SOL_PRIVATE_KEY, owner: SOL_PRIVATE_KEY, diff --git a/solana/Cargo.lock b/solana/Cargo.lock index fee82c049..78cde78b6 100644 --- a/solana/Cargo.lock +++ b/solana/Cargo.lock @@ -1568,6 +1568,7 @@ dependencies = [ "serde_json", "serde_wormhole", "sha3 0.10.8", + "solana-address-lookup-table-program", "solana-program", "solana-program-runtime", "solana-program-test", diff --git a/solana/Cargo.toml b/solana/Cargo.toml index 49269f5c1..5eb52e53b 100644 --- a/solana/Cargo.toml +++ b/solana/Cargo.toml @@ -47,6 +47,7 @@ anchor-spl = "0.29.0" solana-program = "=1.18.10" solana-program-runtime = "=1.18.10" solana-program-test = "=1.18.10" +solana-address-lookup-table-program = "=1.18.10" spl-token = "4.0.0" spl-token-2022 = "3.0.2" diff --git a/solana/programs/example-native-token-transfers/Cargo.toml b/solana/programs/example-native-token-transfers/Cargo.toml index bc064e81d..9a131c741 100644 --- a/solana/programs/example-native-token-transfers/Cargo.toml +++ b/solana/programs/example-native-token-transfers/Cargo.toml @@ -38,6 +38,7 @@ bitmaps = "3.2.1" hex.workspace = true cfg-if.workspace = true solana-program.workspace = true +solana-address-lookup-table-program.workspace = true spl-token-2022 = { workspace = true, features = ["no-entrypoint"] } wormhole-anchor-sdk.workspace = true wormhole-io.workspace = true diff --git a/solana/programs/example-native-token-transfers/src/instructions/luts.rs b/solana/programs/example-native-token-transfers/src/instructions/luts.rs new file mode 100644 index 000000000..c9a79b527 --- /dev/null +++ b/solana/programs/example-native-token-transfers/src/instructions/luts.rs @@ -0,0 +1,172 @@ +//! This instructions manages a canonical address lookup table (or LUT) for the +//! NTT program. +//! LUTs in general can be created permissionlessly, so support from the +//! program's side is not strictly necessary. When submitting a transaction, the +//! client could just manage its own ad-hoc lookup table. +//! Nevertheless, we provide this instruction to make it easier for the client +//! to query the lookup table from a deterministic address, and for integrators +//! to be able to fetch the accounts from the LUT in a standardised way. +//! +//! This way, the client sdk can abstract away the lookup table logic in a +//! maintanable way. +//! +//! The [`initialize_lut`] instruction can be called multiple times, each time +//! it will create a new lookup table, with the accounts defined in the +//! [`Entries`] struct. +//! An alternative would be to keep extending the existing lookup table, but +//! ensuring the instruction is idempotent (which requires ensuring no duplicate +//! entries) has O(n^2) complexity (since LUTs are append only, we can't keep it +//! sorted), and in the worst case would require ~16k checks. So we keep things +//! simple, and just create a new LUT each time. This operation won't be called +//! often, so the extra allocation is justifiable. +//! +//! Because of all the above, this instruction can be called permissionlessly. + +use anchor_lang::prelude::*; +use solana_address_lookup_table_program; +use solana_program::program::{invoke, invoke_signed}; + +use crate::{config::Config, queue::outbox::OutboxRateLimit, transceivers::wormhole::accounts::*}; + +#[account] +#[derive(InitSpace)] +pub struct LUT { + pub bump: u8, + pub address: Pubkey, +} + +#[derive(Accounts)] +#[instruction(recent_slot: u64)] +pub struct InitializeLUT<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + #[account( + seeds = [b"lut_authority"], + bump + )] + pub authority: AccountInfo<'info>, + + #[account( + mut, + seeds = [authority.key().as_ref(), &recent_slot.to_le_bytes()], + seeds::program = solana_address_lookup_table_program::id(), + bump + )] + pub lut_address: AccountInfo<'info>, + + #[account( + init_if_needed, + payer = payer, + space = 8 + LUT::INIT_SPACE, + seeds = [b"lut"], + bump + )] + pub lut: Account<'info, LUT>, + + /// CHECK: address lookup table program (checked by instruction) + #[account(executable)] + pub lut_program: AccountInfo<'info>, + + pub system_program: Program<'info, System>, + + /// These are the entries that will populate the LUT. + pub entries: Entries<'info>, +} + +#[derive(Accounts)] +pub struct Entries<'info> { + pub config: Account<'info, Config>, + + #[account( + constraint = custody.key() == config.custody, + )] + pub custody: AccountInfo<'info>, + + #[account( + constraint = token_program.key() == config.token_program, + )] + pub token_program: AccountInfo<'info>, + + #[account( + constraint = mint.key() == config.mint, + )] + pub mint: AccountInfo<'info>, + + #[account( + seeds = [crate::TOKEN_AUTHORITY_SEED], + bump, + )] + pub token_authority: AccountInfo<'info>, + + pub outbox_rate_limit: Account<'info, OutboxRateLimit>, + + // NOTE: this includes the system program so we don't need to add it in the outer context + pub wormhole: WormholeAccounts<'info>, +} + +pub fn initialize_lut(ctx: Context, recent_slot: u64) -> Result<()> { + let (ix, lut_address) = solana_address_lookup_table_program::instruction::create_lookup_table( + ctx.accounts.authority.key(), + ctx.accounts.payer.key(), + recent_slot, + ); + + // just a sanity check, should never be hit, so we don't provide a custom + // error message + assert_eq!(lut_address, ctx.accounts.lut_address.key()); + + // the LUT might already exist, in which case the new one will simply + // override it. Since we don't delete the old LUTs, this is safe -- clients + // holding references to old LUTs will still be able to use them. + ctx.accounts.lut.set_inner(LUT { + bump: ctx.bumps.lut, + address: lut_address, + }); + + // NOTE: LUTs can be permissionlessly created (i.e. the authority does + // not need to sign the transaction). This means that the LUT might + // already exist (if someone frontran us). However, it's not a problem: + // AddressLookupTable::create_lookup_table checks if the LUT already + // exists and does nothing if it does. + // + // LUTs can only be created permissionlessly, but only the authority is + // authorised to actually populate the fields, so we don't have to worry + // about the frontrunner populating it with junk. The only risk of that would + // be the LUT being filled to capacity (256 addresses), with no + // possibility for us to add our own accounts -- no other security impact. + invoke( + &ix, + &[ + ctx.accounts.lut_address.to_account_info(), + ctx.accounts.authority.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ], + )?; + + let entries_infos = ctx.accounts.entries.to_account_infos(); + let mut entries = Vec::with_capacity(1 + entries_infos.len()); + entries.push(crate::ID); + entries.extend(entries_infos.into_iter().map(|x| x.key)); + + let ix = solana_address_lookup_table_program::instruction::extend_lookup_table( + ctx.accounts.lut_address.key(), + ctx.accounts.authority.key(), + Some(ctx.accounts.payer.key()), + entries, + ); + + invoke_signed( + &ix, + &[ + ctx.accounts.lut_address.to_account_info(), + ctx.accounts.authority.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ], + &[&[b"lut_authority", &[ctx.bumps.authority]]], + )?; + + Ok(()) +} diff --git a/solana/programs/example-native-token-transfers/src/instructions/mod.rs b/solana/programs/example-native-token-transfers/src/instructions/mod.rs index a716c567e..ed009b00c 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/mod.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/mod.rs @@ -1,11 +1,13 @@ pub mod admin; pub mod initialize; +pub mod luts; pub mod redeem; pub mod release_inbound; pub mod transfer; pub use admin::*; pub use initialize::*; +pub use luts::*; pub use redeem::*; pub use release_inbound::*; pub use transfer::*; diff --git a/solana/programs/example-native-token-transfers/src/lib.rs b/solana/programs/example-native-token-transfers/src/lib.rs index b5bb5fbe5..6521acf1e 100644 --- a/solana/programs/example-native-token-transfers/src/lib.rs +++ b/solana/programs/example-native-token-transfers/src/lib.rs @@ -73,6 +73,10 @@ pub mod example_native_token_transfers { instructions::initialize(ctx, args) } + pub fn initialize_lut(ctx: Context, recent_slot: u64) -> Result<()> { + instructions::initialize_lut(ctx, recent_slot) + } + pub fn version(_ctx: Context) -> Result { Ok(VERSION.to_string()) } diff --git a/solana/tests/example-native-token-transfer.ts b/solana/tests/example-native-token-transfer.ts index 9d87a84b1..4431a5af3 100644 --- a/solana/tests/example-native-token-transfer.ts +++ b/solana/tests/example-native-token-transfer.ts @@ -176,6 +176,15 @@ describe("example-native-token-transfers", () => { mode: "burning", }); + // NOTE: this is a hack. The next instruction will fail if we don't wait + // here, because the address lookup table is not yet available, despite + // the transaction having been confirmed. + // Looks like a bug, but I haven't investigated further. In practice, this + // won't be an issue, becase the address lookup table will have been + // created well before anyone is trying to use it, but we might want to be + // mindful in the deploy script too. + await new Promise((resolve) => setTimeout(resolve, 200)); + await ntt.registerTransceiver({ payer, owner: payer, diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index d2da6a330..9c0a650da 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -10,7 +10,7 @@ import { nativeTokenTransferLayout } from './nttLayout' import { derivePostedVaaKey, getWormholeDerivedAccounts } from '@certusone/wormhole-sdk/lib/cjs/solana/wormhole' -import { BN, translateError, type IdlAccounts, Program, AnchorProvider, Wallet, } from '@coral-xyz/anchor' +import { BN, translateError, type IdlAccounts, Program, web3, } from '@coral-xyz/anchor' import { getAssociatedTokenAddressSync } from '@solana/spl-token' import { PublicKey, Keypair, @@ -23,7 +23,9 @@ import { TransactionMessage, VersionedTransaction, Commitment, - AccountMeta + AccountMeta, + AddressLookupTableProgram, + AddressLookupTableAccount } from '@solana/web3.js' import { Keccak } from 'sha3' import { type ExampleNativeTokenTransfers as RawExampleNativeTokenTransfers } from '../../target/types/example_native_token_transfers' @@ -78,6 +80,7 @@ export class NTT { readonly wormholeId: PublicKey // mapping from error code to error message. Used for prettifying error messages private readonly errors: Map + addressLookupTable: web3.AddressLookupTableAccount | null = null constructor(connection: Connection, args: { nttId: NttProgramId, wormholeId: WormholeProgramId }) { // TODO: initialise a new Program here with a passed in Connection @@ -107,6 +110,14 @@ export class NTT { return this.derivePda('config') } + lutAccountAddress(): PublicKey { + return this.derivePda('lut') + } + + lutAuthorityAddress(): PublicKey { + return this.derivePda('lut_authority') + } + outboxRateLimitAccountAddress(): PublicKey { return this.derivePda('outbox_rate_limit') } @@ -221,7 +232,7 @@ export class NTT { mint: PublicKey outboundLimit: BN mode: 'burning' | 'locking' - }) { + }): Promise { const mode: any = args.mode === 'burning' ? { burning: {} } @@ -248,7 +259,97 @@ export class NTT { associatedTokenProgram: splToken.ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, }).instruction(); - return sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix), [args.payer, args.owner]); + await this.sendAndConfirmTransaction(new Transaction().add(ix), [args.payer, args.owner], false); + await this.initializeOrUpdateLUT({ payer: args.payer }) + } + + // This function should be called after each upgrade. If there's nothing to + // do, it won't actually submit a transaction, so it's cheap to call. + async initializeOrUpdateLUT(args: { + payer: Keypair + }): Promise { + // TODO: find a more robust way of fetching a recent slot + const slot = await this.program.provider.connection.getSlot() - 1 + + const [_, lutAddress] = web3.AddressLookupTableProgram.createLookupTable({ + authority: this.lutAuthorityAddress(), + payer: args.payer.publicKey, + recentSlot: slot, + }); + + const whAccs = getWormholeDerivedAccounts(this.program.programId, this.wormholeId) + const config = await this.getConfig() + + const entries = { + config: this.configAccountAddress(), + custody: await this.custodyAccountAddress(config), + tokenProgram: await this.tokenProgram(config), + mint: await this.mintAccountAddress(config), + tokenAuthority: this.tokenAuthorityAddress(), + outboxRateLimit: this.outboxRateLimitAccountAddress(), + wormhole: { + bridge: whAccs.wormholeBridge, + feeCollector: whAccs.wormholeFeeCollector, + sequence: whAccs.wormholeSequence, + program: this.wormholeId, + systemProgram: SystemProgram.programId, + clock: web3.SYSVAR_CLOCK_PUBKEY, + rent: web3.SYSVAR_RENT_PUBKEY, + } + }; + + // collect all pubkeys in entries recursively + const collectPubkeys = (obj: any): Array => { + const pubkeys = new Array() + for (const key in obj) { + const value = obj[key] + if (value instanceof PublicKey) { + pubkeys.push(value) + } else if (typeof value === 'object') { + pubkeys.push(...collectPubkeys(value, pubkeys)) + } + } + return pubkeys + } + const pubkeys = collectPubkeys(entries).map(pk => pk.toBase58()) + + var existingLut: web3.AddressLookupTableAccount | null = null + try { + existingLut = await this.getAddressLookupTable(false) + } catch { + // swallow errors here, it just means that lut doesn't exist + } + + if (existingLut !== null) { + const existingPubkeys = existingLut.state.addresses?.map(a => a.toBase58()) ?? [] + + // if pubkeys contains keys that are not in the existing LUT, we need to + // add them to the LUT + const missingPubkeys = pubkeys.filter(pk => !existingPubkeys.includes(pk)) + + if (missingPubkeys.length === 0) { + return existingLut + } + } + + const ix = await this.program.methods + .initializeLut(new BN(slot)) + .accountsStrict({ + payer: args.payer.publicKey, + authority: this.lutAuthorityAddress(), + lutAddress, + lut: this.lutAccountAddress(), + lutProgram: AddressLookupTableProgram.programId, + systemProgram: SystemProgram.programId, + entries + }).instruction(); + + const signers = [args.payer] + await this.sendAndConfirmTransaction(new Transaction().add(ix), signers, false); + + // NOTE: explicitly invalidate the cache. This is the only operation that + // modifies the LUT, so this is the only place we need to invalide. + return this.getAddressLookupTable(false) } async transfer(args: { @@ -316,9 +417,27 @@ export class NTT { /** * Like `sendAndConfirmTransaction` but parses the anchor error code. */ - private async sendAndConfirmTransaction(tx: Transaction, signers: Keypair[]): Promise { + private async sendAndConfirmTransaction(tx: Transaction, signers: Keypair[], useLut = true): Promise { + const blockhash = await this.program.provider.connection.getLatestBlockhash() + const luts: AddressLookupTableAccount[] = [] + if (useLut) { + luts.push(await this.getAddressLookupTable()) + } + try { - return await sendAndConfirmTransaction(this.program.provider.connection, tx, signers) + const messageV0 = new TransactionMessage({ + payerKey: signers[0].publicKey, + recentBlockhash: blockhash.blockhash, + instructions: tx.instructions, + }).compileToV0Message(luts) + + const transactionV0 = new VersionedTransaction(messageV0) + transactionV0.sign(signers) + + // The types for this function are wrong -- the type says it doesn't + // support version transactions, but it does 🤫 + // @ts-ignore + return await sendAndConfirmTransaction(this.program.provider.connection, transactionV0) } catch (err) { throw translateError(err, this.errors) } @@ -543,7 +662,7 @@ export class NTT { tx.add(await this.createReleaseOutboundInstruction(txArgs)) const signers = [args.payer] - return await sendAndConfirmTransaction(this.program.provider.connection, tx, signers) + return await this.sendAndConfirmTransaction(tx, signers) } // TODO: document that if recipient is provided, then the instruction can be @@ -746,7 +865,7 @@ export class NTT { peer: this.peerAccountAddress(args.chain), inboxRateLimit: this.inboxRateLimitAccountAddress(args.chain) }).instruction() - return await sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix), [args.payer, args.owner]) + return await this.sendAndConfirmTransaction(new Transaction().add(ix), [args.payer, args.owner]) } async setWormholeTransceiverPeer(args: { @@ -783,7 +902,7 @@ export class NTT { program: this.wormholeId } }).instruction() - return await sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix, broadcastIx), [args.payer, args.owner, wormholeMessage]) + return await this.sendAndConfirmTransaction(new Transaction().add(ix, broadcastIx), [args.payer, args.owner, wormholeMessage]) } async registerTransceiver(args: { @@ -816,8 +935,8 @@ export class NTT { program: this.wormholeId } }).instruction() - return await sendAndConfirmTransaction( - this.program.provider.connection, new Transaction().add(ix, broadcastIx), [args.payer, args.owner, wormholeMessage]) + return await this.sendAndConfirmTransaction( + new Transaction().add(ix, broadcastIx), [args.payer, args.owner, wormholeMessage]) } async setOutboundLimit(args: { @@ -833,7 +952,7 @@ export class NTT { config: this.configAccountAddress(), rateLimit: this.outboxRateLimitAccountAddress(), }).instruction(); - return sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix), [args.owner]); + return this.sendAndConfirmTransaction(new Transaction().add(ix), [args.owner]); } async setInboundLimit(args: { @@ -850,7 +969,7 @@ export class NTT { config: this.configAccountAddress(), rateLimit: this.inboxRateLimitAccountAddress(args.chain), }).instruction(); - return sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix), [args.owner]); + return this.sendAndConfirmTransaction(new Transaction().add(ix), [args.owner]); } async createReceiveWormholeMessageInstruction(args: { @@ -1018,6 +1137,21 @@ export class NTT { return await this.program.account.inboxItem.fetch(this.inboxItemAccountAddress(chain, nttMessage)) } + async getAddressLookupTable(useCache = true): Promise { + if (!useCache || !this.addressLookupTable) { + const lut = await this.program.account.lut.fetchNullable(this.lutAccountAddress()) + if (!lut) { + throw new Error('Address lookup table not found. Did you forget to call initializeLUT?') + } + const response = await this.program.provider.connection.getAddressLookupTable(lut.address) + this.addressLookupTable = response.value + } + if (!this.addressLookupTable) { + throw new Error('Address lookup table not found. Did you forget to call initializeLUT?') + } + return this.addressLookupTable + } + /** * Returns the address of the custody account. If the config is available * (i.e. the program is initialised), the mint is derived from the config.