Skip to main content

cast/cmd/
receive_policy.rs

1use crate::{
2    cmd::{
3        keychain::ensure_tempo_precompile_active,
4        tip20::{resolve_tip20_signer, send_tip20_transaction},
5    },
6    tempo::print_payload,
7    tx::{SendTxOpts, TxParams},
8};
9use alloy_ens::NameOrAddress;
10use alloy_primitives::{Address, Bytes, U256, keccak256};
11use alloy_provider::Provider;
12use alloy_sol_types::{SolCall, SolValue};
13use clap::{Parser, Subcommand};
14use eyre::{Result, WrapErr, ensure};
15use foundry_cli::{
16    json::print_json_success,
17    opts::RpcOpts,
18    utils::{LoadConfig, get_provider},
19};
20use foundry_common::{provider::ProviderBuilder, shell};
21use foundry_evm::hardfork::TempoHardfork;
22use foundry_evm_networks::TEMPO_PRECOMPILE_ADDRESSES;
23use serde_json::{Value, json};
24use std::str::FromStr;
25use tempo_alloy::TempoNetwork;
26use tempo_contracts::precompiles::{
27    ADDRESS_REGISTRY_ADDRESS, IAddressRegistry, IReceivePolicyGuard, ITIP403Registry,
28    RECEIVE_POLICY_GUARD_ADDRESS, TIP403_REGISTRY_ADDRESS,
29};
30use tempo_primitives::TempoAddressExt;
31
32/// Account-level receive policy operations (Tempo).
33#[derive(Debug, Parser, Clone)]
34pub enum ReceivePolicySubcommand {
35    /// Set the caller's TIP-403 receive policy.
36    ///
37    /// Create the sender and token-filter policies referenced here with `cast tip403 create`.
38    Set {
39        /// Sender policy ID to evaluate for inbound transfer originators.
40        sender_policy_id: u64,
41
42        /// Token filter policy ID to evaluate for inbound TIP-20 tokens.
43        token_filter_id: u64,
44
45        /// Address authorized to recover held receipts. Defaults to originator recovery.
46        #[arg(long, value_name = "ADDRESS", default_value_t = Address::ZERO)]
47        recovery_authority: Address,
48
49        /// Print the calldata and receive-policy warning without sending a transaction.
50        #[arg(long, visible_alias = "dry-run")]
51        preview: bool,
52
53        /// Suppress the originator-recovery/system-sender warning.
54        #[arg(long)]
55        force: bool,
56
57        #[command(flatten)]
58        send_tx: SendTxOpts,
59
60        #[command(flatten)]
61        tx: TxParams,
62    },
63
64    /// Get an account's configured receive policy.
65    Get {
66        /// Account whose receive policy should be queried.
67        #[arg(value_parser = NameOrAddress::from_str)]
68        account: NameOrAddress,
69
70        #[command(flatten)]
71        rpc: RpcOpts,
72    },
73
74    /// Validate whether an inbound TIP-20 transfer or mint would be credited or held.
75    Validate {
76        /// TIP-20 token address.
77        #[arg(value_parser = NameOrAddress::from_str)]
78        token: NameOrAddress,
79
80        /// Inbound transfer sender or mint originator.
81        #[arg(value_parser = NameOrAddress::from_str)]
82        sender: NameOrAddress,
83
84        /// Intended recipient.
85        #[arg(value_parser = NameOrAddress::from_str)]
86        receiver: NameOrAddress,
87
88        #[command(flatten)]
89        rpc: RpcOpts,
90    },
91
92    /// Blocked receive-policy receipt utilities.
93    Receipt {
94        #[command(subcommand)]
95        command: ReceivePolicyReceiptSubcommand,
96    },
97
98    /// Claim held TIP-20 funds using a blocked receive-policy receipt.
99    Claim {
100        /// Desired release target. The guard decides onchain whether to resume or reroute.
101        #[arg(value_parser = NameOrAddress::from_str)]
102        to: NameOrAddress,
103
104        /// ABI-encoded ReceivePolicyGuard claim receipt.
105        receipt: Bytes,
106
107        #[command(flatten)]
108        send_tx: SendTxOpts,
109
110        #[command(flatten)]
111        tx: TxParams,
112    },
113}
114
115#[derive(Debug, Subcommand, Clone)]
116pub enum ReceivePolicyReceiptSubcommand {
117    /// Decode an ABI-encoded ReceivePolicyGuard claim receipt.
118    Decode {
119        /// ABI-encoded ReceivePolicyGuard claim receipt.
120        receipt: Bytes,
121    },
122
123    /// Query the held TIP-20 balance for a claim receipt.
124    Balance {
125        /// ABI-encoded ReceivePolicyGuard claim receipt.
126        receipt: Bytes,
127
128        #[command(flatten)]
129        rpc: RpcOpts,
130    },
131
132    /// Burn held funds for a blocked receipt when authorized by the token.
133    Burn {
134        /// ABI-encoded ReceivePolicyGuard claim receipt.
135        receipt: Bytes,
136
137        #[command(flatten)]
138        send_tx: Box<SendTxOpts>,
139
140        #[command(flatten)]
141        tx: Box<TxParams>,
142    },
143}
144
145impl ReceivePolicySubcommand {
146    pub async fn run(self) -> Result<()> {
147        match self {
148            Self::Set {
149                sender_policy_id,
150                token_filter_id,
151                recovery_authority,
152                preview,
153                force,
154                send_tx,
155                tx,
156            } => {
157                set(
158                    sender_policy_id,
159                    token_filter_id,
160                    recovery_authority,
161                    preview,
162                    force,
163                    send_tx,
164                    tx,
165                )
166                .await?
167            }
168            Self::Get { account, rpc } => get(account, rpc).await?,
169            Self::Validate { token, sender, receiver, rpc } => {
170                validate(token, sender, receiver, rpc).await?
171            }
172            Self::Receipt { command } => match command {
173                ReceivePolicyReceiptSubcommand::Decode { receipt } => decode_receipt(receipt)?,
174                ReceivePolicyReceiptSubcommand::Balance { receipt, rpc } => {
175                    receipt_balance(receipt, rpc).await?
176                }
177                ReceivePolicyReceiptSubcommand::Burn { receipt, send_tx, tx } => {
178                    burn_receipt(receipt, *send_tx, *tx).await?
179                }
180            },
181            Self::Claim { to, receipt, send_tx, tx } => claim(to, receipt, send_tx, tx).await?,
182        }
183
184        Ok(())
185    }
186}
187
188async fn set(
189    sender_policy_id: u64,
190    token_filter_id: u64,
191    recovery_authority: Address,
192    preview: bool,
193    force: bool,
194    send_tx: SendTxOpts,
195    tx: TxParams,
196) -> Result<()> {
197    // Reject authorities that can never satisfy `ReceivePolicyGuard.claim()` before doing
198    // anything else; `--force` only suppresses the softer originator-recovery warning below.
199    if let Some(message) = invalid_recovery_authority_message(recovery_authority) {
200        eyre::bail!("{message}");
201    }
202
203    let warning = if force {
204        None
205    } else {
206        recovery_warning(sender_policy_id, recovery_authority, &send_tx.eth.rpc).await?
207    };
208
209    let call = ITIP403Registry::setReceivePolicyCall {
210        senderPolicyId: sender_policy_id,
211        tokenFilterId: token_filter_id,
212        recoveryAuthority: recovery_authority,
213    };
214    let calldata = Bytes::from(call.abi_encode());
215
216    if preview {
217        let payload = json!({
218            "action": "set_receive_policy",
219            "registry": format!("{TIP403_REGISTRY_ADDRESS}"),
220            "sender_policy_id": sender_policy_id,
221            "token_filter_id": token_filter_id,
222            "recovery_authority": format!("{recovery_authority}"),
223            "recovery_mode": recovery_mode(recovery_authority),
224            "calldata": format!("{calldata}"),
225            "warning": warning,
226        });
227        if shell::is_json() {
228            print_json_success(payload)?;
229        } else {
230            sh_println!(
231                "Registry:           {TIP403_REGISTRY_ADDRESS}\n\
232                 Sender policy ID:   {sender_policy_id}\n\
233                 Token filter ID:    {token_filter_id}\n\
234                 Recovery authority: {recovery_authority}\n\
235                 Recovery mode:      {}\n\
236                 Calldata:           {calldata}",
237                recovery_mode(recovery_authority)
238            )?;
239            if let Some(warning) = warning.as_deref() {
240                sh_warn!("{warning}")?;
241            }
242        }
243        return Ok(());
244    }
245
246    if let Some(warning) = warning.as_deref() {
247        sh_warn!("{warning}")?;
248    }
249
250    let (signer, access_key) = resolve_tip20_signer(&send_tx, &tx).await?;
251    send_tip20_transaction(
252        NameOrAddress::Address(TIP403_REGISTRY_ADDRESS),
253        "setReceivePolicy(uint64,uint64,address)",
254        vec![
255            sender_policy_id.to_string(),
256            token_filter_id.to_string(),
257            recovery_authority.to_string(),
258        ],
259        send_tx,
260        tx,
261        signer,
262        access_key,
263    )
264    .await
265}
266
267async fn get(account: NameOrAddress, rpc: RpcOpts) -> Result<()> {
268    let config = rpc.load_config()?;
269    let provider = get_provider(&config)?;
270    let account = account.resolve(&provider).await?;
271    let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider);
272    let policy = registry.receivePolicy(account).call().await?;
273
274    let payload = json!({
275        "account": format!("{account}"),
276        "has_receive_policy": policy.hasReceivePolicy,
277        "sender_policy_id": policy.senderPolicyId,
278        "sender_policy_type": policy_type(policy.senderPolicyType),
279        "token_filter_id": policy.tokenFilterId,
280        "token_filter_type": policy_type(policy.tokenFilterType),
281        "recovery_authority": format!("{}", policy.recoveryAuthority),
282        "recovery_mode": recovery_mode(policy.recoveryAuthority),
283    });
284    print_payload(payload, |payload| {
285        sh_println!(
286            "Account:            {}\n\
287             Has receive policy: {}\n\
288             Sender policy ID:   {}\n\
289             Sender policy type: {}\n\
290             Token filter ID:    {}\n\
291             Token filter type:  {}\n\
292             Recovery authority: {}\n\
293             Recovery mode:      {}",
294            payload["account"].as_str().unwrap_or_default(),
295            payload["has_receive_policy"].as_bool().unwrap_or_default(),
296            payload["sender_policy_id"],
297            payload["sender_policy_type"].as_str().unwrap_or_default(),
298            payload["token_filter_id"],
299            payload["token_filter_type"].as_str().unwrap_or_default(),
300            payload["recovery_authority"].as_str().unwrap_or_default(),
301            payload["recovery_mode"].as_str().unwrap_or_default(),
302        )
303    })
304}
305
306async fn validate(
307    token: NameOrAddress,
308    sender: NameOrAddress,
309    receiver: NameOrAddress,
310    rpc: RpcOpts,
311) -> Result<()> {
312    let config = rpc.load_config()?;
313    let provider = get_provider(&config)?;
314    let token = token.resolve(&provider).await?;
315    let sender = sender.resolve(&provider).await?;
316    let receiver = receiver.resolve(&provider).await?;
317    let effective_receiver = IAddressRegistry::new(ADDRESS_REGISTRY_ADDRESS, &provider)
318        .resolveRecipient(receiver)
319        .call()
320        .await?;
321    let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider);
322    let result = registry.validateReceivePolicy(token, sender, effective_receiver).call().await?;
323    let delivery_state = if result.authorized { "credited" } else { "held" };
324
325    let payload = validate_payload(
326        token,
327        sender,
328        receiver,
329        effective_receiver,
330        result.authorized,
331        result.blockedReason,
332        delivery_state,
333    );
334    print_payload(payload, |payload| {
335        sh_println!(
336            "Token:          {}\n\
337             Sender:         {}\n\
338             Receiver:       {}\n\
339             Effective recv: {}\n\
340             Authorized:     {}\n\
341             Blocked reason: {}\n\
342             Delivery state: {}",
343            payload["token"].as_str().unwrap_or_default(),
344            payload["sender"].as_str().unwrap_or_default(),
345            payload["receiver"].as_str().unwrap_or_default(),
346            payload["effective_receiver"].as_str().unwrap_or_default(),
347            payload["authorized"].as_bool().unwrap_or_default(),
348            payload["blocked_reason"].as_str().unwrap_or_default(),
349            payload["delivery_state"].as_str().unwrap_or_default(),
350        )
351    })
352}
353
354fn validate_payload(
355    token: Address,
356    sender: Address,
357    receiver: Address,
358    effective_receiver: Address,
359    authorized: bool,
360    blocked_reason_value: ITIP403Registry::BlockedReason,
361    delivery_state: &str,
362) -> Value {
363    json!({
364        "token": format!("{token}"),
365        "sender": format!("{sender}"),
366        "receiver": format!("{receiver}"),
367        "effective_receiver": format!("{effective_receiver}"),
368        "receiver_was_resolved": receiver != effective_receiver,
369        "authorized": authorized,
370        "blocked_reason": blocked_reason(blocked_reason_value),
371        "delivery_state": delivery_state,
372    })
373}
374
375fn decode_receipt(receipt: Bytes) -> Result<()> {
376    let decoded = decode_claim_receipt(&receipt)?;
377    let payload = receipt_payload(&receipt, &decoded, None);
378    print_payload(payload, |payload| {
379        print_decoded_receipt(payload)?;
380        print_claim_hint(payload)
381    })
382}
383
384async fn receipt_balance(receipt: Bytes, rpc: RpcOpts) -> Result<()> {
385    let config = rpc.load_config()?;
386    let provider = get_provider(&config)?;
387    let guard = IReceivePolicyGuard::new(RECEIVE_POLICY_GUARD_ADDRESS, provider);
388    let amount = guard.balanceOf(receipt.clone()).call().await?;
389    let decoded = decode_claim_receipt(&receipt)?;
390    let payload = receipt_payload(&receipt, &decoded, Some(amount));
391    print_payload(payload, |payload| {
392        print_decoded_receipt(payload)?;
393        sh_println!("Held balance: {}", payload["held_balance"].as_str().unwrap_or_default())
394    })
395}
396
397// The ReceivePolicyGuard precompile only has code from T6 onwards, so a pre-T6 call would
398// succeed as a silent no-op instead of reverting. Fail early with a clear message.
399async fn ensure_receive_policy_t6<P>(provider: &P, command: &str) -> Result<()>
400where
401    P: Provider<TempoNetwork>,
402{
403    ensure_tempo_precompile_active(
404        provider,
405        TempoHardfork::T6,
406        RECEIVE_POLICY_GUARD_ADDRESS,
407        &format!("{command} requires a Tempo T6-capable ReceivePolicy RPC"),
408    )
409    .await
410}
411
412async fn burn_receipt(receipt: Bytes, send_tx: SendTxOpts, tx: TxParams) -> Result<()> {
413    decode_claim_receipt(&receipt)?;
414    let config = send_tx.eth.rpc.load_config()?;
415    let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
416    ensure_receive_policy_t6(&provider, "cast receive-policy receipt burn").await?;
417    let (signer, access_key) = resolve_tip20_signer(&send_tx, &tx).await?;
418    send_tip20_transaction(
419        NameOrAddress::Address(RECEIVE_POLICY_GUARD_ADDRESS),
420        "burnBlockedReceipt(bytes)",
421        vec![format!("{receipt}")],
422        send_tx,
423        tx,
424        signer,
425        access_key,
426    )
427    .await
428}
429
430async fn claim(to: NameOrAddress, receipt: Bytes, send_tx: SendTxOpts, tx: TxParams) -> Result<()> {
431    decode_claim_receipt(&receipt)?;
432    let config = send_tx.eth.rpc.load_config()?;
433    let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
434    ensure_receive_policy_t6(&provider, "cast receive-policy claim").await?;
435    let to = to.resolve(&provider).await?;
436    let (signer, access_key) = resolve_tip20_signer(&send_tx, &tx).await?;
437    send_tip20_transaction(
438        NameOrAddress::Address(RECEIVE_POLICY_GUARD_ADDRESS),
439        "claim(address,bytes)",
440        vec![to.to_string(), format!("{receipt}")],
441        send_tx,
442        tx,
443        signer,
444        access_key,
445    )
446    .await
447}
448
449async fn recovery_warning(
450    sender_policy_id: u64,
451    recovery_authority: Address,
452    rpc: &RpcOpts,
453) -> Result<Option<String>> {
454    if recovery_authority != Address::ZERO {
455        return Ok(None);
456    }
457
458    let config = rpc.load_config()?;
459    let provider = get_provider(&config)?;
460    let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider);
461    let mut blocked = Vec::new();
462    for address in TEMPO_PRECOMPILE_ADDRESSES {
463        if !registry.isAuthorizedSender(sender_policy_id, *address).call().await.unwrap_or(true) {
464            blocked.push(*address);
465        }
466    }
467
468    if blocked.is_empty() {
469        return Ok(None);
470    }
471
472    Ok(Some(format!(
473        "originator recovery is enabled because recovery authority is 0x0, but sender policy \
474         {sender_policy_id} blocks {} Tempo system/precompile sender(s): {}. Receipts created \
475         for those senders may not be claimable by a user. Choose receiver or third-party \
476         recovery authority when blocking system senders, or pass --force if this is intentional.",
477        blocked.len(),
478        blocked.iter().map(Address::to_string).collect::<Vec<_>>().join(", ")
479    )))
480}
481
482/// Returns an error message when `recovery_authority` could never pass
483/// `ReceivePolicyGuard.claim()` and would leave held receipts unclaimable.
484///
485/// `address(0)` is the originator-recovery sentinel and a receiver's own address is valid
486/// (receiver recovery), so those pass. Virtual and TIP-20 addresses are always rejected. Fixed
487/// system precompiles are rejected conservatively against the full known list (a superset of the
488/// registry's spec-aware check), since a not-yet-active precompile is never a sound authority and
489/// becomes unclaimable once it activates.
490fn invalid_recovery_authority_message(recovery_authority: Address) -> Option<String> {
491    if recovery_authority == Address::ZERO {
492        return None;
493    }
494    if recovery_authority.is_virtual() {
495        return Some("recovery authority cannot be a TIP-1022 virtual address".to_string());
496    }
497    if recovery_authority.is_tip20() {
498        return Some("recovery authority cannot be a TIP-20 token address".to_string());
499    }
500    if TEMPO_PRECOMPILE_ADDRESSES.contains(&recovery_authority) {
501        return Some(format!(
502            "recovery authority cannot be a fixed Tempo system precompile: {recovery_authority}"
503        ));
504    }
505    None
506}
507
508fn decode_claim_receipt(receipt: &Bytes) -> Result<IReceivePolicyGuard::ClaimReceiptV1> {
509    let decoded = IReceivePolicyGuard::ClaimReceiptV1::abi_decode(receipt)
510        .wrap_err("invalid ReceivePolicyGuard claim receipt")?;
511
512    ensure!(
513        decoded.version == 1,
514        "unsupported ReceivePolicyGuard claim receipt version {}",
515        decoded.version
516    );
517    ensure!(decoded.token != Address::ZERO, "ReceivePolicyGuard claim receipt token is zero");
518    ensure!(
519        decoded.recipient != RECEIVE_POLICY_GUARD_ADDRESS,
520        "ReceivePolicyGuard claim receipt recipient cannot be the guard precompile"
521    );
522    ensure!(
523        matches!(
524            decoded.blockedReason,
525            reason if reason == ITIP403Registry::BlockedReason::TOKEN_FILTER as u8 ||
526                reason == ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8
527        ),
528        "ReceivePolicyGuard claim receipt blocked reason is not claimable"
529    );
530    ensure!(
531        matches!(
532            decoded.kind,
533            IReceivePolicyGuard::InboundKind::TRANSFER | IReceivePolicyGuard::InboundKind::MINT
534        ),
535        "ReceivePolicyGuard claim receipt inbound kind is unknown"
536    );
537
538    Ok(decoded)
539}
540
541fn receipt_payload(
542    receipt: &Bytes,
543    decoded: &IReceivePolicyGuard::ClaimReceiptV1,
544    amount: Option<U256>,
545) -> Value {
546    let receipt_key = keccak256(receipt);
547    let delivery_state = match amount {
548        Some(amount) if amount > U256::ZERO => "held",
549        Some(_) => "not_held",
550        None => "unknown",
551    };
552    let mut payload = json!({
553        "receipt": format!("{receipt}"),
554        "receipt_key": format!("{receipt_key}"),
555        "version": decoded.version,
556        "token": format!("{}", decoded.token),
557        "recovery_authority": format!("{}", decoded.recoveryAuthority),
558        "recovery_mode": recovery_mode(decoded.recoveryAuthority),
559        "originator": format!("{}", decoded.originator),
560        "recipient": format!("{}", decoded.recipient),
561        "recipient_is_virtual": decoded.recipient.is_virtual(),
562        "claim_target": if decoded.recipient.is_virtual() || decoded.recoveryAuthority == Address::ZERO {
563            Value::Null
564        } else {
565            json!(format!("{}", decoded.recipient))
566        },
567        "blocked_at": decoded.blockedAt,
568        "blocked_nonce": decoded.blockedNonce,
569        "blocked_reason": blocked_reason_u8(decoded.blockedReason),
570        "kind": inbound_kind(decoded.kind),
571        "memo": format!("{}", decoded.memo),
572        "delivery_state": delivery_state,
573    });
574    if let Some(amount) = amount {
575        payload["held_balance"] = json!(amount.to_string());
576    }
577    payload
578}
579
580fn print_decoded_receipt(payload: &Value) -> Result<()> {
581    sh_println!(
582        "Receipt key:        {}\n\
583         Token:              {}\n\
584         Recovery authority: {}\n\
585         Recovery mode:      {}\n\
586         Originator:         {}\n\
587         Recipient:          {}\n\
588         Blocked at:         {}\n\
589         Blocked nonce:      {}\n\
590         Blocked reason:     {}\n\
591         Kind:               {}\n\
592         Memo:               {}\n\
593         Delivery state:     {}",
594        payload["receipt_key"].as_str().unwrap_or_default(),
595        payload["token"].as_str().unwrap_or_default(),
596        payload["recovery_authority"].as_str().unwrap_or_default(),
597        payload["recovery_mode"].as_str().unwrap_or_default(),
598        payload["originator"].as_str().unwrap_or_default(),
599        payload["recipient"].as_str().unwrap_or_default(),
600        payload["blocked_at"],
601        payload["blocked_nonce"],
602        payload["blocked_reason"].as_str().unwrap_or_default(),
603        payload["kind"].as_str().unwrap_or_default(),
604        payload["memo"].as_str().unwrap_or_default(),
605        payload["delivery_state"].as_str().unwrap_or_default(),
606    )
607}
608
609fn print_claim_hint(payload: &Value) -> Result<()> {
610    let recipient = payload["recipient"].as_str().unwrap_or_default();
611    let receipt = payload["receipt"].as_str().unwrap_or_default();
612    if payload["recovery_mode"].as_str() == Some("originator") {
613        sh_println!(
614            "\nClaim target: originator recovery reroutes funds, so do not default to the blocked recipient. Claim to an address that can receive the token:\n  cast receive-policy claim <target-address> {receipt}"
615        )
616    } else if payload["recipient_is_virtual"].as_bool().unwrap_or_default() {
617        sh_println!(
618            "\nClaim target: recipient is a virtual address; resolve it first with:\n  cast vaddr resolve {recipient}\nThen claim to the registered master address:\n  cast receive-policy claim <master-address> {receipt}"
619        )
620    } else {
621        sh_println!("\nClaim path: cast receive-policy claim {recipient} {receipt}")
622    }
623}
624
625fn recovery_mode(recovery_authority: Address) -> &'static str {
626    if recovery_authority == Address::ZERO { "originator" } else { "authority" }
627}
628
629const fn policy_type(policy_type: ITIP403Registry::PolicyType) -> &'static str {
630    match policy_type {
631        ITIP403Registry::PolicyType::WHITELIST => "whitelist",
632        ITIP403Registry::PolicyType::BLACKLIST => "blacklist",
633        ITIP403Registry::PolicyType::COMPOUND => "compound",
634        _ => "unknown",
635    }
636}
637
638const fn blocked_reason(reason: ITIP403Registry::BlockedReason) -> &'static str {
639    match reason {
640        ITIP403Registry::BlockedReason::NONE => "none",
641        ITIP403Registry::BlockedReason::TOKEN_FILTER => "token_filter",
642        ITIP403Registry::BlockedReason::RECEIVE_POLICY => "receive_policy",
643        _ => "unknown",
644    }
645}
646
647const fn blocked_reason_u8(reason: u8) -> &'static str {
648    match reason {
649        0 => "none",
650        1 => "token_filter",
651        2 => "receive_policy",
652        _ => "unknown",
653    }
654}
655
656const fn inbound_kind(kind: IReceivePolicyGuard::InboundKind) -> &'static str {
657    match kind {
658        IReceivePolicyGuard::InboundKind::TRANSFER => "transfer",
659        IReceivePolicyGuard::InboundKind::MINT => "mint",
660        _ => "unknown",
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use alloy_primitives::{address, b256};
668    use tempo_primitives::{MasterId, UserTag};
669
670    fn sample_receipt() -> Bytes {
671        IReceivePolicyGuard::ClaimReceiptV1::new(
672            address!("0000000000000000000000000000000000000010"),
673            address!("0000000000000000000000000000000000000020"),
674            address!("0000000000000000000000000000000000000030"),
675            address!("0000000000000000000000000000000000000040"),
676            1_780_000_000,
677            7,
678            ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8,
679            IReceivePolicyGuard::InboundKind::TRANSFER,
680            b256!("0000000000000000000000000000000000000000000000000000000000000042"),
681        )
682        .abi_encode()
683        .into()
684    }
685
686    #[test]
687    fn decodes_guard_claim_receipt() {
688        let receipt = sample_receipt();
689        let decoded = decode_claim_receipt(&receipt).unwrap();
690        assert_eq!(decoded.version, 1);
691        assert_eq!(decoded.token, address!("0000000000000000000000000000000000000010"));
692        assert_eq!(decoded.recoveryAuthority, address!("0000000000000000000000000000000000000020"));
693        assert_eq!(decoded.originator, address!("0000000000000000000000000000000000000030"));
694        assert_eq!(decoded.recipient, address!("0000000000000000000000000000000000000040"));
695        assert_eq!(decoded.blockedNonce, 7);
696        assert_eq!(decoded.kind, IReceivePolicyGuard::InboundKind::TRANSFER);
697    }
698
699    #[test]
700    fn rejects_invalid_guard_claim_receipt() {
701        let err = decode_claim_receipt(&Bytes::from_static(&[0xde, 0xad])).unwrap_err();
702        assert!(err.to_string().contains("invalid ReceivePolicyGuard claim receipt"));
703    }
704
705    #[test]
706    fn rejects_semantically_invalid_guard_claim_receipts() {
707        let receipt = sample_receipt();
708        let decoded = IReceivePolicyGuard::ClaimReceiptV1::abi_decode(&receipt).unwrap();
709
710        let mut bad_version = decoded.clone();
711        bad_version.version = 2;
712        let err = decode_claim_receipt(&bad_version.abi_encode().into()).unwrap_err();
713        assert!(err.to_string().contains("unsupported ReceivePolicyGuard claim receipt version"));
714
715        let mut bad_token = decoded.clone();
716        bad_token.token = Address::ZERO;
717        let err = decode_claim_receipt(&bad_token.abi_encode().into()).unwrap_err();
718        assert!(err.to_string().contains("token is zero"));
719
720        let mut bad_recipient = decoded.clone();
721        bad_recipient.recipient = RECEIVE_POLICY_GUARD_ADDRESS;
722        let err = decode_claim_receipt(&bad_recipient.abi_encode().into()).unwrap_err();
723        assert!(err.to_string().contains("recipient cannot be the guard precompile"));
724
725        let mut bad_reason = decoded;
726        bad_reason.blockedReason = ITIP403Registry::BlockedReason::NONE as u8;
727        let err = decode_claim_receipt(&bad_reason.abi_encode().into()).unwrap_err();
728        assert!(err.to_string().contains("blocked reason is not claimable"));
729    }
730
731    #[test]
732    fn validate_payload_records_effective_receiver() {
733        let receiver = Address::new_virtual(
734            MasterId::from([0x12, 0x34, 0x56, 0x78]),
735            UserTag::from([0xab, 0xcd, 0xef, 0x01, 0x23, 0x45]),
736        );
737        let effective_receiver = address!("0000000000000000000000000000000000000040");
738
739        let payload = validate_payload(
740            address!("0000000000000000000000000000000000000010"),
741            address!("0000000000000000000000000000000000000030"),
742            receiver,
743            effective_receiver,
744            false,
745            ITIP403Registry::BlockedReason::RECEIVE_POLICY,
746            "held",
747        );
748
749        assert_eq!(payload["receiver"], format!("{receiver}"));
750        assert_eq!(payload["effective_receiver"], format!("{effective_receiver}"));
751        assert_eq!(payload["receiver_was_resolved"], true);
752        assert_eq!(payload["authorized"], false);
753        assert_eq!(payload["blocked_reason"], "receive_policy");
754        assert_eq!(payload["delivery_state"], "held");
755    }
756
757    #[test]
758    fn receipt_payload_preserves_delivery_state_confidence() {
759        let receipt = sample_receipt();
760        let decoded = decode_claim_receipt(&receipt).unwrap();
761
762        let unknown = receipt_payload(&receipt, &decoded, None);
763        assert_eq!(unknown["delivery_state"], "unknown");
764
765        let held = receipt_payload(&receipt, &decoded, Some(U256::from(1)));
766        assert_eq!(held["delivery_state"], "held");
767        assert_eq!(held["blocked_reason"], "receive_policy");
768        assert_eq!(held["kind"], "transfer");
769        assert_eq!(held["held_balance"], "1");
770        assert_eq!(held["recipient_is_virtual"], false);
771        assert_eq!(held["claim_target"], format!("{}", decoded.recipient));
772
773        let not_held = receipt_payload(&receipt, &decoded, Some(U256::ZERO));
774        assert_eq!(not_held["delivery_state"], "not_held");
775        assert_eq!(not_held["held_balance"], "0");
776    }
777
778    #[test]
779    fn virtual_receipt_recipient_requires_resolved_claim_target() {
780        let receipt = sample_receipt();
781        let mut decoded = decode_claim_receipt(&receipt).unwrap();
782        decoded.recipient = Address::new_virtual(
783            MasterId::from([0x12, 0x34, 0x56, 0x78]),
784            UserTag::from([0xab, 0xcd, 0xef, 0x01, 0x23, 0x45]),
785        );
786
787        let payload = receipt_payload(&receipt, &decoded, None);
788        assert_eq!(payload["recipient"], format!("{}", decoded.recipient));
789        assert_eq!(payload["recipient_is_virtual"], true);
790        assert_eq!(payload["claim_target"], Value::Null);
791    }
792
793    #[test]
794    fn originator_recovery_receipt_requires_explicit_claim_target() {
795        let receipt = sample_receipt();
796        let mut decoded = decode_claim_receipt(&receipt).unwrap();
797        decoded.recoveryAuthority = Address::ZERO;
798
799        let payload = receipt_payload(&receipt, &decoded, None);
800        assert_eq!(payload["recovery_mode"], "originator");
801        assert_eq!(payload["recipient_is_virtual"], false);
802        assert_eq!(payload["claim_target"], Value::Null);
803    }
804
805    #[test]
806    fn originator_recovery_takes_precedence_over_virtual_recipient() {
807        let receipt = sample_receipt();
808        let mut decoded = decode_claim_receipt(&receipt).unwrap();
809        decoded.recoveryAuthority = Address::ZERO;
810        decoded.recipient = Address::new_virtual(
811            MasterId::from([0x12, 0x34, 0x56, 0x78]),
812            UserTag::from([0xab, 0xcd, 0xef, 0x01, 0x23, 0x45]),
813        );
814
815        let payload = receipt_payload(&receipt, &decoded, None);
816        assert_eq!(payload["recovery_mode"], "originator");
817        assert_eq!(payload["recipient_is_virtual"], true);
818        assert_eq!(payload["claim_target"], Value::Null);
819    }
820
821    #[test]
822    fn rejects_unclaimable_recovery_authorities() {
823        // Originator recovery and a plain EOA authority are valid.
824        assert_eq!(invalid_recovery_authority_message(Address::ZERO), None);
825        assert_eq!(
826            invalid_recovery_authority_message(address!(
827                "1111111111111111111111111111111111111111"
828            )),
829            None
830        );
831
832        // Every fixed Tempo system precompile is unclaimable.
833        for authority in TEMPO_PRECOMPILE_ADDRESSES {
834            let err = invalid_recovery_authority_message(*authority).unwrap();
835            assert!(err.contains("fixed Tempo system precompile"));
836        }
837
838        // TIP-20 token and TIP-1022 virtual addresses are unclaimable too.
839        let err = invalid_recovery_authority_message(address!(
840            "20c0000000000000000000000000000000000001"
841        ))
842        .unwrap();
843        assert!(err.contains("TIP-20 token address"));
844
845        let virtual_address = Address::new_virtual(
846            MasterId::from([0x12, 0x34, 0x56, 0x78]),
847            UserTag::from([0xab, 0xcd, 0xef, 0x01, 0x23, 0x45]),
848        );
849        let err = invalid_recovery_authority_message(virtual_address).unwrap();
850        assert!(err.contains("TIP-1022 virtual address"));
851    }
852
853    #[test]
854    fn preview_calldata_uses_set_receive_policy_selector() {
855        let call = ITIP403Registry::setReceivePolicyCall {
856            senderPolicyId: 0,
857            tokenFilterId: 1,
858            recoveryAuthority: Address::ZERO,
859        };
860        let calldata = call.abi_encode();
861        assert_eq!(&calldata[..4], ITIP403Registry::setReceivePolicyCall::SELECTOR);
862    }
863}