add community multi-spend proposal (#915)

* feat: add community multi-spend proposal type

* feat: add handler for community multi-spend proposals

* chore: register new community multi-spend proposal

* feat: define client for community multi-spend proposal

* fix typos in example cli json

* fix: register now proposal type with module codec

* fix: register community multi-spend proposal with gov router, not committee

* fix: define kavadist keeper before referencing it

* nit: include deposit in example proposal

* nit: update comment

* nit: fix error codes

* nit: update comments
This commit is contained in:
Kevin Davis 2021-06-02 11:03:25 -06:00 committed by GitHub
parent ffbf31742f
commit fc85052522
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 450 additions and 52 deletions

View File

@ -68,7 +68,7 @@ var (
distr.AppModuleBasic{}, distr.AppModuleBasic{},
gov.NewAppModuleBasic( gov.NewAppModuleBasic(
paramsclient.ProposalHandler, distr.ProposalHandler, committee.ProposalHandler, paramsclient.ProposalHandler, distr.ProposalHandler, committee.ProposalHandler,
upgradeclient.ProposalHandler, upgradeclient.ProposalHandler, kavadist.ProposalHandler,
), ),
params.AppModuleBasic{}, params.AppModuleBasic{},
crisis.AppModuleBasic{}, crisis.AppModuleBasic{},
@ -300,6 +300,14 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts AppOptio
committeeGovRouter, committeeGovRouter,
app.paramsKeeper, app.paramsKeeper,
) )
app.kavadistKeeper = kavadist.NewKeeper(
app.cdc,
keys[kavadist.StoreKey],
kavadistSubspace,
app.supplyKeeper,
app.distrKeeper,
app.ModuleAccountAddrs(),
)
// create gov keeper with router // create gov keeper with router
govRouter := gov.NewRouter() govRouter := gov.NewRouter()
@ -308,7 +316,8 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts AppOptio
AddRoute(params.RouterKey, params.NewParamChangeProposalHandler(app.paramsKeeper)). AddRoute(params.RouterKey, params.NewParamChangeProposalHandler(app.paramsKeeper)).
AddRoute(distr.RouterKey, distr.NewCommunityPoolSpendProposalHandler(app.distrKeeper)). AddRoute(distr.RouterKey, distr.NewCommunityPoolSpendProposalHandler(app.distrKeeper)).
AddRoute(upgrade.RouterKey, upgrade.NewSoftwareUpgradeProposalHandler(app.upgradeKeeper)). AddRoute(upgrade.RouterKey, upgrade.NewSoftwareUpgradeProposalHandler(app.upgradeKeeper)).
AddRoute(committee.RouterKey, committee.NewProposalHandler(app.committeeKeeper)) AddRoute(committee.RouterKey, committee.NewProposalHandler(app.committeeKeeper)).
AddRoute(kavadist.RouterKey, kavadist.NewCommunityPoolMultiSpendProposalHandler(app.kavadistKeeper))
app.govKeeper = gov.NewKeeper( app.govKeeper = gov.NewKeeper(
app.cdc, app.cdc,
keys[gov.StoreKey], keys[gov.StoreKey],
@ -365,12 +374,6 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts AppOptio
app.pricefeedKeeper, app.pricefeedKeeper,
app.auctionKeeper, app.auctionKeeper,
) )
app.kavadistKeeper = kavadist.NewKeeper(
app.cdc,
keys[kavadist.StoreKey],
kavadistSubspace,
app.supplyKeeper,
)
app.incentiveKeeper = incentive.NewKeeper( app.incentiveKeeper = incentive.NewKeeper(
app.cdc, app.cdc,
keys[incentive.StoreKey], keys[incentive.StoreKey],

View File

@ -1,56 +1,69 @@
// nolint
// autogenerated code using github.com/rigelrozanski/multitool
// aliases generated for the following subdirectories:
// ALIASGEN: github.com/kava-labs/kava/x/kavadist/types
// ALIASGEN: github.com/kava-labs/kava/x/kavadist/keeper
// ALIASGEN: github.com/kava-labs/kava/x/kavadist/client
package kavadist package kavadist
// DO NOT EDIT - generated by aliasgen tool (github.com/rhuairahrighairidh/aliasgen)
import ( import (
"github.com/kava-labs/kava/x/kavadist/client"
"github.com/kava-labs/kava/x/kavadist/keeper" "github.com/kava-labs/kava/x/kavadist/keeper"
"github.com/kava-labs/kava/x/kavadist/types" "github.com/kava-labs/kava/x/kavadist/types"
) )
const ( const (
EventTypeKavaDist = types.EventTypeKavaDist
AttributeKeyInflation = types.AttributeKeyInflation AttributeKeyInflation = types.AttributeKeyInflation
AttributeKeyStatus = types.AttributeKeyStatus AttributeKeyStatus = types.AttributeKeyStatus
AttributeValueInactive = types.AttributeValueInactive AttributeValueInactive = types.AttributeValueInactive
DefaultParamspace = types.DefaultParamspace
EventTypeKavaDist = types.EventTypeKavaDist
KavaDistMacc = types.KavaDistMacc
ModuleName = types.ModuleName ModuleName = types.ModuleName
QuerierRoute = types.QuerierRoute
QueryGetBalance = types.QueryGetBalance
QueryGetParams = types.QueryGetParams
RouterKey = types.RouterKey
StoreKey = types.StoreKey StoreKey = types.StoreKey
RouterKey = types.RouterKey
QuerierRoute = types.QuerierRoute
DefaultParamspace = types.DefaultParamspace
KavaDistMacc = types.KavaDistMacc
ProposalTypeCommunityPoolMultiSpend = types.ProposalTypeCommunityPoolMultiSpend
QueryGetParams = types.QueryGetParams
QueryGetBalance = types.QueryGetBalance
) )
var ( var (
// function aliases // functions aliases
NewKeeper = keeper.NewKeeper
NewQuerier = keeper.NewQuerier
DefaultGenesisState = types.DefaultGenesisState
DefaultParams = types.DefaultParams
NewGenesisState = types.NewGenesisState
NewParams = types.NewParams
NewPeriod = types.NewPeriod
ParamKeyTable = types.ParamKeyTable
RegisterCodec = types.RegisterCodec RegisterCodec = types.RegisterCodec
NewGenesisState = types.NewGenesisState
DefaultGenesisState = types.DefaultGenesisState
NewPeriod = types.NewPeriod
NewParams = types.NewParams
DefaultParams = types.DefaultParams
ParamKeyTable = types.ParamKeyTable
NewCommunityPoolMultiSpendProposal = types.NewCommunityPoolMultiSpendProposal
NewKeeper = keeper.NewKeeper
HandleCommunityPoolMultiSpendProposal = keeper.HandleCommunityPoolMultiSpendProposal
NewQuerier = keeper.NewQuerier
// variable aliases // variable aliases
ModuleCdc = types.ModuleCdc
ErrInvalidProposalAmount = types.ErrInvalidProposalAmount
ErrEmptyProposalRecipient = types.ErrEmptyProposalRecipient
CurrentDistPeriodKey = types.CurrentDistPeriodKey CurrentDistPeriodKey = types.CurrentDistPeriodKey
PreviousBlockTimeKey = types.PreviousBlockTimeKey
KeyActive = types.KeyActive
KeyPeriods = types.KeyPeriods
DefaultActive = types.DefaultActive DefaultActive = types.DefaultActive
DefaultPeriods = types.DefaultPeriods DefaultPeriods = types.DefaultPeriods
DefaultPreviousBlockTime = types.DefaultPreviousBlockTime DefaultPreviousBlockTime = types.DefaultPreviousBlockTime
GovDenom = types.GovDenom GovDenom = types.GovDenom
KeyActive = types.KeyActive ProposalHandler = client.ProposalHandler
KeyPeriods = types.KeyPeriods
ModuleCdc = types.ModuleCdc
PreviousBlockTimeKey = types.PreviousBlockTimeKey
) )
type ( type (
Keeper = keeper.Keeper
GenesisState = types.GenesisState GenesisState = types.GenesisState
Params = types.Params Params = types.Params
Period = types.Period Period = types.Period
Periods = types.Periods Periods = types.Periods
SupplyKeeper = types.SupplyKeeper CommunityPoolMultiSpendProposal = types.CommunityPoolMultiSpendProposal
MultiSpendRecipient = types.MultiSpendRecipient
MultiSpendRecipients = types.MultiSpendRecipients
Keeper = keeper.Keeper
) )

View File

@ -0,0 +1,93 @@
package cli
import (
"bufio"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/cosmos/cosmos-sdk/client/context"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/version"
"github.com/cosmos/cosmos-sdk/x/auth"
"github.com/cosmos/cosmos-sdk/x/auth/client/utils"
"github.com/cosmos/cosmos-sdk/x/gov"
"github.com/kava-labs/kava/x/kavadist/types"
)
// GetCmdSubmitProposal implements the command to submit a community-pool multi-spend proposal
func GetCmdSubmitProposal(cdc *codec.Codec) *cobra.Command {
cmd := &cobra.Command{
Use: "community-pool-multi-spend [proposal-file]",
Args: cobra.ExactArgs(1),
Short: "Submit a community pool multi-spend proposal",
Long: strings.TrimSpace(
fmt.Sprintf(`Submit a community pool multi-spend proposal along with an initial deposit.
The proposal details must be supplied via a JSON file.
Example:
$ %s tx gov submit-proposal community-pool-multi-spend <path/to/proposal.json> --from=<key_or_address>
Where proposal.json contains:
{
"title": "Community Pool Multi-Spend",
"description": "Pay many users some KAVA!",
"recipient_list": [
{
"address": "kava1mz2003lathm95n5vnlthmtfvrzrjkrr53j4464",
"amount": [
{
"denom": "ukava",
"amount": "1000000"
}
]
},
{
"address": "kava1zqezafa0luyetvtj8j67g336vaqtuudnsjq7vm",
"amount": [
{
"denom": "ukava",
"amount": "1000000"
}
]
}
],
"deposit": [
{
"denom": "ukava",
"amount": "1000000000"
}
]
}
`,
version.ClientName,
),
),
RunE: func(cmd *cobra.Command, args []string) error {
inBuf := bufio.NewReader(cmd.InOrStdin())
txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc))
cliCtx := context.NewCLIContextWithInput(inBuf).WithCodec(cdc)
proposal, err := ParseCommunityPoolMultiSpendProposalJSON(cdc, args[0])
if err != nil {
return err
}
from := cliCtx.GetFromAddress()
content := types.NewCommunityPoolMultiSpendProposal(proposal.Title, proposal.Description, proposal.RecipientList)
msg := gov.NewMsgSubmitProposal(content, proposal.Deposit, from)
if err := msg.ValidateBasic(); err != nil {
return err
}
return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg})
},
}
return cmd
}

