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#[derive(Debug, Parser, Clone)]
26pub enum ReceivePolicySubcommand {
27 Set {
31 sender_policy_id: u64,
33
34 token_filter_id: u64,
36
37 #[arg(long, value_name = "ADDRESS", default_value_t = Address::ZERO)]
39 recovery_authority: Address,
40
41 #[arg(long, visible_alias = "dry-run")]
43 preview: bool,
44
45 #[arg(long)]
47 force: bool,
48
49 #[command(flatten)]
50 send_tx: SendTxOpts,
51
52 #[command(flatten)]
53 tx: TxParams,
54 },
55
56 Get {
58 #[arg(value_parser = NameOrAddress::from_str)]
60 account: NameOrAddress,
61
62 #[command(flatten)]
63 rpc: RpcOpts,
64 },
65
66 Validate {
68 #[arg(value_parser = NameOrAddress::from_str)]
70 token: NameOrAddress,
71
72 #[arg(value_parser = NameOrAddress::from_str)]
74 sender: NameOrAddress,
75
76 #[arg(value_parser = NameOrAddress::from_str)]
78 receiver: NameOrAddress,
79
80 #[command(flatten)]
81 rpc: RpcOpts,
82 },
83
84 Receipt {
86 #[command(subcommand)]
87 command: ReceivePolicyReceiptSubcommand,
88 },
89
90 Claim {
92 #[arg(value_parser = NameOrAddress::from_str)]
94 to: NameOrAddress,
95
96 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 {
111 receipt: Bytes,
113 },
114
115 Balance {
117 receipt: Bytes,
119
120 #[command(flatten)]
121 rpc: RpcOpts,
122 },
123
124 Burn {
126 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 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
371async 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
435fn 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
579const 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 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 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 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}