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 f5e04979..083b4224 100644 --- a/crates/node/src/rpc.rs +++ b/crates/node/src/rpc.rs @@ -315,6 +315,18 @@ impl ZoneRpc { .map_err(internal) } + async fn enforce_authorized( + &self, + request: &mut TempoTransactionRequest, + auth: &AuthContext, + ) -> Result<(), JsonRpcError> { + 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( &self, deposit_hash: B256, @@ -596,8 +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_authorized(&mut request, &auth).await?; let result = EthCall::call( &self.eth.api, @@ -623,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, @@ -670,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 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<()> { 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); + } }