Skip to content

feat: zone fee manager#680

Draft
0xKitsune wants to merge 8 commits into
mainfrom
kit/fee-manager-impl
Draft

feat: zone fee manager#680
0xKitsune wants to merge 8 commits into
mainfrom
kit/fee-manager-impl

Conversation

@0xKitsune

@0xKitsune 0xKitsune commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

This PR implements a zone fee manager that allows users to pay transaction fees in any token enabled for the zone. Fees are collected and credited to the sequencer in the same token, without requiring FeeAMM liquidity or swaps.

ZoneFeeManager replaces Tempo's fee manager precompile at 0xfeEC000000000000000000000000000000000000 and is wired into Tempo's ProtocolFeeManager hooks.

@0xKitsune 0xKitsune changed the title feat: add zone-native fee manager feat: zone fee manager Jul 16, 2026
# Conflicts:
#	crates/evm/src/executor.rs
#	crates/evm/src/lib.rs
@0xKitsune 0xKitsune added cyclops and removed cyclops labels Jul 16, 2026

@tempoxyz-bot tempoxyz-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👁️ Cyclops Review

PR #680 adds a zone-native fee manager, but the change has several cross-layer integration issues around txpool fee-token admission, TIP-403 policy enforcement, L1 token-enable caching, default-token initialization, and transient RPC error classification. I left the actionable findings inline.

Reviewer Callouts
  • Prover/witness coverage for fee-time L1 reads: collect_fee_pre_tx -> ensure_enabled -> is_enabled_token now performs implicit L1 storage reads for every charged transaction, outside the explicit TempoState.readTempoStorageSlot user-call surface. Confirm the proving/witness pipeline records these reads deterministically.
  • T8/TIP-1042 fee-policy semantics: when making fee collection L1-policy-aware, align sender/recipient authorization exemptions with Tempo's fee semantics so the FeeManager recipient is not accidentally made a blocker for otherwise valid fee tokens.
  • Pool/execution consistency: fixing the slot-collision issue should include a broader check that public txpool validation and block execution use the same zone fee-token resolution, portal-enable checks, and no-FeeAMM assumptions.

/// `TipFeeManager` storage slots are read or overwritten.
#[contract(addr = ZONE_FEE_MANAGER_ADDRESS)]
pub struct ZoneFeeManager {
user_tokens: Mapping<Address, Address>,

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] ZoneFeeManager slot-0 layout collides with Tempo txpool AMM liquidity cache

ZoneFeeManager is deployed at the same 0xfeEC...0000 account as Tempo's TipFeeManager, but this first field makes user_tokens occupy mapping slot 0. The zone txpool still reuses Tempo's AmmLiquidityCache, which reads slot 0 at this address as TipFeeManager.validator_tokens[beneficiary]; the PR also removes the executor-side override_validator_token write that previously kept that view aligned with each tx. Non-default portal-enabled fee tokens can therefore be rejected from the mempool for missing FeeAMM liquidity, and a sequencer setting a non-default user token can make pathUSD transactions look unliquid.

Recommended Fix: Use a zone-specific txpool validator/ZoneEvmConfig that accepts portal-enabled fee tokens without FeeAMM routing, or otherwise reserve/restore the old slot-0 validator-token semantics so txpool cache reads cannot observe ZoneFeeManager.user_tokens.

) -> Result<Address> {
self.ensure_enabled(provider, fee_token)?;

let mut token = TIP20Token::from_address(fee_token)?;

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] Zone fee debits and distributions bypass L1 TIP-403 policy enforcement

The fee manager instantiates vanilla TIP20Token directly for fee collection (and later does the same for distributeFees). On zones, normal TIP-20 calls are routed through ZoneTip20Token/ZoneTip403ProxyRegistry so L1-backed transfer policies are enforced; vanilla TIP20Token consults only the local L2 registry, which is not the canonical zone policy source. A policy-restricted account can move restricted tokens to the sequencer by paying gas fees, and distribution can complete the transfer without the L1-backed policy check a normal transfer would require.

Recommended Fix: Thread the same PolicyProvider/ZoneTip403ProxyRegistry used by ZoneTip20Token into the fee path. Enforce L1-backed sender authorization before fee debit and enforce or explicitly define a protocol exemption for FeeManager -> sequencer distributions; add deny-all policy regression tests for both paths.


// 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.

block_number: u64,
) -> core::result::Result<bool, PrecompileError> {
let slot = keccak256((token, PORTAL_TOKEN_CONFIGS_SLOT).abi_encode());
let value = self.read_l1_storage(self.zone_portal_address(), slot, block_number)?;

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] Pre-enable false cache entries can poison ZoneFeeManager token validation

Fee-token enablement now reads ZonePortal._tokenConfigs[token].enabled through the shared cache-first L1StateProvider. That cache returns the latest value at or before the requested finalized L1 block, and RPC misses are written back into the shared cache. Because TokenEnabled events are decoded but not written into L1StateCache for this mapping slot, a public isEnabledToken/fee read before enablement can cache false; later, after L1 enables the token, the same node can keep rejecting it while other nodes fetch true.

Recommended Fix: When processing TokenEnabled, update or invalidate the corresponding portal token-config slot in L1StateCache at the event block, including startup/backfill seeding for initially enabled tokens. Avoid treating RPC-fetched values for mutable untracked slots as future-block baselines unless all writes to those slots are tracked.

self.__initialize()
}

