Skip to main content

tempo_precompiles/receive_policy_guard/
mod.rs

1//! [TIP-1028] ReceivePolicyGuard precompile for blocked inbound TIP-20 transfers and mints.
2
3pub mod dispatch;
4
5pub use tempo_contracts::precompiles::IReceivePolicyGuard::{self, InboundKind};
6use tempo_contracts::precompiles::{
7    IReceivePolicyGuard::ClaimReceiptV1, ITIP403Registry::BlockedReason, ReceivePolicyGuardError,
8};
9
10use crate::{
11    RECEIVE_POLICY_GUARD_ADDRESS,
12    address_registry::AddressRegistry,
13    error::{Result, TempoPrecompileError},
14    storage::{Handler, Mapping},
15    tip20::{Recipient, TIP20Token},
16};
17use alloy::{
18    primitives::{Address, B256, Bytes, U256},
19    sol_types::SolValue,
20};
21use tempo_precompiles_macros::{Storable, contract};
22use tempo_primitives::TempoAddressExt;
23
24/// Version tag for the v1 [`IReceivePolicyGuard::ClaimReceiptV1`] layout.
25pub const BLOCKED_RECEIPT_VERSION: u8 = 1;
26
27/// Recovery-authority sentinel: originator/sender is authorized to claim (`address(0)`).
28pub const RECOVERY_ORIGINATOR: Address = Address::ZERO;
29
30/// TIP-1028 precompile holding blocked inbound transfers and mints until claimed.
31#[contract(addr = RECEIVE_POLICY_GUARD_ADDRESS)]
32pub struct ReceivePolicyGuard {
33    nonce: u64,
34    balances: Mapping<B256, U256>,
35}
36
37impl ReceivePolicyGuard {
38    /// One-time storage initialization.
39    pub fn initialize(&mut self) -> Result<()> {
40        self.__initialize()
41    }
42
43    /// Returns the unclaimed amount for a receipt, or zero if unknown or already claimed.
44    pub fn balance_of(&self, receipt: Bytes) -> Result<U256> {
45        let receipt = ClaimReceiptV1::try_from(receipt)?;
46        self.balances[self.receipt_key(&receipt)?].read()
47    }
48
49    /// Records a blocked inbound transfer or mint and emits `TransferBlocked` event.
50    /// Caller must send the funds into this address, which are claimable with a valid receipt.
51    #[allow(clippy::too_many_arguments)]
52    pub(crate) fn store_blocked(
53        &mut self,
54        token: Address,
55        originator: Address,
56        to: &Recipient,
57        recovery_address: Address,
58        amount: U256,
59        blocked_reason: BlockedReason,
60        kind: InboundKind,
61        memo: B256,
62    ) -> Result<(u64, u64)> {
63        debug_assert!(
64            token.is_tip20(),
65            "ReceivePolicyGuard only accepts TIP20 tokens"
66        );
67
68        let is_invalid_reason = match blocked_reason {
69            BlockedReason::RECEIVE_POLICY | BlockedReason::TOKEN_FILTER => false,
70            BlockedReason::NONE | BlockedReason::__Invalid => true,
71        };
72        if is_invalid_reason || matches!(kind, InboundKind::__Invalid) {
73            return Err(ReceivePolicyGuardError::invalid_receipt().into());
74        }
75
76        let receiver = to.target;
77        let recipient = to.virtual_addr.unwrap_or(to.target);
78
79        let blocked_nonce = self.next_receipt_nonce()?;
80        let blocked_at = self.storage.timestamp().saturating_to::<u64>();
81        let receipt = IReceivePolicyGuard::ClaimReceiptV1::new(
82            token,
83            recovery_address,
84            originator,
85            recipient,
86            blocked_at,
87            blocked_nonce,
88            blocked_reason as u8,
89            kind,
90            memo,
91        );
92        let key = self.receipt_key(&receipt)?;
93        self.balances[key].write(amount)?;
94
95        self.emit_event(receipt.blocked_event(receiver, amount))?;
96        Ok((blocked_nonce, blocked_at))
97    }
98
99    /// Given a valid receipt, releases blocked funds to the authorized receiver.
100    pub fn claim(&mut self, msg_sender: Address, to: Address, receipt: Bytes) -> Result<()> {
101        if to == RECEIVE_POLICY_GUARD_ADDRESS {
102            return Err(ReceivePolicyGuardError::invalid_claim_address().into());
103        }
104
105        let (receipt, receiver, recovery_mode) = resolve_receipt(receipt)?;
106        let recovery_authority = recovery_mode.authority(&receipt);
107        if recovery_authority != msg_sender {
108            return Err(ReceivePolicyGuardError::unauthorized_claimer().into());
109        };
110
111        let key = self.receipt_key(&receipt)?;
112        let amount = self.balances[key].read()?;
113        if amount.is_zero() {
114            return Err(ReceivePolicyGuardError::invalid_receipt().into());
115        }
116
117        self.balances[key].write(U256::ZERO)?;
118
119        TIP20Token::from_address(receipt.token)?.release_blocked_funds(
120            receipt.originator,
121            receiver,
122            to,
123            amount,
124            recovery_mode,
125            recovery_authority,
126        )?;
127
128        self.emit_event(receipt.claimed_event(receiver, msg_sender, to, amount))
129    }
130
131    /// Burns the blocked funds for one receipt.
132    ///
133    /// Lets token issuers use `burnBlocked` for receipt-backed funds without burning directly from
134    /// the `ReceivePolicyGuard`.
135    pub fn burn_blocked_receipt(&mut self, msg_sender: Address, receipt: Bytes) -> Result<()> {
136        let (receipt, receiver, recovery_mode) = resolve_receipt(receipt)?;
137
138        let key = self.receipt_key(&receipt)?;
139        let amount = self.balances[key].read()?;
140        if amount.is_zero() {
141            return Err(ReceivePolicyGuardError::invalid_receipt().into());
142        }
143
144        // Burn from the account with ownership of the funds.
145        let owner = recovery_mode.policy_subject(receipt.originator, receiver);
146        TIP20Token::from_address(receipt.token)?.burn_blocked(msg_sender, owner, amount, false)?;
147        self.balances[key].write(U256::ZERO)?;
148
149        self.emit_event(receipt.burned_event(receiver, msg_sender, amount))
150    }
151
152    /// Allocates the next nonzero receipt nonce.
153    fn next_receipt_nonce(&mut self) -> Result<u64> {
154        let nonce = self.nonce.read()?.max(1);
155        self.nonce.write(
156            nonce
157                .checked_add(1)
158                .ok_or(TempoPrecompileError::under_overflow())?,
159        )?;
160        Ok(nonce)
161    }
162
163    /// Content hash over every receipt field. Any mutation yields a different empty slot.
164    fn receipt_key(&self, receipt: &IReceivePolicyGuard::ClaimReceiptV1) -> Result<B256> {
165        self.storage.keccak256(receipt.abi_encode().as_ref())
166    }
167}
168
169/// Recovery authority for blocked inbound funds.
170#[derive(Debug, Clone, Copy, Default, Storable, PartialEq)]
171#[repr(u8)]
172pub(crate) enum RecoveryMode {
173    #[default]
174    Originator,
175    Receiver,
176    ThirdParty,
177}
178
179impl RecoveryMode {
180    /// Encodes a configured recovery authority into a mode and stored authority value.
181    pub(crate) fn encode(authority: Address, msg_sender: Address) -> (Self, Address) {
182        if authority == RECOVERY_ORIGINATOR {
183            (Self::Originator, Address::ZERO)
184        } else if authority == msg_sender {
185            (Self::Receiver, Address::ZERO)
186        } else {
187            (Self::ThirdParty, authority)
188        }
189    }
190
191    /// Resolves the recovery mode for a receipt and resolved receiver.
192    pub(crate) fn from(receipt: &ClaimReceiptV1, receiver: Address) -> Self {
193        if receipt.recoveryAuthority == RECOVERY_ORIGINATOR {
194            Self::Originator
195        } else if receipt.recoveryAuthority == receiver {
196            Self::Receiver
197        } else {
198            Self::ThirdParty
199        }
200    }
201
202    /// Returns the authorized claimer address for this mode.
203    pub(crate) fn authority(self, receipt: &ClaimReceiptV1) -> Address {
204        match self {
205            Self::Originator => receipt.originator,
206            Self::Receiver | Self::ThirdParty => receipt.recoveryAuthority,
207        }
208    }
209
210    /// Returns the address of the account who has effective ownership of the blocked funds.
211    pub(crate) fn policy_subject(self, originator: Address, receiver: Address) -> Address {
212        match self {
213            Self::Originator => originator,
214            Self::Receiver | Self::ThirdParty => receiver,
215        }
216    }
217
218    /// Returns whether a claim is a reroute under TIP-1028.
219    /// Originator-authorized claims are always reroutes; non-originator recovery claims resume
220    /// only when claiming to the receiver.
221    pub(crate) fn is_reroute(self, to: Address, receiver: Address) -> bool {
222        match self {
223            Self::Originator => true,
224            Self::Receiver | Self::ThirdParty => to != receiver,
225        }
226    }
227
228    /// Returns the account charged for access-key spending limits, if any.
229    pub(crate) fn spending_account(self, recovery_authority: Address) -> Option<Address> {
230        match self {
231            Self::Originator | Self::Receiver => Some(recovery_authority),
232            Self::ThirdParty => None,
233        }
234    }
235}
236
237fn resolve_receipt(bytes: Bytes) -> Result<(ClaimReceiptV1, Address, RecoveryMode)> {
238    let receipt = ClaimReceiptV1::try_from(bytes)?;
239    let receiver = AddressRegistry::new()
240        .resolve_recipient(receipt.recipient)
241        .map_err(|_| ReceivePolicyGuardError::invalid_claim_address())?;
242    let recovery_mode = RecoveryMode::from(&receipt, receiver);
243
244    Ok((receipt, receiver, recovery_mode))
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::{
251        address_registry::AddressRegistry,
252        error::TempoPrecompileError,
253        storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider},
254        test_util::{TIP20Setup, VIRTUAL_MASTER, register_virtual_master},
255        tip20::{BURN_BLOCKED_ROLE, ITIP20},
256        tip403_registry::{ALLOW_ALL_POLICY_ID, REJECT_ALL_POLICY_ID, TIP403Registry},
257    };
258    use alloy::sol_types::SolValue;
259    use tempo_chainspec::hardfork::TempoHardfork;
260    use tempo_contracts::precompiles::{
261        ITIP403Registry, ReceivePolicyGuardEvent, TIP20Error, TIP20Event,
262    };
263
264    fn block_all_senders(receiver: Address, recovery_authority: Address) -> Result<()> {
265        TIP403Registry::new().set_receive_policy(
266            receiver,
267            ITIP403Registry::setReceivePolicyCall {
268                senderPolicyId: REJECT_ALL_POLICY_ID,
269                tokenFilterId: ALLOW_ALL_POLICY_ID,
270                recoveryAuthority: recovery_authority,
271            },
272        )
273    }
274
275    fn assert_invalid_receipt(result: Result<()>) {
276        assert_eq!(
277            result.unwrap_err(),
278            ReceivePolicyGuardError::invalid_receipt().into()
279        );
280    }
281
282    fn assert_unauthorized(result: Result<()>) {
283        assert_eq!(
284            result.unwrap_err(),
285            ReceivePolicyGuardError::unauthorized_claimer().into()
286        );
287    }
288
289    #[test]
290    fn test_claim_blocked_inbound() -> eyre::Result<()> {
291        let admin = Address::random();
292        let transfer_originator = Address::random();
293        let receiver = Address::random();
294        let amount = U256::from(100u64);
295        let blocked_at = 1_728_000u64;
296
297        for kind in [InboundKind::TRANSFER, InboundKind::MINT] {
298            let originator = match kind {
299                InboundKind::TRANSFER => transfer_originator,
300                InboundKind::MINT => admin,
301                InboundKind::__Invalid => unreachable!(),
302            };
303
304            let third_party = Address::random();
305            for (configured_authority, claimer, destination, is_third_party) in [
306                (RECOVERY_ORIGINATOR, originator, originator, false),
307                (receiver, receiver, receiver, false),
308                (third_party, third_party, Address::random(), true),
309            ] {
310                let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
311                storage.set_timestamp(U256::from(blocked_at));
312
313                StorageCtx::enter(&mut storage, || {
314                    let mut setup = TIP20Setup::create("T", "T", admin).with_issuer(admin);
315                    if matches!(kind, InboundKind::TRANSFER) {
316                        setup = setup.with_mint(originator, amount);
317                    }
318                    let mut token = setup.apply()?;
319                    block_all_senders(receiver, configured_authority)?;
320
321                    let unknown = ClaimReceiptV1::new(
322                        token.address(),
323                        configured_authority,
324                        originator,
325                        receiver,
326                        blocked_at,
327                        99,
328                        BlockedReason::RECEIVE_POLICY as u8,
329                        kind,
330                        B256::ZERO,
331                    );
332                    let mut guard = ReceivePolicyGuard::new();
333                    assert_eq!(guard.balance_of(unknown.abi_encode().into())?, U256::ZERO);
334
335                    guard.clear_emitted_events();
336                    token.clear_emitted_events();
337                    match kind {
338                        InboundKind::TRANSFER => {
339                            token.transfer(
340                                originator,
341                                ITIP20::transferCall {
342                                    to: receiver,
343                                    amount,
344                                },
345                            )?;
346                            token.assert_emitted_events(vec![TIP20Event::transfer(
347                                originator,
348                                RECEIVE_POLICY_GUARD_ADDRESS,
349                                amount,
350                            )]);
351                        }
352                        InboundKind::MINT => {
353                            token.mint(
354                                admin,
355                                ITIP20::mintCall {
356                                    to: receiver,
357                                    amount,
358                                },
359                            )?;
360                            token.assert_emitted_events(vec![
361                                TIP20Event::transfer(
362                                    Address::ZERO,
363                                    RECEIVE_POLICY_GUARD_ADDRESS,
364                                    amount,
365                                ),
366                                TIP20Event::mint(RECEIVE_POLICY_GUARD_ADDRESS, amount),
367                            ]);
368                        }
369                        InboundKind::__Invalid => unreachable!(),
370                    }
371                    let receipt = ClaimReceiptV1::new(
372                        token.address(),
373                        configured_authority,
374                        originator,
375                        receiver,
376                        blocked_at,
377                        1,
378                        BlockedReason::RECEIVE_POLICY as u8,
379                        kind,
380                        B256::ZERO,
381                    );
382                    guard.assert_emitted_events(vec![ReceivePolicyGuardEvent::TransferBlocked(
383                        IReceivePolicyGuard::TransferBlocked {
384                            token: token.address(),
385                            receiver,
386                            blockedNonce: 1,
387                            receiptVersion: BLOCKED_RECEIPT_VERSION,
388                            amount,
389                            receipt: receipt.abi_encode().into(),
390                        },
391                    )]);
392                    assert_eq!(guard.balance_of(receipt.abi_encode().into())?, amount);
393
394                    if is_third_party {
395                        assert_unauthorized(guard.claim(
396                            receiver,
397                            receiver,
398                            receipt.abi_encode().into(),
399                        ));
400                        assert_unauthorized(guard.claim(
401                            Address::random(),
402                            receiver,
403                            receipt.abi_encode().into(),
404                        ));
405                        assert_eq!(guard.balance_of(receipt.abi_encode().into())?, amount);
406                    }
407
408                    guard.clear_emitted_events();
409                    guard.claim(claimer, destination, receipt.abi_encode().into())?;
410                    guard.assert_emitted_events(vec![ReceivePolicyGuardEvent::ReceiptClaimed(
411                        IReceivePolicyGuard::ReceiptClaimed {
412                            token: token.address(),
413                            receiver,
414                            blockedNonce: 1,
415                            blockedAt: blocked_at,
416                            receiptVersion: BLOCKED_RECEIPT_VERSION,
417                            originator,
418                            recipient: receiver,
419                            recoveryAuthority: configured_authority,
420                            caller: claimer,
421                            to: destination,
422                            amount,
423                        },
424                    )]);
425
426                    assert_eq!(guard.balance_of(receipt.abi_encode().into())?, U256::ZERO);
427                    assert_eq!(
428                        token.balance_of(ITIP20::balanceOfCall {
429                            account: RECEIVE_POLICY_GUARD_ADDRESS
430                        })?,
431                        U256::ZERO
432                    );
433                    assert_eq!(
434                        token.balance_of(ITIP20::balanceOfCall {
435                            account: destination
436                        })?,
437                        amount
438                    );
439
440                    Ok::<(), TempoPrecompileError>(())
441                })?;
442            }
443        }
444
445        Ok(())
446    }
447
448    #[test]
449    fn test_burn_blocked_receipt_emits_receipt_burned() -> eyre::Result<()> {
450        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
451        let blocked_at = 1_728_010u64;
452        storage.set_timestamp(U256::from(blocked_at));
453
454        let admin = Address::random();
455        let burner = Address::random();
456        let originator = Address::random();
457        let receiver = Address::random();
458        let amount = U256::from(100u64);
459
460        StorageCtx::enter(&mut storage, || {
461            let mut token = TIP20Setup::create("T", "T", admin)
462                .with_issuer(admin)
463                .with_role(burner, *BURN_BLOCKED_ROLE)
464                .with_mint(originator, amount)
465                .apply()?;
466            block_all_senders(receiver, receiver)?;
467
468            token.transfer(
469                originator,
470                ITIP20::transferCall {
471                    to: receiver,
472                    amount,
473                },
474            )?;
475
476            let receipt = ClaimReceiptV1::new(
477                token.address(),
478                receiver,
479                originator,
480                receiver,
481                blocked_at,
482                1,
483                BlockedReason::RECEIVE_POLICY as u8,
484                InboundKind::TRANSFER,
485                B256::ZERO,
486            );
487
488            let mut registry = TIP403Registry::new();
489            let policy_id = registry.create_policy(
490                admin,
491                ITIP403Registry::createPolicyCall {
492                    admin,
493                    policyType: ITIP403Registry::PolicyType::BLACKLIST,
494                },
495            )?;
496            registry.modify_policy_blacklist(
497                admin,
498                ITIP403Registry::modifyPolicyBlacklistCall {
499                    policyId: policy_id,
500                    account: receiver,
501                    restricted: true,
502                },
503            )?;
504            token.change_transfer_policy_id(
505                admin,
506                ITIP20::changeTransferPolicyIdCall {
507                    newPolicyId: policy_id,
508                },
509            )?;
510
511            let mut guard = ReceivePolicyGuard::new();
512            guard.clear_emitted_events();
513
514            guard.burn_blocked_receipt(burner, receipt.abi_encode().into())?;
515
516            guard.assert_emitted_events(vec![ReceivePolicyGuardEvent::ReceiptBurned(
517                IReceivePolicyGuard::ReceiptBurned {
518                    token: token.address(),
519                    receiver,
520                    receiptVersion: BLOCKED_RECEIPT_VERSION,
521                    blockedNonce: 1,
522                    blockedAt: blocked_at,
523                    originator,
524                    recipient: receiver,
525                    recoveryAuthority: receiver,
526                    caller: burner,
527                    amount,
528                },
529            )]);
530            assert_eq!(guard.balance_of(receipt.abi_encode().into())?, U256::ZERO);
531
532            Ok(())
533        })
534    }
535
536    #[test]
537    fn test_claim_rejects_when_token_paused() -> eyre::Result<()> {
538        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
539        let blocked_at = 1_728_000u64;
540        storage.set_timestamp(U256::from(blocked_at));
541
542        let admin = Address::random();
543        let originator = Address::random();
544        let receiver = Address::random();
545        let amount = U256::from(100u64);
546
547        StorageCtx::enter(&mut storage, || {
548            let mut token = TIP20Setup::create("T", "T", admin)
549                .with_issuer(admin)
550                .with_role(admin, TIP20Token::pause_role())
551                .with_mint(originator, amount)
552                .apply()?;
553            block_all_senders(receiver, receiver)?;
554
555            token.transfer(
556                originator,
557                ITIP20::transferCall {
558                    to: receiver,
559                    amount,
560                },
561            )?;
562            token.pause(admin, ITIP20::pauseCall {})?;
563
564            let receipt = ClaimReceiptV1::new(
565                token.address(),
566                receiver,
567                originator,
568                receiver,
569                blocked_at,
570                1,
571                BlockedReason::RECEIVE_POLICY as u8,
572                InboundKind::TRANSFER,
573                B256::ZERO,
574            );
575            let mut guard = ReceivePolicyGuard::new();
576            let result = guard.claim(receiver, receiver, receipt.abi_encode().into());
577            assert_eq!(result.unwrap_err(), TIP20Error::contract_paused().into());
578
579            Ok(())
580        })
581    }
582
583    #[test]
584    fn test_receive_policy_guard_balance_matches_open_receipts() -> eyre::Result<()> {
585        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
586        storage.set_timestamp(U256::from(1_728_001u64));
587
588        let admin = Address::random();
589        let originator = Address::random();
590        let receiver_a = Address::random();
591        let receiver_b = Address::random();
592        let receiver_c = Address::random();
593        let recovery = Address::random();
594        let amount_a = U256::from(30u64);
595        let amount_b = U256::from(45u64);
596        let amount_c = U256::from(70u64);
597
598        StorageCtx::enter(&mut storage, || {
599            let mut token_a = TIP20Setup::create("A", "A", admin)
600                .with_issuer(admin)
601                .with_mint(originator, amount_a + amount_b)
602                .apply()?;
603            let mut token_b = TIP20Setup::create("B", "B", admin)
604                .with_issuer(admin)
605                .with_mint(originator, amount_c)
606                .apply()?;
607
608            block_all_senders(receiver_a, receiver_a)?;
609            block_all_senders(receiver_b, recovery)?;
610            block_all_senders(receiver_c, receiver_c)?;
611
612            token_a.transfer(
613                originator,
614                ITIP20::transferCall {
615                    to: receiver_a,
616                    amount: amount_a,
617                },
618            )?;
619            token_a.transfer(
620                originator,
621                ITIP20::transferCall {
622                    to: receiver_b,
623                    amount: amount_b,
624                },
625            )?;
626            token_b.transfer(
627                originator,
628                ITIP20::transferCall {
629                    to: receiver_c,
630                    amount: amount_c,
631                },
632            )?;
633
634            let receipt_a = ClaimReceiptV1::new(
635                token_a.address(),
636                receiver_a,
637                originator,
638                receiver_a,
639                1_728_001,
640                1,
641                BlockedReason::RECEIVE_POLICY as u8,
642                InboundKind::TRANSFER,
643                B256::ZERO,
644            );
645            let receipt_b = ClaimReceiptV1::new(
646                token_a.address(),
647                recovery,
648                originator,
649                receiver_b,
650                1_728_001,
651                2,
652                BlockedReason::RECEIVE_POLICY as u8,
653                InboundKind::TRANSFER,
654                B256::ZERO,
655            );
656            let receipt_c = ClaimReceiptV1::new(
657                token_b.address(),
658                receiver_c,
659                originator,
660                receiver_c,
661                1_728_001,
662                3,
663                BlockedReason::RECEIVE_POLICY as u8,
664                InboundKind::TRANSFER,
665                B256::ZERO,
666            );
667
668            let mut guard = ReceivePolicyGuard::new();
669            assert_eq!(
670                token_a.balance_of(ITIP20::balanceOfCall {
671                    account: RECEIVE_POLICY_GUARD_ADDRESS
672                })?,
673                guard.balance_of(receipt_a.abi_encode().into())?
674                    + guard.balance_of(receipt_b.abi_encode().into())?
675            );
676            assert_eq!(
677                token_b.balance_of(ITIP20::balanceOfCall {
678                    account: RECEIVE_POLICY_GUARD_ADDRESS
679                })?,
680                guard.balance_of(receipt_c.abi_encode().into())?
681            );
682
683            guard.claim(receiver_a, receiver_a, receipt_a.abi_encode().into())?;
684            assert_eq!(
685                token_a.balance_of(ITIP20::balanceOfCall {
686                    account: RECEIVE_POLICY_GUARD_ADDRESS
687                })?,
688                guard.balance_of(receipt_b.abi_encode().into())?
689            );
690            assert_eq!(
691                token_b.balance_of(ITIP20::balanceOfCall {
692                    account: RECEIVE_POLICY_GUARD_ADDRESS
693                })?,
694                guard.balance_of(receipt_c.abi_encode().into())?
695            );
696
697            guard.claim(recovery, recovery, receipt_b.abi_encode().into())?;
698            assert_eq!(
699                token_a.balance_of(ITIP20::balanceOfCall {
700                    account: RECEIVE_POLICY_GUARD_ADDRESS
701                })?,
702                U256::ZERO
703            );
704            assert_eq!(
705                token_b.balance_of(ITIP20::balanceOfCall {
706                    account: RECEIVE_POLICY_GUARD_ADDRESS
707                })?,
708                guard.balance_of(receipt_c.abi_encode().into())?
709            );
710
711            Ok(())
712        })
713    }
714
715    #[test]
716    fn test_receipt_rejects_bad_encoding() -> eyre::Result<()> {
717        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
718
719        StorageCtx::enter(&mut storage, || {
720            let guard = ReceivePolicyGuard::new();
721            let result = guard.balance_of(vec![0xde, 0xad, 0xbe, 0xef].into());
722            assert!(matches!(
723                result,
724                Err(e) if e == ReceivePolicyGuardError::invalid_receipt().into()
725            ));
726
727            Ok(())
728        })
729    }
730
731    #[test]
732    fn test_store_rejects_invalid_metadata() -> eyre::Result<()> {
733        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
734        let admin = Address::random();
735
736        StorageCtx::enter(&mut storage, || {
737            let token = TIP20Setup::create("T", "T", admin).apply()?;
738            let mut guard = ReceivePolicyGuard::new();
739
740            for (blocked_reason, kind) in [
741                (BlockedReason::NONE, InboundKind::TRANSFER),
742                (BlockedReason::__Invalid, InboundKind::TRANSFER),
743                (BlockedReason::RECEIVE_POLICY, InboundKind::__Invalid),
744            ] {
745                let result = guard.store_blocked(
746                    token.address(),
747                    Address::random(),
748                    &Recipient::direct(Address::random()),
749                    Address::ZERO,
750                    U256::from(1u64),
751                    blocked_reason,
752                    kind,
753                    B256::ZERO,
754                );
755                assert!(matches!(
756                    result,
757                    Err(e) if e == ReceivePolicyGuardError::invalid_receipt().into()
758                ));
759            }
760
761            Ok(())
762        })
763    }
764
765    #[test]
766    fn test_receipt_key_binds_receipt_fields() -> eyre::Result<()> {
767        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
768        storage.set_timestamp(U256::from(1_728_002u64));
769
770        let admin = Address::random();
771        let originator_a = Address::random();
772        let originator_b = Address::random();
773        let recipient = Address::random();
774        let recovery = Address::random();
775        let memo = B256::repeat_byte(0x11);
776        let amount_a = U256::from(10u64);
777        let amount_b = U256::from(20u64);
778
779        StorageCtx::enter(&mut storage, || {
780            let token_a = TIP20Setup::create("A", "A", admin).apply()?;
781            let token_b = TIP20Setup::create("B", "B", admin).apply()?;
782            let mut guard = ReceivePolicyGuard::new();
783
784            let (nonce_a, blocked_at_a) = guard.store_blocked(
785                token_a.address(),
786                originator_a,
787                &Recipient::direct(recipient),
788                recovery,
789                amount_a,
790                BlockedReason::RECEIVE_POLICY,
791                InboundKind::TRANSFER,
792                memo,
793            )?;
794            let (nonce_b, blocked_at_b) = guard.store_blocked(
795                token_b.address(),
796                originator_b,
797                &Recipient::direct(recipient),
798                recovery,
799                amount_b,
800                BlockedReason::TOKEN_FILTER,
801                InboundKind::MINT,
802                B256::repeat_byte(0x22),
803            )?;
804
805            let receipt_a = ClaimReceiptV1::new(
806                token_a.address(),
807                recovery,
808                originator_a,
809                recipient,
810                blocked_at_a,
811                nonce_a,
812                BlockedReason::RECEIVE_POLICY as u8,
813                InboundKind::TRANSFER,
814                memo,
815            );
816            let receipt_b = ClaimReceiptV1::new(
817                token_b.address(),
818                recovery,
819                originator_b,
820                recipient,
821                blocked_at_b,
822                nonce_b,
823                BlockedReason::TOKEN_FILTER as u8,
824                InboundKind::MINT,
825                B256::repeat_byte(0x22),
826            );
827
828            assert_eq!(guard.balance_of(receipt_a.abi_encode().into())?, amount_a);
829            assert_eq!(guard.balance_of(receipt_b.abi_encode().into())?, amount_b);
830
831            let mutated_receipts = [
832                ClaimReceiptV1::new(
833                    token_a.address(),
834                    recovery,
835                    Address::random(),
836                    recipient,
837                    blocked_at_a,
838                    nonce_a,
839                    BlockedReason::RECEIVE_POLICY as u8,
840                    InboundKind::TRANSFER,
841                    memo,
842                ),
843                ClaimReceiptV1::new(
844                    token_a.address(),
845                    recovery,
846                    originator_a,
847                    Address::random(),
848                    blocked_at_a,
849                    nonce_a,
850                    BlockedReason::RECEIVE_POLICY as u8,
851                    InboundKind::TRANSFER,
852                    memo,
853                ),
854                ClaimReceiptV1::new(
855                    token_a.address(),
856                    recovery,
857                    originator_a,
858                    recipient,
859                    blocked_at_a + 1,
860                    nonce_a,
861                    BlockedReason::RECEIVE_POLICY as u8,
862                    InboundKind::TRANSFER,
863                    memo,
864                ),
865                ClaimReceiptV1::new(
866                    token_a.address(),
867                    recovery,
868                    originator_a,
869                    recipient,
870                    blocked_at_a,
871                    nonce_a + 1,
872                    BlockedReason::RECEIVE_POLICY as u8,
873                    InboundKind::TRANSFER,
874                    memo,
875                ),
876                ClaimReceiptV1::new(
877                    token_a.address(),
878                    recovery,
879                    originator_a,
880                    recipient,
881                    blocked_at_a,
882                    nonce_a,
883                    BlockedReason::TOKEN_FILTER as u8,
884                    InboundKind::TRANSFER,
885                    memo,
886                ),
887                ClaimReceiptV1::new(
888                    token_a.address(),
889                    recovery,
890                    originator_a,
891                    recipient,
892                    blocked_at_a,
893                    nonce_a,
894                    BlockedReason::RECEIVE_POLICY as u8,
895                    InboundKind::MINT,
896                    memo,
897                ),
898                ClaimReceiptV1::new(
899                    token_a.address(),
900                    recovery,
901                    originator_a,
902                    recipient,
903                    blocked_at_a,
904                    nonce_a,
905                    BlockedReason::RECEIVE_POLICY as u8,
906                    InboundKind::TRANSFER,
907                    B256::repeat_byte(0x33),
908                ),
909            ];
910
911            for mutated in mutated_receipts {
912                assert_eq!(guard.balance_of(mutated.abi_encode().into())?, U256::ZERO);
913            }
914            assert_eq!(guard.balance_of(receipt_a.abi_encode().into())?, amount_a);
915            assert_eq!(guard.balance_of(receipt_b.abi_encode().into())?, amount_b);
916
917            Ok(())
918        })
919    }
920
921    #[test]
922    fn test_claim_rejects_missing_receipt() -> eyre::Result<()> {
923        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
924        storage.set_timestamp(U256::from(1_728_003u64));
925
926        let admin = Address::random();
927        let originator = Address::random();
928        let receiver = Address::random();
929        let amount = U256::from(100u64);
930        StorageCtx::enter(&mut storage, || {
931            let mut token = TIP20Setup::create("T", "T", admin)
932                .with_issuer(admin)
933                .with_mint(originator, amount)
934                .apply()?;
935
936            let receipt = ClaimReceiptV1::new(
937                token.address(),
938                receiver,
939                originator,
940                receiver,
941                1_728_003,
942                1,
943                BlockedReason::RECEIVE_POLICY as u8,
944                InboundKind::TRANSFER,
945                B256::ZERO,
946            );
947
948            let mut guard = ReceivePolicyGuard::new();
949            assert_invalid_receipt(guard.claim(receiver, receiver, receipt.abi_encode().into()));
950
951            block_all_senders(receiver, receiver)?;
952            token.transfer(
953                originator,
954                ITIP20::transferCall {
955                    to: receiver,
956                    amount,
957                },
958            )?;
959            guard.claim(receiver, receiver, receipt.abi_encode().into())?;
960            assert_invalid_receipt(guard.claim(receiver, receiver, receipt.abi_encode().into()));
961
962            Ok(())
963        })
964    }
965
966    #[test]
967    fn test_claim_requires_authorized_caller() -> eyre::Result<()> {
968        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
969        storage.set_timestamp(U256::from(1_728_004u64));
970
971        let admin = Address::random();
972        let originator = Address::random();
973        let receiver = Address::random();
974        let recovery_receiver = Address::random();
975        let recovery = Address::random();
976        let stranger = Address::random();
977        let amount = U256::from(50u64);
978
979        StorageCtx::enter(&mut storage, || {
980            let mut token = TIP20Setup::create("T", "T", admin)
981                .with_issuer(admin)
982                .with_mint(originator, amount * U256::from(2u64))
983                .apply()?;
984            block_all_senders(receiver, receiver)?;
985            block_all_senders(recovery_receiver, recovery)?;
986
987            token.transfer(
988                originator,
989                ITIP20::transferCall {
990                    to: receiver,
991                    amount,
992                },
993            )?;
994            token.transfer(
995                originator,
996                ITIP20::transferCall {
997                    to: recovery_receiver,
998                    amount,
999                },
1000            )?;
1001
1002            let self_receipt = ClaimReceiptV1::new(
1003                token.address(),
1004                receiver,
1005                originator,
1006                receiver,
1007                1_728_004,
1008                1,
1009                BlockedReason::RECEIVE_POLICY as u8,
1010                InboundKind::TRANSFER,
1011                B256::ZERO,
1012            );
1013            let recovery_receipt = ClaimReceiptV1::new(
1014                token.address(),
1015                recovery,
1016                originator,
1017                recovery_receiver,
1018                1_728_004,
1019                2,
1020                BlockedReason::RECEIVE_POLICY as u8,
1021                InboundKind::TRANSFER,
1022                B256::ZERO,
1023            );
1024
1025            let mut guard = ReceivePolicyGuard::new();
1026            assert_unauthorized(guard.claim(stranger, receiver, self_receipt.abi_encode().into()));
1027            for caller in [recovery_receiver, stranger] {
1028                assert_unauthorized(guard.claim(
1029                    caller,
1030                    recovery_receiver,
1031                    recovery_receipt.abi_encode().into(),
1032                ));
1033            }
1034            assert_eq!(guard.balance_of(self_receipt.abi_encode().into())?, amount);
1035            assert_eq!(
1036                guard.balance_of(recovery_receipt.abi_encode().into())?,
1037                amount
1038            );
1039
1040            Ok(())
1041        })
1042    }
1043
1044    #[test]
1045    fn test_claim_virtual_recipient() -> eyre::Result<()> {
1046        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
1047        storage.set_timestamp(U256::from(1_728_009u64));
1048
1049        let admin = Address::random();
1050        let originator = Address::random();
1051        let amount = U256::from(123u64);
1052
1053        StorageCtx::enter(&mut storage, || {
1054            let (_, virtual_addr) = register_virtual_master(&mut AddressRegistry::new())?;
1055            let mut token = TIP20Setup::create("T", "T", admin)
1056                .with_issuer(admin)
1057                .with_mint(originator, amount)
1058                .apply()?;
1059            block_all_senders(VIRTUAL_MASTER, VIRTUAL_MASTER)?;
1060            let mut guard = ReceivePolicyGuard::new();
1061            guard.clear_emitted_events();
1062            token.transfer(
1063                originator,
1064                ITIP20::transferCall {
1065                    to: virtual_addr,
1066                    amount,
1067                },
1068            )?;
1069
1070            let receipt = ClaimReceiptV1::new(
1071                token.address(),
1072                VIRTUAL_MASTER,
1073                originator,
1074                virtual_addr,
1075                1_728_009,
1076                1,
1077                BlockedReason::RECEIVE_POLICY as u8,
1078                InboundKind::TRANSFER,
1079                B256::ZERO,
1080            );
1081            guard.assert_emitted_events(vec![ReceivePolicyGuardEvent::TransferBlocked(
1082                IReceivePolicyGuard::TransferBlocked {
1083                    token: token.address(),
1084                    receiver: VIRTUAL_MASTER,
1085                    blockedNonce: 1,
1086                    receiptVersion: BLOCKED_RECEIPT_VERSION,
1087                    amount,
1088                    receipt: receipt.abi_encode().into(),
1089                },
1090            )]);
1091            guard.clear_emitted_events();
1092            guard.claim(VIRTUAL_MASTER, VIRTUAL_MASTER, receipt.abi_encode().into())?;
1093
1094            guard.assert_emitted_events(vec![ReceivePolicyGuardEvent::ReceiptClaimed(
1095                IReceivePolicyGuard::ReceiptClaimed {
1096                    token: token.address(),
1097                    receiver: VIRTUAL_MASTER,
1098                    blockedNonce: 1,
1099                    blockedAt: 1_728_009,
1100                    receiptVersion: BLOCKED_RECEIPT_VERSION,
1101                    originator,
1102                    recipient: virtual_addr,
1103                    recoveryAuthority: VIRTUAL_MASTER,
1104                    caller: VIRTUAL_MASTER,
1105                    to: VIRTUAL_MASTER,
1106                    amount,
1107                },
1108            )]);
1109            assert_eq!(
1110                token.balance_of(ITIP20::balanceOfCall {
1111                    account: VIRTUAL_MASTER
1112                })?,
1113                amount
1114            );
1115            assert_eq!(
1116                token.balance_of(ITIP20::balanceOfCall {
1117                    account: virtual_addr
1118                })?,
1119                U256::ZERO
1120            );
1121
1122            Ok(())
1123        })
1124    }
1125}