diff --git a/crates/precompiles/src/policy.rs b/crates/precompiles/src/policy.rs index f88c8f15..f3e34b01 100644 --- a/crates/precompiles/src/policy.rs +++ b/crates/precompiles/src/policy.rs @@ -6,8 +6,54 @@ use alloy_primitives::Address; use revm::precompile::PrecompileError; +use tempo_contracts::precompiles::ITIP403Registry::{BlockedReason, PolicyType}; use zone_primitives::policy::AuthRole; +/// Cached TIP-1028 receive-policy configuration for one account. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReceivePolicy { + pub has_receive_policy: bool, + pub sender_policy_id: u64, + pub sender_policy_type: PolicyType, + pub token_filter_id: u64, + pub token_filter_type: PolicyType, + pub recovery_authority: Address, +} + +impl ReceivePolicy { + /// Default L1 receive-policy view for an account with no configured policy. + pub const fn none() -> Self { + Self { + has_receive_policy: false, + sender_policy_id: 0, + sender_policy_type: PolicyType::WHITELIST, + token_filter_id: 0, + token_filter_type: PolicyType::WHITELIST, + recovery_authority: Address::ZERO, + } + } +} + +/// Result of applying an account receive policy to an inbound transfer or mint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReceivePolicyDecision { + Authorized, + Blocked { + reason: BlockedReason, + recovery_authority: Address, + }, +} + +impl ReceivePolicyDecision { + /// Returns the `validateReceivePolicy` ABI fields. + pub const fn as_validate_return(self) -> (bool, BlockedReason) { + match self { + Self::Authorized => (true, BlockedReason::NONE), + Self::Blocked { reason, .. } => (false, reason), + } + } +} + /// Authorization provider used by the TIP-403 proxy and zone TIP-20 precompiles. /// /// Implementors resolve policy queries — either from an in-memory cache with @@ -42,4 +88,15 @@ pub trait PolicyCheck { /// Return the highest known policy ID counter. fn policy_id_counter(&self) -> u64; + + /// Return an account's TIP-1028 receive-policy configuration. + fn receive_policy(&self, account: Address) -> Result; + + /// Validate an inbound transfer or mint against the receiver's TIP-1028 policy. + fn validate_receive_policy( + &self, + token: Address, + sender: Address, + receiver: Address, + ) -> Result; } diff --git a/crates/precompiles/src/tip403_proxy.rs b/crates/precompiles/src/tip403_proxy.rs index 5851e09c..2de73bff 100644 --- a/crates/precompiles/src/tip403_proxy.rs +++ b/crates/precompiles/src/tip403_proxy.rs @@ -5,7 +5,8 @@ //! queries from the zone's [`PolicyCheck`] provider (cache-first, L1 RPC fallback). //! //! **Read-only calls** (`isAuthorized`, `isAuthorizedSender`, `isAuthorizedRecipient`, -//! `isAuthorizedMintRecipient`, `policyData`, `compoundPolicyData`, `policyExists`) +//! `isAuthorizedMintRecipient`, `policyData`, `compoundPolicyData`, `policyExists`, +//! `receivePolicy`, `validateReceivePolicy`) //! are resolved via the [`PolicyCheck`] trait. //! //! **Mutating calls** (`createPolicy`, `modifyPolicyWhitelist`, etc.) are reverted — @@ -23,7 +24,7 @@ use tempo_precompiles::tip403_registry::{ALLOW_ALL_POLICY_ID, REJECT_ALL_POLICY_ use tracing::{debug, warn}; use zone_primitives::policy::AuthRole; -use crate::policy::PolicyCheck; +use crate::policy::{PolicyCheck, ReceivePolicyDecision}; /// The precompile address — same as the L1 TIP403Registry. pub const ZONE_TIP403_PROXY_ADDRESS: Address = TIP403_REGISTRY_ADDRESS; @@ -93,6 +94,17 @@ impl ZoneTip403ProxyRegistry

{ } self.is_authorized(policy_id, to, AuthRole::Recipient) } + + /// Validate `receiver`'s TIP-1028 receive policy for an inbound transfer or mint. + pub fn validate_receive_policy( + &self, + token: Address, + sender: Address, + receiver: Address, + ) -> Result { + self.provider + .validate_receive_policy(token, sender, receiver) + } } impl ZoneTip403ProxyRegistry