View File

@ -0,0 +1,36 @@
package cli
import (
"io/ioutil"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/kava-labs/kava/x/kavadist/types"
)
type (
// CommunityPoolMultiSpendProposalJSON defines a CommunityPoolMultiSpendProposal with a deposit
CommunityPoolMultiSpendProposalJSON struct {
Title string `json:"title" yaml:"title"`
Description string `json:"description" yaml:"description"`
RecipientList types.MultiSpendRecipients `json:"recipient_list" yaml:"recipient_list"`
Deposit sdk.Coins `json:"deposit" yaml:"deposit"`
}
)
// ParseCommunityPoolMultiSpendProposalJSON reads and parses a CommunityPoolMultiSpendProposalJSON from a file.
func ParseCommunityPoolMultiSpendProposalJSON(cdc *codec.Codec, proposalFile string) (CommunityPoolMultiSpendProposalJSON, error) {
proposal := CommunityPoolMultiSpendProposalJSON{}
contents, err := ioutil.ReadFile(proposalFile)
if err != nil {
return proposal, err
}
if err := cdc.UnmarshalJSON(contents, &proposal); err != nil {
return proposal, err
}
return proposal, nil
}

View File

@ -0,0 +1,13 @@
package client
import (
govclient "github.com/cosmos/cosmos-sdk/x/gov/client"
"github.com/kava-labs/kava/x/kavadist/client/cli"
"github.com/kava-labs/kava/x/kavadist/client/rest"
)
// community-pool multi-spend proposal handler
var (
ProposalHandler = govclient.NewProposalHandler(cli.GetCmdSubmitProposal, rest.ProposalRESTHandler)
)

