diff --git a/crates/precompiles/src/ztip20.rs b/crates/precompiles/src/ztip20.rs index 9108cd4b2..026639125 100644 --- a/crates/precompiles/src/ztip20.rs +++ b/crates/precompiles/src/ztip20.rs @@ -154,6 +154,12 @@ impl ZoneTip20Token

{ if let Some(revert) = self.reject_crossed_mint_caller(caller) { return Some(revert); } + // Skip TIP-403 enforcement for deposit mints from ZoneInbox. + // The sequencer already checks the recipient, and failed-deposit + // refunds to the sender must always succeed to prevent zone lockup. + if caller == ZONE_INBOX_ADDRESS { + return None; + } let call = decode_or_revert!(ITIP20::mintCall, args); self.enforce_mint(address, call.to) } @@ -161,6 +167,9 @@ impl ZoneTip20Token

{ if let Some(revert) = self.reject_crossed_mint_caller(caller) { return Some(revert); } + if caller == ZONE_INBOX_ADDRESS { + return None; + } let call = decode_or_revert!(ITIP20::mintWithMemoCall, args); self.enforce_mint(address, call.to) } @@ -1140,6 +1149,56 @@ mod tests { Ok(()) } + #[test] + fn inbox_mint_skips_tip403_for_blocked_recipient() -> TestResult { + // Simulates a failed encrypted deposit refund to a TIP-403 blocked sender. + // The zone must not enforce TIP-403 on deposit mints from ZoneInbox, + // otherwise the zone locks up. + let mut harness = PrecompileHarness::new(MockPolicyProvider { + mint_authorized: false, + transfer_authorized: false, + policy_id: 1, + fail_policy_id_resolution: false, + })?; + + // Mint from ZoneInbox to a blocked address must succeed. + let inbox_mint = harness.call( + ZONE_INBOX_ADDRESS, + ITIP20::mintCall { + to: harness.bob, + amount: U256::from(99_000u64), + } + .abi_encode() + .into(), + 100_000, + false, + )?; + assert!( + !inbox_mint.reverted, + "ZoneInbox deposit mint must not be blocked by TIP-403" + ); + assert_eq!(harness.balance_of(harness.bob)?, U256::from(99_000u64)); + + // Mint from the issuer to the same blocked address must still be rejected. + let issuer_mint = harness.call( + harness.issuer, + ITIP20::mintCall { + to: harness.bob, + amount: U256::from(1_000u64), + } + .abi_encode() + .into(), + 100_000, + false, + )?; + assert!( + issuer_mint.reverted, + "issuer mint to blocked address must still be rejected" + ); + + Ok(()) + } + #[test] fn has_role_enforces_account_or_sequencer_access() -> TestResult { let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; diff --git a/crates/tempo-zone/src/l1.rs b/crates/tempo-zone/src/l1.rs index 258569a88..ed09371aa 100644 --- a/crates/tempo-zone/src/l1.rs +++ b/crates/tempo-zone/src/l1.rs @@ -1196,7 +1196,7 @@ impl L1BlockDeposits { // Check TIP-403 policy via the provider (cache-first, RPC fallback). // Errors are propagated so the engine retries rather than allowing // unauthorized deposits through. - let authorized = policy_provider + let recipient_authorized = policy_provider .is_authorized_async( d.token, dec.to, @@ -1205,7 +1205,7 @@ impl L1BlockDeposits { ) .await?; - let recipient = if authorized { + let recipient = if recipient_authorized { debug!( target: "zone::engine", recipient = %dec.to, @@ -1214,12 +1214,38 @@ impl L1BlockDeposits { ); dec.to } else { + // Best-effort check whether the sender is also + // blocked. The zone precompile skips TIP-403 on + // deposit mints so this won't cause a lockup; we + // only log it for observability. + let sender_blocked = match policy_provider + .is_authorized_async( + d.token, + d.sender, + l1_block_number, + crate::l1_state::AuthRole::MintRecipient, + ) + .await + { + Ok(authorized) => !authorized, + Err(error) => { + warn!( + target: "zone::engine", + sender = %d.sender, + %error, + "Failed to check sender TIP-403 status, continuing" + ); + false + } + }; + warn!( target: "zone::engine", sender = %d.sender, recipient = %dec.to, token = %d.token, amount = %d.amount, + sender_blocked, "Encrypted deposit recipient unauthorized, redirecting to sender" ); d.sender