{ @@ -154,12 +166,19 @@ impl ZoneTip403ProxyRegistry

{ if selector == ITIP403Registry::policyIdCounterCall::SELECTOR { return self.handle_policy_id_counter(reservoir); } + if selector == ITIP403Registry::receivePolicyCall::SELECTOR { + return self.handle_receive_policy(data, reservoir); + } + if selector == ITIP403Registry::validateReceivePolicyCall::SELECTOR { + return self.handle_validate_receive_policy(data, reservoir); + } // Mutating functions — all reverted on zone if selector == ITIP403Registry::createPolicyCall::SELECTOR || selector == ITIP403Registry::createPolicyWithAccountsCall::SELECTOR || selector == ITIP403Registry::createCompoundPolicyCall::SELECTOR || selector == ITIP403Registry::setPolicyAdminCall::SELECTOR + || selector == ITIP403Registry::setReceivePolicyCall::SELECTOR || selector == ITIP403Registry::modifyPolicyWhitelistCall::SELECTOR || selector == ITIP403Registry::modifyPolicyBlacklistCall::SELECTOR { @@ -299,4 +318,51 @@ impl ZoneTip403ProxyRegistry

{ reservoir, )) } + + /// Handle `receivePolicy(account)`. + fn handle_receive_policy(&self, data: &[u8], reservoir: u64) -> PrecompileResult { + let call = match ITIP403Registry::receivePolicyCall::abi_decode(data) { + Ok(call) => call, + Err(_) => return Ok(PrecompileOutput::revert(0, Bytes::new(), reservoir)), + }; + + let policy = self.provider.receive_policy(call.account)?; + let ret = ITIP403Registry::receivePolicyReturn { + hasReceivePolicy: policy.has_receive_policy, + senderPolicyId: policy.sender_policy_id, + senderPolicyType: policy.sender_policy_type, + tokenFilterId: policy.token_filter_id, + tokenFilterType: policy.token_filter_type, + recoveryAuthority: policy.recovery_authority, + }; + let encoded = ITIP403Registry::receivePolicyCall::abi_encode_returns(&ret); + Ok(PrecompileOutput::new( + POLICY_DATA_GAS, + encoded.into(), + reservoir, + )) + } + + /// Handle `validateReceivePolicy(token, sender, receiver)`. + fn handle_validate_receive_policy(&self, data: &[u8], reservoir: u64) -> PrecompileResult { + let call = match ITIP403Registry::validateReceivePolicyCall::abi_decode(data) { + Ok(call) => call, + Err(_) => return Ok(PrecompileOutput::revert(0, Bytes::new(), reservoir)), + }; + + let (authorized, blocked_reason) = self + .provider + .validate_receive_policy(call.token, call.sender, call.receiver)? + .as_validate_return(); + let ret = ITIP403Registry::validateReceivePolicyReturn { + authorized, + blockedReason: blocked_reason, + }; + let encoded = ITIP403Registry::validateReceivePolicyCall::abi_encode_returns(&ret); + Ok(PrecompileOutput::new( + AUTH_CHECK_GAS, + encoded.into(), + reservoir, + )) + } } diff --git a/crates/precompiles/src/ztip20.rs b/crates/precompiles/src/ztip20.rs index 76904bb9..7701856b 100644 --- a/crates/precompiles/src/ztip20.rs +++ b/crates/precompiles/src/ztip20.rs @@ -10,19 +10,28 @@ //! [`PolicyCheck`] — cache-first, L1 RPC fallback), and only then delegates //! to the vanilla `TIP20Token` implementation. -use alloc::sync::Arc; +use alloc::{string::String, sync::Arc}; use alloy_evm::precompiles::DynPrecompile; -use alloy_primitives::{Address, Bytes}; -use alloy_sol_types::{SolCall, SolError, SolInterface}; +use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_sol_types::{SolCall, SolError, SolInterface, SolValue}; use revm::precompile::{ PrecompileError, PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult, }; +use tempo_contracts::precompiles::{ + IReceivePolicyGuard, ITIP403Registry::BlockedReason, ReceivePolicyGuardError, +}; use tempo_precompiles::{ - DelegateCallNotAllowed, Precompile as TempoPrecompile, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - tip20::{IRolesAuth, ITIP20, RolesAuthError, TIP20Token}, + DelegateCallNotAllowed, Precompile as TempoPrecompile, RECEIVE_POLICY_GUARD_ADDRESS, + StaticCallNotAllowed, + address_registry::AddressRegistry, + storage::{ContractStorage, Handler, Mapping, StorageCtx, evm::EvmPrecompileStorageProvider}, + tip20::{ + IRolesAuth, ISSUER_ROLE, ITIP20, RolesAuthError, TIP20Error, TIP20Event, TIP20Token, + is_tip20_prefix, rewards::UserRewardInfo, + }, }; +use tempo_precompiles_macros::contract; use tempo_zone_contracts::Unauthorized; use tracing::{trace, warn}; use zone_primitives::{ @@ -31,7 +40,7 @@ use zone_primitives::{ }; use crate::{ - policy::PolicyCheck, + policy::{PolicyCheck, ReceivePolicyDecision}, tip403_proxy::{AUTH_CHECK_GAS, ZoneTip403ProxyRegistry}, }; @@ -53,6 +62,306 @@ macro_rules! decode_or_revert { }; } +/// Convert token/precompile business errors into normal ABI reverts. +macro_rules! token_or_revert { + ($expr:expr) => { + match $expr { + Ok(value) => value, + Err(err) => return StorageCtx::default().error_result(err), + } + }; +} + +/// Recipient resolved through the Tempo address registry. +#[derive(Debug, Clone, Copy)] +struct ZoneRecipient { + target: Address, + virtual_addr: Option