View File

@ -1,12 +1,48 @@
package rest package rest
import ( import (
"net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/cosmos/cosmos-sdk/client/context" "github.com/cosmos/cosmos-sdk/client/context"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/rest"
"github.com/cosmos/cosmos-sdk/x/auth/client/utils"
"github.com/cosmos/cosmos-sdk/x/gov"
govrest "github.com/cosmos/cosmos-sdk/x/gov/client/rest"
"github.com/kava-labs/kava/x/kavadist/types"
) )
// RegisterRoutes registers kavadist-related REST handlers to a router // RegisterRoutes registers kavadist-related REST handlers to a router
func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router) { func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router) {
registerQueryRoutes(cliCtx, r) registerQueryRoutes(cliCtx, r)
} }
// ProposalRESTHandler returns a ProposalRESTHandler that exposes the community pool multi-spend REST handler with a given sub-route.
func ProposalRESTHandler(cliCtx context.CLIContext) govrest.ProposalRESTHandler {
return govrest.ProposalRESTHandler{
SubRoute: types.ProposalTypeCommunityPoolMultiSpend,
Handler: postProposalHandlerFn(cliCtx),
}
}
func postProposalHandlerFn(cliCtx context.CLIContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req CommunityPoolMultiSpendProposalReq
if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) {
return
}
req.BaseReq = req.BaseReq.Sanitize()
if !req.BaseReq.ValidateBasic(w) {
return
}
content := types.NewCommunityPoolMultiSpendProposal(req.Title, req.Description, req.RecipientList)
msg := gov.NewMsgSubmitProposal(content, req.Deposit, req.Proposer)
if err := msg.ValidateBasic(); err != nil {
rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error())
return
}
utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg})
}
}

