diff --git a/crates/contracts/src/precompiles/address_registry.rs b/crates/contracts/src/precompiles/address_registry.rs index 1fdec5249b..4b826c7169 100644 --- a/crates/contracts/src/precompiles/address_registry.rs +++ b/crates/contracts/src/precompiles/address_registry.rs @@ -25,6 +25,7 @@ crate::sol! { // Pure functions function isVirtualAddress(address addr) external pure returns (bool); function decodeVirtualAddress(address addr) external pure returns (bool isVirtual, bytes4 masterId, bytes6 userTag); + function isImplicitlyApproved(address addr) external view returns (bool); // Events event MasterRegistered(bytes4 indexed masterId, address indexed masterAddress); diff --git a/crates/precompiles/src/address_registry/dispatch.rs b/crates/precompiles/src/address_registry/dispatch.rs index 50ff6cc78c..343137cc78 100644 --- a/crates/precompiles/src/address_registry/dispatch.rs +++ b/crates/precompiles/src/address_registry/dispatch.rs @@ -1,11 +1,19 @@ use crate::{ - Precompile, address_registry::AddressRegistry, charge_input_cost, dispatch_call, mutate, view, + Precompile, SelectorSchedule, address_registry::AddressRegistry, charge_input_cost, + dispatch_call, mutate, view, +}; +use alloy::{ + primitives::Address, + sol_types::{SolCall, SolInterface}, }; -use alloy::{primitives::Address, sol_types::SolInterface}; use revm::precompile::PrecompileResult; -use tempo_contracts::precompiles::IAddressRegistry::IAddressRegistryCalls; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_contracts::precompiles::IAddressRegistry::{self, IAddressRegistryCalls}; use tempo_primitives::{MasterId, TempoAddressExt, UserTag}; +/// Selectors introduced at T5 (TIP-1035). +const T5_ADDED: &[[u8; 4]] = &[IAddressRegistry::isImplicitlyApprovedCall::SELECTOR]; + impl Precompile for AddressRegistry { fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { if let Some(err) = charge_input_cost(&mut self.storage, calldata) { @@ -14,7 +22,7 @@ impl Precompile for AddressRegistry { dispatch_call( calldata, - &[], + &[SelectorSchedule::new(TempoHardfork::T5).with_added(T5_ADDED)], IAddressRegistryCalls::abi_decode, |call| match call { // Registration @@ -42,6 +50,9 @@ impl Precompile for AddressRegistry { }; Ok((is_virtual, master_id, user_tag).into()) }), + IAddressRegistryCalls::isImplicitlyApproved(call) => { + view(call, |c| Ok(self.is_implicitly_approved(c.addr))) + } }, ) } @@ -55,12 +66,12 @@ mod tests { storage::{StorageCtx, hashmap::HashMapStorageProvider}, test_util::{assert_full_coverage, check_selector_coverage}, }; - use alloy::sol_types::{SolCall, SolValue}; + use alloy::sol_types::{SolCall, SolError, SolValue}; use tempo_chainspec::hardfork::TempoHardfork; #[test] fn test_selector_coverage() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T3); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); StorageCtx::enter(&mut storage, || { let mut registry = AddressRegistry::new(); @@ -77,6 +88,51 @@ mod tests { }) } + #[test] + fn test_is_implicitly_approved_selector_gated_pre_t5() -> eyre::Result<()> { + // Pre-T5: the isImplicitlyApproved selector must be treated as unknown. + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + StorageCtx::enter(&mut storage, || { + let mut registry = AddressRegistry::new(); + let call = IAddressRegistry::isImplicitlyApprovedCall { + addr: Address::ZERO, + }; + let result = registry.call(&call.abi_encode(), Address::ZERO)?; + assert!(result.is_revert()); + assert!( + tempo_contracts::precompiles::UnknownFunctionSelector::abi_decode(&result.bytes) + .is_ok() + ); + Ok(()) + }) + } + + #[test] + fn test_is_implicitly_approved_precompile_t5() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + StorageCtx::enter(&mut storage, || { + let mut registry = AddressRegistry::new(); + + // Listed precompile returns true. + let call = IAddressRegistry::isImplicitlyApprovedCall { + addr: tempo_contracts::precompiles::TIP_FEE_MANAGER_ADDRESS, + }; + let result = registry.call(&call.abi_encode(), Address::ZERO)?; + assert!(!result.is_revert()); + assert!(bool::abi_decode(&result.bytes).unwrap()); + + // Unlisted address returns false. + let call = IAddressRegistry::isImplicitlyApprovedCall { + addr: Address::random(), + }; + let result = registry.call(&call.abi_encode(), Address::ZERO)?; + assert!(!result.is_revert()); + assert!(!bool::abi_decode(&result.bytes).unwrap()); + + Ok(()) + }) + } + #[test] fn test_get_master_precompile() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T3); diff --git a/crates/precompiles/src/address_registry/mod.rs b/crates/precompiles/src/address_registry/mod.rs index a8c6b3ac55..849633801b 100644 --- a/crates/precompiles/src/address_registry/mod.rs +++ b/crates/precompiles/src/address_registry/mod.rs @@ -16,10 +16,31 @@ use alloy::{ primitives::{Address, FixedBytes, keccak256}, sol_types::SolValue, }; -pub use tempo_contracts::precompiles::{AddrRegistryError, AddrRegistryEvent, IAddressRegistry}; +use tempo_chainspec::hardfork::TempoHardfork; +pub use tempo_contracts::precompiles::{ + AddrRegistryError, AddrRegistryEvent, IAddressRegistry, STABLECOIN_DEX_ADDRESS, + TIP_FEE_MANAGER_ADDRESS, +}; use tempo_precompiles_macros::{Storable, contract}; pub use tempo_primitives::{MasterId, TempoAddressExt, UserTag}; +/// TIP-1035 Implicit Approval List. +/// +/// Precompiles on this list are authorized to call +/// [`crate::tip20::TIP20Token::system_transfer_from`], pulling TIP-20 tokens from a user without a +/// prior `approve()`. The list is gated on `TempoHardfork::T5`; before activation it is empty. +pub const IMPLICIT_APPROVAL_LIST: &[Address] = &[TIP_FEE_MANAGER_ADDRESS, STABLECOIN_DEX_ADDRESS]; + +/// Returns `true` iff `addr` is on the [`IMPLICIT_APPROVAL_LIST`] for the given hardfork. +/// +/// Before `TempoHardfork::T5` (TIP-1035 activation), returns `false` for all addresses. +pub fn is_implicitly_approved(addr: Address, hardfork: TempoHardfork) -> bool { + if !hardfork.is_t5() { + return false; + } + IMPLICIT_APPROVAL_LIST.contains(&addr) +} + /// [TIP-1022] virtual address registry contract. /// /// Maps a 4-byte [`MasterId`] to its registered master address and metadata. @@ -155,6 +176,12 @@ impl AddressRegistry { Some((master_id, _)) => Ok(self.get_master(master_id)?.unwrap_or(Address::ZERO)), } } + + /// Returns `true` iff `addr` is on the TIP-1035 [`IMPLICIT_APPROVAL_LIST`] for the active + /// hardfork. Returns `false` for all addresses before `TempoHardfork::T5`. + pub fn is_implicitly_approved(&self, addr: Address) -> bool { + is_implicitly_approved(addr, self.storage.spec()) + } } #[cfg(test)] @@ -168,6 +195,30 @@ mod tests { use alloy_primitives::hex_literal::hex; use tempo_chainspec::hardfork::TempoHardfork; + #[test] + fn test_is_implicitly_approved_pre_t5_returns_false() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + StorageCtx::enter(&mut storage, || { + let registry = AddressRegistry::new(); + assert!(!registry.is_implicitly_approved(TIP_FEE_MANAGER_ADDRESS)); + assert!(!registry.is_implicitly_approved(STABLECOIN_DEX_ADDRESS)); + assert!(!registry.is_implicitly_approved(Address::random())); + Ok(()) + }) + } + + #[test] + fn test_is_implicitly_approved_t5_lists_initial_set() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + StorageCtx::enter(&mut storage, || { + let registry = AddressRegistry::new(); + assert!(registry.is_implicitly_approved(TIP_FEE_MANAGER_ADDRESS)); + assert!(registry.is_implicitly_approved(STABLECOIN_DEX_ADDRESS)); + assert!(!registry.is_implicitly_approved(Address::random())); + Ok(()) + }) + } + #[test] fn test_register_virtual_master() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T2); diff --git a/crates/precompiles/src/stablecoin_dex/mod.rs b/crates/precompiles/src/stablecoin_dex/mod.rs index 15a52b84ac..106ea1a770 100644 --- a/crates/precompiles/src/stablecoin_dex/mod.rs +++ b/crates/precompiles/src/stablecoin_dex/mod.rs @@ -179,15 +179,23 @@ impl StablecoinDEX { } /// Transfer tokens from user, accounting for pathUSD - fn transfer_from(&mut self, token: Address, from: Address, amount: u128) -> Result<()> { - TIP20Token::from_address(token)?.transfer_from( - self.address, - ITIP20::transferFromCall { - from, - to: self.address, - amount: U256::from(amount), - }, - )?; + fn transfer_from(&mut self, token: Address, sender: Address, amount: u128) -> Result<()> { + if self.storage.spec().is_t5() { + TIP20Token::from_address(token)?.system_transfer_from( + self.address, + sender, + U256::from(amount), + )?; + } else { + TIP20Token::from_address(token)?.transfer_from( + self.address, + ITIP20::transferFromCall { + from: sender, + to: self.address, + amount: U256::from(amount), + }, + )?; + } Ok(()) } @@ -199,30 +207,30 @@ impl StablecoinDEX { /// redundant SLOAD. fn decrement_balance_or_transfer_from( &mut self, - user: Address, + sender: Address, token: Address, amount: u128, check_pause: bool, ) -> Result<()> { // Ensure that the token can be transferred let tip20 = TIP20Token::from_address(token)?; - tip20.ensure_transfer_authorized(user, self.address)?; + tip20.ensure_transfer_authorized(sender, self.address)?; - let user_balance = self.balance_of(user, token)?; + let user_balance = self.balance_of(sender, token)?; if user_balance >= amount { // When fully covered by internal balance, TIP-20 transferFrom won't run, // so we must check the pause state ourselves (spec: T4+). if check_pause && self.storage.spec().is_t4() { tip20.check_not_paused()?; } - self.sub_balance(user, token, amount) + self.sub_balance(sender, token, amount) } else { let remaining = amount .checked_sub(user_balance) .ok_or(TempoPrecompileError::under_overflow())?; - self.transfer_from(token, user, remaining)?; - self.set_balance(user, token, 0) + self.transfer_from(token, sender, remaining)?; + self.set_balance(sender, token, 0) } } diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 960721a27b..d3865eeee9 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -749,11 +749,20 @@ impl TIP20Token { Ok(true) } - /// Transfers `amount` from `from` to `to` without approval, for use - /// by other precompiles only (not exposed via ABI). Enforces - /// compliance via the [`TIP403Registry`] and [`AccountKeychain`]. + /// Transfers `amount` from `from` to `to` without checking allowances. For use by precompiles + /// on the [`crate::address_registry::IMPLICIT_APPROVAL_LIST`] only — not exposed via ABI. + /// Enforces compliance via the [`TIP403Registry`] and [`AccountKeychain`]. + /// + /// `caller` is the address of the precompile invoking this function. Starting at + /// `TempoHardfork::T5` (TIP-1035), the call returns `Unauthorized` unless `caller` is on the + /// Implicit Approval List. Pre-T5, `caller` is unchecked (preserves pre-TIP-1035 behavior of + /// the existing internal-only caller, `TipFeeManager`). + /// + /// Callers are also expected to pull only from the current `msg.sender`; this is a security + /// guideline of TIP-1035 enforced at the call site, not by this function. /// /// # Errors + /// - `Unauthorized` — `caller` is not on the Implicit Approval List (T5+) /// - `Paused` — token transfers are currently paused /// - `InvalidRecipient` — recipient address is zero /// - `PolicyForbids` — TIP-403 policy rejects sender or recipient @@ -761,11 +770,17 @@ impl TIP20Token { /// - `InsufficientBalance` — `from` balance lower than transfer amount pub fn system_transfer_from( &mut self, + caller: Address, from: Address, - to: Address, amount: U256, ) -> Result { - let to = Recipient::resolve(to)?; + // [TIP-1035] List gating: at T5+, only listed precompiles may invoke this entrypoint. + let spec = self.storage.spec(); + if spec.is_t5() && !crate::address_registry::is_implicitly_approved(caller, spec) { + return Err(TIP20Error::unauthorized().into()); + } + + let to = Recipient::resolve(caller)?; self.validate_transfer(from, &to)?; self.check_and_update_spending_limit(from, amount)?; @@ -1827,7 +1842,8 @@ pub(crate) mod tests { .with_mint(from, amount) .apply()?; - assert!(token.system_transfer_from(from, to, amount).is_ok()); + // Pre-T5: caller is unchecked (preserves pre-TIP-1035 FeeAMM behavior). + assert!(token.system_transfer_from(to, from, amount).is_ok()); assert_eq!( token.emitted_events().last().unwrap(), &TIP20Event::Transfer(ITIP20::Transfer { from, to, amount }).into_log_data() @@ -1837,6 +1853,54 @@ pub(crate) mod tests { }) } + #[test] + fn test_system_transfer_from_t5_authorized() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let admin = Address::random(); + let from = Address::random(); + let amount = U256::random() % U256::from(u128::MAX); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin) + .with_issuer(admin) + .with_mint(from, amount) + .apply()?; + + // Listed precompile is allowed to invoke `system_transfer_from`. + assert!( + token + .system_transfer_from(TIP_FEE_MANAGER_ADDRESS, from, amount) + .is_ok() + ); + + Ok(()) + }) + } + + #[test] + fn test_system_transfer_from_t5_unauthorized_reverts() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let admin = Address::random(); + let unlisted = Address::random(); + let from = Address::random(); + let amount = U256::random() % U256::from(u128::MAX); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin) + .with_issuer(admin) + .with_mint(from, amount) + .apply()?; + + // Unlisted callers are rejected with `Unauthorized` at T5+. + assert!(matches!( + token.system_transfer_from(unlisted, from, amount), + Err(TempoPrecompileError::TIP20(TIP20Error::Unauthorized(_))) + )); + + Ok(()) + }) + } + #[test] fn test_initialize_sets_next_quote_token() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new(1); diff --git a/crates/precompiles/src/tip_fee_manager/amm.rs b/crates/precompiles/src/tip_fee_manager/amm.rs index 9ac0dcff99..e7429ef32e 100644 --- a/crates/precompiles/src/tip_fee_manager/amm.rs +++ b/crates/precompiles/src/tip_fee_manager/amm.rs @@ -184,8 +184,8 @@ impl TipFeeManager { let amount_in = U256::from(amount_in); let amount_out = U256::from(amount_out); TIP20Token::from_address(validator_token)?.system_transfer_from( - msg_sender, self.address, + msg_sender, amount_in, )?; @@ -295,8 +295,8 @@ impl TipFeeManager { // Transfer validator tokens from user let _ = TIP20Token::from_address(validator_token)?.system_transfer_from( - msg_sender, self.address, + msg_sender, amount_validator_token, )?;