Smart Contract

LiquidStaking

A.d6f80565193ad727.LiquidStaking

Deployed

1w ago
Feb 19, 2026, 10:46:17 AM UTC

Dependents

69 imports
1/**
2
3# Liquid Staking
4
5# Author: Increment Labs
6
7*/
8
9import FlowToken from 0x1654653399040a61
10import FungibleToken from 0xf233dcee88fe0abe
11
12import FlowIDTableStaking from 0x8624b52f9ddcd04a
13import FlowEpoch from 0x8624b52f9ddcd04a
14
15import stFlowToken from 0xd6f80565193ad727
16import LiquidStakingConfig from 0xd6f80565193ad727
17import LiquidStakingError from 0xd6f80565193ad727
18import DelegatorManager from 0xd6f80565193ad727
19
20access(all) contract LiquidStaking {
21
22    /// Paths
23    access(all) let WithdrawVoucherCollectionPath: StoragePath
24    access(all) let WithdrawVoucherCollectionPublicPath: PublicPath
25
26    /// Events
27    access(all) event Stake(flowAmountIn: UFix64, stFlowAmountOut: UFix64, currProtocolEpoch: UInt64)
28    access(all) event Unstake(stFlowAmountIn: UFix64, lockedFlowAmount: UFix64, currProtocolEpoch: UInt64, unlockProtocolEpoch: UInt64, voucherUUID: UInt64)
29    access(all) event UnstakeQuickly(stFlowAmountIn: UFix64, flowAmountOut: UFix64, currProtocolEpoch: UInt64)
30    access(all) event MigrateDelegator(uuid: UInt64, migratedFlowIn: UFix64, stFlowOut: UFix64)
31    access(all) event BurnWithdrawVoucher(uuid: UInt64, amountFlowCashedout: UFix64, currProtocolEpoch: UInt64)
32
33    /// Reserved parameter fields: {ParamName: Value}
34    access(self) let _reservedFields: {String: AnyStruct}
35
36    /// A voucher indicating the amount of locked $flow will be redeemable after certain protocol epoch (inclusive)
37    access(all) resource WithdrawVoucher {
38
39        /// Flow token amount to be unlocked
40        access(all) let lockedFlowAmount: UFix64
41
42        /// Unlock epoch
43        access(all) let unlockEpoch: UInt64
44
45        init(lockedFlowAmount: UFix64, unlockEpoch: UInt64) {
46            self.lockedFlowAmount = lockedFlowAmount
47            self.unlockEpoch = unlockEpoch
48        }
49    }
50
51    /// Deposit and stake $flow in exchange for $stFlow token
52    access(all) fun stake(flowVault: @FlowToken.Vault): @stFlowToken.Vault {
53        pre {
54            // Pause check
55            LiquidStakingConfig.isStakingPaused == false: LiquidStakingError.ErrorEncode(msg: "Staking is paused", err: LiquidStakingError.ErrorCode.STAKE_NOT_OPEN)
56            // Flow blockchain staking state check
57            FlowIDTableStaking.stakingEnabled(): LiquidStakingError.ErrorEncode(msg: "Cannot stake as not in flowchain's staking auction period", err: LiquidStakingError.ErrorCode.STAKING_AUCTION_NOT_IN_PROGRESS)
58            // Protocol epoch sync check
59            FlowEpoch.currentEpochCounter == DelegatorManager.quoteEpochCounter: LiquidStakingError.ErrorEncode(msg: "Cannot stake until protocol epoch syncs with chain epoch", err: LiquidStakingError.ErrorCode.QUOTE_EPOCH_EXPIRED)
60            // Min staking amount check
61            flowVault.balance >= LiquidStakingConfig.minStakingAmount: LiquidStakingError.ErrorEncode(msg: "Stake amount must be greater than ".concat(LiquidStakingConfig.minStakingAmount.toString()), err: LiquidStakingError.ErrorCode.INVALID_PARAMETERS)
62            // Staking amount cap check
63            LiquidStakingConfig.stakingCap >= flowVault.balance + DelegatorManager.getTotalValidStakingAmount(): LiquidStakingError.ErrorEncode(msg: "Exceed stake cap: ".concat(LiquidStakingConfig.stakingCap.toString()), err: LiquidStakingError.ErrorCode.EXCEED_STAKE_CAP)
64        }
65
66        let flowAmountToStake = flowVault.balance
67        let stFlowAmountToMint = self.calcStFlowFromFlow(flowAmount: flowAmountToStake)
68
69        // Stake to committed tokens
70        DelegatorManager.depositToCommitted(flowVault: <-flowVault)
71
72        // Mint stFlow
73        let stFlowVault <- stFlowToken.mintTokens(amount: stFlowAmountToMint)
74
75        emit Stake(flowAmountIn: flowAmountToStake, stFlowAmountOut: stFlowAmountToMint, currProtocolEpoch: DelegatorManager.quoteEpochCounter)
76
77        return <-stFlowVault
78    }
79
80    /// To unstake (normally) that needs to wait for several epochs before finally withdrawing $flow (principal + interests) from the protocol
81    /// Returns a ticket indicating the amount of $flow redeemable after certain protocol epoch (so you won't get $flow back immediately)
82    access(all) fun unstake(stFlowVault: @stFlowToken.Vault): @WithdrawVoucher {
83        pre {
84            // Pause check
85            LiquidStakingConfig.isUnstakingPaused == false: LiquidStakingError.ErrorEncode(msg: "Unstaking is paused", err: LiquidStakingError.ErrorCode.UNSTAKE_NOT_OPEN)
86            // Unstaking amount check
87            stFlowVault.balance > 0.0: LiquidStakingError.ErrorEncode(msg: "Unstake amount must be greater than 0.0", err: LiquidStakingError.ErrorCode.INVALID_PARAMETERS)
88        }
89
90        let stFlowAmountToBurn = stFlowVault.balance
91        let flowAmountToUnstake = self.calcFlowFromStFlow(stFlowAmount: stFlowAmountToBurn)
92
93        // Burn stFlow
94        stFlowToken.burnTokens(from: <-stFlowVault)
95
96        // Request unstake from staked & committed tokens
97        DelegatorManager.requestWithdrawFromStaked(amount: flowAmountToUnstake)
98
99        let currentBlockView = getCurrentBlock().view
100        let stakingEndView = FlowEpoch.getEpochMetadata(FlowEpoch.currentEpochCounter)!.stakingEndView
101        let protocolEpoch = DelegatorManager.quoteEpochCounter
102
103        // In most of the time, the voucher will be redeemable after 2 protocol epochs
104        var unlockEpoch = protocolEpoch + 2
105        if FlowEpoch.currentEpochCounter > DelegatorManager.quoteEpochCounter {
106            // If unstake() happenes in the short period of time that chain epoch has advanced already but the protocol is waiting to collect 
107            // delegators and update $stFlow price. For safety purpose the unlockEpoch is postponed for an additional epoch
108            unlockEpoch = protocolEpoch + 3
109        } else  {
110            if FlowIDTableStaking.stakingEnabled() == false {
111                // Otherwise if unstake() happenes in flowchain's epoch setup & commit stage, no "real" action is allowed in the underlying layer
112                // So for safety purpose it's postponed for an extra epoch, too
113                // In this case the waiting time <= (2 epochs + epoch setup & commit time). Epoch setup & commit time usually takes less than half a day
114                // See https://developers.flow.com/nodes/staking/schedule#schedule for more details
115                unlockEpoch = protocolEpoch + 3
116            } else if currentBlockView + LiquidStakingConfig.windowSizeBeforeStakingEnd > stakingEndView {
117                // Before staking auction ends, a window of processing time is reserved to handle unprocessed unstake requests
118                // Any unstake() happening within this short period of time will be postponed with an extra epoch for safety purpose
119                // However, even in this case the waiting time <= (2 epochs + safetyWindowSize)
120                unlockEpoch = protocolEpoch + 3
121            }
122        }
123
124        let unstakeVoucher <- create WithdrawVoucher(
125            lockedFlowAmount: flowAmountToUnstake,
126            unlockEpoch: unlockEpoch
127        )
128
129        emit Unstake(stFlowAmountIn: stFlowAmountToBurn, lockedFlowAmount: flowAmountToUnstake, currProtocolEpoch: protocolEpoch, unlockProtocolEpoch: unlockEpoch, voucherUUID: unstakeVoucher.uuid)
130
131        return <- unstakeVoucher
132    }
133
134    /// To unstake (quickly) from liquid staking protocol's default staking node, without waiting for several epochs
135    /// You'll immediately get $flow back, as long as the default staking node has enough newly committed tokens from this epoch; otherwise reverts internally.
136    access(all) fun unstakeQuickly(stFlowVault: @stFlowToken.Vault): @FlowToken.Vault {
137        pre {
138            // Pause check
139            LiquidStakingConfig.isUnstakingPaused == false: LiquidStakingError.ErrorEncode(msg: "Unstaking is paused", err: LiquidStakingError.ErrorCode.UNSTAKE_NOT_OPEN)
140            // Flow chain unstaking state check
141            FlowIDTableStaking.stakingEnabled(): LiquidStakingError.ErrorEncode(msg: "Cannot unstake as not in flowchain's staking auction period", err: LiquidStakingError.ErrorCode.STAKING_AUCTION_NOT_IN_PROGRESS)
142            // Protocol epoch sync check
143            FlowEpoch.currentEpochCounter == DelegatorManager.quoteEpochCounter: LiquidStakingError.ErrorEncode(msg: "Cannot unstake until protocol epoch syncs with chain epoch", err: LiquidStakingError.ErrorCode.QUOTE_EPOCH_EXPIRED)
144            // Unstaking amount check
145            stFlowVault.balance > 0.0: LiquidStakingError.ErrorEncode(msg: "Unstake amount must be greater than 0.0", err: LiquidStakingError.ErrorCode.INVALID_PARAMETERS)
146        }
147
148        let stFlowAmountToBurn = stFlowVault.balance
149        let flowAmountToUnstake = self.calcFlowFromStFlow(stFlowAmount: stFlowAmountToBurn)
150
151        // Burn stFlow
152        stFlowToken.burnTokens(from: <-stFlowVault)
153
154        // Withdraw from default staking node's committed vault
155        let flowVault <- DelegatorManager.withdrawFromCommitted(amount: flowAmountToUnstake)
156
157        // Deduct fast unstake protocol fee
158        let feeVault <- flowVault.withdraw(amount: LiquidStakingConfig.quickUnstakeFee * flowAmountToUnstake)
159        DelegatorManager.depositToProtocolFees(flowVault: <-feeVault, purpose: "fast unstake fee -> protocol fee")
160
161        emit UnstakeQuickly(stFlowAmountIn: stFlowAmountToBurn, flowAmountOut: flowVault.balance, currProtocolEpoch: DelegatorManager.quoteEpochCounter)
162
163        return <-flowVault
164    }
165
166    /// Cashout WithdrawVoucher by burning it for FlowToken
167    access(all) fun cashoutWithdrawVoucher(voucher: @WithdrawVoucher): @FlowToken.Vault {
168        pre {
169            DelegatorManager.quoteEpochCounter >= voucher.unlockEpoch: LiquidStakingError.ErrorEncode(msg: "Voucher is not redeemable yet", err: LiquidStakingError.ErrorCode.CANNOT_CASHOUT_WITHDRAW_VOUCHER)
170        }
171        let cashoutAmount = voucher.lockedFlowAmount
172
173        let flowVault <-DelegatorManager.withdrawFromUnstaked(amount: cashoutAmount)
174
175        emit BurnWithdrawVoucher(uuid: voucher.uuid, amountFlowCashedout: cashoutAmount, currProtocolEpoch: DelegatorManager.quoteEpochCounter)
176
177        destroy voucher
178
179        return <-flowVault
180    }
181
182    /// Migrate staked NodeDelegator resources to get it managed by the liquid staking protocol, and mint corresponding $stFlow back
183    /// This is a useful feature for users who have already staked $flow to nodes. To get the benefits of $stFlow and its ecosystem, they do not have to
184    /// first unstake from nodes (as it usually takes 1~2 epochs and lose staking rewards meanwhile); users can just migrate their NodeDelegator resources
185    /// (represents their staked positions) in and get $stFlow immediately.
186    access(all) fun migrate(delegator: @FlowIDTableStaking.NodeDelegator): @stFlowToken.Vault {
187        pre {
188            // Pause check
189            LiquidStakingConfig.isMigratingPaused == false: LiquidStakingError.ErrorEncode(msg: "Migrating is paused", err: LiquidStakingError.ErrorCode.MIGRATE_NOT_OPEN)
190            // Flowchain staking state check
191            FlowIDTableStaking.stakingEnabled(): LiquidStakingError.ErrorEncode(msg: "Cannot migrate as not in flowchain's staking auction period", err: LiquidStakingError.ErrorCode.STAKING_AUCTION_NOT_IN_PROGRESS)
192            // Protocol epoch sync check
193            FlowEpoch.currentEpochCounter == DelegatorManager.quoteEpochCounter: LiquidStakingError.ErrorEncode(msg: "Cannot migrate until protocol epoch syncs with chain epoch", err: LiquidStakingError.ErrorCode.QUOTE_EPOCH_EXPIRED)
194        }
195
196        let delegatroInfo = FlowIDTableStaking.DelegatorInfo(nodeID: delegator.nodeID, delegatorID: delegator.id)
197
198        assert(LiquidStakingConfig.stakingCap >= delegatroInfo.tokensStaked + DelegatorManager.getTotalValidStakingAmount(), message: "Exceed stake cap")
199        assert(delegatroInfo.tokensUnstaking == 0.0, message: "Wait for the previous unstaking processing to complete")
200        assert(delegatroInfo.tokensRewarded == 0.0, message: "Please claim the reward before migrating")
201        assert(delegatroInfo.tokensUnstaked == 0.0, message: "Please withdraw the unstaked tokens before migrating")
202        assert(delegatroInfo.tokensRequestedToUnstake == 0.0, message: "Please cancel the unstake requests before migrating")
203        assert(delegatroInfo.tokensCommitted == 0.0, message: "Please cancel the stake requests before migrating")
204        assert(delegatroInfo.tokensStaked > 0.0, message: "No staked tokens need to be migrated.")
205
206        let stakedFlowToMigrate = delegatroInfo.tokensStaked
207        let stFlowAmountToMint = self.calcStFlowFromFlow(flowAmount: stakedFlowToMigrate)
208
209        emit MigrateDelegator(uuid: delegator.uuid, migratedFlowIn: stakedFlowToMigrate, stFlowOut: stFlowAmountToMint)
210
211        // Migrate NodeDelegator resource to make it managed by the liquid staking protocol
212        DelegatorManager.migrateDelegator(delegator: <-delegator)
213
214        // Mint stFlow
215        return <-stFlowToken.mintTokens(amount: stFlowAmountToMint)
216    }
217
218    /// WithdrawVoucher collection
219    access(all) resource interface WithdrawVoucherCollectionPublic {
220        access(all) view fun getVoucherInfos(): [AnyStruct]
221        access(all) fun deposit(voucher: @WithdrawVoucher)
222    }
223
224    access(all) resource WithdrawVoucherCollection: WithdrawVoucherCollectionPublic {
225        /// A list of withdraw vouchers
226        access(self) var vouchers: @[WithdrawVoucher]
227
228        access(all) fun deposit(voucher: @WithdrawVoucher) {
229            self.vouchers.append(<-voucher)
230        }
231
232        access(FungibleToken.Withdraw) fun withdraw(uuid: UInt64): @WithdrawVoucher {
233            var findIndex: Int? = nil
234            var index = 0
235            while index < self.vouchers.length {
236                if self.vouchers[index].uuid == uuid {
237                    findIndex = index
238                    break
239                }
240                index = index + 1
241            }
242
243            assert(findIndex != nil, message: "Cannot find voucher with uuid ".concat(uuid.toString()))
244            return <-self.vouchers.remove(at: findIndex!)
245        }
246
247        access(all) view fun getVoucherInfos(): [AnyStruct] {
248            var voucherInfos: [AnyStruct] = []
249            var index = 0
250            while index < self.vouchers.length {
251                voucherInfos = voucherInfos.concat([
252                    {
253                        "uuid": self.vouchers[index].uuid,
254                        "lockedFlowAmount": self.vouchers[index].lockedFlowAmount,
255                        "unlockEpoch": self.vouchers[index].unlockEpoch
256                    }
257                ])
258                index = index + 1
259            }
260            return voucherInfos
261        }
262
263        init() {
264            self.vouchers <- []
265        }
266    }
267
268    access(all) fun createEmptyWithdrawVoucherCollection(): @WithdrawVoucherCollection {
269        return <-create WithdrawVoucherCollection()
270    }
271
272    /// Calculate exchange amount from Flow to stFlow
273    access(all) view fun calcStFlowFromFlow(flowAmount: UFix64): UFix64 {
274        let currentEpochSnapshot = DelegatorManager.borrowCurrentQuoteEpochSnapshot()
275        let scaledFlowPrice = currentEpochSnapshot.scaledQuoteFlowStFlow
276        let scaledFlowAmount = LiquidStakingConfig.UFix64ToScaledUInt256(flowAmount)
277
278        let stFlowAmount = LiquidStakingConfig.ScaledUInt256ToUFix64(
279            scaledFlowPrice * scaledFlowAmount / LiquidStakingConfig.scaleFactor
280        )
281        return stFlowAmount
282    }
283
284    /// Calculate exchange amount from stFlow to Flow
285    access(all) view fun calcFlowFromStFlow(stFlowAmount: UFix64): UFix64 {
286        let currentEpochSnapshot = DelegatorManager.borrowCurrentQuoteEpochSnapshot()
287        let scaledStFlowPrice = currentEpochSnapshot.scaledQuoteStFlowFlow
288        let scaledStFlowAmount = LiquidStakingConfig.UFix64ToScaledUInt256(stFlowAmount)
289
290        let flowAmount = LiquidStakingConfig.ScaledUInt256ToUFix64(
291            scaledStFlowPrice * scaledStFlowAmount / LiquidStakingConfig.scaleFactor
292        )
293        return flowAmount
294    }
295
296    init() {
297        self.WithdrawVoucherCollectionPath = /storage/liquid_staking_withdraw_voucher_collection
298        self.WithdrawVoucherCollectionPublicPath = /public/liquid_staking_withdraw_voucher_collection
299        self._reservedFields = {}
300    }
301}
302