diff --git a/crates/contracts/src/precompiles/escrow.rs b/crates/contracts/src/precompiles/escrow.rs new file mode 100644 index 0000000000..622c4c168d --- /dev/null +++ b/crates/contracts/src/precompiles/escrow.rs @@ -0,0 +1,63 @@ +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 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(); + error InvalidReceiptClaim(); + error InsufficientEscrowBalance(); + error EscrowAddressReserved(); + error InvalidClaimAddress(); + 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 insufficient_escrow_balance() -> Self { + Self::InsufficientEscrowBalance(ITIP1028Escrow::InsufficientEscrowBalance {}) + } + + pub const fn escrow_address_reserved() -> Self { + 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/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"); diff --git a/crates/contracts/src/precompiles/tip403_registry.rs b/crates/contracts/src/precompiles/tip403_registry.rs index a83b31d483..31d1b48c24 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); @@ -22,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); @@ -30,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); @@ -37,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(); @@ -45,6 +55,8 @@ crate::sol! { error InvalidPolicyType(); error IncompatiblePolicyType(); error VirtualAddressNotAllowed(); + error InvalidReceivePolicyType(); + error InvalidReceivePolicyAddress(); } } @@ -94,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/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..0a1aabbcea --- /dev/null +++ b/crates/e2e/src/tests/tip1028_escrow.rs @@ -0,0 +1,361 @@ +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(), "claim receipt: {claim:#?}"); + + 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(), "claim receipt: {claim:#?}"); + + 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: Bytes = 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 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); + 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/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/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/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/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 8c0792017d..a4b320e183 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..5541121327 --- /dev/null +++ b/crates/precompiles/src/tip1028_escrow/mod.rs @@ -0,0 +1,1406 @@ +//! [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; + +/// Version tag for the v1 [`ITIP1028Escrow::ClaimReceiptV1`] layout. +pub const BLOCKED_RECEIPT_VERSION: u8 = 1; + +/// TIP-1028 escrow holding blocked inbound transfers and mints until claimed. +#[contract(addr = ESCROW_ADDRESS)] +pub struct TIP1028Escrow { + blocked_receipt_nonce: u64, + blocked_receipt_amount: Mapping, +} + +impl TIP1028Escrow { + /// One-time storage initialization. + pub fn initialize(&mut self) -> Result<()> { + self.__initialize() + } + + /// Returns the unclaimed amount for a receipt, or zero if unknown or already claimed. + 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() + } + + /// 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, + token: Address, + originator: Address, + receiver: 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)?; + + 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)) + } + + /// Releases escrowed receipt funds to the authorized recipient. + 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)?; + + TIP20Token::from_address(call.token)?.release_from_escrow( + receipt.originator, + 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(()) + } + + /// 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( + nonce + .checked_add(1) + .ok_or(TempoPrecompileError::under_overflow())?, + )?; + Ok(nonce) + } + + /// 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()); + } + ITIP1028Escrow::ClaimReceiptV1::abi_decode(receipt) + .map_err(|_| TIP1028EscrowError::invalid_receipt_claim().into()) + } + + /// Content hash over every receipt field. Any mutation yields a different empty slot. + 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(), + ) + } +} + +#[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 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)); + + 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 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); + 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 test_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 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)); + + 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 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)); + + 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 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)); + + 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 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)); + + 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 test_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 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)); + + 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 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)); + + 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 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)); + + 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 test_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 test_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(()) + }) + } +} diff --git a/crates/precompiles/src/tip20/mod.rs b/crates/precompiles/src/tip20/mod.rs index 960721a27b..dbe0f0a86c 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}, @@ -394,8 +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)?; + 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, @@ -414,8 +427,20 @@ impl TIP20Token { call: ITIP20::mintWithMemoCall, ) -> Result<()> { let to = Recipient::resolve(call.to)?; - self._mint(msg_sender, &to, call.amount)?; + 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, @@ -433,13 +458,8 @@ impl TIP20Token { } /// 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)?; + 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())?; @@ -453,7 +473,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)?; @@ -699,10 +719,21 @@ impl TIP20Token { self.validate_transfer(msg_sender, &to)?; self.check_and_update_spending_limit(msg_sender, call.amount)?; + if self.validate_or_escrow_funds( + msg_sender, + &to, + call.amount, + 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)?; } + Ok(true) } @@ -721,7 +752,20 @@ impl TIP20Token { call: ITIP20::transferFromCall, ) -> Result { let to = Recipient::resolve(call.to)?; - self._transfer_from(msg_sender, call.from, &to, call.amount)?; + 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, + )? { + 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)?; } @@ -735,8 +779,20 @@ impl TIP20Token { call: ITIP20::transferFromWithMemoCall, ) -> Result { let to = Recipient::resolve(call.to)?; - self._transfer_from(msg_sender, call.from, &to, call.amount)?; + 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, + )? { + return Ok(true); + } + + self._transfer(call.from, &to, call.amount)?; self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { from: call.from, to: call.to, @@ -769,6 +825,10 @@ impl TIP20Token { self.validate_transfer(from, &to)?; self.check_and_update_spending_limit(from, 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)?; @@ -777,16 +837,9 @@ impl TIP20Token { Ok(true) } - fn _transfer_from( - &mut self, - msg_sender: Address, - from: Address, - to: &Recipient, - amount: U256, - ) -> Result { - self.validate_transfer(from, to)?; - - let allowed = self.get_allowance(from, msg_sender)?; + /// 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 { return Err(TIP20Error::insufficient_allowance().into()); } @@ -795,12 +848,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. @@ -813,8 +863,17 @@ impl TIP20Token { self.validate_transfer(msg_sender, &to)?; self.check_and_update_spending_limit(msg_sender, call.amount)?; - self._transfer(msg_sender, &to, call.amount)?; + if self.validate_or_escrow_funds( + msg_sender, + &to, + call.amount, + InboundKind::TRANSFER, + call.memo, + )? { + return Ok(()); + } + self._transfer(msg_sender, &to, call.amount)?; self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo { from: msg_sender, to: call.to, @@ -1009,6 +1068,118 @@ impl TIP20Token { self.emit_event(to.build_transfer_event(from, amount)) } + /// 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, + to: &Recipient, + amount: U256, + kind: InboundKind, + memo: B256, + ) -> 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(); + 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, + to.target, + recipient, + recovery, + amount, + 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_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(()) + } + /// 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. /// @@ -1317,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::{ @@ -1394,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 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(); + 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 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(); + 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 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(); + 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 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(); + 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 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(); + 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 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(); + 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 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(); + 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(); diff --git a/crates/precompiles/src/tip403_registry/dispatch.rs b/crates/precompiles/src/tip403_registry/dispatch.rs index a1b7c84b1d..676abaa80f 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 T6_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::T6).with_added(T6_ADDED), + ], ITIP403RegistryCalls::abi_decode, |call| match call { ITIP403RegistryCalls::policyIdCounter(call) => { @@ -53,6 +62,19 @@ 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| { + let blocked_reason = self + .validate_receive_policy(c.token, c.sender, c.receiver)? + .unwrap_or(ITIP403Registry::BlockedReason::NONE); + Ok(ITIP403Registry::validateReceivePolicyReturn { + authorized: blocked_reason == ITIP403Registry::BlockedReason::NONE, + blockedReason: blocked_reason, + }) + }), + 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)) } @@ -87,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<()> { @@ -533,8 +557,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(); @@ -550,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 b57168bf54..fc634850eb 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,16 @@ 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, +} + +#[derive(Debug, Clone, Default, Storable)] +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]) @@ -90,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, @@ -230,6 +239,53 @@ impl TIP403Registry { }) } + /// Returns `account`'s receive-policy configuration. + 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: config.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> { + let config = self.address_receive_config[receiver].read()?; + if !config.has_receive_policy { + return Ok(None); + } + + if !self.is_authorized_simple(config.token_filter_id, token)? { + return Ok(Some(ITIP403Registry::BlockedReason::TOKEN_FILTER)); + } + + if !self.is_authorized_simple(config.sender_policy_id, sender)? { + return Ok(Some(ITIP403Registry::BlockedReason::RECEIVE_POLICY)); + } + + 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. /// /// # Errors @@ -276,6 +332,39 @@ impl TIP403Registry { Ok(new_policy_id) } + /// Sets the caller's TIP-1028 receive policy. + 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, + recovery_address: 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 +726,36 @@ impl TIP403Registry { Ok(()) } + /// 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(()); + } + 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(()) + } + + /// 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) + .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. @@ -924,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); 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 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`.