Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion crates/contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! accepts batch proofs, and processes withdrawals back to L1 recipients.
//! - **ZoneOutbox** — deployed on the Zone L2. Collects user withdrawal requests, builds
//! withdrawal hash chains, and exposes `LastBatch` state for proof generation.
//! - **ZoneInbox**, **TempoState**, **ZoneTxContext** — Zone L2 predeploys.
//! - **ZoneInbox**, **TempoState**, **ZoneTxContext**, **ZoneFeeManager** — Zone L2 system contracts and precompiles.
//! - **ZoneFactory**, **SwapAndDepositRouter** — deployed on Tempo L1.

#![cfg_attr(not(feature = "std"), no_std)]
Expand Down
7 changes: 5 additions & 2 deletions crates/contracts/src/precompiles/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod common;
pub mod swap_and_deposit_router;
pub mod tempo_state;
pub mod zone_factory;
pub mod zone_fee_manager;
pub mod zone_inbox;
pub mod zone_outbox;
pub mod zone_portal;
Expand All @@ -11,6 +12,7 @@ pub use common::*;
pub use swap_and_deposit_router::*;
pub use tempo_state::*;
pub use zone_factory::*;
pub use zone_fee_manager::*;
pub use zone_inbox::*;
pub use zone_outbox::*;
pub use zone_portal::*;
Expand All @@ -21,6 +23,7 @@ pub use zone_tx_context::*;
// contracts crate, e.g. `tempo_zone_contracts::TEMPO_STATE_ADDRESS`.
pub use zone_primitives::constants::{
EMPTY_SENTINEL, MAX_WITHDRAWAL_GAS_LIMIT, NO_QUEUE_INDEX, PORTAL_ADMIN_SLOT,
PORTAL_PENDING_SEQUENCER_SLOT, PORTAL_SEQUENCER_SLOT, TEMPO_STATE_ADDRESS, ZONE_CONFIG_ADDRESS,
ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, ZONE_TOKEN_ADDRESS, ZONE_TX_CONTEXT_ADDRESS,
PORTAL_PENDING_SEQUENCER_SLOT, PORTAL_SEQUENCER_SLOT, PORTAL_TOKEN_CONFIGS_SLOT,
TEMPO_STATE_ADDRESS, ZONE_CONFIG_ADDRESS, ZONE_FEE_MANAGER_ADDRESS, ZONE_INBOX_ADDRESS,
ZONE_OUTBOX_ADDRESS, ZONE_TOKEN_ADDRESS, ZONE_TX_CONTEXT_ADDRESS,
};
15 changes: 15 additions & 0 deletions crates/contracts/src/precompiles/zone_fee_manager.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! `ZoneFeeManager` — Zone L2 protocol fee manager.

crate::sol! {
#[derive(Debug)]
contract IZoneFeeManager {
event UserTokenSet(address indexed user, address indexed token);
event FeesDistributed(address indexed sequencer, address indexed token, uint256 amount);

function userTokens(address user) external view returns (address);
function collectedFees(address sequencer, address token) external view returns (uint256);
function setUserToken(address token) external;
function distributeFees(address sequencer, address token) external;
function isEnabledToken(address token) external view returns (bool);
}
}
177 changes: 1 addition & 176 deletions crates/evm/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,9 @@ use alloy_evm::{
};
use reth_evm::block::StateDB;
use reth_revm::Inspector;
use revm::context::{ContextTr, JournalTr, Transaction};
use tempo_evm::{TempoBlockExecutionCtx, TempoReceiptBuilder};
use tempo_precompiles::{
TIP_FEE_MANAGER_ADDRESS, storage::actions::StorageActions, tip_fee_manager::TipFeeManager,
};
use tempo_primitives::{TempoReceipt, TempoTxEnvelope, TempoTxType};
use tempo_revm::{TempoStateAccess, evm::TempoContext};
use tempo_revm::evm::TempoContext;
use zone_chainspec::ZoneChainSpec;

use crate::{ZoneEvm, tx_context};
Expand Down Expand Up @@ -50,32 +46,6 @@ where
),
}
}

/// Overrides `validatorTokens[beneficiary]` to match the resolved fee token
/// so the handler skips FeeAMM.
fn override_validator_token(&mut self) {
let ctx = self.inner.evm.ctx_mut();
let fee_payer = ctx.tx.fee_payer().unwrap_or(ctx.tx.caller());
let spec = ctx.cfg.spec;

let fee_token = match ctx.journaled_state.get_fee_token(
&ctx.tx,
fee_payer,
spec,
StorageActions::disabled(),
) {
Ok(token) => token,
Err(_) => return,
};

let beneficiary = ctx.block.beneficiary;
let slot = TipFeeManager::new().validator_tokens[beneficiary].slot();

let _ = ctx.journal_mut().load_account(TIP_FEE_MANAGER_ADDRESS);
let _ =
ctx.journal_mut()
.sstore(TIP_FEE_MANAGER_ADDRESS, slot, fee_token.into_word().into());
}
}

