Skip to main content

cast/cmd/
receive_policy.rs

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