From 31c38b40525370737382efb4f99799929323cafc Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:20:22 +0000 Subject: [PATCH] feat(chainspec): add FeatureRegistry precompile with admin killswitch Adds a FeatureRegistry precompile at 0xFEA7...0000 that stores a bitmap of active features and supports: - Immediate activate/deactivate by the admin (owner) - Timestamp-scheduled activations with admin killswitch - deactivate() clears both the bitmap bit AND any pending schedule - cancelScheduledActivation() for surgical schedule removal - transferOwnership() for admin rotation Also adds TempoFeatures::from_state() to read the feature bitmap directly from on-chain storage via SLOAD, enabling per-block feature resolution without full EVM execution. Co-authored-by: Arsenii Kulikov Amp-Thread-ID: https://ampcode.com/threads/T-019c9bc1-ca89-701e-9632-452eefceccdc Co-authored-by: Amp --- crates/chainspec/src/features.rs | 56 ++ .../src/precompiles/feature_registry.rs | 91 +++ crates/contracts/src/precompiles/mod.rs | 4 + crates/precompiles/src/error.rs | 13 +- .../src/feature_registry/dispatch.rs | 144 +++++ .../precompiles/src/feature_registry/mod.rs | 578 ++++++++++++++++++ crates/precompiles/src/lib.rs | 26 +- 7 files changed, 906 insertions(+), 6 deletions(-) create mode 100644 crates/contracts/src/precompiles/feature_registry.rs create mode 100644 crates/precompiles/src/feature_registry/dispatch.rs create mode 100644 crates/precompiles/src/feature_registry/mod.rs diff --git a/crates/chainspec/src/features.rs b/crates/chainspec/src/features.rs index 1fbf70c9a0..cc646149a8 100644 --- a/crates/chainspec/src/features.rs +++ b/crates/chainspec/src/features.rs @@ -20,8 +20,14 @@ //! Existing hardforks (T0–T2) are mapped to feature sets for backward compatibility. //! New protocol changes should be defined as features directly, not as hardfork variants. +use alloy_primitives::{Address, U256, address, keccak256}; + use crate::hardfork::TempoHardfork; +/// Address of the FeatureRegistry precompile. +pub const FEATURE_REGISTRY_ADDRESS: Address = + address!("0xFEA7000000000000000000000000000000000000"); + /// A single feature identified by a numeric ID. /// /// Features are cheap to copy and compare. The ID is an index into the @@ -203,6 +209,56 @@ impl TempoFeatures { let id = feature.id(); ((id / 64) as usize, id % 64) } + + /// Read active features directly from on-chain state via SLOAD. + /// + /// Reads the feature bitmap from the `FeatureRegistry` precompile's storage. + /// The bitmap is stored as a `Mapping` at slot 1 (the second field + /// in the contract struct). Each 256-bit word encodes 256 features. + /// + /// Stops reading when a zero word is encountered (assumes features are + /// activated sequentially from word 0). + pub fn from_state( + mut sload: impl FnMut(Address, U256) -> Result>, + ) -> Result> { + let mut features = Self::empty(); + + // Slot 1 is the base slot for the `features` mapping (Mapping). + // The actual storage slot for mapping key `k` is keccak256(k || base_slot). + let base_slot = U256::from(1); + + for word_index in 0u64..16 { + // Compute Solidity mapping slot: keccak256(abi.encode(key, base_slot)) + let key = U256::from(word_index); + let mut buf = [0u8; 64]; + buf[0..32].copy_from_slice(&key.to_be_bytes::<32>()); + buf[32..64].copy_from_slice(&base_slot.to_be_bytes::<32>()); + let slot = U256::from_be_bytes(keccak256(buf).0); + + let word = sload(FEATURE_REGISTRY_ADDRESS, slot)?; + if word.is_zero() { + break; + } + + // Each U256 word encodes 256 features; convert to our u64-word bitset + let word_bytes = word.to_le_bytes::<32>(); + for (sub_idx, chunk) in word_bytes.chunks_exact(8).enumerate() { + let sub_word = u64::from_le_bytes(chunk.try_into().unwrap()); + if sub_word == 0 { + continue; + } + for bit in 0..64u32 { + if sub_word & (1u64 << bit) != 0 { + let feature_id = + word_index as u32 * 256 + sub_idx as u32 * 64 + bit; + features.insert(TempoFeature::new(feature_id)); + } + } + } + } + + Ok(features) + } } impl FromIterator for TempoFeatures { diff --git a/crates/contracts/src/precompiles/feature_registry.rs b/crates/contracts/src/precompiles/feature_registry.rs new file mode 100644 index 0000000000..ff45ee77a9 --- /dev/null +++ b/crates/contracts/src/precompiles/feature_registry.rs @@ -0,0 +1,91 @@ +pub use IFeatureRegistry::IFeatureRegistryErrors as FeatureRegistryError; + +crate::sol! { + /// Feature Registry interface for managing protocol feature flags. + /// + /// Stores a bitmap of active features and allows the admin to activate, + /// deactivate, or schedule features for timestamp-based activation. + /// The admin can also killswitch (cancel) scheduled features. + #[derive(Debug, PartialEq, Eq)] + #[sol(abi)] + interface IFeatureRegistry { + // ===================================================================== + // View functions + // ===================================================================== + + /// Get a single 256-bit word of the feature bitmap at `index`. + function featureWord(uint64 index) external view returns (uint256); + + /// Check whether a feature is currently active. + function isActive(uint32 featureId) external view returns (bool); + + /// Get the scheduled activation timestamp for a feature (0 = not scheduled). + function scheduledActivation(uint32 featureId) external view returns (uint64); + + /// Get the contract owner / admin. + function owner() external view returns (address); + + // ===================================================================== + // Mutate functions (owner only) + // ===================================================================== + + /// Immediately activate a feature. + function activate(uint32 featureId) external; + + /// Immediately deactivate a feature (killswitch). + function deactivate(uint32 featureId) external; + + /// Schedule a feature to activate at a future timestamp. + function scheduleActivation(uint32 featureId, uint64 activateAt) external; + + /// Cancel a scheduled activation (killswitch for scheduled features). + function cancelScheduledActivation(uint32 featureId) external; + + /// Transfer admin ownership. + function transferOwnership(address newOwner) external; + + // ===================================================================== + // Errors + // ===================================================================== + + error Unauthorized(); + error FeatureAlreadyActive(uint32 featureId); + error FeatureNotActive(uint32 featureId); + error FeatureNotScheduled(uint32 featureId); + error FeatureAlreadyScheduled(uint32 featureId); + error InvalidActivationTime(); + error InvalidOwner(); + } +} + +impl FeatureRegistryError { + pub const fn unauthorized() -> Self { + Self::Unauthorized(IFeatureRegistry::Unauthorized {}) + } + + pub const fn feature_already_active(feature_id: u32) -> Self { + Self::FeatureAlreadyActive(IFeatureRegistry::FeatureAlreadyActive { featureId: feature_id }) + } + + pub const fn feature_not_active(feature_id: u32) -> Self { + Self::FeatureNotActive(IFeatureRegistry::FeatureNotActive { featureId: feature_id }) + } + + pub const fn feature_not_scheduled(feature_id: u32) -> Self { + Self::FeatureNotScheduled(IFeatureRegistry::FeatureNotScheduled { featureId: feature_id }) + } + + pub const fn feature_already_scheduled(feature_id: u32) -> Self { + Self::FeatureAlreadyScheduled(IFeatureRegistry::FeatureAlreadyScheduled { + featureId: feature_id, + }) + } + + pub const fn invalid_activation_time() -> Self { + Self::InvalidActivationTime(IFeatureRegistry::InvalidActivationTime {}) + } + + pub const fn invalid_owner() -> Self { + Self::InvalidOwner(IFeatureRegistry::InvalidOwner {}) + } +} diff --git a/crates/contracts/src/precompiles/mod.rs b/crates/contracts/src/precompiles/mod.rs index 66a6770ee8..665774b890 100644 --- a/crates/contracts/src/precompiles/mod.rs +++ b/crates/contracts/src/precompiles/mod.rs @@ -1,5 +1,6 @@ pub mod account_keychain; pub mod common_errors; +pub mod feature_registry; pub mod nonce; pub mod stablecoin_dex; pub mod tip20; @@ -12,6 +13,7 @@ pub mod validator_config_v2; pub use account_keychain::*; use alloy_primitives::{Address, address}; pub use common_errors::*; +pub use feature_registry::*; pub use nonce::*; pub use stablecoin_dex::*; pub use tip_fee_manager::*; @@ -35,3 +37,5 @@ pub const ACCOUNT_KEYCHAIN_ADDRESS: Address = address!("0xAAAAAAAA00000000000000000000000000000000"); pub const VALIDATOR_CONFIG_V2_ADDRESS: Address = address!("0xCCCCCCCC00000000000000000000000000000001"); +pub const FEATURE_REGISTRY_ADDRESS: Address = + address!("0xFEA7000000000000000000000000000000000000"); diff --git a/crates/precompiles/src/error.rs b/crates/precompiles/src/error.rs index 74d7946b48..421b278060 100644 --- a/crates/precompiles/src/error.rs +++ b/crates/precompiles/src/error.rs @@ -13,9 +13,9 @@ use revm::{ precompile::{PrecompileError, PrecompileOutput, PrecompileResult}, }; use tempo_contracts::precompiles::{ - AccountKeychainError, FeeManagerError, NonceError, RolesAuthError, StablecoinDEXError, - TIP20FactoryError, TIP403RegistryError, TIPFeeAMMError, UnknownFunctionSelector, - ValidatorConfigError, ValidatorConfigV2Error, + AccountKeychainError, FeatureRegistryError, FeeManagerError, NonceError, RolesAuthError, + StablecoinDEXError, TIP20FactoryError, TIP403RegistryError, TIPFeeAMMError, + UnknownFunctionSelector, ValidatorConfigError, ValidatorConfigV2Error, }; /// Top-level error type for all Tempo precompile operations @@ -70,6 +70,10 @@ pub enum TempoPrecompileError { #[error("Account keychain error: {0:?}")] AccountKeychainError(AccountKeychainError), + /// Error from feature registry precompile + #[error("Feature registry error: {0:?}")] + FeatureRegistryError(FeatureRegistryError), + #[error("Gas limit exceeded")] OutOfGas, @@ -110,6 +114,7 @@ impl TempoPrecompileError { | Self::ValidatorConfigError(_) | Self::ValidatorConfigV2Error(_) | Self::AccountKeychainError(_) + | Self::FeatureRegistryError(_) | Self::UnknownFunctionSelector(_) => false, } } @@ -142,6 +147,7 @@ impl TempoPrecompileError { Self::ValidatorConfigError(e) => e.abi_encode().into(), Self::ValidatorConfigV2Error(e) => e.abi_encode().into(), Self::AccountKeychainError(e) => e.abi_encode().into(), + Self::FeatureRegistryError(e) => e.abi_encode().into(), Self::OutOfGas => { return Err(PrecompileError::OutOfGas); } @@ -208,6 +214,7 @@ pub fn error_decoder_registry() -> TempoPrecompileErrorRegistry { add_errors_to_registry(&mut registry, TempoPrecompileError::ValidatorConfigError); add_errors_to_registry(&mut registry, TempoPrecompileError::ValidatorConfigV2Error); add_errors_to_registry(&mut registry, TempoPrecompileError::AccountKeychainError); + add_errors_to_registry(&mut registry, TempoPrecompileError::FeatureRegistryError); registry } diff --git a/crates/precompiles/src/feature_registry/dispatch.rs b/crates/precompiles/src/feature_registry/dispatch.rs new file mode 100644 index 0000000000..b25685b780 --- /dev/null +++ b/crates/precompiles/src/feature_registry/dispatch.rs @@ -0,0 +1,144 @@ +use super::*; +use crate::{Precompile, dispatch_call, input_cost, mutate_void, view}; +use alloy::{primitives::Address, sol_types::SolInterface}; +use revm::precompile::{PrecompileError, PrecompileResult}; +use tempo_contracts::precompiles::IFeatureRegistry::IFeatureRegistryCalls; + +impl Precompile for FeatureRegistry { + fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { + self.storage + .deduct_gas(input_cost(calldata.len())) + .map_err(|_| PrecompileError::OutOfGas)?; + + dispatch_call( + calldata, + IFeatureRegistryCalls::abi_decode, + |call| match call { + IFeatureRegistryCalls::owner(call) => view(call, |_| self.owner()), + IFeatureRegistryCalls::featureWord(call) => { + view(call, |c| self.feature_word(c.index)) + } + IFeatureRegistryCalls::isActive(call) => { + view(call, |c| self.is_active(c.featureId)) + } + IFeatureRegistryCalls::scheduledActivation(call) => { + view(call, |c| self.scheduled_activation(c.featureId)) + } + IFeatureRegistryCalls::activate(call) => { + mutate_void(call, msg_sender, |s, c| self.activate(s, c.featureId)) + } + IFeatureRegistryCalls::deactivate(call) => { + mutate_void(call, msg_sender, |s, c| self.deactivate(s, c.featureId)) + } + IFeatureRegistryCalls::scheduleActivation(call) => { + mutate_void(call, msg_sender, |s, c| { + self.schedule_activation(s, c.featureId, c.activateAt) + }) + } + IFeatureRegistryCalls::cancelScheduledActivation(call) => { + mutate_void(call, msg_sender, |s, c| { + self.cancel_scheduled_activation(s, c.featureId) + }) + } + IFeatureRegistryCalls::transferOwnership(call) => { + mutate_void(call, msg_sender, |s, c| { + self.transfer_ownership(s, c.newOwner) + }) + } + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + expect_precompile_revert, + storage::{StorageCtx, hashmap::HashMapStorageProvider}, + test_util::{assert_full_coverage, check_selector_coverage}, + }; + use alloy::sol_types::{SolCall, SolValue}; + use tempo_contracts::precompiles::{ + FeatureRegistryError, IFeatureRegistry, IFeatureRegistry::IFeatureRegistryCalls, + }; + + #[test] + fn test_dispatch_owner() -> eyre::Result<()> { + let admin = Address::random(); + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + let calldata = IFeatureRegistry::ownerCall {}.abi_encode(); + let result = reg.call(&calldata, admin)?; + + assert!(!result.reverted); + let decoded = Address::abi_decode(&result.bytes)?; + assert_eq!(decoded, admin); + + Ok(()) + }) + } + + #[test] + fn test_dispatch_activate_and_is_active() -> eyre::Result<()> { + let admin = Address::random(); + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + // Activate feature 5 + let calldata = IFeatureRegistry::activateCall { featureId: 5 }.abi_encode(); + let result = reg.call(&calldata, admin)?; + assert!(!result.reverted); + + // Check isActive + let calldata = IFeatureRegistry::isActiveCall { featureId: 5 }.abi_encode(); + let result = reg.call(&calldata, admin)?; + assert!(!result.reverted); + let active = bool::abi_decode(&result.bytes)?; + assert!(active); + + Ok(()) + }) + } + + #[test] + fn test_dispatch_unauthorized() -> eyre::Result<()> { + let admin = Address::random(); + let non_owner = Address::random(); + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + let calldata = IFeatureRegistry::activateCall { featureId: 0 }.abi_encode(); + let result = reg.call(&calldata, non_owner); + expect_precompile_revert(&result, FeatureRegistryError::unauthorized()); + + Ok(()) + }) + } + + #[test] + fn test_selector_coverage() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + + let unsupported = check_selector_coverage( + &mut reg, + IFeatureRegistryCalls::SELECTORS, + "IFeatureRegistry", + IFeatureRegistryCalls::name_by_selector, + ); + + assert_full_coverage([unsupported]); + + Ok(()) + }) + } +} diff --git a/crates/precompiles/src/feature_registry/mod.rs b/crates/precompiles/src/feature_registry/mod.rs new file mode 100644 index 0000000000..272bd0f2d5 --- /dev/null +++ b/crates/precompiles/src/feature_registry/mod.rs @@ -0,0 +1,578 @@ +pub mod dispatch; + +use tempo_contracts::precompiles::FEATURE_REGISTRY_ADDRESS; +pub use tempo_contracts::precompiles::{FeatureRegistryError, IFeatureRegistry}; +use tempo_precompiles_macros::contract; + +use crate::{ + error::Result, + storage::{Handler, Mapping}, +}; +use alloy::primitives::{Address, U256}; + +/// Feature Registry precompile. +/// +/// Stores a bitmap of active features and supports: +/// - Immediate activation/deactivation by the admin (owner) +/// - Timestamp-scheduled activation with admin killswitch +/// +/// Storage layout: +/// - Slot 0: admin config (owner address) +/// - Slot 1: feature bitmap (mapping from word_index → u256) +/// - Slot 2: scheduled activations (mapping from feature_id → timestamp) +#[contract(addr = FEATURE_REGISTRY_ADDRESS)] +pub struct FeatureRegistry { + owner: Address, + features: Mapping, + scheduled: Mapping, +} + +impl FeatureRegistry { + /// Initialize the feature registry with the given admin owner. + pub fn initialize(&mut self, admin: Address) -> Result<()> { + self.__initialize()?; + self.owner.write(admin) + } + + // ========================================================================= + // View functions + // ========================================================================= + + /// Get the admin owner address. + pub fn owner(&self) -> Result
{ + self.owner.read() + } + + /// Get a single 256-bit word of the feature bitmap. + pub fn feature_word(&self, index: u64) -> Result { + self.features[index].read() + } + + /// Check whether a specific feature is active. + /// + /// A feature is active if its bit is set in the bitmap, OR if it has a + /// scheduled activation timestamp that has passed. + pub fn is_active(&self, feature_id: u32) -> Result { + // Check the bitmap first + let word_index = (feature_id / 256) as u64; + let bit_index = feature_id % 256; + let word = self.features[word_index].read()?; + if word & (U256::from(1) << bit_index) != U256::ZERO { + return Ok(true); + } + + // Check scheduled activation + let scheduled_at = self.scheduled[feature_id].read()?; + if scheduled_at != 0 { + let now: u64 = self.storage.timestamp().saturating_to(); + if now >= scheduled_at { + return Ok(true); + } + } + + Ok(false) + } + + /// Get the scheduled activation timestamp for a feature (0 if not scheduled). + pub fn scheduled_activation(&self, feature_id: u32) -> Result { + self.scheduled[feature_id].read() + } + + // ========================================================================= + // Mutate functions (owner only) + // ========================================================================= + + fn require_owner(&self, caller: Address) -> Result<()> { + let admin = self.owner.read()?; + if caller != admin { + Err(FeatureRegistryError::unauthorized())? + } + Ok(()) + } + + /// Immediately activate a feature by setting its bit in the bitmap. + pub fn activate(&mut self, caller: Address, feature_id: u32) -> Result<()> { + self.require_owner(caller)?; + + let word_index = (feature_id / 256) as u64; + let bit_index = feature_id % 256; + let word = self.features[word_index].read()?; + let bit = U256::from(1) << bit_index; + + if word & bit != U256::ZERO { + Err(FeatureRegistryError::feature_already_active(feature_id))? + } + + self.features[word_index].write(word | bit)?; + + // Clear any scheduled activation since the feature is now active + let scheduled_at = self.scheduled[feature_id].read()?; + if scheduled_at != 0 { + self.scheduled[feature_id].write(0)?; + } + + Ok(()) + } + + /// Immediately deactivate a feature (killswitch). + /// + /// This clears the bit in the bitmap AND cancels any scheduled activation. + pub fn deactivate(&mut self, caller: Address, feature_id: u32) -> Result<()> { + self.require_owner(caller)?; + + let word_index = (feature_id / 256) as u64; + let bit_index = feature_id % 256; + let word = self.features[word_index].read()?; + let bit = U256::from(1) << bit_index; + + let was_active = word & bit != U256::ZERO; + let scheduled_at = self.scheduled[feature_id].read()?; + + if !was_active && scheduled_at == 0 { + Err(FeatureRegistryError::feature_not_active(feature_id))? + } + + // Clear the bit + if was_active { + self.features[word_index].write(word & !bit)?; + } + + // Also cancel any scheduled activation + if scheduled_at != 0 { + self.scheduled[feature_id].write(0)?; + } + + Ok(()) + } + + /// Schedule a feature to activate at a future timestamp. + pub fn schedule_activation( + &mut self, + caller: Address, + feature_id: u32, + activate_at: u64, + ) -> Result<()> { + self.require_owner(caller)?; + + // Must be in the future + let now: u64 = self.storage.timestamp().saturating_to(); + if activate_at <= now { + Err(FeatureRegistryError::invalid_activation_time())? + } + + // Must not already be active + let word_index = (feature_id / 256) as u64; + let bit_index = feature_id % 256; + let word = self.features[word_index].read()?; + if word & (U256::from(1) << bit_index) != U256::ZERO { + Err(FeatureRegistryError::feature_already_active(feature_id))? + } + + // Must not already be scheduled + let existing = self.scheduled[feature_id].read()?; + if existing != 0 { + Err(FeatureRegistryError::feature_already_scheduled(feature_id))? + } + + self.scheduled[feature_id].write(activate_at) + } + + /// Cancel a scheduled activation (killswitch for scheduled features). + pub fn cancel_scheduled_activation( + &mut self, + caller: Address, + feature_id: u32, + ) -> Result<()> { + self.require_owner(caller)?; + + let scheduled_at = self.scheduled[feature_id].read()?; + if scheduled_at == 0 { + Err(FeatureRegistryError::feature_not_scheduled(feature_id))? + } + + self.scheduled[feature_id].write(0) + } + + /// Transfer admin ownership. + pub fn transfer_ownership(&mut self, caller: Address, new_owner: Address) -> Result<()> { + self.require_owner(caller)?; + + if new_owner == Address::ZERO { + Err(FeatureRegistryError::invalid_owner())? + } + + self.owner.write(new_owner) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider}; + + #[test] + fn test_initialize_and_owner() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + assert!(reg.is_initialized()?); + assert_eq!(reg.owner()?, admin); + Ok(()) + }) + } + + #[test] + fn test_activate_and_deactivate() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + assert!(!reg.is_active(0)?); + + reg.activate(admin, 0)?; + assert!(reg.is_active(0)?); + + reg.deactivate(admin, 0)?; + assert!(!reg.is_active(0)?); + + Ok(()) + }) + } + + #[test] + fn test_activate_rejects_non_owner() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + let non_owner = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + let result = reg.activate(non_owner, 0); + assert_eq!( + result, + Err(FeatureRegistryError::unauthorized().into()) + ); + Ok(()) + }) + } + + #[test] + fn test_activate_already_active() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + reg.activate(admin, 5)?; + let result = reg.activate(admin, 5); + assert_eq!( + result, + Err(FeatureRegistryError::feature_already_active(5).into()) + ); + Ok(()) + }) + } + + #[test] + fn test_deactivate_not_active() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + let result = reg.deactivate(admin, 42); + assert_eq!( + result, + Err(FeatureRegistryError::feature_not_active(42).into()) + ); + Ok(()) + }) + } + + #[test] + fn test_schedule_activation() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + storage.set_timestamp(U256::from(1000u64)); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + // Schedule for timestamp 2000 + reg.schedule_activation(admin, 7, 2000)?; + assert_eq!(reg.scheduled_activation(7)?, 2000); + + // Not active yet (current time is 1000) + assert!(!reg.is_active(7)?); + + Ok::<_, eyre::Report>(()) + })?; + + // After the scheduled time, feature should be active + storage.set_timestamp(U256::from(2000u64)); + StorageCtx::enter(&mut storage, || { + let reg = FeatureRegistry::new(); + assert!(reg.is_active(7)?); + Ok(()) + }) + } + + #[test] + fn test_schedule_past_timestamp_fails() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + storage.set_timestamp(U256::from(1000u64)); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + let result = reg.schedule_activation(admin, 7, 500); + assert_eq!( + result, + Err(FeatureRegistryError::invalid_activation_time().into()) + ); + Ok(()) + }) + } + + #[test] + fn test_cancel_scheduled_activation() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + storage.set_timestamp(U256::from(1000u64)); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + reg.schedule_activation(admin, 3, 2000)?; + assert_eq!(reg.scheduled_activation(3)?, 2000); + + // Killswitch: cancel the scheduled activation + reg.cancel_scheduled_activation(admin, 3)?; + assert_eq!(reg.scheduled_activation(3)?, 0); + assert!(!reg.is_active(3)?); + + Ok(()) + }) + } + + #[test] + fn test_cancel_not_scheduled() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + let result = reg.cancel_scheduled_activation(admin, 99); + assert_eq!( + result, + Err(FeatureRegistryError::feature_not_scheduled(99).into()) + ); + Ok(()) + }) + } + + #[test] + fn test_deactivate_killswitches_scheduled_feature() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + storage.set_timestamp(U256::from(1000u64)); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + // Schedule feature + reg.schedule_activation(admin, 10, 2000)?; + assert_eq!(reg.scheduled_activation(10)?, 2000); + + // Deactivate also cancels scheduled (even though bit isn't set) + reg.deactivate(admin, 10)?; + assert_eq!(reg.scheduled_activation(10)?, 0); + assert!(!reg.is_active(10)?); + + Ok(()) + }) + } + + #[test] + fn test_activate_clears_schedule() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + storage.set_timestamp(U256::from(1000u64)); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + reg.schedule_activation(admin, 4, 2000)?; + assert_eq!(reg.scheduled_activation(4)?, 2000); + + // Immediately activate should clear the schedule + reg.activate(admin, 4)?; + assert!(reg.is_active(4)?); + assert_eq!(reg.scheduled_activation(4)?, 0); + + Ok(()) + }) + } + + #[test] + fn test_multiple_features_independent() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + reg.activate(admin, 0)?; + reg.activate(admin, 5)?; + reg.activate(admin, 11)?; + + assert!(reg.is_active(0)?); + assert!(!reg.is_active(1)?); + assert!(reg.is_active(5)?); + assert!(reg.is_active(11)?); + + reg.deactivate(admin, 5)?; + assert!(reg.is_active(0)?); + assert!(!reg.is_active(5)?); + assert!(reg.is_active(11)?); + + Ok(()) + }) + } + + #[test] + fn test_high_feature_ids() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + // Feature ID 300 (word index 1, bit 44) + reg.activate(admin, 300)?; + assert!(reg.is_active(300)?); + assert!(!reg.is_active(299)?); + + // Feature ID 1000 (word index 3, bit 232) + reg.activate(admin, 1000)?; + assert!(reg.is_active(1000)?); + + Ok(()) + }) + } + + #[test] + fn test_transfer_ownership() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + let new_admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + reg.transfer_ownership(admin, new_admin)?; + assert_eq!(reg.owner()?, new_admin); + + // Old admin can no longer activate + let result = reg.activate(admin, 0); + assert_eq!( + result, + Err(FeatureRegistryError::unauthorized().into()) + ); + + // New admin can + reg.activate(new_admin, 0)?; + assert!(reg.is_active(0)?); + + Ok(()) + }) + } + + #[test] + fn test_transfer_ownership_to_zero_fails() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + let result = reg.transfer_ownership(admin, Address::ZERO); + assert_eq!( + result, + Err(FeatureRegistryError::invalid_owner().into()) + ); + Ok(()) + }) + } + + #[test] + fn test_feature_word_returns_bitmap() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + reg.activate(admin, 0)?; + reg.activate(admin, 3)?; + + let word = reg.feature_word(0)?; + // Bits 0 and 3 set: 0b1001 = 9 + assert_eq!(word, U256::from(9)); + + // Second word should be empty + let word1 = reg.feature_word(1)?; + assert_eq!(word1, U256::ZERO); + + Ok(()) + }) + } + + #[test] + fn test_schedule_already_active_fails() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + storage.set_timestamp(U256::from(1000u64)); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + reg.activate(admin, 1)?; + let result = reg.schedule_activation(admin, 1, 2000); + assert_eq!( + result, + Err(FeatureRegistryError::feature_already_active(1).into()) + ); + Ok(()) + }) + } + + #[test] + fn test_schedule_already_scheduled_fails() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new(1); + storage.set_timestamp(U256::from(1000u64)); + let admin = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut reg = FeatureRegistry::new(); + reg.initialize(admin)?; + + reg.schedule_activation(admin, 2, 2000)?; + let result = reg.schedule_activation(admin, 2, 3000); + assert_eq!( + result, + Err(FeatureRegistryError::feature_already_scheduled(2).into()) + ); + Ok(()) + }) + } +} diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 5c82d7f509..2f3fe2b3b9 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -10,6 +10,7 @@ pub mod storage; pub(crate) mod ip_validation; pub mod account_keychain; +pub mod feature_registry; pub mod nonce; pub mod stablecoin_dex; pub mod tip20; @@ -24,6 +25,7 @@ pub mod test_util; use crate::{ account_keychain::AccountKeychain, + feature_registry::FeatureRegistry, nonce::NonceManager, stablecoin_dex::StablecoinDEX, storage::StorageCtx, @@ -52,9 +54,10 @@ use revm::{ }; pub use tempo_contracts::precompiles::{ - ACCOUNT_KEYCHAIN_ADDRESS, DEFAULT_FEE_TOKEN, NONCE_PRECOMPILE_ADDRESS, PATH_USD_ADDRESS, - STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS, TIP20_FACTORY_ADDRESS, - TIP403_REGISTRY_ADDRESS, VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, + ACCOUNT_KEYCHAIN_ADDRESS, DEFAULT_FEE_TOKEN, FEATURE_REGISTRY_ADDRESS, + NONCE_PRECOMPILE_ADDRESS, PATH_USD_ADDRESS, STABLECOIN_DEX_ADDRESS, + TIP_FEE_MANAGER_ADDRESS, TIP20_FACTORY_ADDRESS, TIP403_REGISTRY_ADDRESS, + VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, }; // Re-export storage layout helpers for read-only contexts (e.g., pool validation) @@ -118,6 +121,8 @@ pub fn extend_tempo_precompiles(precompiles: &mut PrecompilesMap, cfg: &CfgEnv) -> DynPrecompile { + tempo_precompile!("FeatureRegistry", cfg, |input| { FeatureRegistry::new() }) + } +} + /// EVM precompile wrapper for [`ValidatorConfigV2`]. pub struct ValidatorConfigV2Precompile; impl ValidatorConfigV2Precompile { @@ -640,6 +653,13 @@ mod tests { "AccountKeychain should be registered" ); + // FeatureRegistry should be registered + let feature_registry_precompile = precompiles.get(&FEATURE_REGISTRY_ADDRESS); + assert!( + feature_registry_precompile.is_some(), + "FeatureRegistry should be registered" + ); + // TIP20 tokens with prefix should be registered let tip20_precompile = precompiles.get(&PATH_USD_ADDRESS); assert!(