fn map_reader_error(error: PrecompileError) -> TempoPrecompileError {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ [ISSUE] ZoneFeeManager corrupts [zone rpc] errors before builder classification

map_reader_error stringifies PrecompileError. For PrecompileError::Fatal("[zone rpc] ..."), revm-precompile displays this as "fatal: [zone rpc] ..."; the payload builder's transient-RPC skip logic checks starts_with("[zone rpc]"). A bounded/failing L1 reader on this fee path would therefore be classified as a generic fatal EVM error and abort block building instead of skipping only the affected pool transaction. The current production provider retries indefinitely, so this is latent today, but the marker propagation contract is broken.

Recommended Fix: Preserve the inner string when mapping PrecompileError::Fatal(s) into TempoPrecompileError::Fatal(s), add tests that fee-token validation [zone rpc] errors hit the builder's skip path, and consider making the classifier robust to standard fatal-display prefixes.

@0xKitsune 0xKitsune added cyclops and removed cyclops labels Jul 17, 2026
@tempoxyz-bot

tempoxyz-bot commented Jul 17, 2026

Copy link
Copy Markdown

👁️ Cyclops Security Review

d555931

🧭 Verifying · mode=normal · workers 3/3 done (0 left) · verify pending 1 · ending soon

Worker Engine Latest Status Status
pr-680-w1 claude-opus-4-8 💡 Iteration 3 · Audit Done
pr-680-w2 gpt-5.5 🚨 Iteration 3 · Verify Done
pr-680-w3 gpt-5.6-sol 🚨 Iteration 3 · Verify Done

Findings

# Finding Severity Status
1 Zone fee TIP-403 checks use policy cache height instead of finalized TempoState checkpoint High Iteration 1 · Verify
2 Pool admission still requires FeeAMM liquidity for zone-native fee tokens Medium Iteration 1 · Verify
3 advanceTempo enabledTokens metadata can rewrite token currency and bypass the USD fee-token invariant High Iteration 2 · Verify
4 Zone fee TIP-403 authorization resolved at L1 subscriber tip (last_l1_block) instead of the finalized Tempo checkpoint used for token enablement High Iteration 3 · Audit
5 Followers can be cache-poisoned into rejecting post-enable blocks Medium Iteration 2 · Verify
6 Cyclops Invariant Review — PR #680 Unknown Iteration 3 · Verify
7 Unconditional L1-backed fee hooks break replay and recovery Medium Iteration 3 · Verify
⚙️ Controls
  • 🚀 Keep only 1 remaining iteration per worker after the current work finishes.
  • 👀 Keep only 2 remaining iterations per worker after the current work finishes.
  • ❤️ Let only worker 1 continue; other workers skip queued iterations.
  • 😄 Let only worker 2 continue; other workers skip queued iterations.
  • 🎉 End faster by skipping queued iterations and moving toward consolidation.
  • 😕 Stop active workers/verifiers now and start consolidation immediately.

📜 40 events

🔍 pr-680-w1 iter 1/3 [audit-general.md]
🔍 pr-680-w2 iter 1/3 [audit-ripple.md]
🔍 pr-680-w3 iter 1/3 [audit-invariants.md]
pr-680-w1 iter 1 — clear
🔍 pr-680-w1 iter 2/3 [audit-invariants.md]
🚨 pr-680-w2 iter 1 — finding
🚨 Finding: Zone fee TIP-403 checks use policy cache height instead of finalized TempoState checkpoint (High)
🔍 pr-680-w2 iter 2/3 [audit-ripple.md]
🔬 Verifying: Zone fee TIP-403 checks use policy cache height instead of finalized TempoState checkpoint
pr-680-w1 iter 2 — clear
🔍 pr-680-w1 iter 3/3 [audit-invariants.md]
🚨 pr-680-w3 iter 1 — finding
🚨 Finding: Pool admission still requires FeeAMM liquidity for zone-native fee tokens (Medium)
🔬 Verifying: Pool admission still requires FeeAMM liquidity for zone-native fee tokens
🔍 pr-680-w3 iter 2/3 [audit-ripple.md]
📋 Verify: Zone fee TIP-403 checks use policy cache height instead of finalized TempoState checkpoint → ✅ Verified
📋 Verify: Pool admission still requires FeeAMM liquidity for zone-native fee tokens → ✅ Verified
🚨 pr-680-w2 iter 2 — finding
🚨 Finding: advanceTempo enabledTokens metadata can rewrite token currency and bypass the USD fee-token invariant (High)
🔍 pr-680-w2 iter 3/3 [audit-invariants.md]
🔬 Verifying: advanceTempo enabledTokens metadata can rewrite token currency and bypass the USD fee-token invariant
💡 pr-680-w1 iter 3 — suggestion
🚨 Finding: Zone fee TIP-403 authorization resolved at L1 subscriber tip (last_l1_block) instead of the finalized Tempo checkpoint used for token enablement (High)
🏁 pr-680-w1 done
📋 Verify: advanceTempo enabledTokens metadata can rewrite token currency and bypass the USD fee-token invariant → ✅ Verified
🚨 pr-680-w3 iter 2 — finding
🚨 Finding: Followers can be cache-poisoned into rejecting post-enable blocks (Medium)
🔍 pr-680-w3 iter 3/3 [audit-ripple.md]
🔬 Verifying: Followers can be cache-poisoned into rejecting post-enable blocks
📋 Verify: Followers can be cache-poisoned into rejecting post-enable blocks → ⏩ Dup
🚨 pr-680-w2 iter 3 — finding
🚨 Finding: Cyclops Invariant Review — PR #680 (Unknown)
🔬 Verifying: Cyclops Invariant Review — PR #680
📋 Verify: Cyclops Invariant Review — PR #680 → ✅ Verified
🏁 pr-680-w2 done
🚨 pr-680-w3 iter 3 — finding
🚨 Finding: Unconditional L1-backed fee hooks break replay and recovery (Medium)
🔬 Verifying: Unconditional L1-backed fee hooks break replay and recovery
📋 Verify: Unconditional L1-backed fee hooks break replay and recovery → ✅ Verified
🏁 pr-680-w3 done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants