From 3936c61e5e8395b7c9bb5cdef9a7d938f5727529 Mon Sep 17 00:00:00 2001 From: evlekht Date: Thu, 28 Sep 2023 16:16:24 +0400 Subject: [PATCH] [PVM, DAC] AddMemberProposal --- .../dac/camino_add_member_proposal.go | 187 +++++++++++ .../dac/camino_add_member_proposal_test.go | 308 ++++++++++++++++++ vms/platformvm/dac/camino_proposal.go | 3 + vms/platformvm/txs/codec.go | 2 + vms/platformvm/txs/executor/camino_dac.go | 50 ++- .../txs/executor/camino_dac_test.go | 192 +++++++++++ 6 files changed, 741 insertions(+), 1 deletion(-) create mode 100644 vms/platformvm/dac/camino_add_member_proposal.go create mode 100644 vms/platformvm/dac/camino_add_member_proposal_test.go diff --git a/vms/platformvm/dac/camino_add_member_proposal.go b/vms/platformvm/dac/camino_add_member_proposal.go new file mode 100644 index 000000000000..d0d1d8b2436d --- /dev/null +++ b/vms/platformvm/dac/camino_add_member_proposal.go @@ -0,0 +1,187 @@ +// Copyright (C) 2023, Chain4Travel AG. All rights reserved. +// See the file LICENSE for licensing terms. + +package dac + +import ( + "bytes" + "fmt" + "time" + + "github.com/ava-labs/avalanchego/ids" + "golang.org/x/exp/slices" +) + +const ( + addMemberProposalDuration = uint64(time.Hour * 24 * 30 * 2 / time.Second) // 2 month + addMemberProposalOptionsCount = 2 +) + +var ( + _ Proposal = (*AddMemberProposal)(nil) + _ ProposalState = (*AddMemberProposalState)(nil) +) + +type AddMemberProposal struct { + ApplicantAddress ids.ShortID `serialize:"true"` + Start uint64 `serialize:"true"` + End uint64 `serialize:"true"` +} + +func (p *AddMemberProposal) StartTime() time.Time { + return time.Unix(int64(p.Start), 0) +} + +func (p *AddMemberProposal) EndTime() time.Time { + return time.Unix(int64(p.End), 0) +} + +func (*AddMemberProposal) GetOptions() any { + return []bool{true, false} +} + +func (p *AddMemberProposal) Verify() error { + switch { + case p.Start >= p.End: + return errEndNotAfterStart + case p.End-p.Start != addMemberProposalDuration: + return fmt.Errorf("%w (expected: %d, actual: %d)", errWrongDuration, addMemberProposalDuration, p.End-p.Start) + } + return nil +} + +func (p *AddMemberProposal) CreateProposalState(allowedVoters []ids.ShortID) ProposalState { + stateProposal := &AddMemberProposalState{ + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true}, + {Value: false}, + }, + }, + ApplicantAddress: p.ApplicantAddress, + Start: p.Start, + End: p.End, + AllowedVoters: allowedVoters, + TotalAllowedVoters: uint32(len(allowedVoters)), + } + return stateProposal +} + +func (p *AddMemberProposal) Visit(visitor VerifierVisitor) error { + return visitor.AddMemberProposal(p) +} + +type AddMemberProposalState struct { + SimpleVoteOptions[bool] `serialize:"true"` + + ApplicantAddress ids.ShortID `serialize:"true"` + Start uint64 `serialize:"true"` + End uint64 `serialize:"true"` + AllowedVoters []ids.ShortID `serialize:"true"` + TotalAllowedVoters uint32 `serialize:"true"` +} + +func (p *AddMemberProposalState) StartTime() time.Time { + return time.Unix(int64(p.Start), 0) +} + +func (p *AddMemberProposalState) EndTime() time.Time { + return time.Unix(int64(p.End), 0) +} + +func (p *AddMemberProposalState) IsActiveAt(time time.Time) bool { + timestamp := uint64(time.Unix()) + return p.Start <= timestamp && timestamp <= p.End +} + +func (p *AddMemberProposalState) CanBeFinished() bool { + mostVotedWeight, _, unambiguous := p.GetMostVoted() + voted := p.Voted() + return voted == p.TotalAllowedVoters || unambiguous && mostVotedWeight > p.TotalAllowedVoters/2 +} + +func (p *AddMemberProposalState) IsSuccessful() bool { + mostVotedWeight, _, unambiguous := p.GetMostVoted() + voted := p.Voted() + return unambiguous && voted > p.TotalAllowedVoters/2 && mostVotedWeight > voted/2 +} + +func (p *AddMemberProposalState) Outcome() any { + _, mostVotedOptionIndex, unambiguous := p.GetMostVoted() + if !unambiguous { + return -1 + } + return mostVotedOptionIndex +} + +// Votes must be valid for this proposal, could panic otherwise. +func (p *AddMemberProposalState) Result() (bool, uint32, bool) { + mostVotedWeight, mostVotedOptionIndex, unambiguous := p.GetMostVoted() + return p.Options[mostVotedOptionIndex].Value, mostVotedWeight, unambiguous +} + +// Will return modified proposal with added vote, original proposal will not be modified! +func (p *AddMemberProposalState) AddVote(voterAddress ids.ShortID, voteIntf Vote) (ProposalState, error) { + vote, ok := voteIntf.(*SimpleVote) + if !ok { + return nil, ErrWrongVote + } + if int(vote.OptionIndex) >= len(p.Options) { + return nil, ErrWrongVote + } + + voterAddrPos, allowedToVote := slices.BinarySearchFunc(p.AllowedVoters, voterAddress, func(id, other ids.ShortID) int { + return bytes.Compare(id[:], other[:]) + }) + if !allowedToVote { + return nil, ErrNotAllowedToVoteOnProposal + } + + updatedProposal := &AddMemberProposalState{ + ApplicantAddress: p.ApplicantAddress, + Start: p.Start, + End: p.End, + AllowedVoters: make([]ids.ShortID, len(p.AllowedVoters)-1), + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: make([]SimpleVoteOption[bool], len(p.Options)), + }, + TotalAllowedVoters: p.TotalAllowedVoters, + } + // we can't use the same slice, cause we need to change its elements + copy(updatedProposal.AllowedVoters, p.AllowedVoters[:voterAddrPos]) + updatedProposal.AllowedVoters = append(updatedProposal.AllowedVoters[:voterAddrPos], p.AllowedVoters[voterAddrPos+1:]...) + // we can't use the same slice, cause we need to change its element + copy(updatedProposal.Options, p.Options) + updatedProposal.Options[vote.OptionIndex].Weight++ + return updatedProposal, nil +} + +// Will return modified proposal with added vote ignoring allowed voters, original proposal will not be modified! +func (p *AddMemberProposalState) ForceAddVote(voterAddress ids.ShortID, voteIntf Vote) (ProposalState, error) { //nolint:revive + vote, ok := voteIntf.(*SimpleVote) + if !ok { + return nil, ErrWrongVote + } + if int(vote.OptionIndex) >= len(p.Options) { + return nil, ErrWrongVote + } + + updatedProposal := &AddMemberProposalState{ + ApplicantAddress: p.ApplicantAddress, + Start: p.Start, + End: p.End, + AllowedVoters: p.AllowedVoters, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: make([]SimpleVoteOption[bool], len(p.Options)), + }, + TotalAllowedVoters: p.TotalAllowedVoters, + } + // we can't use the same slice, cause we need to change its element + copy(updatedProposal.Options, p.Options) + updatedProposal.Options[vote.OptionIndex].Weight++ + return updatedProposal, nil +} + +func (p *AddMemberProposalState) Visit(visitor ExecutorVisitor) error { + return visitor.AddMemberProposal(p) +} diff --git a/vms/platformvm/dac/camino_add_member_proposal_test.go b/vms/platformvm/dac/camino_add_member_proposal_test.go new file mode 100644 index 000000000000..c3fb59b0636d --- /dev/null +++ b/vms/platformvm/dac/camino_add_member_proposal_test.go @@ -0,0 +1,308 @@ +// Copyright (C) 2023, Chain4Travel AG. All rights reserved. +// See the file LICENSE for licensing terms. + +package dac + +import ( + "testing" + + "github.com/ava-labs/avalanchego/ids" + "github.com/stretchr/testify/require" +) + +func TestAddMemberProposalCreateProposalState(t *testing.T) { + tests := map[string]struct { + proposal *AddMemberProposal + allowedVoters []ids.ShortID + expectedProposalState ProposalState + expectedProposal *AddMemberProposal + }{ + "OK: even number of allowed voters": { + proposal: &AddMemberProposal{ + Start: 100, + End: 101, + }, + allowedVoters: []ids.ShortID{{1}, {2}, {3}, {4}}, + expectedProposalState: &AddMemberProposalState{ + Start: 100, + End: 101, + AllowedVoters: []ids.ShortID{{1}, {2}, {3}, {4}}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true}, + {Value: false}, + }, + }, + TotalAllowedVoters: 4, + }, + expectedProposal: &AddMemberProposal{ + Start: 100, + End: 101, + }, + }, + "OK: odd number of allowed voters": { + proposal: &AddMemberProposal{ + Start: 100, + End: 101, + }, + allowedVoters: []ids.ShortID{{1}, {2}, {3}, {4}, {5}}, + expectedProposalState: &AddMemberProposalState{ + Start: 100, + End: 101, + AllowedVoters: []ids.ShortID{{1}, {2}, {3}, {4}, {5}}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true}, + {Value: false}, + }, + }, + TotalAllowedVoters: 5, + }, + expectedProposal: &AddMemberProposal{ + Start: 100, + End: 101, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + proposalState := tt.proposal.CreateProposalState(tt.allowedVoters) + require.Equal(t, tt.expectedProposal, tt.proposal) + require.Equal(t, tt.expectedProposalState, proposalState) + }) + } +} + +func TestAddMemberProposalStateAddVote(t *testing.T) { + voterAddr1 := ids.ShortID{1} + voterAddr2 := ids.ShortID{1} + voterAddr3 := ids.ShortID{1} + + tests := map[string]struct { + proposal *AddMemberProposalState + voterAddr ids.ShortID + vote Vote + expectedUpdatedProposal ProposalState + expectedOriginalProposal *AddMemberProposalState + expectedErr error + }{ + "Wrong vote type": { + proposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true, Weight: 2}, // 0 + {Value: false, Weight: 1}, // 1 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + voterAddr: voterAddr1, + vote: &DummyVote{}, // not *SimpleVote + expectedOriginalProposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true, Weight: 2}, // 0 + {Value: false, Weight: 1}, // 1 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + expectedErr: ErrWrongVote, + }, + "Wrong vote option index": { + proposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true, Weight: 2}, // 0 + {Value: false, Weight: 1}, // 1 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + voterAddr: ids.ShortID{3}, + vote: &SimpleVote{OptionIndex: 2}, + expectedOriginalProposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true, Weight: 2}, // 0 + {Value: false, Weight: 1}, // 1 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + expectedErr: ErrWrongVote, + }, + "Not allowed to vote on this proposal": { + proposal: &AddMemberProposalState{ + AllowedVoters: []ids.ShortID{{1}, {2}}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{{}}, + }, + }, + voterAddr: ids.ShortID{3}, + vote: &SimpleVote{OptionIndex: 0}, + expectedOriginalProposal: &AddMemberProposalState{ + AllowedVoters: []ids.ShortID{{1}, {2}}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{{}}, + }, + }, + expectedErr: ErrNotAllowedToVoteOnProposal, + }, + "OK: adding vote to not voted option": { + proposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true, Weight: 2}, // 0 + {Value: false, Weight: 0}, // 1 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + voterAddr: voterAddr1, + vote: &SimpleVote{OptionIndex: 1}, + expectedUpdatedProposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true, Weight: 2}, // 0 + {Value: false, Weight: 1}, // 1 + }, + }, + }, + expectedOriginalProposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true, Weight: 2}, // 0 + {Value: false, Weight: 0}, // 1 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + }, + "OK: adding vote to already voted option": { + proposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true, Weight: 2}, // 0 + {Value: false, Weight: 1}, // 1 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + voterAddr: voterAddr1, + vote: &SimpleVote{OptionIndex: 1}, + expectedUpdatedProposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true, Weight: 2}, // 0 + {Value: false, Weight: 2}, // 1 + }, + }, + }, + expectedOriginalProposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{ + {Value: true, Weight: 2}, // 0 + {Value: false, Weight: 1}, // 1 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + }, + "OK: voter addr in the middle of allowedVoters array": { + proposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1, voterAddr2, voterAddr3}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{{}}, + }, + }, + voterAddr: voterAddr2, + vote: &SimpleVote{OptionIndex: 0}, + expectedUpdatedProposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1, voterAddr3}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{{Weight: 1}}, + }, + }, + expectedOriginalProposal: &AddMemberProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1, voterAddr2, voterAddr3}, + SimpleVoteOptions: SimpleVoteOptions[bool]{ + Options: []SimpleVoteOption[bool]{{}}, + }, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + updatedProposal, err := tt.proposal.AddVote(tt.voterAddr, tt.vote) + require.ErrorIs(t, err, tt.expectedErr) + require.Equal(t, tt.expectedUpdatedProposal, updatedProposal) + require.Equal(t, tt.expectedOriginalProposal, tt.proposal) + }) + } +} diff --git a/vms/platformvm/dac/camino_proposal.go b/vms/platformvm/dac/camino_proposal.go index 8f368fbf26c9..497787f448a1 100644 --- a/vms/platformvm/dac/camino_proposal.go +++ b/vms/platformvm/dac/camino_proposal.go @@ -13,16 +13,19 @@ import ( var ( errEndNotAfterStart = errors.New("proposal end-time is not after start-time") + errWrongDuration = errors.New("wrong proposal duration") ErrWrongVote = errors.New("this proposal can't be voted with this vote") ErrNotAllowedToVoteOnProposal = errors.New("this address has already voted or not allowed to vote on this proposal") ) type VerifierVisitor interface { BaseFeeProposal(*BaseFeeProposal) error + AddMemberProposal(*AddMemberProposal) error } type ExecutorVisitor interface { BaseFeeProposal(*BaseFeeProposalState) error + AddMemberProposal(*AddMemberProposalState) error } type Proposal interface { diff --git a/vms/platformvm/txs/codec.go b/vms/platformvm/txs/codec.go index 498b2839559e..f7b2716ae1b9 100644 --- a/vms/platformvm/txs/codec.go +++ b/vms/platformvm/txs/codec.go @@ -130,6 +130,8 @@ func RegisterUnsignedTxsTypes(targetCodec codec.CaminoRegistry) error { targetCodec.RegisterCustomType(&dac.BaseFeeProposalState{}), targetCodec.RegisterCustomType(&dac.DummyVote{}), targetCodec.RegisterCustomType(&dac.SimpleVote{}), + targetCodec.RegisterCustomType(&dac.AddMemberProposal{}), + targetCodec.RegisterCustomType(&dac.AddMemberProposalState{}), ) return errs.Err } diff --git a/vms/platformvm/txs/executor/camino_dac.go b/vms/platformvm/txs/executor/camino_dac.go index 5207fcc69c8e..091a22e69463 100644 --- a/vms/platformvm/txs/executor/camino_dac.go +++ b/vms/platformvm/txs/executor/camino_dac.go @@ -16,6 +16,7 @@ var ( _ dac.VerifierVisitor = (*proposalVerifier)(nil) _ dac.ExecutorVisitor = (*proposalExecutor)(nil) + errConsortiumMember = errors.New("address is consortium member") errNotPermittedToCreateProposal = errors.New("don't have permission to create proposal of this type") errAlreadyActiveProposal = errors.New("there is already active proposal of this type") ) @@ -86,9 +87,56 @@ func (e *proposalVerifier) BaseFeeProposal(*dac.BaseFeeProposal) error { return nil } -// should never error func (e *proposalExecutor) BaseFeeProposal(proposal *dac.BaseFeeProposalState) error { _, mostVotedOptionIndex, _ := proposal.GetMostVoted() e.state.SetBaseFee(proposal.Options[mostVotedOptionIndex].Value) return nil } + +// AddMemberProposal + +func (e *proposalVerifier) AddMemberProposal(proposal *dac.AddMemberProposal) error { + // verify that address isn't consortium member + applicantAddress, err := e.state.GetAddressStates(proposal.ApplicantAddress) + if err != nil { + return err + } + + if applicantAddress.Is(txs.AddressStateConsortiumMember) { + return errConsortiumMember + } + + // verify that there is no existing add member proposal for this address + proposalsIterator, err := e.state.GetProposalIterator() + if err != nil { + return err + } + defer proposalsIterator.Release() + for proposalsIterator.Next() { + existingProposal, err := proposalsIterator.Value() + if err != nil { + return err + } + addMemberProposal, ok := existingProposal.(*dac.AddMemberProposalState) + if ok && addMemberProposal.ApplicantAddress == proposal.ApplicantAddress { + return errAlreadyActiveProposal + } + } + + if err := proposalsIterator.Error(); err != nil { + return err + } + + return nil +} + +func (e *proposalExecutor) AddMemberProposal(proposal *dac.AddMemberProposalState) error { + if accepted, _, _ := proposal.Result(); accepted { + addrState, err := e.state.GetAddressStates(proposal.ApplicantAddress) + if err != nil { + return err + } + e.state.SetAddressStates(proposal.ApplicantAddress, addrState|txs.AddressStateConsortiumMember) + } + return nil +} diff --git a/vms/platformvm/txs/executor/camino_dac_test.go b/vms/platformvm/txs/executor/camino_dac_test.go index 609e7f3897b9..8bbe81907ebf 100644 --- a/vms/platformvm/txs/executor/camino_dac_test.go +++ b/vms/platformvm/txs/executor/camino_dac_test.go @@ -195,3 +195,195 @@ func TestProposalExecutorBaseFeeProposal(t *testing.T) { }) } } + +func TestProposalVerifierAddMemberProposal(t *testing.T) { + ctx, _ := defaultCtx(nil) + caminoGenesisConf := api.Camino{ + VerifyNodeSignature: true, + LockModeBondDeposit: true, + } + + feeOwnerKey, _, feeOwner := generateKeyAndOwner(t) + bondOwnerKey, _, bondOwner := generateKeyAndOwner(t) + proposerKey, proposerAddr, _ := generateKeyAndOwner(t) + applicantAddress := ids.ShortID{1} + + proposalBondAmt := uint64(100) + feeUTXO := generateTestUTXO(ids.ID{1, 2, 3, 4, 5}, ctx.AVAXAssetID, defaultTxFee, feeOwner, ids.Empty, ids.Empty) + bondUTXO := generateTestUTXO(ids.ID{1, 2, 3, 4, 6}, ctx.AVAXAssetID, proposalBondAmt, bondOwner, ids.Empty, ids.Empty) + + proposal := &txs.ProposalWrapper{Proposal: &dac.AddMemberProposal{End: 1, ApplicantAddress: applicantAddress}} + proposalBytes, err := txs.Codec.Marshal(txs.Version, proposal) + require.NoError(t, err) + + baseTx := txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: []*avax.TransferableInput{ + generateTestInFromUTXO(feeUTXO, []uint32{0}), + generateTestInFromUTXO(bondUTXO, []uint32{0}), + }, + Outs: []*avax.TransferableOutput{ + generateTestOut(ctx.AVAXAssetID, proposalBondAmt, bondOwner, ids.Empty, locked.ThisTxID), + }, + }} + + tests := map[string]struct { + state func(*gomock.Controller, *txs.AddProposalTx) *state.MockDiff + utx func() *txs.AddProposalTx + signers [][]*secp256k1.PrivateKey + expectedErr error + }{ + "Applicant address is consortium member": { + state: func(c *gomock.Controller, utx *txs.AddProposalTx) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().GetAddressStates(applicantAddress).Return(txs.AddressStateConsortiumMember, nil) + return s + }, + utx: func() *txs.AddProposalTx { + return &txs.AddProposalTx{ + BaseTx: baseTx, + ProposalPayload: proposalBytes, + ProposerAddress: proposerAddr, + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {bondOwnerKey}, {proposerKey}, + }, + expectedErr: errConsortiumMember, + }, + "Already active AddMemberProposal for this applicant": { + state: func(c *gomock.Controller, utx *txs.AddProposalTx) *state.MockDiff { + s := state.NewMockDiff(c) + proposalsIterator := state.NewMockProposalsIterator(c) + proposalsIterator.EXPECT().Next().Return(true) + proposalsIterator.EXPECT().Value().Return(&dac.AddMemberProposalState{ApplicantAddress: applicantAddress}, nil) + proposalsIterator.EXPECT().Release() + + s.EXPECT().GetAddressStates(applicantAddress).Return(txs.AddressStateEmpty, nil) + s.EXPECT().GetProposalIterator().Return(proposalsIterator, nil) + return s + }, + utx: func() *txs.AddProposalTx { + return &txs.AddProposalTx{ + BaseTx: baseTx, + ProposalPayload: proposalBytes, + ProposerAddress: proposerAddr, + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {bondOwnerKey}, {proposerKey}, + }, + expectedErr: errAlreadyActiveProposal, + }, + "OK": { + state: func(c *gomock.Controller, utx *txs.AddProposalTx) *state.MockDiff { + s := state.NewMockDiff(c) + proposalsIterator := state.NewMockProposalsIterator(c) + proposalsIterator.EXPECT().Next().Return(false) + proposalsIterator.EXPECT().Release() + proposalsIterator.EXPECT().Error().Return(nil) + + s.EXPECT().GetAddressStates(applicantAddress).Return(txs.AddressStateEmpty, nil) + s.EXPECT().GetProposalIterator().Return(proposalsIterator, nil) + return s + }, + utx: func() *txs.AddProposalTx { + return &txs.AddProposalTx{ + BaseTx: baseTx, + ProposalPayload: proposalBytes, + ProposerAddress: proposerAddr, + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {bondOwnerKey}, {proposerKey}, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + env := newCaminoEnvironmentWithMocks(caminoGenesisConf, nil) + defer func() { require.NoError(t, shutdownCaminoEnvironment(env)) }() + + utx := tt.utx() + avax.SortTransferableInputsWithSigners(utx.Ins, tt.signers) + avax.SortTransferableOutputs(utx.Outs, txs.Codec) + tx, err := txs.NewSigned(utx, txs.Codec, tt.signers) + require.NoError(t, err) + + txExecutor := CaminoStandardTxExecutor{StandardTxExecutor{ + Backend: &env.backend, + State: tt.state(ctrl, utx), + Tx: tx, + }} + + proposal, err := utx.Proposal() + require.NoError(t, err) + err = proposal.Visit(txExecutor.proposalVerifier(utx)) + require.ErrorIs(t, err, tt.expectedErr) + }) + } +} + +func TestProposalExecutorAddMemberProposal(t *testing.T) { + caminoGenesisConf := api.Camino{ + VerifyNodeSignature: true, + LockModeBondDeposit: true, + } + + applicantAddress := ids.ShortID{1} + applicantAddressState := txs.AddressStateCaminoProposer // just not empty + + tests := map[string]struct { + state func(*gomock.Controller) *state.MockDiff + proposal dac.ProposalState + expectedErr error + }{ + "OK: rejected": { + state: state.NewMockDiff, + proposal: &dac.AddMemberProposalState{ + ApplicantAddress: applicantAddress, + SimpleVoteOptions: dac.SimpleVoteOptions[bool]{Options: []dac.SimpleVoteOption[bool]{ + {Value: true, Weight: 1}, + {Value: false, Weight: 2}, + }}, + }, + }, + "OK: accepted": { + state: func(c *gomock.Controller) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().GetAddressStates(applicantAddress).Return(applicantAddressState, nil) + s.EXPECT().SetAddressStates(applicantAddress, applicantAddressState|txs.AddressStateConsortiumMember) + return s + }, + proposal: &dac.AddMemberProposalState{ + ApplicantAddress: applicantAddress, + SimpleVoteOptions: dac.SimpleVoteOptions[bool]{Options: []dac.SimpleVoteOption[bool]{ + {Value: true, Weight: 2}, + {Value: false, Weight: 1}, + }}, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + env := newCaminoEnvironmentWithMocks(caminoGenesisConf, nil) + defer func() { require.NoError(t, shutdownCaminoEnvironment(env)) }() + + txExecutor := CaminoStandardTxExecutor{StandardTxExecutor{ + Backend: &env.backend, + State: tt.state(ctrl), + }} + + err := tt.proposal.Visit(txExecutor.proposalExecutor()) + require.ErrorIs(t, err, tt.expectedErr) + }) + } +}