Smart Contract

FRC20Staking

A.d2abb5dbf5e08666.FRC20Staking

Valid From

86,128,744

Deployed

3d ago
Feb 24, 2026, 11:54:28 PM UTC

Dependents

14 imports
1/**
2> Author: Fixes Lab <https://github.com/fixes-world/>
3
4# FRC20 Staking Contract
5
6This contract is a FRC20 token staking contract, which allows users to stake their FRC20 tokens and earn rewards.
7
8*/
9// Third Party Imports
10import FungibleToken from 0xf233dcee88fe0abe
11import FlowToken from 0x1654653399040a61
12import MetadataViews from 0x1d7e57aa55817448
13import ViewResolver from 0x1d7e57aa55817448
14import NonFungibleToken from 0x1d7e57aa55817448
15import Burner from 0xf233dcee88fe0abe
16// Fixes Imports
17import Fixes from 0xd2abb5dbf5e08666
18import FRC20Indexer from 0xd2abb5dbf5e08666
19import FRC20FTShared from 0xd2abb5dbf5e08666
20import FRC20SemiNFT from 0xd2abb5dbf5e08666
21
22access(all) contract FRC20Staking {
23    /* --- Events --- */
24    /// Event emitted when the contract is initialized
25    access(all) event ContractInitialized()
26    /// Event emitted when the staking pool is created
27    access(all) event StakingInitialized(pool: Address, tick: String)
28    /// Event emitted when the reward strategy is created
29    access(all) event RewardStrategyInitialized(pool: Address, stakeTick: String, rewardTick: String, ftVaultType: String?)
30    /// Event emitted when the reward strategy is added
31    access(all) event RewardStrategyAdded(pool: Address, stakeTick: String, rewardTick: String)
32    /// Event emitted when the reward income is added
33    access(all) event RewardIncomeAdded(pool: Address, tick: String, amount: UFix64, from: Address)
34    /// Event emitted when the delegator record is added
35    access(all) event DelegatorRecordAdded(pool: Address, tick: String, delegatorID: UInt32, delegatorAddress: Address)
36    /// Event emitted when the delegator staked FRC20 token
37    access(all) event DelegatorStaked(pool: Address, tick: String, delegatorID: UInt32, delegatorAddress: Address, amount: UFix64)
38    /// Event emitted when the delegator try to staking FRC20 token and lock tokens
39    access(all) event DelegatorUnStakingLocked(pool: Address, tick: String, delegatorID: UInt32, delegatorAddress: Address, amount: UFix64, unlockTime: UInt64)
40    /// Event emitted when the delegator unstaked FRC20 token
41    access(all) event DelegatorUnStakingUnlocked(pool: Address, tick: String, delegatorID: UInt32, delegatorAddress: Address, amount: UFix64)
42    /// Event emitted when the delegator unstaked FRC20 token
43    access(all) event DelegatorUnStaked(pool: Address, tick: String, delegatorID: UInt32, delegatorAddress: Address, amount: UFix64)
44    /// Event emitted when the delegator claim status is updated
45    access(all) event DelegatorClaimedReward(pool: Address, stakeTick: String, rewardTick: String, amount: UFix64, yieldAdded: UFix64)
46    /// Event emitted when the delegator received staked FRC20 token
47    access(all) event DelegatorStakedTokenDeposited(tick: String, pool: Address, receiver: Address, amount: UFix64, semiNftId: UInt64)
48
49    /* --- Variable, Enums and Structs --- */
50    access(all)
51    let StakingPoolStoragePath: StoragePath
52    access(all)
53    let StakingPoolPublicPath: PublicPath
54    access(all)
55    let DelegatorStoragePath: StoragePath
56    access(all)
57    let DelegatorPublicPath: PublicPath
58
59    /* --- Interfaces & Resources --- */
60
61    /// Staking Info Struct, represents the staking info of a FRC20 token
62    ///
63    access(all) struct StakingInfo {
64        access(all)
65        let tick: String
66        access(all)
67        let totalStaked: UFix64
68        access(all)
69        let totalUnstakingLocked: UFix64
70        access(all)
71        let delegatorsAmount: UInt32
72        access(all)
73        let rewardStrategies: [String]
74
75        view init(
76            tick: String,
77            totalStaked: UFix64,
78            totalUnstakingLocked: UFix64,
79            delegatorsAmount: UInt32,
80            rewardStrategies: [String]
81        ) {
82            self.tick = tick
83            self.totalStaked = totalStaked
84            self.totalUnstakingLocked = totalUnstakingLocked
85            self.delegatorsAmount = delegatorsAmount
86            self.rewardStrategies = rewardStrategies
87        }
88    }
89
90    /// Pool Public Interface
91    ///
92    access(all) resource interface PoolPublic {
93        /// The ticker name of the FRC20 Staking Pool
94        access(all)
95        let tick: String
96
97        /// Returns the details of the staking pool
98        access(all)
99        view fun getDetails(): StakingInfo
100
101        /** ---- Rewards ---- */
102        /// Returns the reward strategy names
103        access(all)
104        view fun getRewardNames(): [String]
105
106        /// Returns the reward details of the given name
107        access(all)
108        view fun getRewardDetails(_ rewardTick: String): RewardDetails?
109
110        /** -- Rewards: Account Level Methods -- */
111
112        /// register reward strategy
113        access(account)
114        fun registerRewardStrategy(rewardTick: String)
115
116        /// Borrow the Reward Strategy
117        access(account)
118        view fun borrowRewardStrategy(_ rewardTick: String): &RewardStrategy?
119
120        /** ---- Delegators ---- */
121
122        /// Returns the delegators of this staking pool
123        access(all)
124        view fun getDelegators(): [Address]
125
126        /// Returns the delegator unstaking info
127        access(all)
128        view fun getDelegatorUnstakingInfo(_ delegator: Address): DelegatorUnstakingInfo?
129
130        /** -- Delegators: Account Level Methods -- */
131
132        /// Stake FRC20 token
133        access(account)
134        fun stake(_ change: @FRC20FTShared.Change)
135
136        /// Unstake FRC20 token
137        access(account)
138        fun unstake(
139            _ semiNFTCol: auth(NonFungibleToken.Withdraw) &FRC20SemiNFT.Collection,
140            nftId: UInt64
141        )
142
143        /// Claim all unlocked staked changes
144        access(account)
145        fun claimUnlockedUnstakingChange(
146            delegator: Address
147        ): @FRC20FTShared.Change?
148
149        /** ---- Contract Level Methods ---- */
150
151        /// Borrow Delegator Record
152        access(contract)
153        view fun borrowDelegatorRecord(_ addr: Address): &DelegatorRecord?
154    }
155
156    access(all) resource Pool: PoolPublic {
157        /// The ticker name of the FRC20 Staking Pool
158        access(all)
159        let tick:String
160        /// The total FRC20 tokens staked in the pool
161        access(contract)
162        var totalStaked: @FRC20FTShared.Change?
163        /// The counter for FRC20 tokens unstaking locked in the pool
164        access(all)
165        var totalUnstakingLocked: UFix64
166        /** ----- Delegators ---- */
167        /// The delegator ID counter
168        access(all)
169        var delegatorIDCounter: UInt32
170        /// The delegators of this staking pool
171        access(self)
172        let delegators: @{Address: DelegatorRecord}
173        /** ----- Rewards ----- */
174        /// The rewards of this staking pool
175        access(self)
176        let rewards: @{String: RewardStrategy}
177
178        init(
179            _ tick: String
180        ) {
181            // Singleton
182            let frc20Indexer = FRC20Indexer.getIndexer()
183            assert(
184                frc20Indexer.getTokenMeta(tick: tick) != nil,
185                message: "Reward tick must be valid"
186            )
187
188            self.tick = tick
189            self.totalStaked <- nil
190            self.totalUnstakingLocked = 0.0
191            self.delegators <- {}
192            self.delegatorIDCounter = 0
193            self.rewards <- {}
194        }
195
196        /// Initialize the staking record
197        ///
198        access(account)
199        fun initialize() {
200            pre {
201                self.totalStaked == nil: "Total staked must be nil"
202            }
203            let owner = self.owner?.address ?? panic("Pool owner must exist")
204            self.totalStaked <-! FRC20FTShared.createEmptyChange(tick: self.tick, from: owner)
205
206            // emit event
207            emit StakingInitialized(pool: owner, tick: self.tick)
208        }
209
210        /** ---- Public Methods ---- */
211
212        /// Returns the details of the staking pool
213        ///
214        access(all)
215        view fun getDetails(): StakingInfo {
216            let totalStakedRef = self.borrowTotalStaked()
217            return StakingInfo(
218                tick: self.tick,
219                totalStaked: totalStakedRef.getBalance(),
220                totalUnstakingLocked: self.totalUnstakingLocked,
221                delegatorsAmount: UInt32(self.delegators.length),
222                rewardStrategies: self.getRewardNames()
223            )
224        }
225
226        /// Returns the reward strategy names
227        ///
228        access(all)
229        view fun getRewardNames(): [String] {
230            return self.rewards.keys
231        }
232
233        /// Returns the reward details of the given tick name
234        ///
235        access(all)
236        view fun getRewardDetails(_ rewardTick: String): RewardDetails? {
237            if let reward = self.borrowRewardStrategy(rewardTick) {
238                return RewardDetails(
239                    stakeTick: reward.stakeTick,
240                    totalReward: reward.totalReward.getBalance(),
241                    globalYieldRate: reward.globalYieldRate,
242                    rewardTick: reward.rewardTick,
243                    registeredAt: reward.registeredAt
244                )
245            }
246            return nil
247        }
248
249        /// Returns the delegators of this staking pool
250        ///
251        access(all)
252        view fun getDelegators(): [Address] {
253            return self.delegators.keys
254        }
255
256        /// Returns the delegator unstaking info
257        ///
258        access(all)
259        view fun getDelegatorUnstakingInfo(_ delegator: Address): DelegatorUnstakingInfo? {
260            if let delegatorRecordRef = self.borrowDelegatorRecord(delegator) {
261                return delegatorRecordRef.getDetails()
262            }
263            return nil
264        }
265
266        /** ---- Account Level Methods ----- */
267
268        /// register reward strategy
269        ///
270        access(account)
271        fun registerRewardStrategy(rewardTick: String) {
272            pre {
273                self.rewards[rewardTick] == nil: "Reward strategy name already exists"
274            }
275            let poolAddr = self.owner?.address ?? panic("Pool owner must exist")
276            let strategy <- create RewardStrategy(
277                pool: FRC20Staking.getPoolCap(poolAddr),
278                rewardTick: rewardTick
279            )
280            self.rewards[rewardTick] <-! strategy
281
282            // emit event
283            emit RewardStrategyAdded(
284                pool: self.owner?.address ?? panic("Reward owner must exist"),
285                stakeTick: self.tick,
286                rewardTick: rewardTick
287            )
288        }
289
290        /// Stake FRC20 token
291        ///
292        access(account)
293        fun stake(_ change: @FRC20FTShared.Change) {
294            pre {
295                change.tick == self.tick: "Staked change tick must match"
296            }
297
298            let stakedAmount = change.getBalance()
299            let delegator = change.from
300
301            // ensure delegator record exists
302            let delegatorRecordRef = self.borrowOrCreateDelegatorRecord(delegator)
303
304            let poolAddr = self.owner?.address ?? panic("Pool owner must exist")
305
306            // create staked tick change
307            let stakedChange <- FRC20FTShared.createStakedChange(
308                ref: &change as &FRC20FTShared.Change,
309                issuer: poolAddr
310            )
311
312            // update staked change
313            let totalStakeRef = self.borrowTotalStaked()
314            totalStakeRef.forceMerge(from: <- change)
315
316            // update staked change for delegator
317            let delegatorRef = delegatorRecordRef.borrowDelegatorRef()
318            // call onFRC20Staked to save the staked change
319            delegatorRef.onFRC20Staked(stakedChange: <- stakedChange)
320
321            // emit stake event
322            emit DelegatorStaked(
323                pool: poolAddr,
324                tick: self.tick,
325                delegatorID: delegatorRecordRef.id,
326                delegatorAddress: delegator,
327                amount: stakedAmount
328            )
329        }
330
331        /// Unstake FRC20 token
332        ///
333        access(account)
334        fun unstake(
335            _ semiNFTCol: auth(NonFungibleToken.Withdraw) &FRC20SemiNFT.Collection,
336            nftId: UInt64
337        ) {
338            let poolAddr = self.owner?.address ?? panic("Pool owner must exist")
339            let delegator = semiNFTCol.owner?.address ?? panic("Delegator must exist")
340            // ensure the nft is valid
341            let nftRef = semiNFTCol.borrowFRC20SemiNFTPublic(id: nftId)
342                ?? panic("Staked NFT must exist")
343            assert(
344                nftRef.getOriginalTick() == self.tick,
345                message: "NFT tick must match"
346            )
347            assert(
348                nftRef.getFromAddress() == poolAddr,
349                message: "NFT must be created from pool"
350            )
351
352            // add to totalUnstakingLocked
353            self.totalUnstakingLocked = self.totalUnstakingLocked + nftRef.getBalance()
354
355            // withdraw the nft from semiNFT collection
356            let nft <- semiNFTCol.withdraw(withdrawID: nftId) as! @FRC20SemiNFT.NFT
357
358            // ensure delegator record exists
359            let delegatorRecordRef = self.borrowOrCreateDelegatorRecord(delegator)
360
361            // save the nft to unstaking queue in delegator record
362            delegatorRecordRef.addUnstakingEntry(<- nft)
363        }
364
365        /// Claim all unlocked staked changes
366        ///
367        access(account)
368        fun claimUnlockedUnstakingChange(
369            delegator: Address
370        ): @FRC20FTShared.Change? {
371            let poolAddr = self.owner?.address ?? panic("Pool owner must exist")
372            let delegatorRecordRef = self.borrowDelegatorRecord(delegator)
373                ?? panic("Delegator record must exist")
374            if let unlockedStakedChange <- delegatorRecordRef.refundAllUnlockedEnties() {
375                // extract all unlocked staked change
376                let amount = unlockedStakedChange.extract()
377                // destroy the unlocked staked change
378                Burner.burn(<- unlockedStakedChange)
379
380                let totalStakedRef = self.borrowTotalStaked()
381                // withdraw from totalStaked
382                let unstakedChange <- totalStakedRef.withdrawAsChange(amount: amount)
383
384                // decrease totalUnstakingLocked
385                self.totalUnstakingLocked = self.totalUnstakingLocked - unstakedChange.getBalance()
386
387                // emit event
388                emit DelegatorUnStaked(
389                    pool: poolAddr,
390                    tick: self.tick,
391                    delegatorID: delegatorRecordRef.id,
392                    delegatorAddress: delegator,
393                    amount: amount
394                )
395                return <- unstakedChange
396            }
397            return nil
398        }
399
400        /// Borrow Reward Strategy
401        ///
402        access(account)
403        view fun borrowRewardStrategy(_ rewardTick: String): &RewardStrategy? {
404            return &self.rewards[rewardTick]
405        }
406
407        /** ---- Contract Level Methods ----- */
408
409        /// Borrow or craete the delegator record
410        ///
411        access(contract)
412        fun borrowOrCreateDelegatorRecord(_ addr: Address): &DelegatorRecord {
413            // check if delegator's record exists
414            if self.delegators[addr] == nil {
415                self._addDelegator(<- create DelegatorRecord(
416                    id: self.delegatorIDCounter,
417                    tick: self.tick,
418                    address: addr
419                ))
420            }
421            return self.borrowDelegatorRecord(addr)!
422        }
423
424        /// Borrow Delegator Record
425        ///
426        access(contract)
427        view fun borrowDelegatorRecord(_ addr: Address): &DelegatorRecord? {
428            return &self.delegators[addr]
429        }
430
431        /// Borrow Staked Change
432        ///
433        access(contract)
434        view fun borrowTotalStaked(): auth(FRC20FTShared.Write) &FRC20FTShared.Change {
435            return &self.totalStaked as auth(FRC20FTShared.Write) &FRC20FTShared.Change?
436                ?? panic("Total staked must exist")
437        }
438
439        /** ---- Internal Methods */
440
441        /// Add the Delegator Record
442        ///
443        access(self)
444        fun _addDelegator(_ newRecord: @DelegatorRecord) {
445            pre {
446                self.delegators[newRecord.delegator] == nil: "Delegator id already exists"
447            }
448            let delegatorID = newRecord.id
449            let address = newRecord.delegator
450            self.delegators[newRecord.delegator] <-! newRecord
451
452            // increase delegator ID counter
453            self.delegatorIDCounter = self.delegatorIDCounter + 1
454
455            let ref = self.borrowDelegatorRecord(address)
456                ?? panic("Delegator record must exist")
457
458            // emit event
459            emit DelegatorRecordAdded(
460                pool: self.owner?.address ?? panic("Pool owner must exist"),
461                tick: self.tick,
462                delegatorID: delegatorID,
463                delegatorAddress: ref.delegator
464            )
465        }
466    }
467
468    /// Delegator Unstaking Info Struct, represents a delegator unstaking info for a FRC20 token
469    ///
470    access(all) struct DelegatorUnstakingInfo {
471        access(all) let delegator: Address
472        access(all) let delegatorId: UInt32
473        access(all) let stakeTick: String
474        access(all) let unstakingEntriesNFTIds: [UInt64]
475        access(all) let totalUnstakingBalance: UFix64
476        access(all) let totalUnlockedClaimableBalance: UFix64
477        view init(
478            delegator: Address,
479            delegatorId: UInt32,
480            stakeTick: String,
481            unstakingEntriesNFTIds: [UInt64],
482            totalUnstakingBalance: UFix64,
483            totalUnlockedClaimableBalance: UFix64
484        ) {
485            self.delegator = delegator
486            self.delegatorId = delegatorId
487            self.stakeTick = stakeTick
488            self.unstakingEntriesNFTIds = unstakingEntriesNFTIds
489            self.totalUnstakingBalance = totalUnstakingBalance
490            self.totalUnlockedClaimableBalance = totalUnlockedClaimableBalance
491        }
492    }
493
494    /// Interface of Delegator Unstaking Entry
495    ///
496    access(all) resource interface UnstakingEntryPublic {
497        access(all)
498        let unlockTime: UInt64
499
500        access(all)
501        view fun getNFTId(): UInt64?
502
503        access(all)
504        view fun isUnlocked(): Bool
505
506        access(all)
507        view fun unlockingBalance(): UFix64
508
509        access(all)
510        view fun isExtracted(): Bool
511    }
512
513    /// Unstaking Entry Resource, represents a unstaking entry for a FRC20 token
514    ///
515    access(all) resource UnstakingEntry: UnstakingEntryPublic, Burner.Burnable {
516        access(all)
517        let unlockTime: UInt64
518        access(all)
519        var unstakingNFT: @FRC20SemiNFT.NFT?
520
521        init(
522            unlockTime: UInt64,
523            unstakingNFT: @FRC20SemiNFT.NFT
524        ) {
525            self.unlockTime = unlockTime
526            self.unstakingNFT <- unstakingNFT
527        }
528
529        access(contract)
530        fun burnCallback() {
531            pre {
532                self.isExtracted(): "Unstaking NFT must be extracted"
533            }
534            // NOTHING
535        }
536
537        access(all)
538        view fun getNFTId(): UInt64? {
539            return self.unstakingNFT?.id
540        }
541
542        access(all)
543        view fun isUnlocked(): Bool {
544            return UInt64(getCurrentBlock().timestamp) >= self.unlockTime
545        }
546
547        access(all)
548        view fun unlockingBalance(): UFix64 {
549            return self.unstakingNFT?.getBalance() ?? 0.0
550        }
551
552        access(all)
553        view fun isExtracted(): Bool {
554            return self.unstakingNFT == nil
555        }
556
557        /// Extract the unstaking FRC20 token
558        access(contract)
559        fun extract(): @FRC20FTShared.Change {
560            pre {
561                self.unstakingNFT != nil : "Unstaking NFT must exist"
562            }
563            post {
564                self.unstakingNFT == nil : "Unstaking NFT must be destroyed"
565            }
566            var toUnwrapNft: @FRC20SemiNFT.NFT? <- nil
567            self.unstakingNFT <-> toUnwrapNft
568
569            return <- FRC20SemiNFT.unwrapStakedFRC20(nftToUnwrap: <- toUnwrapNft!)
570        }
571    }
572
573    /// Delegator Record Resource, represents a delegator record for a FRC20 token and store in pool's account
574    ///
575    access(all) resource DelegatorRecord: Burner.Burnable {
576        // The delegator ID
577        access(all)
578        let id: UInt32
579        // The delegator address
580        access(all)
581        let delegator: Address
582        // The staking tick
583        access(all)
584        let stakeTick: String
585        // The delegator's unstaking entries
586        access(contract)
587        let unstakingEntries: @[UnstakingEntry]
588
589        init(
590            id: UInt32,
591            tick: String,
592            address: Address
593        ) {
594            pre {
595                FRC20Staking.borrowDelegator(address) != nil: "Delegator must exist"
596            }
597            self.id = id
598            self.stakeTick = tick
599            self.delegator = address
600            self.unstakingEntries <- []
601        }
602
603        access(contract)
604        fun burnCallback() {
605            pre {
606                self.unstakingEntries.length == 0: "Unstaking entries must be empty"
607            }
608            // NOTHING
609        }
610
611        access(all)
612        view fun entriesLength(): UInt64 {
613            return UInt64(self.unstakingEntries.length)
614        }
615
616        /// Is the frist unstaking entry unlocked
617        ///
618        access(all)
619        view fun isFirstUnlocked(): Bool {
620            if self.unstakingEntries.length > 0 {
621                return self.unstakingEntries[0].isUnlocked()
622            }
623            return false
624        }
625
626        /// Get all unlocked balance
627        ///
628        access(all)
629        view fun getDetails(): DelegatorUnstakingInfo {
630            var totalUnlockedBalance = 0.0
631            var totalBalance = 0.0
632            var nftIds: [UInt64] = []
633            let len = self.unstakingEntries.length
634            var i = 0
635            while i < len {
636                let entryRef = self.borrowEntry(i)
637                let unlockingBalance = entryRef.unlockingBalance()
638                // add to NFTIds
639                if let nftId = entryRef.getNFTId() {
640                    nftIds = nftIds.concat([nftId])
641                }
642                // add to unlocked balance
643                if entryRef.isUnlocked() {
644                    totalUnlockedBalance = totalUnlockedBalance + unlockingBalance
645                }
646                // add to total balance
647                totalBalance = totalBalance + unlockingBalance
648                // loop next
649                i = i + 1
650            }
651            return DelegatorUnstakingInfo(
652                delegator: self.delegator,
653                delegatorId: self.id,
654                stakeTick: self.stakeTick,
655                unstakingEntriesNFTIds: nftIds,
656                totalUnstakingBalance: totalBalance,
657                totalUnlockedClaimableBalance: totalUnlockedBalance
658            )
659        }
660
661        /// Locking unstaking FRC20 token
662        ///
663        access(contract)
664        fun addUnstakingEntry(
665            _ unstakingNFT: @FRC20SemiNFT.NFT
666        ) {
667            pre {
668                unstakingNFT.getOriginalTick() == self.stakeTick: "Unstaking NFT tick must match"
669            }
670            let unlockTime = UInt64(getCurrentBlock().timestamp) + FRC20Staking.getUnstakingLockTime()
671            let tick = unstakingNFT.getOriginalTick()
672            let amount = unstakingNFT.getBalance()
673            self.unstakingEntries.append(
674                <- create UnstakingEntry(
675                    unlockTime: unlockTime,
676                    unstakingNFT: <- unstakingNFT
677                )
678            )
679
680            // emit event
681            emit DelegatorUnStakingLocked(
682                pool: self.owner?.address ?? panic("Pool owner must exist"),
683                tick: tick,
684                delegatorID: self.id,
685                delegatorAddress: self.delegator,
686                amount: amount,
687                unlockTime: unlockTime
688            )
689        }
690
691        access(contract)
692        fun refundAllUnlockedEnties(): @FRC20FTShared.Change? {
693            if self.unstakingEntries.length > 0 {
694                // create new staked frc20 change
695                let ret: @FRC20FTShared.Change <- FRC20FTShared.createEmptyChange(
696                    tick: "!".concat(self.stakeTick),
697                    from: self.owner?.address ?? panic("Pool owner must exist"),
698                )
699
700                let len = self.unstakingEntries.length
701                var isFirstUnlocked = self.isFirstUnlocked()
702                while isFirstUnlocked {
703                    let entryRef = self.borrowEntry(0)
704                    if entryRef.isUnlocked() {
705                        // remove the first entry
706                        let entry <- self.unstakingEntries.remove(at: 0)
707                        // extract the unstaked change
708                        let unwrappedChange <- entry.extract()
709                        // extracted
710                        assert(
711                            entry.isExtracted(),
712                            message: "Unstaking entry must be extracted"
713                        )
714                        Burner.burn(<- entry)
715                        // merge to ret change
716                        let amount = unwrappedChange.getBalance()
717                        // deposit to change
718                        ret.forceMerge(from: <- unwrappedChange)
719
720                        // emit event
721                        emit DelegatorUnStakingUnlocked(
722                            pool: self.owner?.address ?? panic("Pool owner must exist"),
723                            tick: self.stakeTick,
724                            delegatorID: self.id,
725                            delegatorAddress: self.delegator,
726                            amount: amount
727                        )
728                    }
729                    // check again, if the first entry is unlocked
730                    isFirstUnlocked = self.isFirstUnlocked()
731                }
732                // return the unstaked change
733                return <- ret
734            }
735            return nil
736        }
737
738        /// Borrow Delegator reference
739        ///
740        access(contract)
741        view fun borrowDelegatorRef(): &Delegator {
742            return FRC20Staking.borrowDelegator(self.delegator) ?? panic("Delegator must exist")
743        }
744
745        access(self)
746        view fun borrowEntry(_ index: Int): &UnstakingEntry {
747            pre {
748                index < self.unstakingEntries.length: "Index must be less than entries length"
749            }
750            return &self.unstakingEntries[index]
751        }
752    }
753
754    /// Reward Details Struct, represents a reward details for a FRC20 token
755    ///
756    access(all) struct RewardDetails {
757        access(all)
758        let stakeTick: String
759        access(all)
760        let totalReward: UFix64
761        access(all)
762        let globalYieldRate: UFix64
763        access(all)
764        let rewardTick: String
765        access(all)
766        let rewardVaultType: Type?
767        access(all)
768        let registeredAt: UFix64
769
770        view init(
771            stakeTick: String,
772            totalReward: UFix64,
773            globalYieldRate: UFix64,
774            rewardTick: String,
775            registeredAt: UFix64
776        ) {
777            self.stakeTick = stakeTick
778            self.totalReward = totalReward
779            self.globalYieldRate = globalYieldRate
780            self.rewardTick = rewardTick
781            self.registeredAt = registeredAt
782
783            if rewardTick == "" {
784                self.rewardVaultType = Type<@FlowToken.Vault>()
785            } else if rewardTick.slice(from: 0, upTo: 2) == "A." {
786                self.rewardVaultType = CompositeType(rewardTick)
787            } else {
788                self.rewardVaultType = nil
789            }
790        }
791    }
792
793    /// Reward Strategy Resource, represents a reward strategy for a FRC20 token and store in pool's account
794    ///
795    access(all) resource RewardStrategy: Burner.Burnable {
796        /// The pool capability
797        access(self)
798        let poolCap: Capability<&Pool>
799        /// The ticker name of staking pool
800        access(all)
801        let stakeTick: String
802        /// The ticker name of reward
803        access(all)
804        let rewardTick: String
805        /// The registered time of the reward strategy
806        access(contract)
807        let registeredAt: UFix64
808        /// The global yield rate of the reward strategy
809        access(contract)
810        var globalYieldRate: UFix64
811        /// The reward change, can be any FRC20 token or Flow FT
812        access(contract)
813        let totalReward: @FRC20FTShared.Change
814
815        init(
816            pool: Capability<&Pool>,
817            rewardTick: String,
818        ) {
819            pre {
820                pool.check(): "Pool must be valid"
821            }
822
823            self.registeredAt = getCurrentBlock().timestamp
824            self.poolCap = pool
825            let poolRef = pool.borrow() ?? panic("Pool must exist")
826
827            self.stakeTick = poolRef.tick
828            self.rewardTick = rewardTick
829            self.globalYieldRate = 0.0
830
831            // current only support FlowToken
832            let isFtVault = rewardTick == "" || rewardTick.slice(from: 0, upTo: 2) == "A."
833            /// create empty change
834            if isFtVault {
835                assert(
836                    rewardTick == "" || rewardTick == Type<@FlowToken.Vault>().identifier,
837                    message: "Currently only FlowToken.Vault is supported"
838                )
839                self.totalReward <- FRC20FTShared.createEmptyFlowChange(from: pool.address)
840            } else {
841                // Singleton
842                let frc20Indexer = FRC20Indexer.getIndexer()
843                assert(
844                    frc20Indexer.getTokenMeta(tick: rewardTick) != nil,
845                    message: "Reward tick must be valid"
846                )
847                self.totalReward <- FRC20FTShared.createEmptyChange(tick: rewardTick, from: pool.address)
848            }
849
850            // emit event
851            emit RewardStrategyInitialized(
852                pool: pool.address,
853                stakeTick: poolRef.tick,
854                rewardTick: rewardTick,
855                ftVaultType: isFtVault ? self.totalReward.getVaultType()?.identifier! : nil
856            )
857        }
858
859        access(contract)
860        fun burnCallback() {
861            pre {
862                self.totalReward.getBalance() == 0.0: "Total reward must be zero"
863            }
864            // NOTHING
865        }
866
867        access(account)
868        fun addIncome(income: @FRC20FTShared.Change) {
869            pre {
870                self.poolCap.check(): "Pool must be valid"
871                self.owner?.address == self.poolCap.address: "Pool owner must match with reward strategy owner"
872                income.tick == self.rewardTick: "Income tick must match with reward strategy tick"
873            }
874
875            let pool = self.poolCap.borrow() ?? panic("Pool must exist")
876
877            let incomeFrom = income.from
878            let incomeValue = income.getBalance()
879            if incomeValue > 0.0 {
880                let totalStakedRef = pool.borrowTotalStaked()
881                // add to total reward and update global yield rate
882                let totalStakedToken = totalStakedRef.getBalance()
883                let newAddedYieldRate = totalStakedToken > 0.0
884                    ? incomeValue / totalStakedToken
885                    : 0.0
886                // update global yield rate
887                self.globalYieldRate = self.globalYieldRate + newAddedYieldRate
888
889                if newAddedYieldRate > 0.0 {
890                    // add to total reward
891                    self.totalReward.forceMerge(from: <- income)
892
893                    // emit event
894                    emit RewardIncomeAdded(
895                        pool: pool.owner?.address!,
896                        tick: self.rewardTick,
897                        amount: incomeValue,
898                        from: incomeFrom
899                    )
900                } else {
901                    // if the income is not enough to update the global yield rate
902                    // deposit the income to pool's address
903                    let poolAddr = pool.owner?.address ?? panic("Pool owner must exist")
904                    // create an empty change for the reward
905                    let newChange <- FRC20FTShared.createEmptyChange(
906                        tick: self.rewardTick,
907                        from: poolAddr
908                    )
909                    // Deposit pool address for accumulating enough values
910                    newChange.forceMerge(from: <- income)
911
912                    let indexer = FRC20Indexer.getIndexer()
913                    // deposit change to indexer
914                    indexer.returnChange(change: <- newChange)
915                }
916            } else {
917                Burner.burn(<- income)
918            }
919        }
920
921        /// Claim reward, the return valus's from is the delegator's address
922        ///
923        access(account)
924        fun claim(
925            byNft: &FRC20SemiNFT.NFT,
926        ): @FRC20FTShared.Change {
927            pre {
928                self.poolCap.check(): "Pool must be valid"
929                self.owner?.address == self.poolCap.address: "Pool owner must match with reward strategy owner"
930                byNft.getOriginalTick() == self.stakeTick: "NFT tick must match with reward strategy tick"
931            }
932            post {
933                byNft.owner?.address == result.from: "Result from must match with NFT owner"
934            }
935            let pool = self.poolCap.borrow() ?? panic("Pool must exist")
936
937            // global info
938            let totalStakedRef = pool.borrowTotalStaked()
939            let totalStakedToken = totalStakedRef.getBalance()
940            let totalRewardBalance = self.totalReward.getBalance()
941
942            // related addreses info
943            let poolAddr = pool.owner?.address ?? panic("Pool owner must exist")
944            let delegator = byNft.owner?.address ?? panic("Delegator must exist")
945
946            // create an empty change for the reward
947            let delegatorRewardChange <- FRC20FTShared.createEmptyChange(
948                tick: self.rewardTick,
949                from: delegator
950            )
951
952            // delegator info
953            let delegatorRef = FRC20Staking.borrowDelegator(delegator)
954                ?? panic("Delegator must exist")
955            let strategyUniqueName = byNft.buildUniqueName(poolAddr, self.rewardTick)
956            let claimingRecord = byNft.getClaimingRecord(strategyUniqueName)
957
958            // calculate reward
959            let delegatorLastGlobalYieldRate = claimingRecord?.lastGlobalYieldRate ?? 0.0
960            let delegatorStakedToken = byNft.getBalance() // staked token's balance is the same as NFT's balance
961
962            // ensure delegator's global yield rate is less than current global yield rate
963            if self.globalYieldRate <= delegatorLastGlobalYieldRate {
964                // no reward to claim
965                return <- delegatorRewardChange
966            }
967
968            // This is reward to distribute
969            let yieldReward = (self.globalYieldRate - delegatorLastGlobalYieldRate) * delegatorStakedToken
970            assert(
971                yieldReward <= totalRewardBalance,
972                message: "Reward must be less than total reward"
973            )
974
975            // withdraw from totalReward
976            let withdrawnChange: @FRC20FTShared.Change <- self.totalReward.withdrawAsChange(amount: yieldReward)
977
978            // update delegator claiming record
979            delegatorRef.onClaimingReward(
980                reward: &self as &RewardStrategy,
981                byNftId: byNft.id,
982                amount: yieldReward,
983                currentGlobalYieldRate: self.globalYieldRate
984            )
985
986            // emit event
987            emit DelegatorClaimedReward(
988                pool: self.owner?.address ?? panic("Reward owner must exist"),
989                stakeTick: self.stakeTick,
990                rewardTick: self.rewardTick,
991                amount: yieldReward,
992                yieldAdded: self.globalYieldRate
993            )
994
995            // Deposit the reward to delegatorRewardChange's change
996            delegatorRewardChange.forceMerge(from: <- withdrawnChange)
997
998            // return the change
999            return <- delegatorRewardChange
1000        }
1001
1002        /** ---- Internal Methods ---- */
1003
1004        access(self)
1005        view fun borrowRewardRef(): &FRC20FTShared.Change {
1006            return &self.totalReward
1007        }
1008    }
1009
1010    /// Delegator Public Interface
1011    ///
1012    access(all) resource interface DelegatorPublic {
1013        /** ---- Public methods ---- */
1014
1015        /// Get the staked frc20 token balance of the delegator
1016        access(all)
1017        view fun getStakedBalance(tick: String): UFix64
1018
1019        /// Get the staked frc20 Semi-NFTs of the delegator
1020        access(all)
1021        view fun getStakedNFTIds(tick: String): [UInt64]
1022
1023        /** ---- Contract level methods ---- */
1024
1025        /// Invoked when the staking is successful
1026        access(contract)
1027        fun onFRC20Staked(
1028            stakedChange: @FRC20FTShared.Change
1029        )
1030
1031        /// Update the claiming record
1032        access(contract)
1033        fun onClaimingReward(
1034            reward: &RewardStrategy,
1035            byNftId: UInt64,
1036            amount: UFix64,
1037            currentGlobalYieldRate: UFix64
1038        )
1039    }
1040
1041    /// Delegator Resource, represents a delegator and store in user's account
1042    ///
1043    access(all) resource Delegator: DelegatorPublic {
1044        access(self)
1045        let semiNFTcolCap: Capability<auth(NonFungibleToken.Update, NonFungibleToken.Withdraw) &FRC20SemiNFT.Collection>
1046
1047        init(
1048            _ semiNFTCol: Capability<auth(NonFungibleToken.Update, NonFungibleToken.Withdraw) &FRC20SemiNFT.Collection>
1049        ) {
1050            pre {
1051                semiNFTCol.check(): "SemiNFT Collection must be valid"
1052            }
1053            self.semiNFTcolCap = semiNFTCol
1054        }
1055
1056        /** ----- Public Methods ----- */
1057
1058        /// Get the staked frc20 token balance of the delegator
1059        ///
1060        access(all)
1061        view fun getStakedBalance(tick: String): UFix64 {
1062            let colRef = self.borrowSemiNFTCollection()
1063            return colRef.getStakedBalance(tick: tick)
1064        }
1065
1066        /// Get the staked frc20 Semi-NFTs of the delegator
1067        ///
1068        access(all)
1069        view fun getStakedNFTIds(tick: String): [UInt64] {
1070            let colRef = self.borrowSemiNFTCollection()
1071            return colRef.getIDsByTick(tick: tick)
1072        }
1073
1074        /** ----- Contract Methods ----- */
1075
1076        /// Invoked when the staking is successful
1077        ///
1078        access(contract)
1079        fun onFRC20Staked(
1080            stakedChange: @FRC20FTShared.Change
1081        ) {
1082            pre {
1083                stakedChange.isStakedTick(): "Staked change tick must be staked tick"
1084            }
1085            let from = stakedChange.from
1086            let pool = FRC20Staking.borrowPool(from)
1087                ?? panic("Pool must exist")
1088            assert(
1089                pool.tick == stakedChange.getOriginalTick(),
1090                message: "Staked change tick must match"
1091            )
1092            // deposit
1093            self._depositStakedToken(change: <- stakedChange)
1094        }
1095
1096        /// Update the claiming record
1097        ///
1098        access(contract)
1099        fun onClaimingReward(
1100            reward: &RewardStrategy,
1101            byNftId: UInt64,
1102            amount: UFix64,
1103            currentGlobalYieldRate: UFix64
1104        ) {
1105            let pool = reward.owner?.address ?? panic("Reward owner must exist")
1106
1107            // borrow the nft from semiNFT collection
1108            let semiNFTCol = self.borrowSemiNFTCollection()
1109            let stakedNFT = semiNFTCol.borrowFRC20SemiNFT(id: byNftId)
1110                ?? panic("Staked NFT must exist")
1111
1112            // update the claiming record
1113            stakedNFT.onClaimingReward(
1114                poolAddress: pool,
1115                rewardTick: reward.rewardTick,
1116                amount: amount,
1117                currentGlobalYieldRate: currentGlobalYieldRate
1118            )
1119        }
1120
1121        /** ----- Internal Methods ----- */
1122
1123        access(self)
1124        fun _depositStakedToken(change: @FRC20FTShared.Change) {
1125            let tick = change.getOriginalTick()
1126            let semiNFTCol = self.borrowSemiNFTCollection()
1127
1128            let initialYieldRates: {String: UFix64} = {}
1129            // update all reward strategies record
1130            let pool = FRC20Staking.borrowPool(change.from)
1131                ?? panic("Pool must exist")
1132            let fromPool = change.from
1133            let amount = change.getBalance()
1134            let strategies = pool.getRewardNames()
1135            log("Init SemiNFT with balance: ".concat(amount.toString()))
1136            for rewardTick in strategies {
1137                if let reward: &FRC20Staking.RewardStrategy = pool.borrowRewardStrategy(rewardTick) {
1138                    // update the claiming record
1139                    initialYieldRates[rewardTick] = reward.globalYieldRate
1140                }
1141                log("Reward tick: ".concat(rewardTick).concat(" initial yield rate:").concat(initialYieldRates[rewardTick]!.toString()))
1142            }
1143            // wrap the change to semiNFT
1144            let nftId = FRC20SemiNFT.wrap(
1145                recipient: semiNFTCol,
1146                change: <- change,
1147                initialYieldRates: initialYieldRates
1148            )
1149
1150            // emit event
1151            emit DelegatorStakedTokenDeposited(
1152                tick: tick,
1153                pool: fromPool,
1154                receiver: self.owner?.address ?? panic("Delegator owner must exist"),
1155                amount: amount,
1156                semiNftId: nftId
1157            )
1158        }
1159
1160        /// Borrow Staked Change
1161        ///
1162        access(self)
1163        view fun borrowSemiNFTCollection(): auth(NonFungibleToken.Update, NonFungibleToken.Withdraw) &FRC20SemiNFT.Collection {
1164            return self.semiNFTcolCap.borrow() ?? panic("The SemiNFT Collection must exist")
1165        }
1166    }
1167
1168    /** ---- Account access methods ---- */
1169
1170    /// Create the Staking Pool resource
1171    ///
1172    access(account)
1173    fun createPool(_ tick: String): @Pool {
1174        return <- create Pool(tick)
1175    }
1176
1177    /** ---- public methods ---- */
1178
1179    /// Get the lock time of unstaking
1180    ///
1181    access(all)
1182    view fun getUnstakingLockTime(): UInt64 {
1183        // 1 day = 86400 seconds
1184        return 86400
1185    }
1186
1187    /// Borrow Pool by address
1188    ///
1189    access(all)
1190    view fun borrowPool(_ addr: Address): &Pool? {
1191        return self.getPoolCap(addr).borrow()
1192    }
1193
1194    /// Borrow Pool Capability by address
1195    ///
1196    access(all)
1197    view fun getPoolCap(_ addr: Address): Capability<&Pool> {
1198        return getAccount(addr)
1199            .capabilities.get<&Pool>(self.StakingPoolPublicPath)
1200    }
1201
1202    /// Borrow Delegate by address
1203    ///
1204    access(all)
1205    view fun borrowDelegator(_ addr: Address): &Delegator? {
1206        return getAccount(addr)
1207            .capabilities.get<&Delegator>(self.DelegatorPublicPath)
1208            .borrow()
1209    }
1210
1211    /// Create the Delegator resource
1212    ///
1213    access(all)
1214    fun createDelegator(
1215        _ semiNFTCol: Capability<auth(NonFungibleToken.Update, NonFungibleToken.Withdraw) &FRC20SemiNFT.Collection>
1216    ): @Delegator {
1217        pre {
1218            semiNFTCol.check(): "SemiNFT Collection must be valid"
1219        }
1220        return <- create Delegator(semiNFTCol)
1221    }
1222
1223    init() {
1224        let identifier = "FRC20Staking_".concat(self.account.address.toString())
1225        self.StakingPoolStoragePath = StoragePath(identifier: identifier.concat("_pool"))!
1226        self.StakingPoolPublicPath = PublicPath(identifier: identifier.concat("_pool"))!
1227        self.DelegatorStoragePath = StoragePath(identifier: identifier.concat("_delegator"))!
1228        self.DelegatorPublicPath = PublicPath(identifier: identifier.concat("_delegator"))!
1229
1230        emit ContractInitialized()
1231    }
1232}
1233