Smart Contract
StakingNFT
A.1b77ba4b414de352.StakingNFT
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