From 4225ead0f037278cd5972f5df9981ff9e0776d27 Mon Sep 17 00:00:00 2001 From: Max <82761650+MaxMustermann2@users.noreply.github.com> Date: Tue, 14 Jan 2025 12:36:30 +0530 Subject: [PATCH] feat(delegation): auto associate when possible (#284) * feat(delegation): auto associate when possible Whenever the asset bearing `ExocoreAssetID` is delegated by a staker to an operator, we should check if the staker address is the same as the operator address. If a match is found, they should be automatically associated. * doc(delegation): update comment about duplicate - It is not possible to overwrite an association of the `stakerID` ending with `0x0` because such an association cannot be made using any other mechanism. - Even if it exists, the overwriting will do nothing of import due to the equality check. * test(delegation): check operator share auto assoc Validate that the operator share is correctly set to the expected value when self-staking ExocoreAssetID * doc(assets): clarify StakerAssetInfo - for ExocoreAssetID, the `TotalDepositAmount` is post-slashing, if any was applied. - for other asset IDs, all components are pre-slashing. --- testutil/fund.go | 12 ++-- x/assets/keeper/staker_asset.go | 12 +++- x/delegation/keeper/delegation.go | 20 ++++++ x/delegation/keeper/delegation_op_test.go | 78 ++++++++++++++++++++--- 4 files changed, 106 insertions(+), 16 deletions(-) diff --git a/testutil/fund.go b/testutil/fund.go index 5ac683806..fd73536ab 100644 --- a/testutil/fund.go +++ b/testutil/fund.go @@ -2,20 +2,20 @@ package testutil import ( "cosmossdk.io/math" + "github.com/ExocoreNetwork/exocore/utils" + exominttypes "github.com/ExocoreNetwork/exocore/x/exomint/types" sdk "github.com/cosmos/cosmos-sdk/types" bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" - "github.com/evmos/evmos/v16/utils" - inflationtypes "github.com/evmos/evmos/v16/x/inflation/v1/types" ) // FundAccount is a utility function that funds an account by minting and // sending the coins to the address. func FundAccount(ctx sdk.Context, bankKeeper bankkeeper.Keeper, addr sdk.AccAddress, amounts sdk.Coins) error { - if err := bankKeeper.MintCoins(ctx, inflationtypes.ModuleName, amounts); err != nil { + if err := bankKeeper.MintCoins(ctx, exominttypes.ModuleName, amounts); err != nil { return err } - return bankKeeper.SendCoinsFromModuleToAccount(ctx, inflationtypes.ModuleName, addr, amounts) + return bankKeeper.SendCoinsFromModuleToAccount(ctx, exominttypes.ModuleName, addr, amounts) } // FundAccountWithBaseDenom is a utility function that uses the FundAccount function @@ -30,9 +30,9 @@ func FundAccountWithBaseDenom(ctx sdk.Context, bankKeeper bankkeeper.Keeper, add // FundModuleAccount is a utility function that funds a module account by // minting and sending the coins to the address. func FundModuleAccount(ctx sdk.Context, bankKeeper bankkeeper.Keeper, recipientMod string, amounts sdk.Coins) error { - if err := bankKeeper.MintCoins(ctx, inflationtypes.ModuleName, amounts); err != nil { + if err := bankKeeper.MintCoins(ctx, exominttypes.ModuleName, amounts); err != nil { return err } - return bankKeeper.SendCoinsFromModuleToModule(ctx, inflationtypes.ModuleName, recipientMod, amounts) + return bankKeeper.SendCoinsFromModuleToModule(ctx, exominttypes.ModuleName, recipientMod, amounts) } diff --git a/x/assets/keeper/staker_asset.go b/x/assets/keeper/staker_asset.go index 81648602d..c68ec013f 100644 --- a/x/assets/keeper/staker_asset.go +++ b/x/assets/keeper/staker_asset.go @@ -104,10 +104,14 @@ func (k Keeper) GetStakerSpecifiedAssetInfo(ctx sdk.Context, stakerID string, as if err != nil { return nil, errorsmod.Wrap(err, "failed to GetOperatorSpecifiedAssetInfo") } + // the `undelegatableTokens` are currently delegated tokens. they are post-slashing, if any is applied. + // this is because slashing is applied to an operator's total amount, of which, the share of a staker is kept + // unchanged. undelegatableTokens, err := delegationkeeper.TokensFromShares(record.UndelegatableShare, operatorAssetInfo.TotalShare, operatorAssetInfo.TotalAmount) if err != nil { return nil, errorsmod.Wrap(err, "failed to get shares from token") } + // this amount is post-slashing, as explained above. info.TotalDepositAmount = info.TotalDepositAmount.Add(undelegatableTokens).Add(record.WaitUndelegationAmount) info.PendingUndelegationAmount = info.PendingUndelegationAmount.Add(record.WaitUndelegationAmount) } @@ -119,7 +123,13 @@ func (k Keeper) GetStakerSpecifiedAssetInfo(ctx sdk.Context, stakerID string, as if value == nil { return nil, errorsmod.Wrap(assetstype.ErrNoStakerAssetKey, fmt.Sprintf("the key is:%s", key)) } - + // when there is a slashing, we do not modify `StakerAssetInfo`. + // hence, all the amounts below are pre-slashing. however, when + // an undelegation is matured, the post-slashing amount is added + // to the withdrawable amount and the pre-slashed amount is removed + // from the amount pending undelegation. + // if a staker were to exit the system, they would leave behind + // `TotalDepositAmount` == lifetime slashing amount. ret := assetstype.StakerAssetInfo{} k.cdc.MustUnmarshal(value, &ret) return &ret, nil diff --git a/x/delegation/keeper/delegation.go b/x/delegation/keeper/delegation.go index 4e9240bd8..ac0081420 100644 --- a/x/delegation/keeper/delegation.go +++ b/x/delegation/keeper/delegation.go @@ -1,6 +1,7 @@ package keeper import ( + "bytes" "fmt" errorsmod "cosmossdk.io/errors" @@ -62,6 +63,20 @@ func (k *Keeper) delegateTo( if err := k.bankKeeper.DelegateCoinsFromAccountToModule(ctx, params.StakerAddress, delegationtype.DelegatedPoolName, coins); err != nil { return err } + // auto associate it, if there is a match. note that both are byte versions of bech32 + // AccAddress. there is no need to check for an existing association because: + // (1) at this point, the `params.ClientChainID` is 0 and such a `stakerID` ending with + // this clientChainID can not be associated with an operator using the standard + // precompile method due to the `ClientChainExists` check. + // (2) an existing association will be overwritten by the exact same association due to + // the equality check below. + if bytes.Equal(params.StakerAddress, params.OperatorAddress[:]) { + // always returns nil. + err := k.SetAssociatedOperator(ctx, stakerID, params.OperatorAddress.String()) + if err != nil { + return err + } + } } // calculate the share from the delegation amount share, err := k.CalculateShare(ctx, params.OperatorAddress, assetID, params.OpAmount) @@ -149,6 +164,11 @@ func (k *Keeper) UndelegateFrom(ctx sdk.Context, params *delegationtype.Delegati } r.CompletedEpochIdentifier = completedEpochID r.CompletedEpochNumber = completedEpochNumber + // the hold count is relevant to async AVSs instead of sync AVSs. for example, the dogfood AVS is sync since it + // runs only on this chain. meanwhile, x/appchain-based AVSs are async because of the IBC's in-built communication + // lag. the hold count is used to ensure that the undelegation is not processed until the AVS has completed its + // unbonding period. + // TODO: remove the hold count increment for x/dogfood AVS. err = k.SetUndelegationRecords(ctx, false, []delegationtype.UndelegationAndHoldCount{ { Undelegation: &r, diff --git a/x/delegation/keeper/delegation_op_test.go b/x/delegation/keeper/delegation_op_test.go index 97633f83e..c74567f2c 100644 --- a/x/delegation/keeper/delegation_op_test.go +++ b/x/delegation/keeper/delegation_op_test.go @@ -2,16 +2,18 @@ package keeper_test import ( "fmt" - epochtypes "github.com/ExocoreNetwork/exocore/x/epochs/types" + "math" "time" + "github.com/ExocoreNetwork/exocore/testutil" + epochtypes "github.com/ExocoreNetwork/exocore/x/epochs/types" + utiltx "github.com/ExocoreNetwork/exocore/testutil/tx" avstypes "github.com/ExocoreNetwork/exocore/x/avs/types" errorsmod "cosmossdk.io/errors" sdkmath "cosmossdk.io/math" assetskeeper "github.com/ExocoreNetwork/exocore/x/assets/keeper" - assetstypes "github.com/ExocoreNetwork/exocore/x/assets/types" "github.com/ExocoreNetwork/exocore/x/assets/types" delegationtype "github.com/ExocoreNetwork/exocore/x/delegation/types" @@ -100,9 +102,9 @@ func (suite *DelegationTestSuite) prepareOptingInDogfood(assetID string) (sdkmat func (suite *DelegationTestSuite) prepareDelegationNativeToken() *delegationtype.DelegationOrUndelegationParams { delegationEvent := &delegationtype.DelegationOrUndelegationParams{ - ClientChainID: assetstypes.ExocoreChainLzID, + ClientChainID: types.ExocoreChainLzID, Action: types.DelegateTo, - AssetsAddress: common.HexToAddress(assetstypes.ExocoreAssetAddr).Bytes(), + AssetsAddress: common.HexToAddress(types.ExocoreAssetAddr).Bytes(), OperatorAddress: suite.opAccAddr, StakerAddress: suite.accAddr[:], OpAmount: suite.delegationAmount, @@ -174,9 +176,9 @@ func (suite *DelegationTestSuite) TestDelegateTo() { // delegate exocore-native-token delegationParams = &delegationtype.DelegationOrUndelegationParams{ - ClientChainID: assetstypes.ExocoreChainLzID, + ClientChainID: types.ExocoreChainLzID, Action: types.DelegateTo, - AssetsAddress: common.HexToAddress(assetstypes.ExocoreAssetAddr).Bytes(), + AssetsAddress: common.HexToAddress(types.ExocoreAssetAddr).Bytes(), OperatorAddress: opAccAddr, StakerAddress: suite.accAddr[:], OpAmount: sdkmath.NewInt(50), @@ -188,7 +190,7 @@ func (suite *DelegationTestSuite) TestDelegateTo() { stakerID, assetID = types.GetStakerIDAndAssetID(delegationParams.ClientChainID, delegationParams.StakerAddress, delegationParams.AssetsAddress) restakerState, err = suite.App.AssetsKeeper.GetStakerSpecifiedAssetInfo(suite.Ctx, stakerID, assetID) suite.NoError(err) - balance := suite.App.BankKeeper.GetBalance(suite.Ctx, suite.accAddr, assetstypes.ExocoreAssetDenom) + balance := suite.App.BankKeeper.GetBalance(suite.Ctx, suite.accAddr, types.ExocoreAssetDenom) suite.Equal(types.StakerAssetInfo{ TotalDepositAmount: balance.Amount.Add(delegationParams.OpAmount), WithdrawableAmount: balance.Amount, @@ -215,6 +217,64 @@ func (suite *DelegationTestSuite) TestDelegateTo() { suite.Equal(delegationParams.OpAmount, totalDelegationAmount) } +func (suite *DelegationTestSuite) TestAutoAssociate() { + genAddr := utiltx.GenerateAddress() + opAccAddr := sdk.AccAddress(genAddr[:]) + + registerReq := &operatortype.RegisterOperatorReq{ + FromAddress: opAccAddr.String(), + Info: &operatortype.OperatorInfo{ + EarningsAddr: opAccAddr.String(), + }, + } + _, err := s.OperatorMsgServer.RegisterOperator(s.Ctx, registerReq) + suite.NoError(err) + + // self delegate exocore-native-token + err = testutil.FundAccountWithBaseDenom( + suite.Ctx, suite.App.BankKeeper, opAccAddr, math.MaxInt64, + ) + suite.NoError(err) + delegationParams := &delegationtype.DelegationOrUndelegationParams{ + ClientChainID: types.ExocoreChainLzID, + Action: types.DelegateTo, + AssetsAddress: common.HexToAddress(types.ExocoreAssetAddr).Bytes(), + OperatorAddress: opAccAddr, + StakerAddress: opAccAddr, + OpAmount: sdkmath.NewInt(50), + TxHash: common.HexToHash("0x24c4a315d757249c12a7a1d7b6fb96261d49deee26f06a3e1787d008b445c3ac"), + } + err = suite.App.DelegationKeeper.DelegateTo(suite.Ctx, delegationParams) + suite.NoError(err) + stakerID, assetID := types.GetStakerIDAndAssetID(delegationParams.ClientChainID, delegationParams.StakerAddress, delegationParams.AssetsAddress) + operator, err := suite.App.DelegationKeeper.GetAssociatedOperator(suite.Ctx, stakerID) + suite.NoError(err) + suite.Equal(opAccAddr.String(), operator) + + // check state + balance := suite.App.BankKeeper.GetBalance(suite.Ctx, opAccAddr, types.ExocoreAssetDenom) + restakerState, err := suite.App.AssetsKeeper.GetStakerSpecifiedAssetInfo(suite.Ctx, stakerID, assetID) + suite.NoError(err) + suite.Equal( + types.StakerAssetInfo{ + TotalDepositAmount: balance.Amount.Add(delegationParams.OpAmount), + WithdrawableAmount: balance.Amount, + PendingUndelegationAmount: sdkmath.ZeroInt(), + }, *restakerState, + ) + + // ensure operator share is credited + operatorState, err := suite.App.AssetsKeeper.GetOperatorSpecifiedAssetInfo(suite.Ctx, opAccAddr, assetID) + suite.NoError(err) + suite.Equal(types.OperatorAssetInfo{ + TotalAmount: delegationParams.OpAmount, + PendingUndelegationAmount: sdkmath.ZeroInt(), + TotalShare: sdkmath.LegacyNewDecFromBigInt(delegationParams.OpAmount.BigInt()), + OperatorShare: sdkmath.LegacyNewDecFromBigInt(delegationParams.OpAmount.BigInt()), + }, *operatorState) + +} + func (suite *DelegationTestSuite) TestUndelegateFrom() { suite.basicPrepare() suite.prepareDeposit(suite.depositAmount) @@ -284,7 +344,7 @@ func (suite *DelegationTestSuite) TestUndelegateFrom() { stakerID, assetID = types.GetStakerIDAndAssetID(delegationEvent.ClientChainID, delegationEvent.StakerAddress, delegationEvent.AssetsAddress) restakerState, err = suite.App.AssetsKeeper.GetStakerSpecifiedAssetInfo(suite.Ctx, stakerID, assetID) suite.NoError(err) - balance := suite.App.BankKeeper.GetBalance(suite.Ctx, suite.accAddr, assetstypes.ExocoreAssetDenom) + balance := suite.App.BankKeeper.GetBalance(suite.Ctx, suite.accAddr, types.ExocoreAssetDenom) suite.Equal(types.StakerAssetInfo{ TotalDepositAmount: balance.Amount.Add(delegationEvent.OpAmount), WithdrawableAmount: balance.Amount, @@ -436,7 +496,7 @@ func (suite *DelegationTestSuite) TestCompleteUndelegation() { restakerState, err = suite.App.AssetsKeeper.GetStakerSpecifiedAssetInfo(suite.Ctx, stakerID, assetID) suite.NoError(err) - balance := suite.App.BankKeeper.GetBalance(suite.Ctx, suite.accAddr, assetstypes.ExocoreAssetDenom) + balance := suite.App.BankKeeper.GetBalance(suite.Ctx, suite.accAddr, types.ExocoreAssetDenom) suite.Equal(types.StakerAssetInfo{ TotalDepositAmount: balance.Amount, WithdrawableAmount: balance.Amount,