diff --git a/crates/contracts/src/precompiles/mod.rs b/crates/contracts/src/precompiles/mod.rs index 2b7df4eca3..f7a3d49adb 100644 --- a/crates/contracts/src/precompiles/mod.rs +++ b/crates/contracts/src/precompiles/mod.rs @@ -5,6 +5,7 @@ pub mod nonce; pub mod signature_verifier; pub mod stablecoin_dex; pub mod tip20; +pub mod tip20_channel_escrow; pub mod tip20_factory; pub mod tip403_registry; pub mod tip_fee_manager; @@ -20,6 +21,7 @@ pub use signature_verifier::*; pub use stablecoin_dex::*; pub use tip_fee_manager::*; pub use tip20::*; +pub use tip20_channel_escrow::*; pub use tip20_factory::*; pub use tip403_registry::*; pub use validator_config::*; diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs new file mode 100644 index 0000000000..d001a2c26a --- /dev/null +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -0,0 +1,235 @@ +pub use ITIP20ChannelEscrow::{ + ITIP20ChannelEscrowErrors as TIP20ChannelEscrowError, + ITIP20ChannelEscrowEvents as TIP20ChannelEscrowEvent, +}; +use alloy_primitives::{Address, address}; + +pub const TIP20_CHANNEL_ESCROW_ADDRESS: Address = + address!("0x4D50500000000000000000000000000000000000"); + +crate::sol! { + #[derive(Debug, PartialEq, Eq)] + #[sol(abi)] + #[allow(clippy::too_many_arguments)] + interface ITIP20ChannelEscrow { + struct ChannelDescriptor { + address payer; + address payee; + address operator; + address token; + bytes32 salt; + address authorizedSigner; + bytes32 openTxHash; + } + + struct ChannelState { + uint96 settled; + uint96 deposit; + uint32 closeData; + } + + struct Channel { + ChannelDescriptor descriptor; + ChannelState state; + } + + function CLOSE_GRACE_PERIOD() external view returns (uint64); + function VOUCHER_TYPEHASH() external view returns (bytes32); + + function open( + address payee, + address operator, + address token, + uint96 deposit, + bytes32 salt, + address authorizedSigner + ) + external + returns (bytes32 channelId); + + function settle( + ChannelDescriptor calldata descriptor, + uint96 cumulativeAmount, + bytes calldata signature + ) + external; + + function topUp( + ChannelDescriptor calldata descriptor, + uint96 additionalDeposit + ) + external; + + function close( + ChannelDescriptor calldata descriptor, + uint96 cumulativeAmount, + uint96 captureAmount, + bytes calldata signature + ) + external; + + function requestClose(ChannelDescriptor calldata descriptor) external; + + function withdraw(ChannelDescriptor calldata descriptor) external; + + function getChannel(ChannelDescriptor calldata descriptor) + external + view + returns (Channel memory); + + function getChannelState(bytes32 channelId) external view returns (ChannelState memory); + + function getChannelStatesBatch(bytes32[] calldata channelIds) + external + view + returns (ChannelState[] memory); + + function computeChannelId( + address payer, + address payee, + address operator, + address token, + bytes32 salt, + address authorizedSigner, + bytes32 openTxHash + ) + external + view + returns (bytes32); + + function getVoucherDigest(bytes32 channelId, uint96 cumulativeAmount) + external + view + returns (bytes32); + + function domainSeparator() external view returns (bytes32); + + event ChannelOpened( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + address operator, + address token, + address authorizedSigner, + bytes32 salt, + bytes32 openTxHash, + uint96 deposit + ); + + event Settled( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 cumulativeAmount, + uint96 deltaPaid, + uint96 newSettled + ); + + event TopUp( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 additionalDeposit, + uint96 newDeposit + ); + + event CloseRequested( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint256 closeGraceEnd + ); + + event ChannelClosed( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 settledToPayee, + uint96 refundedToPayer + ); + + event CloseRequestCancelled( + bytes32 indexed channelId, + address indexed payer, + address indexed payee + ); + + error ChannelAlreadyExists(); + error ChannelNotFound(); + error NotPayer(); + error NotPayee(); + error NotPayeeOrOperator(); + error InvalidPayee(); + error InvalidToken(); + error ZeroDeposit(); + error InvalidSignature(); + error AmountExceedsDeposit(); + error AmountNotIncreasing(); + error CaptureAmountInvalid(); + error CloseNotReady(); + error DepositOverflow(); + error TransferFailed(); + } +} + +impl TIP20ChannelEscrowError { + pub const fn channel_already_exists() -> Self { + Self::ChannelAlreadyExists(ITIP20ChannelEscrow::ChannelAlreadyExists {}) + } + + pub const fn channel_not_found() -> Self { + Self::ChannelNotFound(ITIP20ChannelEscrow::ChannelNotFound {}) + } + + pub const fn not_payer() -> Self { + Self::NotPayer(ITIP20ChannelEscrow::NotPayer {}) + } + + pub const fn not_payee() -> Self { + 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 {}) + } + + pub const fn invalid_token() -> Self { + Self::InvalidToken(ITIP20ChannelEscrow::InvalidToken {}) + } + + pub const fn zero_deposit() -> Self { + Self::ZeroDeposit(ITIP20ChannelEscrow::ZeroDeposit {}) + } + + pub const fn invalid_signature() -> Self { + Self::InvalidSignature(ITIP20ChannelEscrow::InvalidSignature {}) + } + + pub const fn amount_exceeds_deposit() -> Self { + Self::AmountExceedsDeposit(ITIP20ChannelEscrow::AmountExceedsDeposit {}) + } + + pub const fn amount_not_increasing() -> Self { + Self::AmountNotIncreasing(ITIP20ChannelEscrow::AmountNotIncreasing {}) + } + + pub const fn capture_amount_invalid() -> Self { + Self::CaptureAmountInvalid(ITIP20ChannelEscrow::CaptureAmountInvalid {}) + } + + pub const fn close_not_ready() -> Self { + Self::CloseNotReady(ITIP20ChannelEscrow::CloseNotReady {}) + } + + pub const fn deposit_overflow() -> Self { + Self::DepositOverflow(ITIP20ChannelEscrow::DepositOverflow {}) + } + + pub const fn transfer_failed() -> Self { + Self::TransferFailed(ITIP20ChannelEscrow::TransferFailed {}) + } +} diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index bac6540daa..70c81834e3 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -28,7 +28,8 @@ use reth_revm::{ use std::collections::{HashMap, HashSet}; use tempo_chainspec::{TempoChainSpec, hardfork::TempoHardforks}; use tempo_contracts::precompiles::{ - ADDRESS_REGISTRY_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, + ADDRESS_REGISTRY_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, TIP20_CHANNEL_ESCROW_ADDRESS, + VALIDATOR_CONFIG_V2_ADDRESS, }; use tempo_primitives::{ SubBlock, SubBlockMetadata, TempoReceipt, TempoTxEnvelope, TempoTxType, @@ -444,6 +445,9 @@ where self.deploy_precompile_at_boundary(SIGNATURE_VERIFIER_ADDRESS)?; self.deploy_precompile_at_boundary(ADDRESS_REGISTRY_ADDRESS)?; } + if self.inner.spec.is_t5_active_at_timestamp(timestamp) { + self.deploy_precompile_at_boundary(TIP20_CHANNEL_ESCROW_ADDRESS)?; + } Ok(()) } diff --git a/crates/precompiles/src/error.rs b/crates/precompiles/src/error.rs index 094e1e43e1..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, TIP20FactoryError, TIP403RegistryError, - TIPFeeAMMError, UnknownFunctionSelector, ValidatorConfigError, ValidatorConfigV2Error, + SignatureVerifierError, StablecoinDEXError, TIP20ChannelEscrowError, TIP20FactoryError, + TIP403RegistryError, TIPFeeAMMError, UnknownFunctionSelector, ValidatorConfigError, + ValidatorConfigV2Error, }; /// Top-level error type for all Tempo precompile operations @@ -42,6 +43,10 @@ pub enum TempoPrecompileError { #[error("TIP20 factory error: {0:?}")] TIP20Factory(TIP20FactoryError), + /// Error from TIP-20 channel escrow + #[error("TIP20 channel escrow error: {0:?}")] + TIP20ChannelEscrowError(TIP20ChannelEscrowError), + /// Error from roles auth #[error("Roles auth error: {0:?}")] RolesAuthError(RolesAuthError), @@ -137,6 +142,7 @@ impl TempoPrecompileError { Self::OutOfGas | Self::Fatal(_) | Self::Panic(_) => true, Self::StablecoinDEX(_) | Self::TIP20(_) + | Self::TIP20ChannelEscrowError(_) | Self::NonceError(_) | Self::TIP20Factory(_) | Self::RolesAuthError(_) @@ -177,6 +183,7 @@ impl TempoPrecompileError { Self::StablecoinDEX(e) => e.abi_encode().into(), Self::TIP20(e) => e.abi_encode().into(), Self::TIP20Factory(e) => e.abi_encode().into(), + Self::TIP20ChannelEscrowError(e) => e.abi_encode().into(), Self::RolesAuthError(e) => e.abi_encode().into(), Self::AddrRegistryError(e) => e.abi_encode().into(), Self::TIP403RegistryError(e) => e.abi_encode().into(), @@ -251,6 +258,7 @@ pub fn error_decoder_registry() -> TempoPrecompileErrorRegistry { add_errors_to_registry(&mut registry, TempoPrecompileError::StablecoinDEX); add_errors_to_registry(&mut registry, TempoPrecompileError::TIP20); add_errors_to_registry(&mut registry, TempoPrecompileError::TIP20Factory); + add_errors_to_registry(&mut registry, TempoPrecompileError::TIP20ChannelEscrowError); add_errors_to_registry(&mut registry, TempoPrecompileError::RolesAuthError); add_errors_to_registry(&mut registry, TempoPrecompileError::AddrRegistryError); add_errors_to_registry(&mut registry, TempoPrecompileError::TIP403RegistryError); diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 5a3302e2fe..3e0a1b3cb7 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -15,6 +15,7 @@ pub mod nonce; pub mod signature_verifier; pub mod stablecoin_dex; pub mod tip20; +pub mod tip20_channel_escrow; pub mod tip20_factory; pub mod tip403_registry; pub mod tip_fee_manager; @@ -27,9 +28,9 @@ pub mod test_util; use crate::{ account_keychain::AccountKeychain, address_registry::AddressRegistry, nonce::NonceManager, signature_verifier::SignatureVerifier, stablecoin_dex::StablecoinDEX, storage::StorageCtx, - tip_fee_manager::TipFeeManager, tip20::TIP20Token, tip20_factory::TIP20Factory, - tip403_registry::TIP403Registry, validator_config::ValidatorConfig, - validator_config_v2::ValidatorConfigV2, + tip_fee_manager::TipFeeManager, tip20::TIP20Token, tip20_channel_escrow::TIP20ChannelEscrow, + tip20_factory::TIP20Factory, tip403_registry::TIP403Registry, + validator_config::ValidatorConfig, validator_config_v2::ValidatorConfigV2, }; use tempo_chainspec::hardfork::TempoHardfork; use tempo_primitives::TempoAddressExt; @@ -52,8 +53,8 @@ use revm::{ pub use tempo_contracts::precompiles::{ ACCOUNT_KEYCHAIN_ADDRESS, ADDRESS_REGISTRY_ADDRESS, DEFAULT_FEE_TOKEN, NONCE_PRECOMPILE_ADDRESS, PATH_USD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, STABLECOIN_DEX_ADDRESS, - TIP_FEE_MANAGER_ADDRESS, TIP20_FACTORY_ADDRESS, TIP403_REGISTRY_ADDRESS, - VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, + TIP_FEE_MANAGER_ADDRESS, TIP20_CHANNEL_ESCROW_ADDRESS, TIP20_FACTORY_ADDRESS, + TIP403_REGISTRY_ADDRESS, VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, }; // Re-export storage layout helpers for read-only contexts (e.g., pool validation) @@ -120,6 +121,8 @@ pub fn extend_tempo_precompiles(precompiles: &mut PrecompilesMap, cfg: &CfgEnv) -> DynPrecompile { + tempo_precompile!("TIP20ChannelEscrow", cfg, |input| { Self::new() }) + } +} + /// Dispatches a parameterless view call, encoding the return via `T`. #[inline] fn metadata(f: impl FnOnce() -> Result) -> PrecompileResult { @@ -1130,6 +1140,13 @@ mod tests { "SignatureVerifier should be registered at T3" ); + // Channel escrow should be registered at T5 + let channel_escrow_precompile = precompiles.get(&TIP20_CHANNEL_ESCROW_ADDRESS); + assert!( + channel_escrow_precompile.is_none(), + "TIP20 channel escrow should not be registered before T5" + ); + // TIP20 tokens with prefix should be registered let tip20_precompile = precompiles.get(&PATH_USD_ADDRESS); assert!( @@ -1157,6 +1174,26 @@ mod tests { ); } + #[test] + fn test_channel_escrow_registered_at_t5_only() { + let pre_t5 = CfgEnv::::default(); + assert!( + tempo_precompiles(&pre_t5) + .get(&TIP20_CHANNEL_ESCROW_ADDRESS) + .is_none(), + "TIP20 channel escrow should NOT be registered before T5" + ); + + let mut t5 = CfgEnv::::default(); + t5.set_spec(TempoHardfork::T5); + assert!( + tempo_precompiles(&t5) + .get(&TIP20_CHANNEL_ESCROW_ADDRESS) + .is_some(), + "TIP20 channel escrow should be registered at T5" + ); + } + #[test] fn test_p256verify_availability_across_t1c_boundary() { let has_p256 = |spec: TempoHardfork| -> bool { diff --git a/crates/precompiles/src/storage/thread_local.rs b/crates/precompiles/src/storage/thread_local.rs index 2e51068ea8..8fcb3305f0 100644 --- a/crates/precompiles/src/storage/thread_local.rs +++ b/crates/precompiles/src/storage/thread_local.rs @@ -336,8 +336,6 @@ impl<'evm> StorageCtx { { let internals = EvmInternals::new(journal, block_env, cfg, tx_env); let mut provider = EvmPrecompileStorageProvider::new_max_gas(internals, cfg); - - // The core logic of setting up thread-local storage is here. Self::enter(&mut provider, f) } diff --git a/crates/precompiles/src/tip20_channel_escrow/dispatch.rs b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs new file mode 100644 index 0000000000..4465c565c6 --- /dev/null +++ b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs @@ -0,0 +1,67 @@ +//! ABI dispatch for the [`TIP20ChannelEscrow`] 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::{ + ITIP20ChannelEscrow, ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls, +}; + +impl Precompile for TIP20ChannelEscrow { + fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { + if let Some(err) = charge_input_cost(&mut self.storage, calldata) { + return err; + } + + dispatch_call( + calldata, + &[], + ITIP20ChannelEscrowCalls::abi_decode, + |call| match call { + ITIP20ChannelEscrowCalls::CLOSE_GRACE_PERIOD(_) => { + metadata::(|| { + Ok(CLOSE_GRACE_PERIOD) + }) + } + ITIP20ChannelEscrowCalls::VOUCHER_TYPEHASH(_) => { + metadata::(|| Ok(*VOUCHER_TYPEHASH)) + } + ITIP20ChannelEscrowCalls::open(call) => { + mutate(call, msg_sender, |sender, c| self.open(sender, c)) + } + ITIP20ChannelEscrowCalls::settle(call) => { + mutate_void(call, msg_sender, |sender, c| self.settle(sender, c)) + } + ITIP20ChannelEscrowCalls::topUp(call) => { + mutate_void(call, msg_sender, |sender, c| self.top_up(sender, c)) + } + ITIP20ChannelEscrowCalls::close(call) => { + mutate_void(call, msg_sender, |sender, c| self.close(sender, c)) + } + ITIP20ChannelEscrowCalls::requestClose(call) => { + mutate_void(call, msg_sender, |sender, c| self.request_close(sender, c)) + } + ITIP20ChannelEscrowCalls::withdraw(call) => { + mutate_void(call, msg_sender, |sender, c| self.withdraw(sender, c)) + } + ITIP20ChannelEscrowCalls::getChannel(call) => view(call, |c| self.get_channel(c)), + ITIP20ChannelEscrowCalls::getChannelState(call) => { + view(call, |c| self.get_channel_state(c)) + } + ITIP20ChannelEscrowCalls::getChannelStatesBatch(call) => { + view(call, |c| self.get_channel_states_batch(c)) + } + ITIP20ChannelEscrowCalls::computeChannelId(call) => { + view(call, |c| self.compute_channel_id(c)) + } + ITIP20ChannelEscrowCalls::getVoucherDigest(call) => { + view(call, |c| self.get_voucher_digest(c)) + } + ITIP20ChannelEscrowCalls::domainSeparator(call) => { + view(call, |_| self.domain_separator()) + } + }, + ) + } +} diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs new file mode 100644 index 0000000000..8d336922a1 --- /dev/null +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -0,0 +1,1024 @@ +//! TIP-1034 TIP-20 channel escrow precompile. + +pub mod dispatch; + +use crate::{ + error::Result, + signature_verifier::SignatureVerifier, + storage::{Handler, Mapping}, + tip20::{TIP20Token, is_tip20_prefix}, +}; +use alloy::{ + primitives::{Address, B256, U256, aliases::U96, keccak256}, + sol_types::SolValue, +}; +use std::sync::LazyLock; +pub use tempo_contracts::precompiles::{ + ITIP20ChannelEscrow, TIP20_CHANNEL_ESCROW_ADDRESS, TIP20ChannelEscrowError, + TIP20ChannelEscrowEvent, +}; +use tempo_precompiles_macros::{Storable, contract}; + +/// 15 minute grace period between `requestClose` and `withdraw`. +pub const CLOSE_GRACE_PERIOD: u64 = 15 * 60; + +static VOUCHER_TYPEHASH: LazyLock = + LazyLock::new(|| keccak256(b"Voucher(bytes32 channelId,uint96 cumulativeAmount)")); +static EIP712_DOMAIN_TYPEHASH: LazyLock = LazyLock::new(|| { + keccak256(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") +}); +static NAME_HASH: LazyLock = LazyLock::new(|| keccak256(b"TIP20 Channel Escrow")); +static VERSION_HASH: LazyLock = LazyLock::new(|| keccak256(b"1")); + +#[derive(Debug, Clone, Copy, Default, Storable)] +struct PackedChannelState { + settled: U96, + deposit: U96, + close_data: u32, +} + +impl From for ITIP20ChannelEscrow::ChannelState { + fn from(state: PackedChannelState) -> Self { + Self { + settled: state.settled, + deposit: state.deposit, + closeData: state.close_data, + } + } +} + +impl PackedChannelState { + fn exists(self) -> bool { + !self.deposit.is_zero() + } + + fn close_requested_at(self) -> Option { + (self.close_data != 0).then_some(self.close_data) + } +} + +#[contract(addr = TIP20_CHANNEL_ESCROW_ADDRESS)] +pub struct TIP20ChannelEscrow { + channel_states: Mapping, + + // WARNING(rusowsky): transient storage slots must always be placed at the very end until the + // `contract` macro is refactored and has 2 independent layouts (persistent and transient). + // If new (persistent) storage fields need to be added to the precompile, they must go above + // these ones. + current_tx_hash: B256, + opened_this_tx: Mapping, +} + +impl TIP20ChannelEscrow { + pub fn initialize(&mut self) -> Result<()> { + self.__initialize() + } + + /// Sets the current top-level transaction hash for the active execution. + /// + /// Called by the handler before transaction execution. Uses transient storage, so it is + /// automatically cleared after the transaction. + pub fn set_current_tx_hash(&mut self, tx_hash: B256) -> Result<()> { + self.current_tx_hash.t_write(tx_hash) + } + + pub fn open( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::openCall, + ) -> Result { + if call.payee == Address::ZERO { + return Err(TIP20ChannelEscrowError::invalid_payee().into()); + } + if !is_tip20_prefix(call.token) { + return Err(TIP20ChannelEscrowError::invalid_token().into()); + } + + let deposit = call.deposit; + if deposit.is_zero() { + return Err(TIP20ChannelEscrowError::zero_deposit().into()); + } + let open_tx_hash = self.current_tx_hash.t_read()?; + if open_tx_hash.is_zero() { + return Err(crate::error::TempoPrecompileError::Fatal( + "current tx hash unavailable".into(), + )); + } + + let channel_id = self.compute_channel_id_inner( + msg_sender, + call.payee, + call.operator, + call.token, + call.salt, + call.authorizedSigner, + open_tx_hash, + )?; + if self.channel_states[channel_id].read()?.exists() { + return Err(TIP20ChannelEscrowError::channel_already_exists().into()); + } + if self.opened_this_tx[channel_id].t_read()? { + return Err(TIP20ChannelEscrowError::channel_already_exists().into()); + } + + let batch = self.storage.checkpoint(); + self.channel_states[channel_id].write(PackedChannelState { + settled: U96::ZERO, + deposit, + close_data: 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, + openTxHash: open_tx_hash, + deposit: call.deposit, + }, + ))?; + batch.commit(); + + Ok(channel_id) + } + + pub fn settle( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::settleCall, + ) -> Result<()> { + let channel_id = self.channel_id(&call.descriptor)?; + let mut state = self.load_existing_state(channel_id)?; + + 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; + if cumulative > state.deposit { + return Err(TIP20ChannelEscrowError::amount_exceeds_deposit().into()); + } + if cumulative <= state.settled { + return Err(TIP20ChannelEscrowError::amount_not_increasing().into()); + } + + self.validate_voucher( + &call.descriptor, + channel_id, + call.cumulativeAmount, + &call.signature, + )?; + + let delta = cumulative + .checked_sub(state.settled) + .expect("cumulative amount already checked to be increasing"); + + let batch = self.storage.checkpoint(); + state.settled = cumulative; + self.channel_states[channel_id].write(state)?; + TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( + self.address, + call.descriptor.payee, + U256::from(delta), + )?; + self.emit_event(TIP20ChannelEscrowEvent::Settled( + ITIP20ChannelEscrow::Settled { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + cumulativeAmount: call.cumulativeAmount, + deltaPaid: delta.into(), + newSettled: cumulative.into(), + }, + ))?; + batch.commit(); + + Ok(()) + } + + pub fn top_up( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::topUpCall, + ) -> Result<()> { + let channel_id = self.channel_id(&call.descriptor)?; + let mut state = self.load_existing_state(channel_id)?; + + if msg_sender != call.descriptor.payer { + return Err(TIP20ChannelEscrowError::not_payer().into()); + } + + let additional = call.additionalDeposit; + let next_deposit = state + .deposit + .checked_add(additional) + .ok_or_else(TIP20ChannelEscrowError::deposit_overflow)?; + + let had_close_request = state.close_requested_at().is_some(); + let batch = self.storage.checkpoint(); + + if !additional.is_zero() { + state.deposit = next_deposit; + TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( + msg_sender, + self.address, + U256::from(call.additionalDeposit), + )?; + } + if had_close_request { + state.close_data = 0; + } + + self.channel_states[channel_id].write(state)?; + if had_close_request { + self.emit_event(TIP20ChannelEscrowEvent::CloseRequestCancelled( + ITIP20ChannelEscrow::CloseRequestCancelled { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + }, + ))?; + } + self.emit_event(TIP20ChannelEscrowEvent::TopUp(ITIP20ChannelEscrow::TopUp { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + additionalDeposit: call.additionalDeposit, + newDeposit: state.deposit, + }))?; + batch.commit(); + + Ok(()) + } + + pub fn request_close( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::requestCloseCall, + ) -> Result<()> { + let channel_id = self.channel_id(&call.descriptor)?; + let mut state = self.load_existing_state(channel_id)?; + + if msg_sender != call.descriptor.payer { + return Err(TIP20ChannelEscrowError::not_payer().into()); + } + if state.close_requested_at().is_some() { + return Ok(()); + } + + // `close_data = 0` is reserved for "no close request", so synthetic timestamp 0 in + // tests cannot be represented exactly. Real network timestamps are always non-zero. + let close_requested_at = self.now_u32(); + let batch = self.storage.checkpoint(); + state.close_data = close_requested_at; + self.channel_states[channel_id].write(state)?; + self.emit_event(TIP20ChannelEscrowEvent::CloseRequested( + ITIP20ChannelEscrow::CloseRequested { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + closeGraceEnd: U256::from(self.now() + CLOSE_GRACE_PERIOD), + }, + ))?; + batch.commit(); + + Ok(()) + } + + pub fn close( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::closeCall, + ) -> Result<()> { + let channel_id = self.channel_id(&call.descriptor)?; + let state = self.load_existing_state(channel_id)?; + + if msg_sender != call.descriptor.payee { + return Err(TIP20ChannelEscrowError::not_payee().into()); + } + + let cumulative = call.cumulativeAmount; + let capture = call.captureAmount; + let previous_settled = state.settled; + if capture < previous_settled || capture > cumulative { + return Err(TIP20ChannelEscrowError::capture_amount_invalid().into()); + } + if capture > state.deposit { + return Err(TIP20ChannelEscrowError::amount_exceeds_deposit().into()); + } + + if capture > previous_settled { + self.validate_voucher( + &call.descriptor, + channel_id, + call.cumulativeAmount, + &call.signature, + )?; + } + + let delta = capture + .checked_sub(previous_settled) + .expect("capture amount already checked against previous settled amount"); + let refund = state + .deposit + .checked_sub(capture) + .expect("capture amount already checked against deposit"); + + let batch = self.storage.checkpoint(); + self.channel_states[channel_id].write(PackedChannelState::default())?; + + let mut token = TIP20Token::from_address(call.descriptor.token)?; + if !delta.is_zero() { + token.system_transfer_from(self.address, call.descriptor.payee, U256::from(delta))?; + } + if !refund.is_zero() { + token.system_transfer_from(self.address, call.descriptor.payer, U256::from(refund))?; + } + + self.emit_event(TIP20ChannelEscrowEvent::ChannelClosed( + ITIP20ChannelEscrow::ChannelClosed { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + settledToPayee: capture.into(), + refundedToPayer: refund.into(), + }, + ))?; + batch.commit(); + + Ok(()) + } + + pub fn withdraw( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::withdrawCall, + ) -> Result<()> { + let channel_id = self.channel_id(&call.descriptor)?; + let state = self.load_existing_state(channel_id)?; + + if msg_sender != call.descriptor.payer { + return Err(TIP20ChannelEscrowError::not_payer().into()); + } + + let close_ready = state + .close_requested_at() + .is_some_and(|requested_at| self.now() >= requested_at as u64 + CLOSE_GRACE_PERIOD); + if !close_ready { + return Err(TIP20ChannelEscrowError::close_not_ready().into()); + } + + let refund = state + .deposit + .checked_sub(state.settled) + .expect("settled is always <= deposit"); + + let batch = self.storage.checkpoint(); + self.channel_states[channel_id].write(PackedChannelState::default())?; + if !refund.is_zero() { + TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( + self.address, + call.descriptor.payer, + U256::from(refund), + )?; + } + self.emit_event(TIP20ChannelEscrowEvent::ChannelClosed( + ITIP20ChannelEscrow::ChannelClosed { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + settledToPayee: state.settled, + refundedToPayer: refund.into(), + }, + ))?; + batch.commit(); + + Ok(()) + } + + pub fn get_channel( + &self, + call: ITIP20ChannelEscrow::getChannelCall, + ) -> Result { + let channel_id = self.channel_id(&call.descriptor)?; + Ok(ITIP20ChannelEscrow::Channel { + descriptor: call.descriptor, + state: self.channel_states[channel_id].read()?.into(), + }) + } + + pub fn get_channel_state( + &self, + call: ITIP20ChannelEscrow::getChannelStateCall, + ) -> Result { + Ok(self.channel_states[call.channelId].read()?.into()) + } + + pub fn get_channel_states_batch( + &self, + call: ITIP20ChannelEscrow::getChannelStatesBatchCall, + ) -> Result> { + call.channelIds + .into_iter() + .map(|channel_id| self.channel_states[channel_id].read().map(Into::into)) + .collect() + } + + pub fn compute_channel_id( + &self, + call: ITIP20ChannelEscrow::computeChannelIdCall, + ) -> Result { + self.compute_channel_id_inner( + call.payer, + call.payee, + call.operator, + call.token, + call.salt, + call.authorizedSigner, + call.openTxHash, + ) + } + + pub fn get_voucher_digest( + &self, + call: ITIP20ChannelEscrow::getVoucherDigestCall, + ) -> Result { + self.get_voucher_digest_inner(call.channelId, call.cumulativeAmount) + } + + pub fn domain_separator(&self) -> Result { + self.domain_separator_inner() + } + + fn now(&self) -> u64 { + self.storage.timestamp().saturating_to::() + } + + fn now_u32(&self) -> u32 { + self.storage.timestamp().saturating_to::() + } + + fn channel_id(&self, descriptor: &ITIP20ChannelEscrow::ChannelDescriptor) -> Result { + self.compute_channel_id_inner( + descriptor.payer, + descriptor.payee, + descriptor.operator, + descriptor.token, + descriptor.salt, + descriptor.authorizedSigner, + descriptor.openTxHash, + ) + } + + fn compute_channel_id_inner( + &self, + payer: Address, + payee: Address, + operator: Address, + token: Address, + salt: B256, + authorized_signer: Address, + open_tx_hash: B256, + ) -> Result { + self.storage.keccak256( + &( + payer, + payee, + operator, + token, + salt, + authorized_signer, + open_tx_hash, + self.address, + U256::from(self.storage.chain_id()), + ) + .abi_encode(), + ) + } + + fn load_existing_state(&self, channel_id: B256) -> Result { + let state = self.channel_states[channel_id].read()?; + if !state.exists() { + return Err(TIP20ChannelEscrowError::channel_not_found().into()); + } + Ok(state) + } + + fn expected_signer(&self, descriptor: &ITIP20ChannelEscrow::ChannelDescriptor) -> Address { + if descriptor.authorizedSigner.is_zero() { + descriptor.payer + } else { + descriptor.authorizedSigner + } + } + + fn validate_voucher( + &self, + descriptor: &ITIP20ChannelEscrow::ChannelDescriptor, + channel_id: B256, + cumulative_amount: U96, + signature: &alloy::primitives::Bytes, + ) -> Result<()> { + let digest = self.get_voucher_digest_inner(channel_id, cumulative_amount)?; + let signer = SignatureVerifier::new() + .recover(digest, signature.clone()) + .map_err(|_| TIP20ChannelEscrowError::invalid_signature())?; + if signer != self.expected_signer(descriptor) { + return Err(TIP20ChannelEscrowError::invalid_signature().into()); + } + Ok(()) + } + + fn get_voucher_digest_inner(&self, channel_id: B256, cumulative_amount: U96) -> Result { + let struct_hash = self + .storage + .keccak256(&(*VOUCHER_TYPEHASH, channel_id, cumulative_amount).abi_encode())?; + let domain_separator = self.domain_separator_inner()?; + + let mut digest_input = [0u8; 66]; + digest_input[0] = 0x19; + digest_input[1] = 0x01; + digest_input[2..34].copy_from_slice(domain_separator.as_slice()); + digest_input[34..66].copy_from_slice(struct_hash.as_slice()); + self.storage.keccak256(&digest_input) + } + + fn domain_separator_inner(&self) -> Result { + self.storage.keccak256( + &( + *EIP712_DOMAIN_TYPEHASH, + *NAME_HASH, + *VERSION_HASH, + U256::from(self.storage.chain_id()), + self.address, + ) + .abi_encode(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + Precompile, + storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider}, + test_util::{TIP20Setup, assert_full_coverage, check_selector_coverage}, + }; + use alloy::{ + primitives::{Bytes, Signature}, + sol_types::SolCall, + }; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use tempo_chainspec::hardfork::TempoHardfork; + use tempo_contracts::precompiles::ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls; + + fn abi_u96(value: u128) -> U96 { + U96::from(value) + } + + fn descriptor( + payer: Address, + payee: Address, + token: Address, + salt: B256, + authorized_signer: Address, + open_tx_hash: B256, + ) -> ITIP20ChannelEscrow::ChannelDescriptor { + ITIP20ChannelEscrow::ChannelDescriptor { + payer, + payee, + operator: Address::ZERO, + token, + salt, + authorizedSigner: authorized_signer, + openTxHash: open_tx_hash, + } + } + + #[test] + fn test_selector_coverage() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + StorageCtx::enter(&mut storage, || { + let mut escrow = TIP20ChannelEscrow::new(); + let unsupported = check_selector_coverage( + &mut escrow, + ITIP20ChannelEscrowCalls::SELECTORS, + "ITIP20ChannelEscrow", + ITIP20ChannelEscrowCalls::name_by_selector, + ); + assert_full_coverage([unsupported]); + Ok(()) + }) + } + + #[test] + fn test_open_settle_close_flow_and_reopen_with_new_tx_hash() -> 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 salt = B256::random(); + let open_tx_hash = B256::repeat_byte(0x11); + let reopen_tx_hash = B256::repeat_byte(0x22); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(1_000u128)) + .apply()?; + + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + escrow.set_current_tx_hash(open_tx_hash)?; + + let channel_id = escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + operator: Address::ZERO, + token: token.address(), + deposit: abi_u96(300), + salt, + authorizedSigner: Address::ZERO, + }, + )?; + + let digest = escrow.get_voucher_digest(ITIP20ChannelEscrow::getVoucherDigestCall { + channelId: channel_id, + cumulativeAmount: abi_u96(120), + })?; + 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, + open_tx_hash, + ); + escrow.settle( + payee, + ITIP20ChannelEscrow::settleCall { + descriptor: channel_descriptor.clone(), + cumulativeAmount: abi_u96(120), + signature: signature.clone(), + }, + )?; + escrow.close( + payee, + ITIP20ChannelEscrow::closeCall { + descriptor: channel_descriptor.clone(), + cumulativeAmount: abi_u96(120), + captureAmount: abi_u96(120), + signature, + }, + )?; + + let state = escrow.get_channel_state(ITIP20ChannelEscrow::getChannelStateCall { + channelId: channel_id, + })?; + assert_eq!(state.closeData, 0); + assert_eq!(state.deposit, 0); + assert_eq!(state.settled, 0); + + let same_tx_reopen = escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + operator: Address::ZERO, + token: token.address(), + deposit: abi_u96(1), + salt, + authorizedSigner: Address::ZERO, + }, + ); + assert_eq!( + same_tx_reopen.unwrap_err(), + TIP20ChannelEscrowError::channel_already_exists().into() + ); + + escrow.set_current_tx_hash(reopen_tx_hash)?; + let reopened_channel_id = escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + operator: Address::ZERO, + token: token.address(), + deposit: abi_u96(1), + salt, + authorizedSigner: Address::ZERO, + }, + )?; + assert_ne!(reopened_channel_id, channel_id); + + Ok(()) + }) + } + + #[test] + fn test_open_rejects_missing_tx_hash_context() -> 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(1_000u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + + let err = escrow + .open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + operator: Address::ZERO, + token: token.address(), + deposit: abi_u96(1), + salt: B256::random(), + authorizedSigner: Address::ZERO, + }, + ) + .unwrap_err(); + assert_eq!( + err, + crate::error::TempoPrecompileError::Fatal("current tx hash unavailable".into()) + ); + + Ok(()) + }) + } + + #[test] + fn test_operator_can_settle_to_payee() -> 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(); + let salt = B256::random(); + let open_tx_hash = B256::repeat_byte(0x77); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(1_000u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + escrow.set_current_tx_hash(open_tx_hash)?; + + let channel_id = escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + operator, + token: token.address(), + deposit: abi_u96(100), + salt, + authorizedSigner: Address::ZERO, + }, + )?; + 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()); + + let mut channel_descriptor = descriptor( + payer, + payee, + token.address(), + salt, + Address::ZERO, + open_tx_hash, + ); + channel_descriptor.operator = operator; + escrow.settle( + operator, + ITIP20ChannelEscrow::settleCall { + descriptor: channel_descriptor, + cumulativeAmount: abi_u96(40), + signature, + }, + )?; + + assert_eq!( + token.balance_of(tempo_contracts::precompiles::ITIP20::balanceOfCall { + account: payee, + })?, + U256::from(40) + ); + Ok(()) + }) + } + + #[test] + fn test_top_up_cancels_close_request() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer = Address::random(); + let payee = Address::random(); + let salt = B256::random(); + + let open_tx_hash = B256::repeat_byte(0x33); + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(1_000u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + escrow.set_current_tx_hash(open_tx_hash)?; + + let descriptor = descriptor( + payer, + payee, + token.address(), + salt, + Address::ZERO, + open_tx_hash, + ); + escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + operator: Address::ZERO, + token: token.address(), + deposit: abi_u96(100), + salt, + authorizedSigner: Address::ZERO, + }, + )?; + + escrow.request_close( + payer, + ITIP20ChannelEscrow::requestCloseCall { + descriptor: descriptor.clone(), + }, + )?; + escrow.top_up( + payer, + ITIP20ChannelEscrow::topUpCall { + descriptor: descriptor.clone(), + additionalDeposit: abi_u96(25), + }, + )?; + + let channel = escrow.get_channel(ITIP20ChannelEscrow::getChannelCall { descriptor })?; + assert_eq!(channel.state.closeData, 0); + assert_eq!(channel.state.deposit, 125); + + Ok(()) + }) + } + + #[test] + fn test_dispatch_rejects_static_mutation() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + StorageCtx::enter(&mut storage, || { + let mut escrow = TIP20ChannelEscrow::new(); + escrow.set_current_tx_hash(B256::repeat_byte(0x44))?; + let result = escrow.call( + &ITIP20ChannelEscrow::openCall { + payee: Address::random(), + operator: Address::ZERO, + token: TIP20_CHANNEL_ESCROW_ADDRESS, + deposit: abi_u96(1), + salt: B256::ZERO, + authorizedSigner: Address::ZERO, + } + .abi_encode(), + Address::ZERO, + ); + assert!(result.is_ok()); + Ok(()) + }) + } + + #[test] + fn test_settle_rejects_invalid_signature() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer = Address::random(); + let payee = Address::random(); + let salt = B256::random(); + + let open_tx_hash = B256::repeat_byte(0x55); + 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()?; + escrow.set_current_tx_hash(open_tx_hash)?; + escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + operator: Address::ZERO, + token: token.address(), + deposit: abi_u96(100), + salt, + authorizedSigner: Address::ZERO, + }, + )?; + + let result = escrow.settle( + payee, + ITIP20ChannelEscrow::settleCall { + descriptor: descriptor( + payer, + payee, + token.address(), + salt, + Address::ZERO, + open_tx_hash, + ), + cumulativeAmount: abi_u96(10), + signature: Bytes::copy_from_slice( + &Signature::test_signature().as_bytes()[..64], + ), + }, + ); + assert_eq!( + result.unwrap_err(), + TIP20ChannelEscrowError::invalid_signature().into() + ); + Ok(()) + }) + } + + #[test] + fn test_settle_rejects_keychain_signature_wrapper() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer = Address::random(); + let payee = Address::random(); + let salt = B256::random(); + + let open_tx_hash = B256::repeat_byte(0x66); + 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()?; + escrow.set_current_tx_hash(open_tx_hash)?; + escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + operator: Address::ZERO, + token: token.address(), + deposit: abi_u96(100), + salt, + authorizedSigner: Address::ZERO, + }, + )?; + + let mut keychain_signature = Vec::with_capacity(1 + 20 + 65); + keychain_signature.push(0x03); + keychain_signature.extend_from_slice(Address::random().as_slice()); + keychain_signature.extend_from_slice(Signature::test_signature().as_bytes().as_slice()); + + let result = escrow.settle( + payee, + ITIP20ChannelEscrow::settleCall { + descriptor: descriptor( + payer, + payee, + token.address(), + salt, + Address::ZERO, + open_tx_hash, + ), + cumulativeAmount: abi_u96(10), + signature: keychain_signature.into(), + }, + ); + assert_eq!( + result.unwrap_err(), + TIP20ChannelEscrowError::invalid_signature().into() + ); + Ok(()) + }) + } +} diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index e86a1d677e..3fcbe4607a 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, @@ -416,6 +417,26 @@ impl TempoEvmHandler { ) .map_err(|e| EVMError::Custom(e.to_string())) } + + fn seed_channel_escrow_tx_hash( + &self, + evm: &mut TempoEvm, + ) -> Result<(), EVMError> { + let ctx = evm.ctx_mut(); + let tx_hash = ctx.tx.tx_hash().unwrap_or_default(); + + StorageCtx::enter_evm( + &mut ctx.journaled_state, + &ctx.block, + &ctx.cfg, + &ctx.tx, + || { + let mut escrow = TIP20ChannelEscrow::new(); + escrow.set_current_tx_hash(tx_hash) + }, + ) + .map_err(|e| EVMError::Custom(e.to_string())) + } } impl TempoEvmHandler @@ -869,6 +890,7 @@ where init_gas: &mut InitialAndFloorGas, ) -> Result<(), Self::Error> { self.seed_tx_origin(evm)?; + self.seed_channel_escrow_tx_hash(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..1e30029591 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -1,5 +1,7 @@ use crate::TempoInvalidTransaction; -use alloy_consensus::{EthereumTxEnvelope, TxEip4844, Typed2718, crypto::secp256k1}; +use alloy_consensus::{ + EthereumTxEnvelope, TxEip4844, Typed2718, crypto::secp256k1, transaction::TxHashRef, +}; use alloy_evm::{FromRecoveredTx, FromTxWithEncoded, IntoTxEnv, TransactionEnvMut}; use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; use core::num::NonZeroU64; @@ -93,6 +95,9 @@ pub struct TempoTxEnv { /// AA-specific transaction environment (boxed to keep TempoTxEnv lean for non-AA tx) pub tempo_tx_env: Option>, + + /// Transaction hash for the current top-level transaction. + pub tx_hash: Option, } impl TempoTxEnv { @@ -146,6 +151,11 @@ impl TempoTxEnv { ))) } } + + /// Returns the top-level transaction hash when available. + pub fn tx_hash(&self) -> Option { + self.tx_hash + } } impl From for TempoTxEnv { @@ -264,7 +274,10 @@ impl IntoTxEnv for TempoTxEnv { impl FromRecoveredTx> for TempoTxEnv { fn from_recovered_tx(tx: &EthereumTxEnvelope, sender: Address) -> Self { - TxEnv::from_recovered_tx(tx, sender).into() + Self { + tx_hash: Some(*tx.tx_hash()), + ..TxEnv::from_recovered_tx(tx, sender).into() + } } } @@ -365,6 +378,7 @@ impl FromRecoveredTx for TempoTxEnv { // can only be derived when given an entire block expiring_nonce_idx: None, })), + tx_hash: Some(*aa_signed.hash()), } } } @@ -378,10 +392,20 @@ impl FromRecoveredTx for TempoTxEnv { is_system_tx: tx.is_system_tx(), fee_payer: None, tempo_tx_env: None, // Non-AA transaction + tx_hash: Some(*tx.tx_hash()), + }, + TempoTxEnvelope::Eip2930(tx) => Self { + tx_hash: Some(*tx.tx_hash()), + ..TxEnv::from_recovered_tx(tx.tx(), sender).into() + }, + TempoTxEnvelope::Eip1559(tx) => Self { + tx_hash: Some(*tx.tx_hash()), + ..TxEnv::from_recovered_tx(tx.tx(), sender).into() + }, + TempoTxEnvelope::Eip7702(tx) => Self { + tx_hash: Some(*tx.tx_hash()), + ..TxEnv::from_recovered_tx(tx.tx(), sender).into() }, - 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::AA(tx) => Self::from_recovered_tx(tx, sender), } } diff --git a/tips/tip-1034.md b/tips/tip-1034.md new file mode 100644 index 0000000000..9ef9b42cf2 --- /dev/null +++ b/tips/tip-1034.md @@ -0,0 +1,283 @@ +--- +id: TIP-1034 +title: TIP-20 Channel Escrow Precompile +description: Enshrines TIP-20 channel escrow as a Tempo precompile with payment-lane admission and native escrow transfer semantics. +authors: Tanishk Goyal, Brendan Ryan +status: Draft +related: TIP-20, TIP-1000, TIP-1020, Tempo Session, Tempo Charge +protocolVersion: TBD +--- + +# TIP-1034: TIP-20 Channel Escrow Precompile + +## Abstract + +This TIP enshrines TIP-20 channel escrow in Tempo as a native precompile. The implementation follows the existing reference channel model (`open`, `settle`, `topUp`, `requestClose`, `close`, `withdraw`) and keeps the same EIP-712 voucher flow, with explicit partial-capture updates defined in this spec. + +The precompile is introduced to reduce execution overhead, remove the separate `approve` UX via native escrow movement, and make channel operations first-class payment-lane traffic under congestion. + +## Motivation + +TIP-20 channel escrow is currently specified as a Solidity contract reference implementation. Enshrining that behavior as a precompile is motivated by three protocol-level goals: + +1. **Efficiency**: Channel operations become native precompile execution instead of generic contract execution, reducing overhead and making gas behavior more predictable for high-frequency payment flows. +2. **Payment-Lane Access**: Channel operations become payment-lane transactions, so they are not throttled by the non-payment `general_gas_limit` path during block contention. +3. **Approve-less Escrow UX**: `open` and `topUp` can escrow TIP-20 funds through native system transfer (`systemTransferFrom` semantics), removing the extra `approve` transaction from the user flow. + +This produces a simpler and more reliable path for session and auth/capture style integrations without changing the core channel model developers already use. + +--- + +# Specification + +## Precompile Address + +This TIP introduces a new system precompile at: + +```solidity +address constant TIP20_CHANNEL_ESCROW = 0x4D50500000000000000000000000000000000000; +``` + +`TIP20_CHANNEL_ESCROW` MUST refer to this address throughout this specification. + +## Implementation Details + +This TIP is normative. The current reference Solidity artifacts are informative and live at: + +1. [`tips/verify/src/interfaces/ITIP20ChannelEscrow.sol`](verify/src/interfaces/ITIP20ChannelEscrow.sol) +2. [`tips/verify/src/TIP20ChannelEscrow.sol`](verify/src/TIP20ChannelEscrow.sol) + +Implementations SHOULD keep those reference artifacts aligned with the normative interface and execution rules defined below. + +Channels are unidirectional (`payer -> payee`) and token-specific. + +### Channel Identity And Packed State + +```solidity +struct ChannelDescriptor { + address payer; + address payee; + address operator; + address token; + bytes32 salt; + address authorizedSigner; + bytes32 openTxHash; +} + +struct ChannelState { + uint96 settled; + uint96 deposit; + uint32 closeData; +} + +struct Channel { + ChannelDescriptor descriptor; + ChannelState state; +} +``` + +Implementations MUST store only `ChannelState` on-chain, keyed by `channelId` and packed into a +single 32-byte storage slot. `ChannelDescriptor` is immutable channel identity and MUST NOT be +stored on-chain; it MUST instead be supplied in calldata for post-open operations and emitted in +`ChannelOpened` so indexers and counterparties can recover it. + +The packed slot layout is: + +```text +bits 0..95 settled uint96 +bits 96..191 deposit uint96 +bits 192..223 closeData uint32 +bits 224..255 reserved zero +``` + +These widths are chosen to keep the entire mutable channel state in one storage slot without +introducing any practical limit for production usage. `uint96` supports up to +`2^96 - 1 = 79,228,162,514,264,337,593,543,950,335` base units, which is far above the supply or +escrow size of any realistic TIP-20 token deployment. `uint32` stores second-resolution unix +timestamps through February 2106, which is sufficient for close-request tracking because channels +are expected to live for minutes, hours, days, or months rather than many decades. The reserved +high 32 bits MUST remain zero. + +`closeData` MUST be encoded as: + +1. `0` for an active channel with no close request. +2. Any non-zero value for an active channel with a close request, where the stored value is the + exact `closeRequestedAt` timestamp. + +Closed channels MUST not retain any packed `ChannelState` slot at all. + +`channelId` MUST use the following deterministic domain-separated construction: + +```solidity +channelId = keccak256( + abi.encode( + payer, + payee, + operator, + token, + salt, + authorizedSigner, + openTxHash, + TIP20_CHANNEL_ESCROW, + block.chainid + ) +); +``` + +For `open`, `openTxHash` MUST be the hash of the enclosing channel-open transaction. For all +post-open operations, `openTxHash` MUST be supplied via `ChannelDescriptor` so the implementation +can recompute the same `channelId` without storing immutable descriptor fields on-chain. + +### Interface + +The canonical interface for this TIP is [`tips/verify/src/interfaces/ITIP20ChannelEscrow.sol`](verify/src/interfaces/ITIP20ChannelEscrow.sol). + +Implementations MUST expose an external interface that is semantically identical to that file, +including the `ChannelDescriptor`, `ChannelState`, and `Channel` structs, the descriptor-based +post-open methods, the events, the errors, and the function signatures. + +### Execution Semantics + +Voucher signatures use the following EIP-712 type: + +```solidity +Voucher(bytes32 channelId,uint96 cumulativeAmount) +``` + +Voucher signatures MUST be verified via the TIP-1020 Signature Verification Precompile at +`0x5165300000000000000000000000000000000000`, using the same signature encodings and +verification rules as Tempo transaction signatures. + +This means: + +1. Implementations MUST compute the EIP-712 voucher digest and validate signatures using TIP-1020 `recover` or `verify`, not raw `ecrecover`. +2. Voucher signatures MAY use any TIP-1020-supported Tempo signature type. +3. TIP-1020 keychain wrapper signatures (`0x03` / `0x04`) MUST be rejected for direct voucher verification. +4. Delegated voucher signing MUST use `authorizedSigner`, rather than a keychain wrapper around `payer`. + +Execution semantics use exact timestamp boundaries. Close-grace completion MUST use the strict +predicate `block.timestamp >= closeRequestedAt + CLOSE_GRACE_PERIOD`. Implementations MUST NOT +substitute a different comparison predicate. + +`operator` is an immutable settlement authority for the channel. `address(0)` means the payee is +the only settlement operator. A nonzero `operator` MAY submit `settle` on the payee's behalf, but +all settlement payouts still transfer to `payee`. + +Execution semantics are: + +1. `open` MUST reject zero deposit, invalid token address, and invalid payee address. +2. `open` MUST derive `openTxHash` from the enclosing transaction hash, persist only the packed `ChannelState` slot, and MUST emit the full immutable descriptor in `ChannelOpened`. +3. Post-open methods (`settle`, `topUp`, `close`, `requestClose`, `withdraw`, and descriptor-based views) MUST recompute `channelId` from the supplied descriptor and use that derived id for storage lookup. +4. If `closeData != 0`, a successful `topUp` MUST clear it back to `0` and emit `CloseRequestCancelled`. +5. `requestClose` MUST set `closeData = uint32(block.timestamp)` on the first successful call and leave it unchanged on later successful calls. +6. `settle` MUST be callable only by `payee`, or by `operator` when `operator != address(0)`. +7. `close` MUST be callable only by `payee`. +8. `close` MUST validate the voucher signature via TIP-1020 for any capture-increasing close. +9. Signer MUST be `authorizedSigner` from the supplied descriptor when set, otherwise `payer` from the supplied descriptor. +10. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`. +11. `close` MUST reject when `captureAmount > deposit`, even if `cumulativeAmount > deposit`. +12. A `close` voucher with `cumulativeAmount > deposit` remains valid for signature verification; `captureAmount` is the escrow-bounded amount that may actually be paid out. +13. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. +14. `withdraw` MUST be allowed only when the close grace period has elapsed from `closeData`. +15. Terminal `close` and `withdraw` MUST delete the stored slot entirely. Reopening the same logical channel in a later transaction MUST produce a different `channelId` because `openTxHash` changes. +16. Within one top-level transaction, `open` MUST reject any `channelId` that was already opened earlier in that same transaction, even if the channel was terminally closed or withdrawn before the later `open` call. + +## Native Escrow Movement + +In this precompile, escrow transfers MUST use system TIP-20 movement semantics equivalent to `systemTransferFrom`. + +Required behavior: + +1. `open` escrows `deposit` from `payer` to channel escrow state without requiring a prior user `approve` transaction. +2. `topUp` escrows `additionalDeposit` the same way. +3. `settle`, `close`, and `withdraw` payout paths continue to transfer TIP-20 value using protocol-native token movement. + +### No Separate Emergency Close + +This TIP does not define a separate `emergencyClose` entrypoint for ordinary recipient-specific +`close` failures. + +`close` is the only channel operation that atomically performs both outbound payout legs in one +call: `escrow -> payee` and `escrow -> payer`. If that combined path fails only because one +recipient leg cannot receive funds under the token's current transfer policy, the unaffected party +already has a unilateral single-recipient fallback: + +1. If the payer-side refund leg makes `close` unusable, the payee can continue using `settle` + subject to the normal `settle` bounds. +2. If the payee-side payout leg makes `close` unusable, the payer can recover the remaining escrow + via `requestClose` + `withdraw`. + +This means a recipient-specific `close` failure does not create a new bilateral hostage condition, +so a dedicated `emergencyClose` is not required for that case. + +This reasoning is limited to recipient-specific `close` failures. It does not change the general +requirement that `settle`, `close`, and `withdraw` all rely on protocol-native TIP-20 payout +transfers. If the token's pause state or transfer policy later prevents the escrow address from +sending funds at all, all outbound exit paths can fail. + +## Payment-Lane Integration (Mandatory) + +Channel escrow operations MUST be treated as payment-lane transactions in consensus classification, pool admission, and payload building. + +### Classification Rules + +Implementations MUST define a strict classifier `is_channel_escrow_payment(to, input)` that returns true iff: + +1. `to == TIP20_CHANNEL_ESCROW`. +2. `input` selector is one of `{open, settle, topUp, close, requestClose, withdraw}`. +3. Calldata length/encoding is valid for that selector. +4. For `settle` and `close`, the trailing `signature` bytes use a valid TIP-1020 / Tempo transaction signature encoding. + +Transactions with authorization side effects MUST be classified as non-payment. + +For EIP-7702 transactions, payment classification requires `authorization_list.length == 0`. + +For AA transactions, payment classification MUST satisfy all of: + +1. `calls.length > 0`. +2. `tempo_authorization_list.length == 0`. +3. `key_authorization` is absent. +4. Every call satisfies either TIP-20 strict payment classification or `is_channel_escrow_payment`. + +An AA transaction with an empty `calls` array MUST be classified as non-payment. + +### Required Integration Points + +1. The consensus-level payment classifier (`is_payment`) MUST include TIP-20 channel escrow calls and MUST enforce the same authorization-side-effect exclusions above. +2. The strict builder/pool classifier (`is_payment_v2`) MUST include `is_channel_escrow_payment` and MUST enforce the same authorization-side-effect exclusions above. +3. The transaction pool payment flag MUST be computed from the strict classifier, so channel escrow calls are admitted in the payment lane path. +4. The payload builder non-payment gate (`general_gas_limit` enforcement) MUST treat channel escrow calls as payment, so they are not rejected by the non-payment overflow path. + +### What This Means Operationally + +With this integration, channel lifecycle calls consume payment-lane capacity rather than non-payment capacity. Under high congestion, these transactions continue to compete in the same lane as other payment traffic instead of being excluded by non-payment limits. + +--- + +# Invariants + +1. `settled <= deposit` MUST hold in all reachable states. +2. `settled` is monotonic and can never decrease. +3. Any successful capture (`settle` or `close`) MUST be authorized by a valid voucher signature from the expected signer. +4. Only payer can `topUp`, `requestClose`, and `withdraw`. +5. Only payee, or a nonzero operator, can `settle`. +6. Only payee can `close`. +7. A channel MUST consume exactly one storage slot of mutable on-chain state. +8. `closeData == 0` MUST mean active with no close request. +9. `closeData != 0` MUST mean active with a close request timestamp equal to `closeData`. +10. Closed channels MUST have no remaining mutable on-chain state. +11. Reopening the same logical channel in a later transaction MUST yield a different `channelId` because `openTxHash` is different. +12. Reopening the same `channelId` within one top-level transaction MUST be impossible. +13. Fund conservation MUST hold at all terminal states. +14. Channel escrow calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. +15. `open` and `topUp` MUST not require a prior user `approve` transaction. +16. `withdraw` MUST require an active close request whose grace period has elapsed. +17. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`, and `captureAmount <= deposit`. + +## References + +- [TIP-20](https://docs.tempo.xyz/protocol/tip20/spec) +- [TIP-1000](tip-1000.md) +- [TIP-1020](tip-1020.md) +- [Tempo Session Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-session-00.html) +- [Tempo Charge Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-charge-00.html) diff --git a/tips/verify/src/TIP20ChannelEscrow.sol b/tips/verify/src/TIP20ChannelEscrow.sol new file mode 100644 index 0000000000..46fed6068f --- /dev/null +++ b/tips/verify/src/TIP20ChannelEscrow.sol @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ISignatureVerifier} from "./interfaces/ISignatureVerifier.sol"; +import {ITIP20} from "./interfaces/ITIP20.sol"; +import {ITIP20ChannelEscrow} from "./interfaces/ITIP20ChannelEscrow.sol"; + +/// @title TIP20ChannelEscrow +/// @notice Reference contract for the TIP-1034 channel model. +contract TIP20ChannelEscrow is ITIP20ChannelEscrow { + address public constant TIP20_CHANNEL_ESCROW = 0x4d50500000000000000000000000000000000000; + address public constant SIGNATURE_VERIFIER_PRECOMPILE = 0x5165300000000000000000000000000000000000; + + bytes32 public constant VOUCHER_TYPEHASH = keccak256("Voucher(bytes32 channelId,uint96 cumulativeAmount)"); + + uint64 public constant CLOSE_GRACE_PERIOD = 15 minutes; + + uint256 internal constant _DEPOSIT_OFFSET = 96; + uint256 internal constant _CLOSE_DATA_OFFSET = 192; + + bytes32 internal constant _EIP712_DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 internal constant _NAME_HASH = keccak256("TIP20 Channel Escrow"); + bytes32 internal constant _VERSION_HASH = keccak256("1"); + + mapping(bytes32 => uint256) internal channelStates; + bytes32 internal _openTxHashContext; + // Reference-contract-only approximation of the precompile's transient per-transaction guard. + // Because `channelId` includes `openTxHash`, this does not block real cross-transaction + // reopens, which always use a different transaction hash. + mapping(bytes32 => bool) internal _openedChannelIdsForTest; + + error OpenTxHashNotSet(); + + /// @dev Reference-contract-only hook. The precompile derives this from the enclosing tx hash. + function setOpenTxHashForTest(bytes32 openTxHash) external { + _openTxHashContext = openTxHash; + } + + function open( + address payee, + address operator, + address token, + uint96 deposit, + bytes32 salt, + address authorizedSigner + ) external returns (bytes32 channelId) { + if (payee == address(0)) revert InvalidPayee(); + if (token == address(0)) revert InvalidToken(); + if (deposit == 0) revert ZeroDeposit(); + + bytes32 openTxHash = _consumeOpenTxHash(); + channelId = computeChannelId(msg.sender, payee, operator, token, salt, authorizedSigner, openTxHash); + if (channelStates[channelId] != 0) revert ChannelAlreadyExists(); + if (_openedChannelIdsForTest[channelId]) revert ChannelAlreadyExists(); + + channelStates[channelId] = + _encodeChannelState(ChannelState({settled: 0, deposit: deposit, closeData: 0})); + + // The reference contract keeps ERC-20-style allowance flow for local verification. + // The enshrined precompile should use TIP-20 `systemTransferFrom` semantics instead. + bool success = ITIP20(token).transferFrom(msg.sender, address(this), deposit); + if (!success) revert TransferFailed(); + _openedChannelIdsForTest[channelId] = true; + + emit ChannelOpened(channelId, msg.sender, payee, operator, token, authorizedSigner, salt, openTxHash, deposit); + } + + function settle(ChannelDescriptor calldata descriptor, uint96 cumulativeAmount, bytes calldata signature) external { + bytes32 channelId = _channelId(descriptor); + ChannelState memory channel = _loadChannelState(channelId); + + if (msg.sender != descriptor.payee && (descriptor.operator == address(0) || msg.sender != descriptor.operator)) { + revert NotPayeeOrOperator(); + } + if (cumulativeAmount > channel.deposit) revert AmountExceedsDeposit(); + if (cumulativeAmount <= channel.settled) revert AmountNotIncreasing(); + + _validateVoucher(descriptor, channelId, cumulativeAmount, signature); + + uint96 delta = cumulativeAmount - channel.settled; + channel.settled = cumulativeAmount; + channelStates[channelId] = _encodeChannelState(channel); + + bool success = ITIP20(descriptor.token).transfer(descriptor.payee, delta); + if (!success) revert TransferFailed(); + + emit Settled(channelId, descriptor.payer, descriptor.payee, cumulativeAmount, delta, channel.settled); + } + + function topUp(ChannelDescriptor calldata descriptor, uint96 additionalDeposit) external { + bytes32 channelId = _channelId(descriptor); + ChannelState memory channel = _loadChannelState(channelId); + + if (msg.sender != descriptor.payer) revert NotPayer(); + + if (additionalDeposit > type(uint96).max - channel.deposit) revert DepositOverflow(); + + if (additionalDeposit > 0) { + channel.deposit += additionalDeposit; + + // The reference contract keeps ERC-20-style allowance flow for local verification. + // The enshrined precompile should use TIP-20 `systemTransferFrom` semantics instead. + bool success = ITIP20(descriptor.token).transferFrom(msg.sender, address(this), additionalDeposit); + if (!success) revert TransferFailed(); + } + + if (channel.closeData != 0) { + channel.closeData = 0; + emit CloseRequestCancelled(channelId, descriptor.payer, descriptor.payee); + } + + channelStates[channelId] = _encodeChannelState(channel); + + emit TopUp(channelId, descriptor.payer, descriptor.payee, additionalDeposit, channel.deposit); + } + + function requestClose(ChannelDescriptor calldata descriptor) external { + bytes32 channelId = _channelId(descriptor); + ChannelState memory channel = _loadChannelState(channelId); + + if (msg.sender != descriptor.payer) revert NotPayer(); + + if (channel.closeData == 0) { + channel.closeData = uint32(block.timestamp); + channelStates[channelId] = _encodeChannelState(channel); + emit CloseRequested( + channelId, descriptor.payer, descriptor.payee, uint256(block.timestamp) + CLOSE_GRACE_PERIOD + ); + } + } + + function close( + ChannelDescriptor calldata descriptor, + uint96 cumulativeAmount, + uint96 captureAmount, + bytes calldata signature + ) external { + bytes32 channelId = _channelId(descriptor); + ChannelState memory channel = _loadChannelState(channelId); + + if (msg.sender != descriptor.payee) revert NotPayee(); + + uint96 previousSettled = channel.settled; + if (captureAmount < previousSettled || captureAmount > cumulativeAmount) { + revert CaptureAmountInvalid(); + } + if (captureAmount > channel.deposit) revert AmountExceedsDeposit(); + + if (captureAmount > previousSettled) { + _validateVoucher(descriptor, channelId, cumulativeAmount, signature); + } + + uint96 delta = captureAmount - previousSettled; + uint96 refund = channel.deposit - captureAmount; + + delete channelStates[channelId]; + + if (delta > 0) { + bool payeeTransferSucceeded = ITIP20(descriptor.token).transfer(descriptor.payee, delta); + if (!payeeTransferSucceeded) revert TransferFailed(); + } + + if (refund > 0) { + bool payerTransferSucceeded = ITIP20(descriptor.token).transfer(descriptor.payer, refund); + if (!payerTransferSucceeded) revert TransferFailed(); + } + + emit ChannelClosed(channelId, descriptor.payer, descriptor.payee, captureAmount, refund); + } + + function withdraw(ChannelDescriptor calldata descriptor) external { + bytes32 channelId = _channelId(descriptor); + ChannelState memory channel = _loadChannelState(channelId); + + if (msg.sender != descriptor.payer) revert NotPayer(); + + uint32 closeRequestedAt = _closeRequestedAt(channel.closeData); + bool closeGracePassed = + closeRequestedAt != 0 && block.timestamp >= uint256(closeRequestedAt) + CLOSE_GRACE_PERIOD; + + if (!closeGracePassed) revert CloseNotReady(); + + uint96 refund = channel.deposit - channel.settled; + delete channelStates[channelId]; + + if (refund > 0) { + bool success = ITIP20(descriptor.token).transfer(descriptor.payer, refund); + if (!success) revert TransferFailed(); + } + + emit ChannelClosed(channelId, descriptor.payer, descriptor.payee, channel.settled, refund); + } + + function getChannel(ChannelDescriptor calldata descriptor) external view returns (Channel memory channel) { + channel.descriptor = ChannelDescriptor({ + payer: descriptor.payer, + payee: descriptor.payee, + operator: descriptor.operator, + token: descriptor.token, + salt: descriptor.salt, + authorizedSigner: descriptor.authorizedSigner, + openTxHash: descriptor.openTxHash + }); + channel.state = _decodeChannelState(channelStates[_channelId(descriptor)]); + } + + function getChannelState(bytes32 channelId) external view returns (ChannelState memory) { + return _decodeChannelState(channelStates[channelId]); + } + + function getChannelStatesBatch(bytes32[] calldata channelIds) external view returns (ChannelState[] memory states) { + uint256 length = channelIds.length; + states = new ChannelState[](length); + + for (uint256 i = 0; i < length; ++i) { + states[i] = _decodeChannelState(channelStates[channelIds[i]]); + } + } + + function computeChannelId( + address payer, + address payee, + address operator, + address token, + bytes32 salt, + address authorizedSigner, + bytes32 openTxHash + ) public view returns (bytes32) { + return keccak256( + abi.encode(payer, payee, operator, token, salt, authorizedSigner, openTxHash, TIP20_CHANNEL_ESCROW, block.chainid) + ); + } + + function getVoucherDigest(bytes32 channelId, uint96 cumulativeAmount) external view returns (bytes32) { + bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, cumulativeAmount)); + return _hashTypedData(structHash); + } + + function domainSeparator() external view returns (bytes32) { + return _domainSeparator(); + } + + function _channelId(ChannelDescriptor calldata descriptor) internal view returns (bytes32) { + return computeChannelId( + descriptor.payer, + descriptor.payee, + descriptor.operator, + descriptor.token, + descriptor.salt, + descriptor.authorizedSigner, + descriptor.openTxHash + ); + } + + function _loadChannelState(bytes32 channelId) internal view returns (ChannelState memory) { + uint256 packedState = channelStates[channelId]; + if (packedState == 0) revert ChannelNotFound(); + return _decodeChannelState(packedState); + } + + function _decodeChannelState(uint256 packedState) internal pure returns (ChannelState memory state) { + if (packedState == 0) { + return state; + } + + state.settled = uint96(packedState); + state.deposit = uint96(packedState >> _DEPOSIT_OFFSET); + state.closeData = uint32(packedState >> _CLOSE_DATA_OFFSET); + } + + function _encodeChannelState(ChannelState memory state) internal pure returns (uint256 packedState) { + packedState = uint256(state.settled); + packedState |= uint256(state.deposit) << _DEPOSIT_OFFSET; + packedState |= uint256(state.closeData) << _CLOSE_DATA_OFFSET; + } + + function _closeRequestedAt(uint32 closeData) internal pure returns (uint32) { + return closeData; + } + + function _validateVoucher( + ChannelDescriptor calldata descriptor, + bytes32 channelId, + uint96 cumulativeAmount, + bytes calldata signature + ) internal view { + bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, cumulativeAmount)); + bytes32 digest = _hashTypedData(structHash); + address expectedSigner = + descriptor.authorizedSigner != address(0) ? descriptor.authorizedSigner : descriptor.payer; + + bool isValid; + try ISignatureVerifier(SIGNATURE_VERIFIER_PRECOMPILE).verify(expectedSigner, digest, signature) returns ( + bool valid + ) { + isValid = valid; + } catch { + revert InvalidSignature(); + } + + if (!isValid) revert InvalidSignature(); + } + + function _domainSeparator() internal view returns (bytes32) { + return + keccak256( + abi.encode(_EIP712_DOMAIN_TYPEHASH, _NAME_HASH, _VERSION_HASH, block.chainid, TIP20_CHANNEL_ESCROW) + ); + } + + function _hashTypedData(bytes32 structHash) internal view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); + } + + function _consumeOpenTxHash() internal returns (bytes32 openTxHash) { + openTxHash = _openTxHashContext; + if (openTxHash == bytes32(0)) revert OpenTxHashNotSet(); + delete _openTxHashContext; + } +} diff --git a/tips/verify/src/interfaces/ISignatureVerifier.sol b/tips/verify/src/interfaces/ISignatureVerifier.sol new file mode 100644 index 0000000000..b7b19cb273 --- /dev/null +++ b/tips/verify/src/interfaces/ISignatureVerifier.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.13 <0.9.0; + +/// @title ISignatureVerifier +/// @notice Interface for the TIP-1020 Signature Verification Precompile +/// @dev Deployed at 0x5165300000000000000000000000000000000000 +interface ISignatureVerifier { + + error InvalidFormat(); + error InvalidSignature(); + + /// @notice Recovers the signer of a Tempo signature (secp256k1, P256, WebAuthn). + /// @param hash The message hash that was signed + /// @param signature The encoded signature (see Tempo Transaction spec for formats) + /// @return signer Address of the signer if valid, reverts otherwise + function recover(bytes32 hash, bytes calldata signature) external view returns (address signer); + + /// @notice Verifies a signer against a Tempo signature (secp256k1, P256, WebAuthn). + /// @param signer The input address verified against the recovered signer + /// @param hash The message hash that was signed + /// @param signature The encoded signature (see Tempo Transaction spec for formats) + /// @return True if the input address signed, false otherwise. Reverts on invalid signatures. + function verify( + address signer, + bytes32 hash, + bytes calldata signature + ) + external + view + returns (bool); + +} diff --git a/tips/verify/src/interfaces/ITIP20.sol b/tips/verify/src/interfaces/ITIP20.sol new file mode 100644 index 0000000000..7e8ef2b57b --- /dev/null +++ b/tips/verify/src/interfaces/ITIP20.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.13 <0.9.0; + +/// @title Minimal TIP-20 interface required by the historical TempoStreamChannel reference impl. +interface ITIP20 { + + function transfer(address to, uint256 amount) external returns (bool); + + function transferFrom(address from, address to, uint256 amount) external returns (bool); + +} diff --git a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol new file mode 100644 index 0000000000..45bbe64b5b --- /dev/null +++ b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.20 <0.9.0; + +/// @title ITIP20ChannelEscrow +/// @notice Reference interface for the TIP-1034 channel model. +interface ITIP20ChannelEscrow { + struct ChannelDescriptor { + address payer; + address payee; + address operator; + address token; + bytes32 salt; + address authorizedSigner; + bytes32 openTxHash; + } + + struct ChannelState { + uint96 settled; + uint96 deposit; + uint32 closeData; + } + + struct Channel { + ChannelDescriptor descriptor; + ChannelState state; + } + + function CLOSE_GRACE_PERIOD() external view returns (uint64); + function VOUCHER_TYPEHASH() external view returns (bytes32); + + function open( + address payee, + address operator, + address token, + uint96 deposit, + bytes32 salt, + address authorizedSigner + ) external returns (bytes32 channelId); + + function settle(ChannelDescriptor calldata descriptor, uint96 cumulativeAmount, bytes calldata signature) external; + + function topUp(ChannelDescriptor calldata descriptor, uint96 additionalDeposit) external; + + function close( + ChannelDescriptor calldata descriptor, + uint96 cumulativeAmount, + uint96 captureAmount, + bytes calldata signature + ) external; + + function requestClose(ChannelDescriptor calldata descriptor) external; + + function withdraw(ChannelDescriptor calldata descriptor) external; + + function getChannel(ChannelDescriptor calldata descriptor) external view returns (Channel memory); + + function getChannelState(bytes32 channelId) external view returns (ChannelState memory); + + function getChannelStatesBatch(bytes32[] calldata channelIds) external view returns (ChannelState[] memory); + + function computeChannelId( + address payer, + address payee, + address operator, + address token, + bytes32 salt, + address authorizedSigner, + bytes32 openTxHash + ) external view returns (bytes32); + + function getVoucherDigest(bytes32 channelId, uint96 cumulativeAmount) external view returns (bytes32); + + function domainSeparator() external view returns (bytes32); + + event ChannelOpened( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + address operator, + address token, + address authorizedSigner, + bytes32 salt, + bytes32 openTxHash, + uint96 deposit + ); + + event Settled( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 cumulativeAmount, + uint96 deltaPaid, + uint96 newSettled + ); + + event TopUp( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 additionalDeposit, + uint96 newDeposit + ); + + event CloseRequested( + bytes32 indexed channelId, address indexed payer, address indexed payee, uint256 closeGraceEnd + ); + + event ChannelClosed( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 settledToPayee, + uint96 refundedToPayer + ); + + event CloseRequestCancelled(bytes32 indexed channelId, address indexed payer, address indexed payee); + + error ChannelAlreadyExists(); + error ChannelNotFound(); + error NotPayer(); + error NotPayee(); + error NotPayeeOrOperator(); + error InvalidPayee(); + error InvalidToken(); + error ZeroDeposit(); + error InvalidSignature(); + error AmountExceedsDeposit(); + error AmountNotIncreasing(); + error CaptureAmountInvalid(); + error CloseNotReady(); + error DepositOverflow(); + error TransferFailed(); +} diff --git a/tips/verify/test/TIP20ChannelEscrow.t.sol b/tips/verify/test/TIP20ChannelEscrow.t.sol new file mode 100644 index 0000000000..265bc56536 --- /dev/null +++ b/tips/verify/test/TIP20ChannelEscrow.t.sol @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {TIP20} from "../src/TIP20.sol"; +import {TIP20ChannelEscrow} from "../src/TIP20ChannelEscrow.sol"; +import {ITIP20ChannelEscrow} from "../src/interfaces/ITIP20ChannelEscrow.sol"; +import {BaseTest} from "./BaseTest.t.sol"; + +contract MockSignatureVerifier { + error InvalidFormat(); + error InvalidSignature(); + + function recover(bytes32 hash, bytes calldata signature) external pure returns (address signer) { + return _recover(hash, signature); + } + + function verify(address signer, bytes32 hash, bytes calldata signature) external pure returns (bool) { + return _recover(hash, signature) == signer; + } + + function _recover(bytes32 hash, bytes calldata signature) internal pure returns (address signer) { + if (signature.length != 65) revert InvalidSignature(); + + bytes32 r; + bytes32 s; + uint8 v; + + assembly { + r := calldataload(signature.offset) + s := calldataload(add(signature.offset, 32)) + v := byte(0, calldataload(add(signature.offset, 64))) + } + + if (v < 27) v += 27; + if (v != 27 && v != 28) revert InvalidSignature(); + + signer = ecrecover(hash, v, r, s); + if (signer == address(0)) revert InvalidSignature(); + } +} + +contract TIP20ChannelEscrowTest is BaseTest { + TIP20ChannelEscrow public channel; + TIP20 public token; + + address public payer; + uint256 public payerKey; + address public payee; + bytes32 internal lastOpenTxHash; + uint256 internal openTxCounter; + + uint96 internal constant DEPOSIT = 1_000_000; + bytes32 internal constant SALT = bytes32(uint256(1)); + + function setUp() public override { + super.setUp(); + + channel = new TIP20ChannelEscrow(); + MockSignatureVerifier verifier = new MockSignatureVerifier(); + vm.etch(channel.SIGNATURE_VERIFIER_PRECOMPILE(), address(verifier).code); + token = TIP20(factory.createToken("Stream Token", "STR", "USD", pathUSD, admin, bytes32("stream"))); + + (payer, payerKey) = makeAddrAndKey("payer"); + payee = makeAddr("payee"); + + vm.startPrank(admin); + token.grantRole(_ISSUER_ROLE, admin); + token.mint(payer, 20_000_000); + vm.stopPrank(); + + vm.prank(payer); + token.approve(address(channel), type(uint256).max); + } + + function _openChannel() internal returns (bytes32) { + _prepareNextOpenTxHash(); + vm.prank(payer); + return channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); + } + + function _descriptor() internal view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { + return _descriptor(SALT, address(0), lastOpenTxHash); + } + + function _descriptor(bytes32 salt, address authorizedSigner) + internal + view + returns (ITIP20ChannelEscrow.ChannelDescriptor memory) + { + return _descriptor(salt, authorizedSigner, lastOpenTxHash); + } + + function _descriptor(bytes32 salt, address authorizedSigner, bytes32 openTxHash) + internal + view + returns (ITIP20ChannelEscrow.ChannelDescriptor memory) + { + return ITIP20ChannelEscrow.ChannelDescriptor({ + payer: payer, + payee: payee, + operator: address(0), + token: address(token), + salt: salt, + authorizedSigner: authorizedSigner, + openTxHash: openTxHash + }); + } + + function _prepareNextOpenTxHash() internal returns (bytes32 openTxHash) { + openTxHash = keccak256(abi.encodePacked("open", ++openTxCounter)); + channel.setOpenTxHashForTest(openTxHash); + lastOpenTxHash = openTxHash; + } + + function _channelStateSlot(bytes32 channelId) internal pure returns (bytes32) { + return keccak256(abi.encode(channelId, uint256(0))); + } + + function _signVoucher(bytes32 channelId, uint96 amount) internal view returns (bytes memory) { + return _signVoucher(channelId, amount, payerKey); + } + + function _signVoucher(bytes32 channelId, uint96 amount, uint256 signerKey) internal view returns (bytes memory) { + bytes32 digest = channel.getVoucherDigest(channelId, amount); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, digest); + return abi.encodePacked(r, s, v); + } + + function test_open_success() public { + bytes32 openTxHash = _prepareNextOpenTxHash(); + + vm.prank(payer); + bytes32 channelId = channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); + + ITIP20ChannelEscrow.Channel memory ch = channel.getChannel(_descriptor()); + assertEq(ch.descriptor.payer, payer); + assertEq(ch.descriptor.payee, payee); + assertEq(ch.descriptor.operator, address(0)); + assertEq(ch.descriptor.token, address(token)); + assertEq(ch.descriptor.authorizedSigner, address(0)); + assertEq(ch.descriptor.openTxHash, openTxHash); + assertEq(ch.state.settled, 0); + assertEq(ch.state.deposit, DEPOSIT); + assertEq(ch.state.closeData, 0); + assertEq(channel.getChannelState(channelId).deposit, DEPOSIT); + } + + function test_open_revert_zeroPayee() public { + _prepareNextOpenTxHash(); + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.InvalidPayee.selector); + channel.open(address(0), address(0), address(token), DEPOSIT, SALT, address(0)); + } + + function test_open_revert_zeroToken() public { + _prepareNextOpenTxHash(); + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.InvalidToken.selector); + channel.open(payee, address(0), address(0), DEPOSIT, SALT, address(0)); + } + + function test_open_revert_zeroDeposit() public { + _prepareNextOpenTxHash(); + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.ZeroDeposit.selector); + channel.open(payee, address(0), address(token), 0, SALT, address(0)); + } + + function test_open_same_descriptor_uses_distinct_open_tx_hashes() public { + bytes32 channelId1 = _openChannel(); + bytes32 openTxHash1 = lastOpenTxHash; + + bytes32 channelId2 = _openChannel(); + bytes32 openTxHash2 = lastOpenTxHash; + + assertNotEq(openTxHash1, openTxHash2); + assertNotEq(channelId1, channelId2); + } + + function test_settle_success() public { + bytes32 channelId = _openChannel(); + bytes memory sig = _signVoucher(channelId, 500_000); + + vm.prank(payee); + channel.settle(_descriptor(), 500_000, sig); + + assertEq(channel.getChannelState(channelId).settled, 500_000); + assertEq(token.balanceOf(payee), 500_000); + } + + function test_settle_revert_invalidSignature() public { + bytes32 channelId = _openChannel(); + (, uint256 wrongKey) = makeAddrAndKey("wrong"); + bytes memory sig = _signVoucher(channelId, 500_000, wrongKey); + + vm.prank(payee); + vm.expectRevert(ITIP20ChannelEscrow.InvalidSignature.selector); + channel.settle(_descriptor(), 500_000, sig); + } + + function test_settle_revert_wrongDescriptor() public { + bytes32 channelId = _openChannel(); + bytes memory sig = _signVoucher(channelId, 500_000); + + vm.prank(payee); + vm.expectRevert(ITIP20ChannelEscrow.ChannelNotFound.selector); + channel.settle(_descriptor(bytes32(uint256(2)), address(0)), 500_000, sig); + } + + function test_authorizedSigner_settleSuccess() public { + (address delegateSigner, uint256 delegateKey) = makeAddrAndKey("delegate"); + + _prepareNextOpenTxHash(); + vm.prank(payer); + bytes32 channelId = channel.open(payee, address(0), address(token), DEPOSIT, SALT, delegateSigner); + + bytes memory sig = _signVoucher(channelId, 500_000, delegateKey); + + vm.prank(payee); + channel.settle(_descriptor(SALT, delegateSigner), 500_000, sig); + + assertEq(channel.getChannelState(channelId).settled, 500_000); + } + + function test_topUp_updatesDeposit() public { + bytes32 channelId = _openChannel(); + + vm.prank(payer); + channel.topUp(_descriptor(), 250_000); + + ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); + assertEq(ch.deposit, DEPOSIT + 250_000); + } + + function test_topUp_cancelsCloseRequest() public { + bytes32 channelId = _openChannel(); + + vm.prank(payer); + channel.requestClose(_descriptor()); + + vm.prank(payer); + channel.topUp(_descriptor(), 100_000); + + assertEq(channel.getChannelState(channelId).closeData, 0); + } + + function test_requestClose_storesTimestampInCloseData() public { + bytes32 channelId = _openChannel(); + uint32 closeRequestedAt = uint32(block.timestamp); + + vm.prank(payer); + channel.requestClose(_descriptor()); + + ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); + assertEq(ch.closeData, closeRequestedAt); + + uint256 raw = uint256(vm.load(address(channel), _channelStateSlot(channelId))); + assertEq(uint32(raw >> 192), closeRequestedAt); + } + + function test_close_partialCapture_success() public { + bytes32 channelId = _openChannel(); + bytes memory sig = _signVoucher(channelId, 900_000); + + uint256 payeeBalanceBefore = token.balanceOf(payee); + uint256 payerBalanceBefore = token.balanceOf(payer); + + vm.prank(payee); + channel.close(_descriptor(), 900_000, 600_000, sig); + + ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); + assertEq(ch.settled, 0); + assertEq(ch.closeData, 0); + assertEq(token.balanceOf(payee), payeeBalanceBefore + 600_000); + assertEq(token.balanceOf(payer), payerBalanceBefore + 400_000); + } + + function test_close_usesPreviousSettledForDelta() public { + bytes32 channelId = _openChannel(); + bytes memory settleSig = _signVoucher(channelId, 300_000); + + vm.prank(payee); + channel.settle(_descriptor(), 300_000, settleSig); + + bytes memory closeSig = _signVoucher(channelId, 800_000); + uint256 payeeBalanceBefore = token.balanceOf(payee); + + vm.prank(payee); + channel.close(_descriptor(), 800_000, 500_000, closeSig); + + assertEq(token.balanceOf(payee), payeeBalanceBefore + 200_000); + assertEq(channel.getChannelState(channelId).settled, 0); + } + + function test_close_allowsVoucherAmountAboveDepositWhenCaptureWithinDeposit() public { + bytes32 channelId = _openChannel(); + bytes memory sig = _signVoucher(channelId, DEPOSIT + 250_000); + + uint256 payeeBalanceBefore = token.balanceOf(payee); + + vm.prank(payee); + channel.close(_descriptor(), DEPOSIT + 250_000, DEPOSIT, sig); + + ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); + assertEq(ch.settled, 0); + assertEq(ch.closeData, 0); + assertEq(token.balanceOf(payee), payeeBalanceBefore + DEPOSIT); + } + + function test_close_revert_invalidCaptureAmount() public { + bytes32 channelId = _openChannel(); + bytes memory settleSig = _signVoucher(channelId, 300_000); + + vm.prank(payee); + channel.settle(_descriptor(), 300_000, settleSig); + + vm.prank(payee); + vm.expectRevert(ITIP20ChannelEscrow.CaptureAmountInvalid.selector); + channel.close(_descriptor(), 300_000, 200_000, ""); + } + + function test_close_clears_state_and_allows_reopen_with_new_open_tx_hash() public { + bytes32 channelId = _openChannel(); + bytes memory sig = _signVoucher(channelId, 600_000); + bytes32 originalOpenTxHash = lastOpenTxHash; + + vm.prank(payee); + channel.close(_descriptor(), 600_000, 600_000, sig); + + assertEq(channel.getChannelState(channelId).closeData, 0); + + channel.setOpenTxHashForTest(originalOpenTxHash); + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); + channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); + + bytes32 reopenedChannelId = _openChannel(); + assertNotEq(reopenedChannelId, channelId); + } + + function test_withdraw_afterGracePeriod() public { + bytes32 channelId = _openChannel(); + + vm.prank(payer); + channel.requestClose(_descriptor()); + + vm.warp(block.timestamp + channel.CLOSE_GRACE_PERIOD() + 1); + + uint256 payerBalanceBefore = token.balanceOf(payer); + vm.prank(payer); + channel.withdraw(_descriptor()); + + assertEq(channel.getChannelState(channelId).closeData, 0); + assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); + } + + function test_getChannelStatesBatch_success() public { + bytes32 channelId1 = _openChannel(); + bytes32 channel1OpenTxHash = lastOpenTxHash; + + _prepareNextOpenTxHash(); + vm.prank(payer); + bytes32 channelId2 = + channel.open(payee, address(0), address(token), DEPOSIT, bytes32(uint256(2)), address(0)); + + bytes memory sig = _signVoucher(channelId1, 400_000); + vm.prank(payee); + channel.settle(_descriptor(SALT, address(0), channel1OpenTxHash), 400_000, sig); + + bytes32[] memory ids = new bytes32[](2); + ids[0] = channelId1; + ids[1] = channelId2; + + ITIP20ChannelEscrow.ChannelState[] memory states = channel.getChannelStatesBatch(ids); + assertEq(states.length, 2); + assertEq(states[0].settled, 400_000); + assertEq(states[1].settled, 0); + } + + function test_computeChannelId_usesFixedPrecompileAddress() public { + TIP20ChannelEscrow other = new TIP20ChannelEscrow(); + bytes32 openTxHash = keccak256("openTxHash"); + + bytes32 id1 = channel.computeChannelId(payer, payee, address(0), address(token), SALT, address(0), openTxHash); + bytes32 id2 = other.computeChannelId(payer, payee, address(0), address(token), SALT, address(0), openTxHash); + + assertEq(id1, id2); + assertEq(channel.domainSeparator(), other.domainSeparator()); + } +}