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
29 changes: 24 additions & 5 deletions crates/tempo-zone/src/l1_state/precompile.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
//! `DynPrecompile` implementation for the TempoStateReader.
//!
//! The TempoStateReader is a **standalone precompile** (separate from the TempoState contract)
//! that allows zone system contracts to read Tempo L1 contract storage at a specific block height
//! during EVM execution. The caller provides the L1 block number to query, making the precompile
//! fully stateless.
//! that allows the `TempoState` predeploy to read Tempo L1 contract storage at a specific block
//! height during EVM execution on behalf of zone system contracts. The caller provides the L1
//! block number to query, making the precompile fully stateless.
//!
//! This precompile implements two functions:
//!
Expand Down Expand Up @@ -31,6 +31,7 @@ use revm::precompile::{PrecompileError, PrecompileId, PrecompileOutput, Precompi
use tracing::{debug, error, warn};

use super::provider::L1StateProvider;
use crate::abi::TEMPO_STATE_ADDRESS;

alloy_sol_types::sol! {
/// Read a single storage slot from a Tempo L1 contract at a specific block height.
Expand All @@ -41,6 +42,9 @@ alloy_sol_types::sol! {

/// Returned when the precompile is invoked via `DELEGATECALL` instead of `CALL`.
error DelegateCallNotAllowed();

/// Returned when a caller other than TempoState invokes the precompile.
error OnlyTempoStateAllowed();
}

/// Fixed gas cost charged on every call.
Expand All @@ -56,12 +60,15 @@ const PER_SLOT_GAS: u64 = 200;
/// contract storage via an [`L1StateProvider`].
///
/// The caller provides the L1 block number to query, making the precompile fully stateless.
/// Zone system contracts (ZoneInbox, ZoneConfig) pass the `tempoBlockNumber` from the
/// TempoState contract after `finalizeTempo` has been called.
/// In protocol execution only the `TempoState` predeploy is allowed to invoke it; zone system
/// contracts call `TempoState.readTempoStorageSlot(s)`, and `TempoState` forwards here using the
/// `tempoBlockNumber` from the currently finalized header.
///
/// # Restrictions
///
/// - Only direct `CALL`s are accepted; `DELEGATECALL` reverts with [`DelegateCallNotAllowed`].
/// - Only the [`TEMPO_STATE_ADDRESS`] predeploy may call the precompile; all other callers
/// revert with [`OnlyTempoStateAllowed`].
/// - The precompile is **view-only** — it never writes to EVM state.
/// - On cache miss the provider retries the RPC fetch indefinitely with backoff, stalling
/// block production until L1 connectivity is restored.
Expand All @@ -86,6 +93,18 @@ impl TempoStateReader {
));
}

if input.caller != TEMPO_STATE_ADDRESS {
warn!(
target: "zone::precompile",
caller = %input.caller,
"TempoStateReader called by non-TempoState caller — rejecting"
);
return Ok(PrecompileOutput::new_reverted(
0,
OnlyTempoStateAllowed {}.abi_encode().into(),
));
}

