Smart Contract

StakingNFT

A.1b77ba4b414de352.StakingNFT

Deployed

1w ago
Feb 20, 2026, 07:32:00 AM UTC

Dependents

20 imports
1/**
2
3# Staking NFTs to farm
4# Multi-farming, staking seed `NFT` to get multiple ft rewards from a pool.
5# Anyone can add reward during farming to extend the farming period; but only
6# admin or poolAdmin can add a new type of reward token to a pool.
7# Author: Increment Labs
8
9**/
10import FungibleToken from 0xf233dcee88fe0abe
11import NonFungibleToken from 0x1d7e57aa55817448
12import StakingError from 0x1b77ba4b414de352
13import SwapConfig from 0xb78ef7afa52ff906
14
15access(all) contract StakingNFT {
16  access(all) let address: Address
17  // Paths
18  access(all) let CollectionStoragePath: StoragePath
19  access(all) let CollectionPublicPath: PublicPath
20
21  // Staking admin resource path
22  access(all) let StakingAdminStoragePath: StoragePath
23  // path for pool admin resource
24  access(all) let PoolAdminStoragePath: StoragePath
25
26  // Resource path for user stake pass resource
27  access(all) let UserCertificateStoragePath: StoragePath
28
29  // pool status for Pool life cycle
30  access(all) enum PoolStatus: UInt8 {
31    access(all) case CREATED
32    access(all) case RUNNING
33    access(all) case ENDED
34    access(all) case CLEARED
35  }
36
37  // if true only Admin can create staking pool; otherwise everyone can create
38  access(all) var isPermissionless: Bool
39
40  // global pause: true will stop pool creation
41  access(all) var pause: Bool
42
43  access(all) var poolCount: UInt64
44
45  // User participated pool: { userAddress => { pid => true } }
46  access(self) let userStakingIds: {Address: {UInt64: Bool}}
47
48  /// Reserved parameter fields: {ParamName: Value}
49  access(self) let _reservedFields: {String: AnyStruct}
50
51  init() {
52    self.address = self.account.address
53    self.isPermissionless = false
54    self.pause = false
55    self.userStakingIds = {}
56    self._reservedFields = {}
57
58    self.CollectionStoragePath = /storage/increment_nft_stakingCollectionStorage
59    self.CollectionPublicPath = /public/increment_nft_stakingCollectionPublic
60
61    self.StakingAdminStoragePath = /storage/increment_nft_stakingAdmin
62
63    self.PoolAdminStoragePath = /storage/increment_nft_stakingPoolAdmin
64    self.UserCertificateStoragePath = /storage/increment_nft_stakingUserCertificate
65
66    self.poolCount = 0
67
68    self.account.storage.save(<- create Admin(), to: self.StakingAdminStoragePath)
69    self.account.storage.save(<- create StakingPoolCollection(), to: self.CollectionStoragePath)
70    self.account.capabilities.publish(
71      self.account.capabilities.storage.issue<&{PoolCollectionPublic}>(self.CollectionStoragePath),
72      at: self.CollectionPublicPath
73    )
74
75    self.account.storage.save(<- create PoolAdmin(), to: self.PoolAdminStoragePath)
76  }
77
78  // Per-pool RewardInfo struct
79  // One pool can have multiple reward tokens with {String: RewardInfo}
80  access(all) struct RewardInfo {
81    access(all) var startTimestamp: UFix64
82    // start timestamp with Block.timestamp
83    access(all) var endTimestamp: UFix64
84    // token reward amount per session
85    access(all) var rewardPerSession: UFix64
86    // interval of session
87    access(all) var sessionInterval: UFix64
88    // token type of reward token
89    access(all) let rewardTokenKey: String
90    // total reward amount
91    access(all) var totalReward: UFix64
92    // last update reward round 
93    access(all) var lastRound: UInt64
94    // total round
95    access(all) var totalRound: UInt64
96    // token reward per staking token 
97    access(all) var rewardPerSeed: UFix64
98
99    view init(rewardPerSession: UFix64, sessionInterval: UFix64, rewardTokenKey: String, startTimestamp: UFix64) {
100      pre {
101        sessionInterval % 1.0 == 0.0 : StakingError.errorEncode(msg: "sessionInterval must be integer", err: StakingError.ErrorCode.INVALID_PARAMETERS)
102        rewardPerSession > 0.0: StakingError.errorEncode(msg: "rewardPerSession must be non-zero", err: StakingError.ErrorCode.INVALID_PARAMETERS)
103      }
104      self.startTimestamp = startTimestamp
105      self.endTimestamp = 0.0
106      self.rewardPerSession = rewardPerSession
107      self.sessionInterval = sessionInterval
108      self.rewardTokenKey = rewardTokenKey
109      self.totalReward = 0.0
110      self.lastRound = 0
111      self.totalRound = 0
112      self.rewardPerSeed = 0.0
113    }
114
115    // update pool reward info with staking seed balance and timestamp
116    access(contract) fun updateRewardInfo(currentTimestamp: UFix64, stakingBalance: UFix64) {
117      let sessionInterval = self.sessionInterval
118      let startTimestamp = self.startTimestamp
119      let lastRound = self.lastRound
120      let totalRound = self.totalRound
121
122      // not start yet
123      if currentTimestamp < self.startTimestamp {
124        return
125      }
126
127      // get current round
128      let timeCliff = currentTimestamp - startTimestamp
129      let remainder = timeCliff % sessionInterval
130      var currentRound = UInt64((timeCliff - remainder) / sessionInterval)
131      if currentRound > totalRound {
132        currentRound = totalRound
133      }
134
135      if currentRound <= lastRound {
136        return
137      }
138      if stakingBalance == 0.0 {
139        // just update last round
140        self.lastRound = currentRound
141        return
142      }
143
144      let toBeDistributeReward = self.rewardPerSession * UFix64(currentRound - lastRound)
145      let toBeDistributeRewardScaled = SwapConfig.UFix64ToScaledUInt256(toBeDistributeReward)
146      let stakingBalanceScaled = SwapConfig.UFix64ToScaledUInt256(stakingBalance)
147      // update pool's reward per seed index
148      self.rewardPerSeed = self.rewardPerSeed + SwapConfig.ScaledUInt256ToUFix64(toBeDistributeRewardScaled * SwapConfig.scaleFactor / stakingBalanceScaled)
149      emit RPSUpdated(timestamp: currentTimestamp, toBeDistributeReward: toBeDistributeReward, stakingBalance: stakingBalance, rewardPerSeed: self.rewardPerSeed)
150      // update last round
151      self.lastRound = currentRound
152    }
153
154    // update reward info after pool add reward token
155    // Note: caller ensures addRewardAmount to be multiples of rewardPerSession
156    access(contract) fun appendReward(addRewardAmount: UFix64) {
157      self.totalReward = self.totalReward + addRewardAmount
158      let appendRound = addRewardAmount / self.rewardPerSession
159      self.totalRound = self.totalRound + UInt64(appendRound)
160      let appendDuration = self.sessionInterval * appendRound
161      if self.startTimestamp == 0.0 {
162        self.startTimestamp = getCurrentBlock().timestamp
163        self.endTimestamp = self.startTimestamp + appendDuration
164      } else {
165        if self.endTimestamp == 0.0 {
166          self.endTimestamp = self.startTimestamp + appendDuration
167        } else {
168          self.endTimestamp = self.endTimestamp + appendDuration
169        }
170      }
171    }
172
173    // increase reward per session without delaying the end timestamp
174    // Note: caller ensures addRewardAmount to be multiples of rounds left
175    access(contract) fun appendRewardPerSession(addRewardAmount: UFix64) {
176      self.totalReward = self.totalReward + addRewardAmount
177      let leftRound = self.totalRound - self.lastRound
178      self.rewardPerSession = self.rewardPerSession + addRewardAmount / UFix64(leftRound)
179    }
180  }
181
182  // Pool info for script query
183  access(all) struct PoolInfo {
184    access(all) let pid: UInt64
185    access(all) let status: String
186    access(all) let rewardsInfo: {String: RewardInfo}
187    access(all) let limitAmount: UInt64
188    access(all) let totalStaking: UInt64
189    access(all) let acceptedNFTKey: String
190    access(all) let creator: Address
191    access(all) let stringTypedVerifiers: [String]
192
193    view init(pid: UInt64, status: String, rewardsInfo: {String: RewardInfo}, limitAmount: UInt64, totalStaking: UInt64, acceptedNFTKey: String, creator: Address, verifiers: [{INFTVerifier}]) {
194      self.pid = pid
195      self.status = status
196      self.rewardsInfo = rewardsInfo
197      self.limitAmount = limitAmount
198      self.totalStaking = totalStaking
199      self.acceptedNFTKey = acceptedNFTKey
200      self.creator = creator
201      var _v: [String] = []
202      for verifier in verifiers {
203        _v = _v.concat([
204          verifier.getType().identifier
205        ])
206      }
207      self.stringTypedVerifiers = _v
208    }
209  }
210
211  // user info for each pool record user's reward and staking stats
212  access(all) struct UserInfo {
213    access(all) let pid: UInt64
214    access(all) let addr: Address
215    // Mapping of nft.tokenId staked into a specific pool. All nfts should belong to the same pool.acceptedNFTKey collection.
216    access(all) let stakedNftIds: {UInt64: Bool}
217    // is blocked by staking and claim reward
218    access(all) var isBlocked: Bool
219    // user claimed rewards per seed token, update after claim 
220    access(all) let rewardPerSeed: {String: UFix64}
221    // user claimed token amount
222    access(all) let claimedRewards: {String: UFix64}
223    access(all) let unclaimedRewards: {String: UFix64}
224
225    view init(pid: UInt64, addr: Address, isBlocked: Bool, rewardPerSeed: {String : UFix64}, claimedRewards: {String : UFix64}, unclaimedRewards: {String: UFix64}) {
226      self.pid = pid
227      self.addr = addr
228      self.stakedNftIds = {}
229      self.isBlocked = isBlocked
230      self.rewardPerSeed = rewardPerSeed
231      self.claimedRewards = claimedRewards
232      self.unclaimedRewards = unclaimedRewards
233    }
234
235    access(contract) fun updateRewardPerSeed(tokenKey: String, rps: UFix64) {
236      if self.rewardPerSeed.containsKey(tokenKey) {
237        self.rewardPerSeed[tokenKey] = rps
238      } else {
239        self.rewardPerSeed.insert(key: tokenKey, rps)
240      }
241    }
242
243    access(contract) fun addClaimedReward(tokenKey: String, amount: UFix64) {
244      if self.claimedRewards.containsKey(tokenKey) {
245        self.claimedRewards[tokenKey] = self.claimedRewards[tokenKey]! + amount
246      } else {
247        self.claimedRewards.insert(key: tokenKey, amount)
248      }
249    }
250
251    access(contract) fun updateUnclaimedReward(tokenKey: String, newValue: UFix64) {
252      if self.unclaimedRewards.containsKey(tokenKey) {
253        self.unclaimedRewards[tokenKey] = newValue
254      } else {
255        self.unclaimedRewards.insert(key: tokenKey, newValue)
256      }
257    }
258
259    // Return true if insert happenes, otherwise return false
260    access(contract) fun addTokenId(tokenId: UInt64): Bool {
261      if self.stakedNftIds.containsKey(tokenId) {
262        return false
263      } else {
264        self.stakedNftIds.insert(key: tokenId, true)
265        return true
266      }
267    }
268
269    // Return true if remove happenes, otherwise return false
270    access(contract) fun removeTokenId(tokenId: UInt64): Bool {
271      if self.stakedNftIds.containsKey(tokenId) {
272        self.stakedNftIds.remove(key: tokenId)
273        return true
274      } else {
275        return false
276      }
277    }
278
279    access(contract) fun setBlockStatus(_ flag: Bool) {
280      pre {
281         flag != self.isBlocked : StakingError.errorEncode(msg: "UserInfo: status is same", err: StakingError.ErrorCode.SAME_BOOL_STATE)
282      }
283      self.isBlocked = flag
284    }
285  }
286
287  // interfaces
288
289  // Verifies eligible NFT from the collection a staking pool supports
290  access(all) struct interface INFTVerifier {
291    // Returns true if valid, otherwise false
292    access(all) view fun verify(nftRef: &{NonFungibleToken.NFT}, extraParams: {String: AnyStruct}): Bool
293  }
294
295  // store pools in collection 
296  access(all) resource interface PoolCollectionPublic {
297    access(all) fun createStakingPool(adminRef: &Admin?, poolAdminAddr: Address, limitAmount: UInt64, collection: @{NonFungibleToken.Collection}, rewards:[RewardInfo], verifiers: [{INFTVerifier}], extraParams: {String: AnyStruct})
298    access(all) view fun getCollectionLength(): Int
299    access(all) view fun getPool(pid: UInt64): &{PoolPublic}
300    access(all) view fun getSlicedPoolInfo(from: UInt64, to: UInt64): [PoolInfo]
301  }
302
303  // Pool interfaces verify PoolAdmin resource's pid as auth
304  // use userCertificate to verify user and record user's address
305  access(all) resource interface PoolPublic {
306    access(all) fun addNewReward(adminRef: &Admin?, poolAdminRef: &PoolAdmin, newRewardToken: @{FungibleToken.Vault}, rewardPerSession: UFix64, sessionInterval: UFix64, startTimestamp: UFix64?)
307    access(all) fun extendReward(rewardTokenVault: @{FungibleToken.Vault})
308    access(all) fun boostReward(rewardPerSessionToAdd: UFix64, rewardToken: @{FungibleToken.Vault}): @{FungibleToken.Vault}
309    access(all) fun stake(staker: Address, nft: @{NonFungibleToken.NFT})
310    access(all) fun unstake(userCertificate: &UserCertificate, tokenId: UInt64): @{NonFungibleToken.NFT}
311    access(all) fun claimRewards(userCertificate: &UserCertificate): @{String: {FungibleToken.Vault}}
312    access(all) fun setClear(adminRef: &Admin?, poolAdminRef: &PoolAdmin): @{String: {FungibleToken.Vault}}
313    access(all) fun setUserBlockedStatus(adminRef: &Admin?, poolAdminRef: &PoolAdmin, address: Address, flag: Bool)
314    access(all) fun updatePool()
315    access(all) view fun getPoolInfo(): PoolInfo
316    access(all) view fun getRewardInfo(): {String: RewardInfo}
317    access(all) view fun getUserInfo(address: Address): UserInfo?
318    access(all) view fun getSlicedUserInfo(from: UInt64, to: UInt64): [UserInfo]
319    access(all) view fun getVerifiers(): [{INFTVerifier}]
320    access(all) view fun getExtraParams(): {String: AnyStruct}
321  }
322
323  // events
324  access(all) event PoolRewardAdded(pid: UInt64, tokenKey: String, amount: UFix64)
325  access(all) event PoolRewardBoosted(pid: UInt64, tokenKey: String, amount: UFix64, newRewardPerSession: UFix64)
326  access(all) event PoolOpened(pid: UInt64, timestamp: UFix64)
327  access(all) event TokenStaked(pid: UInt64, tokenKey: String, tokenId: UInt64, operator: Address)
328  access(all) event TokenUnstaked(pid: UInt64, tokenKey: String, tokenId: UInt64, operator: Address)
329  access(all) event RewardClaimed(pid: UInt64, tokenKey: String, amount: UFix64, userAddr: Address, userRPSAfter: UFix64)
330  access(all) event PoolCreated(pid: UInt64, acceptedNFTKey: String, rewardsInfo: {String: RewardInfo}, operator: Address)
331  access(all) event PoolStatusChanged(pid: UInt64, status: String)
332  access(all) event PoolUpdated(pid: UInt64, timestamp: UFix64, poolInfo: PoolInfo)
333  access(all) event RPSUpdated(timestamp: UFix64, toBeDistributeReward:UFix64, stakingBalance: UFix64, rewardPerSeed: UFix64)
334
335  // Staking admin events
336  access(all) event PauseStateChanged(pauseFlag: Bool, operator: Address)
337  access(all) event PermissionlessStateChanged(permissionless: Bool, operator: Address)
338
339  // Pool admin events
340  access(all) event UserBlockedStateChanged(pid: UInt64, address: Address, blockedFlag: Bool, operator: Address)
341
342  // resources
343  // staking admin resource for manage staking contract
344  access(all) resource Admin {
345    access(all) fun setPause(_ flag: Bool) {
346      pre {
347        StakingNFT.pause != flag : StakingError.errorEncode(msg: "Set pause state faild, the state is same", err: StakingError.ErrorCode.SAME_BOOL_STATE)
348      }
349      StakingNFT.pause = flag
350      emit PauseStateChanged(pauseFlag: flag, operator: self.owner!.address)
351    }
352
353    access(all) fun setIsPermissionless(_ flag: Bool) {
354      pre {
355        StakingNFT.isPermissionless != flag : StakingError.errorEncode(msg: "Set permissionless state faild, the state is same", err: StakingError.ErrorCode.SAME_BOOL_STATE)
356      }
357      StakingNFT.isPermissionless = flag
358      emit PermissionlessStateChanged(permissionless: flag, operator: self.owner!.address)
359    }
360  }
361
362  // Pool creator / mananger should mint one and stores under PoolAdminStoragePath
363  access(all) resource PoolAdmin {}
364
365  // UserCertificate store in user's storage path for Pool function to verify user's address
366  access(all) resource UserCertificate {}
367
368  access(all) resource Pool: PoolPublic {
369    // pid
370    access(all) let pid: UInt64
371    // Uplimit a user is allowed to stake up to
372    access(all) let limitAmount: UInt64
373    // Staking pool rewards
374    access(all) let rewardsInfo: {String: RewardInfo}
375
376    access(all) var status: PoolStatus
377
378    access(all) let creator: Address
379
380    // supported NFT type: e.g. "A.2d4c3caffbeab845.FLOAT"
381    access(all) let acceptedNFTKey: String
382
383    // Extra verifiers to check if a given nft is eligible to stake into this pool
384    access(self) let verifiers: [{INFTVerifier}]
385
386    // Collection for NFT staking
387    access(self) let stakingNFTCollection: @{NonFungibleToken.Collection}
388  
389    // Vaults for reward tokens
390    access(self) let rewardVaults: @{String: {FungibleToken.Vault}}
391
392    // maps for userInfo
393    access(self) let usersInfo: {Address: UserInfo}
394
395    // Any nft-relaated extra parameters the admin would provide in pool creation
396    access(self) let extraParams: {String: AnyStruct}
397
398    init(limitAmount: UInt64, collection: @{NonFungibleToken.Collection}, rewardsInfo: {String: RewardInfo}, creator: Address, verifiers: [{INFTVerifier}], extraParams: {String: AnyStruct}) {
399      pre {
400        collection.getIDs().length == 0: StakingError.errorEncode(msg: "nonempty seed collection", err: StakingError.ErrorCode.INVALID_PARAMETERS)
401      }
402      let newPid = StakingNFT.poolCount
403      StakingNFT.poolCount = StakingNFT.poolCount + 1
404      let acceptedNFTKey = StakingNFT.getNFTType(collectionIdentifier: collection.getType().identifier)
405      self.pid = newPid
406      self.limitAmount = limitAmount
407      self.acceptedNFTKey = acceptedNFTKey
408      self.rewardsInfo = rewardsInfo
409      self.status = PoolStatus.CREATED
410      self.stakingNFTCollection <- collection
411      self.rewardVaults <- {}
412      self.usersInfo = {}
413      self.verifiers = verifiers
414      self.extraParams = extraParams
415      self.creator = creator
416
417      emit PoolCreated(pid: newPid, acceptedNFTKey: acceptedNFTKey, rewardsInfo: rewardsInfo, operator: creator)
418    }
419
420    // update pool rewards info before any user action
421    access(all) fun updatePool() {
422      if self.rewardsInfo.length == 0 {
423        return
424      }
425
426      let stakingBalance = self.stakingNFTCollection.getIDs().length
427      let currentTimestamp = getCurrentBlock().timestamp
428      var numClosed = 0
429      // update multiple reward info
430      for key in self.rewardsInfo.keys {
431        let rewardInfoRef = (&self.rewardsInfo[key] as &RewardInfo?)!
432        if rewardInfoRef.endTimestamp > 0.0 && currentTimestamp >= rewardInfoRef.endTimestamp {
433          numClosed = numClosed + 1
434        }
435        // update pool reward info
436        rewardInfoRef.updateRewardInfo(currentTimestamp: currentTimestamp, stakingBalance: UFix64(stakingBalance))
437      }   
438
439      // when all rewards ended change the pool status
440      if numClosed == self.rewardsInfo.length && self.status.rawValue < PoolStatus.ENDED.rawValue {
441        self.status = PoolStatus.ENDED
442        emit PoolStatusChanged(pid: self.pid, status: self.status.rawValue.toString())
443      }
444
445      emit PoolUpdated(pid: self.pid, timestamp: currentTimestamp, poolInfo: self.getPoolInfo())
446    }
447
448    // claim and return pending rewards, if any
449    // @Param harvestMode - if true, claim and return; otherwise, just compute and update userInfo.unclaimedRewards
450    access(self) fun harvest(harvester: Address, harvestMode: Bool): @{String: {FungibleToken.Vault}} {
451      pre{
452        self.status != PoolStatus.CLEARED : StakingError.errorEncode(msg: "Pool: pool already cleaned", err: StakingError.ErrorCode.POOL_LIFECYCLE_ERROR)
453        self.usersInfo.containsKey(harvester): StakingError.errorEncode(msg: "Pool: no UserInfo", err: StakingError.ErrorCode.INVALID_PARAMETERS)
454        !harvestMode || self.usersInfo[harvester]!.isBlocked == false: StakingError.errorEncode(msg: "Pool: user is blocked", err: StakingError.ErrorCode.ACCESS_DENY)
455      }
456
457      let vaults: @{String: {FungibleToken.Vault}} <- {}
458      let userInfoRef = (&self.usersInfo[harvester] as &UserInfo?)!
459
460      for key in self.rewardsInfo.keys {
461        let rewardTokenKey = self.rewardsInfo[key]!.rewardTokenKey
462        let poolRPS = self.rewardsInfo[key]!.rewardPerSeed
463        // new reward added after user last stake
464        if (!userInfoRef.rewardPerSeed.containsKey(key)) {
465          userInfoRef.updateRewardPerSeed(tokenKey: key, rps: 0.0)
466        }
467        let userRPS = userInfoRef.rewardPerSeed[key]!
468        let stakingAmount = UFix64(userInfoRef.stakedNftIds.length)
469        let stakingAmountScaled = SwapConfig.UFix64ToScaledUInt256(stakingAmount)
470        let poolRPSScaled = SwapConfig.UFix64ToScaledUInt256(poolRPS)
471        let userRPSScaled = SwapConfig.UFix64ToScaledUInt256(userRPS)
472
473        // Update UserInfo with pool RewardInfo RPS index
474        userInfoRef.updateRewardPerSeed(tokenKey: rewardTokenKey, rps: poolRPS)
475
476        // newly generated pending reward to be claimed
477        let newPendingClaim = SwapConfig.ScaledUInt256ToUFix64((poolRPSScaled - userRPSScaled) * stakingAmountScaled / SwapConfig.scaleFactor)
478        let pendingClaimAll = newPendingClaim + (userInfoRef.unclaimedRewards[rewardTokenKey] ?? 0.0)
479        if pendingClaimAll > 0.0 {
480          if !harvestMode {
481            // No real harvest, just compute and update userInfo.unclaimedRewards
482            userInfoRef.updateUnclaimedReward(tokenKey: rewardTokenKey, newValue: pendingClaimAll)
483          } else {
484            userInfoRef.updateUnclaimedReward(tokenKey: rewardTokenKey, newValue: 0.0)
485            userInfoRef.addClaimedReward(tokenKey: rewardTokenKey, amount: pendingClaimAll)
486            emit RewardClaimed(pid: self.pid, tokenKey: rewardTokenKey, amount: pendingClaimAll, userAddr: harvester, userRPSAfter: poolRPS)
487            let rewardVault = (&self.rewardVaults[rewardTokenKey] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)!
488            let claimVault <- rewardVault.withdraw(amount: pendingClaimAll)
489            vaults[rewardTokenKey] <-! claimVault
490          }
491        }
492      }
493      return <- vaults
494    }
495
496    access(all) fun claimRewards(userCertificate: &UserCertificate): @{String: {FungibleToken.Vault}} {
497      pre{
498        userCertificate.owner != nil: StakingError.errorEncode(msg: "Cannot borrow reference to UserCertificate", err: StakingError.ErrorCode.INVALID_USER_CERTIFICATE)
499      }
500      self.updatePool()
501
502      let userAddress = userCertificate.owner!.address
503      return <- self.harvest(harvester: userAddress, harvestMode: true)
504    }
505
506    // Add a new type of reward to the pool.
507    // Reward starts immediately if no starttime is given.
508    access(all) fun addNewReward(adminRef: &Admin?, poolAdminRef: &PoolAdmin, newRewardToken: @{FungibleToken.Vault}, rewardPerSession: UFix64, sessionInterval: UFix64, startTimestamp: UFix64?) {
509      pre {
510        adminRef != nil || poolAdminRef.owner!.address == self.creator: StakingError.errorEncode(msg: "Pool: no access to add pool rewards", err: StakingError.ErrorCode.ACCESS_DENY)
511        newRewardToken.balance > 0.0 : StakingError.errorEncode(msg: "Pool: not allowed to add zero reward", err: StakingError.ErrorCode.INVALID_PARAMETERS)
512        self.status == PoolStatus.CREATED || self.status == PoolStatus.RUNNING: StakingError.errorEncode(msg: "Pool: not allowed to add reward after end", err: StakingError.ErrorCode.POOL_LIFECYCLE_ERROR)
513      }
514      self.updatePool()
515
516      let newRewardTokenKey = SwapConfig.SliceTokenTypeIdentifierFromVaultType(vaultTypeIdentifier: newRewardToken.getType().identifier)
517      if !self.rewardsInfo.containsKey(newRewardTokenKey) {
518        self.rewardsInfo.insert(
519          key: newRewardTokenKey,
520          RewardInfo(rewardPerSession: rewardPerSession, sessionInterval: sessionInterval, rewardTokenKey: newRewardTokenKey, startTimestamp: startTimestamp ?? 0.0)
521        )
522      }
523      return self.extendReward(rewardTokenVault: <-newRewardToken)
524    }
525
526    // Extend the end time of an existing type of reward.
527    // Note: Caller ensures rewardInfo of the added token has been setup already
528    access(all) fun extendReward(rewardTokenVault: @{FungibleToken.Vault}) {
529      pre {
530        rewardTokenVault.balance > 0.0 : StakingError.errorEncode(msg: "Pool: not allowed to add zero reward", err: StakingError.ErrorCode.INVALID_PARAMETERS)
531        self.status == PoolStatus.CREATED || self.status == PoolStatus.RUNNING: StakingError.errorEncode(msg: "Pool: not allowed to add reward after end", err: StakingError.ErrorCode.POOL_LIFECYCLE_ERROR)
532      }
533      self.updatePool()
534
535      let rewardTokenKey = SwapConfig.SliceTokenTypeIdentifierFromVaultType(vaultTypeIdentifier: rewardTokenVault.getType().identifier)
536      assert(
537        self.rewardsInfo.containsKey(rewardTokenKey), message: StakingError.errorEncode(msg: "Pool: rewards type not support", err: StakingError.ErrorCode.MISMATCH_VAULT_TYPE)
538      )
539      let rewardInfoRef = (&self.rewardsInfo[rewardTokenKey] as &RewardInfo?)!
540      assert(
541        rewardInfoRef.rewardTokenKey == rewardTokenKey, message: StakingError.errorEncode(msg: "Pool: reward type not match", err: StakingError.ErrorCode.MISMATCH_VAULT_TYPE)
542      )
543      let rewardBalance = rewardTokenVault.balance
544      assert(
545        rewardBalance >= rewardInfoRef.rewardPerSession, message: StakingError.errorEncode(msg: "Pool: reward balance not enough", err: StakingError.ErrorCode.INSUFFICIENT_REWARD_BALANCE)
546      )
547      assert(
548        rewardBalance % rewardInfoRef.rewardPerSession == 0.0, message: StakingError.errorEncode(msg: "Pool: reward balance not valid ".concat(rewardTokenKey), err: StakingError.ErrorCode.INVALID_BALANCE_AMOUNT)
549      )
550      // update reward info 
551      rewardInfoRef.appendReward(addRewardAmount: rewardBalance)
552
553      // add reward vault to pool resource
554      if self.rewardVaults.containsKey(rewardTokenKey) {
555        let vault = (&self.rewardVaults[rewardTokenKey] as &{FungibleToken.Vault}?)!
556        vault.deposit(from: <- rewardTokenVault)
557      } else {
558        self.rewardVaults[rewardTokenKey] <-! rewardTokenVault
559      }
560
561      emit PoolRewardAdded(pid: self.pid, tokenKey: rewardTokenKey, amount: rewardBalance)
562
563      if self.status == PoolStatus.CREATED {
564        self.status = PoolStatus.RUNNING
565        emit PoolOpened(pid: self.pid, timestamp: getCurrentBlock().timestamp)
566        emit PoolStatusChanged(pid: self.pid, status: self.status.rawValue.toString())
567      }
568    }
569
570    // Boost the apr of an existing type of reward token by increasing rewardPerSession. This doesn't extend the reward window.
571    // Return: any remaining reward token not added in.
572    // Note: Caller ensures rewardInfo of the added token has been setup already.
573    access(all) fun boostReward(rewardPerSessionToAdd: UFix64, rewardToken: @{FungibleToken.Vault}): @{FungibleToken.Vault} {
574      pre {
575        rewardToken.balance > 0.0 : StakingError.errorEncode(msg: "Pool: not allowed to add zero reward", err: StakingError.ErrorCode.INVALID_PARAMETERS)
576        self.status == PoolStatus.CREATED || self.status == PoolStatus.RUNNING: StakingError.errorEncode(msg: "Pool: not allowed to add reward after end", err: StakingError.ErrorCode.POOL_LIFECYCLE_ERROR)
577      }
578      self.updatePool()
579
580      let rewardTokenKey = SwapConfig.SliceTokenTypeIdentifierFromVaultType(vaultTypeIdentifier: rewardToken.getType().identifier)
581      assert(
582        self.rewardsInfo.containsKey(rewardTokenKey), message: StakingError.errorEncode(msg: "Pool: rewards type not support", err: StakingError.ErrorCode.MISMATCH_VAULT_TYPE)
583      )
584      let rewardInfoRef = (&self.rewardsInfo[rewardTokenKey] as &RewardInfo?)!
585      assert(
586        rewardInfoRef.rewardTokenKey == rewardTokenKey, message: StakingError.errorEncode(msg: "Pool: reward type not match", err: StakingError.ErrorCode.MISMATCH_VAULT_TYPE)
587      )
588      let leftRound = rewardInfoRef.totalRound - rewardInfoRef.lastRound
589      assert(leftRound >= 1, message: StakingError.errorEncode(msg: "Pool: either no reward added or no time left to boost reward", err: StakingError.ErrorCode.INVALID_PARAMETERS))
590
591      let boostedRewardAmount = rewardPerSessionToAdd * UFix64(leftRound)
592      // update reward info 
593      rewardInfoRef.appendRewardPerSession(addRewardAmount: boostedRewardAmount)
594      // add reward vault to pool resource
595      let vault = (&self.rewardVaults[rewardTokenKey] as &{FungibleToken.Vault}?)!
596      vault.deposit(from: <- rewardToken.withdraw(amount: boostedRewardAmount))
597
598      emit PoolRewardBoosted(pid: self.pid, tokenKey: rewardTokenKey, amount: boostedRewardAmount, newRewardPerSession: rewardInfoRef.rewardPerSession)
599
600      return <- rewardToken
601    }
602
603    access(all) view fun eligibilityCheck(nftRef: &{NonFungibleToken.NFT}, extraParams: {String: AnyStruct}): Bool {
604      for verifier in self.verifiers {
605        if verifier.verify(nftRef: nftRef, extraParams: extraParams) == false {
606          return false
607        }
608      }
609      return true
610    }
611
612    // Deposit staking token on behalf of staker
613    access(all) fun stake(staker: Address, nft: @{NonFungibleToken.NFT}) {
614      pre {
615        self.status == PoolStatus.RUNNING || self.status == PoolStatus.CREATED : StakingError.errorEncode(msg: "Pool: not open staking yet", err: StakingError.ErrorCode.POOL_LIFECYCLE_ERROR)
616      }
617      self.updatePool()
618
619      // Here has to use auth reference, as verifiers may need to downcast to get extra info 
620      let nftRef = &nft as &{NonFungibleToken.NFT}
621      // This nft must pass eligibility check before staking, with extraParams setup by the pool creator / admin.
622      assert(
623        self.eligibilityCheck(nftRef: nftRef, extraParams: self.extraParams), message: StakingError.errorEncode(msg: "Pool: nft ineligible to stake", err: StakingError.ErrorCode.NOT_ELIGIBLE)
624      )
625
626      let userAddress = staker
627      if !self.usersInfo.containsKey(userAddress) {
628        // create user info
629        let userRPS: {String: UFix64} = {}
630        for key in self.rewardsInfo.keys {
631          let poolRewardInfo = self.rewardsInfo[key]!
632          userRPS[key] = poolRewardInfo.rewardPerSeed
633        }
634
635        if StakingNFT.userStakingIds.containsKey(userAddress) == false {
636          StakingNFT.userStakingIds.insert(key: userAddress, {self.pid: true})
637        } else if StakingNFT.userStakingIds[userAddress]!.containsKey(self.pid) == false {
638          StakingNFT.userStakingIds[userAddress]!.insert(key: self.pid, true)
639        }
640        self.usersInfo[userAddress] = UserInfo(pid: self.pid, addr: userAddress, isBlocked: false, rewardPerSeed: userRPS, claimedRewards: {}, unclaimedRewards: {})
641        self.usersInfo[userAddress]!.addTokenId(tokenId: nft.id)
642      } else {
643        let userInfoRef = (&self.usersInfo[userAddress] as &UserInfo?)!
644        assert(userInfoRef.isBlocked == false, message: StakingError.errorEncode(msg: "Pool: user is blocked", err: StakingError.ErrorCode.ACCESS_DENY))
645        assert(UInt64(userInfoRef.stakedNftIds.length) + 1 <= self.limitAmount, message: StakingError.errorEncode(msg: "Staking: staking amount exceeds limit: ".concat(self.limitAmount.toString()), err: StakingError.ErrorCode.EXCEEDED_AMOUNT_LIMIT))
646        // 1. Update userInfo rewards index and unclaimedRewards but don't do real claim
647        let anyClaimedRewards <- self.harvest(harvester: userAddress, harvestMode: false)
648        assert(anyClaimedRewards.length == 0, message: "panic: something wrong, shouldn't be here")
649        destroy anyClaimedRewards
650        // 2. Insert nft tokenId
651        userInfoRef.addTokenId(tokenId: nft.id)
652      }
653
654      emit TokenStaked(pid: self.pid, tokenKey: self.acceptedNFTKey, tokenId: nft.id, operator: userAddress)
655      self.stakingNFTCollection.deposit(token: <- nft)
656    }
657
658    // Withdraw and return seed staking token
659    access(all) fun unstake(userCertificate: &UserCertificate, tokenId: UInt64): @{NonFungibleToken.NFT} {
660      pre {
661        self.stakingNFTCollection.getIDs().contains(tokenId): StakingError.errorEncode(msg: "Unstake: nonexistent tokenId in staked Collection", err: StakingError.ErrorCode.NOT_FOUND)
662        self.status != PoolStatus.CLEARED : StakingError.errorEncode(msg: "Unstake: Pool already cleared", err: StakingError.ErrorCode.POOL_LIFECYCLE_ERROR)
663        userCertificate.owner != nil: StakingError.errorEncode(msg: "Cannot borrow reference to UserCertificate", err: StakingError.ErrorCode.INVALID_USER_CERTIFICATE)
664      }
665      self.updatePool()
666
667      let userAddress = userCertificate.owner!.address
668      let userInfoRef = (&self.usersInfo[userAddress] as &UserInfo?)!
669      assert(
670        userInfoRef.stakedNftIds.containsKey(tokenId), message: StakingError.errorEncode(msg: "Unstake: cannot unstake nft doesn't belong to the user", err: StakingError.ErrorCode.NOT_FOUND)
671      )
672      // 1. Update userInfo rewards index and unclaimedRewards but don't do real claim
673      let anyClaimedRewards <- self.harvest(harvester: userAddress, harvestMode: false)
674      assert(anyClaimedRewards.length == 0, message: "panic: something wrong, shouldn't be here")
675      destroy anyClaimedRewards
676      // 2. Remove unstaked nft tokenId
677      userInfoRef.removeTokenId(tokenId: tokenId)
678
679      emit TokenUnstaked(pid: self.pid, tokenKey: self.acceptedNFTKey, tokenId: tokenId, operator: userAddress)
680      return <- self.stakingNFTCollection.withdraw(withdrawID: tokenId)
681    }
682
683    access(all) view fun getPoolInfo(): PoolInfo {
684      let poolInfo = PoolInfo(pid: self.pid, status: self.status.rawValue.toString(), rewardsInfo: self.rewardsInfo, limitAmount: self.limitAmount, totalStaking: UInt64(self.stakingNFTCollection.getIDs().length), acceptedNFTKey: self.acceptedNFTKey, creator: self.creator, verifiers: self.verifiers)
685      return poolInfo
686    }
687
688    access(all) view fun getRewardInfo(): {String: RewardInfo} {
689      return self.rewardsInfo
690    }
691
692    access(all) view fun getUserInfo(address: Address): UserInfo? {
693      return self.usersInfo[address]
694    }
695
696    access(all) view fun getSlicedUserInfo(from: UInt64, to: UInt64): [UserInfo] {
697      pre {
698        from <= to && from < UInt64(self.usersInfo.length): StakingError.errorEncode(msg: "from index out of range", err: StakingError.ErrorCode.INVALID_PARAMETERS)
699      }
700      let userLen = UInt64(self.usersInfo.length)
701      let endIndex = to >= userLen ? userLen - 1 : to
702      var curIndex = from
703      var list: [UserInfo] = []
704      while curIndex <= endIndex {
705        let address = self.usersInfo.keys[curIndex]
706        list = list.concat([
707          self.usersInfo[address]!
708        ])
709        curIndex = curIndex + 1
710      }
711      return list
712    }
713
714    access(all) view fun getVerifiers(): [{INFTVerifier}] {
715      return self.verifiers
716    }
717
718    access(all) view fun getExtraParams(): {String: AnyStruct} {
719      return self.extraParams
720    }
721
722    // Mark ENDED pool as CLEARED after all staking tokens are withdrawn, and reclaim remaining rewards if any.
723    access(all) fun setClear(adminRef: &Admin?, poolAdminRef: &PoolAdmin): @{String: {FungibleToken.Vault}} {
724      pre {
725        adminRef != nil || poolAdminRef.owner!.address == self.creator: StakingError.errorEncode(msg: "Pool: no access to clear pool status", err: StakingError.ErrorCode.ACCESS_DENY)
726        self.status == PoolStatus.ENDED: StakingError.errorEncode(msg: "Pool not end yet", err: StakingError.ErrorCode.POOL_LIFECYCLE_ERROR)
727        self.stakingNFTCollection.getIDs().length == 0: StakingError.errorEncode(msg: "Pool not clear yet", err: StakingError.ErrorCode.POOL_LIFECYCLE_ERROR)
728      }
729      self.updatePool()
730
731      let vaults: @{String: {FungibleToken.Vault}} <- {}
732      let keys = self.rewardsInfo.keys
733
734      for key in keys {
735        let vaultRef = &self.rewardVaults[key] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?
736        if vaultRef != nil {
737          vaults[key] <-! vaultRef!.withdraw(amount: vaultRef!.balance)
738        }
739      }
740
741      self.status = PoolStatus.CLEARED
742
743      emit PoolStatusChanged(pid: self.pid, status: self.status.rawValue.toString())
744      return <- vaults
745    }
746
747    access(all) fun setUserBlockedStatus(adminRef: &Admin?, poolAdminRef: &PoolAdmin, address: Address, flag: Bool) {
748      pre {
749        adminRef != nil || poolAdminRef.owner!.address == self.creator: StakingError.errorEncode(msg: "Pool: no access to block users", err: StakingError.ErrorCode.ACCESS_DENY)
750      }
751      self.updatePool()
752
753      let userInfoRef = &self.usersInfo[address] as &UserInfo?
754      if userInfoRef == nil {
755        self.usersInfo[address] = UserInfo(pid: self.pid, addr: address, isBlocked: flag, rewardPerSeed:{}, claimedRewards: {}, unclaimedRewards: {})
756      } else {
757        userInfoRef!.setBlockStatus(flag)
758      }
759      emit UserBlockedStateChanged(pid: self.pid, address: address, blockedFlag: flag, operator: adminRef != nil ? adminRef!.owner!.address : poolAdminRef.owner!.address)
760    }
761  }
762
763  access(all) resource StakingPoolCollection: PoolCollectionPublic {
764    access(self) let pools: @{UInt64: Pool}
765
766    access(all) fun createStakingPool(adminRef: &Admin?, poolAdminAddr: Address, limitAmount: UInt64, collection: @{NonFungibleToken.Collection}, rewards: [RewardInfo], verifiers: [{INFTVerifier}], extraParams: {String: AnyStruct}) {
767      pre {
768        StakingNFT.isPermissionless || adminRef != nil: StakingError.errorEncode(msg: "Staking: no access to create pool", err: StakingError.ErrorCode.ACCESS_DENY)
769        StakingNFT.pause != true: StakingError.errorEncode(msg: "Staking: pool creation paused", err: StakingError.ErrorCode.ACCESS_DENY)
770      }
771
772      let rewardsInfo: {String: RewardInfo} = {}
773      for reward in rewards {
774        let tokenKey = reward.rewardTokenKey
775        rewardsInfo[tokenKey] = reward
776      }
777
778      let pool <- create Pool(limitAmount: limitAmount, collection: <- collection, rewardsInfo: rewardsInfo, creator: poolAdminAddr, verifiers: verifiers, extraParams: extraParams)
779      let newPid = pool.pid
780      self.pools[newPid] <-! pool
781    }
782
783    access(all) view fun getCollectionLength(): Int {
784      return self.pools.length
785    }
786
787    access(all) view fun getPool(pid: UInt64): &{PoolPublic} {
788      pre{
789        self.pools[pid] != nil: StakingError.errorEncode(msg: "PoolCollection: cannot find pool by pid", err: StakingError.ErrorCode.INVALID_PARAMETERS)
790      }
791      let poolRef = (&self.pools[pid] as &Pool?)!
792      return poolRef
793    }
794
795    access(all) view fun getSlicedPoolInfo(from: UInt64, to: UInt64): [PoolInfo] {
796      pre {
797        from <= to && from < UInt64(self.pools.length): StakingError.errorEncode(msg: "from index out of range", err: StakingError.ErrorCode.INVALID_PARAMETERS)
798      }
799      let poolLen = UInt64(self.pools.length)
800      let endIndex = to >= poolLen ? poolLen - 1 : to
801      var curIndex = from
802      var list: [PoolInfo] = []
803      while curIndex <= endIndex {
804        let pid = self.pools.keys[curIndex]
805        let pool = self.getPool(pid: pid)
806        list = list.concat([
807          pool.getPoolInfo()
808        ])
809        curIndex = curIndex + 1
810      }
811      return list
812    }
813
814    init() {
815      self.pools <- {}
816    }
817  }
818
819  access(all) fun updatePool(pid: UInt64) {
820    let collectionRef = StakingNFT.account.capabilities.borrow<&{StakingNFT.PoolCollectionPublic}>(StakingNFT.CollectionPublicPath)
821    let pool = collectionRef!.getPool(pid: pid)
822    pool.updatePool()
823  }
824
825  // setup poolAdmin resource
826  access(all) fun setupPoolAdmin(): @PoolAdmin {
827    let poolAdmin <- create PoolAdmin()
828    return <- poolAdmin
829  }
830
831  access(all) fun setupUser(): @UserCertificate {
832    let certificate <- create UserCertificate()
833    return <- certificate
834  }
835
836  // get [id] of pools that given user participates
837  access(all) view fun getUserStakingIds(address: Address): [UInt64] {
838    let ids = self.userStakingIds[address]
839    if ids == nil {
840      return []
841    } else {
842      return ids!.keys
843    }
844  }
845
846  /// "A.2d4c3caffbeab845.FLOAT.Collection" -> "A.2d4c3caffbeab845.FLOAT"
847  access(all) view fun getNFTType(collectionIdentifier: String): String {
848    return collectionIdentifier.slice(from: 0, upTo: collectionIdentifier.length - 11)
849  }
850}
851