Skip to main content

tempo_precompiles/tip20/
rewards.rs

1//! Opt-in staking [rewards system] for TIP-20 tokens.
2//!
3//! Token holders opt in by setting a reward recipient via [`TIP20Token::set_reward_recipient`].
4//! Rewards are distributed pro-rata across the opted-in supply and tracked via a global
5//! reward-per-token accumulator scaled by [`ACC_PRECISION`].
6//!
7//! [Reward system]: <https://docs.tempo.xyz/protocol/tip20-rewards/overview>
8
9use crate::{
10    error::{Result, TempoPrecompileError},
11    storage::Handler,
12    tip20::TIP20Token,
13};
14use alloy::primitives::{Address, U256, uint};
15use tempo_contracts::precompiles::{ITIP20, TIP20Error, TIP20Event};
16use tempo_precompiles_macros::Storable;
17
18/// Precision multiplier for reward-per-token accumulator (1e18).
19pub const ACC_PRECISION: U256 = uint!(1000000000000000000_U256);
20
21impl TIP20Token {
22    /// Distributes `amount` of reward tokens from the caller into the opted-in reward pool.
23    /// Transfers tokens to the contract and increases the global reward-per-token accumulator
24    /// proportionally to the opted-in supply.
25    ///
26    /// # Errors
27    /// - `Paused` — token transfers are currently paused
28    /// - `InvalidAmount` — `amount` is zero
29    /// - `PolicyForbids` — TIP-403 policy rejects the transfer
30    /// - `SpendingLimitExceeded` — access key spending limit exceeded
31    /// - `InsufficientBalance` — caller balance lower than `amount`
32    /// - `NoOptedInSupply` — no tokens are currently opted into rewards
33    pub fn distribute_reward(
34        &mut self,
35        msg_sender: Address,
36        call: ITIP20::distributeRewardCall,
37    ) -> Result<()> {
38        self.check_not_paused()?;
39        let token_address = self.address;
40
41        if call.amount == U256::ZERO {
42            return Err(TIP20Error::invalid_amount().into());
43        }
44
45        self.ensure_transfer_authorized(msg_sender, token_address)?;
46        self.check_and_update_spending_limit(msg_sender, call.amount)?;
47
48        self._transfer(msg_sender, token_address, call.amount)?;
49
50        let opted_in_supply = U256::from(self.get_opted_in_supply()?);
51        if opted_in_supply.is_zero() {
52            return Err(TIP20Error::no_opted_in_supply().into());
53        }
54
55        let delta_rpt = call
56            .amount
57            .checked_mul(ACC_PRECISION)
58            .and_then(|v| v.checked_div(opted_in_supply))
59            .ok_or(TempoPrecompileError::under_overflow())?;
60        let current_rpt = self.get_global_reward_per_token()?;
61        let new_rpt = current_rpt
62            .checked_add(delta_rpt)
63            .ok_or(TempoPrecompileError::under_overflow())?;
64        self.set_global_reward_per_token(new_rpt)?;
65
66        // Emit distributed reward event for immediate payout
67        self.emit_event(TIP20Event::RewardDistributed(ITIP20::RewardDistributed {
68            funder: msg_sender,
69            amount: call.amount,
70        }))?;
71
72        Ok(())
73    }
74
75    /// Updates and accumulates accrued rewards for a specific token holder.
76    ///
77    /// This function calculates the rewards earned by a holder based on their
78    /// balance and the reward per token difference since their last update.
79    /// Rewards are accumulated in the delegated recipient's rewardBalance.
80    /// Returns the holder's delegated recipient address.
81    pub fn update_rewards(&mut self, holder: Address) -> Result<Address> {
82        let mut info = self.user_reward_info[holder].read()?;
83
84        let cached_delegate = info.reward_recipient;
85
86        let global_reward_per_token = self.get_global_reward_per_token()?;
87        let reward_per_token_delta = global_reward_per_token
88            .checked_sub(info.reward_per_token)
89            .ok_or(TempoPrecompileError::under_overflow())?;
90
91        if reward_per_token_delta != U256::ZERO {
92            if cached_delegate != Address::ZERO {
93                let holder_balance = self.get_balance(holder)?;
94                let reward = holder_balance
95                    .checked_mul(reward_per_token_delta)
96                    .and_then(|v| v.checked_div(ACC_PRECISION))
97                    .ok_or(TempoPrecompileError::under_overflow())?;
98
99                // Add reward to delegate's balance (or holder's own balance if self-delegated)
100                if cached_delegate == holder {
101                    info.reward_balance = info
102                        .reward_balance
103                        .checked_add(reward)
104                        .ok_or(TempoPrecompileError::under_overflow())?;
105                } else {
106                    let mut delegate_info = self.user_reward_info[cached_delegate].read()?;
107                    delegate_info.reward_balance = delegate_info
108                        .reward_balance
109                        .checked_add(reward)
110                        .ok_or(TempoPrecompileError::under_overflow())?;
111                    self.user_reward_info[cached_delegate].write(delegate_info)?;
112                }
113            }
114            info.reward_per_token = global_reward_per_token;
115            self.user_reward_info[holder].write(info)?;
116        }
117
118        Ok(cached_delegate)
119    }
120
121    /// Sets or changes the reward recipient for a token holder.
122    ///
123    /// This function allows a token holder to designate who should receive their
124    /// share of rewards. Setting to zero address opts out of rewards.
125    ///
126    /// # Errors
127    /// - `Paused` — token transfers are currently paused
128    /// - `PolicyForbids` — TIP-403 policy rejects the sender→recipient transfer authorization
129    pub fn set_reward_recipient(
130        &mut self,
131        msg_sender: Address,
132        call: ITIP20::setRewardRecipientCall,
133    ) -> Result<()> {
134        self.check_not_paused()?;
135        if call.recipient != Address::ZERO {
136            self.ensure_transfer_authorized(msg_sender, call.recipient)?;
137        }
138
139        let from_delegate = self.update_rewards(msg_sender)?;
140
141        let holder_balance = self.get_balance(msg_sender)?;
142
143        if from_delegate != Address::ZERO {
144            if call.recipient == Address::ZERO {
145                let opted_in_supply = U256::from(self.get_opted_in_supply()?)
146                    .checked_sub(holder_balance)
147                    .ok_or(TempoPrecompileError::under_overflow())?;
148                self.set_opted_in_supply(
149                    opted_in_supply
150                        .try_into()
151                        .map_err(|_| TempoPrecompileError::under_overflow())?,
152                )?;
153            }
154        } else if call.recipient != Address::ZERO {
155            let opted_in_supply = U256::from(self.get_opted_in_supply()?)
156                .checked_add(holder_balance)
157                .ok_or(TempoPrecompileError::under_overflow())?;
158            self.set_opted_in_supply(
159                opted_in_supply
160                    .try_into()
161                    .map_err(|_| TempoPrecompileError::under_overflow())?,
162            )?;
163        }
164
165        let mut info = self.user_reward_info[msg_sender].read()?;
166        info.reward_recipient = call.recipient;
167        self.user_reward_info[msg_sender].write(info)?;
168
169        // Emit reward recipient set event
170        self.emit_event(TIP20Event::RewardRecipientSet(ITIP20::RewardRecipientSet {
171            holder: msg_sender,
172            recipient: call.recipient,
173        }))?;
174
175        Ok(())
176    }
177
178    /// Claims accumulated rewards for a recipient.
179    ///
180    /// This function allows a reward recipient to claim their accumulated rewards
181    /// and receive them as token transfers to their own balance.
182    ///
183    /// # Errors
184    /// - `Paused` — token transfers are currently paused
185    /// - `PolicyForbids` — TIP-403 policy rejects the contract→caller transfer authorization
186    pub fn claim_rewards(&mut self, msg_sender: Address) -> Result<U256> {
187        self.check_not_paused()?;
188        self.ensure_transfer_authorized(self.address, msg_sender)?;
189
190        self.update_rewards(msg_sender)?;
191
192        let mut info = self.user_reward_info[msg_sender].read()?;
193        let amount = info.reward_balance;
194        let contract_address = self.address;
195        let contract_balance = self.get_balance(contract_address)?;
196        let max_amount = amount.min(contract_balance);
197
198        let reward_recipient = info.reward_recipient;
199        info.reward_balance = amount
200            .checked_sub(max_amount)
201            .ok_or(TempoPrecompileError::under_overflow())?;
202        self.user_reward_info[msg_sender].write(info)?;
203
204        if max_amount > U256::ZERO {
205            let new_contract_balance = contract_balance
206                .checked_sub(max_amount)
207                .ok_or(TempoPrecompileError::under_overflow())?;
208            self.set_balance(contract_address, new_contract_balance)?;
209
210            let recipient_balance = self
211                .get_balance(msg_sender)?
212                .checked_add(max_amount)
213                .ok_or(TempoPrecompileError::under_overflow())?;
214            self.set_balance(msg_sender, recipient_balance)?;
215
216            if reward_recipient != Address::ZERO {
217                let opted_in_supply = U256::from(self.get_opted_in_supply()?)
218                    .checked_add(max_amount)
219                    .ok_or(TempoPrecompileError::under_overflow())?;
220                self.set_opted_in_supply(
221                    opted_in_supply
222                        .try_into()
223                        .map_err(|_| TempoPrecompileError::under_overflow())?,
224                )?;
225            }
226
227            self.emit_event(TIP20Event::Transfer(ITIP20::Transfer {
228                from: contract_address,
229                to: msg_sender,
230                amount: max_amount,
231            }))?;
232        }
233
234        Ok(max_amount)
235    }
236
237    /// Gets the accumulated global reward per token.
238    pub fn get_global_reward_per_token(&self) -> Result<U256> {
239        self.global_reward_per_token.read()
240    }
241
242    /// Sets the accumulated global reward per token in storage.
243    fn set_global_reward_per_token(&mut self, value: U256) -> Result<()> {
244        self.global_reward_per_token.write(value)
245    }
246
247    /// Gets the total supply of tokens opted into rewards from storage.
248    pub fn get_opted_in_supply(&self) -> Result<u128> {
249        self.opted_in_supply.read()
250    }
251
252    /// Sets the total supply of tokens opted into rewards.
253    pub fn set_opted_in_supply(&mut self, value: u128) -> Result<()> {
254        self.opted_in_supply.write(value)
255    }
256
257    /// Handles reward accounting for both sender and receiver during token transfers.
258    pub fn handle_rewards_on_transfer(
259        &mut self,
260        from: Address,
261        to: Address,
262        amount: U256,
263    ) -> Result<()> {
264        let from_delegate = self.update_rewards(from)?;
265        let to_delegate = self.update_rewards(to)?;
266
267        if !from_delegate.is_zero() {
268            if to_delegate.is_zero() {
269                let opted_in_supply = U256::from(self.get_opted_in_supply()?)
270                    .checked_sub(amount)
271                    .ok_or(TempoPrecompileError::under_overflow())?;
272                self.set_opted_in_supply(
273                    opted_in_supply
274                        .try_into()
275                        .map_err(|_| TempoPrecompileError::under_overflow())?,
276                )?;
277            }
278        } else if !to_delegate.is_zero() {
279            let opted_in_supply = U256::from(self.get_opted_in_supply()?)
280                .checked_add(amount)
281                .ok_or(TempoPrecompileError::under_overflow())?;
282            self.set_opted_in_supply(
283                opted_in_supply
284                    .try_into()
285                    .map_err(|_| TempoPrecompileError::under_overflow())?,
286            )?;
287        }
288
289        Ok(())
290    }
291
292    /// Handles reward accounting when tokens are minted to an address.
293    pub fn handle_rewards_on_mint(&mut self, to: Address, amount: U256) -> Result<()> {
294        let to_delegate = self.update_rewards(to)?;
295
296        if !to_delegate.is_zero() {
297            let opted_in_supply = U256::from(self.get_opted_in_supply()?)
298                .checked_add(amount)
299                .ok_or(TempoPrecompileError::under_overflow())?;
300            self.set_opted_in_supply(
301                opted_in_supply
302                    .try_into()
303                    .map_err(|_| TempoPrecompileError::under_overflow())?,
304            )?;
305        }
306
307        Ok(())
308    }
309
310    /// Retrieves user reward information for a given account.
311    pub fn get_user_reward_info(&self, account: Address) -> Result<UserRewardInfo> {
312        self.user_reward_info[account].read()
313    }
314
315    /// Calculates the pending claimable rewards for an account without modifying state.
316    ///
317    /// This function returns the total pending claimable reward amount, which includes:
318    /// 1. The stored reward balance from previous updates
319    /// 2. Newly accrued rewards based on the current global reward per token
320    ///
321    /// For accounts that have delegated their rewards to another recipient, this returns 0
322    /// since their rewards accrue to their delegate instead.
323    pub fn get_pending_rewards(&self, account: Address) -> Result<u128> {
324        let info = self.user_reward_info[account].read()?;
325
326        // Start with the stored reward balance
327        let mut pending = info.reward_balance;
328
329        // For the account's own accrued rewards (if self-delegated):
330        if info.reward_recipient == account {
331            let holder_balance = self.get_balance(account)?;
332            if holder_balance > U256::ZERO {
333                let global_reward_per_token = self.get_global_reward_per_token()?;
334                let reward_per_token_delta = global_reward_per_token
335                    .checked_sub(info.reward_per_token)
336                    .ok_or(TempoPrecompileError::under_overflow())?;
337
338                if reward_per_token_delta > U256::ZERO {
339                    let accrued = holder_balance
340                        .checked_mul(reward_per_token_delta)
341                        .and_then(|v| v.checked_div(ACC_PRECISION))
342                        .ok_or(TempoPrecompileError::under_overflow())?;
343                    pending = pending
344                        .checked_add(accrued)
345                        .ok_or(TempoPrecompileError::under_overflow())?;
346                }
347            }
348        }
349
350        pending
351            .try_into()
352            .map_err(|_| TempoPrecompileError::under_overflow())
353    }
354}
355
356/// Per-user reward tracking state for the opt-in staking rewards system.
357#[derive(Debug, Clone, Storable)]
358pub struct UserRewardInfo {
359    /// Address that receives this user's accrued rewards (`Address::ZERO` = opted out).
360    pub reward_recipient: Address,
361    /// Snapshot of the global reward-per-token at the user's last update.
362    pub reward_per_token: U256,
363    /// Accumulated but unclaimed reward balance.
364    pub reward_balance: U256,
365}
366
367impl From<UserRewardInfo> for ITIP20::UserRewardInfo {
368    fn from(value: UserRewardInfo) -> Self {
369        Self {
370            rewardRecipient: value.reward_recipient,
371            rewardPerToken: value.reward_per_token,
372            rewardBalance: value.reward_balance,
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::{
381        error::TempoPrecompileError,
382        storage::{StorageCtx, hashmap::HashMapStorageProvider},
383        test_util::TIP20Setup,
384        tip403_registry::TIP403Registry,
385    };
386    use alloy::primitives::{Address, U256};
387    use tempo_contracts::precompiles::{ITIP403Registry, TIP20Error};
388
389    #[test]
390    fn test_set_reward_recipient() -> eyre::Result<()> {
391        let mut storage = HashMapStorageProvider::new(1);
392        let admin = Address::random();
393        let alice = Address::random();
394        let amount = U256::random() % U256::from(u128::MAX);
395
396        StorageCtx::enter(&mut storage, || {
397            let mut token = TIP20Setup::create("Test", "TST", admin)
398                .with_issuer(admin)
399                .with_mint(alice, amount)
400                .apply()?;
401
402            token
403                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
404
405            let info = token.user_reward_info[alice].read()?;
406            assert_eq!(info.reward_recipient, alice);
407            assert_eq!(token.get_opted_in_supply()?, amount.to::<u128>());
408            assert_eq!(info.reward_per_token, U256::ZERO);
409
410            token.set_reward_recipient(
411                alice,
412                ITIP20::setRewardRecipientCall {
413                    recipient: Address::ZERO,
414                },
415            )?;
416
417            let info = token.user_reward_info[alice].read()?;
418            assert_eq!(info.reward_recipient, Address::ZERO);
419            assert_eq!(token.get_opted_in_supply()?, 0u128);
420            assert_eq!(info.reward_per_token, U256::ZERO);
421
422            Ok(())
423        })
424    }
425
426    #[test]
427    fn test_distribute_reward() -> eyre::Result<()> {
428        let mut storage = HashMapStorageProvider::new(1);
429        let admin = Address::random();
430        let alice = Address::random();
431        let amount = U256::from(1000);
432        let reward_amount = amount / U256::from(10);
433
434        StorageCtx::enter(&mut storage, || {
435            let mut token = TIP20Setup::create("Test", "TST", admin)
436                .with_issuer(admin)
437                .with_mint(alice, amount)
438                .with_mint(admin, reward_amount)
439                .apply()?;
440
441            token
442                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
443
444            // Distribute rewards
445            token.distribute_reward(
446                admin,
447                ITIP20::distributeRewardCall {
448                    amount: reward_amount,
449                },
450            )?;
451
452            // Verify global_reward_per_token increased correctly
453            let expected_rpt = reward_amount * ACC_PRECISION / amount;
454            assert_eq!(token.get_global_reward_per_token()?, expected_rpt);
455
456            // Verify contract balance increased (rewards transferred from admin to contract)
457            assert_eq!(token.get_balance(token.address)?, reward_amount);
458            assert_eq!(token.get_balance(admin)?, U256::ZERO);
459
460            // Update rewards to accrue alice's share
461            token.update_rewards(alice)?;
462            let info = token.get_user_reward_info(alice)?;
463            assert_eq!(info.reward_balance, reward_amount);
464
465            // Alice claims the full reward
466            let claimed = token.claim_rewards(alice)?;
467            assert_eq!(claimed, reward_amount);
468            assert_eq!(token.get_balance(alice)?, amount + reward_amount);
469            assert_eq!(token.get_balance(token.address)?, U256::ZERO);
470
471            // Distributing zero amount should fail
472            token.mint(
473                admin,
474                ITIP20::mintCall {
475                    to: admin,
476                    amount: U256::from(1),
477                },
478            )?;
479            let result =
480                token.distribute_reward(admin, ITIP20::distributeRewardCall { amount: U256::ZERO });
481            assert!(result.is_err());
482
483            Ok(())
484        })
485    }
486
487    #[test]
488    fn test_get_pending_rewards() -> eyre::Result<()> {
489        let mut storage = HashMapStorageProvider::new(1);
490        let admin = Address::random();
491        let alice = Address::random();
492
493        StorageCtx::enter(&mut storage, || {
494            let alice_balance = U256::from(1000e18);
495            let reward_amount = U256::from(100e18);
496
497            let mut token = TIP20Setup::create("Test", "TST", admin)
498                .with_issuer(admin)
499                .with_mint(alice, alice_balance)
500                .with_mint(admin, reward_amount)
501                .apply()?;
502
503            token
504                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
505
506            // Before any rewards, pending should be 0
507            let pending_before = token.get_pending_rewards(alice)?;
508            assert_eq!(pending_before, 0u128);
509
510            // Distribute immediate reward
511            token.distribute_reward(
512                admin,
513                ITIP20::distributeRewardCall {
514                    amount: reward_amount,
515                },
516            )?;
517
518            // Now alice should have pending rewards equal to reward_amount (she's the only opted-in holder)
519            let pending_after = token.get_pending_rewards(alice)?;
520            assert_eq!(U256::from(pending_after), reward_amount);
521
522            // Verify that calling get_pending_rewards did not modify state
523            let user_info = token.get_user_reward_info(alice)?;
524            assert_eq!(
525                user_info.reward_balance,
526                U256::ZERO,
527                "get_pending_rewards should not modify state"
528            );
529
530            Ok(())
531        })
532    }
533
534    #[test]
535    fn test_get_pending_rewards_includes_stored_balance() -> eyre::Result<()> {
536        let mut storage = HashMapStorageProvider::new(1);
537        let admin = Address::random();
538        let alice = Address::random();
539
540        StorageCtx::enter(&mut storage, || {
541            let alice_balance = U256::from(1000e18);
542            let reward_amount = U256::from(50e18);
543
544            let mut token = TIP20Setup::create("Test", "TST", admin)
545                .with_issuer(admin)
546                .with_mint(alice, alice_balance)
547                .with_mint(admin, reward_amount * U256::from(2))
548                .apply()?;
549
550            token
551                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
552
553            // Distribute first reward
554            token.distribute_reward(
555                admin,
556                ITIP20::distributeRewardCall {
557                    amount: reward_amount,
558                },
559            )?;
560
561            // Trigger an action to update alice's stored reward balance
562            token.update_rewards(alice)?;
563            let user_info = token.get_user_reward_info(alice)?;
564            assert_eq!(user_info.reward_balance, reward_amount);
565
566            // Distribute second reward
567            token.distribute_reward(
568                admin,
569                ITIP20::distributeRewardCall {
570                    amount: reward_amount,
571                },
572            )?;
573
574            // get_pending_rewards should return stored + new accrued
575            let pending = token.get_pending_rewards(alice)?;
576            assert_eq!(U256::from(pending), reward_amount * U256::from(2));
577
578            Ok(())
579        })
580    }
581
582    #[test]
583    fn test_get_pending_rewards_with_delegation() -> eyre::Result<()> {
584        let mut storage = HashMapStorageProvider::new(1);
585        let admin = Address::random();
586        let alice = Address::random();
587        let bob = Address::random();
588
589        StorageCtx::enter(&mut storage, || {
590            let alice_balance = U256::from(1000e18);
591            let reward_amount = U256::from(100e18);
592
593            let mut token = TIP20Setup::create("Test", "TST", admin)
594                .with_issuer(admin)
595                .with_mint(alice, alice_balance)
596                .with_mint(admin, reward_amount)
597                .apply()?;
598
599            // Alice delegates to bob
600            token.set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: bob })?;
601
602            // Distribute immediate reward
603            token.distribute_reward(
604                admin,
605                ITIP20::distributeRewardCall {
606                    amount: reward_amount,
607                },
608            )?;
609
610            // Alice's pending should be 0 (she delegated to bob)
611            let alice_pending = token.get_pending_rewards(alice)?;
612            assert_eq!(alice_pending, 0u128);
613
614            // Bob's pending should be 0 until update_rewards is called for alice
615            // (We can't iterate all delegators on-chain, so pending calculation is limited
616            // to stored balance + self-delegated accrued rewards)
617            let bob_pending_before_update = token.get_pending_rewards(bob)?;
618            assert_eq!(bob_pending_before_update, 0u128);
619
620            // After calling update_rewards on alice, bob's stored balance is updated
621            token.update_rewards(alice)?;
622            let bob_pending_after_update = token.get_pending_rewards(bob)?;
623            assert_eq!(U256::from(bob_pending_after_update), reward_amount);
624
625            Ok(())
626        })
627    }
628
629    #[test]
630    fn test_get_pending_rewards_not_opted_in() -> eyre::Result<()> {
631        let mut storage = HashMapStorageProvider::new(1);
632        let admin = Address::random();
633        let alice = Address::random();
634        let bob = Address::random();
635
636        StorageCtx::enter(&mut storage, || {
637            let balance = U256::from(1000e18);
638            let reward_amount = U256::from(100e18);
639
640            let mut token = TIP20Setup::create("Test", "TST", admin)
641                .with_issuer(admin)
642                .with_mint(alice, balance)
643                .with_mint(bob, balance)
644                .with_mint(admin, reward_amount)
645                .apply()?;
646
647            // Only alice opts in
648            token
649                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
650
651            // Distribute reward
652            token.distribute_reward(
653                admin,
654                ITIP20::distributeRewardCall {
655                    amount: reward_amount,
656                },
657            )?;
658
659            // Alice should have pending rewards
660            let alice_pending = token.get_pending_rewards(alice)?;
661            assert_eq!(U256::from(alice_pending), reward_amount);
662
663            // Bob should have 0 pending rewards (not opted in)
664            let bob_pending = token.get_pending_rewards(bob)?;
665            assert_eq!(bob_pending, 0u128);
666
667            Ok(())
668        })
669    }
670
671    #[test]
672    fn test_claim_rewards_unauthorized() -> eyre::Result<()> {
673        let mut storage = HashMapStorageProvider::new(1);
674        let admin = Address::random();
675        let alice = Address::random();
676
677        StorageCtx::enter(&mut storage, || {
678            let mut registry = TIP403Registry::new();
679            registry.initialize()?;
680
681            let policy_id = registry.create_policy(
682                admin,
683                ITIP403Registry::createPolicyCall {
684                    admin,
685                    policyType: ITIP403Registry::PolicyType::BLACKLIST,
686                },
687            )?;
688
689            registry.modify_policy_blacklist(
690                admin,
691                ITIP403Registry::modifyPolicyBlacklistCall {
692                    policyId: policy_id,
693                    account: alice,
694                    restricted: true,
695                },
696            )?;
697
698            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
699
700            token.change_transfer_policy_id(
701                admin,
702                ITIP20::changeTransferPolicyIdCall {
703                    newPolicyId: policy_id,
704                },
705            )?;
706
707            let err = token.claim_rewards(alice).unwrap_err();
708            assert!(
709                matches!(
710                    err,
711                    TempoPrecompileError::TIP20(TIP20Error::PolicyForbids(_))
712                ),
713                "Expected PolicyForbids error, got: {err:?}"
714            );
715
716            Ok(())
717        })
718    }
719}