, +} + +impl ZoneRecipient { + fn direct(addr: Address) -> Self { + Self { + target: addr, + virtual_addr: None, + } + } + + fn resolve(addr: Address) -> tempo_precompiles::error::Result { + let effective = AddressRegistry::new().resolve_recipient(addr)?; + Ok(if effective == addr { + Self::direct(addr) + } else { + Self { + target: effective, + virtual_addr: Some(addr), + } + }) + } + + fn validate(&self) -> tempo_precompiles::error::Result<()> { + if self.target.is_zero() || is_tip20_prefix(self.target) { + return Err(tempo_contracts::precompiles::TIP20Error::invalid_recipient().into()); + } + Ok(()) + } + + fn addressed_recipient(&self) -> Address { + self.virtual_addr.unwrap_or(self.target) + } +} + +/// Zone-local writer for the upstream `ReceivePolicyGuard` storage layout. +#[contract(addr = RECEIVE_POLICY_GUARD_ADDRESS)] +struct ZoneReceivePolicyGuard { + nonce: u64, + balances: Mapping, +} + +impl ZoneReceivePolicyGuard { + #[allow(clippy::too_many_arguments)] + fn store_blocked( + &mut self, + token: Address, + originator: Address, + recipient: &ZoneRecipient, + recovery_authority: Address, + amount: U256, + blocked_reason: BlockedReason, + kind: IReceivePolicyGuard::InboundKind, + memo: B256, + ) -> tempo_precompiles::error::Result<()> { + if matches!( + blocked_reason, + BlockedReason::NONE | BlockedReason::__Invalid + ) || matches!(kind, IReceivePolicyGuard::InboundKind::__Invalid) + { + return Err(ReceivePolicyGuardError::invalid_receipt().into()); + } + + let blocked_nonce = self.next_receipt_nonce()?; + let blocked_at = self.storage.timestamp().saturating_to::(); + let receipt = IReceivePolicyGuard::ClaimReceiptV1::new( + token, + recovery_authority, + originator, + recipient.addressed_recipient(), + blocked_at, + blocked_nonce, + blocked_reason as u8, + kind, + memo, + ); + let key = self.storage.keccak256(receipt.abi_encode().as_ref())?; + self.balances[key].write(amount)?; + self.emit_event(receipt.blocked_event(recipient.target, amount)) + } + + fn next_receipt_nonce(&mut self) -> tempo_precompiles::error::Result { + let nonce = self.nonce.read()?.max(1); + self.nonce.write( + nonce + .checked_add(1) + .ok_or_else(tempo_precompiles::error::TempoPrecompileError::under_overflow)?, + )?; + Ok(nonce) + } +} + +mod zone_tip20_ledger { + use super::*; + + /// Zone-local writer for the upstream `TIP20Token` storage layout. + /// + /// This deliberately mirrors the upstream storage fields so a blocked inbound + /// transfer can move funds to the receive-policy guard without going through + /// the public TIP20 entrypoints that reject the guard as a direct recipient. + #[contract] + pub(super) struct ZoneTip20Ledger { + roles: Mapping>, + role_admins: Mapping, + + name: String, + symbol: String, + currency: String, + logo_uri: String, + quote_token: Address, + next_quote_token: Address, + transfer_policy_id: u64, + + total_supply: U256, + balances: Mapping, + allowances: Mapping>, + permit_nonces: Mapping, + paused: bool, + supply_cap: U256, + _salts: Mapping, + + global_reward_per_token: U256, + opted_in_supply: u128, + user_reward_info: Mapping, + } + + impl ZoneTip20Ledger { + pub(super) fn from_valid_tip20_address(address: Address) -> Self { + Self::__new(address) + } + + pub(super) fn consume_allowance( + &mut self, + owner: Address, + spender: Address, + amount: U256, + ) -> tempo_precompiles::error::Result<()> { + let allowed = self.get_allowance(owner, spender)?; + if amount > allowed { + return Err(TIP20Error::insufficient_allowance().into()); + } + + if allowed != U256::MAX { + let new_allowance = allowed + .checked_sub(amount) + .ok_or_else(TIP20Error::insufficient_allowance)?; + self.set_allowance(owner, spender, new_allowance)?; + } + Ok(()) + } + + pub(super) fn checked_from_balance_for_transfer( + &self, + from: Address, + amount: U256, + ) -> tempo_precompiles::error::Result> { + if self.storage.spec().is_t8() { + return Ok(None); + } + + let from_balance = self.get_balance(from)?; + if amount > from_balance { + return Err( + TIP20Error::insufficient_balance(from_balance, amount, self.address).into(), + ); + } + Ok(Some(from_balance)) + } + + pub(super) fn transfer_to_guard( + &mut self, + from: Address, + amount: U256, + from_balance: Option, + ) -> tempo_precompiles::error::Result<()> { + if let Some(from_balance) = from_balance { + let new_from_balance = from_balance + .checked_sub(amount) + .ok_or_else(tempo_precompiles::error::TempoPrecompileError::under_overflow)?; + self.set_balance(from, new_from_balance)?; + } else { + self.decrement_balance(from, amount)?; + } + + self.increment_balance(RECEIVE_POLICY_GUARD_ADDRESS, amount)?; + self.emit_event(TIP20Event::transfer( + from, + RECEIVE_POLICY_GUARD_ADDRESS, + amount, + )) + } + + pub(super) fn checked_mint_supply( + &self, + total_supply: U256, + amount: U256, + ) -> tempo_precompiles::error::Result { + let new_supply = total_supply + .checked_add(amount) + .ok_or_else(tempo_precompiles::error::TempoPrecompileError::under_overflow)?; + + let supply_cap = self.supply_cap.read()?; + if new_supply > supply_cap { + return Err(TIP20Error::supply_cap_exceeded().into()); + } + + Ok(new_supply) + } + + pub(super) fn mint_to_guard( + &mut self, + new_supply: U256, + amount: U256, + ) -> tempo_precompiles::error::Result<()> { + self.total_supply.write(new_supply)?; + self.increment_balance(RECEIVE_POLICY_GUARD_ADDRESS, amount)?; + self.emit_event(TIP20Event::transfer( + Address::ZERO, + RECEIVE_POLICY_GUARD_ADDRESS, + amount, + ))?; + self.emit_event(TIP20Event::mint(RECEIVE_POLICY_GUARD_ADDRESS, amount)) + } + + fn get_balance(&self, account: Address) -> tempo_precompiles::error::Result { + self.balances[account].read() + } + + fn set_balance( + &mut self, + account: Address, + amount: U256, + ) -> tempo_precompiles::error::Result<()> { + self.balances[account].write(amount) + } + + fn increment_balance( + &mut self, + account: Address, + amount: U256, + ) -> tempo_precompiles::error::Result<()> { + self.balances[account].sinc(amount).map_err(|err| { + if err == tempo_precompiles::error::TempoPrecompileError::under_overflow() { + TIP20Error::supply_cap_exceeded().into() + } else { + err + } + }) + } + + fn decrement_balance( + &mut self, + account: Address, + amount: U256, + ) -> tempo_precompiles::error::Result<()> { + self.balances[account] + .sdec(amount) + .map_err(|err| match err { + tempo_precompiles::error::TempoPrecompileError::StorageDeltaUnderflow( + current, + ) => TIP20Error::insufficient_balance(current, amount, self.address).into(), + err => err, + }) + } + + fn get_allowance( + &self, + owner: Address, + spender: Address, + ) -> tempo_precompiles::error::Result { + self.allowances[owner][spender].read() + } + + fn set_allowance( + &mut self, + owner: Address, + spender: Address, + amount: U256, + ) -> tempo_precompiles::error::Result<()> { + self.allowances[owner][spender].write(amount) + } + } +} + +use zone_tip20_ledger::ZoneTip20Ledger; + /// Capability trait for resolving the active zone sequencer. /// /// The zone runtime implements this for its L1-backed state provider so the @@ -137,33 +446,83 @@ impl ZoneTip20Token

{ } ITIP20::transferCall::SELECTOR => { let call = decode_or_revert!(ITIP20::transferCall, args); - self.enforce_transfer(address, caller, call.to) + if let Some(revert) = self.enforce_transfer(address, caller, call.to) { + return Some(revert); + } + self.enforce_receive_transfer( + address, + None, + caller, + call.to, + call.amount, + B256::ZERO, + true, + ) } ITIP20::transferFromCall::SELECTOR => { let call = decode_or_revert!(ITIP20::transferFromCall, args); - self.enforce_transfer(address, call.from, call.to) + if let Some(revert) = self.enforce_transfer(address, call.from, call.to) { + return Some(revert); + } + self.enforce_receive_transfer( + address, + Some(caller), + call.from, + call.to, + call.amount, + B256::ZERO, + true, + ) } ITIP20::transferWithMemoCall::SELECTOR => { let call = decode_or_revert!(ITIP20::transferWithMemoCall, args); - self.enforce_transfer(address, caller, call.to) + if let Some(revert) = self.enforce_transfer(address, caller, call.to) { + return Some(revert); + } + self.enforce_receive_transfer( + address, + None, + caller, + call.to, + call.amount, + call.memo, + false, + ) } ITIP20::transferFromWithMemoCall::SELECTOR => { let call = decode_or_revert!(ITIP20::transferFromWithMemoCall, args); - self.enforce_transfer(address, call.from, call.to) + if let Some(revert) = self.enforce_transfer(address, call.from, call.to) { + return Some(revert); + } + self.enforce_receive_transfer( + address, + Some(caller), + call.from, + call.to, + call.amount, + call.memo, + true, + ) } ITIP20::mintCall::SELECTOR => { if let Some(revert) = self.reject_crossed_mint_caller(caller) { return Some(revert); } let call = decode_or_revert!(ITIP20::mintCall, args); - self.enforce_mint(address, call.to) + if let Some(revert) = self.enforce_mint(address, call.to) { + return Some(revert); + } + self.enforce_receive_mint(address, caller, call.to, call.amount, B256::ZERO) } ITIP20::mintWithMemoCall::SELECTOR => { if let Some(revert) = self.reject_crossed_mint_caller(caller) { return Some(revert); } let call = decode_or_revert!(ITIP20::mintWithMemoCall, args); - self.enforce_mint(address, call.to) + if let Some(revert) = self.enforce_mint(address, call.to) { + return Some(revert); + } + self.enforce_receive_mint(address, caller, call.to, call.amount, call.memo) } ITIP20::burnCall::SELECTOR | ITIP20::burnWithMemoCall::SELECTOR => { self.reject_crossed_burn_caller(caller) @@ -281,6 +640,244 @@ impl ZoneTip20Token

{ } } + /// Apply TIP-1028 receive policy to a transfer-family call. + fn enforce_receive_transfer( + &self, + token: Address, + spender: Option