impl<'a, DB, I> BlockExecutor for ZoneBlockExecutor<'a, DB, I>
Expand All @@ -98,10 +68,6 @@ where
) -> Result<Self::Result, BlockExecutionError> {
let (tx_env, recovered) = tx.into_parts();

// Override the validator's fee token preference to match this
// transaction's resolved fee token, so the handler skips FeeAMM.
self.override_validator_token();

let _tx_hash_guard = tx_context::set_current_tx_hash(*recovered.tx().tx_hash());
self.inner
.execute_transaction_without_commit((tx_env, recovered))
Expand Down Expand Up @@ -129,144 +95,3 @@ where
self.inner.receipts()
}
}

#[cfg(test)]
mod tests {
use alloy_primitives::{Address, U256};
use tempo_precompiles::{
DEFAULT_FEE_TOKEN, TIP_FEE_MANAGER_ADDRESS,
storage::{ContractStorage, Handler, StorageCtx, hashmap::HashMapStorageProvider},
test_util::TIP20Setup,
tip_fee_manager::{TipFeeManager, amm::PoolKey},
};

/// Simulates the zone executor's per-tx validator token override and runs
/// the full fee lifecycle across multiple TIP-20 tokens, verifying:
///
/// 1. Default validator token is PATH_USD (no explicit preference set).
/// 2. No FeeAMM liquidity exists for any token pair.
/// 3. Paying fees in betaUSD, gammaUSD, and pathUSD all succeed when the
/// validator token is overridden per-tx.
/// 4. Fees are credited in the user's token (no conversion).
/// 5. FeeAMM pool reserves remain zero throughout.
#[test]
fn multi_token_fees_with_validator_override() -> eyre::Result<()> {
let mut storage = HashMapStorageProvider::new(1);
let admin = Address::random();
let user = Address::random();
let sequencer = Address::random();

StorageCtx::enter(&mut storage, || {
// Deploy three tokens.
let path_usd = TIP20Setup::create("PathUSD", "pUSD", admin)
.with_issuer(admin)
.with_mint(user, U256::from(10_000_000u64))
.with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
.apply()?;
let beta_usd = TIP20Setup::create("BetaUSD", "bUSD", admin)
.with_issuer(admin)
.with_mint(user, U256::from(10_000_000u64))
.with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
.apply()?;
let gamma_usd = TIP20Setup::create("GammaUSD", "gUSD", admin)
.with_issuer(admin)
.with_mint(user, U256::from(10_000_000u64))
.with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
.apply()?;

let fee_manager = TipFeeManager::new();

// 1. Validator token defaults to PATH_USD.
assert_eq!(
fee_manager.get_validator_token(sequencer)?,
DEFAULT_FEE_TOKEN
);

// 2. No FeeAMM pools exist.
for (a, b) in [
(beta_usd.address(), DEFAULT_FEE_TOKEN),
(gamma_usd.address(), DEFAULT_FEE_TOKEN),
(beta_usd.address(), gamma_usd.address()),
] {
let pool = fee_manager.pools[PoolKey::new(a, b).get_id()].read()?;
assert_eq!(pool.reserve_user_token, 0);
assert_eq!(pool.reserve_validator_token, 0);
}

// 3. Three transactions, each paying in a different token.
let txs = [
(
beta_usd.address(),
U256::from(5_000u64),
U256::from(3_000u64),
),
(
gamma_usd.address(),
U256::from(8_000u64),
U256::from(7_000u64),
),
(
path_usd.address(),
U256::from(4_000u64),
U256::from(2_000u64),
),
];

let mut fee_manager = TipFeeManager::new();
for (token, max, used) in &txs {
// Zone executor override: validatorTokens[sequencer] = fee_token.
fee_manager.validator_tokens[sequencer].write(*token)?;

fee_manager.collect_fee_pre_tx(user, *token, *max, sequencer, false)?;
fee_manager.collect_fee_post_tx(user, *used, *max - *used, *token, sequencer)?;
}

// 4. Fees credited per-token — no conversion happened.
for (token, _, used) in &txs {
let collected = fee_manager.collected_fees[sequencer][*token].read()?;
assert_eq!(collected, *used, "fees should be credited in {token}");
}

// 5. FeeAMM pools still empty — never touched.
for (a, b) in [
(beta_usd.address(), DEFAULT_FEE_TOKEN),
(gamma_usd.address(), DEFAULT_FEE_TOKEN),
(beta_usd.address(), gamma_usd.address()),
] {
let pool = fee_manager.pools[PoolKey::new(a, b).get_id()].read()?;
assert_eq!(
pool.reserve_user_token, 0,
"pool {a}-{b} user reserve should be 0"
);
assert_eq!(
pool.reserve_validator_token, 0,
"pool {a}-{b} validator reserve should be 0"
);
}

Ok(())
})
}

/// Validator token slot computation is deterministic and the storage
/// write produces the expected value when read back via TipFeeManager.
#[test]
fn validator_token_slot_roundtrip() -> eyre::Result<()> {
let mut storage = HashMapStorageProvider::new(1);
let sequencer = Address::random();
let token = Address::random();

StorageCtx::enter(&mut storage, || {
let mut fee_manager = TipFeeManager::new();

// Write via the Mapping handler (what the executor does via journal sstore).
fee_manager.validator_tokens[sequencer].write(token)?;

// Read back via TipFeeManager API.
let read_back = fee_manager.get_validator_token(sequencer)?;
assert_eq!(read_back, token);

Ok(())
})
}
}
103 changes: 103 additions & 0 deletions crates/evm/src/fee_manager.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! Adapter between Tempo's protocol fee hooks and the zone fee-manager precompile.

