test(accounts): add multisig e2e tests (#20310)

This commit is contained in:
Facundo Medica
2024-05-13 12:53:15 +00:00
committed by GitHub
parent 559f78403a
commit 1a8425a343
7 changed files with 497 additions and 26 deletions
+265
View File
@@ -0,0 +1,265 @@
package multisig
import (
"testing"
"time"
"github.com/stretchr/testify/suite"
bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1"
basev1beta1 "cosmossdk.io/api/cosmos/base/v1beta1"
"cosmossdk.io/core/header"
"cosmossdk.io/math"
v1 "cosmossdk.io/x/accounts/defaults/multisig/v1"
accountsv1 "cosmossdk.io/x/accounts/v1"
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
"github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1"
sdk "github.com/cosmos/cosmos-sdk/types"
)
func TestE2ETestSuite(t *testing.T) {
suite.Run(t, NewE2ETestSuite())
}
// TestSimpleSendProposal creates a multisig account with 1 member, sends a tx, votes and executes it.
func (s *E2ETestSuite) TestSimpleSendProposal() {
ctx := sdk.NewContext(s.app.CommitMultiStore(), false, s.app.Logger()).WithHeaderInfo(header.Info{
Time: time.Now(),
})
randAcc := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address())
addr, err := s.app.AuthKeeper.AddressCodec().BytesToString(randAcc)
s.NoError(err)
initialMembers := map[string]uint64{
s.membersAddr[0]: 100,
}
accountAddr, accAddrStr := s.initAccount(ctx, s.members[0], initialMembers)
balance := s.app.BankKeeper.GetBalance(ctx, randAcc, "stake")
s.Equal(math.NewInt(0), balance.Amount)
// do a simple bank send
msg := &bankv1beta1.MsgSend{
FromAddress: accAddrStr,
ToAddress: addr,
Amount: []*basev1beta1.Coin{
{
Denom: "stake",
Amount: "100",
},
},
}
anyMsg, err := codectypes.NewAnyWithValue(msg)
s.NoError(err)
s.createProposal(ctx, accountAddr, s.members[0], anyMsg)
// now we vote for it
voteReq := &v1.MsgVote{
ProposalId: 0,
Vote: v1.VoteOption_VOTE_OPTION_YES,
}
err = s.executeTx(ctx, voteReq, accountAddr, s.members[0])
s.NoError(err)
// now we execute it
err = s.executeProposal(ctx, accountAddr, s.members[0], 0)
s.NoError(err)
foundPropResult := false
for _, v := range ctx.EventManager().Events() {
if v.Type == "proposal_tally" {
foundPropResult = true
status, found := v.GetAttribute("status")
s.True(found)
s.Equal(v1.ProposalStatus_PROPOSAL_STATUS_PASSED.String(), status.Value)
yesVotes, found := v.GetAttribute("yes_votes")
s.True(found)
s.Equal("100", yesVotes.Value)
noVotes, found := v.GetAttribute("no_votes")
s.True(found)
s.Equal("0", noVotes.Value)
propID, found := v.GetAttribute("proposal_id")
s.True(found)
s.Equal("0", propID.Value)
execErr, found := v.GetAttribute("exec_err")
s.True(found)
s.Equal("<nil>", execErr.Value)
rejectErr, found := v.GetAttribute("reject_err")
s.True(found)
s.Equal("<nil>", rejectErr.Value)
}
}
s.True(foundPropResult)
balance = s.app.BankKeeper.GetBalance(ctx, randAcc, "stake")
s.Equal(int64(100), balance.Amount.Int64())
// try to execute again, should fail
err = s.executeProposal(ctx, accountAddr, s.members[0], 0)
s.Error(err)
}
// TestConfigUpdate creates a multisig with 1 member, adds 2 more members and
// changes the config to require 2/3 majority (also through a proposal).
func (s *E2ETestSuite) TestConfigUpdate() {
ctx := sdk.NewContext(s.app.CommitMultiStore(), false, s.app.Logger()).WithHeaderInfo(header.Info{
Time: time.Now(),
})
initialMembers := map[string]uint64{
s.membersAddr[0]: 100,
}
accountAddr, accAddrStr := s.initAccount(ctx, s.members[0], initialMembers)
// Add 2 members and pass the proposal
// create proposal
updateMsg := &v1.MsgUpdateConfig{
UpdateMembers: []*v1.Member{
{
Address: s.membersAddr[1],
Weight: 100,
},
{
Address: s.membersAddr[2],
Weight: 100,
},
},
Config: &v1.Config{
Threshold: 200, // 3 members with 100 power each, 2/3 majority
Quorum: 200,
VotingPeriod: 120,
Revote: false,
EarlyExecution: false,
},
}
msgExec := &accountsv1.MsgExecute{
Sender: accAddrStr,
Target: accAddrStr,
Message: codectypes.UnsafePackAny(updateMsg),
Funds: []sdk.Coin{},
}
s.createProposal(ctx, accountAddr, s.members[0], codectypes.UnsafePackAny(msgExec))
// vote
voteReq := &v1.MsgVote{
ProposalId: 0,
Vote: v1.VoteOption_VOTE_OPTION_YES,
}
err := s.executeTx(ctx, voteReq, accountAddr, s.members[0])
s.NoError(err)
err = s.executeProposal(ctx, accountAddr, s.members[0], 0)
s.NoError(err)
// get members
res, err := s.queryAcc(ctx, &v1.QueryConfig{}, accountAddr)
s.NoError(err)
resp := res.(*v1.QueryConfigResponse)
s.Len(resp.Members, 3)
s.Equal(int64(200), resp.Config.Threshold)
// Try to remove a member, but it doesn't reach passing threshold
// create proposal
msgExec = &accountsv1.MsgExecute{
Sender: accAddrStr,
Target: accAddrStr,
Message: codectypes.UnsafePackAny(&v1.MsgUpdateConfig{
UpdateMembers: []*v1.Member{
{
Address: s.membersAddr[1],
Weight: 0,
},
},
Config: &v1.Config{
Threshold: 200, // 3 members with 100 power each, 2/3 majority
Quorum: 200,
VotingPeriod: 120,
Revote: false,
EarlyExecution: false,
},
}),
Funds: []sdk.Coin{},
}
s.createProposal(ctx, accountAddr, s.members[0], codectypes.UnsafePackAny(msgExec))
// vote
voteReq = &v1.MsgVote{
ProposalId: 1,
Vote: v1.VoteOption_VOTE_OPTION_NO,
}
err = s.executeTx(ctx, voteReq, accountAddr, s.members[0])
s.NoError(err)
// need to wait until voting period is over because we disabled early execution on the last
// config update
err = s.executeProposal(ctx, accountAddr, s.members[0], 0)
s.ErrorContains(err, "voting period has not ended yet, and early execution is not enabled")
// vote with member 1
voteReq = &v1.MsgVote{
ProposalId: 1,
Vote: v1.VoteOption_VOTE_OPTION_NO,
}
err = s.executeTx(ctx, voteReq, accountAddr, s.members[1])
s.NoError(err)
// need to wait until voting period is over because we disabled early execution on the last
// config update
err = s.executeProposal(ctx, accountAddr, s.members[0], 1)
s.ErrorContains(err, "voting period has not ended yet, and early execution is not enabled")
headerInfo := ctx.HeaderInfo()
headerInfo.Time = headerInfo.Time.Add(time.Second * 121)
ctx = ctx.WithHeaderInfo(headerInfo)
// now it should work, but the proposal will fail
err = s.executeProposal(ctx, accountAddr, s.members[0], 1)
s.NoError(err)
foundPropResult := false
for _, v := range ctx.EventManager().Events() {
if v.Type == "proposal_tally" {
propID, found := v.GetAttribute("proposal_id")
s.True(found)
if propID.Value == "1" {
foundPropResult = true
status, found := v.GetAttribute("status")
s.True(found)
s.Equal(v1.ProposalStatus_PROPOSAL_STATUS_REJECTED.String(), status.Value)
// exec_err is nil because the proposal didn't execute
execErr, found := v.GetAttribute("exec_err")
s.True(found)
s.Equal("<nil>", execErr.Value)
rejectErr, found := v.GetAttribute("reject_err")
s.True(found)
s.Equal("threshold not reached", rejectErr.Value)
}
}
}
s.True(foundPropResult)
// get members
res, err = s.queryAcc(ctx, &v1.QueryConfig{}, accountAddr)
s.NoError(err)
resp = res.(*v1.QueryConfigResponse)
s.Len(resp.Members, 3)
s.Equal(int64(200), resp.Config.Threshold)
}
+118
View File
@@ -0,0 +1,118 @@
package multisig
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"google.golang.org/protobuf/runtime/protoiface"
"cosmossdk.io/math"
"cosmossdk.io/simapp"
multisigaccount "cosmossdk.io/x/accounts/defaults/multisig"
v1 "cosmossdk.io/x/accounts/defaults/multisig/v1"
"cosmossdk.io/x/bank/testutil"
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
"github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1"
sdk "github.com/cosmos/cosmos-sdk/types"
)
type ProtoMsg = protoiface.MessageV1
type E2ETestSuite struct {
suite.Suite
app *simapp.SimApp
members []sdk.AccAddress
membersAddr []string
}
func NewE2ETestSuite() *E2ETestSuite {
return &E2ETestSuite{}
}
func (s *E2ETestSuite) SetupSuite() {
s.app = setupApp(s.T())
s.members = []sdk.AccAddress{}
for i := 0; i < 10; i++ {
addr := secp256k1.GenPrivKey().PubKey().Address()
addrStr, err := s.app.AuthKeeper.AddressCodec().BytesToString(addr)
require.NoError(s.T(), err)
s.membersAddr = append(s.membersAddr, addrStr)
s.members = append(s.members, sdk.AccAddress(addr))
}
}
func (s *E2ETestSuite) TearDownSuite() {}
func setupApp(t *testing.T) *simapp.SimApp {
t.Helper()
app := simapp.Setup(t, false)
return app
}
func (s *E2ETestSuite) executeTx(ctx context.Context, msg sdk.Msg, accAddr, sender []byte) error {
_, err := s.app.AccountsKeeper.Execute(ctx, accAddr, sender, msg, nil)
return err
}
func (s *E2ETestSuite) queryAcc(ctx context.Context, req sdk.Msg, accAddr []byte) (ProtoMsg, error) {
resp, err := s.app.AccountsKeeper.Query(ctx, accAddr, req)
return resp, err
}
func (s *E2ETestSuite) fundAccount(ctx context.Context, addr sdk.AccAddress, amt sdk.Coins) {
require.NoError(s.T(), testutil.FundAccount(ctx, s.app.BankKeeper, addr, amt))
}
// initAccount initializes a multisig account with the given members and powers
// and returns the account address
func (s *E2ETestSuite) initAccount(ctx context.Context, sender []byte, membersPowers map[string]uint64) ([]byte, string) {
s.fundAccount(ctx, sender, sdk.Coins{sdk.NewCoin("stake", math.NewInt(1000000))})
members := []*v1.Member{}
for addrStr, power := range membersPowers {
members = append(members, &v1.Member{Address: addrStr, Weight: power})
}
_, accountAddr, err := s.app.AccountsKeeper.Init(ctx, multisigaccount.MULTISIG_ACCOUNT, sender,
&v1.MsgInit{
Members: members,
Config: &v1.Config{
Threshold: 100,
Quorum: 100,
VotingPeriod: 120,
Revote: false,
EarlyExecution: true,
},
}, sdk.Coins{sdk.NewCoin("stake", math.NewInt(1000))})
s.NoError(err)
accountAddrStr, err := s.app.AuthKeeper.AddressCodec().BytesToString(accountAddr)
s.NoError(err)
return accountAddr, accountAddrStr
}
// createProposal
func (s *E2ETestSuite) createProposal(ctx context.Context, accAddr, sender []byte, msgs ...*codectypes.Any) {
propReq := &v1.MsgCreateProposal{
Proposal: &v1.Proposal{
Title: "test",
Summary: "test",
Messages: msgs,
},
}
err := s.executeTx(ctx, propReq, accAddr, sender)
s.NoError(err)
}
func (s *E2ETestSuite) executeProposal(ctx context.Context, accAddr, sender []byte, proposalID uint64) error {
execReq := &v1.MsgExecuteProposal{
ProposalId: proposalID,
}
return s.executeTx(ctx, execReq, accAddr, sender)
}
+13 -13
View File
@@ -16,6 +16,8 @@ import (
"github.com/cosmos/cosmos-sdk/codec"
)
var MULTISIG_ACCOUNT = "multisig-account"
var (
MembersPrefix = collections.NewPrefix(0)
SequencePrefix = collections.NewPrefix(1)
@@ -43,19 +45,17 @@ type Account struct {
}
// 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
}
func NewAccount(deps accountstd.Dependencies) (*Account, error) {
return &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.
+92 -12
View File
@@ -11,17 +11,17 @@ import (
"cosmossdk.io/core/store"
"cosmossdk.io/x/accounts/accountstd"
v1 "cosmossdk.io/x/accounts/defaults/multisig/v1"
accountsv1 "cosmossdk.io/x/accounts/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)
acc, err := NewAccount(deps)
require.NoError(t, err)
return acc.(*Account)
return acc
}
func TestInit(t *testing.T) {
@@ -321,6 +321,8 @@ func TestUpdateConfig(t *testing.T) {
_, err := acc.Init(ctx, startAcc)
require.NoError(t, err)
ctx = accountstd.SetSender(ctx, []byte("mock_multisig_account"))
_, err = acc.UpdateConfig(ctx, tc.msg)
if tc.expErr != "" {
require.EqualError(t, err, tc.expErr)
@@ -342,6 +344,51 @@ func TestUpdateConfig(t *testing.T) {
}
}
func TestProposal_WrongSender(t *testing.T) {
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 := newMockContext(t)
acc := setup(t, ctx, ss, nil)
_, err := acc.Init(ctx, startAcc)
require.NoError(t, err)
// change the sender to be something else to trigger an error
ctx = accountstd.SetSender(ctx, []byte("wrong_sender_here"))
newCfg := startAcc.Config
newCfg.VotingPeriod = 120
updateCfg := &v1.MsgUpdateConfig{
Config: newCfg,
}
_, err = acc.UpdateConfig(ctx, updateCfg)
require.ErrorContains(t, err, "only the account itself can update the config (through a proposal)")
}
func TestProposal_NotPassing(t *testing.T) {
// all test cases start from the same initial state
startAcc := &v1.MsgInit{
@@ -491,12 +538,20 @@ func TestProposalPassing(t *testing.T) {
}
var acc *Account
ctx, ss := accountstd.NewMockContext(
var ctx context.Context
var ss store.KVStoreService
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))
}, func(ictx context.Context, sender []byte, msg ProtoMsg) (ProtoMsg, error) {
if execmsg, ok := msg.(*accountsv1.MsgExecute); ok {
updateCfg, err := accountstd.UnpackAny[v1.MsgUpdateConfig](execmsg.GetMessage())
if err != nil {
return nil, err
}
ctx = accountstd.SetSender(ctx, []byte("multisig_acc"))
return acc.UpdateConfig(ctx, updateCfg)
}
return nil, nil
}, func(ctx context.Context, req, resp ProtoMsg) error {
@@ -523,12 +578,21 @@ func TestProposalPassing(t *testing.T) {
anymsg, err := accountstd.PackAny(msg)
require.NoError(t, err)
execMsg := &accountsv1.MsgExecute{
Sender: "multisig_acc",
Target: "multisig_acc",
Message: anymsg,
Funds: nil,
}
execMsgAny, err := accountstd.PackAny(execMsg)
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},
Messages: []*types.Any{execMsgAny},
},
})
require.NoError(t, err)
@@ -572,9 +636,25 @@ func TestProposalPassing(t *testing.T) {
// 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)
}
expectedMembers := []*v1.Member{
{
Address: "addr1",
Weight: 500,
},
{
Address: "addr2",
Weight: 1000,
},
{
Address: "addr3",
Weight: 1000,
},
{
Address: "addr4",
Weight: 1000,
},
}
require.Equal(t, expectedMembers, cfg.Members)
}
@@ -4,11 +4,17 @@ import (
"context"
"errors"
"cosmossdk.io/x/accounts/accountstd"
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) {
// this function can only be executed by the account itself
if !accountstd.SenderIsSelf(ctx) {
return nil, errors.New("only the account itself can update the config (through a proposal)")
}
// set members
for i := range msg.UpdateMembers {
addrBz, err := a.addrCodec.StringToBytes(msg.UpdateMembers[i].Address)
+1 -1
View File
@@ -163,7 +163,7 @@ func (a addressCodec) BytesToString(bz []byte) (string, error) { return string
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 {
0, []byte("mock_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
+2
View File
@@ -12,6 +12,7 @@ import (
"cosmossdk.io/x/accounts/accountstd"
baseaccount "cosmossdk.io/x/accounts/defaults/base"
"cosmossdk.io/x/accounts/defaults/lockup"
"cosmossdk.io/x/accounts/defaults/multisig"
"cosmossdk.io/x/tx/signing"
"github.com/cosmos/cosmos-sdk/codec"
@@ -70,6 +71,7 @@ func ProvideModule(in ModuleInputs) ModuleOutputs {
accountstd.AddAccount(lockup.PERIODIC_LOCKING_ACCOUNT, lockup.NewPeriodicLockingAccount),
accountstd.AddAccount(lockup.DELAYED_LOCKING_ACCOUNT, lockup.NewDelayedLockingAccount),
accountstd.AddAccount(lockup.PERMANENT_LOCKING_ACCOUNT, lockup.NewPermanentLockingAccount),
accountstd.AddAccount(multisig.MULTISIG_ACCOUNT, multisig.NewAccount),
)
if err != nil {
panic(err)