View File

@ -0,0 +1,21 @@
package rest
import (
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/rest"
"github.com/kava-labs/kava/x/kavadist/types"
)
type (
// CommunityPoolMultiSpendProposalReq defines a community pool multi-spend proposal request body.
CommunityPoolMultiSpendProposalReq struct {
BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"`
Title string `json:"title" yaml:"title"`
Description string `json:"description" yaml:"description"`
RecipientList types.MultiSpendRecipients `json:"recipient_list" yaml:"recipient_list"`
Deposit sdk.Coins `json:"deposit" yaml:"deposit"`
Proposer sdk.AccAddress `json:"proposer" yaml:"proposer"`
}
)

View File

@ -3,6 +3,9 @@ package kavadist
import ( import (
sdk "github.com/cosmos/cosmos-sdk/types" sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
govtypes "github.com/cosmos/cosmos-sdk/x/gov/types"
"github.com/kava-labs/kava/x/kavadist/keeper"
"github.com/kava-labs/kava/x/kavadist/types"
) )
// NewHandler creates an sdk.Handler for kavadist messages // NewHandler creates an sdk.Handler for kavadist messages
@ -15,3 +18,15 @@ func NewHandler(k Keeper) sdk.Handler {
} }
} }
} }
func NewCommunityPoolMultiSpendProposalHandler(k Keeper) govtypes.Handler {
return func(ctx sdk.Context, content govtypes.Content) error {
switch c := content.(type) {
case types.CommunityPoolMultiSpendProposal:
return keeper.HandleCommunityPoolMultiSpendProposal(ctx, k, c)
default:
return sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "unrecognized kavadist proposal content type: %T", c)
}
}
}

