diff --git a/programs/address-lookup-table-tests/tests/close_lookup_table_ix.rs b/programs/address-lookup-table-tests/tests/close_lookup_table_ix.rs index 71a369c5ce..3fd6225502 100644 --- a/programs/address-lookup-table-tests/tests/close_lookup_table_ix.rs +++ b/programs/address-lookup-table-tests/tests/close_lookup_table_ix.rs @@ -7,6 +7,7 @@ use { solana_address_lookup_table_program::instruction::close_lookup_table, solana_program_test::*, solana_sdk::{ + clock::Clock, instruction::InstructionError, pubkey::Pubkey, signature::{Keypair, Signer}, @@ -77,6 +78,38 @@ async fn test_close_lookup_table_not_deactivated() { .await; } +#[tokio::test] +async fn test_close_lookup_table_deactivated_in_current_slot() { + let mut context = setup_test_context().await; + + let clock = context.banks_client.get_sysvar::().await.unwrap(); + let authority_keypair = Keypair::new(); + let initialized_table = { + let mut table = new_address_lookup_table(Some(authority_keypair.pubkey()), 0); + table.meta.deactivation_slot = clock.slot; + table + }; + let lookup_table_address = Pubkey::new_unique(); + add_lookup_table_account(&mut context, lookup_table_address, initialized_table).await; + + let ix = close_lookup_table( + lookup_table_address, + authority_keypair.pubkey(), + context.payer.pubkey(), + ); + + // Context sets up the slot hashes sysvar to have an entry + // for slot 0 which is when the table was deactivated. + // Because that slot is present, the ix should fail. + assert_ix_error( + &mut context, + ix, + Some(&authority_keypair), + InstructionError::InvalidArgument, + ) + .await; +} + #[tokio::test] async fn test_close_lookup_table_recently_deactivated() { let mut context = setup_test_context().await; diff --git a/programs/address-lookup-table/src/processor.rs b/programs/address-lookup-table/src/processor.rs index 824955d464..11c7b57b48 100644 --- a/programs/address-lookup-table/src/processor.rs +++ b/programs/address-lookup-table/src/processor.rs @@ -2,8 +2,8 @@ use { crate::{ instruction::ProgramInstruction, state::{ - AddressLookupTable, LookupTableMeta, ProgramState, LOOKUP_TABLE_MAX_ADDRESSES, - LOOKUP_TABLE_META_SIZE, + AddressLookupTable, LookupTableMeta, LookupTableStatus, ProgramState, + LOOKUP_TABLE_MAX_ADDRESSES, LOOKUP_TABLE_META_SIZE, }, }, solana_program_runtime::{ic_msg, invoke_context::InvokeContext}, @@ -15,7 +15,7 @@ use { keyed_account::keyed_account_at_index, program_utils::limited_deserialize, pubkey::{Pubkey, PUBKEY_BYTES}, - slot_hashes::{SlotHashes, MAX_ENTRIES}, + slot_hashes::SlotHashes, system_instruction, sysvar::{ clock::{self, Clock}, @@ -419,26 +419,25 @@ impl Processor { if lookup_table.meta.authority != Some(*authority_account.unsigned_key()) { return Err(InstructionError::IncorrectAuthority); } - if lookup_table.meta.deactivation_slot == Slot::MAX { - ic_msg!(invoke_context, "Lookup table is not deactivated"); - return Err(InstructionError::InvalidArgument); - } - // Assert that the deactivation slot is no longer recent to give in-flight transactions - // enough time to land and to remove indeterminism caused by transactions loading - // addresses in the same slot when a table is closed. This enforced delay has a side - // effect of not allowing lookup tables to be recreated at the same derived address - // because tables must be created at an address derived from a recent slot. + let clock: Clock = invoke_context.get_sysvar(&clock::id())?; let slot_hashes: SlotHashes = invoke_context.get_sysvar(&slot_hashes::id())?; - if let Some(position) = slot_hashes.position(&lookup_table.meta.deactivation_slot) { - let expiration = MAX_ENTRIES.saturating_sub(position); - ic_msg!( - invoke_context, - "Table cannot be closed until its derivation slot expires in {} blocks", - expiration - ); - return Err(InstructionError::InvalidArgument); - } + + match lookup_table.meta.status(clock.slot, &slot_hashes) { + LookupTableStatus::Activated => { + ic_msg!(invoke_context, "Lookup table is not deactivated"); + Err(InstructionError::InvalidArgument) + } + LookupTableStatus::Deactivating { remaining_blocks } => { + ic_msg!( + invoke_context, + "Table cannot be closed until it's fully deactivated in {} blocks", + remaining_blocks + ); + Err(InstructionError::InvalidArgument) + } + LookupTableStatus::Deactivated => Ok(()), + }?; drop(lookup_table_account_ref); diff --git a/programs/address-lookup-table/src/state.rs b/programs/address-lookup-table/src/state.rs index 673ab11123..8bf7fc3457 100644 --- a/programs/address-lookup-table/src/state.rs +++ b/programs/address-lookup-table/src/state.rs @@ -1,7 +1,12 @@ use { serde::{Deserialize, Serialize}, solana_frozen_abi_macro::{AbiEnumVisitor, AbiExample}, - solana_sdk::{clock::Slot, instruction::InstructionError, pubkey::Pubkey}, + solana_sdk::{ + clock::Slot, + instruction::InstructionError, + pubkey::Pubkey, + slot_hashes::{SlotHashes, MAX_ENTRIES}, + }, std::borrow::Cow, }; @@ -21,6 +26,14 @@ pub enum ProgramState { LookupTable(LookupTableMeta), } +/// Activation status of a lookup table +#[derive(Debug, PartialEq, Clone)] +pub enum LookupTableStatus { + Activated, + Deactivating { remaining_blocks: usize }, + Deactivated, +} + /// Address lookup table metadata #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, AbiExample)] pub struct LookupTableMeta { @@ -61,6 +74,41 @@ impl LookupTableMeta { ..LookupTableMeta::default() } } + + /// Returns whether the table is considered active for address lookups + pub fn is_active(&self, current_slot: Slot, slot_hashes: &SlotHashes) -> bool { + match self.status(current_slot, slot_hashes) { + LookupTableStatus::Activated => true, + LookupTableStatus::Deactivating { .. } => true, + LookupTableStatus::Deactivated => false, + } + } + + /// Return the current status of the lookup table + pub fn status(&self, current_slot: Slot, slot_hashes: &SlotHashes) -> LookupTableStatus { + if self.deactivation_slot == Slot::MAX { + LookupTableStatus::Activated + } else if self.deactivation_slot == current_slot { + LookupTableStatus::Deactivating { + remaining_blocks: MAX_ENTRIES.saturating_add(1), + } + } else if let Some(slot_hash_position) = slot_hashes.position(&self.deactivation_slot) { + // Deactivation requires a cool-down period to give in-flight transactions + // enough time to land and to remove indeterminism caused by transactions loading + // addresses in the same slot when a table is closed. The cool-down period is + // equivalent to the amount of time it takes for a slot to be removed from the + // slot hash list. + // + // By using the slot hash to enforce the cool-down, there is a side effect + // of not allowing lookup tables to be recreated at the same derived address + // because tables must be created at an address derived from a recent slot. + LookupTableStatus::Deactivating { + remaining_blocks: MAX_ENTRIES.saturating_sub(slot_hash_position), + } + } else { + LookupTableStatus::Deactivated + } + } } #[derive(Debug, PartialEq, Clone, AbiExample)] @@ -127,6 +175,7 @@ impl<'a> AddressLookupTable<'a> { #[cfg(test)] mod tests { use super::*; + use solana_sdk::hash::Hash; impl AddressLookupTable<'_> { fn new_for_tests(meta: LookupTableMeta, num_addresses: usize) -> Self { @@ -161,6 +210,74 @@ mod tests { assert_eq!(meta_size as usize, 24); } + #[test] + fn test_lookup_table_meta_status() { + let mut slot_hashes = SlotHashes::default(); + for slot in 1..=MAX_ENTRIES as Slot { + slot_hashes.add(slot, Hash::new_unique()); + } + + let most_recent_slot = slot_hashes.first().unwrap().0; + let least_recent_slot = slot_hashes.last().unwrap().0; + assert!(least_recent_slot < most_recent_slot); + + // 10 was chosen because the current slot isn't necessarily the next + // slot after the most recent block + let current_slot = most_recent_slot + 10; + + let active_table = LookupTableMeta { + deactivation_slot: Slot::MAX, + ..LookupTableMeta::default() + }; + + let just_started_deactivating_table = LookupTableMeta { + deactivation_slot: current_slot, + ..LookupTableMeta::default() + }; + + let recently_started_deactivating_table = LookupTableMeta { + deactivation_slot: most_recent_slot, + ..LookupTableMeta::default() + }; + + let almost_deactivated_table = LookupTableMeta { + deactivation_slot: least_recent_slot, + ..LookupTableMeta::default() + }; + + let deactivated_table = LookupTableMeta { + deactivation_slot: least_recent_slot - 1, + ..LookupTableMeta::default() + }; + + assert_eq!( + active_table.status(current_slot, &slot_hashes), + LookupTableStatus::Activated + ); + assert_eq!( + just_started_deactivating_table.status(current_slot, &slot_hashes), + LookupTableStatus::Deactivating { + remaining_blocks: MAX_ENTRIES.saturating_add(1), + } + ); + assert_eq!( + recently_started_deactivating_table.status(current_slot, &slot_hashes), + LookupTableStatus::Deactivating { + remaining_blocks: MAX_ENTRIES, + } + ); + assert_eq!( + almost_deactivated_table.status(current_slot, &slot_hashes), + LookupTableStatus::Deactivating { + remaining_blocks: 1, + } + ); + assert_eq!( + deactivated_table.status(current_slot, &slot_hashes), + LookupTableStatus::Deactivated + ); + } + #[test] fn test_overwrite_meta_data() { let meta = LookupTableMeta::new_for_tests();