package lockup import ( "bytes" "context" "errors" "time" "github.com/cosmos/gogoproto/proto" "cosmossdk.io/collections" collcodec "cosmossdk.io/collections/codec" "cosmossdk.io/core/address" "cosmossdk.io/core/header" errorsmod "cosmossdk.io/errors" "cosmossdk.io/math" "cosmossdk.io/x/accounts/accountstd" lockuptypes "cosmossdk.io/x/accounts/defaults/lockup/v1" banktypes "cosmossdk.io/x/bank/types" distrtypes "cosmossdk.io/x/distribution/types" stakingtypes "cosmossdk.io/x/staking/types" "github.com/cosmos/cosmos-sdk/codec" codectypes "github.com/cosmos/cosmos-sdk/codec/types" sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" ) var ( OriginalLockingPrefix = collections.NewPrefix(0) DelegatedFreePrefix = collections.NewPrefix(1) DelegatedLockingPrefix = collections.NewPrefix(2) EndTimePrefix = collections.NewPrefix(3) StartTimePrefix = collections.NewPrefix(4) LockingPeriodsPrefix = collections.NewPrefix(5) OwnerPrefix = collections.NewPrefix(6) UnbondEntriesPrefix = collections.NewPrefix(7) ) var ( CONTINUOUS_LOCKING_ACCOUNT = "continuous-locking-account" DELAYED_LOCKING_ACCOUNT = "delayed-locking-account" PERIODIC_LOCKING_ACCOUNT = "periodic-locking-account" PERMANENT_LOCKING_ACCOUNT = "permanent-locking-account" ) type getLockedCoinsFunc = func(ctx context.Context, time time.Time, denoms ...string) (sdk.Coins, error) // newBaseLockup creates a new BaseLockup object. func newBaseLockup(d accountstd.Dependencies) *BaseLockup { BaseLockup := &BaseLockup{ Owner: collections.NewItem(d.SchemaBuilder, OwnerPrefix, "owner", collections.BytesValue), OriginalLocking: collections.NewMap(d.SchemaBuilder, OriginalLockingPrefix, "original_locking", collections.StringKey, sdk.IntValue), DelegatedFree: collections.NewMap(d.SchemaBuilder, DelegatedFreePrefix, "delegated_free", collections.StringKey, sdk.IntValue), DelegatedLocking: collections.NewMap(d.SchemaBuilder, DelegatedLockingPrefix, "delegated_locking", collections.StringKey, sdk.IntValue), UnbondEntries: collections.NewMap(d.SchemaBuilder, UnbondEntriesPrefix, "unbond_entries", collections.StringKey, codec.CollValue[lockuptypes.UnbondingEntries](d.LegacyStateCodec)), addressCodec: d.AddressCodec, headerService: d.Environment.HeaderService, EndTime: collections.NewItem(d.SchemaBuilder, EndTimePrefix, "end_time", collcodec.KeyToValueCodec[time.Time](sdk.TimeKey)), } return BaseLockup } type BaseLockup struct { // Owner is the address of the account owner. Owner collections.Item[[]byte] OriginalLocking collections.Map[string, math.Int] DelegatedFree collections.Map[string, math.Int] DelegatedLocking collections.Map[string, math.Int] // map val address to unbonding entries UnbondEntries collections.Map[string, lockuptypes.UnbondingEntries] addressCodec address.Codec headerService header.Service // lockup end time. EndTime collections.Item[time.Time] } func (bva *BaseLockup) Init(ctx context.Context, msg *lockuptypes.MsgInitLockupAccount) ( *lockuptypes.MsgInitLockupAccountResponse, error, ) { owner, err := bva.addressCodec.StringToBytes(msg.Owner) if err != nil { return nil, sdkerrors.ErrInvalidAddress.Wrapf("invalid 'owner' address: %s", err) } err = bva.Owner.Set(ctx, owner) if err != nil { return nil, err } funds := accountstd.Funds(ctx) sortedAmt := funds.Sort() for _, coin := range sortedAmt { err = bva.OriginalLocking.Set(ctx, coin.Denom, coin.Amount) if err != nil { return nil, err } } bondDenom, err := getStakingDenom(ctx) if err != nil { return nil, err } // Set initial value for all locked token err = bva.DelegatedFree.Set(ctx, bondDenom, math.ZeroInt()) if err != nil { return nil, err } // Set initial value for all locked token err = bva.DelegatedLocking.Set(ctx, bondDenom, math.ZeroInt()) if err != nil { return nil, err } err = bva.EndTime.Set(ctx, msg.EndTime) if err != nil { return nil, err } return &lockuptypes.MsgInitLockupAccountResponse{}, nil } func (bva *BaseLockup) Delegate( ctx context.Context, msg *lockuptypes.MsgDelegate, getLockedCoinsFunc getLockedCoinsFunc, ) ( *lockuptypes.MsgExecuteMessagesResponse, error, ) { err := bva.checkSender(ctx, msg.Sender) if err != nil { return nil, err } whoami := accountstd.Whoami(ctx) delegatorAddress, err := bva.addressCodec.BytesToString(whoami) if err != nil { return nil, err } hs := bva.headerService.HeaderInfo(ctx) balance, err := bva.getBalance(ctx, delegatorAddress, msg.Amount.Denom) if err != nil { return nil, err } lockedCoins, err := getLockedCoinsFunc(ctx, hs.Time, msg.Amount.Denom) if err != nil { return nil, err } // refresh ubd entries to make sure delegation locking amount is up to date err = bva.checkUnbondingEntriesMature(ctx) if err != nil { return nil, err } err = bva.TrackDelegation( ctx, sdk.Coins{*balance}, lockedCoins, sdk.Coins{msg.Amount}, ) if err != nil { return nil, err } msgDelegate := &stakingtypes.MsgDelegate{ DelegatorAddress: delegatorAddress, ValidatorAddress: msg.ValidatorAddress, Amount: msg.Amount, } resp, err := sendMessage(ctx, msgDelegate) if err != nil { return nil, err } return &lockuptypes.MsgExecuteMessagesResponse{Responses: resp}, nil } func (bva *BaseLockup) Undelegate( ctx context.Context, msg *lockuptypes.MsgUndelegate, ) ( *lockuptypes.MsgExecuteMessagesResponse, error, ) { err := bva.checkSender(ctx, msg.Sender) if err != nil { return nil, err } whoami := accountstd.Whoami(ctx) delegatorAddress, err := bva.addressCodec.BytesToString(whoami) if err != nil { return nil, err } msgUndelegate := &stakingtypes.MsgUndelegate{ DelegatorAddress: delegatorAddress, ValidatorAddress: msg.ValidatorAddress, Amount: msg.Amount, } resp, err := sendMessage(ctx, msgUndelegate) if err != nil { return nil, err } header := bva.headerService.HeaderInfo(ctx) msgUndelegateResp, err := accountstd.UnpackAny[stakingtypes.MsgUndelegateResponse](resp[0]) if err != nil { return nil, err } isNewEntry := true entries, err := bva.UnbondEntries.Get(ctx, msg.ValidatorAddress) if err != nil { if errorsmod.IsOf(err, collections.ErrNotFound) { entries = lockuptypes.UnbondingEntries{ Entries: []*lockuptypes.UnbondingEntry{}, } } else { return nil, err } } for i, entry := range entries.Entries { if entry.CreationHeight == header.Height && entry.EndTime.Equal(msgUndelegateResp.CompletionTime) { entry.Amount = entry.Amount.Add(msg.Amount) // update the entry entries.Entries[i] = entry isNewEntry = false break } } if isNewEntry { entries.Entries = append(entries.Entries, &lockuptypes.UnbondingEntry{ EndTime: msgUndelegateResp.CompletionTime, Amount: msgUndelegateResp.Amount, ValidatorAddress: msg.ValidatorAddress, CreationHeight: header.Height, }) } err = bva.UnbondEntries.Set(ctx, msg.ValidatorAddress, entries) if err != nil { return nil, err } return &lockuptypes.MsgExecuteMessagesResponse{Responses: resp}, nil } func (bva *BaseLockup) WithdrawReward( ctx context.Context, msg *lockuptypes.MsgWithdrawReward, ) ( *lockuptypes.MsgExecuteMessagesResponse, error, ) { err := bva.checkSender(ctx, msg.Sender) if err != nil { return nil, err } whoami := accountstd.Whoami(ctx) delegatorAddress, err := bva.addressCodec.BytesToString(whoami) if err != nil { return nil, err } msgWithdraw := &distrtypes.MsgWithdrawDelegatorReward{ DelegatorAddress: delegatorAddress, ValidatorAddress: msg.ValidatorAddress, } responses, err := sendMessage(ctx, msgWithdraw) if err != nil { return nil, err } return &lockuptypes.MsgExecuteMessagesResponse{Responses: responses}, nil } func (bva *BaseLockup) SendCoins( ctx context.Context, msg *lockuptypes.MsgSend, getLockedCoinsFunc getLockedCoinsFunc, ) ( *lockuptypes.MsgExecuteMessagesResponse, error, ) { err := bva.checkSender(ctx, msg.Sender) if err != nil { return nil, err } whoami := accountstd.Whoami(ctx) fromAddress, err := bva.addressCodec.BytesToString(whoami) if err != nil { return nil, err } hs := bva.headerService.HeaderInfo(ctx) if err := msg.Amount.Validate(); err != nil { return nil, err } lockedCoins, err := getLockedCoinsFunc(ctx, hs.Time, msg.Amount.Denoms()...) if err != nil { return nil, err } err = bva.checkTokensSendable(ctx, fromAddress, msg.Amount, lockedCoins) if err != nil { return nil, err } msgSend := &banktypes.MsgSend{ FromAddress: fromAddress, ToAddress: msg.ToAddress, Amount: msg.Amount, } resp, err := sendMessage(ctx, msgSend) if err != nil { return nil, err } return &lockuptypes.MsgExecuteMessagesResponse{Responses: resp}, nil } func (bva *BaseLockup) checkSender(ctx context.Context, sender string) error { owner, err := bva.Owner.Get(ctx) if err != nil { return sdkerrors.ErrInvalidAddress.Wrapf("invalid owner address: %s", err.Error()) } senderBytes, err := bva.addressCodec.StringToBytes(sender) if err != nil { return sdkerrors.ErrInvalidAddress.Wrapf("invalid sender address: %s", err.Error()) } if !bytes.Equal(owner, senderBytes) { return errors.New("sender is not the owner of this vesting account") } return nil } func sendMessage(ctx context.Context, msg proto.Message) ([]*codectypes.Any, error) { asAny, err := accountstd.PackAny(msg) if err != nil { return nil, err } return accountstd.ExecModuleAnys(ctx, []*codectypes.Any{asAny}) } func getStakingDenom(ctx context.Context) (string, error) { // Query account balance for the sent denom resp, err := accountstd.QueryModule[*stakingtypes.QueryParamsResponse](ctx, &stakingtypes.QueryParamsRequest{}) if err != nil { return "", err } return resp.Params.BondDenom, nil } // checkUnbondingEntriesMature iterates through all the unbonding entries and check if any of the entries are matured and handled. func (bva *BaseLockup) checkUnbondingEntriesMature(ctx context.Context) error { whoami := accountstd.Whoami(ctx) delAddr, err := bva.addressCodec.BytesToString(whoami) if err != nil { return err } currentTime := bva.headerService.HeaderInfo(ctx).Time removeKeys := []string{} err = bva.UnbondEntries.Walk(ctx, nil, func(key string, value lockuptypes.UnbondingEntries) (stop bool, err error) { for i := 0; i < len(value.Entries); i++ { entry := value.Entries[i] // if not mature then skip if entry.EndTime.After(currentTime) { return false, nil } stakingUnbonding, err := bva.getUnbondingEntries(ctx, delAddr, key) if err != nil { // if ubd delegation is empty then skip the next iteration check if !errorsmod.IsOf(err, stakingtypes.ErrNoUnbondingDelegation) { return true, err } } found := false // check if the entry is still exist in the unbonding entries for _, e := range stakingUnbonding { if e.CompletionTime.Equal(entry.EndTime) && e.CreationHeight == entry.CreationHeight { found = true break } } // if not found or ubd delegation is empty then assume ubd entry is being handled if !found { err = bva.TrackUndelegation(ctx, sdk.NewCoins(entry.Amount)) if err != nil { return true, err } // remove entry value.Entries = append(value.Entries[:i], value.Entries[i+1:]...) i-- } } if len(value.Entries) == 0 { removeKeys = append(removeKeys, key) } else { err = bva.UnbondEntries.Set(ctx, key, value) if err != nil { return true, err } } return false, nil }) for _, key := range removeKeys { err = bva.UnbondEntries.Remove(ctx, key) if err != nil { return err } } return err } // TrackDelegation tracks a delegation amount for any given lockup account type // given the amount of coins currently being locked and the current account balance // of the delegation denominations. // // CONTRACT: The account's coins, delegation coins, locked coins, and delegated // locking coins must be sorted. func (bva *BaseLockup) TrackDelegation( ctx context.Context, balance, lockedCoins, amount sdk.Coins, ) error { bondDenom, err := getStakingDenom(ctx) if err != nil { return err } delAmt := amount.AmountOf(bondDenom) baseAmt := balance.AmountOf(bondDenom) // return error if the delegation amount is zero or if the base coins does not // exceed the desired delegation amount. if delAmt.IsZero() || baseAmt.LT(delAmt) { return sdkerrors.ErrInvalidCoins.Wrap("delegation attempt with zero coins for staking denom or insufficient funds") } lockedAmt := lockedCoins.AmountOf(bondDenom) delLockingAmt, err := bva.DelegatedLocking.Get(ctx, bondDenom) if err != nil { return err } delFreeAmt, err := bva.DelegatedFree.Get(ctx, bondDenom) if err != nil { return err } // compute x and y per the specification, where: // X := min(max(V - DV, 0), D) // Y := D - X x := math.MinInt(math.MaxInt(lockedAmt.Sub(delLockingAmt), math.ZeroInt()), delAmt) y := delAmt.Sub(x) delLockingCoin := sdk.NewCoin(bondDenom, delLockingAmt) delFreeCoin := sdk.NewCoin(bondDenom, delFreeAmt) if !x.IsZero() { xCoin := sdk.NewCoin(bondDenom, x) newDelLocking := delLockingCoin.Add(xCoin) err = bva.DelegatedLocking.Set(ctx, bondDenom, newDelLocking.Amount) if err != nil { return err } } if !y.IsZero() { yCoin := sdk.NewCoin(bondDenom, y) newDelFree := delFreeCoin.Add(yCoin) err = bva.DelegatedFree.Set(ctx, bondDenom, newDelFree.Amount) if err != nil { return err } } return nil } // TrackUndelegation tracks an undelegation amount by setting the necessary // values by which delegated locking and delegated free need to decrease and // by which amount the base coins need to increase. // // NOTE: The undelegation (bond refund) amount may exceed the delegated // locking (bond) amount due to the way undelegation truncates the bond refund, // which can increase the validator's exchange rate (tokens/shares) slightly if // the undelegated tokens are non-integral. // // CONTRACT: The account's coins and undelegation coins must be sorted. func (bva *BaseLockup) TrackUndelegation(ctx context.Context, amount sdk.Coins) error { bondDenom, err := getStakingDenom(ctx) if err != nil { return err } delAmt := amount.AmountOf(bondDenom) // return error if the undelegation amount is zero if delAmt.IsZero() { return sdkerrors.ErrInvalidCoins.Wrap("undelegation attempt with zero coins for staking denom") } delFreeAmt, err := bva.DelegatedFree.Get(ctx, bondDenom) if err != nil { return err } delLockingAmt, err := bva.DelegatedLocking.Get(ctx, bondDenom) if err != nil { return err } // compute x and y per the specification, where: // X := min(DF, D) // Y := min(DV, D - X) x := math.MinInt(delFreeAmt, delAmt) y := math.MinInt(delLockingAmt, delAmt.Sub(x)) delLockingCoin := sdk.NewCoin(bondDenom, delLockingAmt) delFreeCoin := sdk.NewCoin(bondDenom, delFreeAmt) if !x.IsZero() { xCoin := sdk.NewCoin(bondDenom, x) newDelFree := delFreeCoin.Sub(xCoin) err = bva.DelegatedFree.Set(ctx, bondDenom, newDelFree.Amount) if err != nil { return err } } if !y.IsZero() { yCoin := sdk.NewCoin(bondDenom, y) newDelLocking := delLockingCoin.Sub(yCoin) err = bva.DelegatedLocking.Set(ctx, bondDenom, newDelLocking.Amount) if err != nil { return err } } return nil } func (bva BaseLockup) getBalance(ctx context.Context, sender, denom string) (*sdk.Coin, error) { // Query account balance for the sent denom resp, err := accountstd.QueryModule[*banktypes.QueryBalanceResponse](ctx, &banktypes.QueryBalanceRequest{Address: sender, Denom: denom}) if err != nil { return nil, err } return resp.Balance, nil } func (bva BaseLockup) getUnbondingEntries(ctx context.Context, delAddr, valAddr string) ([]stakingtypes.UnbondingDelegationEntry, error) { resp, err := accountstd.QueryModule[*stakingtypes.QueryUnbondingDelegationResponse]( ctx, &stakingtypes.QueryUnbondingDelegationRequest{DelegatorAddr: delAddr, ValidatorAddr: valAddr}, ) if err != nil { return nil, err } return resp.Unbond.Entries, nil } func (bva BaseLockup) checkTokensSendable(ctx context.Context, sender string, amount, lockedCoins sdk.Coins) error { // Check if any sent tokens is exceeds lockup account balances for _, coin := range amount { balance, err := bva.getBalance(ctx, sender, coin.Denom) if err != nil { return err } lockedAmt := lockedCoins.AmountOf(coin.Denom) // get lockedCoin from that are not bonded for the sent denom notBondedLockedCoin, err := bva.GetNotBondedLockedCoin(ctx, sdk.NewCoin(coin.Denom, lockedAmt), coin.Denom) if err != nil { return err } spendable, hasNeg := sdk.Coins{*balance}.SafeSub(notBondedLockedCoin) if hasNeg { return errorsmod.Wrapf(sdkerrors.ErrInsufficientFunds, "locked amount exceeds account balance funds: %s > %s", notBondedLockedCoin, balance) } if _, hasNeg := spendable.SafeSub(coin); hasNeg { if len(spendable) == 0 { spendable = sdk.Coins{sdk.NewCoin(coin.Denom, math.ZeroInt())} } return errorsmod.Wrapf( sdkerrors.ErrInsufficientFunds, "spendable balance %s is smaller than %s", spendable, coin, ) } } return nil } // IterateCoinEntries iterates over all the CoinEntries entries. func (bva BaseLockup) IterateCoinEntries( ctx context.Context, entries collections.Map[string, math.Int], cb func(denom string, value math.Int) (bool, error), ) error { err := entries.Walk(ctx, nil, func(key string, value math.Int) (stop bool, err error) { return cb(key, value) }) return err } // GetNotBondedLockedCoin returns the coin that are not spendable that are not bonded by denom // for a lockup account. If the coin by the provided denom are not locked, an coin with zero amount is returned. func (bva BaseLockup) GetNotBondedLockedCoin(ctx context.Context, lockedCoin sdk.Coin, denom string) (sdk.Coin, error) { // refresh the unbonding entries err := bva.checkUnbondingEntriesMature(ctx) if err != nil { return sdk.Coin{}, err } bondDenom, err := getStakingDenom(ctx) if err != nil { return sdk.Coin{}, err } // if not bond denom then return the full locked coin if bondDenom != denom { return lockedCoin, nil } delegatedLockingAmt, err := bva.DelegatedLocking.Get(ctx, denom) if err != nil { return sdk.Coin{}, err } x := math.MinInt(lockedCoin.Amount, delegatedLockingAmt) lockedAmt := lockedCoin.Amount.Sub(x) return sdk.NewCoin(denom, lockedAmt), nil } // QueryLockupAccountBaseInfo returns a lockup account's info func (bva BaseLockup) QueryLockupAccountBaseInfo(ctx context.Context, _ *lockuptypes.QueryLockupAccountInfoRequest) ( *lockuptypes.QueryLockupAccountInfoResponse, error, ) { owner, err := bva.Owner.Get(ctx) if err != nil { return nil, err } ownerAddress, err := bva.addressCodec.BytesToString(owner) if err != nil { return nil, err } endTime, err := bva.EndTime.Get(ctx) if err != nil { return nil, err } originalLocking := sdk.Coins{} err = bva.IterateCoinEntries(ctx, bva.OriginalLocking, func(key string, value math.Int) (stop bool, err error) { originalLocking = append(originalLocking, sdk.NewCoin(key, value)) return false, nil }) if err != nil { return nil, err } bondDenom, err := getStakingDenom(ctx) if err != nil { return nil, err } delegatedLockingAmt, err := bva.DelegatedLocking.Get(ctx, bondDenom) if err != nil { return nil, err } delegatedLocking := sdk.NewCoins(sdk.NewCoin(bondDenom, delegatedLockingAmt)) delegatedFreeAmt, err := bva.DelegatedFree.Get(ctx, bondDenom) if err != nil { return nil, err } delegatedFree := sdk.NewCoins(sdk.NewCoin(bondDenom, delegatedFreeAmt)) return &lockuptypes.QueryLockupAccountInfoResponse{ Owner: ownerAddress, OriginalLocking: originalLocking, DelegatedLocking: delegatedLocking, DelegatedFree: delegatedFree, EndTime: &endTime, }, nil } func (bva BaseLockup) QueryUnbondingEntries(ctx context.Context, req *lockuptypes.QueryUnbondingEntriesRequest) ( *lockuptypes.QueryUnbondingEntriesResponse, error, ) { entries, err := bva.UnbondEntries.Get(ctx, req.ValidatorAddress) if err != nil { if !errorsmod.IsOf(err, collections.ErrNotFound) { return nil, err } entries = lockuptypes.UnbondingEntries{ Entries: []*lockuptypes.UnbondingEntry{}, } } return &lockuptypes.QueryUnbondingEntriesResponse{ UnbondingEntries: entries.Entries, }, nil } func (bva BaseLockup) QuerySpendableTokens(ctx context.Context, lockedCoins sdk.Coins) ( *lockuptypes.QuerySpendableAmountResponse, error, ) { whoami := accountstd.Whoami(ctx) accAddr, err := bva.addressCodec.BytesToString(whoami) if err != nil { return nil, err } spendables := sdk.Coins{} for _, denom := range lockedCoins.Denoms() { balance, err := bva.getBalance(ctx, accAddr, denom) if err != nil { return nil, err } lockedAmt := lockedCoins.AmountOf(balance.Denom) // get lockedCoin from that are not bonded for the sent denom notBondedLockedCoin, err := bva.GetNotBondedLockedCoin(ctx, sdk.NewCoin(balance.Denom, lockedAmt), balance.Denom) if err != nil { return nil, err } spendable, hasNeg := sdk.Coins{*balance}.SafeSub(notBondedLockedCoin) if hasNeg { spendable = sdk.Coins{sdk.NewCoin(balance.Denom, math.ZeroInt())} } spendables = spendables.Add(spendable...) } return &lockuptypes.QuerySpendableAmountResponse{ SpendableTokens: spendables, }, nil } func (bva BaseLockup) RegisterExecuteHandlers(builder *accountstd.ExecuteBuilder) { accountstd.RegisterExecuteHandler(builder, bva.Undelegate) accountstd.RegisterExecuteHandler(builder, bva.WithdrawReward) } func (bva BaseLockup) RegisterQueryHandlers(builder *accountstd.QueryBuilder) { accountstd.RegisterQueryHandler(builder, bva.QueryUnbondingEntries) }