feat(x/accounts): On-chain multisig (#19988)

This commit is contained in:
Facundo Medica 2024-05-06 13:26:49 +02:00 committed by GitHub
parent fc310b6198
commit f8b18f1d52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 14620 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@ -23,4 +23,8 @@ Ref: https://keepachangelog.com/en/1.0.0/
# Changelog
## [Unreleased]
## [Unreleased]
### Features
* [#19988](https://github.com/cosmos/cosmos-sdk/pull/19988) Implemented `x/accounts/multisig`.

View File

@ -25,3 +25,7 @@ func NewMockContext(
ctx, ss, accNumber, accountAddr, sender, funds, moduleExec, moduleExecUntyped, moduleQuery,
), ss
}
func SetSender(ctx context.Context, sender []byte) context.Context {
return implementation.SetSender(ctx, sender)
}

View File

@ -38,6 +38,7 @@ func NewAccount(name string, handlerMap *signing.HandlerMap) accountstd.AccountC
Sequence: collections.NewSequence(deps.SchemaBuilder, SequencePrefix, "sequence"),
addrCodec: deps.AddressCodec,
signingHandlers: handlerMap,
hs: deps.Environment.HeaderService,
}, nil
}
}

View File

@ -0,0 +1,153 @@
# Multisig Accounts
* [State](#state)
* [Config](#config)
* [Proposal](#proposal)
* [Members](#members)
* [Methods](#methods)
* [MsgInit](#msginit)
* [MsgUpdateConfig](#msgupdateconfig)
* [MsgCreateProposal](#msgcreateproposal)
* [MsgVote](#msgvote)
* [MsgExecuteProposal](#msgexecuteproposal)
The x/accounts/defaults/multisig module provides the implementation for multisig accounts within the x/accounts module.
## State
The multisig account keeps its members as a map of addresses and weights (`<[]byte, uint64>`), a config struct, a map of proposals and a map of votes.
```go
type Account struct {
Members collections.Map[[]byte, uint64]
Sequence collections.Sequence
Config collections.Item[v1.Config]
addrCodec address.Codec
headerService header.Service
eventService event.Service
Proposals collections.Map[uint64, v1.Proposal]
Votes collections.Map[collections.Pair[uint64, []byte], int32] // key: proposalID + voter address
}
```
### Config
The config contains the basic rules defining how the multisig will work. All of these fields can be modified afterwards by calling [MsgUpdateConfig](#msgupdateconfig).
```protobuf
message Config {
int64 threshold = 1;
int64 quorum = 2;
// voting_period is the duration in seconds for the voting period.
int64 voting_period = 3;
// revote defines if members can change their vote.
bool revote = 4;
// early_execution defines if the multisig can be executed before the voting period ends.
bool early_execution = 5;
}
```
### Proposal
The proposal contains the title, summary, messages and the status of the proposal. The messages are stored as `google.protobuf.Any` to allow for any type of message to be stored.
```protobuf
message Proposal {
string title = 1;
string summary = 2;
repeated google.protobuf.Any messages = 3;
// if true, the proposal will execute as soon as the quorum is reached (last voter will execute).
bool execute = 4;
// voting_period_end will be set by the account when the proposal is created.
int64 voting_period_end = 5;
ProposalStatus status = 6;
}
```
### Members
Members are stored as a map of addresses and weights. The weight is used to determine the voting power of the member.
## Methods
### MsgInit
The `MsgInit` message initializes a multisig account with the given members and config.
```protobuf
message MsgInit {
repeated Member members = 1;
Config Config = 2;
}
```
### MsgUpdateConfig
The `MsgUpdateConfig` message updates the config of the multisig account. Only the members that are changing are required, and if their weight is 0, they are removed. If the config is nil, then it will not be updated.
```protobuf
message MsgUpdateConfig {
// only the members that are changing are required, if their weight is 0, they are removed.
repeated Member update_members = 1;
// not all fields from Config can be changed
Config Config = 2;
}
```
### MsgCreateProposal
Only a member can create a proposal. The proposal will be stored in the account and the members will be able to vote on it.
If a voting period is not set, the proposal will be created using the voting period from the config. If the proposal has a voting period, it will be used instead.
```protobuf
message MsgCreateProposal {
Proposal proposal = 1;
}
```
### MsgVote
The `MsgVote` message allows a member to vote on a proposal. The vote can be either `Yes`, `No` or `Abstain`.
```protobuf
message MsgVote {
uint64 proposal_id = 1;
VoteOption vote = 2;
}
// VoteOption enumerates the valid vote options for a given proposal.
enum VoteOption {
// VOTE_OPTION_UNSPECIFIED defines a no-op vote option.
VOTE_OPTION_UNSPECIFIED = 0;
// VOTE_OPTION_YES defines the yes proposal vote option.
VOTE_OPTION_YES = 1;
// VOTE_OPTION_ABSTAIN defines the abstain proposal vote option.
VOTE_OPTION_ABSTAIN = 2;
// VOTE_OPTION_NO defines the no proposal vote option.
VOTE_OPTION_NO = 3;
}
```
### MsgExecuteProposal
The `MsgExecuteProposal` message allows a member to execute a proposal. The proposal must have reached the quorum and the voting period must have ended.
```protobuf
message MsgExecuteProposal {
uint64 proposal_id = 1;
}
```

View File

@ -0,0 +1,389 @@
package multisig
import (
"context"
"errors"
"fmt"
"time"
"cosmossdk.io/collections"
"cosmossdk.io/core/address"
"cosmossdk.io/core/event"
"cosmossdk.io/core/header"
"cosmossdk.io/x/accounts/accountstd"
v1 "cosmossdk.io/x/accounts/defaults/multisig/v1"
"github.com/cosmos/cosmos-sdk/codec"
)
var (
MembersPrefix = collections.NewPrefix(0)
SequencePrefix = collections.NewPrefix(1)
ConfigPrefix = collections.NewPrefix(2)
ProposalsPrefix = collections.NewPrefix(3)
VotesPrefix = collections.NewPrefix(4)
)
// Compile-time type assertions
var (
_ accountstd.Interface = (*Account)(nil)
)
type Account struct {
Members collections.Map[[]byte, uint64]
Sequence collections.Sequence
Config collections.Item[v1.Config]
addrCodec address.Codec
headerService header.Service
eventService event.Service
Proposals collections.Map[uint64, v1.Proposal]
Votes collections.Map[collections.Pair[uint64, []byte], int32] // key: proposalID + voter address
}
// NewAccount returns a new multisig account creator function.
func NewAccount(name string) accountstd.AccountCreatorFunc {
return func(deps accountstd.Dependencies) (string, accountstd.Interface, error) {
return name, &Account{
Members: collections.NewMap(deps.SchemaBuilder, MembersPrefix, "members", collections.BytesKey, collections.Uint64Value),
Sequence: collections.NewSequence(deps.SchemaBuilder, SequencePrefix, "sequence"),
Config: collections.NewItem(deps.SchemaBuilder, ConfigPrefix, "config", codec.CollValue[v1.Config](deps.LegacyStateCodec)),
Proposals: collections.NewMap(deps.SchemaBuilder, ProposalsPrefix, "proposals", collections.Uint64Key, codec.CollValue[v1.Proposal](deps.LegacyStateCodec)),
Votes: collections.NewMap(deps.SchemaBuilder, VotesPrefix, "votes", collections.PairKeyCodec(collections.Uint64Key, collections.BytesKey), collections.Int32Value),
addrCodec: deps.AddressCodec,
headerService: deps.Environment.HeaderService,
eventService: deps.Environment.EventService,
}, nil
}
}
// Init initializes the multisig account with the given configuration and members.
func (a *Account) Init(ctx context.Context, msg *v1.MsgInit) (*v1.MsgInitResponse, error) {
if msg.Config == nil {
return nil, errors.New("config must be specified")
}
if len(msg.Members) == 0 {
return nil, errors.New("members must be specified")
}
// set members
totalWeight := uint64(0)
membersMap := map[string]struct{}{} // to check for duplicates
for i := range msg.Members {
if _, ok := membersMap[msg.Members[i].Address]; ok {
return nil, errors.New("duplicate member address found")
}
membersMap[msg.Members[i].Address] = struct{}{}
addrBz, err := a.addrCodec.StringToBytes(msg.Members[i].Address)
if err != nil {
return nil, err
}
if msg.Members[i].Weight == 0 {
return nil, errors.New("member weight must be greater than zero")
}
if err := a.Members.Set(ctx, addrBz, msg.Members[i].Weight); err != nil {
return nil, err
}
totalWeight += msg.Members[i].Weight
}
if err := validateConfig(*msg.Config, totalWeight); err != nil {
return nil, err
}
if err := a.Config.Set(ctx, *msg.Config); err != nil {
return nil, err
}
return &v1.MsgInitResponse{}, nil
}
// Vote casts a vote on a proposal. The sender must be a member of the multisig and the proposal must be in the voting period.
func (a Account) Vote(ctx context.Context, msg *v1.MsgVote) (*v1.MsgVoteResponse, error) {
if msg.Vote <= v1.VoteOption_VOTE_OPTION_UNSPECIFIED {
return nil, errors.New("vote must be specified")
}
cfg, err := a.Config.Get(ctx)
if err != nil {
return nil, err
}
sender := accountstd.Sender(ctx)
// check if the voter is a member
_, err = a.Members.Get(ctx, sender)
if err != nil {
return nil, err
}
// check if the proposal exists
prop, err := a.Proposals.Get(ctx, msg.ProposalId)
if err != nil {
return nil, err
}
// check if the voting period has ended
if a.headerService.HeaderInfo(ctx).Time.Unix() > prop.VotingPeriodEnd || prop.Status != v1.ProposalStatus_PROPOSAL_STATUS_VOTING_PERIOD {
return nil, errors.New("voting period has ended")
}
// check if the voter has already voted
_, err = a.Votes.Get(ctx, collections.Join(msg.ProposalId, sender))
if err == nil && !cfg.Revote {
return nil, errors.New("voter has already voted, can't change its vote per config")
}
if err != nil && !errors.Is(err, collections.ErrNotFound) {
return nil, err
}
addr, err := a.addrCodec.BytesToString(sender)
if err != nil {
return nil, err
}
if err = a.eventService.EventManager(ctx).EmitKV("vote",
event.NewAttribute("proposal_id", fmt.Sprint(msg.ProposalId)),
event.NewAttribute("voter", addr),
event.NewAttribute("vote", msg.Vote.String()),
); err != nil {
return nil, err
}
return &v1.MsgVoteResponse{}, a.Votes.Set(ctx, collections.Join(msg.ProposalId, sender), int32(msg.Vote))
}
// CreateProposal creates a new proposal. If the proposal contains a voting
// period it will be used, otherwise the default voting period will be used.
func (a Account) CreateProposal(ctx context.Context, msg *v1.MsgCreateProposal) (*v1.MsgCreateProposalResponse, error) {
// check if the sender is a member
_, err := a.Members.Get(ctx, accountstd.Sender(ctx))
if err != nil {
return nil, err
}
seq, err := a.Sequence.Next(ctx)
if err != nil {
return nil, err
}
// check if the proposal already exists
_, err = a.Proposals.Get(ctx, seq)
if err == nil {
return nil, errors.New("proposal already exists")
}
config, err := a.Config.Get(ctx)
if err != nil {
return nil, err
}
// create the proposal
proposal := v1.Proposal{
Title: msg.Proposal.Title,
Summary: msg.Proposal.Summary,
Messages: msg.Proposal.Messages,
Status: v1.ProposalStatus_PROPOSAL_STATUS_VOTING_PERIOD,
}
// set the voting period, if not specified, use the default
if msg.Proposal.VotingPeriodEnd != 0 {
proposal.VotingPeriodEnd = msg.Proposal.VotingPeriodEnd
} else {
proposal.VotingPeriodEnd = a.headerService.HeaderInfo(ctx).Time.Add(time.Second * time.Duration(config.VotingPeriod)).Unix()
}
if err = a.Proposals.Set(ctx, seq, proposal); err != nil {
return nil, err
}
addr, err := a.addrCodec.BytesToString(accountstd.Sender(ctx))
if err != nil {
return nil, err
}
if err = a.eventService.EventManager(ctx).EmitKV("proposal_created",
event.NewAttribute("proposal_id", fmt.Sprint(seq)),
event.NewAttribute("proposer", addr),
); err != nil {
return nil, err
}
return &v1.MsgCreateProposalResponse{ProposalId: seq}, nil
}
// deleteProposalAndVotes deletes a proposal and its votes, pruning the state.
func (a Account) deleteProposalAndVotes(ctx context.Context, proposalID uint64) error {
// delete the proposal
if err := a.Proposals.Remove(ctx, proposalID); err != nil {
return err
}
// delete the votes
rng := collections.NewPrefixedPairRange[uint64, []byte](proposalID)
return a.Votes.Clear(ctx, rng)
}
// ExecuteProposal tallies the votes for a proposal and executes it if it passes. If early execution is enabled, it will
// ignore the voting period and tally the votes without deleting them if the proposal has not passed.
func (a Account) ExecuteProposal(ctx context.Context, msg *v1.MsgExecuteProposal) (*v1.MsgExecuteProposalResponse, error) {
prop, err := a.Proposals.Get(ctx, msg.ProposalId)
if err != nil {
return nil, err
}
config, err := a.Config.Get(ctx)
if err != nil {
return nil, err
}
// check if voting period is still active and early execution is disabled
votingPeriodEnded := a.headerService.HeaderInfo(ctx).Time.Unix() > prop.VotingPeriodEnd
if !votingPeriodEnded && !config.EarlyExecution {
return nil, errors.New("voting period has not ended yet, and early execution is not enabled")
}
// perform tally
rng := collections.NewPrefixedPairRange[uint64, []byte](msg.ProposalId)
yesVotes := uint64(0)
noVotes := uint64(0)
abstainVotes := uint64(0)
err = a.Votes.Walk(ctx, rng, func(key collections.Pair[uint64, []byte], vote int32) (stop bool, err error) {
weight, err := a.Members.Get(ctx, key.K2())
if errors.Is(err, collections.ErrNotFound) {
// edge case: if a member has been removed after voting, we should ignore their vote
return false, nil
} else if err != nil {
return true, err
}
switch v1.VoteOption(vote) {
case v1.VoteOption_VOTE_OPTION_YES:
yesVotes += weight
case v1.VoteOption_VOTE_OPTION_NO:
noVotes += weight
case v1.VoteOption_VOTE_OPTION_ABSTAIN:
abstainVotes += weight
}
return false, nil
})
if err != nil {
return nil, err
}
totalWeight := yesVotes + noVotes + abstainVotes
var (
rejectErr error
execErr error
)
resp := &v1.MsgExecuteProposalResponse{}
if totalWeight < uint64(config.Quorum) {
rejectErr = errors.New("quorum not reached")
prop.Status = v1.ProposalStatus_PROPOSAL_STATUS_REJECTED
} else if yesVotes < uint64(config.Threshold) {
rejectErr = errors.New("threshold not reached")
prop.Status = v1.ProposalStatus_PROPOSAL_STATUS_REJECTED
} else {
// we have quorum and threshold, execute the proposal
prop.Status = v1.ProposalStatus_PROPOSAL_STATUS_PASSED
resp.Responses, execErr = accountstd.ExecModuleAnys(ctx, prop.Messages) // do not return this error, just emit the event
}
// if early execution is enabled, we return early if the proposal has NOT passed
if config.EarlyExecution && prop.Status != v1.ProposalStatus_PROPOSAL_STATUS_PASSED {
return nil, errors.New("early execution attempted and proposal has not passed")
}
if err = a.deleteProposalAndVotes(ctx, msg.ProposalId); err != nil {
return nil, err
}
if err = a.eventService.EventManager(ctx).EmitKV("proposal_tally",
event.NewAttribute("proposal_id", fmt.Sprint(msg.ProposalId)),
event.NewAttribute("yes_votes", fmt.Sprint(yesVotes)),
event.NewAttribute("no_votes", fmt.Sprint(noVotes)),
event.NewAttribute("abstain_votes", fmt.Sprint(abstainVotes)),
event.NewAttribute("status", prop.Status.String()),
event.NewAttribute("reject_err", fmt.Sprint(rejectErr)),
event.NewAttribute("exec_err", fmt.Sprint(execErr)),
); err != nil {
return nil, err
}
err = a.Proposals.Set(ctx, msg.ProposalId, prop)
if err != nil {
return nil, err
}
return resp, nil
}
// QuerySequence returns the current sequence number, used for proposal IDs.
func (a Account) QuerySequence(ctx context.Context, _ *v1.QuerySequence) (*v1.QuerySequenceResponse, error) {
seq, err := a.Sequence.Peek(ctx)
if err != nil {
return nil, err
}
return &v1.QuerySequenceResponse{Sequence: seq}, nil
}
// QueryProposal returns a proposal for a given ID, if the proposal hasn't been pruned yet.
func (a Account) QueryProposal(ctx context.Context, q *v1.QueryProposal) (*v1.QueryProposalResponse, error) {
proposal, err := a.Proposals.Get(ctx, q.ProposalId)
if err != nil {
return nil, err
}
return &v1.QueryProposalResponse{Proposal: &proposal}, nil
}
// QueryConfig returns the current multisig configuration.
func (a Account) QueryConfig(ctx context.Context, _ *v1.QueryConfig) (*v1.QueryConfigResponse, error) {
cfg, err := a.Config.Get(ctx)
if err != nil {
return nil, err
}
members := []*v1.Member{}
err = a.Members.Walk(ctx, nil, func(addr []byte, weight uint64) (stop bool, err error) {
addrStr, err := a.addrCodec.BytesToString(addr)
if err != nil {
return true, err
}
members = append(members, &v1.Member{Address: addrStr, Weight: weight})
return false, nil
})
if err != nil {
return nil, err
}
return &v1.QueryConfigResponse{Config: &cfg, Members: members}, nil
}
// RegisterExecuteHandlers implements implementation.Account.
func (a *Account) RegisterExecuteHandlers(builder *accountstd.ExecuteBuilder) {
accountstd.RegisterExecuteHandler(builder, a.Vote)
accountstd.RegisterExecuteHandler(builder, a.CreateProposal)
accountstd.RegisterExecuteHandler(builder, a.ExecuteProposal)
accountstd.RegisterExecuteHandler(builder, a.UpdateConfig)
}
// RegisterInitHandler implements implementation.Account.
func (a *Account) RegisterInitHandler(builder *accountstd.InitBuilder) {
accountstd.RegisterInitHandler(builder, a.Init)
}
// RegisterQueryHandlers implements implementation.Account.
func (a *Account) RegisterQueryHandlers(builder *accountstd.QueryBuilder) {
accountstd.RegisterQueryHandler(builder, a.QuerySequence)
accountstd.RegisterQueryHandler(builder, a.QueryProposal)
accountstd.RegisterQueryHandler(builder, a.QueryConfig)
}

View File

@ -0,0 +1,580 @@
package multisig
import (
"context"
"testing"
"time"
types "github.com/cosmos/gogoproto/types/any"
"github.com/stretchr/testify/require"
"cosmossdk.io/core/store"
"cosmossdk.io/x/accounts/accountstd"
v1 "cosmossdk.io/x/accounts/defaults/multisig/v1"
)
func setup(t *testing.T, ctx context.Context, ss store.KVStoreService, timefn func() time.Time) *Account {
t.Helper()
deps := makeMockDependencies(ss, timefn)
multisig := NewAccount("multisig")
_, acc, err := multisig(deps)
require.NoError(t, err)
return acc.(*Account)
}
func TestInit(t *testing.T) {
ctx, ss := newMockContext(t)
acc := setup(t, ctx, ss, nil)
testcases := []struct {
name string
msg *v1.MsgInit
expErr string
}{
{
"success",
&v1.MsgInit{
Config: &v1.Config{
Threshold: 666,
Quorum: 400,
VotingPeriod: 60,
},
Members: []*v1.Member{
{
Address: "addr1",
Weight: 500,
},
{
Address: "addr2",
Weight: 1000,
},
},
},
"",
},
{
"no members",
&v1.MsgInit{
Config: &v1.Config{
Threshold: 666,
Quorum: 400,
VotingPeriod: 60,
},
Members: []*v1.Member{},
},
"members must be specified",
},
{
"no config",
&v1.MsgInit{
Config: nil,
Members: []*v1.Member{
{
Address: "addr1",
Weight: 500,
},
},
},
"config must be specified",
},
{
"member weight zero",
&v1.MsgInit{
Config: &v1.Config{
Threshold: 666,
Quorum: 400,
VotingPeriod: 60,
},
Members: []*v1.Member{
{
Address: "addr1",
Weight: 0,
},
},
},
"member weight must be greater than zero",
},
{
"threshold is zero",
&v1.MsgInit{
Config: &v1.Config{
Threshold: 0,
Quorum: 400,
VotingPeriod: 60,
},
Members: []*v1.Member{
{
Address: "addr1",
Weight: 500,
},
},
},
"threshold, quorum and voting period must be greater than zero",
},
{
"threshold greater than total weight",
&v1.MsgInit{
Config: &v1.Config{
Threshold: 2000,
Quorum: 400,
VotingPeriod: 60,
},
Members: []*v1.Member{
{
Address: "addr1",
Weight: 500,
},
{
Address: "addr2",
Weight: 1000,
},
},
},
"threshold must be less than or equal to the total weight",
},
{
"quorum greater than total weight",
&v1.MsgInit{
Config: &v1.Config{
Threshold: 666,
Quorum: 2000,
VotingPeriod: 60,
},
Members: []*v1.Member{
{
Address: "addr1",
Weight: 500,
},
{
Address: "addr2",
Weight: 1000,
},
},
},
"quorum must be less than or equal to the total weight",
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
_, err := acc.Init(ctx, tc.msg)
if tc.expErr != "" {
require.EqualError(t, err, tc.expErr)
return
}
require.NoError(t, err)
})
}
}
func TestUpdateConfig(t *testing.T) {
// all test cases start from the same initial state
startAcc := &v1.MsgInit{
Config: &v1.Config{
Threshold: 2640,
Quorum: 2000,
VotingPeriod: 60,
},
Members: []*v1.Member{
{
Address: "addr1",
Weight: 1000,
},
{
Address: "addr2",
Weight: 1000,
},
{
Address: "addr3",
Weight: 1000,
},
{
Address: "addr4",
Weight: 1000,
},
},
}
testcases := []struct {
name string
msg *v1.MsgUpdateConfig
expErr string
expCfg *v1.Config
expMembers []*v1.Member
}{
{
"change members",
&v1.MsgUpdateConfig{
UpdateMembers: []*v1.Member{
{
Address: "addr1",
Weight: 500,
},
{
Address: "addr2",
Weight: 700,
},
},
Config: &v1.Config{
Threshold: 666,
Quorum: 400,
VotingPeriod: 60,
},
},
"",
&v1.Config{
Threshold: 666,
Quorum: 400,
VotingPeriod: 60,
},
[]*v1.Member{
{
Address: "addr1",
Weight: 500,
},
{
Address: "addr2",
Weight: 700,
},
{
Address: "addr3",
Weight: 1000,
},
{
Address: "addr4",
Weight: 1000,
},
},
},
{
"remove member",
&v1.MsgUpdateConfig{
UpdateMembers: []*v1.Member{
{
Address: "addr1",
Weight: 0,
},
},
Config: nil,
},
"",
nil,
[]*v1.Member{
{
Address: "addr2",
Weight: 1000,
},
{
Address: "addr3",
Weight: 1000,
},
{
Address: "addr4",
Weight: 1000,
},
},
},
{
"add member",
&v1.MsgUpdateConfig{
UpdateMembers: []*v1.Member{
{
Address: "addr5",
Weight: 200,
},
},
Config: nil,
},
"",
nil,
[]*v1.Member{
{
Address: "addr1",
Weight: 1000,
},
{
Address: "addr2",
Weight: 1000,
},
{
Address: "addr3",
Weight: 1000,
},
{
Address: "addr4",
Weight: 1000,
},
{
Address: "addr5",
Weight: 200,
},
},
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
ctx, ss := newMockContext(t)
acc := setup(t, ctx, ss, nil)
_, err := acc.Init(ctx, startAcc)
require.NoError(t, err)
_, err = acc.UpdateConfig(ctx, tc.msg)
if tc.expErr != "" {
require.EqualError(t, err, tc.expErr)
return
}
require.NoError(t, err)
cfg, err := acc.QueryConfig(ctx, &v1.QueryConfig{})
require.NoError(t, err)
// if we are not changing the config, we expect the same config as init
if tc.expCfg == nil {
require.Equal(t, startAcc.Config, cfg.Config)
} else {
require.Equal(t, tc.expCfg, cfg.Config)
}
require.Equal(t, tc.expMembers, cfg.Members)
})
}
}
func TestProposal_NotPassing(t *testing.T) {
// all test cases start from the same initial state
startAcc := &v1.MsgInit{
Config: &v1.Config{
Threshold: 2640,
Quorum: 2000,
VotingPeriod: 60,
},
Members: []*v1.Member{
{
Address: "addr1",
Weight: 1000,
},
{
Address: "addr2",
Weight: 1000,
},
{
Address: "addr3",
Weight: 1000,
},
{
Address: "addr4",
Weight: 1000,
},
},
}
ctx, ss := accountstd.NewMockContext(
0, []byte("multisig_acc"), []byte("addr1"), TestFunds, func(ctx context.Context, sender []byte, msg, msgResp ProtoMsg) error {
return nil
}, func(ctx context.Context, sender []byte, msg ProtoMsg) (ProtoMsg, error) {
if _, ok := msg.(*v1.MsgUpdateConfig); ok {
return &v1.MsgUpdateConfigResponse{}, nil
}
return nil, nil
}, func(ctx context.Context, req, resp ProtoMsg) error {
return nil
},
)
currentTime := time.Now()
acc := setup(t, ctx, ss, func() time.Time {
return currentTime
})
_, err := acc.Init(ctx, startAcc)
require.NoError(t, err)
msg := &v1.MsgUpdateConfig{
UpdateMembers: []*v1.Member{
{
Address: "addr1",
Weight: 500,
},
},
}
anymsg, err := accountstd.PackAny(msg)
require.NoError(t, err)
// create a proposal
createRes, err := acc.CreateProposal(ctx, &v1.MsgCreateProposal{
Proposal: &v1.Proposal{
Title: "test",
Summary: "test",
Messages: []*types.Any{anymsg},
},
})
require.NoError(t, err)
propId := createRes.ProposalId
_, err = acc.Vote(ctx, &v1.MsgVote{
ProposalId: propId,
Vote: v1.VoteOption_VOTE_OPTION_YES,
})
require.NoError(t, err)
_, err = acc.Vote(ctx, &v1.MsgVote{
ProposalId: propId,
Vote: v1.VoteOption_VOTE_OPTION_YES,
})
require.ErrorContains(t, err, "voter has already voted, can't change its vote per config")
ctx = accountstd.SetSender(ctx, []byte("addr2"))
_, err = acc.Vote(ctx, &v1.MsgVote{
ProposalId: propId,
Vote: v1.VoteOption_VOTE_OPTION_YES,
})
require.NoError(t, err)
// try to execute the proposal
_, err = acc.ExecuteProposal(ctx, &v1.MsgExecuteProposal{
ProposalId: propId,
})
require.ErrorContains(t, err, "voting period has not ended yet")
// fast forward time
currentTime = currentTime.Add(61 * time.Second)
_, err = acc.ExecuteProposal(ctx, &v1.MsgExecuteProposal{
ProposalId: propId,
})
require.NoError(t, err)
// check proposal status
prop, err := acc.QueryProposal(ctx, &v1.QueryProposal{
ProposalId: propId,
})
require.NoError(t, err)
require.Equal(t, v1.ProposalStatus_PROPOSAL_STATUS_REJECTED, prop.Proposal.Status)
// vote with addr3
ctx = accountstd.SetSender(ctx, []byte("addr3"))
_, err = acc.Vote(ctx, &v1.MsgVote{
ProposalId: propId,
Vote: v1.VoteOption_VOTE_OPTION_YES,
})
require.ErrorContains(t, err, "voting period has ended")
}
func TestProposalPassing(t *testing.T) {
// all test cases start from the same initial state
startAcc := &v1.MsgInit{
Config: &v1.Config{
Threshold: 2640,
Quorum: 2000,
VotingPeriod: 60,
},
Members: []*v1.Member{
{
Address: "addr1",
Weight: 1000,
},
{
Address: "addr2",
Weight: 1000,
},
{
Address: "addr3",
Weight: 1000,
},
{
Address: "addr4",
Weight: 1000,
},
},
}
var acc *Account
ctx, ss := accountstd.NewMockContext(
0, []byte("multisig_acc"), []byte("addr1"), TestFunds, func(ctx context.Context, sender []byte, msg, msgResp ProtoMsg) error {
return nil
}, func(ctx context.Context, sender []byte, msg ProtoMsg) (ProtoMsg, error) {
if _, ok := msg.(*v1.MsgUpdateConfig); ok {
return acc.UpdateConfig(ctx, msg.(*v1.MsgUpdateConfig))
}
return nil, nil
}, func(ctx context.Context, req, resp ProtoMsg) error {
return nil
},
)
currentTime := time.Now()
acc = setup(t, ctx, ss, func() time.Time {
return currentTime
})
_, err := acc.Init(ctx, startAcc)
require.NoError(t, err)
msg := &v1.MsgUpdateConfig{
UpdateMembers: []*v1.Member{
{
Address: "addr1",
Weight: 500,
},
},
}
anymsg, err := accountstd.PackAny(msg)
require.NoError(t, err)
// create a proposal
createRes, err := acc.CreateProposal(ctx, &v1.MsgCreateProposal{
Proposal: &v1.Proposal{
Title: "test",
Summary: "test",
Messages: []*types.Any{anymsg},
},
})
require.NoError(t, err)
propId := createRes.ProposalId
_, err = acc.Vote(ctx, &v1.MsgVote{
ProposalId: propId,
Vote: v1.VoteOption_VOTE_OPTION_YES,
})
require.NoError(t, err)
_, err = acc.Vote(ctx, &v1.MsgVote{
ProposalId: propId,
Vote: v1.VoteOption_VOTE_OPTION_YES,
})
require.ErrorContains(t, err, "voter has already voted, can't change its vote per config")
ctx = accountstd.SetSender(ctx, []byte("addr2"))
_, err = acc.Vote(ctx, &v1.MsgVote{
ProposalId: propId,
Vote: v1.VoteOption_VOTE_OPTION_YES,
})
require.NoError(t, err)
// vote with addr3
ctx = accountstd.SetSender(ctx, []byte("addr3"))
_, err = acc.Vote(ctx, &v1.MsgVote{
ProposalId: propId,
Vote: v1.VoteOption_VOTE_OPTION_YES,
})
require.NoError(t, err)
// fast forward time
currentTime = currentTime.Add(61 * time.Second)
_, err = acc.ExecuteProposal(ctx, &v1.MsgExecuteProposal{
ProposalId: propId,
})
require.NoError(t, err)
// check if addr1's weight changed
cfg, err := acc.QueryConfig(ctx, &v1.QueryConfig{})
require.NoError(t, err)
for _, v := range cfg.Members {
if v.Address == "addr1" {
require.Equal(t, uint64(500), v.Weight)
}
}
}

View File

@ -0,0 +1,78 @@
package multisig
import (
"context"
"errors"
v1 "cosmossdk.io/x/accounts/defaults/multisig/v1"
)
// UpdateConfig updates the configuration of the multisig account.
func (a Account) UpdateConfig(ctx context.Context, msg *v1.MsgUpdateConfig) (*v1.MsgUpdateConfigResponse, error) {
// set members
for i := range msg.UpdateMembers {
addrBz, err := a.addrCodec.StringToBytes(msg.UpdateMembers[i].Address)
if err != nil {
return nil, err
}
if msg.UpdateMembers[i].Weight == 0 {
if err := a.Members.Remove(ctx, addrBz); err != nil {
return nil, err
}
continue
}
if err := a.Members.Set(ctx, addrBz, msg.UpdateMembers[i].Weight); err != nil {
return nil, err
}
}
if msg.Config != nil {
// set config
if err := a.Config.Set(ctx, *msg.Config); err != nil {
return nil, err
}
}
// verify that the new set of members and config are valid
// get the weight from the stored members
totalWeight := uint64(0)
err := a.Members.Walk(ctx, nil, func(_ []byte, value uint64) (stop bool, err error) {
totalWeight += value
return false, nil
})
if err != nil {
return nil, err
}
// get the config from state given that we might not have it in the message
config, err := a.Config.Get(ctx)
if err != nil {
return nil, err
}
if err := validateConfig(config, totalWeight); err != nil {
return nil, err
}
return &v1.MsgUpdateConfigResponse{}, nil
}
func validateConfig(cfg v1.Config, totalWeight uint64) error {
// check for zero values
if cfg.Threshold <= 0 || cfg.Quorum <= 0 || cfg.VotingPeriod <= 0 {
return errors.New("threshold, quorum and voting period must be greater than zero")
}
// threshold must be less than or equal to the total weight
if totalWeight < uint64(cfg.Threshold) {
return errors.New("threshold must be less than or equal to the total weight")
}
// quota must be less than or equal to the total weight
if totalWeight < uint64(cfg.Quorum) {
return errors.New("quorum must be less than or equal to the total weight")
}
return nil
}

View File

@ -0,0 +1,213 @@
package multisig
import (
"context"
"testing"
"time"
gogoproto "github.com/cosmos/gogoproto/proto"
types "github.com/cosmos/gogoproto/types/any"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/runtime/protoiface"
"cosmossdk.io/collections"
"cosmossdk.io/core/appmodule/v2"
"cosmossdk.io/core/event"
"cosmossdk.io/core/header"
"cosmossdk.io/core/store"
"cosmossdk.io/math"
"cosmossdk.io/x/accounts/accountstd"
banktypes "cosmossdk.io/x/bank/types"
"github.com/cosmos/cosmos-sdk/codec"
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
"github.com/cosmos/cosmos-sdk/runtime"
sdk "github.com/cosmos/cosmos-sdk/types"
)
type ProtoMsg = protoiface.MessageV1
var TestFunds = sdk.NewCoins(sdk.NewCoin("test", math.NewInt(10)))
// mock statecodec
type mockStateCodec struct {
codec.Codec
}
// GetMsgAnySigners implements codec.Codec.
func (mockStateCodec) GetMsgAnySigners(msg *types.Any) ([][]byte, protoreflect.ProtoMessage, error) {
panic("unimplemented")
}
// GetMsgV1Signers implements codec.Codec.
func (mockStateCodec) GetMsgV1Signers(msg gogoproto.Message) ([][]byte, protoreflect.ProtoMessage, error) {
panic("unimplemented")
}
// GetMsgV2Signers implements codec.Codec.
func (mockStateCodec) GetMsgV2Signers(msg protoreflect.ProtoMessage) ([][]byte, error) {
panic("unimplemented")
}
// InterfaceRegistry implements codec.Codec.
func (mockStateCodec) InterfaceRegistry() codectypes.InterfaceRegistry {
panic("unimplemented")
}
// MarshalInterface implements codec.Codec.
func (mockStateCodec) MarshalInterface(i gogoproto.Message) ([]byte, error) {
panic("unimplemented")
}
// MarshalInterfaceJSON implements codec.Codec.
func (mockStateCodec) MarshalInterfaceJSON(i gogoproto.Message) ([]byte, error) {
panic("unimplemented")
}
// MarshalJSON implements codec.Codec.
func (mockStateCodec) MarshalJSON(o gogoproto.Message) ([]byte, error) {
panic("unimplemented")
}
// MarshalLengthPrefixed implements codec.Codec.
func (mockStateCodec) MarshalLengthPrefixed(o gogoproto.Message) ([]byte, error) {
panic("unimplemented")
}
// MustMarshal implements codec.Codec.
func (mockStateCodec) MustMarshal(o gogoproto.Message) []byte {
panic("unimplemented")
}
// MustMarshalJSON implements codec.Codec.
func (mockStateCodec) MustMarshalJSON(o gogoproto.Message) []byte {
panic("unimplemented")
}
// MustMarshalLengthPrefixed implements codec.Codec.
func (mockStateCodec) MustMarshalLengthPrefixed(o gogoproto.Message) []byte {
panic("unimplemented")
}
// MustUnmarshal implements codec.Codec.
func (mockStateCodec) MustUnmarshal(bz []byte, ptr gogoproto.Message) {
panic("unimplemented")
}
// MustUnmarshalJSON implements codec.Codec.
func (mockStateCodec) MustUnmarshalJSON(bz []byte, ptr gogoproto.Message) {
panic("unimplemented")
}
// MustUnmarshalLengthPrefixed implements codec.Codec.
func (mockStateCodec) MustUnmarshalLengthPrefixed(bz []byte, ptr gogoproto.Message) {
panic("unimplemented")
}
// UnmarshalInterface implements codec.Codec.
func (mockStateCodec) UnmarshalInterface(bz []byte, ptr interface{}) error {
panic("unimplemented")
}
// UnmarshalInterfaceJSON implements codec.Codec.
func (mockStateCodec) UnmarshalInterfaceJSON(bz []byte, ptr interface{}) error {
panic("unimplemented")
}
// UnmarshalJSON implements codec.Codec.
func (mockStateCodec) UnmarshalJSON(bz []byte, ptr gogoproto.Message) error {
panic("unimplemented")
}
// UnmarshalLengthPrefixed implements codec.Codec.
func (mockStateCodec) UnmarshalLengthPrefixed(bz []byte, ptr gogoproto.Message) error {
panic("unimplemented")
}
// UnpackAny implements codec.Codec.
func (mockStateCodec) UnpackAny(any *types.Any, iface interface{}) error {
panic("unimplemented")
}
var _ codec.Codec = mockStateCodec{}
func (c mockStateCodec) Marshal(m gogoproto.Message) ([]byte, error) {
// Size() check can catch the typed nil value.
if m == nil || gogoproto.Size(m) == 0 {
// return empty bytes instead of nil, because nil has special meaning in places like store.Set
return []byte{}, nil
}
return gogoproto.Marshal(m)
}
func (c mockStateCodec) Unmarshal(bz []byte, ptr gogoproto.Message) error {
err := gogoproto.Unmarshal(bz, ptr)
return err
}
type (
ModuleExecUntypedFunc = func(ctx context.Context, sender []byte, msg ProtoMsg) (ProtoMsg, error)
ModuleExecFunc = func(ctx context.Context, sender []byte, msg, msgResp ProtoMsg) error
ModuleQueryFunc = func(ctx context.Context, queryReq, queryResp ProtoMsg) error
)
// mock address codec
type addressCodec struct{}
func (a addressCodec) StringToBytes(text string) ([]byte, error) { return []byte(text), nil }
func (a addressCodec) BytesToString(bz []byte) (string, error) { return string(bz), nil }
func newMockContext(t *testing.T) (context.Context, store.KVStoreService) {
t.Helper()
return accountstd.NewMockContext(
0, []byte("multisig_account"), []byte("sender"), TestFunds, func(ctx context.Context, sender []byte, msg, msgResp ProtoMsg) error {
return nil
}, func(ctx context.Context, sender []byte, msg ProtoMsg) (ProtoMsg, error) {
return nil, nil
}, func(ctx context.Context, req, resp ProtoMsg) error {
_, ok := req.(*banktypes.QueryBalanceRequest)
require.True(t, ok)
gogoproto.Merge(resp.(gogoproto.Message), &banktypes.QueryBalanceResponse{
Balance: &sdk.Coin{
Denom: "test",
Amount: math.NewInt(5),
},
})
return nil
},
)
}
func makeMockDependencies(storeservice store.KVStoreService, timefn func() time.Time) accountstd.Dependencies {
sb := collections.NewSchemaBuilder(storeservice)
return accountstd.Dependencies{
SchemaBuilder: sb,
AddressCodec: addressCodec{},
LegacyStateCodec: mockStateCodec{},
Environment: appmodule.Environment{
HeaderService: headerService{timefn},
EventService: eventService{},
},
}
}
type headerService struct {
timefn func() time.Time
}
func (h headerService) HeaderInfo(context.Context) header.Info {
return header.Info{
Time: h.timefn(),
}
}
type eventService struct{}
// EventManager implements event.Service.
func (eventService) EventManager(context.Context) event.Manager {
return runtime.EventService{Events: runtime.Events{EventManagerI: sdk.NewEventManager()}}
}

File diff suppressed because it is too large Load Diff

View File

@ -70,6 +70,12 @@ func MakeAccountContext(
})
}
func SetSender(ctx context.Context, sender []byte) context.Context {
v := getCtx(ctx)
v.sender = sender
return addCtx(v.parentContext, v)
}
// makeAccountStore creates the prefixed store for the account.
// It uses the number of the account, this gives constant size
// bytes prefixes for the account state.

View File

@ -0,0 +1,136 @@
syntax = "proto3";
package cosmos.accounts.defaults.multisig.v1;
import "google/protobuf/any.proto";
import "cosmos/msg/v1/msg.proto";
import "cosmos_proto/cosmos.proto";
option go_package = "cosmossdk.io/x/accounts/defaults/multisig/v1";
// MsgInit is used to initialize a multisig account.
message MsgInit {
repeated Member members = 1;
Config Config = 2;
}
// MsgInitResponse is the response returned after account initialization.
message MsgInitResponse {}
message MsgCreateProposal {
Proposal proposal = 1;
}
message MsgCreateProposalResponse {
uint64 proposal_id = 1;
}
message MsgVote {
uint64 proposal_id = 1;
VoteOption vote = 2;
}
message MsgVoteResponse {}
message MsgExecuteProposal {
uint64 proposal_id = 1;
}
message MsgExecuteProposalResponse {
repeated google.protobuf.Any responses = 1;
}
// MsgUpdateConfig is used to change the config or members.
message MsgUpdateConfig {
// only the members that are changing are required, if their weight is 0, they are removed.
repeated Member update_members = 1;
// not all fields from Config can be changed
Config Config = 2;
}
message MsgUpdateConfigResponse {}
message Member {
string address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
uint64 weight = 2;
}
// when aggregating on-chain I can only use the address
// off-chain would send a tx directly, won't create a proposal.
message Config {
int64 threshold = 1;
int64 quorum = 2;
// voting_period is the duration in seconds for the voting period.
int64 voting_period = 3;
// revote defines if members can change their vote.
bool revote = 4;
// early_execution defines if the multisig can be executed before the voting period ends.
bool early_execution = 5;
}
message Proposal {
string title = 1;
string summary = 2;
repeated google.protobuf.Any messages = 3;
// voting_period_end will be set by the account when the proposal is created.
int64 voting_period_end = 4;
ProposalStatus status = 5;
}
// QuerySequence is the request for the account sequence.
message QuerySequence {}
// QuerySequenceResponse returns the sequence of the account.
message QuerySequenceResponse {
// sequence is the current sequence of the account.
uint64 sequence = 1;
}
message QueryConfig {}
// QuerySequenceResponse returns the sequence of the account.
message QueryConfigResponse {
repeated Member members = 1;
Config Config = 2;
}
message QueryProposal {
uint64 proposal_id = 1;
}
message QueryProposalResponse {
Proposal proposal = 1;
}
// ProposalStatus enumerates the valid proposal statuses.
enum ProposalStatus {
// PROPOSAL_STATUS_UNSPECIFIED defines a no-op proposal status.
PROPOSAL_STATUS_UNSPECIFIED = 0;
// PROPOSAL_STATUS_VOTING_PERIOD defines the proposal status during the voting period.
PROPOSAL_STATUS_VOTING_PERIOD = 1;
// PROPOSAL_STATUS_PASSED defines the proposal status when the proposal passed.
PROPOSAL_STATUS_PASSED = 2;
// PROPOSAL_STATUS_REJECTED defines the proposal status when the proposal was rejected.
PROPOSAL_STATUS_REJECTED = 3;
}
// VoteOption enumerates the valid vote options for a given proposal.
enum VoteOption {
// VOTE_OPTION_UNSPECIFIED defines a no-op vote option.
VOTE_OPTION_UNSPECIFIED = 0;
// VOTE_OPTION_YES defines the yes proposal vote option.
VOTE_OPTION_YES = 1;
// VOTE_OPTION_ABSTAIN defines the abstain proposal vote option.
VOTE_OPTION_ABSTAIN = 2;
// VOTE_OPTION_NO defines the no proposal vote option.
VOTE_OPTION_NO = 3;
}

View File

@ -30,6 +30,8 @@ func NewAccount(d accountstd.Dependencies) (Account, error) {
Counter: collections.NewItem(d.SchemaBuilder, CounterPrefix, "counter", collections.Uint64Value),
TestStateCodec: collections.NewItem(d.SchemaBuilder, TestStateCodecPrefix, "test_state_codec", codec.CollValue[counterv1.MsgTestDependencies](d.LegacyStateCodec)),
addressCodec: d.AddressCodec,
hs: d.Environment.HeaderService,
gs: d.Environment.GasService,
}, nil
}