let data = input.data;
if data.len() < 4 {
warn!(target: "zone::precompile", data_len = data.len(), "TempoStateReader called with insufficient data");
Expand Down
9 changes: 7 additions & 2 deletions docs/specs/src/zone/IZone.sol
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,8 @@ interface IAesGcmDecrypt {

/// @title ITempoStateReader
/// @notice Standalone precompile for reading Tempo L1 contract storage at a given block height
/// @dev Predeploy at 0x1c00000000000000000000000000000000000004
/// @dev Predeploy at 0x1c00000000000000000000000000000000000004.
/// Low-level backend used by TempoState; must reject callers other than TempoState.
interface ITempoStateReader {

/// @notice Read a single storage slot from a Tempo L1 contract
Expand Down Expand Up @@ -336,6 +337,9 @@ address constant TEMPO_STATE_READER = 0x1c00000000000000000000000000000000000004
// ZoneTxContext precompile address (0x1c00...0005)
address constant ZONE_TX_CONTEXT = 0x1C00000000000000000000000000000000000005;

// Zone TIP-403 policy mirror address (same as Tempo)
address constant TIP403_REGISTRY_ADDRESS = 0x403c000000000000000000000000000000000000;

/// @title IZoneTxContext
/// @notice Interface for the zone precompile that exposes the currently executing tx hash
interface IZoneTxContext {
Expand Down Expand Up @@ -790,7 +794,8 @@ struct LastBatch {
/// @notice Interface for zone-side Tempo state verification predeploy
/// @dev Deployed at 0x1c00000000000000000000000000000000000000
/// System-only contract. Only ZoneInbox can call finalizeTempo().
/// Only ZoneInbox, ZoneOutbox, and ZoneConfig can call readTempoStorageSlot(s).
/// Only ZoneInbox, ZoneOutbox, ZoneConfig, and the zone TIP-403 mirror
/// can call readTempoStorageSlot(s).
interface ITempoState {

event TempoBlockFinalized(
Expand Down
13 changes: 10 additions & 3 deletions docs/specs/src/zone/TempoState.sol
Original file line number Diff line number Diff line change
Expand Up @@ -112,21 +112,27 @@ contract TempoState is ITempoState {
address private constant ZONE_INBOX = 0x1c00000000000000000000000000000000000001;
address private constant ZONE_OUTBOX = 0x1c00000000000000000000000000000000000002;
address private constant ZONE_CONFIG = 0x1c00000000000000000000000000000000000003;
address private constant ZONE_TIP403_REGISTRY = 0x403c000000000000000000000000000000000000;

/// @notice TempoStateReader precompile address
/// @dev Standalone precompile that reads Tempo L1 contract storage at a given block height.
address private constant TEMPO_STATE_READER = 0x1c00000000000000000000000000000000000004;

/// @notice Check if caller is a zone system contract
modifier onlySystemContract() {
if (msg.sender != ZONE_INBOX && msg.sender != ZONE_OUTBOX && msg.sender != ZONE_CONFIG) {
if (
msg.sender != ZONE_INBOX && msg.sender != ZONE_OUTBOX
&& msg.sender != ZONE_CONFIG
&& msg.sender != ZONE_TIP403_REGISTRY
) {
revert("TempoState: only zone system contracts can read Tempo state");
}
_;
}

/// @notice Read a storage slot from a Tempo L1 contract at the latest finalized block
/// @dev RESTRICTED: Only callable by zone system contracts (ZoneInbox, ZoneOutbox, ZoneConfig).
/// @dev RESTRICTED: Only callable by zone system contracts
/// (ZoneInbox, ZoneOutbox, ZoneConfig, zone TIP-403 mirror).
/// Forwards to the TempoStateReader precompile with the current tempoBlockNumber.
/// @param account The Tempo L1 contract address (ZonePortal or TIP-403)
/// @param slot The storage slot to read
Expand All @@ -144,7 +150,8 @@ contract TempoState is ITempoState {
}

/// @notice Read multiple storage slots from a Tempo L1 contract at the latest finalized block
/// @dev RESTRICTED: Only callable by zone system contracts (ZoneInbox, ZoneOutbox, ZoneConfig).
/// @dev RESTRICTED: Only callable by zone system contracts
/// (ZoneInbox, ZoneOutbox, ZoneConfig, zone TIP-403 mirror).
/// Forwards to the TempoStateReader precompile with the current tempoBlockNumber.
/// @param account The Tempo L1 contract address (ZonePortal or TIP-403)
/// @param slots The storage slots to read
Expand Down
227 changes: 227 additions & 0 deletions docs/specs/src/zone/ZoneTIP403Registry.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import { ITIP403Registry } from "../interfaces/ITIP403Registry.sol";
import { ITempoState, TEMPO_STATE, TIP403_REGISTRY_ADDRESS } from "./IZone.sol";

/// @title ZoneTIP403Registry
/// @notice Read-only zone mirror of Tempo's TIP-403 registry
/// @dev Deployed at the same address as the Tempo TIP-403 registry. Queries are
/// answered by reading Tempo storage through `TempoState` at the latest
/// finalized Tempo block. Mutating methods revert on the zone.
contract ZoneTIP403Registry is ITIP403Registry {

uint64 internal constant REJECT_ALL_POLICY_ID = 0;
uint64 internal constant ALLOW_ALL_POLICY_ID = 1;

// TIP403Registry storage layout on Tempo:
// slot 0: policyIdCounter
// slot 1: policyRecords (mapping(uint64 => PolicyRecord))
// slot 2: policySet (mapping(uint64 => mapping(address => bool)))
uint256 internal constant POLICY_ID_COUNTER_SLOT = 0;
uint256 internal constant POLICY_RECORDS_SLOT = 1;
uint256 internal constant POLICY_SET_SLOT = 2;

ITempoState internal constant TEMPO_STATE_CONTRACT = ITempoState(TEMPO_STATE);

error ReadOnlyRegistry();

function policyIdCounter() public view returns (uint64) {
return _policyIdCounter();
}

function policyExists(uint64 policyId) public view returns (bool) {
if (policyId <= ALLOW_ALL_POLICY_ID) {
return true;
}

return policyId < _policyIdCounter();
}

function policyData(uint64 policyId)
public
view
returns (PolicyType policyType, address admin)
{
if (policyId == REJECT_ALL_POLICY_ID) {
return (PolicyType.WHITELIST, address(0));
}
if (policyId == ALLOW_ALL_POLICY_ID) {
return (PolicyType.BLACKLIST, address(0));
}

return _getPolicyData(policyId);
}

function createPolicy(address, PolicyType) external pure returns (uint64) {
revert ReadOnlyRegistry();
}

function createPolicyWithAccounts(
address,
PolicyType,
address[] calldata
)
external
pure
returns (uint64)
{
revert ReadOnlyRegistry();
}

function setPolicyAdmin(uint64, address) external pure {
revert ReadOnlyRegistry();
}

function modifyPolicyWhitelist(uint64, address, bool) external pure {
revert ReadOnlyRegistry();
}

function modifyPolicyBlacklist(uint64, address, bool) external pure {
revert ReadOnlyRegistry();
}

function isAuthorized(uint64 policyId, address user) public view returns (bool) {
if (policyId <= ALLOW_ALL_POLICY_ID) {
return policyId == ALLOW_ALL_POLICY_ID;
}

(PolicyType policyType,) = _getPolicyData(policyId);
if (policyType == PolicyType.COMPOUND) {
if (!isAuthorizedSender(policyId, user)) {
return false;
}

return isAuthorizedRecipient(policyId, user);
}

return _isAuthorizedSimple(policyId, user, policyType);
}

function createCompoundPolicy(uint64, uint64, uint64)
external
pure
returns (uint64)
{
revert ReadOnlyRegistry();
}

function isAuthorizedSender(uint64 policyId, address user) public view returns (bool) {
if (policyId <= ALLOW_ALL_POLICY_ID) {
return policyId == ALLOW_ALL_POLICY_ID;
}

(PolicyType policyType,) = _getPolicyData(policyId);
if (policyType == PolicyType.COMPOUND) {
(uint64 senderPolicyId,,) = _getCompoundPolicyData(policyId);
return isAuthorized(senderPolicyId, user);
}

return _isAuthorizedSimple(policyId, user, policyType);
}

function isAuthorizedRecipient(uint64 policyId, address user) public view returns (bool) {
if (policyId <= ALLOW_ALL_POLICY_ID) {
return policyId == ALLOW_ALL_POLICY_ID;
}

(PolicyType policyType,) = _getPolicyData(policyId);
if (policyType == PolicyType.COMPOUND) {
(, uint64 recipientPolicyId,) = _getCompoundPolicyData(policyId);
return isAuthorized(recipientPolicyId, user);
}

return _isAuthorizedSimple(policyId, user, policyType);
}

function isAuthorizedMintRecipient(uint64 policyId, address user)
public
view
returns (bool)
{
if (policyId <= ALLOW_ALL_POLICY_ID) {
return policyId == ALLOW_ALL_POLICY_ID;
}

(PolicyType policyType,) = _getPolicyData(policyId);
if (policyType == PolicyType.COMPOUND) {
(,, uint64 mintRecipientPolicyId) = _getCompoundPolicyData(policyId);
return isAuthorized(mintRecipientPolicyId, user);
}

return _isAuthorizedSimple(policyId, user, policyType);
}

function compoundPolicyData(uint64 policyId)
external
view
returns (uint64 senderPolicyId, uint64 recipientPolicyId, uint64 mintRecipientPolicyId)
{
(PolicyType policyType,) = policyData(policyId);
if (policyType != PolicyType.COMPOUND) revert IncompatiblePolicyType();

return _getCompoundPolicyData(policyId);
}

function _policyIdCounter() internal view returns (uint64) {
return uint64(uint256(_readTempoStorage(bytes32(POLICY_ID_COUNTER_SLOT))));
}

function _getPolicyData(uint64 policyId)
internal
view
returns (PolicyType policyType, address admin)
{
bytes32 raw = _readTempoStorage(_policyRecordBaseSlot(policyId));
uint8 rawPolicyType = uint8(uint256(raw));
if (rawPolicyType > uint8(PolicyType.COMPOUND)) revert InvalidPolicyType();

policyType = PolicyType(rawPolicyType);
admin = address(uint160(uint256(raw) >> 8));

// Match the L1 registry's "default slot means maybe-missing" check.
if (
rawPolicyType == uint8(PolicyType.WHITELIST) && admin == address(0)
&& policyId >= _policyIdCounter()
) {
revert PolicyNotFound();
}
}

function _getCompoundPolicyData(uint64 policyId)
internal
view
returns (uint64 senderPolicyId, uint64 recipientPolicyId, uint64 mintRecipientPolicyId)
{
bytes32 raw = _readTempoStorage(bytes32(uint256(_policyRecordBaseSlot(policyId)) + 1));
senderPolicyId = uint64(uint256(raw));
recipientPolicyId = uint64(uint256(raw) >> 64);
mintRecipientPolicyId = uint64(uint256(raw) >> 128);
}

function _isAuthorizedSimple(uint64 policyId, address user, PolicyType policyType)
internal
view
returns (bool)
{
if (policyType == PolicyType.COMPOUND) revert IncompatiblePolicyType();

bool inSet = _readPolicySet(policyId, user);
return policyType == PolicyType.WHITELIST ? inSet : !inSet;
}

function _readPolicySet(uint64 policyId, address user) internal view returns (bool) {
bytes32 policySetBase = keccak256(abi.encode(policyId, uint256(POLICY_SET_SLOT)));
bytes32 userSlot = keccak256(abi.encode(user, policySetBase));
return uint8(uint256(_readTempoStorage(userSlot)) & 0xff) != 0;
}

function _policyRecordBaseSlot(uint64 policyId) internal pure returns (bytes32) {
return keccak256(abi.encode(policyId, uint256(POLICY_RECORDS_SLOT)));
}

function _readTempoStorage(bytes32 slot) internal view returns (bytes32) {
return TEMPO_STATE_CONTRACT.readTempoStorageSlot(TIP403_REGISTRY_ADDRESS, slot);
}

}
Loading