Skip to content
Open
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
1 change: 1 addition & 0 deletions crates/contracts/src/precompiles/address_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
68 changes: 62 additions & 6 deletions crates/precompiles/src/address_registry/dispatch.rs
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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)))
}
},
)
}
Expand All @@ -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();

Expand All @@ -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);
Expand Down
53 changes: 52 additions & 1 deletion crates/precompiles/src/address_registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also add TIP_20_ESCROW_PRECOMPILE to this, ig it also needs to be updated in the TIP itself.
CC: @danrobinson

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't yet have this const defined so i'd suggest we do it in scope of TIP-1034


/// 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.
Expand Down Expand Up @@ -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)]
Expand All @@ -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);
Expand Down
38 changes: 23 additions & 15 deletions crates/precompiles/src/stablecoin_dex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we create this fn as a new TIP20 helper (i.e transfer_from_internal(&mut self, caller: Address, from: Address, amount: U256) which routes based on AddressRegistry::is_implicitly_approved()?

it would ensure that impl and registry are always aligned + that to is always the caller precompile

Copy link
Copy Markdown
Member Author

@klkvr klkvr May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added here be93800

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(())
}

Expand All @@ -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)
}
}

Expand Down
76 changes: 70 additions & 6 deletions crates/precompiles/src/tip20/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,23 +749,38 @@ 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
/// - `SpendingLimitExceeded` — access key spending limit exceeded
/// - `InsufficientBalance` — `from` balance lower than transfer amount
pub fn system_transfer_from(
&mut self,
caller: Address,
from: Address,
to: Address,
amount: U256,
) -> Result<bool> {
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)?;

Expand Down Expand Up @@ -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()
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions crates/precompiles/src/tip_fee_manager/amm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)?;

Expand Down Expand Up @@ -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,
)?;

Expand Down
Loading