Skip to content

Commit ed4e6bd

Browse files
jdubpark0xHansLee
authored andcommitted
feat(x/evmstaking): recovery for lost delegations (#484)
more detail in the [forum post](https://forum.story.foundation/t/recovery-mechanism-for-lost-delegation-funds-due-to-user-mistakes/36879) issue: none
1 parent 2b786a2 commit ed4e6bd

File tree

14 files changed

+796
-153
lines changed

14 files changed

+796
-153
lines changed

client/proto/story/evmstaking/v1/types/params.proto

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ syntax = "proto3";
22
package story.evmstaking.v1.types;
33

44
import "gogoproto/gogo.proto";
5+
import "google/protobuf/duration.proto";
56

67
option go_package = "github.com/piplabs/story/client/x/evmstaking/types";
78

@@ -19,4 +20,11 @@ message Params {
1920
string ubi_withdraw_address = 4 [
2021
(gogoproto.moretags) = "yaml:\"ubi_withdraw_address\""
2122
];
23+
uint32 refund_fee_bps = 5 [
24+
(gogoproto.moretags) = "yaml:\"refund_fee_bps\""
25+
];
26+
google.protobuf.Duration refund_period = 6 [
27+
(gogoproto.nullable) = false, (gogoproto.stdduration) = true,
28+
(gogoproto.moretags) = "yaml:\"refund_period\""
29+
];
2230
}

client/x/evmstaking/keeper/deposit.go

Lines changed: 104 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import (
66
"encoding/hex"
77
"fmt"
88
"strconv"
9+
"time"
10+
11+
"cosmossdk.io/math"
912

1013
sdk "github.com/cosmos/cosmos-sdk/types"
1114
skeeper "github.com/cosmos/cosmos-sdk/x/staking/keeper"
@@ -23,6 +26,10 @@ func (k Keeper) ProcessDeposit(ctx context.Context, ev *bindings.IPTokenStakingD
2326
sdkCtx := sdk.UnwrapSDKContext(ctx)
2427
cachedCtx, writeCache := sdkCtx.CacheContext()
2528

29+
var isRefund bool
30+
var refundAmount math.Int
31+
var completionTime time.Time
32+
2633
defer func() {
2734
if r := recover(); r != nil {
2835
err = errors.WrapErrWithCode(errors.UnexpectedCondition, fmt.Errorf("panic caused by %v", r))
@@ -41,18 +48,27 @@ func (k Keeper) ProcessDeposit(ctx context.Context, ev *bindings.IPTokenStakingD
4148
)
4249
}
4350

44-
sdkCtx.EventManager().EmitEvents(sdk.Events{
45-
e.AppendAttributes(
46-
sdk.NewAttribute(types.AttributeKeyAmount, ev.StakeAmount.String()),
47-
sdk.NewAttribute(types.AttributeKeyBlockHeight, strconv.FormatInt(sdkCtx.BlockHeight(), 10)),
48-
sdk.NewAttribute(types.AttributeKeyDelegatorAddress, ev.Delegator.String()),
49-
sdk.NewAttribute(types.AttributeKeyValidatorCmpPubKey, hex.EncodeToString(ev.ValidatorCmpPubkey)),
50-
sdk.NewAttribute(types.AttributeKeyDelegateID, ev.DelegationId.String()),
51-
sdk.NewAttribute(types.AttributeKeyPeriodType, strconv.FormatInt(ev.StakingPeriod.Int64(), 10)),
52-
sdk.NewAttribute(types.AttributeKeySenderAddress, ev.OperatorAddress.Hex()),
53-
sdk.NewAttribute(types.AttributeKeyTxHash, hex.EncodeToString(ev.Raw.TxHash.Bytes())),
54-
),
55-
})
51+
e = e.AppendAttributes(
52+
sdk.NewAttribute(types.AttributeKeyAmount, ev.StakeAmount.String()),
53+
sdk.NewAttribute(types.AttributeKeyBlockHeight, strconv.FormatInt(sdkCtx.BlockHeight(), 10)),
54+
sdk.NewAttribute(types.AttributeKeyDelegatorAddress, ev.Delegator.String()),
55+
sdk.NewAttribute(types.AttributeKeyValidatorCmpPubKey, hex.EncodeToString(ev.ValidatorCmpPubkey)),
56+
sdk.NewAttribute(types.AttributeKeyDelegateID, ev.DelegationId.String()),
57+
sdk.NewAttribute(types.AttributeKeyPeriodType, strconv.FormatInt(ev.StakingPeriod.Int64(), 10)),
58+
sdk.NewAttribute(types.AttributeKeySenderAddress, ev.OperatorAddress.Hex()),
59+
sdk.NewAttribute(types.AttributeKeyTxHash, hex.EncodeToString(ev.Raw.TxHash.Bytes())),
60+
sdk.NewAttribute(types.AttributeKeyIsRefund, strconv.FormatBool(isRefund)),
61+
)
62+
63+
// if it's a refund, add refund attributes
64+
if isRefund {
65+
e = e.AppendAttributes(
66+
sdk.NewAttribute(types.AttributeKeyRefundAmount, refundAmount.String()),
67+
sdk.NewAttribute(types.AttributeKeyRefundCompletionTime, completionTime.String()),
68+
)
69+
}
70+
71+
sdkCtx.EventManager().EmitEvents(sdk.Events{e})
5672
}()
5773

5874
validatorPubkey, err := k1util.PubKeyBytesToCosmos(ev.ValidatorCmpPubkey)
@@ -96,8 +112,19 @@ func (k Keeper) ProcessDeposit(ctx context.Context, ev *bindings.IPTokenStakingD
96112
}
97113

98114
val, err := k.stakingKeeper.GetValidator(cachedCtx, validatorAddr)
115+
99116
if errors.Is(err, stypes.ErrNoValidatorFound) {
100-
return errors.WrapErrWithCode(errors.ValidatorNotFound, err)
117+
isRefund = true
118+
log.Info(cachedCtx, "Validator not found, refunding deposit minus the refund fee",
119+
"val_addr", validatorAddr,
120+
"val_evm_addr", valEvmAddr.String(),
121+
"del_addr", depositorAddr.String(),
122+
"del_evm_addr", ev.Delegator.String(),
123+
)
124+
125+
refundAmount, completionTime, err = k.RefundDelegation(ctx, depositorAddr, validatorAddr, amountCoin)
126+
127+
return err // skip delegation logic
101128
} else if err != nil {
102129
return errors.Wrap(err, "get validator failed")
103130
}
@@ -115,11 +142,6 @@ func (k Keeper) ProcessDeposit(ctx context.Context, ev *bindings.IPTokenStakingD
115142
delID = stypes.FlexiblePeriodDelegationID
116143
}
117144

118-
evmstakingSKeeper, ok := k.stakingKeeper.(*skeeper.Keeper)
119-
if !ok {
120-
return errors.New("type assertion failed")
121-
}
122-
123145
// Note that, after minting, we save the mapping between delegator bech32 address and evm address, which will be used in the withdrawal queue.
124146
// The saving is done regardless of any error below, as the money is already minted and sent to the delegator, who can withdraw the minted amount.
125147
// NOTE: Do not overwrite the existing withdraw/reward address set by the delegator.
@@ -146,13 +168,25 @@ func (k Keeper) ProcessDeposit(ctx context.Context, ev *bindings.IPTokenStakingD
146168
return errors.Wrap(err, "create stake coin for depositor: send coins")
147169
}
148170

171+
return k.CreateDelegation(cachedCtx, validatorAddr.String(), depositorAddr.String(), amountCoin, delID, periodType)
172+
}
173+
174+
func (k Keeper) CreateDelegation(
175+
cachedCtx context.Context, validatorAddr, depositorAddr string, amountCoin sdk.Coin, periodDelegationID string,
176+
periodType int32,
177+
) error {
178+
evmstakingSKeeper, ok := k.stakingKeeper.(*skeeper.Keeper)
179+
if !ok {
180+
return errors.New("type assertion failed")
181+
}
182+
149183
skeeperMsgServer := skeeper.NewMsgServerImpl(evmstakingSKeeper)
150184
// Delegation by the depositor on the validator (validator existence is checked in msgServer.Delegate)
151185
msg := stypes.NewMsgDelegate(
152-
depositorAddr.String(), validatorAddr.String(), amountCoin,
153-
delID, periodType,
186+
depositorAddr, validatorAddr, amountCoin,
187+
periodDelegationID, periodType,
154188
)
155-
if _, err = skeeperMsgServer.Delegate(cachedCtx, msg); errors.Is(err, stypes.ErrDelegationBelowMinimum) {
189+
if _, err := skeeperMsgServer.Delegate(cachedCtx, msg); errors.Is(err, stypes.ErrDelegationBelowMinimum) {
156190
return errors.WrapErrWithCode(errors.InvalidDelegationAmount, err)
157191
} else if errors.Is(err, stypes.ErrNoPeriodTypeFound) {
158192
return errors.WrapErrWithCode(errors.InvalidPeriodType, err)
@@ -166,3 +200,52 @@ func (k Keeper) ProcessDeposit(ctx context.Context, ev *bindings.IPTokenStakingD
166200
func (k Keeper) ParseDepositLog(ethlog ethtypes.Log) (*bindings.IPTokenStakingDeposit, error) {
167201
return k.ipTokenStakingContract.ParseDeposit(ethlog)
168202
}
203+
204+
func (k Keeper) RefundDelegation(
205+
ctx context.Context, delegatorAddr sdk.AccAddress, validatorAddr sdk.ValAddress, rawRefundAmountCoin sdk.Coin,
206+
) (refundAmount math.Int, completionTime time.Time, err error) {
207+
sdkCtx := sdk.UnwrapSDKContext(ctx)
208+
209+
// the min refund fee amount will be `refundFeeBps * minDelegationAmount (1024) / 10_000bps`
210+
refundFeeBps, err := k.RefundFeeBps(ctx)
211+
if err != nil {
212+
return math.Int{}, time.Time{}, errors.Wrap(err, "get refund fee")
213+
}
214+
refundFeeAmount := rawRefundAmountCoin.Amount.Mul(math.NewInt(int64(refundFeeBps))).Quo(math.NewInt(10_000))
215+
refundAmount = rawRefundAmountCoin.Amount.Sub(refundFeeAmount)
216+
217+
refundPeriod, err := k.RefundPeriod(ctx)
218+
if err != nil {
219+
return math.Int{}, time.Time{}, errors.Wrap(err, "get refund period")
220+
}
221+
222+
completionTime = sdkCtx.BlockTime().Add(refundPeriod)
223+
224+
// set ubd index in mapping (needed before inserting to ubd queue)
225+
ubd, err := k.stakingKeeper.SetUnbondingDelegationEntry(
226+
ctx,
227+
delegatorAddr,
228+
validatorAddr,
229+
sdkCtx.BlockHeight(),
230+
completionTime,
231+
refundAmount,
232+
)
233+
if err != nil {
234+
return math.Int{}, time.Time{}, errors.Wrap(err, "set unbonding delegation entry")
235+
}
236+
237+
// push the refund to the unbonding queue
238+
err = k.stakingKeeper.InsertUBDQueue(ctx, ubd, completionTime)
239+
if err != nil {
240+
return math.Int{}, time.Time{}, errors.Wrap(err, "insert unbonding delegation queue")
241+
}
242+
243+
log.Debug(ctx, "Added refund to unbonding queue",
244+
"del_addr", delegatorAddr.String(),
245+
"val_addr", validatorAddr.String(),
246+
"amount", refundAmount.String(),
247+
"completion_time", completionTime,
248+
)
249+
250+
return refundAmount, completionTime, nil
251+
}

client/x/evmstaking/keeper/deposit_test.go

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,18 +39,27 @@ func (s *TestSuite) createValidator(ctx context.Context, valPubKey crypto.PubKey
3939
_ = skeeper.TestingUpdateValidator(stakingKeeper, sdkCtx, validator, true)
4040
}
4141

42+
type expectedResultDeposit struct {
43+
validatorAddr sdk.ValAddress
44+
delegatorAddr sdk.AccAddress
45+
delegation stypes.Delegation
46+
}
47+
4248
func (s *TestSuite) TestProcessDeposit() {
4349
require := s.Require()
4450
ctx, keeper, stakingKeeper := s.Ctx, s.EVMStakingKeeper, s.StakingKeeper
4551

46-
pubKeys, accAddrs, valAddrs := createAddresses(2)
52+
pubKeys, accAddrs, valAddrs := createAddresses(3)
4753
// delegator
4854
delPubKey := pubKeys[0]
4955
delAddr := accAddrs[0]
5056
// validator
5157
valPubKey := pubKeys[1]
5258
valAddr := valAddrs[1]
5359
s.createValidator(ctx, valPubKey, valAddr)
60+
// non-existing validator
61+
val2Pubkey := pubKeys[2]
62+
val2Addr := valAddrs[2]
5463

5564
createDeposit := func(delPubKey, valPubKey []byte, amount *big.Int) *bindings.IPTokenStakingDeposit {
5665
return &bindings.IPTokenStakingDeposit{
@@ -63,13 +72,25 @@ func (s *TestSuite) TestProcessDeposit() {
6372
}
6473
}
6574

75+
refundFeeBps, err := keeper.RefundFeeBps(ctx)
76+
require.NoError(err)
77+
refundPeriod, err := keeper.RefundPeriod(ctx)
78+
require.NoError(err)
79+
6680
tcs := []struct {
6781
name string
6882
settingMock func()
6983
deposit *bindings.IPTokenStakingDeposit
70-
expectedResult stypes.Delegation
71-
expectedErr string
84+
expectedResult []expectedResultDeposit
85+
expectedRefund struct {
86+
DelegatorAddress sdk.AccAddress
87+
ValidatorAddress sdk.ValAddress
88+
RefundAmount sdkmath.Int
89+
// NOTE: CompletionTime is calculated during the test if RefundAmount is greater than 0
90+
}
91+
expectedErr string
7292
}{
93+
// TODO: corrupted delegator and validator pubkey
7394
{
7495
name: "fail: invalid validator pubkey",
7596
deposit: &bindings.IPTokenStakingDeposit{
@@ -82,25 +103,54 @@ func (s *TestSuite) TestProcessDeposit() {
82103
},
83104
expectedErr: "validator pubkey to cosmos: invalid pubkey length",
84105
},
85-
// TODO: corrupted delegator and validator pubkey
106+
{
107+
name: "pass: validator not found, refund deposit",
108+
deposit: &bindings.IPTokenStakingDeposit{
109+
Delegator: common.Address(delAddr),
110+
ValidatorCmpPubkey: val2Pubkey.Bytes(),
111+
StakeAmount: new(big.Int).SetUint64(1024),
112+
StakingPeriod: big.NewInt(0),
113+
},
114+
expectedRefund: struct {
115+
DelegatorAddress sdk.AccAddress
116+
ValidatorAddress sdk.ValAddress
117+
RefundAmount sdkmath.Int
118+
}{
119+
DelegatorAddress: delAddr,
120+
ValidatorAddress: val2Addr,
121+
RefundAmount: sdkmath.NewInt(1024).Sub(sdkmath.NewInt(1024).Mul(sdkmath.NewInt(int64(refundFeeBps))).Quo(sdkmath.NewInt(10_000))),
122+
},
123+
},
86124
{
87125
name: "pass: existing delegator",
88126
deposit: createDeposit(delPubKey.Bytes(), valPubKey.Bytes(), new(big.Int).SetUint64(1)),
89-
expectedResult: stypes.Delegation{
90-
DelegatorAddress: delAddr.String(),
91-
ValidatorAddress: valAddr.String(),
92-
Shares: sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(1)),
93-
RewardsShares: sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(1)).Quo(sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(2))),
127+
expectedResult: []expectedResultDeposit{
128+
{
129+
validatorAddr: valAddr,
130+
delegatorAddr: delAddr,
131+
delegation: stypes.Delegation{
132+
DelegatorAddress: delAddr.String(),
133+
ValidatorAddress: valAddr.String(),
134+
Shares: sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(1)),
135+
RewardsShares: sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(1)).Quo(sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(2))),
136+
},
137+
},
94138
},
95139
},
96140
{
97141
name: "pass: new delegator",
98142
deposit: createDeposit(delPubKey.Bytes(), valPubKey.Bytes(), new(big.Int).SetUint64(1)),
99-
expectedResult: stypes.Delegation{
100-
DelegatorAddress: delAddr.String(),
101-
ValidatorAddress: valAddr.String(),
102-
Shares: sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(1)),
103-
RewardsShares: sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(1)).Quo(sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(2))),
143+
expectedResult: []expectedResultDeposit{
144+
{
145+
validatorAddr: valAddr,
146+
delegatorAddr: delAddr,
147+
delegation: stypes.Delegation{
148+
DelegatorAddress: delAddr.String(),
149+
ValidatorAddress: valAddr.String(),
150+
Shares: sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(1)),
151+
RewardsShares: sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(1)).Quo(sdkmath.LegacyNewDecFromInt(sdkmath.NewInt(2))),
152+
},
153+
},
104154
},
105155
},
106156
}
@@ -117,9 +167,27 @@ func (s *TestSuite) TestProcessDeposit() {
117167
} else {
118168
require.NoError(err)
119169
// check delegation
120-
delegation, err := stakingKeeper.GetDelegation(cachedCtx, delAddr, valAddr)
121-
require.NoError(err)
122-
require.Equal(tc.expectedResult, delegation)
170+
if len(tc.expectedResult) > 0 {
171+
for _, tc := range tc.expectedResult {
172+
delegation, err := stakingKeeper.GetDelegation(cachedCtx, tc.delegatorAddr, tc.validatorAddr)
173+
require.NoError(err)
174+
require.Equal(tc.delegation, delegation)
175+
}
176+
}
177+
178+
// check refund in unbonding queue
179+
if tc.expectedRefund.DelegatorAddress != nil {
180+
ubd, err := stakingKeeper.GetUnbondingDelegation(cachedCtx, tc.expectedRefund.DelegatorAddress, tc.expectedRefund.ValidatorAddress)
181+
require.NoError(err)
182+
require.Equal(tc.expectedRefund.ValidatorAddress.String(), ubd.ValidatorAddress)
183+
require.Equal(tc.expectedRefund.DelegatorAddress.String(), ubd.DelegatorAddress)
184+
185+
completionTime := ctx.BlockTime().Add(refundPeriod)
186+
for _, entry := range ubd.Entries {
187+
require.True(tc.expectedRefund.RefundAmount.Equal(sdkmath.NewIntFromBigInt(entry.Balance.BigInt())))
188+
require.Equal(completionTime, entry.CompletionTime)
189+
}
190+
}
123191
}
124192
})
125193
}

client/x/evmstaking/keeper/params.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package keeper
22

33
import (
44
"context"
5+
"time"
56

67
sdk "github.com/cosmos/cosmos-sdk/types"
78

@@ -36,6 +37,24 @@ func (k Keeper) MinPartialWithdrawalAmount(ctx context.Context) (uint64, error)
3637
return params.MinPartialWithdrawalAmount, nil
3738
}
3839

40+
func (k Keeper) RefundFeeBps(ctx context.Context) (uint32, error) {
41+
params, err := k.GetParams(ctx)
42+
if err != nil {
43+
return 0, err
44+
}
45+
46+
return params.RefundFeeBps, nil
47+
}
48+
49+
func (k Keeper) RefundPeriod(ctx context.Context) (time.Duration, error) {
50+
params, err := k.GetParams(ctx)
51+
if err != nil {
52+
return 0, err
53+
}
54+
55+
return params.RefundPeriod, nil
56+
}
57+
3958
// This method performs no validation of the parameters.
4059
func (k Keeper) SetParams(ctx context.Context, params types.Params) error {
4160
store := k.storeService.OpenKVStore(ctx)

0 commit comments

Comments
 (0)