View File

@ -17,10 +17,16 @@ type Keeper struct {
cdc *codec.Codec cdc *codec.Codec
paramSubspace subspace.Subspace paramSubspace subspace.Subspace
supplyKeeper types.SupplyKeeper supplyKeeper types.SupplyKeeper
distKeeper types.DistKeeper
blacklistedAddrs map[string]bool
} }
// NewKeeper creates a new keeper // NewKeeper creates a new keeper
func NewKeeper(cdc *codec.Codec, key sdk.StoreKey, paramstore subspace.Subspace, sk types.SupplyKeeper) Keeper { func NewKeeper(
cdc *codec.Codec, key sdk.StoreKey, paramstore subspace.Subspace, sk types.SupplyKeeper,
dk types.DistKeeper, blacklistedAddrs map[string]bool,
) Keeper {
if !paramstore.HasKeyTable() { if !paramstore.HasKeyTable() {
paramstore = paramstore.WithKeyTable(types.ParamKeyTable()) paramstore = paramstore.WithKeyTable(types.ParamKeyTable())
} }
@ -30,6 +36,8 @@ func NewKeeper(cdc *codec.Codec, key sdk.StoreKey, paramstore subspace.Subspace,
cdc: cdc, cdc: cdc,
paramSubspace: paramstore, paramSubspace: paramstore,
supplyKeeper: sk, supplyKeeper: sk,
distKeeper: dk,
blacklistedAddrs: blacklistedAddrs,
} }
} }

View File

@ -0,0 +1,23 @@
package keeper
import (
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
"github.com/kava-labs/kava/x/kavadist/types"
)
// HandleCommunityPoolMultiSpendProposal is a handler for executing a passed community multi-spend proposal
func HandleCommunityPoolMultiSpendProposal(ctx sdk.Context, k Keeper, p types.CommunityPoolMultiSpendProposal) error {
for _, receiverInfo := range p.RecipientList {
if k.blacklistedAddrs[receiverInfo.Address.String()] {
return sdkerrors.Wrapf(sdkerrors.ErrUnauthorized, "%s is blacklisted from receiving external funds", receiverInfo.Address)
}
err := k.distKeeper.DistributeFromFeePool(ctx, receiverInfo.Amount, receiverInfo.Address)
if err != nil {
return err
}
}
return nil
}

View File

@ -14,4 +14,5 @@ func init() {
// RegisterCodec registers the necessary types for cdp module // RegisterCodec registers the necessary types for cdp module
func RegisterCodec(cdc *codec.Codec) { func RegisterCodec(cdc *codec.Codec) {
cdc.RegisterConcrete(CommunityPoolMultiSpendProposal{}, "kava/CommunityPoolMultiSpendProposal", nil)
} }

View File

@ -0,0 +1,11 @@
package types
import (
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
)
// x/kavadist errors
var (
ErrInvalidProposalAmount = sdkerrors.Register(ModuleName, 2, "invalid community pool multi-spend proposal amount")
ErrEmptyProposalRecipient = sdkerrors.Register(ModuleName, 3, "invalid community pool multi-spend proposal recipient")
)

View File

@ -13,3 +13,8 @@ type SupplyKeeper interface {
SendCoinsFromModuleToModule(ctx sdk.Context, senderModule, recipientModule string, amt sdk.Coins) error SendCoinsFromModuleToModule(ctx sdk.Context, senderModule, recipientModule string, amt sdk.Coins) error
MintCoins(ctx sdk.Context, name string, amt sdk.Coins) error MintCoins(ctx sdk.Context, name string, amt sdk.Coins) error
} }
// DistKeeper defines the expected distribution keeper interface
type DistKeeper interface {
DistributeFromFeePool(ctx sdk.Context, amount sdk.Coins, receiveAddr sdk.AccAddress) error
}

View File