, + from: Address, + to: Address, + amount: U256, + memo: B256, + returns_bool: bool, + ) -> Option { + if !StorageCtx::default().spec().is_t6() { + return None; + } + + let registry = self.registry.as_ref()?; + let recipient = match Self::resolve_and_validate_recipient(to) { + Ok(recipient) => recipient, + Err(e) => return Some(StorageCtx::default().error_result(e)), + }; + + if recipient.target == RECEIVE_POLICY_GUARD_ADDRESS { + return Some(Ok(Self::receive_policy_guard_address_reserved_output())); + } + + match registry + .validate_receive_policy(token, from, recipient.target) + .map_err(|e| { + warn!( + target: "zone::precompile", + %token, %from, receiver = %recipient.target, error = %e, + "failed to validate receive policy, rejecting transfer" + ); + e + }) { + Ok(ReceivePolicyDecision::Authorized) => None, + Ok(ReceivePolicyDecision::Blocked { + reason, + recovery_authority, + }) => Some(self.block_transfer( + token, + spender, + from, + &recipient, + amount, + reason, + recovery_authority, + memo, + returns_bool, + )), + Err(e) => Some(Err(e)), + } + } + + /// Apply TIP-1028 receive policy to a mint-family call. + fn enforce_receive_mint( + &self, + token: Address, + originator: Address, + to: Address, + amount: U256, + memo: B256, + ) -> Option { + if !StorageCtx::default().spec().is_t6() { + return None; + } + + let registry = self.registry.as_ref()?; + let recipient = match Self::resolve_and_validate_recipient(to) { + Ok(recipient) => recipient, + Err(e) => return Some(StorageCtx::default().error_result(e)), + }; + + if recipient.target == RECEIVE_POLICY_GUARD_ADDRESS { + return Some(Ok(Self::receive_policy_guard_address_reserved_output())); + } + + match registry.validate_receive_policy(token, originator, recipient.target) { + Ok(ReceivePolicyDecision::Authorized) => None, + Ok(ReceivePolicyDecision::Blocked { + reason, + recovery_authority, + }) => Some(self.block_mint( + token, + originator, + &recipient, + amount, + reason, + recovery_authority, + memo, + )), + Err(e) => { + warn!( + target: "zone::precompile", + %token, %originator, receiver = %recipient.target, error = %e, + "failed to validate receive policy for mint, deferring to L1 enforcement" + ); + None + } + } + } + + #[allow(clippy::too_many_arguments)] + fn block_transfer( + &self, + token: Address, + spender: Option
, + from: Address, + recipient: &ZoneRecipient, + amount: U256, + reason: BlockedReason, + recovery_authority: Address, + memo: B256, + returns_bool: bool, + ) -> PrecompileResult { + trace!( + target: "zone::precompile", + %token, %from, receiver = %recipient.target, ?reason, + "receive policy blocked transfer, moving funds to guard" + ); + + if StorageCtx::default().is_static() { + return Ok(Self::static_call_not_allowed_output()); + } + + let mut tip20 = token_or_revert!(TIP20Token::from_address(token)); + if !token_or_revert!(tip20.is_initialized()) { + return StorageCtx::default().error_result(TIP20Error::uninitialized()); + } + + token_or_revert!(tip20.check_not_paused()); + let mut ledger = ZoneTip20Ledger::from_valid_tip20_address(token); + if let Some(spender) = spender { + token_or_revert!(ledger.consume_allowance(from, spender, amount)); + } else { + token_or_revert!(tip20.check_and_update_spending_limit(from, amount)); + } + let from_balance = token_or_revert!(ledger.checked_from_balance_for_transfer(from, amount)); + token_or_revert!(tip20.handle_rewards_on_transfer( + from, + RECEIVE_POLICY_GUARD_ADDRESS, + amount + )); + token_or_revert!(ledger.transfer_to_guard(from, amount, from_balance)); + + token_or_revert!(Self::store_blocked_receipt( + token, + from, + recipient, + recovery_authority, + amount, + reason, + IReceivePolicyGuard::InboundKind::TRANSFER, + memo, + )); + Ok(Self::transfer_success_output(returns_bool)) + } + + #[allow(clippy::too_many_arguments)] + fn block_mint( + &self, + token: Address, + originator: Address, + recipient: &ZoneRecipient, + amount: U256, + reason: BlockedReason, + recovery_authority: Address, + memo: B256, + ) -> PrecompileResult { + trace!( + target: "zone::precompile", + %token, %originator, receiver = %recipient.target, ?reason, + "receive policy blocked mint, moving funds to guard" + ); + + if StorageCtx::default().is_static() { + return Ok(Self::static_call_not_allowed_output()); + } + + let mut tip20 = token_or_revert!(TIP20Token::from_address(token)); + if !token_or_revert!(tip20.is_initialized()) { + return StorageCtx::default().error_result(TIP20Error::uninitialized()); + } + + token_or_revert!(tip20.check_role(originator, *ISSUER_ROLE)); + let total_supply = token_or_revert!(tip20.total_supply()); + if StorageCtx::default().spec().is_t3() { + token_or_revert!(tip20.check_not_paused()); + } + + let mut ledger = ZoneTip20Ledger::from_valid_tip20_address(token); + let new_supply = token_or_revert!(ledger.checked_mint_supply(total_supply, amount)); + token_or_revert!(tip20.handle_rewards_on_mint(RECEIVE_POLICY_GUARD_ADDRESS, amount)); + token_or_revert!(ledger.mint_to_guard(new_supply, amount)); + + token_or_revert!(Self::store_blocked_receipt( + token, + originator, + recipient, + recovery_authority, + amount, + reason, + IReceivePolicyGuard::InboundKind::MINT, + memo, + )); + Ok(StorageCtx::default().success_output(Bytes::new())) + } + + fn store_blocked_receipt( + token: Address, + originator: Address, + recipient: &ZoneRecipient, + recovery_authority: Address, + amount: U256, + reason: BlockedReason, + kind: IReceivePolicyGuard::InboundKind, + memo: B256, + ) -> tempo_precompiles::error::Result<()> { + ZoneReceivePolicyGuard::new().store_blocked( + token, + originator, + recipient, + recovery_authority, + amount, + reason, + kind, + memo, + ) + } + + fn resolve_and_validate_recipient( + to: Address, + ) -> tempo_precompiles::error::Result { + let recipient = ZoneRecipient::resolve(to)?; + recipient.validate()?; + Ok(recipient) + } + /// Reject the system caller that is only allowed on the opposite bridge path. fn reject_crossed_mint_caller(&self, caller: Address) -> Option { if caller == ZONE_OUTBOX_ADDRESS { @@ -321,6 +918,32 @@ impl ZoneTip20Token

