From 5b2b8c6516c4b0a5dbe83cbb3a07fee73ffdd372 Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Thu, 25 Jun 2026 12:39:54 +0200 Subject: [PATCH 1/2] fix: scope ZoneInbox refund calls --- crates/node/src/rpc.rs | 139 +++++++++++++++++++++++- crates/node/tests/it/private_rpc_e2e.rs | 70 ++++++++++++ 2 files changed, 207 insertions(+), 2 deletions(-) diff --git a/crates/node/src/rpc.rs b/crates/node/src/rpc.rs index f5e04979..615fffb6 100644 --- a/crates/node/src/rpc.rs +++ b/crates/node/src/rpc.rs @@ -11,8 +11,8 @@ use std::{ time::Duration, }; -use alloy_network::{ReceiptResponse, TransactionResponse}; -use alloy_primitives::{Address, B256, Bloom, Bytes, U64, U256}; +use alloy_network::{ReceiptResponse, TransactionBuilder, TransactionResponse}; +use alloy_primitives::{Address, B256, Bloom, Bytes, TxKind, U64, U256}; use alloy_provider::{DynProvider, Provider, ProviderBuilder}; use alloy_rpc_types_eth::{ Block, BlockId, BlockNumberOrTag, BlockTransactions, Filter, FilterChanges, FilterId, @@ -315,6 +315,24 @@ impl ZoneRpc { .map_err(internal) } + async fn enforce_zone_inbox_refund_call_privacy( + &self, + request: &TempoTransactionRequest, + auth: &AuthContext, + ) -> Result<(), JsonRpcError> { + // Non-sequencer `eth_call` simulations may only read + // `ZoneInbox.refunds(token, owner)` for the authenticated owner. + if zone_inbox_refunds_mismatched_owner(request, auth.caller).is_none() { + return Ok(()); + } + + if self.zone_sequencer().await? == auth.caller { + return Ok(()); + } + + Err(JsonRpcError::account_mismatch()) + } + async fn terminal_event_for_deposit( &self, deposit_hash: B256, @@ -598,6 +616,8 @@ where zone_rpc::policy::enforce_from(&mut request, &auth)?; zone_rpc::policy::enforce_no_contract_creation(&request)?; + self.enforce_zone_inbox_refund_call_privacy(&request, &auth) + .await?; let result = EthCall::call( &self.eth.api, @@ -974,6 +994,44 @@ where } } +/// Finds a direct or nested `ZoneInbox.refunds(token, owner)` read where +/// `owner` is not the authenticated caller. +/// +/// Other calls, contract creations, and malformed calldata are ignored here. +fn zone_inbox_refunds_mismatched_owner( + request: &TempoTransactionRequest, + caller: Address, +) -> Option
{ + let refunds_owner_mismatch = |to: Option
, input: Option<&Bytes>| { + if to != Some(ZONE_INBOX_ADDRESS) { + return None; + } + + let input = input?; + if !input.starts_with(&ZoneInbox::refundsCall::SELECTOR) { + return None; + } + + let owner = ZoneInbox::refundsCall::abi_decode(input).ok()?.owner; + (owner != caller).then_some(owner) + }; + + if let Some(owner) = refunds_owner_mismatch( + TransactionBuilder::to(request), + TransactionBuilder::input(request), + ) { + return Some(owner); + } + + request.calls.iter().find_map(|call| { + let to = match call.to { + TxKind::Call(to) => Some(to), + TxKind::Create => None, + }; + refunds_owner_mismatch(to, Some(&call.input)) + }) +} + #[derive(Debug, Clone)] enum PortalDepositRecord { Regular { @@ -1066,6 +1124,26 @@ pub(crate) fn rpc_connection_config(retry_connection_interval: Duration) -> Conn #[cfg(test)] mod tests { use super::*; + use alloy_rpc_types_eth::TransactionInput; + use tempo_primitives::transaction::Call; + + fn zone_inbox_refunds_request(owner: Address) -> TempoTransactionRequest { + TempoTransactionRequest { + inner: TransactionRequest { + to: Some(TxKind::Call(ZONE_INBOX_ADDRESS)), + input: TransactionInput::new( + ZoneInbox::refundsCall { + token: ZONE_TOKEN_ADDRESS, + owner, + } + .abi_encode() + .into(), + ), + ..Default::default() + }, + ..Default::default() + } + } #[test] fn regular_deposit_status_maps_terminal_events() { @@ -1141,4 +1219,61 @@ mod tests { assert!(stale_ids.is_empty()); } + + #[test] + fn zone_inbox_refunds_mismatched_owner_detects_outer_call() { + let caller = Address::repeat_byte(0x11); + let owner = Address::repeat_byte(0x22); + let request = zone_inbox_refunds_request(owner); + + assert_eq!( + zone_inbox_refunds_mismatched_owner(&request, caller), + Some(owner) + ); + } + + #[test] + fn zone_inbox_refunds_mismatched_owner_allows_own_outer_call() { + let caller = Address::repeat_byte(0x11); + let request = zone_inbox_refunds_request(caller); + + assert_eq!(zone_inbox_refunds_mismatched_owner(&request, caller), None); + } + + #[test] + fn zone_inbox_refunds_mismatched_owner_detects_nested_tempo_call() { + let caller = Address::repeat_byte(0x11); + let owner = Address::repeat_byte(0x22); + let mut request = TempoTransactionRequest { + inner: TransactionRequest { + to: Some(TxKind::Call(Address::repeat_byte(0x33))), + ..Default::default() + }, + ..Default::default() + }; + request.calls.push(Call { + to: TxKind::Call(ZONE_INBOX_ADDRESS), + value: U256::ZERO, + input: ZoneInbox::refundsCall { + token: ZONE_TOKEN_ADDRESS, + owner, + } + .abi_encode() + .into(), + }); + + assert_eq!( + zone_inbox_refunds_mismatched_owner(&request, caller), + Some(owner) + ); + } + + #[test] + fn zone_inbox_refunds_mismatched_owner_ignores_other_calls() { + let caller = Address::repeat_byte(0x11); + let mut request = zone_inbox_refunds_request(Address::repeat_byte(0x22)); + request.inner.to = Some(TxKind::Call(Address::repeat_byte(0x33))); + + assert_eq!(zone_inbox_refunds_mismatched_owner(&request, caller), None); + } } diff --git a/crates/node/tests/it/private_rpc_e2e.rs b/crates/node/tests/it/private_rpc_e2e.rs index 93f7e7b7..8127c2fb 100644 --- a/crates/node/tests/it/private_rpc_e2e.rs +++ b/crates/node/tests/it/private_rpc_e2e.rs @@ -29,6 +29,7 @@ use tempo_contracts::precompiles::{ account_keychain::IAccountKeychain::SignatureType as KeyInfoSignatureType, }; use tempo_precompiles::{PATH_USD_ADDRESS, tip20::ITIP20 as PrecompileTip20}; +use tempo_zone_contracts::{ZONE_INBOX_ADDRESS, ZONE_TOKEN_ADDRESS, ZoneInbox}; use tokio::time::sleep; use tokio_tungstenite::{ connect_async, @@ -651,6 +652,75 @@ async fn test_tip20_eth_call_privacy() -> eyre::Result<()> { Ok(()) } +/// `eth_call` against ZoneInbox refund balances is scoped to the authenticated +/// owner, preventing arbitrary `refunds(token, owner)` reads. +#[tokio::test(flavor = "multi_thread")] +async fn test_zone_inbox_refunds_eth_call_privacy() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let ctx = start_zone_with_private_rpc().await?; + + let owner_signer = PrivateKeySigner::random(); + let owner = owner_signer.address(); + let outsider_signer = PrivateKeySigner::random(); + + let refunds_call = ZoneInbox::refundsCall { + token: ZONE_TOKEN_ADDRESS, + owner, + }; + let refunds_data = format!("0x{}", hex::encode(refunds_call.abi_encode())); + + let outsider_refunds = ctx + .call_as_user( + "eth_call", + json!([ + { + "to": format!("{ZONE_INBOX_ADDRESS:#x}"), + "data": refunds_data, + }, + "latest" + ]), + &outsider_signer, + ) + .await?; + assert_eq!( + outsider_refunds["error"]["code"].as_i64().unwrap(), + -32004, + "non-owner refunds(token, owner) should be rejected" + ); + assert_eq!( + outsider_refunds["error"]["message"].as_str().unwrap(), + "Account mismatch" + ); + + let owner_refunds = ctx + .call_as_user( + "eth_call", + json!([ + { + "to": format!("{ZONE_INBOX_ADDRESS:#x}"), + "data": format!("0x{}", hex::encode(refunds_call.abi_encode())), + }, + "latest" + ]), + &owner_signer, + ) + .await?; + let owner_refunds_bytes = hex::decode( + owner_refunds["result"] + .as_str() + .expect("own refunds call should return hex") + .trim_start_matches("0x"), + )?; + assert_eq!( + ZoneInbox::refundsCall::abi_decode_returns(&owner_refunds_bytes)?, + 0, + "own refunds(token, owner) read should retain normal eth_call behavior" + ); + + Ok(()) +} + /// Simulation methods reject contract creation and override extensions. #[tokio::test(flavor = "multi_thread")] async fn test_simulation_validation_rejects_create_and_overrides() -> eyre::Result<()> { From 32fd7d581e3f2b13fe78e8146eebe74049b0e13b Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Mon, 29 Jun 2026 16:56:00 +0200 Subject: [PATCH 2/2] fix: move refund privacy checks into rpc policy --- Cargo.lock | 2 + crates/node/src/rpc.rs | 151 +++---------------------------------- crates/rpc/Cargo.toml | 2 + crates/rpc/src/policy.rs | 158 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 173 insertions(+), 140 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f20a7a6e..a3ea320f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13524,6 +13524,7 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-signer", "alloy-signer-local", + "alloy-sol-types", "axum", "base64 0.22.1", "eyre", @@ -13540,6 +13541,7 @@ dependencies = [ "tempo-alloy", "tempo-contracts", "tempo-primitives", + "tempo-zone-contracts", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.28.0", diff --git a/crates/node/src/rpc.rs b/crates/node/src/rpc.rs index 615fffb6..083b4224 100644 --- a/crates/node/src/rpc.rs +++ b/crates/node/src/rpc.rs @@ -11,8 +11,8 @@ use std::{ time::Duration, }; -use alloy_network::{ReceiptResponse, TransactionBuilder, TransactionResponse}; -use alloy_primitives::{Address, B256, Bloom, Bytes, TxKind, U64, U256}; +use alloy_network::{ReceiptResponse, TransactionResponse}; +use alloy_primitives::{Address, B256, Bloom, Bytes, U64, U256}; use alloy_provider::{DynProvider, Provider, ProviderBuilder}; use alloy_rpc_types_eth::{ Block, BlockId, BlockNumberOrTag, BlockTransactions, Filter, FilterChanges, FilterId, @@ -315,22 +315,16 @@ impl ZoneRpc { .map_err(internal) } - async fn enforce_zone_inbox_refund_call_privacy( + async fn enforce_authorized( &self, - request: &TempoTransactionRequest, + request: &mut TempoTransactionRequest, auth: &AuthContext, ) -> Result<(), JsonRpcError> { - // Non-sequencer `eth_call` simulations may only read - // `ZoneInbox.refunds(token, owner)` for the authenticated owner. - if zone_inbox_refunds_mismatched_owner(request, auth.caller).is_none() { - return Ok(()); - } - - if self.zone_sequencer().await? == auth.caller { - return Ok(()); - } - - Err(JsonRpcError::account_mismatch()) + let caller = auth.caller; + zone_rpc::policy::enforce_authorized(request, auth, async { + Ok(self.zone_sequencer().await? == caller) + }) + .await } async fn terminal_event_for_deposit( @@ -614,10 +608,7 @@ where return Err(JsonRpcError::invalid_params("state overrides not allowed")); } - zone_rpc::policy::enforce_from(&mut request, &auth)?; - zone_rpc::policy::enforce_no_contract_creation(&request)?; - self.enforce_zone_inbox_refund_call_privacy(&request, &auth) - .await?; + self.enforce_authorized(&mut request, &auth).await?; let result = EthCall::call( &self.eth.api, @@ -643,9 +634,7 @@ where return Err(JsonRpcError::invalid_params("state overrides not allowed")); } - zone_rpc::policy::enforce_from(&mut request, &auth)?; - - zone_rpc::policy::enforce_no_contract_creation(&request)?; + self.enforce_authorized(&mut request, &auth).await?; let result = EthCall::estimate_gas_at( &self.eth.api, @@ -690,8 +679,7 @@ where auth: AuthContext, ) -> BoxFut<'_> { Box::pin(async move { - zone_rpc::policy::enforce_from(&mut request, &auth)?; - zone_rpc::policy::enforce_no_contract_creation(&request)?; + self.enforce_authorized(&mut request, &auth).await?; let result = EthTransactions::fill_transaction(&self.eth.api, request) .await @@ -994,44 +982,6 @@ where } } -/// Finds a direct or nested `ZoneInbox.refunds(token, owner)` read where -/// `owner` is not the authenticated caller. -/// -/// Other calls, contract creations, and malformed calldata are ignored here. -fn zone_inbox_refunds_mismatched_owner( - request: &TempoTransactionRequest, - caller: Address, -) -> Option
{ - let refunds_owner_mismatch = |to: Option
, input: Option<&Bytes>| { - if to != Some(ZONE_INBOX_ADDRESS) { - return None; - } - - let input = input?; - if !input.starts_with(&ZoneInbox::refundsCall::SELECTOR) { - return None; - } - - let owner = ZoneInbox::refundsCall::abi_decode(input).ok()?.owner; - (owner != caller).then_some(owner) - }; - - if let Some(owner) = refunds_owner_mismatch( - TransactionBuilder::to(request), - TransactionBuilder::input(request), - ) { - return Some(owner); - } - - request.calls.iter().find_map(|call| { - let to = match call.to { - TxKind::Call(to) => Some(to), - TxKind::Create => None, - }; - refunds_owner_mismatch(to, Some(&call.input)) - }) -} - #[derive(Debug, Clone)] enum PortalDepositRecord { Regular { @@ -1124,26 +1074,6 @@ pub(crate) fn rpc_connection_config(retry_connection_interval: Duration) -> Conn #[cfg(test)] mod tests { use super::*; - use alloy_rpc_types_eth::TransactionInput; - use tempo_primitives::transaction::Call; - - fn zone_inbox_refunds_request(owner: Address) -> TempoTransactionRequest { - TempoTransactionRequest { - inner: TransactionRequest { - to: Some(TxKind::Call(ZONE_INBOX_ADDRESS)), - input: TransactionInput::new( - ZoneInbox::refundsCall { - token: ZONE_TOKEN_ADDRESS, - owner, - } - .abi_encode() - .into(), - ), - ..Default::default() - }, - ..Default::default() - } - } #[test] fn regular_deposit_status_maps_terminal_events() { @@ -1219,61 +1149,4 @@ mod tests { assert!(stale_ids.is_empty()); } - - #[test] - fn zone_inbox_refunds_mismatched_owner_detects_outer_call() { - let caller = Address::repeat_byte(0x11); - let owner = Address::repeat_byte(0x22); - let request = zone_inbox_refunds_request(owner); - - assert_eq!( - zone_inbox_refunds_mismatched_owner(&request, caller), - Some(owner) - ); - } - - #[test] - fn zone_inbox_refunds_mismatched_owner_allows_own_outer_call() { - let caller = Address::repeat_byte(0x11); - let request = zone_inbox_refunds_request(caller); - - assert_eq!(zone_inbox_refunds_mismatched_owner(&request, caller), None); - } - - #[test] - fn zone_inbox_refunds_mismatched_owner_detects_nested_tempo_call() { - let caller = Address::repeat_byte(0x11); - let owner = Address::repeat_byte(0x22); - let mut request = TempoTransactionRequest { - inner: TransactionRequest { - to: Some(TxKind::Call(Address::repeat_byte(0x33))), - ..Default::default() - }, - ..Default::default() - }; - request.calls.push(Call { - to: TxKind::Call(ZONE_INBOX_ADDRESS), - value: U256::ZERO, - input: ZoneInbox::refundsCall { - token: ZONE_TOKEN_ADDRESS, - owner, - } - .abi_encode() - .into(), - }); - - assert_eq!( - zone_inbox_refunds_mismatched_owner(&request, caller), - Some(owner) - ); - } - - #[test] - fn zone_inbox_refunds_mismatched_owner_ignores_other_calls() { - let caller = Address::repeat_byte(0x11); - let mut request = zone_inbox_refunds_request(Address::repeat_byte(0x22)); - request.inner.to = Some(TxKind::Call(Address::repeat_byte(0x33))); - - assert_eq!(zone_inbox_refunds_mismatched_owner(&request, caller), None); - } } diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 66aec59c..ab61b5d3 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -15,6 +15,7 @@ workspace = true tempo-alloy.workspace = true tempo-contracts.workspace = true tempo-primitives.workspace = true +tempo-zone-contracts.workspace = true # alloy alloy-consensus.workspace = true @@ -25,6 +26,7 @@ alloy-provider = { workspace = true, features = ["reqwest"] } alloy-rpc-types-eth.workspace = true alloy-signer.workspace = true alloy-signer-local.workspace = true +alloy-sol-types.workspace = true # rpc server axum = { workspace = true, features = ["ws"] } diff --git a/crates/rpc/src/policy.rs b/crates/rpc/src/policy.rs index 2fd99193..4c923059 100644 --- a/crates/rpc/src/policy.rs +++ b/crates/rpc/src/policy.rs @@ -2,16 +2,38 @@ //! //! Shared by [`ZoneRpcApi`] implementations. +use std::future::Future; + use alloy_consensus::transaction::SignerRecoverable; use alloy_eips::eip2718::Decodable2718; use alloy_network::TransactionBuilder; +use alloy_primitives::{Address, Bytes, TxKind}; +use alloy_sol_types::SolCall; use tempo_alloy::rpc::TempoTransactionRequest; use tempo_primitives::TempoTxEnvelope; +use tempo_zone_contracts::{ZONE_INBOX_ADDRESS, ZoneInbox}; use crate::{auth::AuthContext, types::JsonRpcError}; const CONTRACT_CREATION_NOT_SUPPORTED: &str = "contract creation not supported on zones"; +/// Enforce all private RPC authorization rules for simulation-style requests. +/// +/// The sequencer check is lazy: it is awaited only for calls that try to read +/// another account's `ZoneInbox.refunds(token, owner)` entry. +pub async fn enforce_authorized( + request: &mut TempoTransactionRequest, + auth: &AuthContext, + is_sequencer: F, +) -> Result<(), JsonRpcError> +where + F: Future>, +{ + enforce_from(request, auth)?; + enforce_no_contract_creation(request)?; + enforce_zone_inbox_refund_call_privacy(request, auth, is_sequencer).await +} + /// Enforce that `from` matches the authenticated caller. /// /// - If `from` is omitted, sets it to `auth.caller`. @@ -49,6 +71,63 @@ pub fn enforce_no_contract_creation(request: &TempoTransactionRequest) -> Result Ok(()) } +async fn enforce_zone_inbox_refund_call_privacy( + request: &TempoTransactionRequest, + auth: &AuthContext, + is_sequencer: F, +) -> Result<(), JsonRpcError> +where + F: Future>, +{ + if zone_inbox_refunds_mismatched_owner(request, auth.caller).is_none() { + return Ok(()); + } + + if is_sequencer.await? { + return Ok(()); + } + + Err(JsonRpcError::account_mismatch()) +} + +/// Finds a direct or nested `ZoneInbox.refunds(token, owner)` read where +/// `owner` is not the authenticated caller. +/// +/// Other calls, contract creations, and malformed calldata are ignored here. +fn zone_inbox_refunds_mismatched_owner( + request: &TempoTransactionRequest, + caller: Address, +) -> Option
{ + let refunds_owner_mismatch = |to: Option
, input: Option<&Bytes>| { + if to != Some(ZONE_INBOX_ADDRESS) { + return None; + } + + let input = input?; + if !input.starts_with(&ZoneInbox::refundsCall::SELECTOR) { + return None; + } + + let owner = ZoneInbox::refundsCall::abi_decode(input).ok()?.owner; + (owner != caller).then_some(owner) + }; + + if let Some(owner) = refunds_owner_mismatch( + TransactionBuilder::to(request), + TransactionBuilder::input(request), + ) { + return Some(owner); + } + + request.calls.iter().find_map(|call| { + let to = match call.to { + TxKind::Call(to) => Some(to), + TxKind::Create => None, + }; + refunds_owner_mismatch(to, Some(&call.input)) + }) +} + /// Decode a raw transaction and verify the recovered sender matches the /// authenticated caller. Returns `-32003 Transaction rejected` on mismatch. pub fn verify_raw_tx_sender(data: &[u8], auth: &AuthContext) -> Result<(), JsonRpcError> { @@ -70,10 +149,12 @@ pub fn verify_raw_tx_sender(data: &[u8], auth: &AuthContext) -> Result<(), JsonR mod tests { use alloy_primitives::{Address, Bytes, TxKind, U256}; use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; + use alloy_sol_types::SolCall; use tempo_alloy::rpc::TempoTransactionRequest; use tempo_primitives::transaction::Call; + use tempo_zone_contracts::{ZONE_INBOX_ADDRESS, ZONE_TOKEN_ADDRESS, ZoneInbox}; - use super::enforce_no_contract_creation; + use super::{enforce_no_contract_creation, zone_inbox_refunds_mismatched_owner}; fn call_target(byte: u8) -> TxKind { TxKind::Call(Address::repeat_byte(byte)) @@ -90,6 +171,24 @@ mod tests { } } + fn zone_inbox_refunds_request(owner: Address) -> TempoTransactionRequest { + TempoTransactionRequest { + inner: TransactionRequest { + to: Some(TxKind::Call(ZONE_INBOX_ADDRESS)), + input: TransactionInput::new( + ZoneInbox::refundsCall { + token: ZONE_TOKEN_ADDRESS, + owner, + } + .abi_encode() + .into(), + ), + ..Default::default() + }, + ..Default::default() + } + } + #[test] fn no_create_allows_standard_call_request() { let request = call_request(Some(call_target(0x11))); @@ -137,4 +236,61 @@ mod tests { assert_eq!(err.code, -32602); assert_eq!(err.message, "contract creation not supported on zones"); } + + #[test] + fn zone_inbox_refunds_mismatched_owner_detects_outer_call() { + let caller = Address::repeat_byte(0x11); + let owner = Address::repeat_byte(0x22); + let request = zone_inbox_refunds_request(owner); + + assert_eq!( + zone_inbox_refunds_mismatched_owner(&request, caller), + Some(owner) + ); + } + + #[test] + fn zone_inbox_refunds_mismatched_owner_allows_own_outer_call() { + let caller = Address::repeat_byte(0x11); + let request = zone_inbox_refunds_request(caller); + + assert_eq!(zone_inbox_refunds_mismatched_owner(&request, caller), None); + } + + #[test] + fn zone_inbox_refunds_mismatched_owner_detects_nested_tempo_call() { + let caller = Address::repeat_byte(0x11); + let owner = Address::repeat_byte(0x22); + let mut request = TempoTransactionRequest { + inner: TransactionRequest { + to: Some(TxKind::Call(Address::repeat_byte(0x33))), + ..Default::default() + }, + ..Default::default() + }; + request.calls.push(Call { + to: TxKind::Call(ZONE_INBOX_ADDRESS), + value: U256::ZERO, + input: ZoneInbox::refundsCall { + token: ZONE_TOKEN_ADDRESS, + owner, + } + .abi_encode() + .into(), + }); + + assert_eq!( + zone_inbox_refunds_mismatched_owner(&request, caller), + Some(owner) + ); + } + + #[test] + fn zone_inbox_refunds_mismatched_owner_ignores_other_calls() { + let caller = Address::repeat_byte(0x11); + let mut request = zone_inbox_refunds_request(Address::repeat_byte(0x22)); + request.inner.to = Some(TxKind::Call(Address::repeat_byte(0x33))); + + assert_eq!(zone_inbox_refunds_mismatched_owner(&request, caller), None); + } }