Smart Contract
FRC20Staking
A.d2abb5dbf5e08666.FRC20Staking
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