{ StorageCtx::default().revert_output(RolesAuthError::unauthorized().selector().into()) } + fn receive_policy_guard_address_reserved_output() -> PrecompileOutput { + StorageCtx::default().revert_output( + ReceivePolicyGuardError::address_reserved() + .selector() + .into(), + ) + } + + fn static_call_not_allowed_output() -> PrecompileOutput { + let storage = StorageCtx::default(); + PrecompileOutput::revert( + 0, + StaticCallNotAllowed {}.abi_encode().into(), + storage.reservoir(), + ) + } + + fn transfer_success_output(returns_bool: bool) -> PrecompileOutput { + let bytes = if returns_bool { + ITIP20::transferCall::abi_encode_returns(&true).into() + } else { + Bytes::new() + }; + StorageCtx::default().success_output(bytes) + } + /// Build a reverted output with the `policyForbids()` error selector. fn policy_forbids_output() -> PrecompileOutput { PrecompileOutput::revert( @@ -428,7 +1051,7 @@ mod tests { }; use tempo_chainspec::hardfork::TempoHardfork; use tempo_precompiles::{ - PATH_USD_ADDRESS, + PATH_USD_ADDRESS, RECEIVE_POLICY_GUARD_ADDRESS, tip20::{ISSUER_ROLE, ITIP20, TIP20Token}, }; @@ -446,6 +1069,7 @@ mod tests { mint_authorized: bool, policy_id: u64, fail_policy_id_resolution: bool, + receive_policy_decision: Option, } impl MockPolicyProvider { @@ -455,6 +1079,7 @@ mod tests { mint_authorized: true, policy_id: 1, fail_policy_id_resolution: false, + receive_policy_decision: None, } } @@ -509,6 +1134,24 @@ mod tests { fn policy_id_counter(&self) -> u64 { self.policy_id } + + fn receive_policy( + &self, + _account: Address, + ) -> Result { + Ok(crate::policy::ReceivePolicy::none()) + } + + fn validate_receive_policy( + &self, + _token: Address, + _sender: Address, + _receiver: Address, + ) -> Result { + Ok(self + .receive_policy_decision + .unwrap_or(ReceivePolicyDecision::Authorized)) + } } #[derive(Clone, Copy)] @@ -543,6 +1186,13 @@ mod tests { } fn new_with_registry(policy: Option) -> TestResult { + Self::new_with_registry_and_spec(policy, TempoHardfork::default()) + } + + fn new_with_registry_and_spec( + policy: Option, + spec: TempoHardfork, + ) -> TestResult { let token = PATH_USD_ADDRESS; let admin = address!("0x00000000000000000000000000000000000000a1"); let alice = address!("0x00000000000000000000000000000000000000a2"); @@ -550,7 +1200,7 @@ mod tests { let spender = address!("0x00000000000000000000000000000000000000a4"); let issuer = address!("0x00000000000000000000000000000000000000a5"); let sequencer = address!("0x00000000000000000000000000000000000000a6"); - let mut ctx = Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()); + let mut ctx = Context::new(CacheDB::new(EmptyDB::new()), spec); Self::with_storage(&mut ctx, u64::MAX, |storage| { StorageCtx::enter(storage, || -> TestResult { @@ -778,6 +1428,74 @@ mod tests { Ok(()) } + #[test] + fn receive_policy_blocked_transfer_moves_funds_to_guard() -> TestResult { + let mut policy = MockPolicyProvider::allow_all(); + policy.receive_policy_decision = Some(ReceivePolicyDecision::Blocked { + reason: BlockedReason::RECEIVE_POLICY, + recovery_authority: Address::ZERO, + }); + let mut harness = + PrecompileHarness::new_with_registry_and_spec(Some(policy), TempoHardfork::T6)?; + + let amount = U256::from(42_000u64); + let transfer = harness.call( + harness.alice, + ITIP20::transferCall { + to: harness.bob, + amount, + } + .abi_encode() + .into(), + FIXED_TRANSFER_GAS, + false, + )?; + + assert!(transfer.is_success()); + assert_eq!( + ITIP20::transferCall::abi_decode_returns(&transfer.bytes)?, + true + ); + assert_eq!(harness.balance_of(harness.bob)?, U256::ZERO); + assert_eq!(harness.balance_of(RECEIVE_POLICY_GUARD_ADDRESS)?, amount); + assert_eq!( + harness.balance_of(harness.alice)?, + U256::from(1_000_000u64) - amount + ); + + Ok(()) + } + + #[test] + fn receive_policy_blocked_mint_moves_funds_to_guard() -> TestResult { + let mut policy = MockPolicyProvider::allow_all(); + policy.receive_policy_decision = Some(ReceivePolicyDecision::Blocked { + reason: BlockedReason::TOKEN_FILTER, + recovery_authority: Address::ZERO, + }); + let mut harness = + PrecompileHarness::new_with_registry_and_spec(Some(policy), TempoHardfork::T6)?; + + let amount = U256::from(17_000u64); + let mint = harness.call( + harness.issuer, + ITIP20::mintCall { + to: harness.bob, + amount, + } + .abi_encode() + .into(), + 200_000, + false, + )?; + + assert!(mint.is_success()); + assert_eq!(harness.balance_of(harness.bob)?, U256::ZERO); + assert_eq!(harness.balance_of(RECEIVE_POLICY_GUARD_ADDRESS)?, amount); + + Ok(()) + } + #[test] fn bridge_auth_rejects_crossed_system_calls_and_keeps_allowed_paths() -> TestResult { let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; diff --git a/crates/tempo-zone/src/evm.rs b/crates/tempo-zone/src/evm.rs index 725bcec8..6bfd6f0a 100644 --- a/crates/tempo-zone/src/evm.rs +++ b/crates/tempo-zone/src/evm.rs @@ -39,10 +39,11 @@ use tempo_evm::{ }; use tempo_payload_types::TempoExecutionData; use tempo_precompiles::{ - ACCOUNT_KEYCHAIN_ADDRESS, NONCE_PRECOMPILE_ADDRESS, PrecompileEnv, STABLECOIN_DEX_ADDRESS, - TIP_FEE_MANAGER_ADDRESS, account_keychain::AccountKeychain, nonce::NonceManager, - storage::actions::StorageActions, storage_credits::NonCreditableSlots, - tip_fee_manager::TipFeeManager, tip20::is_tip20_prefix, + ACCOUNT_KEYCHAIN_ADDRESS, NONCE_PRECOMPILE_ADDRESS, PrecompileEnv, + RECEIVE_POLICY_GUARD_ADDRESS, STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS, + account_keychain::AccountKeychain, nonce::NonceManager, + receive_policy_guard::ReceivePolicyGuard, storage::actions::StorageActions, + storage_credits::NonCreditableSlots, tip_fee_manager::TipFeeManager, tip20::is_tip20_prefix, }; use tempo_primitives::{ Block, TempoHeader, TempoPrimitives, TempoReceipt, TempoTxEnvelope, TempoTxType, @@ -135,6 +136,8 @@ impl ZoneEvmFactory { Some(NonceManager::create_precompile(&zone_env)) } else if *address == ACCOUNT_KEYCHAIN_ADDRESS { Some(AccountKeychain::create_precompile(&zone_env)) + } else if *address == RECEIVE_POLICY_GUARD_ADDRESS && zone_cfg.spec.is_t6() { + Some(ReceivePolicyGuard::create_precompile(&zone_env)) } else { None } diff --git a/crates/tempo-zone/src/l1_state/tip403/cache.rs b/crates/tempo-zone/src/l1_state/tip403/cache.rs index 8f8b287b..4fd328b5 100644 --- a/crates/tempo-zone/src/l1_state/tip403/cache.rs +++ b/crates/tempo-zone/src/l1_state/tip403/cache.rs @@ -14,6 +14,11 @@ //! mirroring `WhitelistUpdated` / `BlacklistUpdated` events. //! - Compound sub-policy IDs for sender, recipient, and mint recipient roles. //! +//! - **Receive policies**: Each account maps to its TIP-1028 receive policy via +//! [`HeightVersioned`](crate::l1_state::versioned::HeightVersioned), tracking +//! `ReceivePolicyUpdated` events plus negative RPC lookups for accounts with +//! no configured policy. +//! //! ## Special policies //! //! Policy ID `0` always rejects, policy ID `1` always allows. These are handled inline by @@ -49,6 +54,7 @@ use tracing::info; use super::{builtin_authorization, events::PolicyEvent, policy_set::PolicySet}; use crate::l1_state::versioned::HeightVersioned; +use zone_precompiles::policy::{ReceivePolicy, ReceivePolicyDecision}; /// Thread-safe TIP-403 policy cache backed by an `Arc>`. #[derive(Debug, Clone, Deref, Default)] @@ -145,6 +151,8 @@ pub struct PolicyCacheInner { /// `TransferPolicyUpdate`, so pre-caching all policies avoids RPC /// round-trips on policy switch. The memory overhead is negligible. policies: HashMap, + /// Per-account TIP-1028 receive-policy configuration. + receive_policies: HashMap>, /// Highest L1 block number processed by the engine. /// /// This equals the last block height the engine has processed and @@ -194,6 +202,24 @@ impl PolicyCacheInner { entry.compound = Some(compound); } + /// Returns an account's receive policy at the given block, if cached. + pub fn get_receive_policy(&self, account: Address, block_number: u64) -> Option { + self.receive_policies.get(&account)?.get(block_number) + } + + /// Records an account receive policy at the given block. + pub fn set_receive_policy( + &mut self, + account: Address, + block_number: u64, + policy: ReceivePolicy, + ) { + self.receive_policies + .entry(account) + .or_default() + .set(block_number, policy); + } + /// Returns a reference to the per-policy-ID records for direct inspection. pub fn policies(&self) -> &HashMap { &self.policies @@ -209,6 +235,11 @@ impl PolicyCacheInner { self.tokens.len() } + /// Returns the number of cached receive-policy account records. + pub fn num_receive_policies(&self) -> usize { + self.receive_policies.len() + } + /// Returns the highest L1 block number processed by the cache. /// /// Returns `0` if no events have been applied yet. @@ -338,6 +369,40 @@ impl PolicyCacheInner { } } + /// Validate `receiver`'s receive policy for an inbound token movement. + /// + /// Returns `None` when the receiver policy or one of its simple sub-policies + /// is not cached and the caller should fall back to L1 RPC. + pub fn check_receive_policy( + &self, + token: Address, + sender: Address, + receiver: Address, + block_number: u64, + ) -> Option { + let policy = self.get_receive_policy(receiver, block_number)?; + if !policy.has_receive_policy { + return Some(ReceivePolicyDecision::Authorized); + } + + if !self.check_simple(policy.token_filter_id, token, block_number)? { + return Some(ReceivePolicyDecision::Blocked { + reason: tempo_contracts::precompiles::ITIP403Registry::BlockedReason::TOKEN_FILTER, + recovery_authority: policy.recovery_authority, + }); + } + + if !self.check_simple(policy.sender_policy_id, sender, block_number)? { + return Some(ReceivePolicyDecision::Blocked { + reason: + tempo_contracts::precompiles::ITIP403Registry::BlockedReason::RECEIVE_POLICY, + recovery_authority: policy.recovery_authority, + }); + } + + Some(ReceivePolicyDecision::Authorized) + } + /// Apply a batch of decoded policy events for a single block. /// /// This is the primary ingestion path used by [`L1Subscriber`](crate::l1::L1Subscriber). @@ -383,6 +448,25 @@ impl PolicyCacheInner { }, ); } + PolicyEvent::ReceivePolicyUpdated { + account, + sender_policy_id, + token_filter_id, + recovery_authority, + } => { + self.set_receive_policy( + *account, + block_number, + ReceivePolicy { + has_receive_policy: true, + sender_policy_id: *sender_policy_id, + sender_policy_type: policy_type_hint(*sender_policy_id), + token_filter_id: *token_filter_id, + token_filter_type: policy_type_hint(*token_filter_id), + recovery_authority: *recovery_authority, + }, + ); + } } } } @@ -392,6 +476,7 @@ impl PolicyCacheInner { pub fn clear(&mut self) { self.tokens.clear(); self.policies.clear(); + self.receive_policies.clear(); } /// Collapse all history before `min_block` into single baseline entries. @@ -402,6 +487,9 @@ impl PolicyCacheInner { for policy in self.policies.values_mut() { policy.policy_set.flatten(min_block); } + for policy in self.receive_policies.values_mut() { + policy.flatten(min_block); + } } /// Advance the baseline to `new_height` for all tracked entries. @@ -426,6 +514,16 @@ impl PolicyCacheInner { for policy in self.policies.values_mut() { policy.policy_set.advance(new_height); } + for policy in self.receive_policies.values_mut() { + policy.advance(new_height); + } + } +} + +fn policy_type_hint(policy_id: u64) -> PolicyType { + match policy_id { + 1 => PolicyType::BLACKLIST, + _ => PolicyType::WHITELIST, } } @@ -1298,4 +1396,61 @@ mod tests { Some(true) ); } + + #[test] + fn apply_events_with_receive_policy_blocks_sender() { + let mut cache = PolicyCacheInner::default(); + cache.set_policy_type(2, PolicyType::BLACKLIST); + cache.set_policy_status(2, USER_A, 10, true); + + cache.apply_events( + 10, + &[PolicyEvent::ReceivePolicyUpdated { + account: USER_B, + sender_policy_id: 2, + token_filter_id: 1, + recovery_authority: USER_B, + }], + ); + + assert_eq!( + cache + .get_receive_policy(USER_B, 10) + .map(|p| p.has_receive_policy), + Some(true) + ); + assert_eq!( + cache.check_receive_policy(TOKEN, USER_A, USER_B, 10), + Some(ReceivePolicyDecision::Blocked { + reason: + tempo_contracts::precompiles::ITIP403Registry::BlockedReason::RECEIVE_POLICY, + recovery_authority: USER_B, + }) + ); + } + + #[test] + fn receive_policy_reports_token_filter_before_sender_policy() { + let mut cache = PolicyCacheInner::default(); + cache.set_policy_type(2, PolicyType::BLACKLIST); + cache.set_policy_status(2, TOKEN, 10, true); + + cache.apply_events( + 10, + &[PolicyEvent::ReceivePolicyUpdated { + account: USER_B, + sender_policy_id: 0, + token_filter_id: 2, + recovery_authority: USER_B, + }], + ); + + assert_eq!( + cache.check_receive_policy(TOKEN, USER_A, USER_B, 10), + Some(ReceivePolicyDecision::Blocked { + reason: tempo_contracts::precompiles::ITIP403Registry::BlockedReason::TOKEN_FILTER, + recovery_authority: USER_B, + }) + ); + } } diff --git a/crates/tempo-zone/src/l1_state/tip403/events.rs b/crates/tempo-zone/src/l1_state/tip403/events.rs index 16d4babf..973d2734 100644 --- a/crates/tempo-zone/src/l1_state/tip403/events.rs +++ b/crates/tempo-zone/src/l1_state/tip403/events.rs @@ -35,18 +35,26 @@ pub enum PolicyEvent { recipient_policy_id: u64, mint_recipient_policy_id: u64, }, + /// An account's TIP-1028 receive policy changed on L1 (`ReceivePolicyUpdated`). + ReceivePolicyUpdated { + account: Address, + sender_policy_id: u64, + token_filter_id: u64, + recovery_authority: Address, + }, } impl PolicyEvent { /// Try to decode an `ITIP403Registry` log into a [`PolicyEvent`]. /// - /// Handles `WhitelistUpdated`, `BlacklistUpdated`, `PolicyCreated`, and - /// `CompoundPolicyCreated` events. `PolicyAdminUpdated` is logged but ignored - /// (returns `None`). Returns `None` for unrecognised logs. + /// Handles `WhitelistUpdated`, `BlacklistUpdated`, `PolicyCreated`, + /// `CompoundPolicyCreated`, and `ReceivePolicyUpdated` events. + /// `PolicyAdminUpdated` is logged but ignored (returns `None`). Returns + /// `None` for unrecognised logs. pub fn decode_registry(log: &alloy_rpc_types_eth::Log) -> Option { use tempo_contracts::precompiles::ITIP403Registry::{ BlacklistUpdated, CompoundPolicyCreated, ITIP403RegistryEvents, PolicyCreated, - WhitelistUpdated, + ReceivePolicyUpdated, WhitelistUpdated, }; let event = match ITIP403RegistryEvents::decode_log(&log.inner) { @@ -138,12 +146,26 @@ impl PolicyEvent { ); None } - ITIP403RegistryEvents::ReceivePolicyUpdated(event) => { + ITIP403RegistryEvents::ReceivePolicyUpdated(ReceivePolicyUpdated { + account, + senderPolicyId, + tokenFilterId, + recoveryAuthority, + .. + }) => { tracing::debug!( - policy_id = ?event, - "Receive policy updated on L1 (ignored)" + account = %account, + sender_policy_id = senderPolicyId, + token_filter_id = tokenFilterId, + recovery_authority = %recoveryAuthority, + "Receive policy updated on L1" ); - None + Some(Self::ReceivePolicyUpdated { + account, + sender_policy_id: senderPolicyId, + token_filter_id: tokenFilterId, + recovery_authority: recoveryAuthority, + }) } } } diff --git a/crates/tempo-zone/src/l1_state/tip403/provider.rs b/crates/tempo-zone/src/l1_state/tip403/provider.rs index 51fd58a9..303a521b 100644 --- a/crates/tempo-zone/src/l1_state/tip403/provider.rs +++ b/crates/tempo-zone/src/l1_state/tip403/provider.rs @@ -18,12 +18,14 @@ use tempo_contracts::precompiles::{ ITIP20, ITIP403Registry, ITIP403Registry::PolicyType, TIP403_REGISTRY_ADDRESS, }; use tracing::{debug, info, warn}; -use zone_precompiles::policy::PolicyCheck; +use zone_precompiles::policy::{PolicyCheck, ReceivePolicy, ReceivePolicyDecision}; use super::builtin_authorization; use super::{AuthRole, CompoundData, PolicyCache, metrics::Tip403Metrics}; +type BlockedReason = tempo_contracts::precompiles::ITIP403Registry::BlockedReason; + /// Cache-first, RPC-fallback provider for TIP-403 policy authorization. /// /// Wraps a [`PolicyCache`] (populated by the [`L1Subscriber`](crate::l1::L1Subscriber)) @@ -541,6 +543,174 @@ impl PolicyProvider { }) } + /// Cache-first, RPC-fallback receive-policy lookup (sync). + pub fn receive_policy_sync(&self, account: Address) -> Result { + let block_number = self.cache.read().last_l1_block(); + tokio::task::block_in_place(|| { + self.runtime_handle + .block_on(self.resolve_receive_policy(account, block_number)) + }) + } + + /// Cache-first, RPC-fallback receive-policy validation (sync). + pub fn check_receive_policy( + &self, + token: Address, + sender: Address, + receiver: Address, + ) -> Result { + self.metrics.authorization_checks_total.increment(1); + + let block_number = self.cache.read().last_l1_block(); + if let Some(decision) = + self.cache + .read() + .check_receive_policy(token, sender, receiver, block_number) + { + self.metrics.cache_hits.increment(1); + return Ok(decision); + } + + self.metrics.cache_misses.increment(1); + debug!( + %token, %sender, %receiver, block_number, + "Receive policy cache miss, fetching from L1 RPC" + ); + tokio::task::block_in_place(|| { + self.runtime_handle + .block_on(self.fetch_receive_policy_decision(token, sender, receiver, block_number)) + }) + } + + /// Resolve an account receive policy, filling in policy types for event-sourced entries. + async fn resolve_receive_policy( + &self, + account: Address, + block_number: u64, + ) -> Result { + let policy = match self.cache.read().get_receive_policy(account, block_number) { + Some(policy) => policy, + None => self.fetch_receive_policy(account, block_number).await?, + }; + + if !policy.has_receive_policy { + return Ok(policy); + } + + let sender_policy_type = self + .resolve_receive_policy_type(policy.sender_policy_id, block_number) + .await?; + let token_filter_type = self + .resolve_receive_policy_type(policy.token_filter_id, block_number) + .await?; + + Ok(ReceivePolicy { + sender_policy_type, + token_filter_type, + ..policy + }) + } + + /// Fetch an account receive policy from L1 and cache the result. + async fn fetch_receive_policy( + &self, + account: Address, + block_number: u64, + ) -> Result { + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, &self.provider); + let result = registry + .receivePolicy(account) + .block(BlockId::number(block_number)) + .call() + .await + .map_err(|e| { + self.metrics.rpc_errors.increment(1); + warn!(%account, block_number, %e, "receivePolicy RPC failed"); + eyre::eyre!("receivePolicy RPC failed for account {account}: {e}") + })?; + + let policy = ReceivePolicy { + has_receive_policy: result.hasReceivePolicy, + sender_policy_id: result.senderPolicyId, + sender_policy_type: result.senderPolicyType, + token_filter_id: result.tokenFilterId, + token_filter_type: result.tokenFilterType, + recovery_authority: result.recoveryAuthority, + }; + + { + let mut cache = self.cache.write(); + cache.set_receive_policy(account, block_number, policy); + if policy.has_receive_policy { + if policy.sender_policy_id > 1 { + cache.set_policy_type(policy.sender_policy_id, policy.sender_policy_type); + } + if policy.token_filter_id > 1 { + cache.set_policy_type(policy.token_filter_id, policy.token_filter_type); + } + } + } + + info!( + %account, + has_receive_policy = policy.has_receive_policy, + sender_policy_id = policy.sender_policy_id, + token_filter_id = policy.token_filter_id, + block_number, + "Cached receive policy from L1 RPC" + ); + + Ok(policy) + } + + async fn resolve_receive_policy_type( + &self, + policy_id: u64, + block_number: u64, + ) -> Result { + match policy_id { + 0 => Ok(PolicyType::WHITELIST), + 1 => Ok(PolicyType::BLACKLIST), + _ => self.resolve_policy_type(policy_id, block_number).await, + } + } + + /// Fetch enough L1 state to validate one inbound transfer or mint. + async fn fetch_receive_policy_decision( + &self, + token: Address, + sender: Address, + receiver: Address, + block_number: u64, + ) -> Result { + let policy = self.resolve_receive_policy(receiver, block_number).await?; + if !policy.has_receive_policy { + return Ok(ReceivePolicyDecision::Authorized); + } + + if !self + .resolve_simple_sub_policy(policy.token_filter_id, token, block_number) + .await? + { + return Ok(ReceivePolicyDecision::Blocked { + reason: BlockedReason::TOKEN_FILTER, + recovery_authority: policy.recovery_authority, + }); + } + + if !self + .resolve_simple_sub_policy(policy.sender_policy_id, sender, block_number) + .await? + { + return Ok(ReceivePolicyDecision::Blocked { + reason: BlockedReason::RECEIVE_POLICY, + recovery_authority: policy.recovery_authority, + }); + } + + Ok(ReceivePolicyDecision::Authorized) + } + /// Cache-first, RPC-fallback policy existence check (sync). /// /// Returns `Ok(true)` if the policy exists, `Ok(false)` if it doesn't, @@ -662,4 +832,26 @@ impl PolicyCheck for PolicyProvider { let cache = self.cache.read(); cache.policies().keys().max().map_or(2, |max| max + 1) } + + fn receive_policy(&self, account: Address) -> Result { + self.receive_policy_sync(account).map_err(|e| { + zone_precompiles::zone_rpc_error(format!( + "receivePolicy failed for account {account}: {e}" + )) + }) + } + + fn validate_receive_policy( + &self, + token: Address, + sender: Address, + receiver: Address, + ) -> Result { + self.check_receive_policy(token, sender, receiver) + .map_err(|e| { + zone_precompiles::zone_rpc_error(format!( + "validateReceivePolicy failed for token {token} sender {sender} receiver {receiver}: {e}" + )) + }) + } }