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::{Recipient, TIP20Token},
13};
14use alloy::primitives::{Address, U256, uint};
15use tempo_contracts::precompiles::{ITIP20, TIP20Error, TIP20Event};
16use tempo_precompiles_macros::Storable;
17use tempo_primitives::TempoAddressExt;
18
19/// Precision multiplier for reward-per-token accumulator (1e18).
20pub const ACC_PRECISION: U256 = uint!(1000000000000000000_U256);
21
22impl TIP20Token {
23    /// Distributes `amount` of reward tokens from the caller into the opted-in reward pool.
24    /// Transfers tokens to the contract and increases the global reward-per-token accumulator
25    /// proportionally to the opted-in supply.
26    ///
27    /// # Errors
28    /// - `Paused` — token transfers are currently paused
29    /// - `InvalidAmount` — `amount` is zero
30    /// - `PolicyForbids` — TIP-403 policy rejects the transfer
31    /// - `SpendingLimitExceeded` — access key spending limit exceeded
32    /// - `InsufficientBalance` — caller balance lower than `amount`
33    /// - `NoOptedInSupply` — no tokens are currently opted into rewards
34    pub fn distribute_reward(
35        &mut self,
36        msg_sender: Address,
37        call: ITIP20::distributeRewardCall,
38    ) -> Result<()> {
39        if self.storage.spec().is_t7() {
40            return Ok(());
41        }
42
43        self.check_not_paused()?;
44        let token_address = self.address;
45
46        if call.amount == U256::ZERO {
47            return Err(TIP20Error::invalid_amount().into());
48        }
49
50        self.ensure_transfer_authorized(msg_sender, token_address)?;
51        self.check_and_update_spending_limit(msg_sender, call.amount)?;
52
53        self._transfer(msg_sender, &Recipient::direct(token_address), call.amount)?;
54
55        let opted_in_supply = U256::from(self.get_opted_in_supply()?);
56        if opted_in_supply.is_zero() {
57            return Err(TIP20Error::no_opted_in_supply().into());
58        }
59
60        let delta_rpt = call
61            .amount
62            .checked_mul(ACC_PRECISION)
63            .and_then(|v| v.checked_div(opted_in_supply))
64            .ok_or(TempoPrecompileError::under_overflow())?;
65        let current_rpt = self.get_global_reward_per_token()?;
66        let new_rpt = current_rpt
67            .checked_add(delta_rpt)
68            .ok_or(TempoPrecompileError::under_overflow())?;
69        self.set_global_reward_per_token(new_rpt)?;
70
71        // Emit distributed reward event (recipients claim accrued rewards separately)
72        self.emit_event(TIP20Event::reward_distributed(msg_sender, call.amount))?;
73
74        Ok(())
75    }
76
77    /// Updates and accumulates accrued rewards for a specific token holder.
78    ///
79    /// This function calculates the rewards earned by a holder based on their balance and the
80    /// reward per token difference since their last update. Rewards are accumulated in the
81    /// delegated recipient's rewardBalance. Returns the holder's delegated recipient address.
82    ///
83    /// T8+: no-op, as rewards are disabled.
84    pub fn update_rewards(&mut self, holder: Address) -> Result<Address> {
85        // T8+: no-op, as rewards are disabled.
86        if self.storage.spec().is_t8() {
87            return Ok(Address::ZERO);
88        }
89
90        let mut info = self.user_reward_info[holder].read()?;
91
92        let cached_delegate = info.reward_recipient;
93
94        let global_reward_per_token = self.get_global_reward_per_token()?;
95        let reward_per_token_delta = global_reward_per_token
96            .checked_sub(info.reward_per_token)
97            .ok_or(TempoPrecompileError::under_overflow())?;
98
99        if reward_per_token_delta != U256::ZERO {
100            if cached_delegate != Address::ZERO {
101                let holder_balance = self.get_balance(holder)?;
102                let reward = holder_balance
103                    .checked_mul(reward_per_token_delta)
104                    .and_then(|v| v.checked_div(ACC_PRECISION))
105                    .ok_or(TempoPrecompileError::under_overflow())?;
106
107                // Add reward to delegate's balance (or holder's own balance if self-delegated)
108                if cached_delegate == holder {
109                    info.reward_balance = info
110                        .reward_balance
111                        .checked_add(reward)
112                        .ok_or(TempoPrecompileError::under_overflow())?;
113                } else {
114                    let mut delegate_info = self.user_reward_info[cached_delegate].read()?;
115                    delegate_info.reward_balance = delegate_info
116                        .reward_balance
117                        .checked_add(reward)
118                        .ok_or(TempoPrecompileError::under_overflow())?;
119                    self.user_reward_info[cached_delegate].write(delegate_info)?;
120                }
121            }
122            info.reward_per_token = global_reward_per_token;
123            self.user_reward_info[holder].write(info)?;
124        }
125
126        Ok(cached_delegate)
127    }
128
129    /// Sets or changes the reward recipient for a token holder.
130    ///
131    /// This function allows a token holder to designate who should receive their
132    /// share of rewards. Setting to zero address opts out of rewards.
133    ///
134    /// # Errors
135    /// - `Paused` — token transfers are currently paused
136    /// - `PolicyForbids` — TIP-403 policy rejects the sender→recipient transfer authorization
137    /// - `InvalidRecipient` — TIP-1022 virtual addresses are rejected
138    pub fn set_reward_recipient(
139        &mut self,
140        msg_sender: Address,
141        call: ITIP20::setRewardRecipientCall,
142    ) -> Result<()> {
143        if self.storage.spec().is_t7() {
144            return Ok(());
145        }
146
147        self.check_not_paused()?;
148
149        // TIP-1022: reject virtual addresses as reward recipients
150        if self.storage.spec().is_t3() && call.recipient.is_virtual() {
151            return Err(TIP20Error::invalid_recipient().into());
152        }
153
154        if call.recipient != Address::ZERO {
155            self.ensure_transfer_authorized(msg_sender, call.recipient)?;
156        }
157
158        let from_delegate = self.update_rewards(msg_sender)?;
159
160        let holder_balance = self.get_balance(msg_sender)?;
161
162        if from_delegate != Address::ZERO {
163            if call.recipient == Address::ZERO {
164                let opted_in_supply = U256::from(self.get_opted_in_supply()?)
165                    .checked_sub(holder_balance)
166                    .ok_or(TempoPrecompileError::under_overflow())?;
167                self.set_opted_in_supply(
168                    opted_in_supply
169                        .try_into()
170                        .map_err(|_| TempoPrecompileError::under_overflow())?,
171                )?;
172            }
173        } else if call.recipient != Address::ZERO {
174            let opted_in_supply = U256::from(self.get_opted_in_supply()?)
175                .checked_add(holder_balance)
176                .ok_or(TempoPrecompileError::under_overflow())?;
177            self.set_opted_in_supply(
178                opted_in_supply
179                    .try_into()
180                    .map_err(|_| TempoPrecompileError::under_overflow())?,
181            )?;
182        }
183
184        let mut info = self.user_reward_info[msg_sender].read()?;
185        info.reward_recipient = call.recipient;
186        self.user_reward_info[msg_sender].write(info)?;
187
188        // Emit reward recipient set event
189        self.emit_event(TIP20Event::reward_recipient_set(msg_sender, call.recipient))?;
190
191        Ok(())
192    }
193
194    /// Claims accumulated rewards for a recipient.
195    ///
196    /// Pays out the lesser of the accrued reward balance and the contract's token
197    /// balance. Any remainder stays stored for future claims.
198    ///
199    /// # Errors
200    /// - `Paused` — token transfers are currently paused
201    /// - `PolicyForbids` — TIP-403 policy rejects the contract→caller transfer authorization
202    pub fn claim_rewards(&mut self, msg_sender: Address) -> Result<U256> {
203        self.check_not_paused()?;
204        self.ensure_transfer_authorized(self.address, msg_sender)?;
205
206        // T8+: pay only settled rewards; pending lazy accruals are forfeited.
207        let reward_recipient = self.update_rewards(msg_sender)?;
208
209        let mut info = self.user_reward_info[msg_sender].read()?;
210        let amount = info.reward_balance;
211        let contract_address = self.address;
212        let contract_balance = self.get_balance(contract_address)?;
213        let max_amount = amount.min(contract_balance);
214
215        info.reward_balance = amount
216            .checked_sub(max_amount)
217            .ok_or(TempoPrecompileError::under_overflow())?;
218        self.user_reward_info[msg_sender].write(info)?;
219
220        if max_amount > U256::ZERO {
221            let new_contract_balance = contract_balance
222                .checked_sub(max_amount)
223                .ok_or(TempoPrecompileError::under_overflow())?;
224            self.set_balance(contract_address, new_contract_balance)?;
225
226            let recipient_balance = self
227                .get_balance(msg_sender)?
228                .checked_add(max_amount)
229                .ok_or(TempoPrecompileError::under_overflow())?;
230            self.set_balance(msg_sender, recipient_balance)?;
231
232            if reward_recipient != Address::ZERO {
233                let opted_in_supply = U256::from(self.get_opted_in_supply()?)
234                    .checked_add(max_amount)
235                    .ok_or(TempoPrecompileError::under_overflow())?;
236                self.set_opted_in_supply(
237                    opted_in_supply
238                        .try_into()
239                        .map_err(|_| TempoPrecompileError::under_overflow())?,
240                )?;
241            }
242
243            self.emit_event(TIP20Event::transfer(
244                contract_address,
245                msg_sender,
246                max_amount,
247            ))?;
248        }
249
250        Ok(max_amount)
251    }
252
253    /// Gets the accumulated global reward per token.
254    pub fn get_global_reward_per_token(&self) -> Result<U256> {
255        self.global_reward_per_token.read()
256    }
257
258    /// Sets the accumulated global reward per token in storage.
259    fn set_global_reward_per_token(&mut self, value: U256) -> Result<()> {
260        self.global_reward_per_token.write(value)
261    }
262
263    /// Gets the total supply of tokens opted into rewards from storage.
264    pub fn get_opted_in_supply(&self) -> Result<u128> {
265        self.opted_in_supply.read()
266    }
267
268    /// Sets the total supply of tokens opted into rewards.
269    pub fn set_opted_in_supply(&mut self, value: u128) -> Result<()> {
270        self.opted_in_supply.write(value)
271    }
272
273    /// Handles reward accounting for both sender and receiver during token transfers.
274    pub fn handle_rewards_on_transfer(
275        &mut self,
276        from: Address,
277        to: Address,
278        amount: U256,
279    ) -> Result<()> {
280        let from_delegate = self.update_rewards(from)?;
281        let to_delegate = self.update_rewards(to)?;
282
283        if !from_delegate.is_zero() {
284            if to_delegate.is_zero() {
285                let opted_in_supply = U256::from(self.get_opted_in_supply()?)
286                    .checked_sub(amount)
287                    .ok_or(TempoPrecompileError::under_overflow())?;
288                self.set_opted_in_supply(
289                    opted_in_supply
290                        .try_into()
291                        .map_err(|_| TempoPrecompileError::under_overflow())?,
292                )?;
293            }
294        } else if !to_delegate.is_zero() {
295            let opted_in_supply = U256::from(self.get_opted_in_supply()?)
296                .checked_add(amount)
297                .ok_or(TempoPrecompileError::under_overflow())?;
298            self.set_opted_in_supply(
299                opted_in_supply
300                    .try_into()
301                    .map_err(|_| TempoPrecompileError::under_overflow())?,
302            )?;
303        }
304
305        Ok(())
306    }
307
308    /// Handles reward accounting when tokens are minted to an address.
309    pub fn handle_rewards_on_mint(&mut self, to: Address, amount: U256) -> Result<()> {
310        let to_delegate = self.update_rewards(to)?;
311
312        if !to_delegate.is_zero() {
313            let opted_in_supply = U256::from(self.get_opted_in_supply()?)
314                .checked_add(amount)
315                .ok_or(TempoPrecompileError::under_overflow())?;
316            self.set_opted_in_supply(
317                opted_in_supply
318                    .try_into()
319                    .map_err(|_| TempoPrecompileError::under_overflow())?,
320            )?;
321        }
322
323        Ok(())
324    }
325
326    /// Retrieves user reward information for a given account.
327    pub fn get_user_reward_info(&self, account: Address) -> Result<UserRewardInfo> {
328        self.user_reward_info[account].read()
329    }
330
331    /// Calculates the pending claimable rewards for an account without modifying state.
332    ///
333    /// This function returns the total pending claimable reward amount, which includes:
334    /// 1. The stored reward balance from previous updates
335    /// 2. Newly accrued rewards based on the current global reward per token
336    ///
337    /// For accounts that have delegated their rewards to another recipient, only the stored
338    /// reward balance is returned (new accrual is skipped since it goes to the delegate).
339    pub fn get_pending_rewards(&self, account: Address) -> Result<u128> {
340        let info = self.user_reward_info[account].read()?;
341
342        // Start with the stored reward balance
343        let mut pending = info.reward_balance;
344
345        // At T8 and later, reward hooks are disabled; only settled rewards are claimable.
346        if self.storage.spec().is_t8() {
347            return pending
348                .try_into()
349                .map_err(|_| TempoPrecompileError::under_overflow());
350        }
351
352        // For the account's own accrued rewards (if self-delegated):
353        if info.reward_recipient == account {
354            let holder_balance = self.get_balance(account)?;
355            if holder_balance > U256::ZERO {
356                let global_reward_per_token = self.get_global_reward_per_token()?;
357                let reward_per_token_delta = global_reward_per_token
358                    .checked_sub(info.reward_per_token)
359                    .ok_or(TempoPrecompileError::under_overflow())?;
360
361                if reward_per_token_delta > U256::ZERO {
362                    let accrued = holder_balance
363                        .checked_mul(reward_per_token_delta)
364                        .and_then(|v| v.checked_div(ACC_PRECISION))
365                        .ok_or(TempoPrecompileError::under_overflow())?;
366                    pending = pending
367                        .checked_add(accrued)
368                        .ok_or(TempoPrecompileError::under_overflow())?;
369                }
370            }
371        }
372
373        pending
374            .try_into()
375            .map_err(|_| TempoPrecompileError::under_overflow())
376    }
377}
378
379/// Per-user reward tracking state for the opt-in staking rewards system.
380#[derive(Debug, Clone, Storable)]
381pub struct UserRewardInfo {
382    /// Address that receives this user's accrued rewards (`Address::ZERO` = opted out).
383    pub reward_recipient: Address,
384    /// Snapshot of the global reward-per-token at the user's last update.
385    pub reward_per_token: U256,
386    /// Accumulated but unclaimed reward balance.
387    pub reward_balance: U256,
388}
389
390impl From<UserRewardInfo> for ITIP20::UserRewardInfo {
391    fn from(value: UserRewardInfo) -> Self {
392        Self {
393            rewardRecipient: value.reward_recipient,
394            rewardPerToken: value.reward_per_token,
395            rewardBalance: value.reward_balance,
396        }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use crate::{
404        address_registry::{MasterId, UserTag},
405        error::TempoPrecompileError,
406        storage::{StorageCtx, hashmap::HashMapStorageProvider},
407        test_util::TIP20Setup,
408        tip403_registry::TIP403Registry,
409    };
410    use alloy::primitives::{Address, U256};
411    use tempo_chainspec::hardfork::TempoHardfork;
412    use tempo_contracts::precompiles::{ITIP403Registry, TIP20Error};
413
414    #[test]
415    fn test_set_reward_recipient() -> eyre::Result<()> {
416        let mut storage = HashMapStorageProvider::new(1);
417        let admin = Address::random();
418        let alice = Address::random();
419        let amount = U256::random() % U256::from(u128::MAX);
420
421        StorageCtx::enter(&mut storage, || {
422            let mut token = TIP20Setup::create("Test", "TST", admin)
423                .with_issuer(admin)
424                .with_mint(alice, amount)
425                .apply()?;
426
427            token
428                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
429
430            let info = token.user_reward_info[alice].read()?;
431            assert_eq!(info.reward_recipient, alice);
432            assert_eq!(token.get_opted_in_supply()?, amount.to::<u128>());
433            assert_eq!(info.reward_per_token, U256::ZERO);
434
435            token.set_reward_recipient(
436                alice,
437                ITIP20::setRewardRecipientCall {
438                    recipient: Address::ZERO,
439                },
440            )?;
441
442            let info = token.user_reward_info[alice].read()?;
443            assert_eq!(info.reward_recipient, Address::ZERO);
444            assert_eq!(token.get_opted_in_supply()?, 0u128);
445            assert_eq!(info.reward_per_token, U256::ZERO);
446
447            Ok(())
448        })
449    }
450
451    #[test]
452    fn test_t7_zero_rpt_fast_path_preserves_opt_in_state() -> eyre::Result<()> {
453        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
454        let admin = Address::random();
455        let alice = Address::random();
456        let bob = Address::random();
457        let alice_balance = U256::from(1000);
458        let transfer_amount = U256::from(100);
459        let mint_amount = U256::from(100);
460        let reward_amount = U256::from(110);
461
462        StorageCtx::enter(&mut storage, || {
463            let mut token = TIP20Setup::create("Test", "TST", admin)
464                .with_issuer(admin)
465                .with_mint(alice, alice_balance)
466                .with_mint(admin, reward_amount)
467                .apply()?;
468
469            token
470                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
471            token.set_reward_recipient(bob, ITIP20::setRewardRecipientCall { recipient: bob })?;
472            assert_eq!(token.update_rewards(alice)?, alice);
473            assert_eq!(token.get_opted_in_supply()?, alice_balance.to::<u128>());
474
475            StorageCtx.set_spec(TempoHardfork::T7);
476
477            // T7 disables reward mutators, but the zero-RPT fast path still returns existing
478            // opt-in state so pre-T7 lazy reward checkpointing can keep using reward hooks.
479            token.set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: bob })?;
480            assert_eq!(token.get_user_reward_info(alice)?.reward_recipient, alice);
481            assert_eq!(token.update_rewards(alice)?, alice);
482            assert_eq!(token.get_opted_in_supply()?, alice_balance.to::<u128>());
483
484            // Transfers out of an opted-in holder and mints into one still update the opted supply
485            // before any rewards have been distributed.
486            token.transfer(
487                alice,
488                ITIP20::transferCall {
489                    to: bob,
490                    amount: transfer_amount,
491                },
492            )?;
493            assert_eq!(token.get_opted_in_supply()?, alice_balance.to::<u128>());
494
495            token.mint(
496                admin,
497                ITIP20::mintCall {
498                    to: alice,
499                    amount: mint_amount,
500                },
501            )?;
502            let opted_in_supply = alice_balance + mint_amount;
503            assert_eq!(token.get_opted_in_supply()?, opted_in_supply.to::<u128>());
504
505            token.distribute_reward(admin, ITIP20::distributeRewardCall { amount: U256::ZERO })?;
506            token.distribute_reward(
507                admin,
508                ITIP20::distributeRewardCall {
509                    amount: reward_amount,
510                },
511            )?;
512            assert_eq!(token.get_balance(admin)?, reward_amount);
513            assert_eq!(token.get_balance(token.address)?, U256::ZERO);
514            assert_eq!(token.get_global_reward_per_token()?, U256::ZERO);
515            assert_eq!(token.get_pending_rewards(alice)?, 0);
516            assert_eq!(token.get_pending_rewards(bob)?, 0);
517
518            Ok(())
519        })
520    }
521
522    #[test]
523    fn test_distribute_reward() -> eyre::Result<()> {
524        let mut storage = HashMapStorageProvider::new(1);
525        let admin = Address::random();
526        let alice = Address::random();
527        let amount = U256::from(1000);
528        let reward_amount = amount / U256::from(10);
529
530        StorageCtx::enter(&mut storage, || {
531            let mut token = TIP20Setup::create("Test", "TST", admin)
532                .with_issuer(admin)
533                .with_mint(alice, amount)
534                .with_mint(admin, reward_amount)
535                .apply()?;
536
537            token
538                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
539
540            // Distribute rewards
541            token.distribute_reward(
542                admin,
543                ITIP20::distributeRewardCall {
544                    amount: reward_amount,
545                },
546            )?;
547
548            // Verify global_reward_per_token increased correctly
549            let expected_rpt = reward_amount * ACC_PRECISION / amount;
550            assert_eq!(token.get_global_reward_per_token()?, expected_rpt);
551
552            // Verify contract balance increased (rewards transferred from admin to contract)
553            assert_eq!(token.get_balance(token.address)?, reward_amount);
554            assert_eq!(token.get_balance(admin)?, U256::ZERO);
555
556            // Update rewards to accrue alice's share
557            token.update_rewards(alice)?;
558            let info = token.get_user_reward_info(alice)?;
559            assert_eq!(info.reward_balance, reward_amount);
560
561            // Alice claims the full reward
562            let claimed = token.claim_rewards(alice)?;
563            assert_eq!(claimed, reward_amount);
564            assert_eq!(token.get_balance(alice)?, amount + reward_amount);
565            assert_eq!(token.get_balance(token.address)?, U256::ZERO);
566
567            // Distributing zero amount should fail
568            token.mint(
569                admin,
570                ITIP20::mintCall {
571                    to: admin,
572                    amount: U256::from(1),
573                },
574            )?;
575            let result =
576                token.distribute_reward(admin, ITIP20::distributeRewardCall { amount: U256::ZERO });
577            assert!(result.is_err());
578
579            Ok(())
580        })
581    }
582
583    #[test]
584    fn test_tip1075_t7_noops_and_t8_claims_settled_rewards() -> eyre::Result<()> {
585        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5);
586        let admin = Address::random();
587        let alice = Address::random();
588        let bob = Address::random();
589        let amount = U256::from(1000);
590        let reward_amount = U256::from(100);
591
592        StorageCtx::enter(&mut storage, || {
593            let mut token = TIP20Setup::create("Test", "TST", admin)
594                .with_issuer(admin)
595                .with_mint(alice, amount)
596                .with_mint(admin, reward_amount * U256::from(2))
597                .apply()?;
598
599            // Pre-T7: settle one reward distribution, then leave another lazy/pending.
600            token
601                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
602            token.distribute_reward(
603                admin,
604                ITIP20::distributeRewardCall {
605                    amount: reward_amount,
606                },
607            )?;
608            token.update_rewards(alice)?;
609            token.distribute_reward(
610                admin,
611                ITIP20::distributeRewardCall {
612                    amount: reward_amount,
613                },
614            )?;
615            assert_eq!(token.get_opted_in_supply()?, amount.to::<u128>());
616
617            StorageCtx.set_spec(TempoHardfork::T7);
618            token.paused.write(true)?;
619
620            // T7+: setRewardRecipient is a no-op, even while paused.
621            token.set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: bob })?;
622            assert_eq!(
623                token.user_reward_info[alice].read()?.reward_recipient,
624                alice
625            );
626
627            // T7+: distributeReward is a no-op, even for an otherwise invalid zero amount.
628            let rpt = token.get_global_reward_per_token()?;
629            token.distribute_reward(admin, ITIP20::distributeRewardCall { amount: U256::ZERO })?;
630            assert_eq!(token.get_global_reward_per_token()?, rpt);
631
632            // T8+: claimRewards pays settled rewards only and doesn't opt them in.
633            StorageCtx.set_spec(TempoHardfork::T8);
634            token.paused.write(false)?;
635            let claimed = token.claim_rewards(alice)?;
636            assert_eq!(claimed, reward_amount);
637            assert_eq!(token.get_balance(alice)?, amount + reward_amount);
638            assert_eq!(token.get_opted_in_supply()?, amount.to::<u128>());
639            assert_eq!(token.get_global_reward_per_token()?, rpt);
640
641            Ok(())
642        })
643    }
644
645    #[test]
646    fn test_t8_get_pending_rewards_returns_only_stored_balance() -> eyre::Result<()> {
647        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T8);
648        let admin = Address::random();
649        let alice = Address::random();
650        let alice_balance = U256::from(1000);
651        let stored_reward = U256::from(7);
652
653        StorageCtx::enter(&mut storage, || {
654            let mut token = TIP20Setup::create("Test", "TST", admin)
655                .with_issuer(admin)
656                .with_mint(alice, alice_balance)
657                .apply()?;
658
659            token.set_global_reward_per_token(U256::from(100) * ACC_PRECISION)?;
660            token.user_reward_info[alice].write(UserRewardInfo {
661                reward_recipient: alice,
662                reward_per_token: U256::ZERO,
663                reward_balance: stored_reward,
664            })?;
665
666            assert_eq!(U256::from(token.get_pending_rewards(alice)?), stored_reward);
667
668            Ok(())
669        })
670    }
671
672    #[test]
673    fn test_get_pending_rewards() -> eyre::Result<()> {
674        let mut storage = HashMapStorageProvider::new(1);
675        let admin = Address::random();
676        let alice = Address::random();
677
678        StorageCtx::enter(&mut storage, || {
679            let alice_balance = U256::from(1000e18);
680            let reward_amount = U256::from(100e18);
681
682            let mut token = TIP20Setup::create("Test", "TST", admin)
683                .with_issuer(admin)
684                .with_mint(alice, alice_balance)
685                .with_mint(admin, reward_amount)
686                .apply()?;
687
688            token
689                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
690
691            // Before any rewards, pending should be 0
692            let pending_before = token.get_pending_rewards(alice)?;
693            assert_eq!(pending_before, 0u128);
694
695            // Distribute immediate reward
696            token.distribute_reward(
697                admin,
698                ITIP20::distributeRewardCall {
699                    amount: reward_amount,
700                },
701            )?;
702
703            // Now alice should have pending rewards equal to reward_amount (she's the only opted-in holder)
704            let pending_after = token.get_pending_rewards(alice)?;
705            assert_eq!(U256::from(pending_after), reward_amount);
706
707            // Verify that calling get_pending_rewards did not modify state
708            let user_info = token.get_user_reward_info(alice)?;
709            assert_eq!(
710                user_info.reward_balance,
711                U256::ZERO,
712                "get_pending_rewards should not modify state"
713            );
714
715            Ok(())
716        })
717    }
718
719    #[test]
720    fn test_get_pending_rewards_includes_stored_balance() -> eyre::Result<()> {
721        let mut storage = HashMapStorageProvider::new(1);
722        let admin = Address::random();
723        let alice = Address::random();
724
725        StorageCtx::enter(&mut storage, || {
726            let alice_balance = U256::from(1000e18);
727            let reward_amount = U256::from(50e18);
728
729            let mut token = TIP20Setup::create("Test", "TST", admin)
730                .with_issuer(admin)
731                .with_mint(alice, alice_balance)
732                .with_mint(admin, reward_amount * U256::from(2))
733                .apply()?;
734
735            token
736                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
737
738            // Distribute first reward
739            token.distribute_reward(
740                admin,
741                ITIP20::distributeRewardCall {
742                    amount: reward_amount,
743                },
744            )?;
745
746            // Trigger an action to update alice's stored reward balance
747            token.update_rewards(alice)?;
748            let user_info = token.get_user_reward_info(alice)?;
749            assert_eq!(user_info.reward_balance, reward_amount);
750
751            // Distribute second reward
752            token.distribute_reward(
753                admin,
754                ITIP20::distributeRewardCall {
755                    amount: reward_amount,
756                },
757            )?;
758
759            // get_pending_rewards should return stored + new accrued
760            let pending = token.get_pending_rewards(alice)?;
761            assert_eq!(U256::from(pending), reward_amount * U256::from(2));
762
763            Ok(())
764        })
765    }
766
767    #[test]
768    fn test_get_pending_rewards_with_delegation() -> eyre::Result<()> {
769        let mut storage = HashMapStorageProvider::new(1);
770        let admin = Address::random();
771        let alice = Address::random();
772        let bob = Address::random();
773
774        StorageCtx::enter(&mut storage, || {
775            let alice_balance = U256::from(1000e18);
776            let reward_amount = U256::from(100e18);
777
778            let mut token = TIP20Setup::create("Test", "TST", admin)
779                .with_issuer(admin)
780                .with_mint(alice, alice_balance)
781                .with_mint(admin, reward_amount)
782                .apply()?;
783
784            // Alice delegates to bob
785            token.set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: bob })?;
786
787            // Distribute immediate reward
788            token.distribute_reward(
789                admin,
790                ITIP20::distributeRewardCall {
791                    amount: reward_amount,
792                },
793            )?;
794
795            // Alice's pending should be 0 (she delegated to bob)
796            let alice_pending = token.get_pending_rewards(alice)?;
797            assert_eq!(alice_pending, 0u128);
798
799            // Bob's pending should be 0 until update_rewards is called for alice
800            // (We can't iterate all delegators on-chain, so pending calculation is limited
801            // to stored balance + self-delegated accrued rewards)
802            let bob_pending_before_update = token.get_pending_rewards(bob)?;
803            assert_eq!(bob_pending_before_update, 0u128);
804
805            // After calling update_rewards on alice, bob's stored balance is updated
806            token.update_rewards(alice)?;
807            let bob_pending_after_update = token.get_pending_rewards(bob)?;
808            assert_eq!(U256::from(bob_pending_after_update), reward_amount);
809
810            Ok(())
811        })
812    }
813
814    #[test]
815    fn test_get_pending_rewards_not_opted_in() -> eyre::Result<()> {
816        let mut storage = HashMapStorageProvider::new(1);
817        let admin = Address::random();
818        let alice = Address::random();
819        let bob = Address::random();
820
821        StorageCtx::enter(&mut storage, || {
822            let balance = U256::from(1000e18);
823            let reward_amount = U256::from(100e18);
824
825            let mut token = TIP20Setup::create("Test", "TST", admin)
826                .with_issuer(admin)
827                .with_mint(alice, balance)
828                .with_mint(bob, balance)
829                .with_mint(admin, reward_amount)
830                .apply()?;
831
832            // Only alice opts in
833            token
834                .set_reward_recipient(alice, ITIP20::setRewardRecipientCall { recipient: alice })?;
835
836            // Distribute reward
837            token.distribute_reward(
838                admin,
839                ITIP20::distributeRewardCall {
840                    amount: reward_amount,
841                },
842            )?;
843
844            // Alice should have pending rewards
845            let alice_pending = token.get_pending_rewards(alice)?;
846            assert_eq!(U256::from(alice_pending), reward_amount);
847
848            // Bob should have 0 pending rewards (not opted in)
849            let bob_pending = token.get_pending_rewards(bob)?;
850            assert_eq!(bob_pending, 0u128);
851
852            Ok(())
853        })
854    }
855
856    #[test]
857    fn test_claim_rewards_unauthorized() -> eyre::Result<()> {
858        let mut storage = HashMapStorageProvider::new(1);
859        let admin = Address::random();
860        let alice = Address::random();
861
862        StorageCtx::enter(&mut storage, || {
863            let mut registry = TIP403Registry::new();
864            registry.initialize()?;
865
866            let policy_id = registry.create_policy(
867                admin,
868                ITIP403Registry::createPolicyCall {
869                    admin,
870                    policyType: ITIP403Registry::PolicyType::BLACKLIST,
871                },
872            )?;
873
874            registry.modify_policy_blacklist(
875                admin,
876                ITIP403Registry::modifyPolicyBlacklistCall {
877                    policyId: policy_id,
878                    account: alice,
879                    restricted: true,
880                },
881            )?;
882
883            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
884
885            token.change_transfer_policy_id(
886                admin,
887                ITIP20::changeTransferPolicyIdCall {
888                    newPolicyId: policy_id,
889                },
890            )?;
891
892            let err = token.claim_rewards(alice).unwrap_err();
893            assert!(
894                matches!(
895                    err,
896                    TempoPrecompileError::TIP20(TIP20Error::PolicyForbids(_))
897                ),
898                "Expected PolicyForbids error, got: {err:?}"
899            );
900
901            Ok(())
902        })
903    }
904
905    #[test]
906    fn test_set_reward_recipient_rejects_virtual_on_t3() -> eyre::Result<()> {
907        let virtual_addr = Address::new_virtual(MasterId::ZERO, UserTag::ZERO);
908
909        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
910            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
911            let admin = Address::random();
912            let alice = Address::random();
913
914            StorageCtx::enter(&mut storage, || {
915                let mut token = TIP20Setup::create("Test", "TST", admin)
916                    .with_issuer(admin)
917                    .with_mint(alice, U256::from(1000))
918                    .apply()?;
919
920                let result = token.set_reward_recipient(
921                    alice,
922                    ITIP20::setRewardRecipientCall {
923                        recipient: virtual_addr,
924                    },
925                );
926
927                if hardfork.is_t3() {
928                    assert!(matches!(
929                        result.unwrap_err(),
930                        TempoPrecompileError::TIP20(TIP20Error::InvalidRecipient(_))
931                    ));
932                } else {
933                    // Pre-T3: virtual addresses are accepted
934                    assert!(result.is_ok());
935                }
936
937                Ok::<_, TempoPrecompileError>(())
938            })?;
939        }
940        Ok(())
941    }
942}