Skip to content
Merged
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 15 additions & 7 deletions crates/node/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,18 @@ impl<Api: EthApiTypes + 'static> ZoneRpc<Api> {
.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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions crates/node/tests/it/private_rpc_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<()> {
Expand Down
2 changes: 2 additions & 0 deletions crates/rpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"] }
Expand Down
158 changes: 157 additions & 1 deletion crates/rpc/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<F>(
request: &mut TempoTransactionRequest,
auth: &AuthContext,
is_sequencer: F,
) -> Result<(), JsonRpcError>
where
F: Future<Output = Result<bool, JsonRpcError>>,
{
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`.
Expand Down Expand Up @@ -49,6 +71,63 @@ pub fn enforce_no_contract_creation(request: &TempoTransactionRequest) -> Result
Ok(())
}

async fn enforce_zone_inbox_refund_call_privacy<F>(
request: &TempoTransactionRequest,
auth: &AuthContext,
is_sequencer: F,
) -> Result<(), JsonRpcError>
where
F: Future<Output = Result<bool, JsonRpcError>>,
{
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<Address> {
let refunds_owner_mismatch = |to: Option<Address>, 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> {
Expand All @@ -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))
Expand All @@ -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)));
Expand Down Expand Up @@ -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);
}
}
Loading