/
unbonding.go
411 lines (348 loc) · 17.8 KB
/
unbonding.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
package keeper
import (
"fmt"
"time"
errorsmod "cosmossdk.io/errors"
sdkmath "cosmossdk.io/math"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
"github.com/Stride-Labs/stride/v22/utils"
"github.com/Stride-Labs/stride/v22/x/staketia/types"
)
// Takes custody of staked tokens in an escrow account, updates the current
// accumulating UnbondingRecord with the amount taken, and creates or updates
// the RedemptionRecord for this user
func (k Keeper) RedeemStake(ctx sdk.Context, redeemer string, stTokenAmount sdkmath.Int) (nativeToken sdk.Coin, err error) {
// Validate Basic already has ensured redeemer is legal address, stTokenAmount is above min threshold
// Check HostZone exists, has legal redemption address for escrow, is not halted, has RR in bounds
hostZone, err := k.GetUnhaltedHostZone(ctx)
if err != nil {
return nativeToken, err
}
escrowAccount, err := sdk.AccAddressFromBech32(hostZone.RedemptionAddress)
if err != nil {
return nativeToken, errorsmod.Wrapf(err, "could not bech32 decode redemption address %s on stride", hostZone.RedemptionAddress)
}
err = k.CheckRedemptionRateExceedsBounds(ctx)
if err != nil {
return nativeToken, err
}
// Get the current accumulating UnbondingRecord
accUnbondingRecord, err := k.GetAccumulatingUnbondingRecord(ctx)
if err != nil {
return nativeToken, err
}
// Check redeemer owns at least stTokenAmount of stutia
stDenom := utils.StAssetDenomFromHostZoneDenom(hostZone.NativeTokenDenom)
redeemerAccount, err := sdk.AccAddressFromBech32(redeemer)
if err != nil {
return nativeToken, errorsmod.Wrapf(sdkerrors.ErrInvalidAddress, "invalid address (%s)", redeemer)
}
balance := k.bankKeeper.GetBalance(ctx, redeemerAccount, stDenom)
if balance.Amount.LT(stTokenAmount) {
return nativeToken, errorsmod.Wrapf(sdkerrors.ErrInsufficientFunds,
"wallet balance of stTIA is lower than redemption amount. %v < %v: ", balance.Amount, stTokenAmount)
}
// Estimate a placeholder native amount with current RedemptionRate
// this estimate will be updated when the Undelegation record is finalized
nativeAmount := sdk.NewDecFromInt(stTokenAmount).Mul(hostZone.RedemptionRate).TruncateInt()
if nativeAmount.GT(hostZone.DelegatedBalance) {
return nativeToken, errorsmod.Wrapf(types.ErrUnbondAmountToLarge,
"cannot unstake an amount g.t. total staked balance: %v > %v", nativeAmount, hostZone.DelegatedBalance)
}
// Update the accumulating UnbondingRecord with the undelegation amounts
accUnbondingRecord.StTokenAmount = accUnbondingRecord.StTokenAmount.Add(stTokenAmount)
accUnbondingRecord.NativeAmount = accUnbondingRecord.NativeAmount.Add(nativeAmount)
// Update or create the RedemptionRecord for this redeemer
redemptionRecord, userHasActiveRedemptionRecord := k.GetRedemptionRecord(ctx, accUnbondingRecord.Id, redeemer)
if userHasActiveRedemptionRecord {
// Already active RedemptionRecord found for this redeemer this epoch so will update it
redemptionRecord.StTokenAmount = redemptionRecord.StTokenAmount.Add(stTokenAmount)
redemptionRecord.NativeAmount = redemptionRecord.NativeAmount.Add(nativeAmount)
} else {
// Creating new RedemptionRecord for this redeemer this epoch
redemptionRecord = types.RedemptionRecord{
UnbondingRecordId: accUnbondingRecord.Id,
Redeemer: redeemer,
NativeAmount: nativeAmount,
StTokenAmount: stTokenAmount,
}
}
nativeToken = sdk.NewCoin(hostZone.NativeTokenDenom, nativeAmount) // Should it be NativeTokenIbcDenom?
// Escrow user's stTIA balance before setting either record in the store to verify everything worked
redeemCoins := sdk.NewCoins(sdk.NewCoin(stDenom, stTokenAmount))
err = k.bankKeeper.SendCoins(ctx, redeemerAccount, escrowAccount, redeemCoins)
if err != nil {
return nativeToken, errorsmod.Wrapf(err, "couldn't send %v stutia. err: %s", stTokenAmount, err.Error())
}
// Now that escrow succeeded, actually set the updated records in the store
k.SetUnbondingRecord(ctx, accUnbondingRecord)
k.SetRedemptionRecord(ctx, redemptionRecord)
EmitSuccessfulRedeemStakeEvent(ctx, redeemer, hostZone, nativeAmount, stTokenAmount)
return nativeToken, nil
}
// Freezes the ACCUMULATING record by changing the status to UNBONDING_QUEUE
// and updating the native token amounts on the unbonding and redemption records
func (k Keeper) PrepareUndelegation(ctx sdk.Context, epochNumber uint64) error {
k.Logger(ctx).Info(utils.LogWithHostZone(types.CelestiaChainId, "Preparing undelegation for epoch %d", epochNumber))
// Get the redemption record from the host zone (to calculate the native tokens)
hostZone, err := k.GetUnhaltedHostZone(ctx)
if err != nil {
return err
}
redemptionRate := hostZone.RedemptionRate
// Get the one accumulating record that has the redemptions for the past epoch
unbondingRecord, err := k.GetAccumulatingUnbondingRecord(ctx)
if err != nil {
return err
}
// Create the new accumulating record for this epoch
newUnbondingRecord := types.UnbondingRecord{
Id: epochNumber,
Status: types.ACCUMULATING_REDEMPTIONS,
StTokenAmount: sdkmath.ZeroInt(),
NativeAmount: sdkmath.ZeroInt(),
}
if err := k.SafelySetUnbondingRecord(ctx, newUnbondingRecord); err != nil {
return err
}
// Update the number of native tokens for all the redemption records
// Keep track of the total for the unbonding record
totalNativeTokens := sdkmath.ZeroInt()
for _, redemptionRecord := range k.GetRedemptionRecordsFromUnbondingId(ctx, unbondingRecord.Id) {
nativeAmount := sdk.NewDecFromInt(redemptionRecord.StTokenAmount).Mul(redemptionRate).TruncateInt()
redemptionRecord.NativeAmount = nativeAmount
k.SetRedemptionRecord(ctx, redemptionRecord)
totalNativeTokens = totalNativeTokens.Add(nativeAmount)
}
// If there were no unbondings this epoch, archive the current record
if totalNativeTokens.IsZero() {
k.ArchiveUnbondingRecord(ctx, unbondingRecord)
return nil
}
// Update the total on the record and change the status to QUEUE
unbondingRecord.Status = types.UNBONDING_QUEUE
unbondingRecord.NativeAmount = totalNativeTokens
k.SetUnbondingRecord(ctx, unbondingRecord)
return nil
}
// Confirms that an undelegation has been completed on the host zone
// Updates the record status to UNBONDING_IN_PROGRESS, decrements the delegated balance and burns stTokens
func (k Keeper) ConfirmUndelegation(ctx sdk.Context, recordId uint64, txHash string, sender string) (err error) {
// grab unbonding record, verify it's in the right state, and no tx hash has been submitted yet
record, found := k.GetUnbondingRecord(ctx, recordId)
if !found {
return errorsmod.Wrapf(types.ErrUnbondingRecordNotFound, "couldn't find unbonding record with id: %d", recordId)
}
if record.Status != types.UNBONDING_QUEUE {
return errorsmod.Wrapf(types.ErrInvalidUnbondingRecord, "unbonding record with id: %d is not ready to be undelegated", recordId)
}
if record.UndelegationTxHash != "" {
return errorsmod.Wrapf(types.ErrInvalidUnbondingRecord, "unbonding record with id: %d already has undelegation tx hash set", recordId)
}
if record.UnbondedTokenSweepTxHash != "" {
return errorsmod.Wrapf(types.ErrInvalidUnbondingRecord, "unbonding record with id: %d already has token sweep tx hash set", recordId)
}
// if there are no tokens to unbond (or negative on the record): throw an error!
noTokensUnbondedOrNegative := record.NativeAmount.LTE(sdk.ZeroInt()) || record.StTokenAmount.LTE(sdk.ZeroInt())
if noTokensUnbondedOrNegative {
return errorsmod.Wrapf(types.ErrInvalidUnbondingRecord, "unbonding record with id: %d has no tokens to unbond (or negative)", recordId)
}
// Note: we're intentionally not checking that the host zone is halted, because we still want to process this tx in that case
hostZone, err := k.GetHostZone(ctx)
if err != nil {
return err
}
// sanity check: store down the stToken supply and DelegatedBalance for checking against after burn
stDenom := utils.StAssetDenomFromHostZoneDenom(hostZone.NativeTokenDenom)
stTokenSupplyBefore := k.bankKeeper.GetSupply(ctx, stDenom).Amount
delegatedBalanceBefore := hostZone.DelegatedBalance
// update the record's txhash, status, and unbonding completion time
unbondingLength := time.Duration(hostZone.UnbondingPeriodSeconds) * time.Second // 21 days
unbondingCompletionTime := uint64(ctx.BlockTime().Add(unbondingLength).Unix()) // now + 21 days
record.UndelegationTxHash = txHash
record.Status = types.UNBONDING_IN_PROGRESS
record.UnbondingCompletionTimeSeconds = unbondingCompletionTime
k.SetUnbondingRecord(ctx, record)
// update host zone struct's delegated balance
amountAddedToDelegation := record.NativeAmount
newDelegatedBalance := hostZone.DelegatedBalance.Sub(amountAddedToDelegation)
// sanity check: if the new balance is negative, throw an error
if newDelegatedBalance.IsNegative() {
return errorsmod.Wrapf(types.ErrNegativeNotAllowed, "host zone's delegated balance would be negative after undelegation")
}
hostZone.DelegatedBalance = newDelegatedBalance
k.SetHostZone(ctx, hostZone)
// burn the corresponding stTokens from the redemptionAddress
stTokensToBurn := sdk.NewCoins(sdk.NewCoin(stDenom, record.StTokenAmount))
if err := k.BurnRedeemedStTokens(ctx, stTokensToBurn, hostZone.RedemptionAddress); err != nil {
return errorsmod.Wrapf(err, "unable to burn stTokens in ConfirmUndelegation")
}
// sanity check: check that (DelegatedBalance increment / stToken supply decrement) is within outer bounds
if err := k.VerifyImpliedRedemptionRateFromUnbonding(ctx, stTokenSupplyBefore, delegatedBalanceBefore); err != nil {
return errorsmod.Wrap(err, "ratio of delegation change to burned tokens exceeds redemption rate bounds")
}
EmitSuccessfulConfirmUndelegationEvent(ctx, recordId, record.NativeAmount, txHash, sender)
return nil
}
// Burn stTokens from the redemption account
// - this requires sending them to an module account first, then burning them from there.
// - we use the staketia module account
func (k Keeper) BurnRedeemedStTokens(ctx sdk.Context, stTokensToBurn sdk.Coins, redemptionAddress string) error {
acctAddressRedemption, err := sdk.AccAddressFromBech32(redemptionAddress)
if err != nil {
return fmt.Errorf("could not bech32 decode address %s", redemptionAddress)
}
// send tokens from the EOA to the staketia module account
err = k.bankKeeper.SendCoinsFromAccountToModule(ctx, acctAddressRedemption, types.ModuleName, stTokensToBurn)
if err != nil {
return errorsmod.Wrapf(err, "could not send coins from account %s to module %s. err: %s", redemptionAddress, types.ModuleName, err)
}
// burn the stTokens from the staketia module account
err = k.bankKeeper.BurnCoins(ctx, types.ModuleName, stTokensToBurn)
if err != nil {
return errorsmod.Wrapf(err, "couldn't burn %v tokens in module account", stTokensToBurn)
}
return nil
}
// Sanity check helper for checking diffs on delegated balance and stToken supply are within outer RR bounds
func (k Keeper) VerifyImpliedRedemptionRateFromUnbonding(ctx sdk.Context, stTokenSupplyBefore sdkmath.Int, delegatedBalanceBefore sdkmath.Int) error {
hostZoneAfter, err := k.GetHostZone(ctx)
if err != nil {
return types.ErrHostZoneNotFound
}
stDenom := utils.StAssetDenomFromHostZoneDenom(hostZoneAfter.NativeTokenDenom)
// grab the delegated balance and token supply after the burn
delegatedBalanceAfter := hostZoneAfter.DelegatedBalance
stTokenSupplyAfter := k.bankKeeper.GetSupply(ctx, stDenom).Amount
// calculate the delta for both the delegated balance and stToken burn
delegatedBalanceDecremented := delegatedBalanceBefore.Sub(delegatedBalanceAfter)
stTokenSupplyBurned := stTokenSupplyBefore.Sub(stTokenSupplyAfter)
// It shouldn't be possible for this to be zero, but this will prevent a division by zero error
if stTokenSupplyBurned.IsZero() {
return types.ErrDivisionByZero
}
// calculate the ratio of delegated balance change to stToken burn - it should be close to the redemption rate
ratio := sdk.NewDecFromInt(delegatedBalanceDecremented).Quo(sdk.NewDecFromInt(stTokenSupplyBurned))
// check ratio against bounds
if ratio.LT(hostZoneAfter.MinRedemptionRate) || ratio.GT(hostZoneAfter.MaxRedemptionRate) {
return types.ErrRedemptionRateOutsideSafetyBounds
}
return nil
}
// Checks for any unbonding records that have finished unbonding,
// identified by having status UNBONDING_IN_PROGRESS and an
// unbonding that's older than the current time.
// Records are annotated with a new status UNBONDED
func (k Keeper) MarkFinishedUnbondings(ctx sdk.Context) {
for _, unbondingRecord := range k.GetAllUnbondingRecordsByStatus(ctx, types.UNBONDING_IN_PROGRESS) {
if ctx.BlockTime().Unix() > int64(unbondingRecord.UnbondingCompletionTimeSeconds) {
unbondingRecord.Status = types.UNBONDED
k.SetUnbondingRecord(ctx, unbondingRecord)
}
}
}
// Confirms that unbonded tokens have been sent back to stride and marks the unbonding record CLAIMABLE
func (k Keeper) ConfirmUnbondedTokenSweep(ctx sdk.Context, recordId uint64, txHash string, sender string) (err error) {
// grab unbonding record and verify the record is ready to be swept, and has not been swept yet
record, found := k.GetUnbondingRecord(ctx, recordId)
if !found {
return errorsmod.Wrapf(types.ErrUnbondingRecordNotFound, "couldn't find unbonding record with id: %d", recordId)
}
if record.Status != types.UNBONDED {
return errorsmod.Wrapf(types.ErrInvalidUnbondingRecord, "unbonding record with id: %d is not ready to be swept", recordId)
}
if record.UnbondedTokenSweepTxHash != "" {
return errorsmod.Wrapf(types.ErrInvalidUnbondingRecord, "unbonding record with id: %d already has a tx hash set", recordId)
}
// verify amount to sweep is positive
unbondingRecordIsNonPositive := !record.NativeAmount.IsPositive() || !record.StTokenAmount.IsPositive()
if unbondingRecordIsNonPositive {
return errorsmod.Wrapf(types.ErrInvalidUnbondingRecord, "unbonding record with id: %d has non positive amount to sweep", recordId)
}
// grab claim address from host zone
// note: we're intentionally not checking that the host zone is halted, because we still want to process this tx in that case
hostZone, err := k.GetHostZone(ctx)
if err != nil {
return err
}
claimAddress, err := sdk.AccAddressFromBech32(hostZone.ClaimAddress)
if err != nil {
return err
}
// verify the claim address has the same or more tokens than the record (necessary condition if sweep was successful)
claimAddressBalance := k.bankKeeper.GetBalance(ctx, claimAddress, hostZone.NativeTokenIbcDenom)
if claimAddressBalance.Amount.LT(record.NativeAmount) {
return errorsmod.Wrapf(types.ErrInsufficientFunds, "claim address %s has insufficient funds to confirm sweep unbonded tokens", hostZone.ClaimAddress)
}
// update record status to CLAIMABLE
record.Status = types.CLAIMABLE
record.UnbondedTokenSweepTxHash = txHash
k.SetUnbondingRecord(ctx, record)
EmitSuccessfulConfirmUnbondedTokenSweepEvent(ctx, recordId, record.NativeAmount, txHash, sender)
return nil
}
// Iterates all unbonding records and distributes unbonded tokens to redeemers
// This function will operate atomically by using a cache context wrapper when
// it's invoked. This means that if any redemption send fails across any unbonding
// records, all partial state will be reverted
func (k Keeper) DistributeClaims(ctx sdk.Context) error {
// Get the claim address which will be the sender
// The token denom will be the native host zone token in it's IBC form as it lives on stride
hostZone, err := k.GetUnhaltedHostZone(ctx)
if err != nil {
return err
}
nativeTokenIbcDenom := hostZone.NativeTokenIbcDenom
claimAddress, err := sdk.AccAddressFromBech32(hostZone.ClaimAddress)
if err != nil {
return errorsmod.Wrapf(err, "invalid host zone claim address %s", hostZone.ClaimAddress)
}
// Loop through each claimable unbonding record and send out all the relevant claims
for _, unbondingRecord := range k.GetAllUnbondingRecordsByStatus(ctx, types.CLAIMABLE) {
if err := k.DistributeClaimsForUnbondingRecord(ctx, nativeTokenIbcDenom, claimAddress, unbondingRecord.Id); err != nil {
return errorsmod.Wrapf(err, "Unable to distribute claims for unbonding record %d: %s",
unbondingRecord.Id, err.Error())
}
// Once all claims have been distributed for a record, archive the record
unbondingRecord.Status = types.CLAIMED
k.ArchiveUnbondingRecord(ctx, unbondingRecord)
}
return nil
}
// Distribute claims for a given unbonding record
func (k Keeper) DistributeClaimsForUnbondingRecord(
ctx sdk.Context,
hostNativeIbcDenom string,
claimAddress sdk.AccAddress,
unbondingRecordId uint64,
) error {
k.Logger(ctx).Info(utils.LogWithHostZone(types.CelestiaChainId,
"Distributing claims for unbonding record %d", unbondingRecordId))
// For each redemption record, bank send from the claim address to the user address and then delete the record
for _, redemptionRecord := range k.GetRedemptionRecordsFromUnbondingId(ctx, unbondingRecordId) {
userAddress, err := sdk.AccAddressFromBech32(redemptionRecord.Redeemer)
if err != nil {
return errorsmod.Wrapf(err, "invalid redeemer address %s", userAddress)
}
nativeTokens := sdk.NewCoin(hostNativeIbcDenom, redemptionRecord.NativeAmount)
if err := k.bankKeeper.SendCoins(ctx, claimAddress, userAddress, sdk.NewCoins(nativeTokens)); err != nil {
return errorsmod.Wrapf(err, "unable to send %v from claim address to %s",
nativeTokens, redemptionRecord.Redeemer)
}
k.RemoveRedemptionRecord(ctx, unbondingRecordId, redemptionRecord.Redeemer)
}
return nil
}
// Runs prepare undelegations with a cache context wrapper so revert any partial state changes
func (k Keeper) SafelyPrepareUndelegation(ctx sdk.Context, epochNumber uint64) error {
return utils.ApplyFuncIfNoError(ctx, func(ctx sdk.Context) error {
return k.PrepareUndelegation(ctx, epochNumber)
})
}
// Runs distribute claims with a cache context wrapper so revert any partial state changes
func (k Keeper) SafelyDistributeClaims(ctx sdk.Context) error {
return utils.ApplyFuncIfNoError(ctx, func(ctx sdk.Context) error {
return k.DistributeClaims(ctx)
})
}