use alloy_primitives::{Address, U256};
use alloy_sol_types::SolCall;
use revm::{Database, context::Journal};
use tempo_chainspec::hardfork::TempoHardfork;
use tempo_precompiles::{
DEFAULT_FEE_TOKEN,
error::Result,
storage::{Handler, StorageActions},
};
use tempo_revm::{ProtocolFeeManager, TempoStateAccess, TempoTx, TempoTxEnv};
use tempo_zone_contracts::{IZoneFeeManager, ZONE_FEE_MANAGER_ADDRESS};
use zone_l1::state::L1StateProvider;
use zone_precompiles::ZoneFeeManager;

/// Zone implementation of Tempo's internal protocol fee hooks.
#[derive(Debug, Clone)]
pub(crate) struct ZoneProtocolFeeManager {
provider: L1StateProvider,
}

impl ZoneProtocolFeeManager {
pub(crate) const fn new(provider: L1StateProvider) -> Self {
Self { provider }
}
}

impl<DB: Database> ProtocolFeeManager<DB> for ZoneProtocolFeeManager {
fn get_fee_token(
&self,
journal: &mut Journal<DB>,
tx: &TempoTxEnv,
fee_payer: Address,
spec: TempoHardfork,
actions: StorageActions,
) -> Result<Address> {
if let Some(token) = tx.fee_token() {
return Ok(token);
}

// A direct preference update pays in the newly selected token, matching
// the behavior of Tempo's public fee manager.
if !tx.is_aa()
&& fee_payer == tx.caller()
&& let Some((kind, input)) = tx.calls().next()
&& kind.to() == Some(&ZONE_FEE_MANAGER_ADDRESS)
&& let Ok(call) = IZoneFeeManager::setUserTokenCall::abi_decode(input)
{
return Ok(call.token);
}

let preferred = journal.with_read_only_storage_ctx(spec, actions.clone(), || {
ZoneFeeManager::new().user_tokens[fee_payer].read()
})?;
if !preferred.is_zero() {
return Ok(preferred);
}

// Preserve Tempo's TIP-20 call inference and default-token behavior for
// users that have not selected a zone preference.
journal.get_fee_token(tx, fee_payer, spec, actions)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 [SECURITY] Default pathUSD fees are rejected on zones created with a custom initial token

This preserves Tempo's fallback to DEFAULT_FEE_TOKEN/pathUSD for users without an explicit or stored preference, but ZoneFeeManager::collect_fee_pre_tx now requires the resolved token to be enabled in the L1 ZonePortal registry. The custom-initial-token creation flow enables only the operator-selected initialToken on the portal, while zone genesis still always creates pathUSD as the default fee token. On a zone initialized with a non-pathUSD token, ordinary transactions that fall back to pathUSD fail ensure_enabled(pathUSD) and are rejected until pathUSD is separately enabled on L1.

Recommended Fix: Keep genesis/default fee-token setup and the portal-enabled token set consistent: enable pathUSD on the portal in addition to a custom initial token, make genesis default to the chosen initial token, or explicitly special-case/document the genesis default token with tests for the custom-initial-token flow.

}

fn get_validator_token(
&self,
_journal: &mut Journal<DB>,
_beneficiary: Address,
_spec: TempoHardfork,
_actions: StorageActions,
) -> Result<Address> {
// Zones never negotiate a validator token or enter an AMM route.
Ok(DEFAULT_FEE_TOKEN)
}

fn collect_fee_pre_tx(
&self,
fee_payer: Address,
user_token: Address,
max_amount: U256,
_beneficiary: Address,
_skip_liquidity_check: bool,
) -> Result<Address> {
ZoneFeeManager::new().collect_fee_pre_tx(&self.provider, fee_payer, user_token, max_amount)
}

fn collect_fee_post_tx(
&self,
fee_payer: Address,
actual_spending: U256,
refund_amount: U256,
fee_token: Address,
beneficiary: Address,
) -> Result<U256> {
ZoneFeeManager::new().collect_fee_post_tx(
fee_payer,
actual_spending,
refund_amount,
fee_token,
beneficiary,
)
}
}
Loading
Loading