483 lines
16 KiB
Go
483 lines
16 KiB
Go
package keeper
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"cosmossdk.io/collections"
|
|
"cosmossdk.io/core/appmodule"
|
|
errorsmod "cosmossdk.io/errors"
|
|
"cosmossdk.io/math"
|
|
"cosmossdk.io/x/protocolpool/types"
|
|
|
|
"github.com/cosmos/cosmos-sdk/codec"
|
|
sdk "github.com/cosmos/cosmos-sdk/types"
|
|
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
|
|
)
|
|
|
|
type Keeper struct {
|
|
appmodule.Environment
|
|
|
|
authKeeper types.AccountKeeper
|
|
bankKeeper types.BankKeeper
|
|
stakingKeeper types.StakingKeeper
|
|
|
|
cdc codec.BinaryCodec
|
|
|
|
authority string
|
|
|
|
// State
|
|
Schema collections.Schema
|
|
BudgetProposal collections.Map[sdk.AccAddress, types.Budget]
|
|
ContinuousFund collections.Map[sdk.AccAddress, types.ContinuousFund]
|
|
// RecipientFundDistribution key: RecipientAddr | value: Claimable amount
|
|
RecipientFundDistribution collections.Map[sdk.AccAddress, math.Int]
|
|
Distributions collections.Map[time.Time, math.Int] // key: time.Time | value: amount
|
|
LastBalance collections.Item[math.Int]
|
|
}
|
|
|
|
func NewKeeper(cdc codec.BinaryCodec, env appmodule.Environment, ak types.AccountKeeper, bk types.BankKeeper, sk types.StakingKeeper, authority string,
|
|
) Keeper {
|
|
// ensure pool module account is set
|
|
if addr := ak.GetModuleAddress(types.ModuleName); addr == nil {
|
|
panic(fmt.Sprintf("%s module account has not been set", types.ModuleName))
|
|
}
|
|
// ensure stream account is set
|
|
if addr := ak.GetModuleAddress(types.StreamAccount); addr == nil {
|
|
panic(fmt.Sprintf("%s module account has not been set", types.StreamAccount))
|
|
}
|
|
// ensure protocol pool distribution account is set
|
|
if addr := ak.GetModuleAddress(types.ProtocolPoolDistrAccount); addr == nil {
|
|
panic(fmt.Sprintf("%s module account has not been set", types.ProtocolPoolDistrAccount))
|
|
}
|
|
|
|
sb := collections.NewSchemaBuilder(env.KVStoreService)
|
|
|
|
keeper := Keeper{
|
|
Environment: env,
|
|
authKeeper: ak,
|
|
bankKeeper: bk,
|
|
stakingKeeper: sk,
|
|
cdc: cdc,
|
|
authority: authority,
|
|
BudgetProposal: collections.NewMap(sb, types.BudgetKey, "budget", sdk.AccAddressKey, codec.CollValue[types.Budget](cdc)),
|
|
ContinuousFund: collections.NewMap(sb, types.ContinuousFundKey, "continuous_fund", sdk.AccAddressKey, codec.CollValue[types.ContinuousFund](cdc)),
|
|
RecipientFundDistribution: collections.NewMap(sb, types.RecipientFundDistributionKey, "recipient_fund_distribution", sdk.AccAddressKey, sdk.IntValue),
|
|
Distributions: collections.NewMap(sb, types.DistributionsKey, "distributions", sdk.TimeKey, sdk.IntValue),
|
|
LastBalance: collections.NewItem(sb, types.LastBalanceKey, "last_balance", sdk.IntValue),
|
|
}
|
|
|
|
schema, err := sb.Build()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
keeper.Schema = schema
|
|
|
|
return keeper
|
|
}
|
|
|
|
// GetAuthority returns the x/protocolpool module's authority.
|
|
func (k Keeper) GetAuthority() string {
|
|
return k.authority
|
|
}
|
|
|
|
// FundCommunityPool allows an account to directly fund the community fund pool.
|
|
func (k Keeper) FundCommunityPool(ctx context.Context, amount sdk.Coins, sender []byte) error {
|
|
return k.bankKeeper.SendCoinsFromAccountToModule(ctx, sender, types.ModuleName, amount)
|
|
}
|
|
|
|
// DistributeFromCommunityPool distributes funds from the protocolpool module account to
|
|
// a receiver address.
|
|
func (k Keeper) DistributeFromCommunityPool(ctx context.Context, amount sdk.Coins, receiveAddr []byte) error {
|
|
return k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, receiveAddr, amount)
|
|
}
|
|
|
|
// DistributeFromStreamFunds distributes funds from the protocolpool's stream module account to
|
|
// a receiver address.
|
|
func (k Keeper) DistributeFromStreamFunds(ctx context.Context, amount sdk.Coins, receiveAddr []byte) error {
|
|
return k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.StreamAccount, receiveAddr, amount)
|
|
}
|
|
|
|
// GetCommunityPool gets the community pool balance.
|
|
func (k Keeper) GetCommunityPool(ctx context.Context) (sdk.Coins, error) {
|
|
moduleAccount := k.authKeeper.GetModuleAccount(ctx, types.ModuleName)
|
|
if moduleAccount == nil {
|
|
return nil, errorsmod.Wrapf(sdkerrors.ErrUnknownAddress, "module account %s does not exist", types.ModuleName)
|
|
}
|
|
return k.bankKeeper.GetAllBalances(ctx, moduleAccount.GetAddress()), nil
|
|
}
|
|
|
|
func (k Keeper) withdrawRecipientFunds(ctx context.Context, recipient []byte) (sdk.Coin, error) {
|
|
// get allocated continuous fund
|
|
fundsAllocated, err := k.RecipientFundDistribution.Get(ctx, recipient)
|
|
if err != nil {
|
|
if errors.Is(err, collections.ErrNotFound) {
|
|
return sdk.Coin{}, types.ErrNoRecipientFound
|
|
}
|
|
return sdk.Coin{}, err
|
|
}
|
|
|
|
denom, err := k.stakingKeeper.BondDenom(ctx)
|
|
if err != nil {
|
|
return sdk.Coin{}, err
|
|
}
|
|
|
|
// Distribute funds to the recipient from pool module account
|
|
withdrawnAmount := sdk.NewCoin(denom, fundsAllocated)
|
|
err = k.DistributeFromStreamFunds(ctx, sdk.NewCoins(withdrawnAmount), recipient)
|
|
if err != nil {
|
|
return sdk.Coin{}, fmt.Errorf("error while distributing funds: %w", err)
|
|
}
|
|
|
|
// reset fund distribution
|
|
err = k.RecipientFundDistribution.Set(ctx, recipient, math.ZeroInt())
|
|
if err != nil {
|
|
return sdk.Coin{}, err
|
|
}
|
|
return withdrawnAmount, nil
|
|
}
|
|
|
|
// SetToDistribute sets the amount to be distributed among recipients.
|
|
func (k Keeper) SetToDistribute(ctx context.Context) error {
|
|
// Get current balance of the intermediary module account
|
|
moduleAccount := k.authKeeper.GetModuleAccount(ctx, types.ProtocolPoolDistrAccount)
|
|
if moduleAccount == nil {
|
|
return errorsmod.Wrapf(sdkerrors.ErrUnknownAddress, "module account %s does not exist", types.ProtocolPoolDistrAccount)
|
|
}
|
|
|
|
denom, err := k.stakingKeeper.BondDenom(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
currentBalance := k.bankKeeper.GetAllBalances(ctx, moduleAccount.GetAddress())
|
|
distributionBalance := currentBalance.AmountOf(denom)
|
|
|
|
// if the balance is zero, return early
|
|
if distributionBalance.IsZero() {
|
|
return nil
|
|
}
|
|
|
|
lastBalance, err := k.LastBalance.Get(ctx)
|
|
if err != nil {
|
|
if errors.Is(err, collections.ErrNotFound) {
|
|
lastBalance = math.ZeroInt()
|
|
} else {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Calculate the amount to be distributed
|
|
amountToDistribute := distributionBalance.Sub(lastBalance)
|
|
|
|
if err = k.Distributions.Set(ctx, k.HeaderService.HeaderInfo(ctx).Time, amountToDistribute); err != nil {
|
|
return fmt.Errorf("error while setting Distributions: %w", err)
|
|
}
|
|
|
|
// Update the last balance
|
|
return k.LastBalance.Set(ctx, distributionBalance)
|
|
}
|
|
|
|
func (k Keeper) IterateAndUpdateFundsDistribution(ctx context.Context) error {
|
|
// first we get all the continuous funds, and keep a list of the ones that expired so we can delete later
|
|
funds := []types.ContinuousFund{}
|
|
toDelete := [][]byte{}
|
|
err := k.ContinuousFund.Walk(ctx, nil, func(key sdk.AccAddress, cf types.ContinuousFund) (stop bool, err error) {
|
|
funds = append(funds, cf)
|
|
|
|
// check if the continuous fund has expired, and add it to the list of funds to delete
|
|
if cf.Expiry != nil && cf.Expiry.Before(k.HeaderService.HeaderInfo(ctx).Time) {
|
|
toDelete = append(toDelete, key)
|
|
}
|
|
|
|
return false, nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// next we iterate over the distributions, calculate each recipient's share and the remaining pool funds
|
|
toDistribute := map[string]math.Int{}
|
|
poolFunds := math.ZeroInt()
|
|
fullAmountToDistribute := math.ZeroInt()
|
|
|
|
if err = k.Distributions.Walk(ctx, nil, func(key time.Time, amount math.Int) (stop bool, err error) {
|
|
percentageToDistribute := math.LegacyZeroDec()
|
|
for _, f := range funds {
|
|
if f.Expiry != nil && f.Expiry.Before(key) {
|
|
continue
|
|
}
|
|
|
|
percentageToDistribute = percentageToDistribute.Add(f.Percentage)
|
|
|
|
_, ok := toDistribute[f.Recipient]
|
|
if !ok {
|
|
toDistribute[f.Recipient] = math.ZeroInt()
|
|
}
|
|
amountToDistribute := f.Percentage.MulInt(amount).TruncateInt()
|
|
toDistribute[f.Recipient] = toDistribute[f.Recipient].Add(amountToDistribute)
|
|
fullAmountToDistribute = fullAmountToDistribute.Add(amountToDistribute)
|
|
}
|
|
|
|
// sanity check for max percentage
|
|
if percentageToDistribute.GT(math.LegacyOneDec()) {
|
|
return true, errors.New("total funds percentage cannot exceed 100")
|
|
}
|
|
|
|
remaining := math.LegacyOneDec().Sub(percentageToDistribute).MulInt(amount).RoundInt()
|
|
poolFunds = poolFunds.Add(remaining)
|
|
|
|
return false, nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
// clear the distributions and reset the last balance
|
|
if err = k.Distributions.Clear(ctx, nil); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = k.LastBalance.Set(ctx, math.ZeroInt()); err != nil {
|
|
return err
|
|
}
|
|
|
|
// send the funds to the stream account to be distributed later, and the remaining to the community pool
|
|
bondDenom, err := k.stakingKeeper.BondDenom(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
streamAmt := sdk.NewCoins(sdk.NewCoin(bondDenom, fullAmountToDistribute))
|
|
if !streamAmt.IsZero() {
|
|
if err := k.bankKeeper.SendCoinsFromModuleToModule(ctx, types.ProtocolPoolDistrAccount, types.StreamAccount, streamAmt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !poolFunds.IsZero() {
|
|
poolCoins := sdk.NewCoins(sdk.NewCoin(bondDenom, poolFunds))
|
|
if err := k.bankKeeper.SendCoinsFromModuleToModule(ctx, types.ProtocolPoolDistrAccount, types.ModuleName, poolCoins); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// update the recipient fund distribution, first get the keys and sort them
|
|
recipients := make([]string, 0, len(toDistribute))
|
|
for k2 := range toDistribute {
|
|
recipients = append(recipients, k2)
|
|
}
|
|
sort.Strings(recipients)
|
|
|
|
for _, recipient := range recipients {
|
|
// Set funds to be claimed
|
|
bzAddr, err := k.authKeeper.AddressCodec().StringToBytes(recipient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
toClaim, err := k.RecipientFundDistribution.Get(ctx, bzAddr)
|
|
if err != nil {
|
|
if errors.Is(err, collections.ErrNotFound) {
|
|
toClaim = math.ZeroInt()
|
|
} else {
|
|
return err
|
|
}
|
|
}
|
|
|
|
amount := toClaim.Add(toDistribute[recipient])
|
|
if err = k.RecipientFundDistribution.Set(ctx, bzAddr, amount); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// delete expired continuous funds
|
|
for _, recipient := range toDelete {
|
|
if err = k.ContinuousFund.Remove(ctx, recipient); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (k Keeper) claimFunds(ctx context.Context, recipientAddr string) (amount sdk.Coin, err error) {
|
|
recipient, err := k.authKeeper.AddressCodec().StringToBytes(recipientAddr)
|
|
if err != nil {
|
|
return sdk.Coin{}, sdkerrors.ErrInvalidAddress.Wrapf("invalid recipient address: %s", err)
|
|
}
|
|
|
|
// get claimable funds from distribution info
|
|
amount, err = k.getClaimableFunds(ctx, recipientAddr)
|
|
if err != nil {
|
|
return sdk.Coin{}, fmt.Errorf("error getting claimable funds: %w", err)
|
|
}
|
|
|
|
// distribute amount from community pool
|
|
err = k.DistributeFromCommunityPool(ctx, sdk.NewCoins(amount), recipient)
|
|
if err != nil {
|
|
return sdk.Coin{}, fmt.Errorf("error distributing from community pool: %w", err)
|
|
}
|
|
|
|
return amount, nil
|
|
}
|
|
|
|
func (k Keeper) getClaimableFunds(ctx context.Context, recipientAddr string) (amount sdk.Coin, err error) {
|
|
recipient, err := k.authKeeper.AddressCodec().StringToBytes(recipientAddr)
|
|
if err != nil {
|
|
return sdk.Coin{}, sdkerrors.ErrInvalidAddress.Wrapf("invalid recipient address: %s", err)
|
|
}
|
|
|
|
budget, err := k.BudgetProposal.Get(ctx, recipient)
|
|
if err != nil {
|
|
if errors.Is(err, collections.ErrNotFound) {
|
|
return sdk.Coin{}, fmt.Errorf("no budget found for recipient: %s", recipientAddr)
|
|
}
|
|
return sdk.Coin{}, err
|
|
}
|
|
|
|
totalBudgetAmountLeftToDistribute := budget.BudgetPerTranche.Amount.Mul(math.NewIntFromUint64(budget.TranchesLeft))
|
|
totalBudgetAmountLeft := sdk.NewCoin(budget.BudgetPerTranche.Denom, totalBudgetAmountLeftToDistribute)
|
|
zeroAmount := sdk.NewCoin(totalBudgetAmountLeft.Denom, math.ZeroInt())
|
|
|
|
// check if the distribution is completed
|
|
if budget.TranchesLeft == 0 && budget.ClaimedAmount != nil {
|
|
// check that total budget amount left to distribute equals zero
|
|
if totalBudgetAmountLeft.Equal(zeroAmount) {
|
|
// remove the entry of budget ended recipient
|
|
if err := k.BudgetProposal.Remove(ctx, recipient); err != nil {
|
|
return sdk.Coin{}, err
|
|
}
|
|
// Return the end of the budget
|
|
return sdk.Coin{}, fmt.Errorf("budget ended for recipient: %s", recipientAddr)
|
|
}
|
|
}
|
|
|
|
currentTime := k.HeaderService.HeaderInfo(ctx).Time
|
|
|
|
// Check if the distribution time has not reached
|
|
if budget.LastClaimedAt != nil {
|
|
if currentTime.Before(*budget.LastClaimedAt) {
|
|
return sdk.Coin{}, errors.New("distribution has not started yet")
|
|
}
|
|
}
|
|
|
|
if budget.TranchesLeft != 0 && budget.ClaimedAmount == nil {
|
|
zeroCoin := sdk.NewCoin(budget.BudgetPerTranche.Denom, math.ZeroInt())
|
|
budget.ClaimedAmount = &zeroCoin
|
|
}
|
|
|
|
return k.calculateClaimableFunds(ctx, recipient, budget, currentTime)
|
|
}
|
|
|
|
func (k Keeper) calculateClaimableFunds(ctx context.Context, recipient sdk.AccAddress, budget types.Budget, currentTime time.Time) (amount sdk.Coin, err error) {
|
|
// Calculate the time elapsed since the last claim time
|
|
timeElapsed := currentTime.Sub(*budget.LastClaimedAt)
|
|
|
|
// Check the time elapsed has passed period length
|
|
if timeElapsed < *budget.Period {
|
|
return sdk.Coin{}, errors.New("budget period has not passed yet")
|
|
}
|
|
|
|
// Calculate how many periods have passed
|
|
periodsPassed := int64(timeElapsed) / int64(*budget.Period)
|
|
|
|
if periodsPassed > int64(budget.TranchesLeft) {
|
|
periodsPassed = int64(budget.TranchesLeft)
|
|
}
|
|
|
|
// Calculate the amount to distribute for all passed periods
|
|
coinsToDistribute := math.NewInt(periodsPassed).Mul(budget.BudgetPerTranche.Amount)
|
|
amount = sdk.NewCoin(budget.BudgetPerTranche.Denom, coinsToDistribute)
|
|
|
|
// update the budget's remaining tranches
|
|
if budget.TranchesLeft > uint64(periodsPassed) {
|
|
budget.TranchesLeft -= uint64(periodsPassed)
|
|
} else {
|
|
budget.TranchesLeft = 0
|
|
}
|
|
|
|
// update the ClaimedAmount
|
|
claimedAmount := budget.ClaimedAmount.Add(amount)
|
|
budget.ClaimedAmount = &claimedAmount
|
|
|
|
// Update the last claim time for the budget
|
|
nextClaimFrom := budget.LastClaimedAt.Add(*budget.Period * time.Duration(periodsPassed))
|
|
budget.LastClaimedAt = &nextClaimFrom
|
|
|
|
k.Logger.Debug(fmt.Sprintf("Processing budget for recipient: %s. Amount: %s", budget.RecipientAddress, coinsToDistribute.String()))
|
|
|
|
// Save the updated budget in the state
|
|
if err := k.BudgetProposal.Set(ctx, recipient, budget); err != nil {
|
|
return sdk.Coin{}, fmt.Errorf("error while updating the budget for recipient %s", budget.RecipientAddress)
|
|
}
|
|
|
|
return amount, nil
|
|
}
|
|
|
|
func (k Keeper) validateAndUpdateBudgetProposal(ctx context.Context, bp types.MsgSubmitBudgetProposal) (*types.Budget, error) {
|
|
if bp.BudgetPerTranche.IsZero() {
|
|
return nil, errors.New("invalid budget proposal: budget per tranche cannot be zero")
|
|
}
|
|
|
|
if err := validateAmount(sdk.NewCoins(*bp.BudgetPerTranche)); err != nil {
|
|
return nil, fmt.Errorf("invalid budget proposal: %w", err)
|
|
}
|
|
|
|
currentTime := k.HeaderService.HeaderInfo(ctx).Time
|
|
if bp.StartTime.IsZero() || bp.StartTime == nil {
|
|
bp.StartTime = ¤tTime
|
|
}
|
|
|
|
if currentTime.After(*bp.StartTime) {
|
|
return nil, errors.New("invalid budget proposal: start time cannot be less than the current block time")
|
|
}
|
|
|
|
if bp.Tranches == 0 {
|
|
return nil, errors.New("invalid budget proposal: tranches must be greater than zero")
|
|
}
|
|
|
|
if bp.Period == nil || *bp.Period == 0 {
|
|
return nil, errors.New("invalid budget proposal: period length should be greater than zero")
|
|
}
|
|
|
|
// Create and return an updated budget proposal
|
|
updatedBudget := types.Budget{
|
|
RecipientAddress: bp.RecipientAddress,
|
|
BudgetPerTranche: bp.BudgetPerTranche,
|
|
LastClaimedAt: bp.StartTime,
|
|
TranchesLeft: bp.Tranches,
|
|
Period: bp.Period,
|
|
}
|
|
|
|
return &updatedBudget, nil
|
|
}
|
|
|
|
// validateContinuousFund validates the fields of the CreateContinuousFund message.
|
|
func (k Keeper) validateContinuousFund(ctx context.Context, msg types.MsgCreateContinuousFund) error {
|
|
// Validate percentage
|
|
if msg.Percentage.IsZero() || msg.Percentage.IsNil() {
|
|
return errors.New("percentage cannot be zero or empty")
|
|
}
|
|
if msg.Percentage.IsNegative() {
|
|
return errors.New("percentage cannot be negative")
|
|
}
|
|
if msg.Percentage.GTE(math.LegacyOneDec()) {
|
|
return errors.New("percentage cannot be greater than or equal to one")
|
|
}
|
|
|
|
// Validate expiry
|
|
currentTime := k.HeaderService.HeaderInfo(ctx).Time
|
|
if msg.Expiry != nil && msg.Expiry.Compare(currentTime) == -1 {
|
|
return errors.New("expiry time cannot be less than the current block time")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (k Keeper) BeginBlocker(ctx context.Context) error {
|
|
return k.SetToDistribute(ctx)
|
|
}
|