From d70f4b6317b75ca447f725b82d6e3253a0b742b3 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 6 May 2026 19:44:03 +0530 Subject: [PATCH 1/2] fix(tip-1034): sync escrow implementation with spec Amp-Thread-ID: https://ampcode.com/threads/T-019dfd57-2d44-729b-a226-99e1cc5cdf05 --- crates/alloy/src/rpc/reth_compat.rs | 1 + .../src/precompiles/tip20_channel_escrow.rs | 26 +- crates/precompiles/src/error.rs | 5 +- .../src/tip20_channel_escrow/dispatch.rs | 4 +- .../src/tip20_channel_escrow/mod.rs | 589 +++++++++++++++--- crates/revm/src/handler.rs | 19 +- crates/revm/src/tx.rs | 142 ++++- 7 files changed, 670 insertions(+), 116 deletions(-) diff --git a/crates/alloy/src/rpc/reth_compat.rs b/crates/alloy/src/rpc/reth_compat.rs index cdaeb52e27..11ae2c4a47 100644 --- a/crates/alloy/src/rpc/reth_compat.rs +++ b/crates/alloy/src/rpc/reth_compat.rs @@ -132,6 +132,7 @@ impl TryIntoTxEnv for TempoTransaction Ok(TempoTxEnv { fee_token, is_system_tx: false, + channel_open_context_hash: None, fee_payer, tempo_tx_env: if !calls.is_empty() || !tempo_authorization_list.is_empty() diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs index c92eafe7f0..9722c380ca 100644 --- a/crates/contracts/src/precompiles/tip20_channel_escrow.rs +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -15,15 +15,17 @@ crate::sol! { struct ChannelDescriptor { address payer; address payee; + address operator; address token; bytes32 salt; address authorizedSigner; + bytes32 expiringNonceHash; } struct ChannelState { uint96 settled; uint96 deposit; - uint32 closeData; + uint32 closeRequestedAt; } struct Channel { @@ -36,6 +38,7 @@ crate::sol! { function open( address payee, + address operator, address token, uint96 deposit, bytes32 salt, @@ -84,9 +87,11 @@ crate::sol! { function computeChannelId( address payer, address payee, + address operator, address token, bytes32 salt, - address authorizedSigner + address authorizedSigner, + bytes32 expiringNonceHash ) external view @@ -103,9 +108,11 @@ crate::sol! { bytes32 indexed channelId, address indexed payer, address indexed payee, + address operator, address token, address authorizedSigner, bytes32 salt, + bytes32 expiringNonceHash, uint96 deposit ); @@ -149,12 +156,13 @@ crate::sol! { error ChannelAlreadyExists(); error ChannelNotFound(); - error ChannelFinalized(); error NotPayer(); error NotPayee(); + error NotPayeeOrOperator(); error InvalidPayee(); error InvalidToken(); error ZeroDeposit(); + error ExpiringNonceHashNotSet(); error InvalidSignature(); error AmountExceedsDeposit(); error AmountNotIncreasing(); @@ -174,10 +182,6 @@ impl TIP20ChannelEscrowError { Self::ChannelNotFound(ITIP20ChannelEscrow::ChannelNotFound {}) } - pub const fn channel_finalized() -> Self { - Self::ChannelFinalized(ITIP20ChannelEscrow::ChannelFinalized {}) - } - pub const fn not_payer() -> Self { Self::NotPayer(ITIP20ChannelEscrow::NotPayer {}) } @@ -186,6 +190,10 @@ impl TIP20ChannelEscrowError { Self::NotPayee(ITIP20ChannelEscrow::NotPayee {}) } + pub const fn not_payee_or_operator() -> Self { + Self::NotPayeeOrOperator(ITIP20ChannelEscrow::NotPayeeOrOperator {}) + } + pub const fn invalid_payee() -> Self { Self::InvalidPayee(ITIP20ChannelEscrow::InvalidPayee {}) } @@ -198,6 +206,10 @@ impl TIP20ChannelEscrowError { Self::ZeroDeposit(ITIP20ChannelEscrow::ZeroDeposit {}) } + pub const fn expiring_nonce_hash_not_set() -> Self { + Self::ExpiringNonceHashNotSet(ITIP20ChannelEscrow::ExpiringNonceHashNotSet {}) + } + pub const fn invalid_signature() -> Self { Self::InvalidSignature(ITIP20ChannelEscrow::InvalidSignature {}) } diff --git a/crates/precompiles/src/error.rs b/crates/precompiles/src/error.rs index 0eaa472d63..809db79438 100644 --- a/crates/precompiles/src/error.rs +++ b/crates/precompiles/src/error.rs @@ -21,8 +21,9 @@ use revm::{ }; use tempo_contracts::precompiles::{ AccountKeychainError, AddrRegistryError, FeeManagerError, NonceError, RolesAuthError, - SignatureVerifierError, StablecoinDEXError, TIP20ChannelEscrowError, TIP20FactoryError, TIP403RegistryError, - TIPFeeAMMError, UnknownFunctionSelector, ValidatorConfigError, ValidatorConfigV2Error, + SignatureVerifierError, StablecoinDEXError, TIP20ChannelEscrowError, TIP20FactoryError, + TIP403RegistryError, TIPFeeAMMError, UnknownFunctionSelector, ValidatorConfigError, + ValidatorConfigV2Error, }; /// Top-level error type for all Tempo precompile operations diff --git a/crates/precompiles/src/tip20_channel_escrow/dispatch.rs b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs index bf2bcbaa09..4465c565c6 100644 --- a/crates/precompiles/src/tip20_channel_escrow/dispatch.rs +++ b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs @@ -1,7 +1,7 @@ //! ABI dispatch for the [`TIP20ChannelEscrow`] precompile. -use super::{TIP20ChannelEscrow, CLOSE_GRACE_PERIOD, VOUCHER_TYPEHASH}; -use crate::{charge_input_cost, dispatch_call, metadata, mutate, mutate_void, view, Precompile}; +use super::{CLOSE_GRACE_PERIOD, TIP20ChannelEscrow, VOUCHER_TYPEHASH}; +use crate::{Precompile, charge_input_cost, dispatch_call, metadata, mutate, mutate_void, view}; use alloy::{primitives::Address, sol_types::SolInterface}; use revm::precompile::PrecompileResult; use tempo_contracts::precompiles::{ diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs index f5ed1e846c..e098e7e52b 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -19,8 +19,6 @@ pub use tempo_contracts::precompiles::{ }; use tempo_precompiles_macros::{Storable, contract}; -const FINALIZED_CLOSE_DATA: u32 = 1; - /// 15 minute grace period between `requestClose` and `withdraw`. pub const CLOSE_GRACE_PERIOD: u64 = 15 * 60; @@ -36,7 +34,7 @@ static VERSION_HASH: LazyLock = LazyLock::new(|| keccak256(b"1")); struct PackedChannelState { settled: U96, deposit: U96, - close_data: u32, + close_requested_at: u32, } impl PackedChannelState { @@ -44,19 +42,15 @@ impl PackedChannelState { !self.deposit.is_zero() } - fn is_finalized(self) -> bool { - self.close_data == FINALIZED_CLOSE_DATA - } - fn close_requested_at(self) -> Option { - (self.close_data >= 2).then_some(self.close_data) + (self.close_requested_at != 0).then_some(self.close_requested_at) } fn to_sol(self) -> ITIP20ChannelEscrow::ChannelState { ITIP20ChannelEscrow::ChannelState { settled: self.settled, deposit: self.deposit, - closeData: self.close_data, + closeRequestedAt: self.close_requested_at, } } } @@ -64,6 +58,11 @@ impl PackedChannelState { #[contract(addr = TIP20_CHANNEL_ESCROW_ADDRESS)] pub struct TIP20ChannelEscrow { channel_states: Mapping, + + // WARNING: transient storage slots must remain after persistent storage fields until the + // `contract` macro supports independent persistent/transient layouts. + opened_this_tx: Mapping, + channel_open_context_hash: B256, } impl TIP20ChannelEscrow { @@ -71,6 +70,16 @@ impl TIP20ChannelEscrow { self.__initialize() } + /// Seeds the enclosing transaction's replay-protected context hash for `open` calls. + /// + /// The handler seeds `keccak256(encode_for_signing || sender)` for every real transaction + /// type. The value is stored in transient storage so batched `open` calls share the same + /// transaction-derived hash and the context is automatically cleared before the next + /// transaction. If this is not called, `open` reads zero from transient storage and reverts. + pub fn set_channel_open_context_hash(&mut self, hash: B256) -> Result<()> { + self.channel_open_context_hash.t_write(hash) + } + pub fn open( &mut self, msg_sender: Address, @@ -88,14 +97,19 @@ impl TIP20ChannelEscrow { return Err(TIP20ChannelEscrowError::zero_deposit().into()); } + let expiring_nonce_hash = self.enclosing_channel_open_context_hash()?; let channel_id = self.compute_channel_id_inner( msg_sender, call.payee, + call.operator, call.token, call.salt, call.authorizedSigner, + expiring_nonce_hash, )?; - if self.channel_states[channel_id].read()?.exists() { + if self.channel_states[channel_id].read()?.exists() + || self.opened_this_tx[channel_id].t_read()? + { return Err(TIP20ChannelEscrowError::channel_already_exists().into()); } @@ -103,21 +117,24 @@ impl TIP20ChannelEscrow { self.channel_states[channel_id].write(PackedChannelState { settled: U96::ZERO, deposit, - close_data: 0, + close_requested_at: 0, })?; TIP20Token::from_address(call.token)?.system_transfer_from( msg_sender, self.address, U256::from(call.deposit), )?; + self.opened_this_tx[channel_id].t_write(true)?; self.emit_event(TIP20ChannelEscrowEvent::ChannelOpened( ITIP20ChannelEscrow::ChannelOpened { channelId: channel_id, payer: msg_sender, payee: call.payee, + operator: call.operator, token: call.token, authorizedSigner: call.authorizedSigner, salt: call.salt, + expiringNonceHash: expiring_nonce_hash, deposit: call.deposit, }, ))?; @@ -134,11 +151,10 @@ impl TIP20ChannelEscrow { let channel_id = self.channel_id(&call.descriptor)?; let mut state = self.load_existing_state(channel_id)?; - if msg_sender != call.descriptor.payee { - return Err(TIP20ChannelEscrowError::not_payee().into()); - } - if state.is_finalized() { - return Err(TIP20ChannelEscrowError::channel_finalized().into()); + if msg_sender != call.descriptor.payee + && (call.descriptor.operator.is_zero() || msg_sender != call.descriptor.operator) + { + return Err(TIP20ChannelEscrowError::not_payee_or_operator().into()); } let cumulative = call.cumulativeAmount; @@ -194,9 +210,6 @@ impl TIP20ChannelEscrow { if msg_sender != call.descriptor.payer { return Err(TIP20ChannelEscrowError::not_payer().into()); } - if state.is_finalized() { - return Err(TIP20ChannelEscrowError::channel_finalized().into()); - } let additional = call.additionalDeposit; let next_deposit = state @@ -216,7 +229,7 @@ impl TIP20ChannelEscrow { )?; } if had_close_request { - state.close_data = 0; + state.close_requested_at = 0; } self.channel_states[channel_id].write(state)?; @@ -252,20 +265,13 @@ impl TIP20ChannelEscrow { if msg_sender != call.descriptor.payer { return Err(TIP20ChannelEscrowError::not_payer().into()); } - if state.is_finalized() { - return Err(TIP20ChannelEscrowError::channel_finalized().into()); - } if state.close_requested_at().is_some() { return Ok(()); } - // `close_data` reserves 0 and 1 as sentinels, so tests and local fixtures that run - // with synthetic block timestamps of 0 or 1 can encode inconsistent channel state. - // Mainnet/testnet timestamps are guaranteed to be > 1, so this only matters outside - // real network execution. let close_requested_at = self.now_u32(); let batch = self.storage.checkpoint(); - state.close_data = close_requested_at; + state.close_requested_at = close_requested_at; self.channel_states[channel_id].write(state)?; self.emit_event(TIP20ChannelEscrowEvent::CloseRequested( ITIP20ChannelEscrow::CloseRequested { @@ -286,14 +292,11 @@ impl TIP20ChannelEscrow { call: ITIP20ChannelEscrow::closeCall, ) -> Result<()> { let channel_id = self.channel_id(&call.descriptor)?; - let mut state = self.load_existing_state(channel_id)?; + let state = self.load_existing_state(channel_id)?; if msg_sender != call.descriptor.payee { return Err(TIP20ChannelEscrowError::not_payee().into()); } - if state.is_finalized() { - return Err(TIP20ChannelEscrowError::channel_finalized().into()); - } let cumulative = call.cumulativeAmount; let capture = call.captureAmount; @@ -323,9 +326,7 @@ impl TIP20ChannelEscrow { .expect("capture amount already checked against deposit"); let batch = self.storage.checkpoint(); - state.settled = capture; - state.close_data = FINALIZED_CLOSE_DATA; - self.channel_states[channel_id].write(state)?; + self.channel_states[channel_id].delete()?; let mut token = TIP20Token::from_address(call.descriptor.token)?; if !delta.is_zero() { @@ -355,14 +356,11 @@ impl TIP20ChannelEscrow { call: ITIP20ChannelEscrow::withdrawCall, ) -> Result<()> { let channel_id = self.channel_id(&call.descriptor)?; - let mut state = self.load_existing_state(channel_id)?; + let state = self.load_existing_state(channel_id)?; if msg_sender != call.descriptor.payer { return Err(TIP20ChannelEscrowError::not_payer().into()); } - if state.is_finalized() { - return Err(TIP20ChannelEscrowError::channel_finalized().into()); - } let close_ready = state .close_requested_at() @@ -377,8 +375,7 @@ impl TIP20ChannelEscrow { .expect("settled is always <= deposit"); let batch = self.storage.checkpoint(); - state.close_data = FINALIZED_CLOSE_DATA; - self.channel_states[channel_id].write(state)?; + self.channel_states[channel_id].delete()?; if !refund.is_zero() { TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( self.address, @@ -439,9 +436,11 @@ impl TIP20ChannelEscrow { self.compute_channel_id_inner( call.payer, call.payee, + call.operator, call.token, call.salt, call.authorizedSigner, + call.expiringNonceHash, ) } @@ -468,27 +467,41 @@ impl TIP20ChannelEscrow { self.compute_channel_id_inner( descriptor.payer, descriptor.payee, + descriptor.operator, descriptor.token, descriptor.salt, descriptor.authorizedSigner, + descriptor.expiringNonceHash, ) } + fn enclosing_channel_open_context_hash(&self) -> Result { + let hash = self.channel_open_context_hash.t_read()?; + if hash.is_zero() { + return Err(TIP20ChannelEscrowError::expiring_nonce_hash_not_set().into()); + } + Ok(hash) + } + fn compute_channel_id_inner( &self, payer: Address, payee: Address, + operator: Address, token: Address, salt: B256, authorized_signer: Address, + expiring_nonce_hash: B256, ) -> Result { self.storage.keccak256( &( payer, payee, + operator, token, salt, authorized_signer, + expiring_nonce_hash, self.address, U256::from(self.storage.chain_id()), ) @@ -581,19 +594,47 @@ mod tests { fn descriptor( payer: Address, payee: Address, + operator: Address, token: Address, salt: B256, authorized_signer: Address, + expiring_nonce_hash: B256, ) -> ITIP20ChannelEscrow::ChannelDescriptor { ITIP20ChannelEscrow::ChannelDescriptor { payer, payee, + operator, + token, + salt, + authorizedSigner: authorized_signer, + expiringNonceHash: expiring_nonce_hash, + } + } + + fn open_call( + payee: Address, + operator: Address, + token: Address, + deposit: u128, + salt: B256, + authorized_signer: Address, + ) -> ITIP20ChannelEscrow::openCall { + ITIP20ChannelEscrow::openCall { + payee, + operator, token, + deposit: abi_u96(deposit), salt, authorizedSigner: authorized_signer, } } + fn seed_expiring_nonce_hash(escrow: &mut TIP20ChannelEscrow) -> Result { + let hash = B256::random(); + escrow.set_channel_open_context_hash(hash)?; + Ok(hash) + } + #[test] fn test_selector_coverage() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); @@ -611,7 +652,40 @@ mod tests { } #[test] - fn test_open_settle_close_flow_and_tombstone() -> eyre::Result<()> { + fn test_open_requires_expiring_nonce_hash() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer = Address::random(); + let payee = Address::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(100u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + + let result = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 1, + B256::random(), + Address::ZERO, + ), + ); + assert_eq!( + result.unwrap_err(), + TIP20ChannelEscrowError::expiring_nonce_hash_not_set().into() + ); + Ok(()) + }) + } + + #[test] + fn test_open_settle_close_flow_deletes_state_and_same_tx_reopen_guard() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); let payer_signer = PrivateKeySigner::random(); let payer = payer_signer.address(); @@ -626,16 +700,18 @@ mod tests { let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; let channel_id = escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( payee, - token: token.address(), - deposit: abi_u96(300), + Address::ZERO, + token.address(), + 300, salt, - authorizedSigner: Address::ZERO, - }, + Address::ZERO, + ), )?; let digest = escrow.get_voucher_digest(ITIP20ChannelEscrow::getVoucherDigestCall { @@ -645,7 +721,15 @@ mod tests { let signature = Bytes::copy_from_slice(&payer_signer.sign_hash_sync(&digest)?.as_bytes()); - let channel_descriptor = descriptor(payer, payee, token.address(), salt, Address::ZERO); + let channel_descriptor = descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); escrow.settle( payee, ITIP20ChannelEscrow::settleCall { @@ -654,36 +738,269 @@ mod tests { signature: signature.clone(), }, )?; + + let close_digest = + escrow.get_voucher_digest(ITIP20ChannelEscrow::getVoucherDigestCall { + channelId: channel_id, + cumulativeAmount: abi_u96(500), + })?; + let close_signature = + Bytes::copy_from_slice(&payer_signer.sign_hash_sync(&close_digest)?.as_bytes()); escrow.close( payee, ITIP20ChannelEscrow::closeCall { descriptor: channel_descriptor.clone(), - cumulativeAmount: abi_u96(120), - captureAmount: abi_u96(120), - signature, + cumulativeAmount: abi_u96(500), + captureAmount: abi_u96(200), + signature: close_signature, }, )?; let state = escrow.get_channel_state(ITIP20ChannelEscrow::getChannelStateCall { channelId: channel_id, })?; - assert_eq!(state.closeData, FINALIZED_CLOSE_DATA); - assert_eq!(state.deposit, 300); - assert_eq!(state.settled, 120); + assert!(state.deposit.is_zero()); + assert!(state.settled.is_zero()); + assert_eq!(state.closeRequestedAt, 0); let reopen_result = escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( + payee, + Address::ZERO, + token.address(), + 1, + salt, + Address::ZERO, + ), + ); + assert_eq!( + reopen_result.unwrap_err(), + TIP20ChannelEscrowError::channel_already_exists().into() + ); + + let new_expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + let reopened_channel_id = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 1, + salt, + Address::ZERO, + ), + )?; + assert_ne!(channel_id, reopened_channel_id); + assert_ne!(expiring_nonce_hash, new_expiring_nonce_hash); + + let reopened_state = + escrow.get_channel_state(ITIP20ChannelEscrow::getChannelStateCall { + channelId: reopened_channel_id, + })?; + assert_eq!(reopened_state.deposit, abi_u96(1)); + + Ok(()) + }) + } + + #[test] + fn test_expiring_nonce_hash_and_operator_participate_in_channel_id() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer = Address::random(); + let payee = Address::random(); + let operator = Address::random(); + let salt = B256::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(100u128)) + .apply()?; + let escrow = TIP20ChannelEscrow::new(); + + let hash_a = B256::random(); + let hash_b = B256::random(); + let without_operator = + escrow.compute_channel_id(ITIP20ChannelEscrow::computeChannelIdCall { + payer, payee, + operator: Address::ZERO, + token: token.address(), + salt, + authorizedSigner: Address::ZERO, + expiringNonceHash: hash_a, + })?; + let with_operator = + escrow.compute_channel_id(ITIP20ChannelEscrow::computeChannelIdCall { + payer, + payee, + operator, + token: token.address(), + salt, + authorizedSigner: Address::ZERO, + expiringNonceHash: hash_a, + })?; + let with_other_hash = + escrow.compute_channel_id(ITIP20ChannelEscrow::computeChannelIdCall { + payer, + payee, + operator: Address::ZERO, token: token.address(), - deposit: abi_u96(1), salt, authorizedSigner: Address::ZERO, + expiringNonceHash: hash_b, + })?; + + assert_ne!(without_operator, with_operator); + assert_ne!(without_operator, with_other_hash); + Ok(()) + }) + } + + #[test] + fn test_multiple_opens_same_transaction() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer = Address::random(); + let payee = Address::random(); + let salt = B256::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(100u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + + let hash = seed_expiring_nonce_hash(&mut escrow)?; + let first = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 10, + salt, + Address::ZERO, + ), + )?; + let second = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 10, + B256::random(), + Address::ZERO, + ), + )?; + assert_ne!(first, second); + + let other_hash = seed_expiring_nonce_hash(&mut escrow)?; + let same_descriptor_other_tx_hash = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 10, + salt, + Address::ZERO, + ), + )?; + assert_ne!(first, same_descriptor_other_tx_hash); + assert_ne!(hash, other_hash); + + Ok(()) + }) + } + + #[test] + fn test_settle_allows_operator_and_rejects_unrelated_sender() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let payee = Address::random(); + let operator = Address::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(200u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + + let salt = B256::random(); + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + let channel_id = escrow.open( + payer, + open_call(payee, operator, token.address(), 100, salt, Address::ZERO), + )?; + let channel_descriptor = descriptor( + payer, + payee, + operator, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); + let digest = escrow.get_voucher_digest(ITIP20ChannelEscrow::getVoucherDigestCall { + channelId: channel_id, + cumulativeAmount: abi_u96(40), + })?; + let signature = + Bytes::copy_from_slice(&payer_signer.sign_hash_sync(&digest)?.as_bytes()); + + escrow.settle( + operator, + ITIP20ChannelEscrow::settleCall { + descriptor: channel_descriptor, + cumulativeAmount: abi_u96(40), + signature, + }, + )?; + let state = escrow.get_channel_state(ITIP20ChannelEscrow::getChannelStateCall { + channelId: channel_id, + })?; + assert_eq!(state.settled, abi_u96(40)); + + let salt = B256::random(); + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 10, + salt, + Address::ZERO, + ), + )?; + let descriptor_without_operator = descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); + let result = escrow.settle( + Address::random(), + ITIP20ChannelEscrow::settleCall { + descriptor: descriptor_without_operator, + cumulativeAmount: abi_u96(1), + signature: Bytes::copy_from_slice(&Signature::test_signature().as_bytes()), }, ); assert_eq!( - reopen_result.unwrap_err(), - TIP20ChannelEscrowError::channel_already_exists().into() + result.unwrap_err(), + TIP20ChannelEscrowError::not_payee_or_operator().into() ); Ok(()) @@ -705,24 +1022,40 @@ mod tests { let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; - let descriptor = descriptor(payer, payee, token.address(), salt, Address::ZERO); + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + let descriptor = descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( payee, - token: token.address(), - deposit: abi_u96(100), + Address::ZERO, + token.address(), + 100, salt, - authorizedSigner: Address::ZERO, - }, + Address::ZERO, + ), )?; + escrow.storage.set_timestamp(U256::from(1_000u64)); escrow.request_close( payer, ITIP20ChannelEscrow::requestCloseCall { descriptor: descriptor.clone(), }, )?; + let requested = escrow.get_channel(ITIP20ChannelEscrow::getChannelCall { + descriptor: descriptor.clone(), + })?; + assert_eq!(requested.state.closeRequestedAt, 1_000); + escrow.top_up( payer, ITIP20ChannelEscrow::topUpCall { @@ -732,7 +1065,7 @@ mod tests { )?; let channel = escrow.get_channel(ITIP20ChannelEscrow::getChannelCall { descriptor })?; - assert_eq!(channel.state.closeData, 0); + assert_eq!(channel.state.closeRequestedAt, 0); assert_eq!(channel.state.deposit, 125); Ok(()) @@ -747,6 +1080,7 @@ mod tests { let result = escrow.call( &ITIP20ChannelEscrow::openCall { payee: Address::random(), + operator: Address::ZERO, token: TIP20_CHANNEL_ESCROW_ADDRESS, deposit: abi_u96(1), salt: B256::ZERO, @@ -774,21 +1108,31 @@ mod tests { .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( payee, - token: token.address(), - deposit: abi_u96(100), + Address::ZERO, + token.address(), + 100, salt, - authorizedSigner: Address::ZERO, - }, + Address::ZERO, + ), )?; let result = escrow.settle( payee, ITIP20ChannelEscrow::settleCall { - descriptor: descriptor(payer, payee, token.address(), salt, Address::ZERO), + descriptor: descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ), cumulativeAmount: abi_u96(10), signature: Bytes::copy_from_slice( &Signature::test_signature().as_bytes()[..64], @@ -817,15 +1161,17 @@ mod tests { .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( payee, - token: token.address(), - deposit: abi_u96(100), + Address::ZERO, + token.address(), + 100, salt, - authorizedSigner: Address::ZERO, - }, + Address::ZERO, + ), )?; let mut keychain_signature = Vec::with_capacity(1 + 20 + 65); @@ -836,7 +1182,15 @@ mod tests { let result = escrow.settle( payee, ITIP20ChannelEscrow::settleCall { - descriptor: descriptor(payer, payee, token.address(), salt, Address::ZERO), + descriptor: descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ), cumulativeAmount: abi_u96(10), signature: keychain_signature.into(), }, @@ -849,6 +1203,65 @@ mod tests { }) } + #[test] + fn test_withdraw_after_grace_deletes_state() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer = Address::random(); + let payee = Address::random(); + let salt = B256::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(100u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + let channel_id = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 100, + salt, + Address::ZERO, + ), + )?; + let descriptor = descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); + + escrow.storage.set_timestamp(U256::from(1_000u64)); + escrow.request_close( + payer, + ITIP20ChannelEscrow::requestCloseCall { + descriptor: descriptor.clone(), + }, + )?; + escrow + .storage + .set_timestamp(U256::from(1_000u64 + CLOSE_GRACE_PERIOD)); + escrow.withdraw(payer, ITIP20ChannelEscrow::withdrawCall { descriptor })?; + + let state = escrow.get_channel_state(ITIP20ChannelEscrow::getChannelStateCall { + channelId: channel_id, + })?; + assert!(state.deposit.is_zero()); + assert!(state.settled.is_zero()); + assert_eq!(state.closeRequestedAt, 0); + + Ok(()) + }) + } + #[test] fn test_withdraw_requires_close_request() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); @@ -863,16 +1276,26 @@ mod tests { .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; - let descriptor = descriptor(payer, payee, token.address(), salt, Address::ZERO); + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + let descriptor = descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( payee, - token: token.address(), - deposit: abi_u96(100), + Address::ZERO, + token.address(), + 100, salt, - authorizedSigner: Address::ZERO, - }, + Address::ZERO, + ), )?; let result = escrow.withdraw(payer, ITIP20ChannelEscrow::withdrawCall { descriptor }); diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index fe53647ffe..cba6234bff 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -52,6 +52,7 @@ use tempo_precompiles::{ }, tip_fee_manager::TipFeeManager, tip20::{ITIP20::InsufficientBalance, TIP20Error, TIP20Token}, + tip20_channel_escrow::TIP20ChannelEscrow, }; use tempo_primitives::{ TempoAddressExt, @@ -396,14 +397,15 @@ impl TempoEvmHandler { } impl TempoEvmHandler { - fn seed_tx_origin( + fn seed_precompile_tx_context( &self, evm: &mut TempoEvm, ) -> Result<(), EVMError> { let ctx = evm.ctx_mut(); + let channel_open_context_hash = ctx.tx.channel_open_context_hash(); - // Seed tx.origin in keychain transient storage for both regular execution and - // RPC simulations (`eth_call` / `eth_estimateGas`) that go through handler execution. + // Seed transient precompile transaction context for both regular execution and RPC + // simulations (`eth_call` / `eth_estimateGas`) that go through handler execution. StorageCtx::enter_evm( &mut ctx.journaled_state, &ctx.block, @@ -411,7 +413,14 @@ impl TempoEvmHandler { &ctx.tx, || { let mut keychain = AccountKeychain::new(); - keychain.set_tx_origin(ctx.tx.caller()) + keychain.set_tx_origin(ctx.tx.caller())?; + + if let Some(channel_open_context_hash) = channel_open_context_hash { + let mut channel_escrow = TIP20ChannelEscrow::new(); + channel_escrow.set_channel_open_context_hash(channel_open_context_hash)?; + } + + Ok::<(), TempoPrecompileError>(()) }, ) .map_err(|e| EVMError::Custom(e.to_string())) @@ -868,7 +877,7 @@ where evm: &mut Self::Evm, init_gas: &mut InitialAndFloorGas, ) -> Result<(), Self::Error> { - self.seed_tx_origin(evm)?; + self.seed_precompile_tx_context(evm)?; let block = &evm.inner.ctx.block; let tx = &evm.inner.ctx.tx; diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index 17cbd8e4fd..4254eb6f3e 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -1,7 +1,9 @@ use crate::TempoInvalidTransaction; -use alloy_consensus::{EthereumTxEnvelope, TxEip4844, Typed2718, crypto::secp256k1}; +use alloy_consensus::{ + EthereumTxEnvelope, SignableTransaction, TxEip4844, Typed2718, crypto::secp256k1, +}; use alloy_evm::{FromRecoveredTx, FromTxWithEncoded, IntoTxEnv, TransactionEnvMut}; -use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; +use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256, keccak256}; use core::num::NonZeroU64; use revm::context::{ Transaction, TxEnv, @@ -84,6 +86,11 @@ pub struct TempoTxEnv { /// Whether the transaction is a system transaction. pub is_system_tx: bool, + /// Replay-protected context hash used to derive channel escrow IDs for `open`. + /// + /// Synthetic transaction environments used by tests and simulations may leave this unset. + pub channel_open_context_hash: Option, + /// Optional fee payer specified for the transaction. /// /// - Some(Some(address)) corresponds to a successfully recovered fee payer @@ -117,6 +124,14 @@ impl TempoTxEnv { .is_some_and(|aa| aa.subblock_transaction) } + /// Returns the replay-protected hash used to derive channel escrow IDs for `open`. + /// + /// This is `keccak256(encode_for_signing || sender)` for every real transaction type. For + /// Tempo AA transactions, this matches the existing expiring nonce hash helper. + pub fn channel_open_context_hash(&self) -> Option { + self.channel_open_context_hash + } + /// Returns the first top-level call in the transaction. pub fn first_call(&self) -> Option<(&TxKind, &[u8])> { if let Some(aa) = self.tempo_tx_env.as_ref() { @@ -157,6 +172,16 @@ impl From for TempoTxEnv { } } +fn channel_open_context_hash(tx: &T, sender: Address) -> B256 +where + T: SignableTransaction, +{ + let mut buf = Vec::with_capacity(tx.payload_len_for_signature() + sender.as_slice().len()); + tx.encode_for_signing(&mut buf); + buf.extend_from_slice(sender.as_slice()); + keccak256(buf) +} + impl Transaction for TempoTxEnv { type AccessListItem<'a> = &'a AccessListItem; type Authorization<'a> = &'a Either; @@ -264,7 +289,19 @@ impl IntoTxEnv for TempoTxEnv { impl FromRecoveredTx> for TempoTxEnv { fn from_recovered_tx(tx: &EthereumTxEnvelope, sender: Address) -> Self { - TxEnv::from_recovered_tx(tx, sender).into() + let channel_open_context_hash = match tx { + EthereumTxEnvelope::Legacy(tx) => channel_open_context_hash(tx.tx(), sender), + EthereumTxEnvelope::Eip2930(tx) => channel_open_context_hash(tx.tx(), sender), + EthereumTxEnvelope::Eip1559(tx) => channel_open_context_hash(tx.tx(), sender), + EthereumTxEnvelope::Eip4844(tx) => channel_open_context_hash(tx.tx(), sender), + EthereumTxEnvelope::Eip7702(tx) => channel_open_context_hash(tx.tx(), sender), + }; + + Self { + inner: TxEnv::from_recovered_tx(tx, sender), + channel_open_context_hash: Some(channel_open_context_hash), + ..Default::default() + } } } @@ -337,6 +374,7 @@ impl FromRecoveredTx for TempoTxEnv { }, fee_token: *fee_token, is_system_tx: false, + channel_open_context_hash: Some(aa_signed.expiring_nonce_hash(caller)), fee_payer: fee_payer_signature.map(|sig| { secp256k1::recover_signer(&sig, tx.fee_payer_signature_hash(caller)).ok() }), @@ -376,12 +414,25 @@ impl FromRecoveredTx for TempoTxEnv { inner: TxEnv::from_recovered_tx(inner.tx(), sender), fee_token: None, is_system_tx: tx.is_system_tx(), + channel_open_context_hash: Some(channel_open_context_hash(inner.tx(), sender)), fee_payer: None, tempo_tx_env: None, // Non-AA transaction }, - TempoTxEnvelope::Eip2930(tx) => TxEnv::from_recovered_tx(tx.tx(), sender).into(), - TempoTxEnvelope::Eip1559(tx) => TxEnv::from_recovered_tx(tx.tx(), sender).into(), - TempoTxEnvelope::Eip7702(tx) => TxEnv::from_recovered_tx(tx.tx(), sender).into(), + TempoTxEnvelope::Eip2930(tx) => Self { + inner: TxEnv::from_recovered_tx(tx.tx(), sender), + channel_open_context_hash: Some(channel_open_context_hash(tx.tx(), sender)), + ..Default::default() + }, + TempoTxEnvelope::Eip1559(tx) => Self { + inner: TxEnv::from_recovered_tx(tx.tx(), sender), + channel_open_context_hash: Some(channel_open_context_hash(tx.tx(), sender)), + ..Default::default() + }, + TempoTxEnvelope::Eip7702(tx) => Self { + inner: TxEnv::from_recovered_tx(tx.tx(), sender), + channel_open_context_hash: Some(channel_open_context_hash(tx.tx(), sender)), + ..Default::default() + }, TempoTxEnvelope::AA(tx) => Self::from_recovered_tx(tx, sender), } } @@ -411,17 +462,21 @@ impl FromTxWithEncoded for TempoTxEnv { #[cfg(test)] mod tests { + use alloy_consensus::{Signed, TxLegacy, transaction::TxHashRef}; use alloy_evm::FromRecoveredTx; - use alloy_primitives::{Address, Bytes, Signature, TxKind, U256}; + use alloy_primitives::{Address, Bytes, Signature, TxKind, U256, keccak256}; use core::num::NonZeroU64; use proptest::prelude::*; use revm::context::{Transaction, TxEnv, result::InvalidTransaction}; - use tempo_primitives::transaction::{ - Call, calc_gas_balance_spending, - tempo_transaction::TEMPO_EXPIRING_NONCE_KEY, - tt_signature::{PrimitiveSignature, TempoSignature}, - tt_signed::AASigned, - validate_calls, + use tempo_primitives::{ + TempoTxEnvelope, + transaction::{ + Call, calc_gas_balance_spending, + tempo_transaction::TEMPO_EXPIRING_NONCE_KEY, + tt_signature::{PrimitiveSignature, TempoSignature}, + tt_signed::AASigned, + validate_calls, + }, }; use crate::{TempoInvalidTransaction, TempoTxEnv}; @@ -528,18 +583,25 @@ mod tests { AASigned::new_unhashed(tx, sig) }; - // Expiring nonce tx: expiring_nonce_hash should be Some and match direct computation + // Expiring nonce tx: expiring_nonce_hash should be Some and match direct computation. + // Channel opens reuse the same encode_for_signing||sender hash. let expiring_signed = make_aa_signed(TEMPO_EXPIRING_NONCE_KEY); let expiring_env = TempoTxEnv::from_recovered_tx(&expiring_signed, caller); let tempo_env = expiring_env.tempo_tx_env.as_ref().unwrap(); - let expected_hash = expiring_signed.expiring_nonce_hash(caller); + let expected_expiring_nonce_hash = expiring_signed.expiring_nonce_hash(caller); assert_eq!( tempo_env.expiring_nonce_hash, - Some(expected_hash), + Some(expected_expiring_nonce_hash), "expiring nonce tx must have expiring_nonce_hash set" ); + assert_eq!( + expiring_env.channel_open_context_hash(), + Some(expected_expiring_nonce_hash), + "expiring nonce channel opens must reuse expiring_nonce_hash" + ); - // Regular 2D nonce tx: expiring_nonce_hash should be None + // Regular 2D nonce tx: expiring_nonce_hash should be None and channel opens still use + // the same encode_for_signing||sender construction. let regular_signed = make_aa_signed(U256::from(42)); let regular_env = super::TempoTxEnv::from_recovered_tx(®ular_signed, caller); let regular_tempo_env = regular_env.tempo_tx_env.as_ref().unwrap(); @@ -547,6 +609,52 @@ mod tests { regular_tempo_env.expiring_nonce_hash, None, "regular 2D nonce tx must NOT have expiring_nonce_hash" ); + assert_eq!( + regular_env.channel_open_context_hash(), + Some(regular_signed.expiring_nonce_hash(caller)), + "non-expiring AA channel opens must use encode_for_signing||sender" + ); + } + + #[test] + fn test_legacy_channel_open_context_hash_uses_encoded_signing_payload_and_sender() { + let caller = Address::repeat_byte(0xAA); + let tx = TxLegacy { + chain_id: Some(1), + nonce: 7, + gas_price: 1, + gas_limit: 21_000, + to: TxKind::Call(Address::repeat_byte(0x42)), + value: U256::ZERO, + input: Bytes::new(), + }; + let envelope = + TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); + let tx_hash = *envelope.tx_hash(); + let TempoTxEnvelope::Legacy(signed) = &envelope else { + unreachable!() + }; + + let tx_env = super::TempoTxEnv::from_recovered_tx(&envelope, caller); + let signature_hash = signed.signature_hash(); + assert_ne!( + signature_hash, tx_hash, + "legacy signature hash is the unsigned signing hash, not the signed tx hash" + ); + + let mut signature_hash_and_sender = [0u8; 52]; + signature_hash_and_sender[..32].copy_from_slice(signature_hash.as_slice()); + signature_hash_and_sender[32..].copy_from_slice(caller.as_slice()); + let signature_hash_context = keccak256(signature_hash_and_sender); + let encoded_payload_context = super::channel_open_context_hash(signed.tx(), caller); + assert_ne!( + encoded_payload_context, signature_hash_context, + "channel opens must use the encoded signing payload, not signature_hash||sender" + ); + assert_eq!( + tx_env.channel_open_context_hash(), + Some(encoded_payload_context) + ); } #[test] From e11c01fcd3bbafbbfd1346dc3b2719e26487c020 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 7 May 2026 19:08:01 +0530 Subject: [PATCH 2/2] fix(rpc): seed channel open context in simulations --- crates/alloy/src/rpc/reth_compat.rs | 71 +++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/alloy/src/rpc/reth_compat.rs b/crates/alloy/src/rpc/reth_compat.rs index 11ae2c4a47..bb1b68294f 100644 --- a/crates/alloy/src/rpc/reth_compat.rs +++ b/crates/alloy/src/rpc/reth_compat.rs @@ -1,7 +1,7 @@ use crate::rpc::{TempoHeaderResponse, TempoTransactionRequest}; -use alloy_consensus::{EthereumTxEnvelope, TxEip4844, error::ValueError}; +use alloy_consensus::{EthereumTxEnvelope, SignableTransaction, TxEip4844, error::ValueError}; use alloy_network::{NetworkTransactionBuilder, TxSigner}; -use alloy_primitives::{Address, B256, Bytes, Signature}; +use alloy_primitives::{Address, B256, Bytes, Signature, keccak256}; use core::num::NonZeroU64; use reth_evm::EvmEnv; use reth_primitives_traits::SealedHeader; @@ -99,6 +99,7 @@ impl TryIntoTxEnv for TempoTransaction evm_env: &EvmEnv, ) -> Result { let caller_addr = self.inner.from.unwrap_or_default(); + let channel_open_context_hash = simulation_channel_open_context_hash(&self, caller_addr); let fee_payer = if self.fee_payer_signature.is_some() { // Try to recover the fee payer address from the signature. @@ -132,7 +133,7 @@ impl TryIntoTxEnv for TempoTransaction Ok(TempoTxEnv { fee_token, is_system_tx: false, - channel_open_context_hash: None, + channel_open_context_hash: Some(channel_open_context_hash), fee_payer, tempo_tx_env: if !calls.is_empty() || !tempo_authorization_list.is_empty() @@ -199,6 +200,32 @@ impl TryIntoTxEnv for TempoTransaction } } +fn simulation_channel_open_context_hash(req: &TempoTransactionRequest, sender: Address) -> B256 { + >::try_into_sim_tx(req.clone()) + .map(|tx| channel_open_context_hash_from_sim_tx(&tx, sender)) + .unwrap_or_else(|_| B256::repeat_byte(0x01)) +} + +fn channel_open_context_hash_from_signable(tx: &T, sender: Address) -> B256 +where + T: SignableTransaction, +{ + let mut buf = Vec::with_capacity(tx.payload_len_for_signature() + sender.as_slice().len()); + tx.encode_for_signing(&mut buf); + buf.extend_from_slice(sender.as_slice()); + keccak256(buf) +} + +fn channel_open_context_hash_from_sim_tx(tx: &TempoTxEnvelope, sender: Address) -> B256 { + match tx { + TempoTxEnvelope::Legacy(tx) => channel_open_context_hash_from_signable(tx.tx(), sender), + TempoTxEnvelope::Eip2930(tx) => channel_open_context_hash_from_signable(tx.tx(), sender), + TempoTxEnvelope::Eip1559(tx) => channel_open_context_hash_from_signable(tx.tx(), sender), + TempoTxEnvelope::Eip7702(tx) => channel_open_context_hash_from_signable(tx.tx(), sender), + TempoTxEnvelope::AA(tx) => tx.expiring_nonce_hash(sender), + } +} + /// Creates a mock AA signature for gas estimation based on key type hints /// /// - `key_type`: The primitive signature type (secp256k1, P256, WebAuthn) @@ -390,6 +417,44 @@ mod tests { assert_eq!(estimated_calls, built_calls); } + #[test] + fn test_try_into_tx_env_sets_channel_open_context_hash_for_rpc_simulation() { + let sender = address!("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let target = address!("0x2222222222222222222222222222222222222222"); + + let req = TempoTransactionRequest { + inner: TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(target)), + nonce: Some(0), + gas: Some(100_000), + max_fee_per_gas: Some(1_000_000_000), + max_priority_fee_per_gas: Some(1_000_000), + chain_id: Some(4217), + ..Default::default() + }, + ..Default::default() + }; + + let evm_env = EvmEnv::default(); + let tx_env = req + .clone() + .try_into_tx_env(&evm_env) + .expect("try_into_tx_env"); + let expected = channel_open_context_hash_from_sim_tx( + &>::try_into_sim_tx(req) + .expect("try_into_sim_tx"), + sender, + ); + + assert_eq!(tx_env.channel_open_context_hash(), Some(expected)); + assert_ne!( + tx_env.channel_open_context_hash(), + Some(B256::ZERO), + "RPC simulations must seed a non-zero context hash so TIP20ChannelEscrow.open() does not treat it as unset" + ); + } + #[test] fn test_webauthn_size_clamped_to_max() { // Attempt to create a signature with u32::MAX size (would be ~4GB without fix)