From 79772c647fc2558c897c10f6816e4609d32304e2 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 12:31:22 -0400 Subject: [PATCH 01/32] feat: escrow contract --- crates/contracts/src/precompiles/escrow.rs | 62 ++++++++++++++++++++++ crates/contracts/src/precompiles/mod.rs | 3 ++ 2 files changed, 65 insertions(+) create mode 100644 crates/contracts/src/precompiles/escrow.rs diff --git a/crates/contracts/src/precompiles/escrow.rs b/crates/contracts/src/precompiles/escrow.rs new file mode 100644 index 0000000000..6171cbe17f --- /dev/null +++ b/crates/contracts/src/precompiles/escrow.rs @@ -0,0 +1,62 @@ +pub use ITIP1028Escrow::{ + ITIP1028EscrowErrors as TIP1028EscrowError, ITIP1028EscrowEvents as TIP1028EscrowEvent, +}; + +crate::sol! { + #[derive(Debug, PartialEq, Eq)] + #[sol(abi)] + interface ITIP1028Escrow { + enum InboundKind { + TRANSFER, + MINT + } + + struct ClaimReceiptV1 { + address originator; + address recipient; + uint64 blockedAt; + uint64 blockedNonce; + uint8 blockedReason; + InboundKind kind; + bytes32 memo; + } + + function blockedReceiptBalance(address token, address recoveryContract, uint8 receiptVersion, bytes calldata receipt) external view returns (uint256 amount); + function claimBlocked(address token, address recoveryContract, uint8 receiptVersion, bytes calldata receipt, address to) external; + + event BlockedReceiptClaimed(address indexed token, address indexed receiver, uint8 receiptVersion, uint64 indexed blockedNonce, uint64 blockedAt, address originator, address recipient, address recoveryContract, address caller, address to, uint256 amount); + + error UnauthorizedClaimer(); + error InvalidReceiptClaim(); + error ClaimDestinationUnauthorized(); + error InsufficientEscrowBalance(); + error EscrowAddressReserved(); + error InvalidToken(); + } +} + +impl TIP1028EscrowError { + pub const fn unauthorized_claimer() -> Self { + Self::UnauthorizedClaimer(ITIP1028Escrow::UnauthorizedClaimer {}) + } + + pub const fn invalid_receipt_claim() -> Self { + Self::InvalidReceiptClaim(ITIP1028Escrow::InvalidReceiptClaim {}) + } + + pub const fn claim_destination_unauthorized() -> Self { + Self::ClaimDestinationUnauthorized(ITIP1028Escrow::ClaimDestinationUnauthorized {}) + } + + pub const fn insufficient_escrow_balance() -> Self { + Self::InsufficientEscrowBalance(ITIP1028Escrow::InsufficientEscrowBalance {}) + } + + pub const fn escrow_address_reserved() -> Self { + Self::EscrowAddressReserved(ITIP1028Escrow::EscrowAddressReserved {}) + } + + pub const fn invalid_token() -> Self { + Self::InvalidToken(ITIP1028Escrow::InvalidToken {}) + } +} diff --git a/crates/contracts/src/precompiles/mod.rs b/crates/contracts/src/precompiles/mod.rs index 2b7df4eca3..b3f773b84a 100644 --- a/crates/contracts/src/precompiles/mod.rs +++ b/crates/contracts/src/precompiles/mod.rs @@ -1,6 +1,7 @@ pub mod account_keychain; pub mod address_registry; pub mod common_errors; +pub mod escrow; pub mod nonce; pub mod signature_verifier; pub mod stablecoin_dex; @@ -15,6 +16,7 @@ pub use account_keychain::*; pub use address_registry::*; use alloy_primitives::{Address, address}; pub use common_errors::*; +pub use escrow::*; pub use nonce::*; pub use signature_verifier::*; pub use stablecoin_dex::*; @@ -43,3 +45,4 @@ pub const ADDRESS_REGISTRY_ADDRESS: Address = address!("0xFDC0000000000000000000000000000000000000"); pub const SIGNATURE_VERIFIER_ADDRESS: Address = address!("0x5165300000000000000000000000000000000000"); +pub const ESCROW_ADDRESS: Address = address!("0xE5C0000000000000000000000000000000000000"); From b90d34007d8ee8a2f00807454bb742ac8ba6b564 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 12:56:33 -0400 Subject: [PATCH 02/32] feat: escrow precompile --- crates/contracts/src/precompiles/escrow.rs | 10 +- .../src/precompiles/tip403_registry.rs | 6 + crates/precompiles/src/error.rs | 10 +- crates/precompiles/src/lib.rs | 16 +- .../src/tip1028_escrow/dispatch.rs | 30 +++ crates/precompiles/src/tip1028_escrow/mod.rs | 204 ++++++++++++++++++ crates/precompiles/src/tip20/mod.rs | 2 +- 7 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 crates/precompiles/src/tip1028_escrow/dispatch.rs create mode 100644 crates/precompiles/src/tip1028_escrow/mod.rs diff --git a/crates/contracts/src/precompiles/escrow.rs b/crates/contracts/src/precompiles/escrow.rs index 6171cbe17f..1b20cb5cd9 100644 --- a/crates/contracts/src/precompiles/escrow.rs +++ b/crates/contracts/src/precompiles/escrow.rs @@ -28,9 +28,9 @@ crate::sol! { error UnauthorizedClaimer(); error InvalidReceiptClaim(); - error ClaimDestinationUnauthorized(); error InsufficientEscrowBalance(); error EscrowAddressReserved(); + error InvalidClaimAddress(); error InvalidToken(); } } @@ -44,10 +44,6 @@ impl TIP1028EscrowError { Self::InvalidReceiptClaim(ITIP1028Escrow::InvalidReceiptClaim {}) } - pub const fn claim_destination_unauthorized() -> Self { - Self::ClaimDestinationUnauthorized(ITIP1028Escrow::ClaimDestinationUnauthorized {}) - } - pub const fn insufficient_escrow_balance() -> Self { Self::InsufficientEscrowBalance(ITIP1028Escrow::InsufficientEscrowBalance {}) } @@ -56,6 +52,10 @@ impl TIP1028EscrowError { Self::EscrowAddressReserved(ITIP1028Escrow::EscrowAddressReserved {}) } + pub const fn invalid_claim_address() -> Self { + Self::InvalidClaimAddress(ITIP1028Escrow::InvalidClaimAddress {}) + } + pub const fn invalid_token() -> Self { Self::InvalidToken(ITIP1028Escrow::InvalidToken {}) } diff --git a/crates/contracts/src/precompiles/tip403_registry.rs b/crates/contracts/src/precompiles/tip403_registry.rs index a83b31d483..294fbdd6d0 100644 --- a/crates/contracts/src/precompiles/tip403_registry.rs +++ b/crates/contracts/src/precompiles/tip403_registry.rs @@ -13,6 +13,12 @@ crate::sol! { COMPOUND } + enum BlockedReason { + NONE, + TOKEN_FILTER, + RECEIVE_POLICY + } + // View Functions function policyIdCounter() external view returns (uint64); function policyExists(uint64 policyId) external view returns (bool); diff --git a/crates/precompiles/src/error.rs b/crates/precompiles/src/error.rs index 094e1e43e1..3bf0a910ff 100644 --- a/crates/precompiles/src/error.rs +++ b/crates/precompiles/src/error.rs @@ -22,7 +22,8 @@ use revm::{ use tempo_contracts::precompiles::{ AccountKeychainError, AddrRegistryError, FeeManagerError, NonceError, RolesAuthError, SignatureVerifierError, StablecoinDEXError, TIP20FactoryError, TIP403RegistryError, - TIPFeeAMMError, UnknownFunctionSelector, ValidatorConfigError, ValidatorConfigV2Error, + TIP1028EscrowError, TIPFeeAMMError, UnknownFunctionSelector, ValidatorConfigError, + ValidatorConfigV2Error, }; /// Top-level error type for all Tempo precompile operations @@ -86,6 +87,10 @@ pub enum TempoPrecompileError { #[error("Signature verifier error: {0:?}")] SignatureVerifierError(SignatureVerifierError), + /// Error from TIP-1028 escrow precompile + #[error("TIP1028 escrow error: {0:?}")] + TIP1028EscrowError(TIP1028EscrowError), + /// Gas limit exceeded during precompile execution. #[error("Gas limit exceeded")] OutOfGas, @@ -148,6 +153,7 @@ impl TempoPrecompileError { | Self::ValidatorConfigV2Error(_) | Self::AccountKeychainError(_) | Self::SignatureVerifierError(_) + | Self::TIP1028EscrowError(_) | Self::UnknownFunctionSelector(_) => false, } } @@ -194,6 +200,7 @@ impl TempoPrecompileError { Self::ValidatorConfigV2Error(e) => e.abi_encode().into(), Self::AccountKeychainError(e) => e.abi_encode().into(), Self::SignatureVerifierError(e) => e.abi_encode().into(), + Self::TIP1028EscrowError(e) => e.abi_encode().into(), Self::OutOfGas => { return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, reservoir)); } @@ -261,6 +268,7 @@ pub fn error_decoder_registry() -> TempoPrecompileErrorRegistry { add_errors_to_registry(&mut registry, TempoPrecompileError::ValidatorConfigV2Error); add_errors_to_registry(&mut registry, TempoPrecompileError::AccountKeychainError); add_errors_to_registry(&mut registry, TempoPrecompileError::SignatureVerifierError); + add_errors_to_registry(&mut registry, TempoPrecompileError::TIP1028EscrowError); registry } diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 5a3302e2fe..1101cc556d 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -14,6 +14,7 @@ pub mod address_registry; pub mod nonce; pub mod signature_verifier; pub mod stablecoin_dex; +pub mod tip1028_escrow; pub mod tip20; pub mod tip20_factory; pub mod tip403_registry; @@ -28,8 +29,8 @@ 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, + tip403_registry::TIP403Registry, tip1028_escrow::TIP1028Escrow, + validator_config::ValidatorConfig, validator_config_v2::ValidatorConfigV2, }; use tempo_chainspec::hardfork::TempoHardfork; use tempo_primitives::TempoAddressExt; @@ -50,7 +51,7 @@ use revm::{ }; pub use tempo_contracts::precompiles::{ - ACCOUNT_KEYCHAIN_ADDRESS, ADDRESS_REGISTRY_ADDRESS, DEFAULT_FEE_TOKEN, + ACCOUNT_KEYCHAIN_ADDRESS, ADDRESS_REGISTRY_ADDRESS, DEFAULT_FEE_TOKEN, ESCROW_ADDRESS, 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, @@ -138,6 +139,8 @@ pub fn extend_tempo_precompiles(precompiles: &mut PrecompilesMap, cfg: &CfgEnv) -> DynPrecompile { + tempo_precompile!("TIP1028Escrow", cfg, |input| { Self::new() }) + } +} + /// Dispatches a parameterless view call, encoding the return via `T`. #[inline] fn metadata(f: impl FnOnce() -> Result) -> PrecompileResult { diff --git a/crates/precompiles/src/tip1028_escrow/dispatch.rs b/crates/precompiles/src/tip1028_escrow/dispatch.rs new file mode 100644 index 0000000000..2afcf12d7f --- /dev/null +++ b/crates/precompiles/src/tip1028_escrow/dispatch.rs @@ -0,0 +1,30 @@ +//! ABI dispatch for the [`TIP1028Escrow`] precompile. + +use crate::{ + Precompile, charge_input_cost, dispatch_call, mutate_void, tip1028_escrow::TIP1028Escrow, view, +}; +use alloy::{primitives::Address, sol_types::SolInterface}; +use revm::precompile::PrecompileResult; +use tempo_contracts::precompiles::ITIP1028Escrow::ITIP1028EscrowCalls; + +impl Precompile for TIP1028Escrow { + 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, + &[], + ITIP1028EscrowCalls::abi_decode, + |call| match call { + ITIP1028EscrowCalls::blockedReceiptBalance(call) => { + view(call, |c| self.blocked_receipt_balance(c)) + } + ITIP1028EscrowCalls::claimBlocked(call) => { + mutate_void(call, msg_sender, |s, c| self.claim_blocked(s, c)) + } + }, + ) + } +} diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs new file mode 100644 index 0000000000..4c52367a3a --- /dev/null +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -0,0 +1,204 @@ +//! [TIP-1028] escrow precompile for blocked inbound TIP-20 transfers and mints. + +pub mod dispatch; + +pub use tempo_contracts::precompiles::ITIP1028Escrow::{self, InboundKind}; +use tempo_contracts::precompiles::{ + ITIP403Registry::{self, BlockedReason}, + TIP1028EscrowError, TIP1028EscrowEvent, +}; + +use crate::{ + ESCROW_ADDRESS, + address_registry::AddressRegistry, + error::{Result, TempoPrecompileError}, + storage::{Handler, Mapping}, + tip20::TIP20Token, +}; +use alloy::{ + primitives::{Address, B256, U256}, + sol_types::SolValue, +}; +use tempo_precompiles_macros::contract; +use tempo_primitives::TempoAddressExt; + +pub const BLOCKED_RECEIPT_VERSION: u8 = 1; + +#[contract(addr = ESCROW_ADDRESS)] +pub struct TIP1028Escrow { + blocked_receipt_nonce: u64, + blocked_receipt_amount: Mapping, +} + +impl TIP1028Escrow { + pub fn initialize(&mut self) -> Result<()> { + self.__initialize() + } + + pub fn blocked_receipt_balance( + &self, + call: ITIP1028Escrow::blockedReceiptBalanceCall, + ) -> Result { + if !call.token.is_tip20() { + return Err(TIP1028EscrowError::invalid_token().into()); + } + + let receipt = Self::decode_v1(call.receiptVersion, &call.receipt)?; + self.blocked_receipt_amount[self.receipt_key( + call.receiptVersion, + call.token, + call.recoveryContract, + &receipt, + )?] + .read() + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn store_blocked( + &mut self, + token: Address, + originator: Address, + recipient: Address, + recovery_address: Address, + amount: U256, + blocked_reason: BlockedReason, + kind: InboundKind, + memo: B256, + ) -> Result<(u64, u64)> { + if !token.is_tip20() { + return Err(TIP1028EscrowError::invalid_token().into()); + } + + if matches!( + blocked_reason, + ITIP403Registry::BlockedReason::NONE | ITIP403Registry::BlockedReason::__Invalid + ) || kind == ITIP1028Escrow::InboundKind::__Invalid + { + return Err(TIP1028EscrowError::invalid_receipt_claim().into()); + } + + let blocked_nonce = self.next_blocked_receipt_nonce()?; + let blocked_at = self.storage.timestamp().saturating_to::(); + let receipt = ITIP1028Escrow::ClaimReceiptV1 { + originator, + recipient, + blockedAt: blocked_at, + blockedNonce: blocked_nonce, + blockedReason: blocked_reason as u8, + kind, + memo, + }; + let key = self.receipt_key(BLOCKED_RECEIPT_VERSION, token, recovery_address, &receipt)?; + self.blocked_receipt_amount[key].write(amount)?; + + Ok((blocked_nonce, blocked_at)) + } + + pub fn claim_blocked( + &mut self, + msg_sender: Address, + call: ITIP1028Escrow::claimBlockedCall, + ) -> Result<()> { + if !call.token.is_tip20() { + return Err(TIP1028EscrowError::invalid_token().into()); + } + + if call.to == ESCROW_ADDRESS { + return Err(TIP1028EscrowError::invalid_claim_address().into()); + } + + let receipt = Self::decode_v1(call.receiptVersion, &call.receipt)?; + let receiver = AddressRegistry::new() + .resolve_recipient(receipt.recipient) + .map_err(|_| TIP1028EscrowError::invalid_claim_address())?; + + let recovery_address = call.recoveryContract; + let authorized = if recovery_address == Address::ZERO { + msg_sender == receiver + } else { + msg_sender == recovery_address + }; + if !authorized { + return Err(TIP1028EscrowError::unauthorized_claimer().into()); + } + + let key = self.receipt_key(call.receiptVersion, call.token, recovery_address, &receipt)?; + let amount = self.blocked_receipt_amount[key].read()?; + if amount.is_zero() { + return Err(TIP1028EscrowError::invalid_receipt_claim().into()); + } + + let guard = self.storage.checkpoint(); + self.blocked_receipt_amount[key].write(U256::ZERO)?; + + // NOTE: we will update this + // TIP20Token::from_address(call.token)?.release_from_tip1028_escrow( + // receiver, + // call.to, + // amount, + // recovery_address == Address::ZERO, + // )?; + + self.emit_event(TIP1028EscrowEvent::BlockedReceiptClaimed( + ITIP1028Escrow::BlockedReceiptClaimed { + token: call.token, + receiver, + receiptVersion: call.receiptVersion, + blockedNonce: receipt.blockedNonce, + blockedAt: receipt.blockedAt, + originator: receipt.originator, + recipient: receipt.recipient, + recoveryContract: recovery_address, + caller: msg_sender, + to: call.to, + amount, + }, + ))?; + + guard.commit(); + Ok(()) + } + + fn next_blocked_receipt_nonce(&mut self) -> Result { + let nonce = self.blocked_receipt_nonce.read()?.max(1); + self.blocked_receipt_nonce.write( + nonce + .checked_add(1) + .ok_or(TempoPrecompileError::under_overflow())?, + )?; + Ok(nonce) + } + + fn decode_v1(receipt_version: u8, receipt: &[u8]) -> Result { + if receipt_version != BLOCKED_RECEIPT_VERSION { + return Err(TIP1028EscrowError::invalid_receipt_claim().into()); + } + ITIP1028Escrow::ClaimReceiptV1::abi_decode(receipt) + .map_err(|_| TIP1028EscrowError::invalid_receipt_claim().into()) + } + + fn receipt_key( + &self, + receipt_version: u8, + token: Address, + recovery_address: Address, + receipt: &ITIP1028Escrow::ClaimReceiptV1, + ) -> Result { + self.storage.keccak256( + ( + U256::from(receipt_version), + token, + receipt.originator, + receipt.recipient, + recovery_address, + U256::from(receipt.blockedReason), + receipt.kind, + receipt.memo, + U256::from(receipt.blockedAt), + U256::from(receipt.blockedNonce), + ) + .abi_encode() + .as_ref(), + ) + } +} diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 960721a27b..c533286831 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -12,7 +12,7 @@ pub mod dispatch; pub mod rewards; pub mod roles; -use tempo_contracts::precompiles::STABLECOIN_DEX_ADDRESS; +use tempo_contracts::precompiles::{ESCROW_ADDRESS, STABLECOIN_DEX_ADDRESS, TIP1028EscrowError}; pub use tempo_contracts::precompiles::{ IRolesAuth, ITIP20, RolesAuthError, RolesAuthEvent, TIP20Error, TIP20Event, USD_CURRENCY, }; From a8aea3205aebf2ade293c812b4d6fc26f789dcff Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 14:46:20 -0400 Subject: [PATCH 03/32] feat: tip403 logic --- .../src/precompiles/tip403_registry.rs | 14 ++ crates/precompiles/src/tip1028_escrow/mod.rs | 1 - crates/precompiles/src/tip20/mod.rs | 2 +- .../src/tip403_registry/dispatch.rs | 18 ++- crates/precompiles/src/tip403_registry/mod.rs | 126 +++++++++++++++++- 5 files changed, 157 insertions(+), 4 deletions(-) diff --git a/crates/contracts/src/precompiles/tip403_registry.rs b/crates/contracts/src/precompiles/tip403_registry.rs index 294fbdd6d0..31d1b48c24 100644 --- a/crates/contracts/src/precompiles/tip403_registry.rs +++ b/crates/contracts/src/precompiles/tip403_registry.rs @@ -28,6 +28,8 @@ crate::sol! { function isAuthorizedRecipient(uint64 policyId, address user) external view returns (bool); function isAuthorizedMintRecipient(uint64 policyId, address user) external view returns (bool); function compoundPolicyData(uint64 policyId) external view returns (uint64 senderPolicyId, uint64 recipientPolicyId, uint64 mintRecipientPolicyId); + function receivePolicy(address account) external view returns (bool hasReceivePolicy, uint64 senderPolicyId, PolicyType senderPolicyType, uint64 tokenFilterId, PolicyType tokenFilterType, address recoveryAddress); + function validateReceivePolicy(address token, address sender, address receiver) external view returns (bool authorized, BlockedReason blockedReason); // State-Changing Functions function createPolicy(address admin, PolicyType policyType) external returns (uint64); @@ -36,6 +38,7 @@ crate::sol! { function modifyPolicyWhitelist(uint64 policyId, address account, bool allowed) external; function modifyPolicyBlacklist(uint64 policyId, address account, bool restricted) external; function createCompoundPolicy(uint64 senderPolicyId, uint64 recipientPolicyId, uint64 mintRecipientPolicyId) external returns (uint64); + function setReceivePolicy(uint64 senderPolicyId, uint64 tokenFilterId, address recoveryAddress) external; // Events event PolicyAdminUpdated(uint64 indexed policyId, address indexed updater, address indexed admin); @@ -43,6 +46,7 @@ crate::sol! { event WhitelistUpdated(uint64 indexed policyId, address indexed updater, address indexed account, bool allowed); event BlacklistUpdated(uint64 indexed policyId, address indexed updater, address indexed account, bool restricted); event CompoundPolicyCreated(uint64 indexed policyId, address indexed creator, uint64 senderPolicyId, uint64 recipientPolicyId, uint64 mintRecipientPolicyId); + event ReceivePolicyUpdated(address indexed account, uint64 senderPolicyId, uint64 tokenFilterId, address recoveryAddress); // Errors error Unauthorized(); @@ -51,6 +55,8 @@ crate::sol! { error InvalidPolicyType(); error IncompatiblePolicyType(); error VirtualAddressNotAllowed(); + error InvalidReceivePolicyType(); + error InvalidReceivePolicyAddress(); } } @@ -100,4 +106,12 @@ impl TIP403RegistryError { pub const fn virtual_address_not_allowed() -> Self { Self::VirtualAddressNotAllowed(ITIP403Registry::VirtualAddressNotAllowed {}) } + + pub const fn invalid_receive_policy_type() -> Self { + Self::InvalidReceivePolicyType(ITIP403Registry::InvalidReceivePolicyType {}) + } + + pub const fn invalid_receive_policy_address() -> Self { + Self::InvalidReceivePolicyAddress(ITIP403Registry::InvalidReceivePolicyAddress {}) + } } diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs index 4c52367a3a..e8227dd50a 100644 --- a/crates/precompiles/src/tip1028_escrow/mod.rs +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -13,7 +13,6 @@ use crate::{ address_registry::AddressRegistry, error::{Result, TempoPrecompileError}, storage::{Handler, Mapping}, - tip20::TIP20Token, }; use alloy::{ primitives::{Address, B256, U256}, diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index c533286831..960721a27b 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -12,7 +12,7 @@ pub mod dispatch; pub mod rewards; pub mod roles; -use tempo_contracts::precompiles::{ESCROW_ADDRESS, STABLECOIN_DEX_ADDRESS, TIP1028EscrowError}; +use tempo_contracts::precompiles::STABLECOIN_DEX_ADDRESS; pub use tempo_contracts::precompiles::{ IRolesAuth, ITIP20, RolesAuthError, RolesAuthEvent, TIP20Error, TIP20Event, USD_CURRENCY, }; diff --git a/crates/precompiles/src/tip403_registry/dispatch.rs b/crates/precompiles/src/tip403_registry/dispatch.rs index a1b7c84b1d..a0bf616e0e 100644 --- a/crates/precompiles/src/tip403_registry/dispatch.rs +++ b/crates/precompiles/src/tip403_registry/dispatch.rs @@ -21,6 +21,12 @@ const T2_ADDED: &[[u8; 4]] = &[ ITIP403Registry::createCompoundPolicyCall::SELECTOR, ]; +const T5_ADDED: &[[u8; 4]] = &[ + ITIP403Registry::receivePolicyCall::SELECTOR, + ITIP403Registry::validateReceivePolicyCall::SELECTOR, + ITIP403Registry::setReceivePolicyCall::SELECTOR, +]; + impl Precompile for TIP403Registry { fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { if let Some(err) = charge_input_cost(&mut self.storage, calldata) { @@ -29,7 +35,10 @@ impl Precompile for TIP403Registry { dispatch_call( calldata, - &[SelectorSchedule::new(TempoHardfork::T2).with_added(T2_ADDED)], + &[ + SelectorSchedule::new(TempoHardfork::T2).with_added(T2_ADDED), + SelectorSchedule::new(TempoHardfork::T5).with_added(T5_ADDED), + ], ITIP403RegistryCalls::abi_decode, |call| match call { ITIP403RegistryCalls::policyIdCounter(call) => { @@ -53,6 +62,13 @@ impl Precompile for TIP403Registry { ITIP403RegistryCalls::compoundPolicyData(call) => { view(call, |c| self.compound_policy_data(c)) } + ITIP403RegistryCalls::receivePolicy(call) => view(call, |c| self.receive_policy(c)), + ITIP403RegistryCalls::validateReceivePolicy(call) => { + view(call, |c| self.validate_receive_policy_call(c)) + } + ITIP403RegistryCalls::setReceivePolicy(call) => { + mutate_void(call, msg_sender, |s, c| self.set_receive_policy(s, c)) + } ITIP403RegistryCalls::createPolicy(call) => { mutate(call, msg_sender, |s, c| self.create_policy(s, c)) } diff --git a/crates/precompiles/src/tip403_registry/mod.rs b/crates/precompiles/src/tip403_registry/mod.rs index b57168bf54..b459b97884 100644 --- a/crates/precompiles/src/tip403_registry/mod.rs +++ b/crates/precompiles/src/tip403_registry/mod.rs @@ -15,7 +15,7 @@ pub use tempo_contracts::precompiles::{ use tempo_precompiles_macros::{Storable, contract}; use crate::{ - TIP403_REGISTRY_ADDRESS, + ESCROW_ADDRESS, TIP403_REGISTRY_ADDRESS, error::{Result, TempoPrecompileError}, storage::{Handler, Mapping}, }; @@ -48,6 +48,17 @@ pub struct TIP403Registry { /// value is `true` when the address is allowed; for blacklists it is `true` when the /// address is restricted. policy_set: Mapping>, + /// Account receive policy configuration. + address_receive_config: Mapping, + /// Account current recovery address to record in blocked receipts. + address_recovery_address: Mapping, +} + +#[derive(Debug, Clone, Default, Storable)] +struct ReceivePolicyConfig { + has_receive_policy: bool, + sender_policy_id: u64, + token_filter_id: u64, } /// Policy record containing base data and optional data for compound policies ([TIP-1015]) @@ -230,6 +241,59 @@ impl TIP403Registry { }) } + pub fn receive_policy( + &self, + call: ITIP403Registry::receivePolicyCall, + ) -> Result { + let config = self.address_receive_config[call.account].read()?; + Ok(ITIP403Registry::receivePolicyReturn { + hasReceivePolicy: config.has_receive_policy, + senderPolicyId: config.sender_policy_id, + senderPolicyType: self.receive_policy_type(config.sender_policy_id)?, + tokenFilterId: config.token_filter_id, + tokenFilterType: self.receive_policy_type(config.token_filter_id)?, + recoveryAddress: self.address_recovery_address[call.account].read()?, + }) + } + + pub fn validate_receive_policy( + &self, + token: Address, + sender: Address, + receiver: Address, + ) -> Result<(bool, ITIP403Registry::BlockedReason)> { + let config = self.address_receive_config[receiver].read()?; + if !config.has_receive_policy { + return Ok((true, ITIP403Registry::BlockedReason::NONE)); + } + + if !self.is_authorized_simple(config.token_filter_id, token)? { + return Ok((false, ITIP403Registry::BlockedReason::TOKEN_FILTER)); + } + + if !self.is_authorized_simple(config.sender_policy_id, sender)? { + return Ok((false, ITIP403Registry::BlockedReason::RECEIVE_POLICY)); + } + + Ok((true, ITIP403Registry::BlockedReason::NONE)) + } + + pub fn validate_receive_policy_call( + &self, + call: ITIP403Registry::validateReceivePolicyCall, + ) -> Result { + let (authorized, blocked_reason) = + self.validate_receive_policy(call.token, call.sender, call.receiver)?; + Ok(ITIP403Registry::validateReceivePolicyReturn { + authorized, + blockedReason: blocked_reason, + }) + } + + pub fn receive_policy_recovery_address(&self, account: Address) -> Result
{ + self.address_recovery_address[account].read() + } + /// Creates a new simple (whitelist or blacklist) policy and returns its ID. /// /// # Errors @@ -276,6 +340,38 @@ impl TIP403Registry { Ok(new_policy_id) } + pub fn set_receive_policy( + &mut self, + msg_sender: Address, + call: ITIP403Registry::setReceivePolicyCall, + ) -> Result<()> { + if msg_sender == ESCROW_ADDRESS { + return Err(TIP403RegistryError::invalid_receive_policy_address().into()); + } + if msg_sender.is_virtual() { + return Err(TIP403RegistryError::virtual_address_not_allowed().into()); + } + + self.validate_receive_policy_id(call.senderPolicyId)?; + self.validate_receive_policy_id(call.tokenFilterId)?; + + self.address_receive_config[msg_sender].write(ReceivePolicyConfig { + has_receive_policy: true, + sender_policy_id: call.senderPolicyId, + token_filter_id: call.tokenFilterId, + })?; + self.address_recovery_address[msg_sender].write(call.recoveryAddress)?; + + self.emit_event(TIP403RegistryEvent::ReceivePolicyUpdated( + ITIP403Registry::ReceivePolicyUpdated { + account: msg_sender, + senderPolicyId: call.senderPolicyId, + tokenFilterId: call.tokenFilterId, + recoveryAddress: call.recoveryAddress, + }, + )) + } + /// Creates a simple policy and pre-populates it with an initial set of accounts. /// /// # Errors @@ -637,6 +733,34 @@ impl TIP403Registry { Ok(()) } + fn validate_receive_policy_id(&self, policy_id: u64) -> Result<()> { + if self.builtin_authorization(policy_id).is_some() { + return Ok(()); + } + if policy_id >= self.policy_id_counter()? { + return Err(TIP403RegistryError::policy_not_found().into()); + } + let data = self.get_policy_data(policy_id)?; + if !data.is_simple() { + return Err(TIP403RegistryError::invalid_receive_policy_type().into()); + } + Ok(()) + } + + fn receive_policy_type(&self, policy_id: u64) -> Result { + if self.builtin_authorization(policy_id).is_some() { + return (policy_id as u8) + .try_into() + .map_err(|_| TIP403RegistryError::invalid_receive_policy_type().into()); + } + + let data = self.get_policy_data(policy_id)?; + if !data.is_simple() { + return Err(TIP403RegistryError::invalid_receive_policy_type().into()); + } + data.policy_type() + } + // Internal helper functions /// Returns policy data for the given policy ID. From e9cbb0caacf8b30145951b29ea3bcffa4d787e1a Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 14:50:04 -0400 Subject: [PATCH 04/32] wip: simplify storage variables --- .../src/tip403_registry/dispatch.rs | 2 +- crates/precompiles/src/tip403_registry/mod.rs | 35 ++++++++----------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/crates/precompiles/src/tip403_registry/dispatch.rs b/crates/precompiles/src/tip403_registry/dispatch.rs index a0bf616e0e..020bae144a 100644 --- a/crates/precompiles/src/tip403_registry/dispatch.rs +++ b/crates/precompiles/src/tip403_registry/dispatch.rs @@ -64,7 +64,7 @@ impl Precompile for TIP403Registry { } ITIP403RegistryCalls::receivePolicy(call) => view(call, |c| self.receive_policy(c)), ITIP403RegistryCalls::validateReceivePolicy(call) => { - view(call, |c| self.validate_receive_policy_call(c)) + view(call, |c| self.validate_receive_policy(c)) } ITIP403RegistryCalls::setReceivePolicy(call) => { mutate_void(call, msg_sender, |s, c| self.set_receive_policy(s, c)) diff --git a/crates/precompiles/src/tip403_registry/mod.rs b/crates/precompiles/src/tip403_registry/mod.rs index b459b97884..aa2e318f82 100644 --- a/crates/precompiles/src/tip403_registry/mod.rs +++ b/crates/precompiles/src/tip403_registry/mod.rs @@ -50,8 +50,6 @@ pub struct TIP403Registry { policy_set: Mapping>, /// Account receive policy configuration. address_receive_config: Mapping, - /// Account current recovery address to record in blocked receipts. - address_recovery_address: Mapping, } #[derive(Debug, Clone, Default, Storable)] @@ -59,6 +57,7 @@ struct ReceivePolicyConfig { has_receive_policy: bool, sender_policy_id: u64, token_filter_id: u64, + recovery_address: Address, } /// Policy record containing base data and optional data for compound policies ([TIP-1015]) @@ -252,11 +251,23 @@ impl TIP403Registry { senderPolicyType: self.receive_policy_type(config.sender_policy_id)?, tokenFilterId: config.token_filter_id, tokenFilterType: self.receive_policy_type(config.token_filter_id)?, - recoveryAddress: self.address_recovery_address[call.account].read()?, + recoveryAddress: config.recovery_address, }) } pub fn validate_receive_policy( + &self, + call: ITIP403Registry::validateReceivePolicyCall, + ) -> Result { + let (authorized, blocked_reason) = + self.check_receive_policy(call.token, call.sender, call.receiver)?; + Ok(ITIP403Registry::validateReceivePolicyReturn { + authorized, + blockedReason: blocked_reason, + }) + } + + pub fn check_receive_policy( &self, token: Address, sender: Address, @@ -278,22 +289,6 @@ impl TIP403Registry { Ok((true, ITIP403Registry::BlockedReason::NONE)) } - pub fn validate_receive_policy_call( - &self, - call: ITIP403Registry::validateReceivePolicyCall, - ) -> Result { - let (authorized, blocked_reason) = - self.validate_receive_policy(call.token, call.sender, call.receiver)?; - Ok(ITIP403Registry::validateReceivePolicyReturn { - authorized, - blockedReason: blocked_reason, - }) - } - - pub fn receive_policy_recovery_address(&self, account: Address) -> Result
{ - self.address_recovery_address[account].read() - } - /// Creates a new simple (whitelist or blacklist) policy and returns its ID. /// /// # Errors @@ -359,8 +354,8 @@ impl TIP403Registry { has_receive_policy: true, sender_policy_id: call.senderPolicyId, token_filter_id: call.tokenFilterId, + recovery_address: call.recoveryAddress, })?; - self.address_recovery_address[msg_sender].write(call.recoveryAddress)?; self.emit_event(TIP403RegistryEvent::ReceivePolicyUpdated( ITIP403Registry::ReceivePolicyUpdated { From be701cd65bfafc7454bc6cd29a424cf4dd4e1e20 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 14:52:07 -0400 Subject: [PATCH 05/32] docs: comment --- crates/precompiles/src/tip403_registry/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/precompiles/src/tip403_registry/mod.rs b/crates/precompiles/src/tip403_registry/mod.rs index aa2e318f82..cc6e59f0c4 100644 --- a/crates/precompiles/src/tip403_registry/mod.rs +++ b/crates/precompiles/src/tip403_registry/mod.rs @@ -100,11 +100,10 @@ pub enum AuthRole { MintRecipient, } -/// Base policy metadata. Packed into a single storage slot. +/// TIP403 policy data #[derive(Debug, Clone, Storable)] pub struct PolicyData { - // NOTE: enums are defined as u8, and leverage the sol! macro's `TryInto` impl - /// Discriminant of the [`PolicyType`] enum, stored as `u8` for slot packing. + // Policy type, either whitelist, blacklist or compound. pub policy_type: u8, /// Address authorized to modify this policy. pub admin: Address, From ebd6013add805fc83cc21263c6cddb8c6135bdf6 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 15:06:09 -0400 Subject: [PATCH 06/32] chore: cleanup validate policy --- .../src/tip403_registry/dispatch.rs | 11 ++++-- crates/precompiles/src/tip403_registry/mod.rs | 34 +++++++++---------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/crates/precompiles/src/tip403_registry/dispatch.rs b/crates/precompiles/src/tip403_registry/dispatch.rs index 020bae144a..694e50ae85 100644 --- a/crates/precompiles/src/tip403_registry/dispatch.rs +++ b/crates/precompiles/src/tip403_registry/dispatch.rs @@ -63,9 +63,14 @@ impl Precompile for TIP403Registry { view(call, |c| self.compound_policy_data(c)) } ITIP403RegistryCalls::receivePolicy(call) => view(call, |c| self.receive_policy(c)), - ITIP403RegistryCalls::validateReceivePolicy(call) => { - view(call, |c| self.validate_receive_policy(c)) - } + ITIP403RegistryCalls::validateReceivePolicy(call) => view(call, |c| { + let (authorized, blocked_reason, _) = + self.validate_receive_policy(c.token, c.sender, c.receiver)?; + Ok(ITIP403Registry::validateReceivePolicyReturn { + authorized, + blockedReason: blocked_reason, + }) + }), ITIP403RegistryCalls::setReceivePolicy(call) => { mutate_void(call, msg_sender, |s, c| self.set_receive_policy(s, c)) } diff --git a/crates/precompiles/src/tip403_registry/mod.rs b/crates/precompiles/src/tip403_registry/mod.rs index cc6e59f0c4..05221ee398 100644 --- a/crates/precompiles/src/tip403_registry/mod.rs +++ b/crates/precompiles/src/tip403_registry/mod.rs @@ -255,37 +255,37 @@ impl TIP403Registry { } pub fn validate_receive_policy( - &self, - call: ITIP403Registry::validateReceivePolicyCall, - ) -> Result { - let (authorized, blocked_reason) = - self.check_receive_policy(call.token, call.sender, call.receiver)?; - Ok(ITIP403Registry::validateReceivePolicyReturn { - authorized, - blockedReason: blocked_reason, - }) - } - - pub fn check_receive_policy( &self, token: Address, sender: Address, receiver: Address, - ) -> Result<(bool, ITIP403Registry::BlockedReason)> { + ) -> Result<(bool, ITIP403Registry::BlockedReason, Address)> { let config = self.address_receive_config[receiver].read()?; if !config.has_receive_policy { - return Ok((true, ITIP403Registry::BlockedReason::NONE)); + return Ok((true, ITIP403Registry::BlockedReason::NONE, Address::ZERO)); } if !self.is_authorized_simple(config.token_filter_id, token)? { - return Ok((false, ITIP403Registry::BlockedReason::TOKEN_FILTER)); + return Ok(( + false, + ITIP403Registry::BlockedReason::TOKEN_FILTER, + config.recovery_address, + )); } if !self.is_authorized_simple(config.sender_policy_id, sender)? { - return Ok((false, ITIP403Registry::BlockedReason::RECEIVE_POLICY)); + return Ok(( + false, + ITIP403Registry::BlockedReason::RECEIVE_POLICY, + config.recovery_address, + )); } - Ok((true, ITIP403Registry::BlockedReason::NONE)) + Ok(( + true, + ITIP403Registry::BlockedReason::NONE, + config.recovery_address, + )) } /// Creates a new simple (whitelist or blacklist) policy and returns its ID. From eab4a98fef73eef29f996de1a36ffcacfe9f2f05 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 15:16:42 -0400 Subject: [PATCH 07/32] feat: tip20 transfer --- crates/contracts/src/precompiles/escrow.rs | 1 + crates/precompiles/src/tip1028_escrow/mod.rs | 19 +++++++ crates/precompiles/src/tip20/mod.rs | 58 +++++++++++++++++++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/contracts/src/precompiles/escrow.rs b/crates/contracts/src/precompiles/escrow.rs index 1b20cb5cd9..622c4c168d 100644 --- a/crates/contracts/src/precompiles/escrow.rs +++ b/crates/contracts/src/precompiles/escrow.rs @@ -24,6 +24,7 @@ crate::sol! { function blockedReceiptBalance(address token, address recoveryContract, uint8 receiptVersion, bytes calldata receipt) external view returns (uint256 amount); function claimBlocked(address token, address recoveryContract, uint8 receiptVersion, bytes calldata receipt, address to) external; + event TransferBlocked(address indexed token, address indexed from, address indexed receiver, uint8 receiptVersion, uint64 blockedNonce, uint64 blockedAt, address recipient, uint256 amount, uint8 blockedReason, address recoveryContract, bytes32 memo); event BlockedReceiptClaimed(address indexed token, address indexed receiver, uint8 receiptVersion, uint64 indexed blockedNonce, uint64 blockedAt, address originator, address recipient, address recoveryContract, address caller, address to, uint256 amount); error UnauthorizedClaimer(); diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs index e8227dd50a..2c05634814 100644 --- a/crates/precompiles/src/tip1028_escrow/mod.rs +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -57,6 +57,7 @@ impl TIP1028Escrow { &mut self, token: Address, originator: Address, + receiver: Address, recipient: Address, recovery_address: Address, amount: U256, @@ -90,6 +91,24 @@ impl TIP1028Escrow { let key = self.receipt_key(BLOCKED_RECEIPT_VERSION, token, recovery_address, &receipt)?; self.blocked_receipt_amount[key].write(amount)?; + if kind == ITIP1028Escrow::InboundKind::TRANSFER { + self.emit_event(TIP1028EscrowEvent::TransferBlocked( + ITIP1028Escrow::TransferBlocked { + token, + from: originator, + receiver, + receiptVersion: BLOCKED_RECEIPT_VERSION, + blockedNonce: blocked_nonce, + blockedAt: blocked_at, + recipient, + amount, + blockedReason: blocked_reason as u8, + recoveryContract: recovery_address, + memo, + }, + ))?; + } + Ok((blocked_nonce, blocked_at)) } diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 960721a27b..272a060457 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -12,16 +12,16 @@ pub mod dispatch; pub mod rewards; pub mod roles; -use tempo_contracts::precompiles::STABLECOIN_DEX_ADDRESS; pub use tempo_contracts::precompiles::{ IRolesAuth, ITIP20, RolesAuthError, RolesAuthEvent, TIP20Error, TIP20Event, USD_CURRENCY, }; +use tempo_contracts::precompiles::{STABLECOIN_DEX_ADDRESS, TIP1028EscrowError}; // Re-export the generated slots module for external access to storage slot constants pub use slots as tip20_slots; use crate::{ - PATH_USD_ADDRESS, TIP_FEE_MANAGER_ADDRESS, + ESCROW_ADDRESS, PATH_USD_ADDRESS, TIP_FEE_MANAGER_ADDRESS, account_keychain::AccountKeychain, address_registry::AddressRegistry, error::{Result, TempoPrecompileError}, @@ -29,6 +29,7 @@ use crate::{ tip20::{rewards::UserRewardInfo, roles::DEFAULT_ADMIN_ROLE}, tip20_factory::TIP20Factory, tip403_registry::{AuthRole, ITIP403Registry, TIP403Registry}, + tip1028_escrow::{InboundKind, TIP1028Escrow}, }; use alloy::{ primitives::{Address, B256, U256, keccak256, uint}, @@ -696,9 +697,34 @@ impl TIP20Token { pub fn transfer(&mut self, msg_sender: Address, call: ITIP20::transferCall) -> Result { trace!(%msg_sender, ?call, "transferring TIP20"); let to = Recipient::resolve(call.to)?; + if self.storage.spec().is_t5() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) + { + return Err(TIP1028EscrowError::escrow_address_reserved().into()); + } + self.validate_transfer(msg_sender, &to)?; self.check_and_update_spending_limit(msg_sender, call.amount)?; + // T5+ validate the receive policy + if self.storage.spec().is_t5() { + let (authorized, blocked_reason, recovery_address) = TIP403Registry::new() + .validate_receive_policy(self.address, msg_sender, to.target)?; + + // If not authorized, escrow funds and return + if !authorized { + self.escrow_funds( + msg_sender, + call.to, + call.amount, + recovery_address, + blocked_reason, + InboundKind::TRANSFER, + B256::ZERO, + )?; + return Ok(true); + } + } + self._transfer(msg_sender, &to, call.amount)?; if let Some(hop) = to.build_virtual_transfer_event(call.amount) { self.emit_event(hop)?; @@ -1009,6 +1035,34 @@ impl TIP20Token { self.emit_event(to.build_transfer_event(from, amount)) } + fn escrow_funds( + &mut self, + originator: Address, + recipient: Address, + amount: U256, + recovery_address: Address, + blocked_reason: ITIP403Registry::BlockedReason, + kind: InboundKind, + memo: B256, + ) -> Result<()> { + let receiver = AddressRegistry::new().resolve_recipient(recipient)?; + let guard = self.storage.checkpoint(); + self._transfer(originator, &Recipient::direct(ESCROW_ADDRESS), amount)?; + TIP1028Escrow::new().store_blocked( + self.address, + originator, + receiver, + recipient, + recovery_address, + amount, + blocked_reason, + kind, + memo, + )?; + guard.commit(); + Ok(()) + } + /// Transfers fee tokens from `from` to the fee manager before transaction execution. /// Respects the token's pause state and deducts from the [`AccountKeychain`] spending limit. /// From 00f006343ae829da079231ec229ec166e80e535e Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 15:28:24 -0400 Subject: [PATCH 08/32] chore: move to t6 --- crates/precompiles/src/lib.rs | 2 +- crates/precompiles/src/tip20/mod.rs | 6 ++---- crates/precompiles/src/tip403_registry/dispatch.rs | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 0433108977..a4b320e183 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -139,7 +139,7 @@ pub fn extend_tempo_precompiles(precompiles: &mut PrecompilesMap, cfg: &CfgEnv Result { trace!(%msg_sender, ?call, "transferring TIP20"); let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t5() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) + if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) { return Err(TIP1028EscrowError::escrow_address_reserved().into()); } @@ -705,12 +705,10 @@ impl TIP20Token { self.validate_transfer(msg_sender, &to)?; self.check_and_update_spending_limit(msg_sender, call.amount)?; - // T5+ validate the receive policy - if self.storage.spec().is_t5() { + if self.storage.spec().is_t6() { let (authorized, blocked_reason, recovery_address) = TIP403Registry::new() .validate_receive_policy(self.address, msg_sender, to.target)?; - // If not authorized, escrow funds and return if !authorized { self.escrow_funds( msg_sender, diff --git a/crates/precompiles/src/tip403_registry/dispatch.rs b/crates/precompiles/src/tip403_registry/dispatch.rs index 694e50ae85..0db0fa3008 100644 --- a/crates/precompiles/src/tip403_registry/dispatch.rs +++ b/crates/precompiles/src/tip403_registry/dispatch.rs @@ -21,7 +21,7 @@ const T2_ADDED: &[[u8; 4]] = &[ ITIP403Registry::createCompoundPolicyCall::SELECTOR, ]; -const T5_ADDED: &[[u8; 4]] = &[ +const T6_ADDED: &[[u8; 4]] = &[ ITIP403Registry::receivePolicyCall::SELECTOR, ITIP403Registry::validateReceivePolicyCall::SELECTOR, ITIP403Registry::setReceivePolicyCall::SELECTOR, @@ -37,7 +37,7 @@ impl Precompile for TIP403Registry { calldata, &[ SelectorSchedule::new(TempoHardfork::T2).with_added(T2_ADDED), - SelectorSchedule::new(TempoHardfork::T5).with_added(T5_ADDED), + SelectorSchedule::new(TempoHardfork::T6).with_added(T6_ADDED), ], ITIP403RegistryCalls::abi_decode, |call| match call { From 4aa170b566d93b712dc053fa7121e9a02ddbc868 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 15:49:55 -0400 Subject: [PATCH 09/32] feat: tip20 transfer logic --- crates/precompiles/src/tip20/mod.rs | 58 +++++++++++++++++++---------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index edd16c0916..14ab189ca8 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -705,28 +705,11 @@ impl TIP20Token { self.validate_transfer(msg_sender, &to)?; self.check_and_update_spending_limit(msg_sender, call.amount)?; - if self.storage.spec().is_t6() { - let (authorized, blocked_reason, recovery_address) = TIP403Registry::new() - .validate_receive_policy(self.address, msg_sender, to.target)?; - - if !authorized { - self.escrow_funds( - msg_sender, - call.to, - call.amount, - recovery_address, - blocked_reason, - InboundKind::TRANSFER, - B256::ZERO, - )?; - return Ok(true); - } - } - - self._transfer(msg_sender, &to, call.amount)?; - if let Some(hop) = to.build_virtual_transfer_event(call.amount) { + let transferred = self.transfer_or_escrow(msg_sender, &to, call.to, call.amount)?; + if transferred && let Some(hop) = to.build_virtual_transfer_event(call.amount) { self.emit_event(hop)?; } + Ok(true) } @@ -1000,6 +983,41 @@ impl TIP20Token { AccountKeychain::new().authorize_transfer(from, self.address, amount) } + fn transfer_or_escrow( + &mut self, + from: Address, + to: &Recipient, + recipient: Address, + amount: U256, + ) -> Result { + if !self.storage.spec().is_t6() { + self._transfer(from, to, amount)?; + return Ok(true); + } + + if recipient == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS { + return Err(TIP1028EscrowError::escrow_address_reserved().into()); + } + + let (authorized, blocked_reason, recovery_address) = + TIP403Registry::new().validate_receive_policy(self.address, from, to.target)?; + if authorized { + self._transfer(from, to, amount)?; + return Ok(true); + } + + self.escrow_funds( + from, + recipient, + amount, + recovery_address, + blocked_reason, + InboundKind::TRANSFER, + B256::ZERO, + )?; + Ok(false) + } + /// Core transfer: debits `from`, credits `to.target`, emits `Transfer(from, event_addr, amount)`. /// /// For virtual recipients the event address is the virtual alias; the balance update always From 4b4787c4fd9b59f25afaa7e76ba8069b11af673b Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 16:05:02 -0400 Subject: [PATCH 10/32] feat: update transfer or escrow --- crates/precompiles/src/tip20/mod.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 14ab189ca8..77b2f515e2 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -705,7 +705,14 @@ impl TIP20Token { self.validate_transfer(msg_sender, &to)?; self.check_and_update_spending_limit(msg_sender, call.amount)?; - let transferred = self.transfer_or_escrow(msg_sender, &to, call.to, call.amount)?; + let transferred = self.transfer_or_escrow( + msg_sender, + &to, + call.to, + call.amount, + InboundKind::TRANSFER, + B256::ZERO, + )?; if transferred && let Some(hop) = to.build_virtual_transfer_event(call.amount) { self.emit_event(hop)?; } @@ -989,6 +996,8 @@ impl TIP20Token { to: &Recipient, recipient: Address, amount: U256, + kind: InboundKind, + memo: B256, ) -> Result { if !self.storage.spec().is_t6() { self._transfer(from, to, amount)?; @@ -1012,8 +1021,8 @@ impl TIP20Token { amount, recovery_address, blocked_reason, - InboundKind::TRANSFER, - B256::ZERO, + kind, + memo, )?; Ok(false) } From 79cf39f7205d2de2744785a0b48794c0be7b9591 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 16:47:23 -0400 Subject: [PATCH 11/32] chore: simplify transfer_or_escrow --- crates/precompiles/src/tip20/mod.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 77b2f515e2..9514bbc6fe 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -708,7 +708,6 @@ impl TIP20Token { let transferred = self.transfer_or_escrow( msg_sender, &to, - call.to, call.amount, InboundKind::TRANSFER, B256::ZERO, @@ -994,7 +993,6 @@ impl TIP20Token { &mut self, from: Address, to: &Recipient, - recipient: Address, amount: U256, kind: InboundKind, memo: B256, @@ -1004,9 +1002,14 @@ impl TIP20Token { return Ok(true); } - if recipient == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS { + if to.target == ESCROW_ADDRESS { return Err(TIP1028EscrowError::escrow_address_reserved().into()); } + let recipient = if let Some(virtual_addr) = to.virtual_addr { + virtual_addr + } else { + to.target + }; let (authorized, blocked_reason, recovery_address) = TIP403Registry::new().validate_receive_policy(self.address, from, to.target)?; From aa52cd0da852c8b13afd27f68f6f618e5db2621f Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 16:52:58 -0400 Subject: [PATCH 12/32] feat: transfer_from --- crates/precompiles/src/tip20/mod.rs | 33 ++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 9514bbc6fe..da844c5c70 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -734,8 +734,22 @@ impl TIP20Token { call: ITIP20::transferFromCall, ) -> Result { let to = Recipient::resolve(call.to)?; - self._transfer_from(msg_sender, call.from, &to, call.amount)?; - if let Some(hop) = to.build_virtual_transfer_event(call.amount) { + if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) + { + return Err(TIP1028EscrowError::escrow_address_reserved().into()); + } + + self.validate_transfer(call.from, &to)?; + self.consume_allowance(call.from, msg_sender, call.amount)?; + + let transferred = self.transfer_or_escrow( + call.from, + &to, + call.amount, + InboundKind::TRANSFER, + B256::ZERO, + )?; + if transferred && let Some(hop) = to.build_virtual_transfer_event(call.amount) { self.emit_event(hop)?; } Ok(true) @@ -798,8 +812,14 @@ impl TIP20Token { amount: U256, ) -> Result { self.validate_transfer(from, to)?; + self.consume_allowance(from, msg_sender, amount)?; + self._transfer(from, to, amount)?; + + Ok(true) + } - let allowed = self.get_allowance(from, msg_sender)?; + fn consume_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { + let allowed = self.get_allowance(owner, spender)?; if amount > allowed { return Err(TIP20Error::insufficient_allowance().into()); } @@ -808,12 +828,9 @@ impl TIP20Token { let new_allowance = allowed .checked_sub(amount) .ok_or(TIP20Error::insufficient_allowance())?; - self.set_allowance(from, msg_sender, new_allowance)?; + self.set_allowance(owner, spender, new_allowance)?; } - - self._transfer(from, to, amount)?; - - Ok(true) + Ok(()) } /// Like [`Self::transfer`], but attaches a 32-byte memo. From 3576f7536571caf431a1a1a73b9723dca21c78ee Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 17:03:58 -0400 Subject: [PATCH 13/32] feat: memo functions --- crates/precompiles/src/tip20/mod.rs | 79 +++++++++++++++++------------ 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index da844c5c70..fc7dd8dfc5 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -762,16 +762,31 @@ impl TIP20Token { call: ITIP20::transferFromWithMemoCall, ) -> Result { let to = Recipient::resolve(call.to)?; - self._transfer_from(msg_sender, call.from, &to, call.amount)?; + if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) + { + return Err(TIP1028EscrowError::escrow_address_reserved().into()); + } - self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { - from: call.from, - to: call.to, - amount: call.amount, - memo: call.memo, - }))?; - if let Some(hop) = to.build_virtual_transfer_event(call.amount) { - self.emit_event(hop)?; + self.validate_transfer(call.from, &to)?; + self.consume_allowance(call.from, msg_sender, call.amount)?; + + let transferred = self.transfer_or_escrow( + call.from, + &to, + call.amount, + InboundKind::TRANSFER, + call.memo, + )?; + if transferred { + self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { + from: call.from, + to: call.to, + amount: call.amount, + memo: call.memo, + }))?; + if let Some(hop) = to.build_virtual_transfer_event(call.amount) { + self.emit_event(hop)?; + } } Ok(true) } @@ -804,20 +819,6 @@ impl TIP20Token { Ok(true) } - fn _transfer_from( - &mut self, - msg_sender: Address, - from: Address, - to: &Recipient, - amount: U256, - ) -> Result { - self.validate_transfer(from, to)?; - self.consume_allowance(from, msg_sender, amount)?; - self._transfer(from, to, amount)?; - - Ok(true) - } - fn consume_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { let allowed = self.get_allowance(owner, spender)?; if amount > allowed { @@ -840,19 +841,31 @@ impl TIP20Token { call: ITIP20::transferWithMemoCall, ) -> Result<()> { let to = Recipient::resolve(call.to)?; + if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) + { + return Err(TIP1028EscrowError::escrow_address_reserved().into()); + } + self.validate_transfer(msg_sender, &to)?; self.check_and_update_spending_limit(msg_sender, call.amount)?; - self._transfer(msg_sender, &to, call.amount)?; - - self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { - from: msg_sender, - to: call.to, - amount: call.amount, - memo: call.memo, - }))?; - if let Some(hop) = to.build_virtual_transfer_event(call.amount) { - self.emit_event(hop)?; + let transferred = self.transfer_or_escrow( + msg_sender, + &to, + call.amount, + InboundKind::TRANSFER, + call.memo, + )?; + if transferred { + self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { + from: msg_sender, + to: call.to, + amount: call.amount, + memo: call.memo, + }))?; + if let Some(hop) = to.build_virtual_transfer_event(call.amount) { + self.emit_event(hop)?; + } } Ok(()) } From f6d0cc6f7ea69f852ce8e773acc6c22743f94994 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 17:10:17 -0400 Subject: [PATCH 14/32] feat: mint paths --- crates/precompiles/src/tip20/mod.rs | 127 +++++++++++++++++++++------- 1 file changed, 96 insertions(+), 31 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index fc7dd8dfc5..db7ffe1ab6 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -395,14 +395,20 @@ impl TIP20Token { /// - `SupplyCapExceeded` — minting would push total supply above the cap pub fn mint(&mut self, msg_sender: Address, call: ITIP20::mintCall) -> Result<()> { let to = Recipient::resolve(call.to)?; - self._mint(msg_sender, &to, call.amount)?; + if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) + { + return Err(TIP1028EscrowError::escrow_address_reserved().into()); + } - self.emit_event(TIP20Event::Mint(ITIP20::Mint { - to: call.to, - amount: call.amount, - }))?; - if let Some(hop) = to.build_virtual_transfer_event(call.amount) { - self.emit_event(hop)?; + let minted = self.mint_or_escrow(msg_sender, &to, call.amount, B256::ZERO)?; + if minted { + self.emit_event(TIP20Event::Mint(ITIP20::Mint { + to: call.to, + amount: call.amount, + }))?; + if let Some(hop) = to.build_virtual_transfer_event(call.amount) { + self.emit_event(hop)?; + } } Ok(()) @@ -415,32 +421,34 @@ impl TIP20Token { call: ITIP20::mintWithMemoCall, ) -> Result<()> { let to = Recipient::resolve(call.to)?; - self._mint(msg_sender, &to, call.amount)?; + if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) + { + return Err(TIP1028EscrowError::escrow_address_reserved().into()); + } - self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { - from: Address::ZERO, - to: call.to, - amount: call.amount, - memo: call.memo, - }))?; - self.emit_event(TIP20Event::Mint(ITIP20::Mint { - to: call.to, - amount: call.amount, - }))?; - if let Some(hop) = to.build_virtual_transfer_event(call.amount) { - self.emit_event(hop)?; + let minted = self.mint_or_escrow(msg_sender, &to, call.amount, call.memo)?; + if minted { + self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { + from: Address::ZERO, + to: call.to, + amount: call.amount, + memo: call.memo, + }))?; + self.emit_event(TIP20Event::Mint(ITIP20::Mint { + to: call.to, + amount: call.amount, + }))?; + if let Some(hop) = to.build_virtual_transfer_event(call.amount) { + self.emit_event(hop)?; + } } Ok(()) } - /// Internal helper to mint new tokens and update balances. - fn _mint(&mut self, msg_sender: Address, to: &Recipient, amount: U256) -> Result<()> { - self.check_role(msg_sender, *ISSUER_ROLE)?; + /// Raw mint primitive: bumps total supply, credits `to.target`, emits the transfer event. + /// Callers must enforce the `ISSUER_ROLE` and TIP-403 mint-recipient checks upstream. + fn _mint(&mut self, to: &Recipient, amount: U256) -> Result<()> { let total_supply = self.total_supply()?; - - // Check if the resolved target address is authorized to receive minted tokens - self.validate_mint(to)?; - let new_supply = total_supply .checked_add(amount) .ok_or(TempoPrecompileError::under_overflow())?; @@ -454,7 +462,7 @@ impl TIP20Token { self.set_total_supply(new_supply)?; let to_balance = self.get_balance(to.target)?; - let new_to_balance: alloy::primitives::Uint<256, 4> = to_balance + let new_to_balance = to_balance .checked_add(amount) .ok_or(TempoPrecompileError::under_overflow())?; self.set_balance(to.target, new_to_balance)?; @@ -807,12 +815,20 @@ impl TIP20Token { to: Address, amount: U256, ) -> Result { - let to = Recipient::resolve(to)?; + let to_input = to; + let to = Recipient::resolve(to_input)?; + if self.storage.spec().is_t6() + && (to_input == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) + { + return Err(TIP1028EscrowError::escrow_address_reserved().into()); + } + self.validate_transfer(from, &to)?; self.check_and_update_spending_limit(from, amount)?; - self._transfer(from, &to, amount)?; - if let Some(hop) = to.build_virtual_transfer_event(amount) { + let transferred = + self.transfer_or_escrow(from, &to, amount, InboundKind::TRANSFER, B256::ZERO)?; + if transferred && let Some(hop) = to.build_virtual_transfer_event(amount) { self.emit_event(hop)?; } @@ -1060,6 +1076,55 @@ impl TIP20Token { Ok(false) } + fn mint_or_escrow( + &mut self, + msg_sender: Address, + to: &Recipient, + amount: U256, + memo: B256, + ) -> Result { + self.check_role(msg_sender, *ISSUER_ROLE)?; + self.validate_mint(to)?; + + if !self.storage.spec().is_t6() { + self._mint(to, amount)?; + return Ok(true); + } + + if to.target == ESCROW_ADDRESS { + return Err(TIP1028EscrowError::escrow_address_reserved().into()); + } + let recipient = if let Some(virtual_addr) = to.virtual_addr { + virtual_addr + } else { + to.target + }; + + let (authorized, blocked_reason, recovery_address) = TIP403Registry::new() + .validate_receive_policy(self.address, Address::ZERO, to.target)?; + if authorized { + self._mint(to, amount)?; + return Ok(true); + } + + let receiver = AddressRegistry::new().resolve_recipient(recipient)?; + let guard = self.storage.checkpoint(); + self._mint(&Recipient::direct(ESCROW_ADDRESS), amount)?; + TIP1028Escrow::new().store_blocked( + self.address, + Address::ZERO, + receiver, + recipient, + recovery_address, + amount, + blocked_reason, + InboundKind::MINT, + memo, + )?; + guard.commit(); + Ok(false) + } + /// Core transfer: debits `from`, credits `to.target`, emits `Transfer(from, event_addr, amount)`. /// /// For virtual recipients the event address is the virtual alias; the balance update always From 794ac8229978add3ea0ac790063bb377ba00b39a Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 17:13:16 -0400 Subject: [PATCH 15/32] fix: transfer_from --- crates/precompiles/src/tip20/mod.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index db7ffe1ab6..5576d145e5 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -835,6 +835,20 @@ impl TIP20Token { Ok(true) } + fn _transfer_from( + &mut self, + msg_sender: Address, + from: Address, + to: &Recipient, + amount: U256, + ) -> Result { + self.validate_transfer(from, to)?; + self.consume_allowance(from, msg_sender, amount)?; + self._transfer(from, to, amount)?; + + Ok(true) + } + fn consume_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { let allowed = self.get_allowance(owner, spender)?; if amount > allowed { From b76e92633fe708ef54c6a6d7b33b4b2a088badc9 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 17:15:14 -0400 Subject: [PATCH 16/32] fix: update to call transfer_or_escrow --- crates/precompiles/src/tip20/mod.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 5576d145e5..81e725b313 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -747,10 +747,8 @@ impl TIP20Token { return Err(TIP1028EscrowError::escrow_address_reserved().into()); } - self.validate_transfer(call.from, &to)?; - self.consume_allowance(call.from, msg_sender, call.amount)?; - - let transferred = self.transfer_or_escrow( + let transferred = self._transfer_from( + msg_sender, call.from, &to, call.amount, @@ -775,10 +773,8 @@ impl TIP20Token { return Err(TIP1028EscrowError::escrow_address_reserved().into()); } - self.validate_transfer(call.from, &to)?; - self.consume_allowance(call.from, msg_sender, call.amount)?; - - let transferred = self.transfer_or_escrow( + let transferred = self._transfer_from( + msg_sender, call.from, &to, call.amount, @@ -841,12 +837,12 @@ impl TIP20Token { from: Address, to: &Recipient, amount: U256, + kind: InboundKind, + memo: B256, ) -> Result { self.validate_transfer(from, to)?; self.consume_allowance(from, msg_sender, amount)?; - self._transfer(from, to, amount)?; - - Ok(true) + self.transfer_or_escrow(from, to, amount, kind, memo) } fn consume_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { From 84ff52452cf67c081f1e1ae6198298c7f0468b55 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 17:26:29 -0400 Subject: [PATCH 17/32] chore: simplify escrow address check, add docs --- crates/precompiles/src/tip1028_escrow/mod.rs | 37 ++++++++++++++++ crates/precompiles/src/tip20/mod.rs | 44 ++++++++++++------- crates/precompiles/src/tip403_registry/mod.rs | 20 +++++++++ 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs index 2c05634814..0874bb5ad7 100644 --- a/crates/precompiles/src/tip1028_escrow/mod.rs +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -21,8 +21,12 @@ use alloy::{ use tempo_precompiles_macros::contract; use tempo_primitives::TempoAddressExt; +/// On-chain version tag for the v1 [`ITIP1028Escrow::ClaimReceiptV1`] layout. pub const BLOCKED_RECEIPT_VERSION: u8 = 1; +/// TIP-1028 escrow precompile. Holds funds debited from blocked inbound transfers and +/// mints, keyed by a content-addressed hash of the receipt fields, and lets the recipient +/// (or their recovery contract) claim them later. #[contract(addr = ESCROW_ADDRESS)] pub struct TIP1028Escrow { blocked_receipt_nonce: u64, @@ -30,10 +34,17 @@ pub struct TIP1028Escrow { } impl TIP1028Escrow { + /// Initializes the escrow's storage layout. Called once at genesis/activation. pub fn initialize(&mut self) -> Result<()> { self.__initialize() } + /// Returns the unclaimed balance for a receipt, or zero if the receipt is unknown + /// or already claimed. + /// + /// # Errors + /// - `InvalidToken` — `call.token` is not a TIP-20 address + /// - `InvalidReceiptClaim` — receipt version is unsupported or fails to decode pub fn blocked_receipt_balance( &self, call: ITIP1028Escrow::blockedReceiptBalanceCall, @@ -52,6 +63,15 @@ impl TIP1028Escrow { .read() } + /// Records a blocked inbound transfer or mint. Allocates a fresh nonce, writes the + /// claimable amount under the receipt's content hash, and emits `TransferBlocked` + /// for transfers (mints stay silent and surface via the parallel `Mint` event on + /// claim). Caller is responsible for moving the funds into [`ESCROW_ADDRESS`] in + /// the same checkpoint. + /// + /// # Errors + /// - `InvalidToken` — `token` is not a TIP-20 address + /// - `InvalidReceiptClaim` — `blocked_reason` is `NONE`/`__Invalid` or `kind` is `__Invalid` #[allow(clippy::too_many_arguments)] pub(crate) fn store_blocked( &mut self, @@ -112,6 +132,18 @@ impl TIP1028Escrow { Ok((blocked_nonce, blocked_at)) } + /// Releases an escrowed receipt's funds to `call.to`. When `recoveryContract` is + /// zero the resolved master of `receipt.recipient` must be the caller; otherwise + /// only the recovery contract can claim. Zeroes the receipt amount and emits + /// `BlockedReceiptClaimed`. Atomic with the underlying token release. + /// + /// # Errors + /// - `InvalidToken` — `call.token` is not a TIP-20 address + /// - `InvalidClaimAddress` — `call.to` is the escrow address or the recipient + /// cannot be resolved + /// - `UnauthorizedClaimer` — caller is neither the resolved receiver nor the + /// recovery contract + /// - `InvalidReceiptClaim` — receipt is unknown, already claimed, or fails to decode pub fn claim_blocked( &mut self, msg_sender: Address, @@ -177,6 +209,8 @@ impl TIP1028Escrow { Ok(()) } + /// Returns the next blocked-receipt nonce and bumps the counter. Skips zero so + /// nonces are always nonzero (zero is reserved as "unset"). fn next_blocked_receipt_nonce(&mut self) -> Result { let nonce = self.blocked_receipt_nonce.read()?.max(1); self.blocked_receipt_nonce.write( @@ -187,6 +221,7 @@ impl TIP1028Escrow { Ok(nonce) } + /// ABI-decodes a v1 receipt. Errors if `receipt_version` is unsupported. fn decode_v1(receipt_version: u8, receipt: &[u8]) -> Result { if receipt_version != BLOCKED_RECEIPT_VERSION { return Err(TIP1028EscrowError::invalid_receipt_claim().into()); @@ -195,6 +230,8 @@ impl TIP1028Escrow { .map_err(|_| TIP1028EscrowError::invalid_receipt_claim().into()) } + /// Content-addressed key for the receipt amount mapping. Hashes every immutable + /// receipt field so any tampering yields a different (and empty) slot. fn receipt_key( &self, receipt_version: u8, diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 81e725b313..e04d857263 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -395,8 +395,7 @@ impl TIP20Token { /// - `SupplyCapExceeded` — minting would push total supply above the cap pub fn mint(&mut self, msg_sender: Address, call: ITIP20::mintCall) -> Result<()> { let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) - { + if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { return Err(TIP1028EscrowError::escrow_address_reserved().into()); } @@ -421,8 +420,7 @@ impl TIP20Token { call: ITIP20::mintWithMemoCall, ) -> Result<()> { let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) - { + if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { return Err(TIP1028EscrowError::escrow_address_reserved().into()); } @@ -705,8 +703,7 @@ impl TIP20Token { pub fn transfer(&mut self, msg_sender: Address, call: ITIP20::transferCall) -> Result { trace!(%msg_sender, ?call, "transferring TIP20"); let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) - { + if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { return Err(TIP1028EscrowError::escrow_address_reserved().into()); } @@ -742,8 +739,7 @@ impl TIP20Token { call: ITIP20::transferFromCall, ) -> Result { let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) - { + if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { return Err(TIP1028EscrowError::escrow_address_reserved().into()); } @@ -768,8 +764,7 @@ impl TIP20Token { call: ITIP20::transferFromWithMemoCall, ) -> Result { let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) - { + if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { return Err(TIP1028EscrowError::escrow_address_reserved().into()); } @@ -811,11 +806,8 @@ impl TIP20Token { to: Address, amount: U256, ) -> Result { - let to_input = to; - let to = Recipient::resolve(to_input)?; - if self.storage.spec().is_t6() - && (to_input == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) - { + let to = Recipient::resolve(to)?; + if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { return Err(TIP1028EscrowError::escrow_address_reserved().into()); } @@ -831,6 +823,9 @@ impl TIP20Token { Ok(true) } + /// Allowance-aware inbound transfer: validates, consumes `msg_sender`'s allowance on + /// `from`, then routes through [`Self::transfer_or_escrow`]. Returns `true` if funds + /// reached the recipient, `false` if they were escrowed. fn _transfer_from( &mut self, msg_sender: Address, @@ -845,6 +840,8 @@ impl TIP20Token { self.transfer_or_escrow(from, to, amount, kind, memo) } + /// Decrements `spender`'s allowance on `owner` by `amount`. No-op for infinite + /// allowances (`U256::MAX`). Errors with `InsufficientAllowance` if `amount > allowed`. fn consume_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { let allowed = self.get_allowance(owner, spender)?; if amount > allowed { @@ -867,8 +864,7 @@ impl TIP20Token { call: ITIP20::transferWithMemoCall, ) -> Result<()> { let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && (call.to == ESCROW_ADDRESS || to.target == ESCROW_ADDRESS) - { + if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { return Err(TIP1028EscrowError::escrow_address_reserved().into()); } @@ -1045,6 +1041,11 @@ impl TIP20Token { AccountKeychain::new().authorize_transfer(from, self.address, amount) } + /// TIP-1028 receive-policy gate for inbound transfers. Pre-T6 or when the recipient's + /// receive policy authorizes `from`, performs a normal [`Self::_transfer`] and returns + /// `true`. Otherwise debits `from` to [`ESCROW_ADDRESS`] and records a blocked entry + /// tagged with `kind` and `memo`, returning `false`. Callers must suppress + /// follow-up events (virtual hops, `TransferWithMemo`) when this returns `false`. fn transfer_or_escrow( &mut self, from: Address, @@ -1086,6 +1087,12 @@ impl TIP20Token { Ok(false) } + /// TIP-1028 receive-policy gate for mints. Enforces `ISSUER_ROLE` and the TIP-403 + /// mint-recipient check on `to`. Pre-T6 or when the recipient's receive policy + /// authorizes the mint, mints to `to` and returns `true`. Otherwise mints into + /// [`ESCROW_ADDRESS`] and records a blocked entry tagged with [`InboundKind::MINT`] + /// and `memo`, returning `false`. Callers must suppress follow-up events + /// (`Mint`, `TransferWithMemo`, virtual hops) when this returns `false`. fn mint_or_escrow( &mut self, msg_sender: Address, @@ -1168,6 +1175,9 @@ impl TIP20Token { self.emit_event(to.build_transfer_event(from, amount)) } + /// Atomically debits `originator` to [`ESCROW_ADDRESS`] and records the blocked + /// receipt under the master address resolved from `recipient`. The transfer and + /// escrow write are checkpointed together so a failure rolls back both. fn escrow_funds( &mut self, originator: Address, diff --git a/crates/precompiles/src/tip403_registry/mod.rs b/crates/precompiles/src/tip403_registry/mod.rs index 05221ee398..1d3e927b2a 100644 --- a/crates/precompiles/src/tip403_registry/mod.rs +++ b/crates/precompiles/src/tip403_registry/mod.rs @@ -239,6 +239,8 @@ impl TIP403Registry { }) } + /// Returns `account`'s receive-policy configuration. When the account has not + /// opted in, `hasReceivePolicy` is `false` and the other fields are zero/default. pub fn receive_policy( &self, call: ITIP403Registry::receivePolicyCall, @@ -254,6 +256,10 @@ impl TIP403Registry { }) } + /// Evaluates `receiver`'s receive policy for an inbound `(token, sender)` pair. + /// Returns `(authorized, reason, recovery_address)`. When the receiver has no + /// receive policy, always authorizes with `BlockedReason::NONE`. The token filter + /// is checked before the sender policy; the first failure determines `reason`. pub fn validate_receive_policy( &self, token: Address, @@ -334,6 +340,16 @@ impl TIP403Registry { Ok(new_policy_id) } + /// Installs or replaces the caller's TIP-1028 receive policy. Both policy IDs must + /// be simple (whitelist/blacklist) or built-in. The recovery address is the only + /// account allowed to claim escrowed funds when set; zero falls back to the + /// recipient. + /// + /// # Errors + /// - `InvalidReceivePolicyAddress` — caller is the escrow address + /// - `VirtualAddressNotAllowed` — caller is a virtual address + /// - `PolicyNotFound` — `senderPolicyId` or `tokenFilterId` does not exist + /// - `InvalidReceivePolicyType` — referenced policy is not a simple policy pub fn set_receive_policy( &mut self, msg_sender: Address, @@ -727,6 +743,8 @@ impl TIP403Registry { Ok(()) } + /// Ensures `policy_id` is usable in a receive-policy slot: either a built-in or + /// an existing simple (whitelist/blacklist) policy. fn validate_receive_policy_id(&self, policy_id: u64) -> Result<()> { if self.builtin_authorization(policy_id).is_some() { return Ok(()); @@ -741,6 +759,8 @@ impl TIP403Registry { Ok(()) } + /// Returns the [`PolicyType`] of a receive-policy slot. Built-ins decode their ID + /// directly; user policies must be simple. fn receive_policy_type(&self, policy_id: u64) -> Result { if self.builtin_authorization(policy_id).is_some() { return (policy_id as u8) From dd84248205552d92aac1290a81d8654c02fc65c2 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 17:37:35 -0400 Subject: [PATCH 18/32] docs: update comments --- crates/precompiles/src/tip1028_escrow/mod.rs | 26 +++++--------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs index 0874bb5ad7..177daf6536 100644 --- a/crates/precompiles/src/tip1028_escrow/mod.rs +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -21,12 +21,10 @@ use alloy::{ use tempo_precompiles_macros::contract; use tempo_primitives::TempoAddressExt; -/// On-chain version tag for the v1 [`ITIP1028Escrow::ClaimReceiptV1`] layout. +/// Version tag for the v1 [`ITIP1028Escrow::ClaimReceiptV1`] layout. pub const BLOCKED_RECEIPT_VERSION: u8 = 1; -/// TIP-1028 escrow precompile. Holds funds debited from blocked inbound transfers and -/// mints, keyed by a content-addressed hash of the receipt fields, and lets the recipient -/// (or their recovery contract) claim them later. +/// TIP-1028 escrow holding blocked inbound transfers and mints until claimed. #[contract(addr = ESCROW_ADDRESS)] pub struct TIP1028Escrow { blocked_receipt_nonce: u64, @@ -34,17 +32,12 @@ pub struct TIP1028Escrow { } impl TIP1028Escrow { - /// Initializes the escrow's storage layout. Called once at genesis/activation. + /// One-time storage initialization. pub fn initialize(&mut self) -> Result<()> { self.__initialize() } - /// Returns the unclaimed balance for a receipt, or zero if the receipt is unknown - /// or already claimed. - /// - /// # Errors - /// - `InvalidToken` — `call.token` is not a TIP-20 address - /// - `InvalidReceiptClaim` — receipt version is unsupported or fails to decode + /// Returns the unclaimed amount for a receipt, or zero if unknown or already claimed. pub fn blocked_receipt_balance( &self, call: ITIP1028Escrow::blockedReceiptBalanceCall, @@ -63,15 +56,8 @@ impl TIP1028Escrow { .read() } - /// Records a blocked inbound transfer or mint. Allocates a fresh nonce, writes the - /// claimable amount under the receipt's content hash, and emits `TransferBlocked` - /// for transfers (mints stay silent and surface via the parallel `Mint` event on - /// claim). Caller is responsible for moving the funds into [`ESCROW_ADDRESS`] in - /// the same checkpoint. - /// - /// # Errors - /// - `InvalidToken` — `token` is not a TIP-20 address - /// - `InvalidReceiptClaim` — `blocked_reason` is `NONE`/`__Invalid` or `kind` is `__Invalid` + /// Records a blocked inbound `(token, sender, recipient)` and emits `TransferBlocked` + /// (transfers only). Caller moves the funds into escrow in the same checkpoint. #[allow(clippy::too_many_arguments)] pub(crate) fn store_blocked( &mut self, From 5977a06508bae5d20f17b81333e35204b545014a Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 17:39:48 -0400 Subject: [PATCH 19/32] docs: update comments --- crates/precompiles/src/tip1028_escrow/mod.rs | 21 +++------------ crates/precompiles/src/tip20/mod.rs | 8 +++--- crates/precompiles/src/tip403_registry/mod.rs | 26 +++++-------------- 3 files changed, 13 insertions(+), 42 deletions(-) diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs index 177daf6536..cdd598832c 100644 --- a/crates/precompiles/src/tip1028_escrow/mod.rs +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -118,18 +118,7 @@ impl TIP1028Escrow { Ok((blocked_nonce, blocked_at)) } - /// Releases an escrowed receipt's funds to `call.to`. When `recoveryContract` is - /// zero the resolved master of `receipt.recipient` must be the caller; otherwise - /// only the recovery contract can claim. Zeroes the receipt amount and emits - /// `BlockedReceiptClaimed`. Atomic with the underlying token release. - /// - /// # Errors - /// - `InvalidToken` — `call.token` is not a TIP-20 address - /// - `InvalidClaimAddress` — `call.to` is the escrow address or the recipient - /// cannot be resolved - /// - `UnauthorizedClaimer` — caller is neither the resolved receiver nor the - /// recovery contract - /// - `InvalidReceiptClaim` — receipt is unknown, already claimed, or fails to decode + /// Releases escrowed receipt funds to the authorized recipient. pub fn claim_blocked( &mut self, msg_sender: Address, @@ -195,8 +184,7 @@ impl TIP1028Escrow { Ok(()) } - /// Returns the next blocked-receipt nonce and bumps the counter. Skips zero so - /// nonces are always nonzero (zero is reserved as "unset"). + /// Allocates the next nonzero receipt nonce. fn next_blocked_receipt_nonce(&mut self) -> Result { let nonce = self.blocked_receipt_nonce.read()?.max(1); self.blocked_receipt_nonce.write( @@ -207,7 +195,7 @@ impl TIP1028Escrow { Ok(nonce) } - /// ABI-decodes a v1 receipt. Errors if `receipt_version` is unsupported. + /// ABI-decodes a v1 receipt. fn decode_v1(receipt_version: u8, receipt: &[u8]) -> Result { if receipt_version != BLOCKED_RECEIPT_VERSION { return Err(TIP1028EscrowError::invalid_receipt_claim().into()); @@ -216,8 +204,7 @@ impl TIP1028Escrow { .map_err(|_| TIP1028EscrowError::invalid_receipt_claim().into()) } - /// Content-addressed key for the receipt amount mapping. Hashes every immutable - /// receipt field so any tampering yields a different (and empty) slot. + /// Content hash over every receipt field; any mutation yields a different (empty) slot. fn receipt_key( &self, receipt_version: u8, diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index e04d857263..633786ebb5 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -443,8 +443,8 @@ impl TIP20Token { Ok(()) } - /// Raw mint primitive: bumps total supply, credits `to.target`, emits the transfer event. - /// Callers must enforce the `ISSUER_ROLE` and TIP-403 mint-recipient checks upstream. + /// Raw mint: bumps supply, credits `to.target`, emits `Transfer(0, to, amount)`. + /// Callers enforce role and policy checks. fn _mint(&mut self, to: &Recipient, amount: U256) -> Result<()> { let total_supply = self.total_supply()?; let new_supply = total_supply @@ -823,9 +823,7 @@ impl TIP20Token { Ok(true) } - /// Allowance-aware inbound transfer: validates, consumes `msg_sender`'s allowance on - /// `from`, then routes through [`Self::transfer_or_escrow`]. Returns `true` if funds - /// reached the recipient, `false` if they were escrowed. + /// Allowance-aware inbound transfer routed through [`Self::transfer_or_escrow`]. fn _transfer_from( &mut self, msg_sender: Address, diff --git a/crates/precompiles/src/tip403_registry/mod.rs b/crates/precompiles/src/tip403_registry/mod.rs index 1d3e927b2a..29bacf1618 100644 --- a/crates/precompiles/src/tip403_registry/mod.rs +++ b/crates/precompiles/src/tip403_registry/mod.rs @@ -239,8 +239,7 @@ impl TIP403Registry { }) } - /// Returns `account`'s receive-policy configuration. When the account has not - /// opted in, `hasReceivePolicy` is `false` and the other fields are zero/default. + /// Returns `account`'s receive-policy configuration. pub fn receive_policy( &self, call: ITIP403Registry::receivePolicyCall, @@ -256,10 +255,8 @@ impl TIP403Registry { }) } - /// Evaluates `receiver`'s receive policy for an inbound `(token, sender)` pair. - /// Returns `(authorized, reason, recovery_address)`. When the receiver has no - /// receive policy, always authorizes with `BlockedReason::NONE`. The token filter - /// is checked before the sender policy; the first failure determines `reason`. + /// Checks `receiver`'s receive policy against `(token, sender)`; returns + /// `(authorized, reason, recovery_address)`. pub fn validate_receive_policy( &self, token: Address, @@ -340,16 +337,7 @@ impl TIP403Registry { Ok(new_policy_id) } - /// Installs or replaces the caller's TIP-1028 receive policy. Both policy IDs must - /// be simple (whitelist/blacklist) or built-in. The recovery address is the only - /// account allowed to claim escrowed funds when set; zero falls back to the - /// recipient. - /// - /// # Errors - /// - `InvalidReceivePolicyAddress` — caller is the escrow address - /// - `VirtualAddressNotAllowed` — caller is a virtual address - /// - `PolicyNotFound` — `senderPolicyId` or `tokenFilterId` does not exist - /// - `InvalidReceivePolicyType` — referenced policy is not a simple policy + /// Sets the caller's TIP-1028 receive policy. pub fn set_receive_policy( &mut self, msg_sender: Address, @@ -743,8 +731,7 @@ impl TIP403Registry { Ok(()) } - /// Ensures `policy_id` is usable in a receive-policy slot: either a built-in or - /// an existing simple (whitelist/blacklist) policy. + /// Ensures `policy_id` is a built-in or an existing simple policy. fn validate_receive_policy_id(&self, policy_id: u64) -> Result<()> { if self.builtin_authorization(policy_id).is_some() { return Ok(()); @@ -759,8 +746,7 @@ impl TIP403Registry { Ok(()) } - /// Returns the [`PolicyType`] of a receive-policy slot. Built-ins decode their ID - /// directly; user policies must be simple. + /// Returns the [`PolicyType`] of a receive-policy slot. fn receive_policy_type(&self, policy_id: u64) -> Result { if self.builtin_authorization(policy_id).is_some() { return (policy_id as u8) From c068a86dd8172702bfce2aa5a3d483d98c5fe487 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 17:42:17 -0400 Subject: [PATCH 20/32] docs: simplify comments --- crates/precompiles/src/tip1028_escrow/mod.rs | 6 ++--- crates/precompiles/src/tip20/mod.rs | 27 +++++++------------ crates/precompiles/src/tip403_registry/mod.rs | 4 +-- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs index cdd598832c..bfe11003d7 100644 --- a/crates/precompiles/src/tip1028_escrow/mod.rs +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -56,8 +56,8 @@ impl TIP1028Escrow { .read() } - /// Records a blocked inbound `(token, sender, recipient)` and emits `TransferBlocked` - /// (transfers only). Caller moves the funds into escrow in the same checkpoint. + /// Records a blocked inbound transfer or mint and emits `TransferBlocked` for + /// transfers. Caller moves the funds into escrow in the same checkpoint. #[allow(clippy::too_many_arguments)] pub(crate) fn store_blocked( &mut self, @@ -204,7 +204,7 @@ impl TIP1028Escrow { .map_err(|_| TIP1028EscrowError::invalid_receipt_claim().into()) } - /// Content hash over every receipt field; any mutation yields a different (empty) slot. + /// Content hash over every receipt field. Any mutation yields a different empty slot. fn receipt_key( &self, receipt_version: u8, diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 633786ebb5..2580132f8f 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -443,8 +443,8 @@ impl TIP20Token { Ok(()) } - /// Raw mint: bumps supply, credits `to.target`, emits `Transfer(0, to, amount)`. - /// Callers enforce role and policy checks. + /// Raw mint: bumps supply, credits `to.target`, emits the transfer event from the + /// zero address. Callers enforce role and policy checks. fn _mint(&mut self, to: &Recipient, amount: U256) -> Result<()> { let total_supply = self.total_supply()?; let new_supply = total_supply @@ -838,8 +838,7 @@ impl TIP20Token { self.transfer_or_escrow(from, to, amount, kind, memo) } - /// Decrements `spender`'s allowance on `owner` by `amount`. No-op for infinite - /// allowances (`U256::MAX`). Errors with `InsufficientAllowance` if `amount > allowed`. + /// Debits `spender`'s allowance on `owner`. No-op when unlimited. fn consume_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { let allowed = self.get_allowance(owner, spender)?; if amount > allowed { @@ -1039,11 +1038,8 @@ impl TIP20Token { AccountKeychain::new().authorize_transfer(from, self.address, amount) } - /// TIP-1028 receive-policy gate for inbound transfers. Pre-T6 or when the recipient's - /// receive policy authorizes `from`, performs a normal [`Self::_transfer`] and returns - /// `true`. Otherwise debits `from` to [`ESCROW_ADDRESS`] and records a blocked entry - /// tagged with `kind` and `memo`, returning `false`. Callers must suppress - /// follow-up events (virtual hops, `TransferWithMemo`) when this returns `false`. + /// Transfers `amount` from `from` to `to`, escrowing if `to`'s receive policy blocks + /// `from`. Returns `true` on direct transfer, `false` on escrow. fn transfer_or_escrow( &mut self, from: Address, @@ -1085,12 +1081,8 @@ impl TIP20Token { Ok(false) } - /// TIP-1028 receive-policy gate for mints. Enforces `ISSUER_ROLE` and the TIP-403 - /// mint-recipient check on `to`. Pre-T6 or when the recipient's receive policy - /// authorizes the mint, mints to `to` and returns `true`. Otherwise mints into - /// [`ESCROW_ADDRESS`] and records a blocked entry tagged with [`InboundKind::MINT`] - /// and `memo`, returning `false`. Callers must suppress follow-up events - /// (`Mint`, `TransferWithMemo`, virtual hops) when this returns `false`. + /// Mints `amount` to `to`, escrowing if `to`'s receive policy blocks the mint. + /// Returns `true` on direct mint, `false` on escrow. fn mint_or_escrow( &mut self, msg_sender: Address, @@ -1173,9 +1165,8 @@ impl TIP20Token { self.emit_event(to.build_transfer_event(from, amount)) } - /// Atomically debits `originator` to [`ESCROW_ADDRESS`] and records the blocked - /// receipt under the master address resolved from `recipient`. The transfer and - /// escrow write are checkpointed together so a failure rolls back both. + /// Atomically debits `originator` to [`ESCROW_ADDRESS`] and stores the blocked + /// receipt under the resolved master of `recipient`. fn escrow_funds( &mut self, originator: Address, diff --git a/crates/precompiles/src/tip403_registry/mod.rs b/crates/precompiles/src/tip403_registry/mod.rs index 29bacf1618..15478c2df8 100644 --- a/crates/precompiles/src/tip403_registry/mod.rs +++ b/crates/precompiles/src/tip403_registry/mod.rs @@ -255,8 +255,8 @@ impl TIP403Registry { }) } - /// Checks `receiver`'s receive policy against `(token, sender)`; returns - /// `(authorized, reason, recovery_address)`. + /// Checks `receiver`'s receive policy for an inbound transfer. Returns whether the + /// transfer is authorized, the blocked reason, and the recovery address. pub fn validate_receive_policy( &self, token: Address, From 76511af22fcc36d6d330f832c386a5e3e86e6dbf Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 17:43:03 -0400 Subject: [PATCH 21/32] chore: remove redundant check --- crates/precompiles/src/tip20/mod.rs | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 2580132f8f..391c543d2b 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -395,10 +395,6 @@ impl TIP20Token { /// - `SupplyCapExceeded` — minting would push total supply above the cap pub fn mint(&mut self, msg_sender: Address, call: ITIP20::mintCall) -> Result<()> { let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { - return Err(TIP1028EscrowError::escrow_address_reserved().into()); - } - let minted = self.mint_or_escrow(msg_sender, &to, call.amount, B256::ZERO)?; if minted { self.emit_event(TIP20Event::Mint(ITIP20::Mint { @@ -420,10 +416,6 @@ impl TIP20Token { call: ITIP20::mintWithMemoCall, ) -> Result<()> { let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { - return Err(TIP1028EscrowError::escrow_address_reserved().into()); - } - let minted = self.mint_or_escrow(msg_sender, &to, call.amount, call.memo)?; if minted { self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { @@ -703,10 +695,6 @@ impl TIP20Token { pub fn transfer(&mut self, msg_sender: Address, call: ITIP20::transferCall) -> Result { trace!(%msg_sender, ?call, "transferring TIP20"); let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { - return Err(TIP1028EscrowError::escrow_address_reserved().into()); - } - self.validate_transfer(msg_sender, &to)?; self.check_and_update_spending_limit(msg_sender, call.amount)?; @@ -739,10 +727,6 @@ impl TIP20Token { call: ITIP20::transferFromCall, ) -> Result { let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { - return Err(TIP1028EscrowError::escrow_address_reserved().into()); - } - let transferred = self._transfer_from( msg_sender, call.from, @@ -764,10 +748,6 @@ impl TIP20Token { call: ITIP20::transferFromWithMemoCall, ) -> Result { let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { - return Err(TIP1028EscrowError::escrow_address_reserved().into()); - } - let transferred = self._transfer_from( msg_sender, call.from, @@ -807,10 +787,6 @@ impl TIP20Token { amount: U256, ) -> Result { let to = Recipient::resolve(to)?; - if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { - return Err(TIP1028EscrowError::escrow_address_reserved().into()); - } - self.validate_transfer(from, &to)?; self.check_and_update_spending_limit(from, amount)?; @@ -861,10 +837,6 @@ impl TIP20Token { call: ITIP20::transferWithMemoCall, ) -> Result<()> { let to = Recipient::resolve(call.to)?; - if self.storage.spec().is_t6() && to.target == ESCROW_ADDRESS { - return Err(TIP1028EscrowError::escrow_address_reserved().into()); - } - self.validate_transfer(msg_sender, &to)?; self.check_and_update_spending_limit(msg_sender, call.amount)?; From 8588ab4b4b2a87aac5fca553888dd9f7c1462679 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 17:51:28 -0400 Subject: [PATCH 22/32] chore: rename fn --- crates/precompiles/src/tip20/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 391c543d2b..1288e6a953 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -727,7 +727,7 @@ impl TIP20Token { call: ITIP20::transferFromCall, ) -> Result { let to = Recipient::resolve(call.to)?; - let transferred = self._transfer_from( + let transferred = self.transfer_from_or_escrow( msg_sender, call.from, &to, @@ -748,7 +748,7 @@ impl TIP20Token { call: ITIP20::transferFromWithMemoCall, ) -> Result { let to = Recipient::resolve(call.to)?; - let transferred = self._transfer_from( + let transferred = self.transfer_from_or_escrow( msg_sender, call.from, &to, @@ -800,7 +800,7 @@ impl TIP20Token { } /// Allowance-aware inbound transfer routed through [`Self::transfer_or_escrow`]. - fn _transfer_from( + fn transfer_from_or_escrow( &mut self, msg_sender: Address, from: Address, From faa88df3baef8f5d9f5a59b00433e2e7cd126c52 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 18:40:15 -0400 Subject: [PATCH 23/32] wip: simplify escrow logic --- crates/precompiles/src/tip1028_escrow/mod.rs | 15 +- crates/precompiles/src/tip20/mod.rs | 388 +++++++++--------- .../src/tip403_registry/dispatch.rs | 12 +- crates/precompiles/src/tip403_registry/mod.rs | 33 +- 4 files changed, 223 insertions(+), 225 deletions(-) diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs index bfe11003d7..d607dd16d6 100644 --- a/crates/precompiles/src/tip1028_escrow/mod.rs +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -13,6 +13,7 @@ use crate::{ address_registry::AddressRegistry, error::{Result, TempoPrecompileError}, storage::{Handler, Mapping}, + tip20::TIP20Token, }; use alloy::{ primitives::{Address, B256, U256}, @@ -156,13 +157,13 @@ impl TIP1028Escrow { let guard = self.storage.checkpoint(); self.blocked_receipt_amount[key].write(U256::ZERO)?; - // NOTE: we will update this - // TIP20Token::from_address(call.token)?.release_from_tip1028_escrow( - // receiver, - // call.to, - // amount, - // recovery_address == Address::ZERO, - // )?; + TIP20Token::from_address(call.token)?.release_from_tip1028_escrow( + receipt.originator, + receiver, + call.to, + amount, + recovery_address == Address::ZERO, + )?; self.emit_event(TIP1028EscrowEvent::BlockedReceiptClaimed( ITIP1028Escrow::BlockedReceiptClaimed { diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 1288e6a953..3bb9d85231 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -395,15 +395,26 @@ impl TIP20Token { /// - `SupplyCapExceeded` — minting would push total supply above the cap pub fn mint(&mut self, msg_sender: Address, call: ITIP20::mintCall) -> Result<()> { let to = Recipient::resolve(call.to)?; - let minted = self.mint_or_escrow(msg_sender, &to, call.amount, B256::ZERO)?; - if minted { - self.emit_event(TIP20Event::Mint(ITIP20::Mint { - to: call.to, - amount: call.amount, - }))?; - if let Some(hop) = to.build_virtual_transfer_event(call.amount) { - self.emit_event(hop)?; - } + self.check_role(msg_sender, *ISSUER_ROLE)?; + self.validate_mint(&to)?; + + if self.validate_or_escrow_funds( + Address::ZERO, + &to, + call.amount, + InboundKind::MINT, + B256::ZERO, + )? { + return Ok(()); + } + + self._mint(&to, call.amount)?; + self.emit_event(TIP20Event::Mint(ITIP20::Mint { + to: call.to, + amount: call.amount, + }))?; + if let Some(hop) = to.build_virtual_transfer_event(call.amount) { + self.emit_event(hop)?; } Ok(()) @@ -416,21 +427,32 @@ impl TIP20Token { call: ITIP20::mintWithMemoCall, ) -> Result<()> { let to = Recipient::resolve(call.to)?; - let minted = self.mint_or_escrow(msg_sender, &to, call.amount, call.memo)?; - if minted { - self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { - from: Address::ZERO, - to: call.to, - amount: call.amount, - memo: call.memo, - }))?; - self.emit_event(TIP20Event::Mint(ITIP20::Mint { - to: call.to, - amount: call.amount, - }))?; - if let Some(hop) = to.build_virtual_transfer_event(call.amount) { - self.emit_event(hop)?; - } + self.check_role(msg_sender, *ISSUER_ROLE)?; + self.validate_mint(&to)?; + + if self.validate_or_escrow_funds( + Address::ZERO, + &to, + call.amount, + InboundKind::MINT, + call.memo, + )? { + return Ok(()); + } + + self._mint(&to, call.amount)?; + self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { + from: Address::ZERO, + to: call.to, + amount: call.amount, + memo: call.memo, + }))?; + self.emit_event(TIP20Event::Mint(ITIP20::Mint { + to: call.to, + amount: call.amount, + }))?; + if let Some(hop) = to.build_virtual_transfer_event(call.amount) { + self.emit_event(hop)?; } Ok(()) } @@ -685,27 +707,24 @@ impl TIP20Token { /// Transfers `amount` tokens from the caller to `to`. Enforces compliance via the /// [`TIP403Registry`] and deducts from the caller's [`AccountKeychain`] spending limit. - /// - /// # Errors - /// - `Paused` — token transfers are currently paused - /// - `InvalidRecipient` — recipient address is zero - /// - `PolicyForbids` — TIP-403 policy rejects sender or recipient - /// - `SpendingLimitExceeded` — access key spending limit exceeded - /// - `InsufficientBalance` — sender balance lower than transfer amount pub fn transfer(&mut self, msg_sender: Address, call: ITIP20::transferCall) -> Result { trace!(%msg_sender, ?call, "transferring TIP20"); let to = Recipient::resolve(call.to)?; self.validate_transfer(msg_sender, &to)?; self.check_and_update_spending_limit(msg_sender, call.amount)?; - let transferred = self.transfer_or_escrow( + if self.validate_or_escrow_funds( msg_sender, &to, call.amount, InboundKind::TRANSFER, B256::ZERO, - )?; - if transferred && let Some(hop) = to.build_virtual_transfer_event(call.amount) { + )? { + return Ok(true); + } + + self._transfer(msg_sender, &to, call.amount)?; + if let Some(hop) = to.build_virtual_transfer_event(call.amount) { self.emit_event(hop)?; } @@ -714,28 +733,27 @@ impl TIP20Token { /// Transfers `amount` on behalf of `from` using the caller's allowance. /// Enforces compliance via the [`TIP403Registry`]. - /// - /// # Errors - /// - `Paused` — token transfers are currently paused - /// - `InvalidRecipient` — recipient address is zero - /// - `PolicyForbids` — TIP-403 policy rejects sender or recipient - /// - `InsufficientAllowance` — caller allowance lower than transfer amount - /// - `InsufficientBalance` — `from` balance lower than transfer amount pub fn transfer_from( &mut self, msg_sender: Address, call: ITIP20::transferFromCall, ) -> Result { let to = Recipient::resolve(call.to)?; - let transferred = self.transfer_from_or_escrow( - msg_sender, + self.validate_transfer(call.from, &to)?; + self.consume_allowance(call.from, msg_sender, call.amount)?; + + if self.validate_or_escrow_funds( call.from, &to, call.amount, InboundKind::TRANSFER, B256::ZERO, - )?; - if transferred && let Some(hop) = to.build_virtual_transfer_event(call.amount) { + )? { + return Ok(true); + } + + self._transfer(call.from, &to, call.amount)?; + if let Some(hop) = to.build_virtual_transfer_event(call.amount) { self.emit_event(hop)?; } Ok(true) @@ -748,24 +766,28 @@ impl TIP20Token { call: ITIP20::transferFromWithMemoCall, ) -> Result { let to = Recipient::resolve(call.to)?; - let transferred = self.transfer_from_or_escrow( - msg_sender, + self.validate_transfer(call.from, &to)?; + self.consume_allowance(call.from, msg_sender, call.amount)?; + + if self.validate_or_escrow_funds( call.from, &to, call.amount, InboundKind::TRANSFER, call.memo, - )?; - if transferred { - self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { - from: call.from, - to: call.to, - amount: call.amount, - memo: call.memo, - }))?; - if let Some(hop) = to.build_virtual_transfer_event(call.amount) { - self.emit_event(hop)?; - } + )? { + return Ok(true); + } + + self._transfer(call.from, &to, call.amount)?; + self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { + from: call.from, + to: call.to, + amount: call.amount, + memo: call.memo, + }))?; + if let Some(hop) = to.build_virtual_transfer_event(call.amount) { + self.emit_event(hop)?; } Ok(true) } @@ -790,30 +812,18 @@ impl TIP20Token { self.validate_transfer(from, &to)?; self.check_and_update_spending_limit(from, amount)?; - let transferred = - self.transfer_or_escrow(from, &to, amount, InboundKind::TRANSFER, B256::ZERO)?; - if transferred && let Some(hop) = to.build_virtual_transfer_event(amount) { + if self.validate_or_escrow_funds(from, &to, amount, InboundKind::TRANSFER, B256::ZERO)? { + return Ok(true); + } + + self._transfer(from, &to, amount)?; + if let Some(hop) = to.build_virtual_transfer_event(amount) { self.emit_event(hop)?; } Ok(true) } - /// Allowance-aware inbound transfer routed through [`Self::transfer_or_escrow`]. - fn transfer_from_or_escrow( - &mut self, - msg_sender: Address, - from: Address, - to: &Recipient, - amount: U256, - kind: InboundKind, - memo: B256, - ) -> Result { - self.validate_transfer(from, to)?; - self.consume_allowance(from, msg_sender, amount)?; - self.transfer_or_escrow(from, to, amount, kind, memo) - } - /// Debits `spender`'s allowance on `owner`. No-op when unlimited. fn consume_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { let allowed = self.get_allowance(owner, spender)?; @@ -840,23 +850,25 @@ impl TIP20Token { self.validate_transfer(msg_sender, &to)?; self.check_and_update_spending_limit(msg_sender, call.amount)?; - let transferred = self.transfer_or_escrow( + if self.validate_or_escrow_funds( msg_sender, &to, call.amount, InboundKind::TRANSFER, call.memo, - )?; - if transferred { - self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { - from: msg_sender, - to: call.to, - amount: call.amount, - memo: call.memo, - }))?; - if let Some(hop) = to.build_virtual_transfer_event(call.amount) { - self.emit_event(hop)?; - } + )? { + return Ok(()); + } + + self._transfer(msg_sender, &to, call.amount)?; + self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { + from: msg_sender, + to: call.to, + amount: call.amount, + memo: call.memo, + }))?; + if let Some(hop) = to.build_virtual_transfer_event(call.amount) { + self.emit_event(hop)?; } Ok(()) } @@ -1010,100 +1022,6 @@ impl TIP20Token { AccountKeychain::new().authorize_transfer(from, self.address, amount) } - /// Transfers `amount` from `from` to `to`, escrowing if `to`'s receive policy blocks - /// `from`. Returns `true` on direct transfer, `false` on escrow. - fn transfer_or_escrow( - &mut self, - from: Address, - to: &Recipient, - amount: U256, - kind: InboundKind, - memo: B256, - ) -> Result { - if !self.storage.spec().is_t6() { - self._transfer(from, to, amount)?; - return Ok(true); - } - - if to.target == ESCROW_ADDRESS { - return Err(TIP1028EscrowError::escrow_address_reserved().into()); - } - let recipient = if let Some(virtual_addr) = to.virtual_addr { - virtual_addr - } else { - to.target - }; - - let (authorized, blocked_reason, recovery_address) = - TIP403Registry::new().validate_receive_policy(self.address, from, to.target)?; - if authorized { - self._transfer(from, to, amount)?; - return Ok(true); - } - - self.escrow_funds( - from, - recipient, - amount, - recovery_address, - blocked_reason, - kind, - memo, - )?; - Ok(false) - } - - /// Mints `amount` to `to`, escrowing if `to`'s receive policy blocks the mint. - /// Returns `true` on direct mint, `false` on escrow. - fn mint_or_escrow( - &mut self, - msg_sender: Address, - to: &Recipient, - amount: U256, - memo: B256, - ) -> Result { - self.check_role(msg_sender, *ISSUER_ROLE)?; - self.validate_mint(to)?; - - if !self.storage.spec().is_t6() { - self._mint(to, amount)?; - return Ok(true); - } - - if to.target == ESCROW_ADDRESS { - return Err(TIP1028EscrowError::escrow_address_reserved().into()); - } - let recipient = if let Some(virtual_addr) = to.virtual_addr { - virtual_addr - } else { - to.target - }; - - let (authorized, blocked_reason, recovery_address) = TIP403Registry::new() - .validate_receive_policy(self.address, Address::ZERO, to.target)?; - if authorized { - self._mint(to, amount)?; - return Ok(true); - } - - let receiver = AddressRegistry::new().resolve_recipient(recipient)?; - let guard = self.storage.checkpoint(); - self._mint(&Recipient::direct(ESCROW_ADDRESS), amount)?; - TIP1028Escrow::new().store_blocked( - self.address, - Address::ZERO, - receiver, - recipient, - recovery_address, - amount, - blocked_reason, - InboundKind::MINT, - memo, - )?; - guard.commit(); - Ok(false) - } - /// Core transfer: debits `from`, credits `to.target`, emits `Transfer(from, event_addr, amount)`. /// /// For virtual recipients the event address is the virtual alias; the balance update always @@ -1137,33 +1055,115 @@ impl TIP20Token { self.emit_event(to.build_transfer_event(from, amount)) } - /// Atomically debits `originator` to [`ESCROW_ADDRESS`] and stores the blocked - /// receipt under the resolved master of `recipient`. - fn escrow_funds( + /// Validates the TIP-1028 receive-policy check for the destination address. If the receive + /// policy prohibits the action, the funds are escrowed. + fn validate_or_escrow_funds( &mut self, originator: Address, - recipient: Address, + to: &Recipient, amount: U256, - recovery_address: Address, - blocked_reason: ITIP403Registry::BlockedReason, kind: InboundKind, memo: B256, - ) -> Result<()> { - let receiver = AddressRegistry::new().resolve_recipient(recipient)?; + ) -> Result { + if !self.storage.spec().is_t6() { + return Ok(false); + } + if to.target == ESCROW_ADDRESS { + return Err(TIP1028EscrowError::escrow_address_reserved().into()); + } + let registry = TIP403Registry::new(); + let Some(reason) = registry.validate_receive_policy(self.address, originator, to.target)? + else { + return Ok(false); + }; + let recovery = registry.receive_policy_recovery(to.target)?; + let recipient = to.virtual_addr.unwrap_or(to.target); + let guard = self.storage.checkpoint(); - self._transfer(originator, &Recipient::direct(ESCROW_ADDRESS), amount)?; + match kind { + InboundKind::TRANSFER => { + self._transfer(originator, &Recipient::direct(ESCROW_ADDRESS), amount)? + } + InboundKind::MINT => self._mint(&Recipient::direct(ESCROW_ADDRESS), amount)?, + InboundKind::__Invalid => { + return Err(TIP1028EscrowError::invalid_receipt_claim().into()); + } + } TIP1028Escrow::new().store_blocked( self.address, originator, - receiver, + to.target, recipient, - recovery_address, + recovery, amount, - blocked_reason, + reason, kind, memo, )?; guard.commit(); + Ok(true) + } + + /// Releases escrowed funds to `to`. Self-recovery skips policy checks. Redirects + /// revalidate the transfer and receive policies and meter the spending limit when set. + pub(crate) fn release_from_tip1028_escrow( + &mut self, + originator: Address, + receiver: Address, + to: Address, + amount: U256, + meter_spending_limit: bool, + ) -> Result<()> { + if to == ESCROW_ADDRESS { + return Err(TIP1028EscrowError::escrow_address_reserved().into()); + } + + let destination = Recipient::resolve(to)?; + destination.validate()?; + + if destination.target != receiver { + let registry = TIP403Registry::new(); + let policy_id = self.transfer_policy_id()?; + if !registry.is_authorized_as(policy_id, destination.target, AuthRole::recipient())? { + return Err(TIP20Error::policy_forbids().into()); + } + if registry + .validate_receive_policy(self.address, originator, destination.target)? + .is_some() + { + return Err(TIP20Error::policy_forbids().into()); + } + if meter_spending_limit { + self.check_and_update_spending_limit(receiver, amount)?; + } + } + + let escrow_balance = self.get_balance(ESCROW_ADDRESS)?; + if amount > escrow_balance { + return Err(TIP1028EscrowError::insufficient_escrow_balance().into()); + } + + self.handle_rewards_on_transfer(ESCROW_ADDRESS, destination.target, amount)?; + + self.set_balance( + ESCROW_ADDRESS, + escrow_balance + .checked_sub(amount) + .ok_or(TempoPrecompileError::under_overflow())?, + )?; + + let to_balance = self.get_balance(destination.target)?; + self.set_balance( + destination.target, + to_balance + .checked_add(amount) + .ok_or(TempoPrecompileError::under_overflow())?, + )?; + + self.emit_event(destination.build_transfer_event(ESCROW_ADDRESS, amount))?; + if let Some(hop) = destination.build_virtual_transfer_event(amount) { + self.emit_event(hop)?; + } Ok(()) } diff --git a/crates/precompiles/src/tip403_registry/dispatch.rs b/crates/precompiles/src/tip403_registry/dispatch.rs index 0db0fa3008..af2681825f 100644 --- a/crates/precompiles/src/tip403_registry/dispatch.rs +++ b/crates/precompiles/src/tip403_registry/dispatch.rs @@ -64,10 +64,11 @@ impl Precompile for TIP403Registry { } ITIP403RegistryCalls::receivePolicy(call) => view(call, |c| self.receive_policy(c)), ITIP403RegistryCalls::validateReceivePolicy(call) => view(call, |c| { - let (authorized, blocked_reason, _) = - self.validate_receive_policy(c.token, c.sender, c.receiver)?; + let blocked_reason = self + .validate_receive_policy(c.token, c.sender, c.receiver)? + .unwrap_or(ITIP403Registry::BlockedReason::NONE); Ok(ITIP403Registry::validateReceivePolicyReturn { - authorized, + authorized: blocked_reason == ITIP403Registry::BlockedReason::NONE, blockedReason: blocked_reason, }) }), @@ -554,8 +555,9 @@ mod tests { #[test] fn test_selector_coverage() -> eyre::Result<()> { - // Use T2 to test all selectors including TIP-1015 compound policy functions - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T2); + // Use T6 to test all selectors including TIP-1015 compound policy functions and + // TIP-1028 receive-policy functions added in T6. + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); StorageCtx::enter(&mut storage, || { let mut registry = TIP403Registry::new(); diff --git a/crates/precompiles/src/tip403_registry/mod.rs b/crates/precompiles/src/tip403_registry/mod.rs index 15478c2df8..0672ce7531 100644 --- a/crates/precompiles/src/tip403_registry/mod.rs +++ b/crates/precompiles/src/tip403_registry/mod.rs @@ -255,40 +255,35 @@ impl TIP403Registry { }) } - /// Checks `receiver`'s receive policy for an inbound transfer. Returns whether the - /// transfer is authorized, the blocked reason, and the recovery address. + /// Checks `receiver`'s receive policy for an inbound transfer. Returns the blocking + /// reason, or `None` if authorized. pub fn validate_receive_policy( &self, token: Address, sender: Address, receiver: Address, - ) -> Result<(bool, ITIP403Registry::BlockedReason, Address)> { + ) -> Result> { let config = self.address_receive_config[receiver].read()?; if !config.has_receive_policy { - return Ok((true, ITIP403Registry::BlockedReason::NONE, Address::ZERO)); + return Ok(None); } if !self.is_authorized_simple(config.token_filter_id, token)? { - return Ok(( - false, - ITIP403Registry::BlockedReason::TOKEN_FILTER, - config.recovery_address, - )); + return Ok(Some(ITIP403Registry::BlockedReason::TOKEN_FILTER)); } if !self.is_authorized_simple(config.sender_policy_id, sender)? { - return Ok(( - false, - ITIP403Registry::BlockedReason::RECEIVE_POLICY, - config.recovery_address, - )); + return Ok(Some(ITIP403Registry::BlockedReason::RECEIVE_POLICY)); } - Ok(( - true, - ITIP403Registry::BlockedReason::NONE, - config.recovery_address, - )) + Ok(None) + } + + /// Returns `receiver`'s configured recovery address, or zero if no receive policy is set. + pub fn receive_policy_recovery(&self, receiver: Address) -> Result
{ + Ok(self.address_receive_config[receiver] + .read()? + .recovery_address) } /// Creates a new simple (whitelist or blacklist) policy and returns its ID. From bf0ff936a1c704ec0146f8ecd393e42a4ace2d60 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 18:55:08 -0400 Subject: [PATCH 24/32] wip: update mint checks --- crates/precompiles/src/tip20/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 3bb9d85231..691b423aa5 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -457,8 +457,7 @@ impl TIP20Token { Ok(()) } - /// Raw mint: bumps supply, credits `to.target`, emits the transfer event from the - /// zero address. Callers enforce role and policy checks. + /// Internal helper to mint new tokens and update balances. fn _mint(&mut self, to: &Recipient, amount: U256) -> Result<()> { let total_supply = self.total_supply()?; let new_supply = total_supply From 54782eccd8a3d7d1c40d231778ce49fbe77afa49 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 18:58:17 -0400 Subject: [PATCH 25/32] docs: comments --- crates/precompiles/src/tip20/mod.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 691b423aa5..78566834fb 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -706,6 +706,13 @@ impl TIP20Token { /// Transfers `amount` tokens from the caller to `to`. Enforces compliance via the /// [`TIP403Registry`] and deducts from the caller's [`AccountKeychain`] spending limit. + /// + /// # Errors + /// - `Paused` — token transfers are currently paused + /// - `InvalidRecipient` — recipient address is zero + /// - `PolicyForbids` — TIP-403 policy rejects sender or recipient + /// - `SpendingLimitExceeded` — access key spending limit exceeded + /// - `InsufficientBalance` — sender balance lower than transfer amount pub fn transfer(&mut self, msg_sender: Address, call: ITIP20::transferCall) -> Result { trace!(%msg_sender, ?call, "transferring TIP20"); let to = Recipient::resolve(call.to)?; @@ -732,6 +739,13 @@ impl TIP20Token { /// Transfers `amount` on behalf of `from` using the caller's allowance. /// Enforces compliance via the [`TIP403Registry`]. + /// + /// # Errors + /// - `Paused` — token transfers are currently paused + /// - `InvalidRecipient` — recipient address is zero + /// - `PolicyForbids` — TIP-403 policy rejects sender or recipient + /// - `InsufficientAllowance` — caller allowance lower than transfer amount + /// - `InsufficientBalance` — `from` balance lower than transfer amount pub fn transfer_from( &mut self, msg_sender: Address, From ec79af2890c53f17d0ad0d202617098a2967c2ff Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 18:59:40 -0400 Subject: [PATCH 26/32] chore: rename function --- crates/precompiles/src/tip1028_escrow/mod.rs | 2 +- crates/precompiles/src/tip20/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs index d607dd16d6..25e63968ba 100644 --- a/crates/precompiles/src/tip1028_escrow/mod.rs +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -157,7 +157,7 @@ impl TIP1028Escrow { let guard = self.storage.checkpoint(); self.blocked_receipt_amount[key].write(U256::ZERO)?; - TIP20Token::from_address(call.token)?.release_from_tip1028_escrow( + TIP20Token::from_address(call.token)?.release_from_escrow( receipt.originator, receiver, call.to, diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 78566834fb..293fddf609 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -1119,7 +1119,7 @@ impl TIP20Token { /// Releases escrowed funds to `to`. Self-recovery skips policy checks. Redirects /// revalidate the transfer and receive policies and meter the spending limit when set. - pub(crate) fn release_from_tip1028_escrow( + pub(crate) fn release_from_escrow( &mut self, originator: Address, receiver: Address, From cff7814f4bae91865a6f83ff03261469fe4b4119 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 22:27:48 -0400 Subject: [PATCH 27/32] test: tip1028 tests --- crates/precompiles/src/tip1028_escrow/mod.rs | 1041 ++++++++++++++++++ 1 file changed, 1041 insertions(+) diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs index 25e63968ba..840c97199f 100644 --- a/crates/precompiles/src/tip1028_escrow/mod.rs +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -231,3 +231,1044 @@ impl TIP1028Escrow { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + address_registry::AddressRegistry, + error::TempoPrecompileError, + storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider}, + test_util::{TIP20Setup, VIRTUAL_MASTER, register_virtual_master}, + tip20::ITIP20, + tip403_registry::{ALLOW_ALL_POLICY_ID, REJECT_ALL_POLICY_ID, TIP403Registry}, + }; + use alloy::sol_types::SolValue; + use tempo_chainspec::hardfork::TempoHardfork; + use tempo_contracts::precompiles::TIP20Error; + + fn receipt_v1( + originator: Address, + recipient: Address, + blocked_at: u64, + blocked_nonce: u64, + blocked_reason: BlockedReason, + kind: InboundKind, + memo: B256, + ) -> ITIP1028Escrow::ClaimReceiptV1 { + ITIP1028Escrow::ClaimReceiptV1 { + originator, + recipient, + blockedAt: blocked_at, + blockedNonce: blocked_nonce, + blockedReason: blocked_reason as u8, + kind, + memo, + } + } + + fn block_all_senders(receiver: Address, recovery_address: Address) -> Result<()> { + TIP403Registry::new().set_receive_policy( + receiver, + ITIP403Registry::setReceivePolicyCall { + senderPolicyId: REJECT_ALL_POLICY_ID, + tokenFilterId: ALLOW_ALL_POLICY_ID, + recoveryAddress: recovery_address, + }, + ) + } + + fn receipt_balance( + escrow: &TIP1028Escrow, + token: Address, + recovery_contract: Address, + receipt: &ITIP1028Escrow::ClaimReceiptV1, + ) -> Result { + escrow.blocked_receipt_balance(ITIP1028Escrow::blockedReceiptBalanceCall { + token, + recoveryContract: recovery_contract, + receiptVersion: BLOCKED_RECEIPT_VERSION, + receipt: receipt.abi_encode().into(), + }) + } + + fn claim_call( + token: Address, + recovery_contract: Address, + receipt: &ITIP1028Escrow::ClaimReceiptV1, + to: Address, + ) -> ITIP1028Escrow::claimBlockedCall { + ITIP1028Escrow::claimBlockedCall { + token, + recoveryContract: recovery_contract, + receiptVersion: BLOCKED_RECEIPT_VERSION, + receipt: receipt.abi_encode().into(), + to, + } + } + + fn assert_invalid_receipt(result: Result<()>) { + assert!(matches!( + result, + Err(e) if e == TIP1028EscrowError::invalid_receipt_claim().into() + )); + } + + fn assert_unauthorized(result: Result<()>) { + assert!(matches!( + result, + Err(e) if e == TIP1028EscrowError::unauthorized_claimer().into() + )); + } + + #[test] + fn receipt_balance_store_and_claim() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_000u64)); + + let admin = Address::random(); + let originator = Address::random(); + let receiver = Address::random(); + let amount = U256::from(100u64); + let blocked_at = 1_728_000u64; + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("T", "T", admin) + .with_issuer(admin) + .with_mint(originator, amount) + .apply()?; + block_all_senders(receiver, Address::ZERO)?; + + let unknown = receipt_v1( + originator, + receiver, + blocked_at, + 99, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + let escrow = TIP1028Escrow::new(); + assert_eq!( + receipt_balance(&escrow, token.address(), Address::ZERO, &unknown)?, + U256::ZERO + ); + + token.transfer( + originator, + ITIP20::transferCall { + to: receiver, + amount, + }, + )?; + + let receipt = receipt_v1( + originator, + receiver, + blocked_at, + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + assert_eq!( + receipt_balance(&escrow, token.address(), Address::ZERO, &receipt)?, + amount + ); + + TIP1028Escrow::new().claim_blocked( + receiver, + ITIP1028Escrow::claimBlockedCall { + token: token.address(), + recoveryContract: Address::ZERO, + receiptVersion: BLOCKED_RECEIPT_VERSION, + receipt: receipt.abi_encode().into(), + to: receiver, + }, + )?; + + assert_eq!( + receipt_balance(&escrow, token.address(), Address::ZERO, &receipt)?, + U256::ZERO + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + U256::ZERO + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { account: receiver })?, + amount + ); + Ok(()) + }) + } + + #[test] + fn receipt_rejects_bad_encoding() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let admin = Address::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::create("T", "T", admin).apply()?; + let escrow = TIP1028Escrow::new(); + let receipt = receipt_v1( + Address::random(), + Address::random(), + 1, + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + + for (receipt_version, receipt_bytes) in [ + (BLOCKED_RECEIPT_VERSION + 1, receipt.abi_encode().into()), + (BLOCKED_RECEIPT_VERSION, vec![0xde, 0xad, 0xbe, 0xef].into()), + ] { + let result = + escrow.blocked_receipt_balance(ITIP1028Escrow::blockedReceiptBalanceCall { + token: token.address(), + recoveryContract: Address::ZERO, + receiptVersion: receipt_version, + receipt: receipt_bytes, + }); + assert!(matches!( + result, + Err(e) if e == TIP1028EscrowError::invalid_receipt_claim().into() + )); + } + + Ok(()) + }) + } + + #[test] + fn store_rejects_invalid_metadata() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let admin = Address::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::create("T", "T", admin).apply()?; + + for (blocked_reason, kind) in [ + (BlockedReason::NONE, InboundKind::TRANSFER), + (BlockedReason::__Invalid, InboundKind::TRANSFER), + (BlockedReason::RECEIVE_POLICY, InboundKind::__Invalid), + ] { + let result = TIP1028Escrow::new().store_blocked( + token.address(), + Address::random(), + Address::random(), + Address::random(), + Address::ZERO, + U256::from(1u64), + blocked_reason, + kind, + B256::ZERO, + ); + assert!(matches!( + result, + Err(e) if e == TIP1028EscrowError::invalid_receipt_claim().into() + )); + } + + Ok(()) + }) + } + + #[test] + fn store_emits_transfer_blocked_for_transfers() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_001u64)); + + let admin = Address::random(); + let originator = Address::random(); + let receiver = Address::random(); + let recipient = Address::random(); + let recovery = Address::random(); + let amount = U256::from(42u64); + let memo = B256::repeat_byte(0x42); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::create("T", "T", admin).apply()?; + let mut escrow = TIP1028Escrow::new(); + + let (nonce, blocked_at) = escrow.store_blocked( + token.address(), + originator, + receiver, + recipient, + recovery, + amount, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + memo, + )?; + + escrow.assert_emitted_events(vec![TIP1028EscrowEvent::TransferBlocked( + ITIP1028Escrow::TransferBlocked { + token: token.address(), + from: originator, + receiver, + receiptVersion: BLOCKED_RECEIPT_VERSION, + blockedNonce: nonce, + blockedAt: blocked_at, + recipient, + amount, + blockedReason: BlockedReason::RECEIVE_POLICY as u8, + recoveryContract: recovery, + memo, + }, + )]); + + escrow.clear_emitted_events(); + escrow.store_blocked( + token.address(), + Address::ZERO, + receiver, + recipient, + recovery, + amount, + BlockedReason::RECEIVE_POLICY, + InboundKind::MINT, + memo, + )?; + escrow.assert_emitted_events(Vec::::new()); + + Ok(()) + }) + } + + #[test] + fn receipt_key_binds_receipt_fields() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_002u64)); + + let admin = Address::random(); + let originator_a = Address::random(); + let originator_b = Address::random(); + let recipient = Address::random(); + let recovery = Address::random(); + let memo = B256::repeat_byte(0x11); + let amount_a = U256::from(10u64); + let amount_b = U256::from(20u64); + + StorageCtx::enter(&mut storage, || { + let token_a = TIP20Setup::create("A", "A", admin).apply()?; + let token_b = TIP20Setup::create("B", "B", admin).apply()?; + let mut escrow = TIP1028Escrow::new(); + + let (nonce_a, blocked_at_a) = escrow.store_blocked( + token_a.address(), + originator_a, + recipient, + recipient, + recovery, + amount_a, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + memo, + )?; + let (nonce_b, blocked_at_b) = escrow.store_blocked( + token_a.address(), + originator_b, + recipient, + recipient, + recovery, + amount_b, + BlockedReason::TOKEN_FILTER, + InboundKind::MINT, + B256::repeat_byte(0x22), + )?; + + let receipt_a = receipt_v1( + originator_a, + recipient, + blocked_at_a, + nonce_a, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + memo, + ); + let receipt_b = receipt_v1( + originator_b, + recipient, + blocked_at_b, + nonce_b, + BlockedReason::TOKEN_FILTER, + InboundKind::MINT, + B256::repeat_byte(0x22), + ); + + assert_eq!( + receipt_balance(&escrow, token_a.address(), recovery, &receipt_a)?, + amount_a + ); + assert_eq!( + receipt_balance(&escrow, token_a.address(), recovery, &receipt_b)?, + amount_b + ); + + let mutated_receipts = [ + receipt_v1( + Address::random(), + recipient, + blocked_at_a, + nonce_a, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + memo, + ), + receipt_v1( + originator_a, + Address::random(), + blocked_at_a, + nonce_a, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + memo, + ), + receipt_v1( + originator_a, + recipient, + blocked_at_a + 1, + nonce_a, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + memo, + ), + receipt_v1( + originator_a, + recipient, + blocked_at_a, + nonce_a + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + memo, + ), + receipt_v1( + originator_a, + recipient, + blocked_at_a, + nonce_a, + BlockedReason::TOKEN_FILTER, + InboundKind::TRANSFER, + memo, + ), + receipt_v1( + originator_a, + recipient, + blocked_at_a, + nonce_a, + BlockedReason::RECEIVE_POLICY, + InboundKind::MINT, + memo, + ), + receipt_v1( + originator_a, + recipient, + blocked_at_a, + nonce_a, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::repeat_byte(0x33), + ), + ]; + + for mutated in mutated_receipts { + assert_eq!( + receipt_balance(&escrow, token_a.address(), recovery, &mutated)?, + U256::ZERO + ); + } + assert_eq!( + receipt_balance(&escrow, token_a.address(), Address::random(), &receipt_a)?, + U256::ZERO + ); + assert_eq!( + receipt_balance(&escrow, token_b.address(), recovery, &receipt_a)?, + U256::ZERO + ); + + Ok(()) + }) + } + + #[test] + fn claim_rejects_missing_receipt() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_003u64)); + + let admin = Address::random(); + let originator = Address::random(); + let receiver = Address::random(); + let amount = U256::from(100u64); + let receipt = receipt_v1( + originator, + receiver, + 1_728_003, + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("T", "T", admin) + .with_issuer(admin) + .with_mint(originator, amount) + .apply()?; + + assert_invalid_receipt(TIP1028Escrow::new().claim_blocked( + receiver, + claim_call(token.address(), Address::ZERO, &receipt, receiver), + )); + + block_all_senders(receiver, Address::ZERO)?; + token.transfer( + originator, + ITIP20::transferCall { + to: receiver, + amount, + }, + )?; + TIP1028Escrow::new().claim_blocked( + receiver, + claim_call(token.address(), Address::ZERO, &receipt, receiver), + )?; + assert_invalid_receipt(TIP1028Escrow::new().claim_blocked( + receiver, + claim_call(token.address(), Address::ZERO, &receipt, receiver), + )); + + Ok(()) + }) + } + + #[test] + fn claim_requires_authorized_caller() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_004u64)); + + let admin = Address::random(); + let originator = Address::random(); + let receiver = Address::random(); + let recovery_receiver = Address::random(); + let recovery = Address::random(); + let stranger = Address::random(); + let amount = U256::from(50u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("T", "T", admin) + .with_issuer(admin) + .with_mint(originator, amount * U256::from(2u64)) + .apply()?; + block_all_senders(receiver, Address::ZERO)?; + block_all_senders(recovery_receiver, recovery)?; + + token.transfer( + originator, + ITIP20::transferCall { + to: receiver, + amount, + }, + )?; + token.transfer( + originator, + ITIP20::transferCall { + to: recovery_receiver, + amount, + }, + )?; + + let self_receipt = receipt_v1( + originator, + receiver, + 1_728_004, + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + let recovery_receipt = receipt_v1( + originator, + recovery_receiver, + 1_728_004, + 2, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + + assert_unauthorized(TIP1028Escrow::new().claim_blocked( + stranger, + claim_call(token.address(), Address::ZERO, &self_receipt, receiver), + )); + for caller in [recovery_receiver, stranger] { + assert_unauthorized(TIP1028Escrow::new().claim_blocked( + caller, + claim_call( + token.address(), + recovery, + &recovery_receipt, + recovery_receiver, + ), + )); + } + + assert_eq!( + receipt_balance( + &TIP1028Escrow::new(), + token.address(), + Address::ZERO, + &self_receipt + )?, + amount + ); + assert_eq!( + receipt_balance( + &TIP1028Escrow::new(), + token.address(), + recovery, + &recovery_receipt + )?, + amount + ); + + Ok(()) + }) + } + + #[test] + fn claim_self_recovery() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_005u64)); + + let admin = Address::random(); + let originator = Address::random(); + let receiver = Address::random(); + let amount = U256::from(125u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("T", "T", admin) + .with_issuer(admin) + .with_mint(originator, amount) + .apply()?; + block_all_senders(receiver, Address::ZERO)?; + token.transfer( + originator, + ITIP20::transferCall { + to: receiver, + amount, + }, + )?; + + let receipt = receipt_v1( + originator, + receiver, + 1_728_005, + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + + let mut escrow = TIP1028Escrow::new(); + escrow.clear_emitted_events(); + escrow.claim_blocked( + receiver, + claim_call(token.address(), Address::ZERO, &receipt, receiver), + )?; + + escrow.assert_emitted_events(vec![TIP1028EscrowEvent::BlockedReceiptClaimed( + ITIP1028Escrow::BlockedReceiptClaimed { + token: token.address(), + receiver, + receiptVersion: BLOCKED_RECEIPT_VERSION, + blockedNonce: 1, + blockedAt: 1_728_005, + originator, + recipient: receiver, + recoveryContract: Address::ZERO, + caller: receiver, + to: receiver, + amount, + }, + )]); + assert_eq!( + receipt_balance(&escrow, token.address(), Address::ZERO, &receipt)?, + U256::ZERO + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + U256::ZERO + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { account: receiver })?, + amount + ); + + Ok(()) + }) + } + + #[test] + fn claim_via_recovery_contract() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_006u64)); + + let admin = Address::random(); + let originator = Address::random(); + let receiver = Address::random(); + let recovery = Address::random(); + let destination = Address::random(); + let amount = U256::from(75u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("T", "T", admin) + .with_issuer(admin) + .with_mint(originator, amount) + .apply()?; + block_all_senders(receiver, recovery)?; + token.transfer( + originator, + ITIP20::transferCall { + to: receiver, + amount, + }, + )?; + + let receipt = receipt_v1( + originator, + receiver, + 1_728_006, + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + let mut escrow = TIP1028Escrow::new(); + escrow.clear_emitted_events(); + escrow.claim_blocked( + recovery, + claim_call(token.address(), recovery, &receipt, destination), + )?; + + escrow.assert_emitted_events(vec![TIP1028EscrowEvent::BlockedReceiptClaimed( + ITIP1028Escrow::BlockedReceiptClaimed { + token: token.address(), + receiver, + receiptVersion: BLOCKED_RECEIPT_VERSION, + blockedNonce: 1, + blockedAt: 1_728_006, + originator, + recipient: receiver, + recoveryContract: recovery, + caller: recovery, + to: destination, + amount, + }, + )]); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { account: receiver })?, + U256::ZERO + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { + account: destination + })?, + amount + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + U256::ZERO + ); + + Ok(()) + }) + } + + #[test] + fn claim_rolls_back_on_release_error() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_007u64)); + + let admin = Address::random(); + let originator = Address::random(); + let receiver = Address::random(); + let destination = Address::random(); + let amount = U256::from(64u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("T", "T", admin) + .with_issuer(admin) + .with_mint(originator, amount) + .apply()?; + block_all_senders(receiver, Address::ZERO)?; + token.transfer( + originator, + ITIP20::transferCall { + to: receiver, + amount, + }, + )?; + block_all_senders(destination, Address::ZERO)?; + + let receipt = receipt_v1( + originator, + receiver, + 1_728_007, + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + let escrow = TIP1028Escrow::new(); + let result = TIP1028Escrow::new().claim_blocked( + receiver, + claim_call(token.address(), Address::ZERO, &receipt, destination), + ); + assert!(matches!( + result, + Err(TempoPrecompileError::TIP20(TIP20Error::PolicyForbids(_))) + )); + + assert_eq!( + receipt_balance(&escrow, token.address(), Address::ZERO, &receipt)?, + amount + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + amount + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { account: receiver })?, + U256::ZERO + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { + account: destination + })?, + U256::ZERO + ); + + Ok(()) + }) + } + + #[test] + fn claim_binds_recovery_contract() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_008u64)); + + let admin = Address::random(); + let originator = Address::random(); + let receiver = Address::random(); + let recovery = Address::random(); + let other_recovery = Address::random(); + let amount = U256::from(88u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("T", "T", admin) + .with_issuer(admin) + .with_mint(originator, amount) + .apply()?; + block_all_senders(receiver, recovery)?; + token.transfer( + originator, + ITIP20::transferCall { + to: receiver, + amount, + }, + )?; + + let receipt = receipt_v1( + originator, + receiver, + 1_728_008, + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + let escrow = TIP1028Escrow::new(); + assert_eq!( + receipt_balance(&escrow, token.address(), recovery, &receipt)?, + amount + ); + assert_eq!( + receipt_balance(&escrow, token.address(), Address::ZERO, &receipt)?, + U256::ZERO + ); + assert_eq!( + receipt_balance(&escrow, token.address(), other_recovery, &receipt)?, + U256::ZERO + ); + + assert_invalid_receipt(TIP1028Escrow::new().claim_blocked( + receiver, + claim_call(token.address(), Address::ZERO, &receipt, receiver), + )); + assert_invalid_receipt(TIP1028Escrow::new().claim_blocked( + other_recovery, + claim_call(token.address(), other_recovery, &receipt, receiver), + )); + TIP1028Escrow::new().claim_blocked( + recovery, + claim_call(token.address(), recovery, &receipt, receiver), + )?; + + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { account: receiver })?, + amount + ); + Ok(()) + }) + } + + #[test] + fn claim_virtual_recipient() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_009u64)); + + let admin = Address::random(); + let originator = Address::random(); + let amount = U256::from(123u64); + + StorageCtx::enter(&mut storage, || { + let (_, virtual_addr) = register_virtual_master(&mut AddressRegistry::new())?; + let mut token = TIP20Setup::create("T", "T", admin) + .with_issuer(admin) + .with_mint(originator, amount) + .apply()?; + block_all_senders(VIRTUAL_MASTER, Address::ZERO)?; + token.transfer( + originator, + ITIP20::transferCall { + to: virtual_addr, + amount, + }, + )?; + + let receipt = receipt_v1( + originator, + virtual_addr, + 1_728_009, + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + let mut escrow = TIP1028Escrow::new(); + escrow.clear_emitted_events(); + escrow.claim_blocked( + VIRTUAL_MASTER, + claim_call(token.address(), Address::ZERO, &receipt, VIRTUAL_MASTER), + )?; + + escrow.assert_emitted_events(vec![TIP1028EscrowEvent::BlockedReceiptClaimed( + ITIP1028Escrow::BlockedReceiptClaimed { + token: token.address(), + receiver: VIRTUAL_MASTER, + receiptVersion: BLOCKED_RECEIPT_VERSION, + blockedNonce: 1, + blockedAt: 1_728_009, + originator, + recipient: virtual_addr, + recoveryContract: Address::ZERO, + caller: VIRTUAL_MASTER, + to: VIRTUAL_MASTER, + amount, + }, + )]); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { + account: VIRTUAL_MASTER + })?, + amount + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { + account: virtual_addr + })?, + U256::ZERO + ); + + Ok(()) + }) + } + + #[test] + fn claim_blocked_mint() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_010u64)); + + let admin = Address::random(); + let receiver = Address::random(); + let amount = U256::from(144u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("T", "T", admin) + .with_issuer(admin) + .apply()?; + block_all_senders(receiver, Address::ZERO)?; + + let mut escrow = TIP1028Escrow::new(); + escrow.clear_emitted_events(); + token.mint( + admin, + ITIP20::mintCall { + to: receiver, + amount, + }, + )?; + escrow.assert_emitted_events(Vec::::new()); + assert_eq!(token.total_supply()?, amount); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + amount + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { account: receiver })?, + U256::ZERO + ); + + let receipt = receipt_v1( + Address::ZERO, + receiver, + 1_728_010, + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::MINT, + B256::ZERO, + ); + escrow.claim_blocked( + receiver, + claim_call(token.address(), Address::ZERO, &receipt, receiver), + )?; + + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + U256::ZERO + ); + assert_eq!( + token.balance_of(ITIP20::balanceOfCall { account: receiver })?, + amount + ); + + Ok(()) + }) + } +} From eed39a52e53725acc9249a400e07ce6107906b1a Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 23:09:00 -0400 Subject: [PATCH 28/32] test: tip20 tests --- crates/precompiles/src/tip20/mod.rs | 438 +++++++++++++++++++++++++++- 1 file changed, 437 insertions(+), 1 deletion(-) diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 293fddf609..1296fb413e 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -1488,7 +1488,7 @@ mod recipient_tests { #[cfg(test)] pub(crate) mod tests { use alloy::primitives::{Address, FixedBytes, IntoLogData, U256, hex}; - use tempo_contracts::precompiles::ITIP20Factory; + use tempo_contracts::precompiles::{ITIP20Factory, ITIP1028Escrow, TIP1028EscrowEvent}; use super::*; use crate::{ @@ -1565,6 +1565,442 @@ pub(crate) mod tests { }) } + mod tip1028_tests { + use super::*; + use crate::{ + tip403_registry::{ALLOW_ALL_POLICY_ID, REJECT_ALL_POLICY_ID}, + tip1028_escrow::BLOCKED_RECEIPT_VERSION, + }; + + const BLOCKED_AT: u64 = 1_728_100; + + fn set_receive_policy( + receiver: Address, + sender_policy_id: u64, + token_filter_id: u64, + recovery_address: Address, + ) -> Result<()> { + TIP403Registry::new().set_receive_policy( + receiver, + ITIP403Registry::setReceivePolicyCall { + senderPolicyId: sender_policy_id, + tokenFilterId: token_filter_id, + recoveryAddress: recovery_address, + }, + ) + } + + fn receipt_v1( + originator: Address, + recipient: Address, + blocked_nonce: u64, + blocked_reason: ITIP403Registry::BlockedReason, + kind: InboundKind, + memo: B256, + ) -> ITIP1028Escrow::ClaimReceiptV1 { + ITIP1028Escrow::ClaimReceiptV1 { + originator, + recipient, + blockedAt: BLOCKED_AT, + blockedNonce: blocked_nonce, + blockedReason: blocked_reason as u8, + kind, + memo, + } + } + + fn receipt_balance( + token: Address, + recovery_contract: Address, + receipt: &ITIP1028Escrow::ClaimReceiptV1, + ) -> Result { + TIP1028Escrow::new().blocked_receipt_balance( + ITIP1028Escrow::blockedReceiptBalanceCall { + token, + recoveryContract: recovery_contract, + receiptVersion: BLOCKED_RECEIPT_VERSION, + receipt: receipt.abi_encode().into(), + }, + ) + } + + #[test] + fn transfer_blocked_by_receive_policy_escrows_funds() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(BLOCKED_AT)); + let admin = Address::random(); + let sender = Address::random(); + let receiver = Address::random(); + let amount = U256::from(100u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin) + .with_issuer(admin) + .with_mint(sender, amount) + .clear_events() + .apply()?; + set_receive_policy( + receiver, + REJECT_ALL_POLICY_ID, + ALLOW_ALL_POLICY_ID, + Address::ZERO, + )?; + + token.transfer( + sender, + ITIP20::transferCall { + to: receiver, + amount, + }, + )?; + + assert_eq!(token.get_balance(sender)?, U256::ZERO); + assert_eq!(token.get_balance(receiver)?, U256::ZERO); + assert_eq!(token.get_balance(ESCROW_ADDRESS)?, amount); + token.assert_emitted_events(vec![TIP20Event::Transfer(ITIP20::Transfer { + from: sender, + to: ESCROW_ADDRESS, + amount, + })]); + + let receipt = receipt_v1( + sender, + receiver, + 1, + ITIP403Registry::BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + assert_eq!( + receipt_balance(token.address, Address::ZERO, &receipt)?, + amount + ); + TIP1028Escrow::new().assert_emitted_events(vec![ + TIP1028EscrowEvent::TransferBlocked(ITIP1028Escrow::TransferBlocked { + token: token.address, + from: sender, + receiver, + receiptVersion: BLOCKED_RECEIPT_VERSION, + blockedNonce: 1, + blockedAt: BLOCKED_AT, + recipient: receiver, + amount, + blockedReason: ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8, + recoveryContract: Address::ZERO, + memo: B256::ZERO, + }), + ]); + + Ok(()) + }) + } + + #[test] + fn transfer_blocked_by_token_filter_records_reason() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(BLOCKED_AT)); + let admin = Address::random(); + let sender = Address::random(); + let receiver = Address::random(); + let amount = U256::from(40u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin) + .with_issuer(admin) + .with_mint(sender, amount) + .apply()?; + set_receive_policy( + receiver, + ALLOW_ALL_POLICY_ID, + REJECT_ALL_POLICY_ID, + Address::ZERO, + )?; + + token.transfer( + sender, + ITIP20::transferCall { + to: receiver, + amount, + }, + )?; + + let receipt = receipt_v1( + sender, + receiver, + 1, + ITIP403Registry::BlockedReason::TOKEN_FILTER, + InboundKind::TRANSFER, + B256::ZERO, + ); + assert_eq!( + receipt_balance(token.address, Address::ZERO, &receipt)?, + amount + ); + TIP1028Escrow::new().assert_emitted_events(vec![ + TIP1028EscrowEvent::TransferBlocked(ITIP1028Escrow::TransferBlocked { + token: token.address, + from: sender, + receiver, + receiptVersion: BLOCKED_RECEIPT_VERSION, + blockedNonce: 1, + blockedAt: BLOCKED_AT, + recipient: receiver, + amount, + blockedReason: ITIP403Registry::BlockedReason::TOKEN_FILTER as u8, + recoveryContract: Address::ZERO, + memo: B256::ZERO, + }), + ]); + + Ok(()) + }) + } + + #[test] + fn transfer_to_escrow_address_rejects() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let admin = Address::random(); + let sender = Address::random(); + let amount = U256::from(10u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin) + .with_issuer(admin) + .with_mint(sender, amount) + .apply()?; + + let result = token.transfer( + sender, + ITIP20::transferCall { + to: ESCROW_ADDRESS, + amount, + }, + ); + assert!(matches!( + result, + Err(e) if e == TIP1028EscrowError::escrow_address_reserved().into() + )); + assert_eq!(token.get_balance(sender)?, amount); + assert_eq!(token.get_balance(ESCROW_ADDRESS)?, U256::ZERO); + + Ok(()) + }) + } + + #[test] + fn pre_t6_receive_policy_does_not_escrow() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + storage.set_timestamp(U256::from(BLOCKED_AT)); + let admin = Address::random(); + let sender = Address::random(); + let receiver = Address::random(); + let amount = U256::from(25u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin) + .with_issuer(admin) + .with_mint(sender, amount) + .clear_events() + .apply()?; + set_receive_policy( + receiver, + REJECT_ALL_POLICY_ID, + ALLOW_ALL_POLICY_ID, + Address::ZERO, + )?; + + token.transfer( + sender, + ITIP20::transferCall { + to: receiver, + amount, + }, + )?; + + assert_eq!(token.get_balance(sender)?, U256::ZERO); + assert_eq!(token.get_balance(receiver)?, amount); + assert_eq!(token.get_balance(ESCROW_ADDRESS)?, U256::ZERO); + token.assert_emitted_events(vec![TIP20Event::Transfer(ITIP20::Transfer { + from: sender, + to: receiver, + amount, + })]); + assert_eq!( + receipt_balance( + token.address, + Address::ZERO, + &receipt_v1( + sender, + receiver, + 1, + ITIP403Registry::BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ), + )?, + U256::ZERO + ); + + Ok(()) + }) + } + + #[test] + fn transfer_from_blocked_consumes_allowance() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(BLOCKED_AT)); + let admin = Address::random(); + let owner = Address::random(); + let spender = Address::random(); + let receiver = Address::random(); + let amount = U256::from(30u64); + let allowance = amount + U256::from(5u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin) + .with_issuer(admin) + .with_mint(owner, amount) + .with_approval(owner, spender, allowance) + .apply()?; + set_receive_policy( + receiver, + REJECT_ALL_POLICY_ID, + ALLOW_ALL_POLICY_ID, + Address::ZERO, + )?; + + token.transfer_from( + spender, + ITIP20::transferFromCall { + from: owner, + to: receiver, + amount, + }, + )?; + + assert_eq!( + token.allowance(ITIP20::allowanceCall { owner, spender })?, + allowance - amount + ); + assert_eq!(token.get_balance(owner)?, U256::ZERO); + assert_eq!(token.get_balance(receiver)?, U256::ZERO); + assert_eq!(token.get_balance(ESCROW_ADDRESS)?, amount); + + Ok(()) + }) + } + + #[test] + fn transfer_with_memo_blocked_preserves_memo() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(BLOCKED_AT)); + let admin = Address::random(); + let sender = Address::random(); + let receiver = Address::random(); + let amount = U256::from(55u64); + let memo = B256::repeat_byte(0xab); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin) + .with_issuer(admin) + .with_mint(sender, amount) + .clear_events() + .apply()?; + set_receive_policy( + receiver, + REJECT_ALL_POLICY_ID, + ALLOW_ALL_POLICY_ID, + Address::ZERO, + )?; + + token.transfer_with_memo( + sender, + ITIP20::transferWithMemoCall { + to: receiver, + amount, + memo, + }, + )?; + + token.assert_emitted_events(vec![TIP20Event::Transfer(ITIP20::Transfer { + from: sender, + to: ESCROW_ADDRESS, + amount, + })]); + let receipt = receipt_v1( + sender, + receiver, + 1, + ITIP403Registry::BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + memo, + ); + assert_eq!( + receipt_balance(token.address, Address::ZERO, &receipt)?, + amount + ); + + Ok(()) + }) + } + + #[test] + fn mint_blocked_credits_escrow() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(BLOCKED_AT)); + let admin = Address::random(); + let receiver = Address::random(); + let amount = U256::from(70u64); + + StorageCtx::enter(&mut storage, || { + let mut token = TIP20Setup::create("Test", "TST", admin) + .with_issuer(admin) + .clear_events() + .apply()?; + set_receive_policy( + receiver, + REJECT_ALL_POLICY_ID, + ALLOW_ALL_POLICY_ID, + Address::ZERO, + )?; + + TIP1028Escrow::new().clear_emitted_events(); + token.mint( + admin, + ITIP20::mintCall { + to: receiver, + amount, + }, + )?; + + assert_eq!(token.total_supply()?, amount); + assert_eq!(token.get_balance(receiver)?, U256::ZERO); + assert_eq!(token.get_balance(ESCROW_ADDRESS)?, amount); + token.assert_emitted_events(vec![TIP20Event::Transfer(ITIP20::Transfer { + from: Address::ZERO, + to: ESCROW_ADDRESS, + amount, + })]); + TIP1028Escrow::new().assert_emitted_events(Vec::::new()); + + let receipt = receipt_v1( + Address::ZERO, + receiver, + 1, + ITIP403Registry::BlockedReason::RECEIVE_POLICY, + InboundKind::MINT, + B256::ZERO, + ); + assert_eq!( + receipt_balance(token.address, Address::ZERO, &receipt)?, + amount + ); + + Ok(()) + }) + } + } + #[test] fn test_transfer_insufficient_balance_fails() -> eyre::Result<()> { let (mut storage, admin) = setup_storage(); From c3b5f75778de927adb8a6d3dd2e9f0dfd7c72ecc Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Wed, 6 May 2026 23:18:16 -0400 Subject: [PATCH 29/32] test: update 403 and tip20 tests --- crates/precompiles/src/tip1028_escrow/mod.rs | 26 +-- crates/precompiles/src/tip20/mod.rs | 14 +- .../src/tip403_registry/dispatch.rs | 57 ++++- crates/precompiles/src/tip403_registry/mod.rs | 214 ++++++++++++++++++ 4 files changed, 289 insertions(+), 22 deletions(-) diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs index 840c97199f..89c4e952f5 100644 --- a/crates/precompiles/src/tip1028_escrow/mod.rs +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -322,7 +322,7 @@ mod tests { } #[test] - fn receipt_balance_store_and_claim() -> eyre::Result<()> { + fn test_receipt_balance_store_and_claim() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(1_728_000u64)); @@ -406,7 +406,7 @@ mod tests { } #[test] - fn receipt_rejects_bad_encoding() -> eyre::Result<()> { + fn test_receipt_rejects_bad_encoding() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); let admin = Address::random(); @@ -445,7 +445,7 @@ mod tests { } #[test] - fn store_rejects_invalid_metadata() -> eyre::Result<()> { + fn test_store_rejects_invalid_metadata() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); let admin = Address::random(); @@ -479,7 +479,7 @@ mod tests { } #[test] - fn store_emits_transfer_blocked_for_transfers() -> eyre::Result<()> { + fn test_store_emits_transfer_blocked_for_transfers() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(1_728_001u64)); @@ -542,7 +542,7 @@ mod tests { } #[test] - fn receipt_key_binds_receipt_fields() -> eyre::Result<()> { + fn test_receipt_key_binds_receipt_fields() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(1_728_002u64)); @@ -697,7 +697,7 @@ mod tests { } #[test] - fn claim_rejects_missing_receipt() -> eyre::Result<()> { + fn test_claim_rejects_missing_receipt() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(1_728_003u64)); @@ -748,7 +748,7 @@ mod tests { } #[test] - fn claim_requires_authorized_caller() -> eyre::Result<()> { + fn test_claim_requires_authorized_caller() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(1_728_004u64)); @@ -842,7 +842,7 @@ mod tests { } #[test] - fn claim_self_recovery() -> eyre::Result<()> { + fn test_claim_self_recovery() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(1_728_005u64)); @@ -917,7 +917,7 @@ mod tests { } #[test] - fn claim_via_recovery_contract() -> eyre::Result<()> { + fn test_claim_via_recovery_contract() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(1_728_006u64)); @@ -995,7 +995,7 @@ mod tests { } #[test] - fn claim_rolls_back_on_release_error() -> eyre::Result<()> { + fn test_claim_rolls_back_on_release_error() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(1_728_007u64)); @@ -1065,7 +1065,7 @@ mod tests { } #[test] - fn claim_binds_recovery_contract() -> eyre::Result<()> { + fn test_claim_binds_recovery_contract() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(1_728_008u64)); @@ -1135,7 +1135,7 @@ mod tests { } #[test] - fn claim_virtual_recipient() -> eyre::Result<()> { + fn test_claim_virtual_recipient() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(1_728_009u64)); @@ -1207,7 +1207,7 @@ mod tests { } #[test] - fn claim_blocked_mint() -> eyre::Result<()> { + fn test_claim_blocked_mint() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(1_728_010u64)); diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 1296fb413e..dbe0f0a86c 100644 --- a/crates/precompiles/src/tip20/mod.rs +++ b/crates/precompiles/src/tip20/mod.rs @@ -1625,7 +1625,7 @@ pub(crate) mod tests { } #[test] - fn transfer_blocked_by_receive_policy_escrows_funds() -> eyre::Result<()> { + fn test_transfer_blocked_by_receive_policy_escrows_funds() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(BLOCKED_AT)); let admin = Address::random(); @@ -1696,7 +1696,7 @@ pub(crate) mod tests { } #[test] - fn transfer_blocked_by_token_filter_records_reason() -> eyre::Result<()> { + fn test_transfer_blocked_by_token_filter_records_reason() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(BLOCKED_AT)); let admin = Address::random(); @@ -1757,7 +1757,7 @@ pub(crate) mod tests { } #[test] - fn transfer_to_escrow_address_rejects() -> eyre::Result<()> { + fn test_transfer_to_escrow_address_rejects() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); let admin = Address::random(); let sender = Address::random(); @@ -1788,7 +1788,7 @@ pub(crate) mod tests { } #[test] - fn pre_t6_receive_policy_does_not_escrow() -> eyre::Result<()> { + fn test_pre_t6_receive_policy_does_not_escrow() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); storage.set_timestamp(U256::from(BLOCKED_AT)); let admin = Address::random(); @@ -1846,7 +1846,7 @@ pub(crate) mod tests { } #[test] - fn transfer_from_blocked_consumes_allowance() -> eyre::Result<()> { + fn test_transfer_from_blocked_consumes_allowance() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(BLOCKED_AT)); let admin = Address::random(); @@ -1891,7 +1891,7 @@ pub(crate) mod tests { } #[test] - fn transfer_with_memo_blocked_preserves_memo() -> eyre::Result<()> { + fn test_transfer_with_memo_blocked_preserves_memo() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(BLOCKED_AT)); let admin = Address::random(); @@ -1945,7 +1945,7 @@ pub(crate) mod tests { } #[test] - fn mint_blocked_credits_escrow() -> eyre::Result<()> { + fn test_mint_blocked_credits_escrow() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); storage.set_timestamp(U256::from(BLOCKED_AT)); let admin = Address::random(); diff --git a/crates/precompiles/src/tip403_registry/dispatch.rs b/crates/precompiles/src/tip403_registry/dispatch.rs index af2681825f..676abaa80f 100644 --- a/crates/precompiles/src/tip403_registry/dispatch.rs +++ b/crates/precompiles/src/tip403_registry/dispatch.rs @@ -109,9 +109,11 @@ mod tests { test_util::{assert_full_coverage, check_selector_coverage}, tip403_registry::ITIP403Registry, }; - use alloy::sol_types::{SolCall, SolValue}; + use alloy::sol_types::{SolCall, SolError, SolValue}; use tempo_chainspec::hardfork::TempoHardfork; - use tempo_contracts::precompiles::ITIP403Registry::ITIP403RegistryCalls; + use tempo_contracts::precompiles::{ + ITIP403Registry::ITIP403RegistryCalls, UnknownFunctionSelector, + }; #[test] fn test_is_authorized_precompile() -> eyre::Result<()> { @@ -573,4 +575,55 @@ mod tests { Ok(()) }) } + + #[test] + fn test_receive_policy_selectors_are_t6_gated() -> eyre::Result<()> { + let account = Address::random(); + let receive_policy = ITIP403Registry::receivePolicyCall { account }.abi_encode(); + let validate_receive_policy = ITIP403Registry::validateReceivePolicyCall { + token: Address::random(), + sender: Address::random(), + receiver: account, + } + .abi_encode(); + let set_receive_policy = ITIP403Registry::setReceivePolicyCall { + senderPolicyId: 1, + tokenFilterId: 1, + recoveryAddress: Address::ZERO, + } + .abi_encode(); + + for calldata in [ + receive_policy.as_slice(), + validate_receive_policy.as_slice(), + set_receive_policy.as_slice(), + ] { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + StorageCtx::enter(&mut storage, || -> eyre::Result<()> { + let mut registry = TIP403Registry::new(); + let result = registry + .call(calldata, account) + .map_err(|err| eyre::eyre!("{err:?}"))?; + assert!(result.is_revert()); + assert!(UnknownFunctionSelector::abi_decode(&result.bytes).is_ok()); + Ok(()) + })?; + } + + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + StorageCtx::enter(&mut storage, || -> eyre::Result<()> { + let mut registry = TIP403Registry::new(); + for calldata in [ + receive_policy.as_slice(), + validate_receive_policy.as_slice(), + set_receive_policy.as_slice(), + ] { + let result = registry + .call(calldata, account) + .map_err(|err| eyre::eyre!("{err:?}"))?; + assert!(!result.is_revert()); + } + Ok(()) + }) + } } diff --git a/crates/precompiles/src/tip403_registry/mod.rs b/crates/precompiles/src/tip403_registry/mod.rs index 0672ce7531..fc634850eb 100644 --- a/crates/precompiles/src/tip403_registry/mod.rs +++ b/crates/precompiles/src/tip403_registry/mod.rs @@ -1043,6 +1043,220 @@ mod tests { Ok(()) } + #[test] + fn test_receive_policy_defaults_to_none() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let account = Address::random(); + StorageCtx::enter(&mut storage, || { + let registry = TIP403Registry::new(); + + let policy = registry.receive_policy(ITIP403Registry::receivePolicyCall { account })?; + assert!(!policy.hasReceivePolicy); + assert_eq!(policy.senderPolicyId, REJECT_ALL_POLICY_ID); + assert_eq!( + policy.senderPolicyType, + ITIP403Registry::PolicyType::WHITELIST + ); + assert_eq!(policy.tokenFilterId, REJECT_ALL_POLICY_ID); + assert_eq!( + policy.tokenFilterType, + ITIP403Registry::PolicyType::WHITELIST + ); + assert_eq!(policy.recoveryAddress, Address::ZERO); + + assert_eq!( + registry.validate_receive_policy(Address::random(), Address::random(), account)?, + None + ); + assert_eq!(registry.receive_policy_recovery(account)?, Address::ZERO); + + Ok(()) + }) + } + + #[test] + fn test_set_receive_policy_stores_config() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let account = Address::random(); + let recovery = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut registry = TIP403Registry::new(); + + registry.set_receive_policy( + account, + ITIP403Registry::setReceivePolicyCall { + senderPolicyId: REJECT_ALL_POLICY_ID, + tokenFilterId: ALLOW_ALL_POLICY_ID, + recoveryAddress: recovery, + }, + )?; + + let policy = registry.receive_policy(ITIP403Registry::receivePolicyCall { account })?; + assert!(policy.hasReceivePolicy); + assert_eq!(policy.senderPolicyId, REJECT_ALL_POLICY_ID); + assert_eq!( + policy.senderPolicyType, + ITIP403Registry::PolicyType::WHITELIST + ); + assert_eq!(policy.tokenFilterId, ALLOW_ALL_POLICY_ID); + assert_eq!( + policy.tokenFilterType, + ITIP403Registry::PolicyType::BLACKLIST + ); + assert_eq!(policy.recoveryAddress, recovery); + assert_eq!(registry.receive_policy_recovery(account)?, recovery); + + registry.assert_emitted_events(vec![TIP403RegistryEvent::ReceivePolicyUpdated( + ITIP403Registry::ReceivePolicyUpdated { + account, + senderPolicyId: REJECT_ALL_POLICY_ID, + tokenFilterId: ALLOW_ALL_POLICY_ID, + recoveryAddress: recovery, + }, + )]); + + Ok(()) + }) + } + + #[test] + fn test_set_receive_policy_rejects_invalid_account() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + StorageCtx::enter(&mut storage, || { + let mut registry = TIP403Registry::new(); + + let escrow_result = registry.set_receive_policy( + ESCROW_ADDRESS, + ITIP403Registry::setReceivePolicyCall { + senderPolicyId: REJECT_ALL_POLICY_ID, + tokenFilterId: ALLOW_ALL_POLICY_ID, + recoveryAddress: Address::ZERO, + }, + ); + assert!(matches!( + escrow_result, + Err(TempoPrecompileError::TIP403RegistryError( + TIP403RegistryError::InvalidReceivePolicyAddress(_) + )) + )); + + let virtual_result = registry.set_receive_policy( + Address::new_virtual(MasterId::ZERO, UserTag::ZERO), + ITIP403Registry::setReceivePolicyCall { + senderPolicyId: REJECT_ALL_POLICY_ID, + tokenFilterId: ALLOW_ALL_POLICY_ID, + recoveryAddress: Address::ZERO, + }, + ); + assert!(matches!( + virtual_result, + Err(TempoPrecompileError::TIP403RegistryError( + TIP403RegistryError::VirtualAddressNotAllowed(_) + )) + )); + + Ok(()) + }) + } + + #[test] + fn test_set_receive_policy_rejects_invalid_policy() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let account = Address::random(); + let creator = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut registry = TIP403Registry::new(); + + let missing_result = registry.set_receive_policy( + account, + ITIP403Registry::setReceivePolicyCall { + senderPolicyId: 99, + tokenFilterId: ALLOW_ALL_POLICY_ID, + recoveryAddress: Address::ZERO, + }, + ); + assert!(matches!( + missing_result, + Err(TempoPrecompileError::TIP403RegistryError( + TIP403RegistryError::PolicyNotFound(_) + )) + )); + + let compound_id = registry.create_compound_policy( + creator, + ITIP403Registry::createCompoundPolicyCall { + senderPolicyId: REJECT_ALL_POLICY_ID, + recipientPolicyId: ALLOW_ALL_POLICY_ID, + mintRecipientPolicyId: ALLOW_ALL_POLICY_ID, + }, + )?; + let compound_result = registry.set_receive_policy( + account, + ITIP403Registry::setReceivePolicyCall { + senderPolicyId: compound_id, + tokenFilterId: ALLOW_ALL_POLICY_ID, + recoveryAddress: Address::ZERO, + }, + ); + assert!(matches!( + compound_result, + Err(TempoPrecompileError::TIP403RegistryError( + TIP403RegistryError::InvalidReceivePolicyType(_) + )) + )); + + Ok(()) + }) + } + + #[test] + fn test_validate_receive_policy_reports_token_filter_first() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let receiver = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut registry = TIP403Registry::new(); + registry.set_receive_policy( + receiver, + ITIP403Registry::setReceivePolicyCall { + senderPolicyId: REJECT_ALL_POLICY_ID, + tokenFilterId: REJECT_ALL_POLICY_ID, + recoveryAddress: Address::ZERO, + }, + )?; + + assert_eq!( + registry.validate_receive_policy(Address::random(), Address::random(), receiver)?, + Some(ITIP403Registry::BlockedReason::TOKEN_FILTER) + ); + + Ok(()) + }) + } + + #[test] + fn test_validate_receive_policy_reports_sender_policy() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + let receiver = Address::random(); + StorageCtx::enter(&mut storage, || { + let mut registry = TIP403Registry::new(); + registry.set_receive_policy( + receiver, + ITIP403Registry::setReceivePolicyCall { + senderPolicyId: REJECT_ALL_POLICY_ID, + tokenFilterId: ALLOW_ALL_POLICY_ID, + recoveryAddress: Address::ZERO, + }, + )?; + + assert_eq!( + registry.validate_receive_policy(Address::random(), Address::random(), receiver)?, + Some(ITIP403Registry::BlockedReason::RECEIVE_POLICY) + ); + + Ok(()) + }) + } + #[test] fn test_policy_exists() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new(1); From cfe48f54889188b4622996cc68a79be0da8a4d21 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Thu, 7 May 2026 00:38:31 -0400 Subject: [PATCH 30/32] test: invariant tests --- crates/e2e/src/tests/mod.rs | 1 + crates/e2e/src/tests/tip1028_escrow.rs | 348 +++++++++++++++++++ crates/precompiles/src/tip1028_escrow/mod.rs | 132 +++++++ 3 files changed, 481 insertions(+) create mode 100644 crates/e2e/src/tests/tip1028_escrow.rs diff --git a/crates/e2e/src/tests/mod.rs b/crates/e2e/src/tests/mod.rs index 5c27642c13..3549059492 100644 --- a/crates/e2e/src/tests/mod.rs +++ b/crates/e2e/src/tests/mod.rs @@ -18,6 +18,7 @@ mod snapshot; // FIXME: subblocks are currently flaky. // mod subblocks; mod sync; +mod tip1028_escrow; mod v4_at_genesis; #[test_traced] diff --git a/crates/e2e/src/tests/tip1028_escrow.rs b/crates/e2e/src/tests/tip1028_escrow.rs new file mode 100644 index 0000000000..86691d23cb --- /dev/null +++ b/crates/e2e/src/tests/tip1028_escrow.rs @@ -0,0 +1,348 @@ +use std::future::Future; + +use alloy::{ + primitives::{Address, B256, Bytes, U256}, + providers::{Provider, ProviderBuilder}, + rpc::types::TransactionReceipt, + signers::local::{MnemonicBuilder, PrivateKeySigner}, + sol_types::{SolEvent, SolValue}, + transports::http::reqwest::Url, +}; +use commonware_macros::test_traced; +use commonware_runtime::{ + Runner as _, + deterministic::{Config, Runner}, +}; +use eyre::OptionExt as _; +use futures::future::join_all; +use tempo_chainspec::spec::TEMPO_T1_BASE_FEE; +use tempo_precompiles::{ + ESCROW_ADDRESS, PATH_USD_ADDRESS, TIP20_FACTORY_ADDRESS, TIP403_REGISTRY_ADDRESS, + tip20::{IRolesAuth, ISSUER_ROLE, ITIP20}, + tip20_factory::ITIP20Factory, + tip403_registry::{ALLOW_ALL_POLICY_ID, ITIP403Registry, REJECT_ALL_POLICY_ID}, + tip1028_escrow::{ + BLOCKED_RECEIPT_VERSION, + ITIP1028Escrow::{self, ITIP1028EscrowErrors as TIP1028EscrowError}, + InboundKind, + }, +}; + +use crate::{Setup, execution_runtime::TEST_MNEMONIC, setup_validators}; + +const GAS: u64 = 5_000_000; +const GAS_PRICE: u128 = TEMPO_T1_BASE_FEE as u128; + +struct BlockedTransfer { + token: Address, + receiver: Address, + receipt: Bytes, +} + +#[test_traced] +fn test_escrow_claim_no_recovery() { + run_escrow_test(1028, |http_url| async move { + let amount = U256::from(250); + let blocked = create_blocked_transfer( + http_url.clone(), + 10, + 11, + Address::ZERO, + B256::from([0x01; 32]), + amount, + ) + .await?; + + let other_wallet = wallet(12)?; + let other = other_wallet.address(); + let other_provider = ProviderBuilder::new() + .wallet(other_wallet) + .connect_http(http_url.clone()); + let other_escrow = ITIP1028Escrow::new(ESCROW_ADDRESS, other_provider); + let Err(result) = other_escrow + .claimBlocked( + blocked.token, + Address::ZERO, + BLOCKED_RECEIPT_VERSION, + blocked.receipt.clone(), + other, + ) + .call() + .await + else { + panic!("expected recovery claim without recovery address to fail"); + }; + assert_eq!( + result.as_decoded_interface_error::(), + Some(TIP1028EscrowError::unauthorized_claimer()) + ); + assert_eq!( + token_view(http_url.clone(), blocked.token) + .balanceOf(ESCROW_ADDRESS) + .call() + .await?, + amount + ); + + let receiver_wallet = wallet(11)?; + let receiver_provider = ProviderBuilder::new() + .wallet(receiver_wallet) + .connect_http(http_url.clone()); + let receiver_escrow = ITIP1028Escrow::new(ESCROW_ADDRESS, receiver_provider); + let claim = receiver_escrow + .claimBlocked( + blocked.token, + Address::ZERO, + BLOCKED_RECEIPT_VERSION, + blocked.receipt.clone(), + blocked.receiver, + ) + .gas(GAS) + .gas_price(GAS_PRICE) + .send() + .await? + .get_receipt() + .await?; + assert!(claim.status()); + + let token = token_view(http_url, blocked.token); + assert_eq!(token.balanceOf(blocked.receiver).call().await?, amount); + assert_eq!(token.balanceOf(ESCROW_ADDRESS).call().await?, U256::ZERO); + + Ok(()) + }); +} + +#[test_traced] +fn test_escrow_claim_with_recovery() { + run_escrow_test(1029, |http_url| async move { + let amount = U256::from(400); + let recovery = wallet(22)?.address(); + let destination = wallet(23)?.address(); + let blocked = create_blocked_transfer( + http_url.clone(), + 20, + 21, + recovery, + B256::from([0x02; 32]), + amount, + ) + .await?; + + let recovery_provider = ProviderBuilder::new() + .wallet(wallet(22)?) + .connect_http(http_url.clone()); + let recovery_escrow = ITIP1028Escrow::new(ESCROW_ADDRESS, recovery_provider); + let claim = recovery_escrow + .claimBlocked( + blocked.token, + recovery, + BLOCKED_RECEIPT_VERSION, + blocked.receipt, + destination, + ) + .gas(GAS) + .gas_price(GAS_PRICE) + .send() + .await? + .get_receipt() + .await?; + assert!(claim.status()); + + let token = token_view(http_url, blocked.token); + assert_eq!(token.balanceOf(blocked.receiver).call().await?, U256::ZERO); + assert_eq!(token.balanceOf(destination).call().await?, amount); + assert_eq!(token.balanceOf(ESCROW_ADDRESS).call().await?, U256::ZERO); + + Ok(()) + }); +} + +fn run_escrow_test(seed: u64, test: F) +where + F: FnOnce(Url) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + let _ = tempo_eyre::install(); + + Runner::from(Config::default().with_seed(seed)).start(|mut context| async move { + let setup = Setup::new() + .how_many_signers(1) + .epoch_length(100) + .seed(seed); + let (mut nodes, execution_runtime) = setup_validators(&mut context, setup).await; + join_all(nodes.iter_mut().map(|node| node.start(&context))).await; + + let http_url = nodes[0] + .execution() + .rpc_server_handle() + .http_url() + .unwrap() + .parse() + .unwrap(); + + execution_runtime + .run_async(test(http_url)) + .await + .unwrap() + .unwrap(); + }); +} + +async fn create_blocked_transfer( + http_url: Url, + sender_index: u32, + receiver_index: u32, + recovery: Address, + salt: B256, + amount: U256, +) -> eyre::Result { + let admin_wallet = wallet(0)?; + let admin = admin_wallet.address(); + let admin_provider = ProviderBuilder::new() + .wallet(admin_wallet) + .connect_http(http_url.clone()); + let token = create_token(admin_provider.clone(), admin, salt).await?; + + let receiver_wallet = wallet(receiver_index)?; + let receiver = receiver_wallet.address(); + let receiver_provider = ProviderBuilder::new() + .wallet(receiver_wallet) + .connect_http(http_url.clone()); + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, receiver_provider); + registry + .setReceivePolicy(REJECT_ALL_POLICY_ID, ALLOW_ALL_POLICY_ID, recovery) + .gas(GAS) + .gas_price(GAS_PRICE) + .send() + .await? + .get_receipt() + .await?; + + let sender_wallet = wallet(sender_index)?; + let sender = sender_wallet.address(); + let token_admin = ITIP20::new(token, admin_provider); + token_admin + .mint(sender, amount) + .gas(GAS) + .gas_price(GAS_PRICE) + .send() + .await? + .get_receipt() + .await?; + + let sender_provider = ProviderBuilder::new() + .wallet(sender_wallet) + .connect_http(http_url.clone()); + let sender_token = ITIP20::new(token, sender_provider); + let transfer = sender_token + .transfer(receiver, amount) + .gas(GAS) + .gas_price(GAS_PRICE) + .send() + .await? + .get_receipt() + .await?; + assert!(transfer.status()); + + let blocked = transfer_blocked(&transfer)?; + assert_eq!(blocked.token, token); + assert_eq!(blocked.from, sender); + assert_eq!(blocked.receiver, receiver); + assert_eq!(blocked.recipient, receiver); + assert_eq!(blocked.recoveryContract, recovery); + assert_eq!(blocked.amount, amount); + assert_eq!( + blocked.blockedReason, + ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8 + ); + + let receipt = ITIP1028Escrow::ClaimReceiptV1 { + originator: blocked.from, + recipient: blocked.recipient, + blockedAt: blocked.blockedAt, + blockedNonce: blocked.blockedNonce, + blockedReason: blocked.blockedReason, + kind: InboundKind::TRANSFER, + memo: blocked.memo, + } + .abi_encode() + .into(); + + let token_view = token_view(http_url.clone(), token); + assert_eq!(token_view.balanceOf(sender).call().await?, U256::ZERO); + assert_eq!(token_view.balanceOf(receiver).call().await?, U256::ZERO); + assert_eq!(token_view.balanceOf(ESCROW_ADDRESS).call().await?, amount); + + let blocked = BlockedTransfer { + token, + receiver, + receipt, + }; + + Ok(blocked) +} + +async fn create_token

(provider: P, admin: Address, salt: B256) -> eyre::Result

+where + P: Provider + Clone, +{ + let factory = ITIP20Factory::new(TIP20_FACTORY_ADDRESS, provider.clone()); + let receipt = factory + .createToken( + "Escrow Test".to_string(), + "ETEST".to_string(), + "USD".to_string(), + PATH_USD_ADDRESS, + admin, + salt, + ) + .gas(GAS) + .gas_price(GAS_PRICE) + .send() + .await? + .get_receipt() + .await?; + assert!(receipt.status()); + + let created = receipt + .logs() + .iter() + .filter_map(|log| ITIP20Factory::TokenCreated::decode_log(&log.inner).ok()) + .next() + .ok_or_eyre("TokenCreated event missing")?; + let token = created.token; + + let roles = IRolesAuth::new(token, provider); + let grant = roles + .grantRole(*ISSUER_ROLE, admin) + .gas(GAS) + .gas_price(GAS_PRICE) + .send() + .await? + .get_receipt() + .await?; + assert!(grant.status()); + + Ok(token) +} + +fn token_view(http_url: Url, token: Address) -> ITIP20::ITIP20Instance { + ITIP20::new(token, ProviderBuilder::new().connect_http(http_url)) +} + +fn transfer_blocked(receipt: &TransactionReceipt) -> eyre::Result { + receipt + .logs() + .iter() + .filter_map(|log| ITIP1028Escrow::TransferBlocked::decode_log(&log.inner).ok()) + .map(|event| event.data) + .next() + .ok_or_eyre("TransferBlocked event missing") +} + +fn wallet(index: u32) -> eyre::Result { + Ok(MnemonicBuilder::from_phrase(TEST_MNEMONIC) + .index(index)? + .build()?) +} diff --git a/crates/precompiles/src/tip1028_escrow/mod.rs b/crates/precompiles/src/tip1028_escrow/mod.rs index 89c4e952f5..5541121327 100644 --- a/crates/precompiles/src/tip1028_escrow/mod.rs +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -405,6 +405,138 @@ mod tests { }) } + #[test] + fn test_escrow_balance_matches_open_receipts() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_728_001u64)); + + let admin = Address::random(); + let originator = Address::random(); + let receiver_a = Address::random(); + let receiver_b = Address::random(); + let receiver_c = Address::random(); + let recovery = Address::random(); + let amount_a = U256::from(30u64); + let amount_b = U256::from(45u64); + let amount_c = U256::from(70u64); + + StorageCtx::enter(&mut storage, || { + let mut token_a = TIP20Setup::create("A", "A", admin) + .with_issuer(admin) + .with_mint(originator, amount_a + amount_b) + .apply()?; + let mut token_b = TIP20Setup::create("B", "B", admin) + .with_issuer(admin) + .with_mint(originator, amount_c) + .apply()?; + + block_all_senders(receiver_a, Address::ZERO)?; + block_all_senders(receiver_b, recovery)?; + block_all_senders(receiver_c, Address::ZERO)?; + + token_a.transfer( + originator, + ITIP20::transferCall { + to: receiver_a, + amount: amount_a, + }, + )?; + token_a.transfer( + originator, + ITIP20::transferCall { + to: receiver_b, + amount: amount_b, + }, + )?; + token_b.transfer( + originator, + ITIP20::transferCall { + to: receiver_c, + amount: amount_c, + }, + )?; + + let receipt_a = receipt_v1( + originator, + receiver_a, + 1_728_001, + 1, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + let receipt_b = receipt_v1( + originator, + receiver_b, + 1_728_001, + 2, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + let receipt_c = receipt_v1( + originator, + receiver_c, + 1_728_001, + 3, + BlockedReason::RECEIVE_POLICY, + InboundKind::TRANSFER, + B256::ZERO, + ); + + let escrow = TIP1028Escrow::new(); + assert_eq!( + token_a.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + receipt_balance(&escrow, token_a.address(), Address::ZERO, &receipt_a)? + + receipt_balance(&escrow, token_a.address(), recovery, &receipt_b)? + ); + assert_eq!( + token_b.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + receipt_balance(&escrow, token_b.address(), Address::ZERO, &receipt_c)? + ); + + TIP1028Escrow::new().claim_blocked( + receiver_a, + claim_call(token_a.address(), Address::ZERO, &receipt_a, receiver_a), + )?; + assert_eq!( + token_a.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + receipt_balance(&escrow, token_a.address(), recovery, &receipt_b)? + ); + assert_eq!( + token_b.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + receipt_balance(&escrow, token_b.address(), Address::ZERO, &receipt_c)? + ); + + TIP1028Escrow::new().claim_blocked( + recovery, + claim_call(token_a.address(), recovery, &receipt_b, receiver_b), + )?; + assert_eq!( + token_a.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + U256::ZERO + ); + assert_eq!( + token_b.balance_of(ITIP20::balanceOfCall { + account: ESCROW_ADDRESS + })?, + receipt_balance(&escrow, token_b.address(), Address::ZERO, &receipt_c)? + ); + + Ok(()) + }) + } + #[test] fn test_receipt_rejects_bad_encoding() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); From 5914c6704c85dc590649e78660f350c66db5c77f Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Thu, 7 May 2026 01:27:09 -0400 Subject: [PATCH 31/32] fix: deploy escrow at t6 block, update genesis args, fix tests --- crates/e2e/src/tests/tip1028_escrow.rs | 19 ++- crates/evm/src/block.rs | 35 +++-- crates/node/tests/it/eth_call.rs | 4 +- ...transaction__gas_estimation_snapshots.snap | 124 +++++++++--------- xtask/src/genesis_args.rs | 19 +++ 5 files changed, 122 insertions(+), 79 deletions(-) diff --git a/crates/e2e/src/tests/tip1028_escrow.rs b/crates/e2e/src/tests/tip1028_escrow.rs index 86691d23cb..0a1aabbcea 100644 --- a/crates/e2e/src/tests/tip1028_escrow.rs +++ b/crates/e2e/src/tests/tip1028_escrow.rs @@ -103,7 +103,7 @@ fn test_escrow_claim_no_recovery() { .await? .get_receipt() .await?; - assert!(claim.status()); + assert!(claim.status(), "claim receipt: {claim:#?}"); let token = token_view(http_url, blocked.token); assert_eq!(token.balanceOf(blocked.receiver).call().await?, amount); @@ -147,7 +147,7 @@ fn test_escrow_claim_with_recovery() { .await? .get_receipt() .await?; - assert!(claim.status()); + assert!(claim.status(), "claim receipt: {claim:#?}"); let token = token_view(http_url, blocked.token); assert_eq!(token.balanceOf(blocked.receiver).call().await?, U256::ZERO); @@ -257,7 +257,7 @@ async fn create_blocked_transfer( ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8 ); - let receipt = ITIP1028Escrow::ClaimReceiptV1 { + let receipt: Bytes = ITIP1028Escrow::ClaimReceiptV1 { originator: blocked.from, recipient: blocked.recipient, blockedAt: blocked.blockedAt, @@ -269,6 +269,19 @@ async fn create_blocked_transfer( .abi_encode() .into(); + let escrow = ITIP1028Escrow::new( + ESCROW_ADDRESS, + ProviderBuilder::new().connect_http(http_url.clone()), + ); + assert_eq!( + escrow + .blockedReceiptBalance(token, recovery, BLOCKED_RECEIPT_VERSION, receipt.clone()) + .call() + .await?, + amount, + "blocked event: {blocked:#?}" + ); + let token_view = token_view(http_url.clone(), token); assert_eq!(token_view.balanceOf(sender).call().await?, U256::ZERO); assert_eq!(token_view.balanceOf(receiver).call().await?, U256::ZERO); diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 1cab6639c1..0f9541b6c2 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, ESCROW_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, + VALIDATOR_CONFIG_V2_ADDRESS, }; use tempo_primitives::{ SubBlock, SubBlockMetadata, TempoReceipt, TempoTxEnvelope, TempoTxType, @@ -458,6 +459,9 @@ where self.deploy_precompile_at_boundary(SIGNATURE_VERIFIER_ADDRESS)?; self.deploy_precompile_at_boundary(ADDRESS_REGISTRY_ADDRESS)?; } + if self.inner.spec.is_t6_active_at_timestamp(timestamp) { + self.deploy_precompile_at_boundary(ESCROW_ADDRESS)?; + } Ok(()) } @@ -664,7 +668,7 @@ mod tests { context::result::{ExecutionResult, ResultGas}, database::EmptyDB, }; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use tempo_chainspec::spec::DEV; use tempo_primitives::{ SubBlockMetadata, TempoSignature, TempoTransaction, TempoTxType, @@ -1553,9 +1557,6 @@ mod tests { #[test] fn test_apply_pre_execution_deploys_validator_v2_code() { - use std::sync::Arc; - use tempo_chainspec::spec::DEV; - // Dev chainspec has t2Time: 0, so T2 is active at any timestamp. let chainspec = Arc::new(TempoChainSpec::from_genesis(DEV.genesis().clone())); let mut db = State::builder().with_bundle_update().build(); @@ -1572,9 +1573,6 @@ mod tests { #[test] fn test_apply_pre_execution_deploys_signature_verifier_code() { - use std::sync::Arc; - use tempo_chainspec::spec::DEV; - // Dev chainspec has t3Time: 0, so T3 is active at any timestamp. let chainspec = Arc::new(TempoChainSpec::from_genesis(DEV.genesis().clone())); let mut db = State::builder().with_bundle_update().build(); @@ -1589,6 +1587,22 @@ mod tests { assert!(!info.is_empty_code_hash()); } + #[test] + fn test_apply_pre_execution_deploys_escrow_code() { + // Dev chainspec has t6Time: 0, so T6 is active at any timestamp. + let chainspec = Arc::new(TempoChainSpec::from_genesis(DEV.genesis().clone())); + let mut db = State::builder().with_bundle_update().build(); + let mut executor = TestExecutorBuilder::default() + .with_parent_beacon_block_root(B256::ZERO) + .build(&mut db, &chainspec); + + executor.apply_pre_execution_changes().unwrap(); + + let acc = db.load_cache_account(ESCROW_ADDRESS).unwrap(); + let info = acc.account_info().unwrap(); + assert!(!info.is_empty_code_hash()); + } + #[test] fn test_pre_t3_does_not_deploy_signature_verifier_code() { // Moderato does not have T4 active (no t3Time set), so the code should NOT be deployed. @@ -1610,8 +1624,6 @@ mod tests { #[test] fn test_deploy_precompile_at_boundary_dispatches_state_hook() { - use std::sync::{Arc, Mutex}; - let chainspec = test_chainspec(); let mut db = State::builder().with_bundle_update().build(); let mut executor = TestExecutorBuilder::default() @@ -1653,9 +1665,6 @@ mod tests { /// exempted from block capacity. #[test] fn test_t4_finish_exempts_state_gas_from_header() { - use std::sync::Arc; - use tempo_chainspec::spec::DEV; - // DEV chainspec has T4 active at timestamp 0. let chainspec = Arc::new(TempoChainSpec::from_genesis(DEV.genesis().clone())); let mut db = State::builder().with_bundle_update().build(); diff --git a/crates/node/tests/it/eth_call.rs b/crates/node/tests/it/eth_call.rs index a1817eca84..c4201ddc82 100644 --- a/crates/node/tests/it/eth_call.rs +++ b/crates/node/tests/it/eth_call.rs @@ -293,7 +293,9 @@ async fn test_eth_estimate_gas(schedule: ForkSchedule) -> eyre::Result<()> { let gas = provider.estimate_gas(tx.clone()).await?; // gas estimation is calldata dependent, but should be consistent with same calldata // TIP-1000 (T1): gas includes 250k new account cost when nonce=0 - let expected_gas = if schedule.is_active(TempoHardfork::T3) { + let expected_gas = if schedule.is_active(TempoHardfork::T6) { + 555773 + } else if schedule.is_active(TempoHardfork::T3) { 551540 } else { 549423 diff --git a/crates/node/tests/it/tempo_transaction/snapshots/it__tempo_transaction__gas_estimation_snapshots.snap b/crates/node/tests/it/tempo_transaction/snapshots/it__tempo_transaction__gas_estimation_snapshots.snap index 21175c22ab..dc332d0651 100644 --- a/crates/node/tests/it/tempo_transaction/snapshots/it__tempo_transaction__gas_estimation_snapshots.snap +++ b/crates/node/tests/it/tempo_transaction/snapshots/it__tempo_transaction__gas_estimation_snapshots.snap @@ -4,81 +4,81 @@ expression: gas_estimation --- baseline: 274318 "secp256k1::noop": 274318 -"secp256k1::transfer": 553055 -"secp256k1::batch_2_transfers": 559447 -"secp256k1::batch_5_transfers": 578624 -"secp256k1::batch_10_transfers": 610586 +"secp256k1::transfer": 557288 +"secp256k1::batch_2_transfers": 563882 +"secp256k1::batch_5_transfers": 583664 +"secp256k1::batch_10_transfers": 616633 "secp256k1::contract_creation": 779451 "p256::noop": 279358 -"p256::transfer": 558095 -"p256::batch_2_transfers": 564487 -"p256::batch_5_transfers": 583664 -"p256::batch_10_transfers": 615626 +"p256::transfer": 562328 +"p256::batch_2_transfers": 568922 +"p256::batch_5_transfers": 588704 +"p256::batch_10_transfers": 621673 "p256::contract_creation": 784491 "webauthn::noop": 280793 -"webauthn::transfer": 559530 -"webauthn::batch_2_transfers": 565922 -"webauthn::batch_5_transfers": 585099 -"webauthn::batch_10_transfers": 617061 +"webauthn::transfer": 563763 +"webauthn::batch_2_transfers": 570357 +"webauthn::batch_5_transfers": 590139 +"webauthn::batch_10_transfers": 623108 "webauthn::contract_creation": 785926 "key_auth_p256_0_limits::noop": 538498 "key_auth_secp256k1_0_limits::noop": 538498 "key_auth_webauthn_0_limits::noop": 538498 -"key_auth_p256_0_limits::transfer": 817235 -"key_auth_secp256k1_0_limits::transfer": 817235 -"key_auth_webauthn_0_limits::transfer": 817235 -"key_auth_p256_0_limits::batch_2_transfers": 823627 -"key_auth_secp256k1_0_limits::batch_2_transfers": 823627 -"key_auth_webauthn_0_limits::batch_2_transfers": 823627 -"key_auth_p256_0_limits::batch_5_transfers": 842804 -"key_auth_secp256k1_0_limits::batch_5_transfers": 842804 -"key_auth_webauthn_0_limits::batch_5_transfers": 842804 -"key_auth_p256_0_limits::batch_10_transfers": 874766 -"key_auth_secp256k1_0_limits::batch_10_transfers": 874766 -"key_auth_webauthn_0_limits::batch_10_transfers": 874766 +"key_auth_p256_0_limits::transfer": 821468 +"key_auth_secp256k1_0_limits::transfer": 821468 +"key_auth_webauthn_0_limits::transfer": 821468 +"key_auth_p256_0_limits::batch_2_transfers": 828062 +"key_auth_secp256k1_0_limits::batch_2_transfers": 828062 +"key_auth_webauthn_0_limits::batch_2_transfers": 828062 +"key_auth_p256_0_limits::batch_5_transfers": 847844 +"key_auth_secp256k1_0_limits::batch_5_transfers": 847844 +"key_auth_webauthn_0_limits::batch_5_transfers": 847844 +"key_auth_p256_0_limits::batch_10_transfers": 880814 +"key_auth_secp256k1_0_limits::batch_10_transfers": 880814 +"key_auth_webauthn_0_limits::batch_10_transfers": 880814 "key_auth_p256_1_limit::noop": 1042466 "key_auth_secp256k1_1_limit::noop": 1042466 "key_auth_webauthn_1_limit::noop": 1042466 -"key_auth_p256_1_limit::transfer": 1321203 -"key_auth_secp256k1_1_limit::transfer": 1321203 -"key_auth_webauthn_1_limit::transfer": 1321203 -"key_auth_p256_1_limit::batch_2_transfers": 1327596 -"key_auth_secp256k1_1_limit::batch_2_transfers": 1327596 -"key_auth_webauthn_1_limit::batch_2_transfers": 1327596 -"key_auth_p256_1_limit::batch_5_transfers": 1346773 -"key_auth_secp256k1_1_limit::batch_5_transfers": 1346773 -"key_auth_webauthn_1_limit::batch_5_transfers": 1346773 -"key_auth_p256_1_limit::batch_10_transfers": 1378734 -"key_auth_secp256k1_1_limit::batch_10_transfers": 1378734 -"key_auth_webauthn_1_limit::batch_10_transfers": 1378734 -"key_auth_secp256k1_target_any_selector::transfer": 1832227 +"key_auth_p256_1_limit::transfer": 1325437 +"key_auth_secp256k1_1_limit::transfer": 1325437 +"key_auth_webauthn_1_limit::transfer": 1325437 +"key_auth_p256_1_limit::batch_2_transfers": 1332030 +"key_auth_secp256k1_1_limit::batch_2_transfers": 1332030 +"key_auth_webauthn_1_limit::batch_2_transfers": 1332030 +"key_auth_p256_1_limit::batch_5_transfers": 1351812 +"key_auth_secp256k1_1_limit::batch_5_transfers": 1351812 +"key_auth_webauthn_1_limit::batch_5_transfers": 1351812 +"key_auth_p256_1_limit::batch_10_transfers": 1384782 +"key_auth_secp256k1_1_limit::batch_10_transfers": 1384782 +"key_auth_webauthn_1_limit::batch_10_transfers": 1384782 +"key_auth_secp256k1_target_any_selector::transfer": 1836460 "key_auth_p256_3_limits::noop": 2050403 "key_auth_secp256k1_3_limits::noop": 2050403 "key_auth_webauthn_3_limits::noop": 2050403 -"key_auth_p256_3_limits::transfer": 2329140 -"key_auth_secp256k1_3_limits::transfer": 2329140 -"key_auth_webauthn_3_limits::transfer": 2329140 -"key_auth_p256_3_limits::batch_2_transfers": 2335532 -"key_auth_secp256k1_3_limits::batch_2_transfers": 2335532 -"key_auth_webauthn_3_limits::batch_2_transfers": 2335532 -"key_auth_p256_3_limits::batch_5_transfers": 2354709 -"key_auth_secp256k1_3_limits::batch_5_transfers": 2354709 -"key_auth_webauthn_3_limits::batch_5_transfers": 2354709 -"key_auth_p256_3_limits::batch_10_transfers": 2386671 -"key_auth_secp256k1_3_limits::batch_10_transfers": 2386671 -"key_auth_webauthn_3_limits::batch_10_transfers": 2386671 -"key_auth_secp256k1_selector_any_recipient::transfer": 2595235 -"key_auth_secp256k1_selector_recipient::transfer": 3356227 +"key_auth_p256_3_limits::transfer": 2333373 +"key_auth_secp256k1_3_limits::transfer": 2333373 +"key_auth_webauthn_3_limits::transfer": 2333373 +"key_auth_p256_3_limits::batch_2_transfers": 2339967 +"key_auth_secp256k1_3_limits::batch_2_transfers": 2339967 +"key_auth_webauthn_3_limits::batch_2_transfers": 2339967 +"key_auth_p256_3_limits::batch_5_transfers": 2359749 +"key_auth_secp256k1_3_limits::batch_5_transfers": 2359749 +"key_auth_webauthn_3_limits::batch_5_transfers": 2359749 +"key_auth_p256_3_limits::batch_10_transfers": 2392718 +"key_auth_secp256k1_3_limits::batch_10_transfers": 2392718 +"key_auth_webauthn_3_limits::batch_10_transfers": 2392718 +"key_auth_secp256k1_selector_any_recipient::transfer": 2599468 +"key_auth_secp256k1_selector_recipient::transfer": 3360460 "keychain_secp256k1::noop": 541623 -"keychain_secp256k1::transfer": 820561 -"keychain_secp256k1::batch_2_transfers": 827256 -"keychain_secp256k1::batch_5_transfers": 847340 -"keychain_secp256k1::batch_10_transfers": 880814 +"keychain_secp256k1::transfer": 824795 +"keychain_secp256k1::batch_2_transfers": 831691 +"keychain_secp256k1::batch_5_transfers": 852380 +"keychain_secp256k1::batch_10_transfers": 886861 "keychain_p256::noop": 546662 -"keychain_p256::transfer": 825601 -"keychain_p256::batch_2_transfers": 832296 -"keychain_p256::batch_5_transfers": 852380 -"keychain_p256::batch_10_transfers": 885853 -"keychain_secp256k1_selector_any_recipient::transfer": 2600980 -"keychain_secp256k1_selector_recipient::transfer": 3360057 -"keychain_secp256k1_target_any_selector::transfer": 1835755 +"keychain_p256::transfer": 829834 +"keychain_p256::batch_2_transfers": 836730 +"keychain_p256::batch_5_transfers": 857419 +"keychain_p256::batch_10_transfers": 891901 +"keychain_secp256k1_selector_any_recipient::transfer": 2605214 +"keychain_secp256k1_selector_recipient::transfer": 3364291 +"keychain_secp256k1_target_any_selector::transfer": 1839988 diff --git a/xtask/src/genesis_args.rs b/xtask/src/genesis_args.rs index ab46847efb..bc5ada6bbc 100644 --- a/xtask/src/genesis_args.rs +++ b/xtask/src/genesis_args.rs @@ -59,6 +59,7 @@ use tempo_precompiles::{ tip20::{ISSUER_ROLE, ITIP20, TIP20Token}, tip20_factory::TIP20Factory, tip403_registry::TIP403Registry, + tip1028_escrow::TIP1028Escrow, validator_config_v2::ValidatorConfigV2, }; @@ -418,6 +419,11 @@ impl GenesisArgs { initialize_signature_verifier(&mut evm)?; } + if self.t6_time == 0 { + println!("Initializing TIP1028 escrow (T6 active at genesis)"); + initialize_tip1028_escrow(&mut evm)?; + } + if !self.no_pairwise_liquidity { if let (Some(alpha), Some(beta), Some(theta)) = (alpha_token_address, beta_token_address, theta_token_address) @@ -930,6 +936,19 @@ fn initialize_signature_verifier(evm: &mut TempoEvm>) -> eyre:: Ok(()) } +fn initialize_tip1028_escrow(evm: &mut TempoEvm>) -> eyre::Result<()> { + let ctx = evm.ctx_mut(); + StorageCtx::enter_evm( + &mut ctx.journaled_state, + &ctx.block, + &ctx.cfg, + &ctx.tx, + || TIP1028Escrow::new().initialize(), + )?; + + Ok(()) +} + /// Initializes the [`ValidatorConfigV2`] contract at genesis (T2 active at genesis). /// /// Populates validators directly into V2 with `needs_migration = false`. From c131127d4fef4baaab8bbe82430c7d0810da06f9 Mon Sep 17 00:00:00 2001 From: 0xKitsune <0xKitsune@protonmail.com> Date: Thu, 7 May 2026 01:38:00 -0400 Subject: [PATCH 32/32] fix: update TIP-1028 ABI and genesis fixture --- crates/node/tests/assets/test-genesis.json | 5 +++++ tips/verify/lib/tempo-std | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/node/tests/assets/test-genesis.json b/crates/node/tests/assets/test-genesis.json index 55940b6fe3..f55755caf7 100644 --- a/crates/node/tests/assets/test-genesis.json +++ b/crates/node/tests/assets/test-genesis.json @@ -1790,6 +1790,11 @@ "balance": "0x0", "code": "0xef" }, + "0xe5c0000000000000000000000000000000000000": { + "nonce": "0x0", + "balance": "0x0", + "code": "0xef" + }, "0xfdc0000000000000000000000000000000000000": { "nonce": "0x0", "balance": "0x0", diff --git a/tips/verify/lib/tempo-std b/tips/verify/lib/tempo-std index ae53fadbdf..5bf7846ff6 160000 --- a/tips/verify/lib/tempo-std +++ b/tips/verify/lib/tempo-std @@ -1 +1 @@ -Subproject commit ae53fadbdf140b808ea58115882938cc3372009d +Subproject commit 5bf7846ff669a3a479f0a79d27f103bc6b0bfd09