@ -0,0 +1,120 @@
package types
import (
"fmt"
"strings"
sdk "github.com/cosmos/cosmos-sdk/types"
govtypes "github.com/cosmos/cosmos-sdk/x/gov/types"
)
const (
// ProposalTypeCommunityPoolMultiSpend defines the type for a CommunityPoolMultiSpendProposal
ProposalTypeCommunityPoolMultiSpend = "CommunityPoolMultiSpend"
)
// Assert CommunityPoolMultiSpendProposal implements govtypes.Content at compile-time
var _ govtypes.Content = CommunityPoolMultiSpendProposal{}
func init() {
govtypes.RegisterProposalType(ProposalTypeCommunityPoolMultiSpend)
govtypes.RegisterProposalTypeCodec(CommunityPoolMultiSpendProposal{}, "kava/CommunityPoolMultiSpendProposal")
}
// CommunityPoolMultiSpendProposal spends from the community pool by sending to one or more addresses
type CommunityPoolMultiSpendProposal struct {
Title string `json:"title" yaml:"title"`
Description string `json:"description" yaml:"description"`
RecipientList MultiSpendRecipients `json:"recipient_list" yaml:"recipient_list"`
}
// NewCommunityPoolMultiSpendProposal creates a new community pool multi-spend proposal.
func NewCommunityPoolMultiSpendProposal(title, description string, recipientList MultiSpendRecipients) CommunityPoolMultiSpendProposal {
return CommunityPoolMultiSpendProposal{
Title: title,
Description: description,
RecipientList: recipientList}
}
// GetTitle returns the title of a community pool multi-spend proposal.
func (csp CommunityPoolMultiSpendProposal) GetTitle() string { return csp.Title }
// GetDescription returns the description of a community pool multi-spend proposal.
func (csp CommunityPoolMultiSpendProposal) GetDescription() string { return csp.Description }
// GetDescription returns the routing key of a community pool multi-spend proposal.
func (csp CommunityPoolMultiSpendProposal) ProposalRoute() string { return RouterKey }
// ProposalType returns the type of a community pool multi-spend proposal.
func (csp CommunityPoolMultiSpendProposal) ProposalType() string {
return ProposalTypeCommunityPoolMultiSpend
}
// ValidateBasic stateless validation of a community pool multi-spend proposal.
func (csp CommunityPoolMultiSpendProposal) ValidateBasic() error {
err := govtypes.ValidateAbstract(csp)
if err != nil {
return err
}
if err := csp.RecipientList.Validate(); err != nil {
return err
}
return nil
}
// String implements fmt.Stringer
func (csp CommunityPoolMultiSpendProposal) String() string {
var b strings.Builder
b.WriteString(fmt.Sprintf(`Community Pool Multi Spend Proposal:
Title: %s
Description: %s
Recipient List: %s
`, csp.Title, csp.Description, csp.RecipientList))
return b.String()
}
// MultiSpendRecipient defines a recipient and the amount of coins they are receiving
type MultiSpendRecipient struct {
Address sdk.AccAddress `json:"address" yaml:"address"`
Amount sdk.Coins `json:"amount" yaml:"amount"`
}
// Validate stateless validation of MultiSpendRecipient
func (msr MultiSpendRecipient) Validate() error {
if !msr.Amount.IsValid() {
return ErrInvalidProposalAmount
}
if msr.Address.Empty() {
return ErrEmptyProposalRecipient
}
return nil
}
// String implements fmt.Stringer
func (msr MultiSpendRecipient) String() string {
return fmt.Sprintf(`Receiver: %s
Amount: %s
`, msr.Address, msr.Amount)
}
// MultiSpendRecipients slice of MultiSpendRecipient
type MultiSpendRecipients []MultiSpendRecipient
// Validate stateless validation of MultiSpendRecipients
func (msrs MultiSpendRecipients) Validate() error {
for _, msr := range msrs {
if err := msr.Validate(); err != nil {
return err
}
}
return nil
}
// String implements fmt.Stringer
func (msrs MultiSpendRecipients) String() string {
out := ""
for _, msr := range msrs {
out += msr.String()
}
return out
}