From 2360863c4a07c41a5dc5110ce9f404912eb467bb Mon Sep 17 00:00:00 2001 From: evlekht Date: Thu, 5 Oct 2023 18:00:44 +0400 Subject: [PATCH] [PVM, DAC] Add BaseFeeProposal, proposal-related unit-tests and integration-tests --- vms/platformvm/camino_helpers_test.go | 2 +- vms/platformvm/camino_vm_test.go | 403 ++++ .../dac/camino_change_base_fee_proposal.go | 193 ++ .../camino_change_base_fee_proposal_test.go | 324 +++ vms/platformvm/dac/camino_proposal.go | 9 +- vms/platformvm/state/camino.go | 4 + vms/platformvm/state/camino_diff.go | 20 + vms/platformvm/state/camino_diff_test.go | 1993 ++++++++++++++++- vms/platformvm/state/camino_proposal_test.go | 941 ++++++++ vms/platformvm/state/camino_state.go | 8 + .../txs/camino_add_proposal_tx_test.go | 123 + vms/platformvm/txs/codec.go | 2 + vms/platformvm/txs/executor/camino_dac.go | 48 + .../txs/executor/camino_dac_test.go | 197 ++ .../txs/executor/camino_helpers_test.go | 61 +- .../txs/executor/camino_tx_executor.go | 73 +- .../txs/executor/camino_tx_executor_test.go | 1494 ++++++++++++ .../txs/executor/staker_tx_verification.go | 7 +- .../txs/executor/standard_tx_executor.go | 14 +- 19 files changed, 5832 insertions(+), 84 deletions(-) create mode 100644 vms/platformvm/dac/camino_change_base_fee_proposal.go create mode 100644 vms/platformvm/dac/camino_change_base_fee_proposal_test.go create mode 100644 vms/platformvm/txs/camino_add_proposal_tx_test.go create mode 100644 vms/platformvm/txs/executor/camino_dac_test.go diff --git a/vms/platformvm/camino_helpers_test.go b/vms/platformvm/camino_helpers_test.go index 5c6846465257..82c7b08e2b05 100644 --- a/vms/platformvm/camino_helpers_test.go +++ b/vms/platformvm/camino_helpers_test.go @@ -47,7 +47,7 @@ var ( _, caminoPreFundedNodeIDs = nodeid.LoadLocalCaminoNodeKeysAndIDs(localStakingPath) ) -func newCaminoVM(genesisConfig api.Camino, genesisUTXOs []api.UTXO, startTime *time.Time) *VM { //nolint:unparam +func newCaminoVM(genesisConfig api.Camino, genesisUTXOs []api.UTXO, startTime *time.Time) *VM { vm := &VM{Config: defaultCaminoConfig(true)} baseDBManager := manager.NewMemDB(version.Semantic1_0_0) diff --git a/vms/platformvm/camino_vm_test.go b/vms/platformvm/camino_vm_test.go index 1113e5a10cc7..e2bea9d718a6 100644 --- a/vms/platformvm/camino_vm_test.go +++ b/vms/platformvm/camino_vm_test.go @@ -21,8 +21,10 @@ import ( "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/platformvm/api" "github.com/ava-labs/avalanchego/vms/platformvm/blocks" + "github.com/ava-labs/avalanchego/vms/platformvm/dac" "github.com/ava-labs/avalanchego/vms/platformvm/deposit" "github.com/ava-labs/avalanchego/vms/platformvm/genesis" + "github.com/ava-labs/avalanchego/vms/platformvm/locked" "github.com/ava-labs/avalanchego/vms/platformvm/reward" "github.com/ava-labs/avalanchego/vms/platformvm/state" "github.com/ava-labs/avalanchego/vms/platformvm/status" @@ -528,6 +530,200 @@ func TestDepositsAutoUnlock(t *testing.T) { require.ErrorIs(err, database.ErrNotFound) } +func TestProposals(t *testing.T) { + proposerKey, proposerAddr, _ := generateKeyAndOwner(t) + proposerAddrStr, err := address.FormatBech32(constants.NetworkIDToHRP[testNetworkID], proposerAddr.Bytes()) + require.NoError(t, err) + caminoPreFundedKey0AddrStr, err := address.FormatBech32(constants.NetworkIDToHRP[testNetworkID], caminoPreFundedKeys[0].Address().Bytes()) + require.NoError(t, err) + + defaultConfig := defaultCaminoConfig(true) + proposalBondAmount := defaultConfig.CaminoConfig.DACProposalBondAmount + newFee := (defaultTxFee + 7) * 10 + + type vote struct { + option uint32 + success bool // proposal is successful after this vote + } + + tests := map[string]struct { + feeOptions []uint64 + winningOption uint32 + earlyFinish bool + votes []vote // no more than 5 votes, cause we have only 5 validators + }{ + "Early success: 1|3 votes": { + feeOptions: []uint64{1, newFee}, + winningOption: 1, + earlyFinish: true, + votes: []vote{ + {option: 1}, + {option: 1}, + {option: 0, success: true}, + {option: 1, success: true}, + }, + }, + "Early fail: 2|2|1 votes, not reaching mostVoted threshold and being ambiguous": { + feeOptions: []uint64{1, 2, 3}, + earlyFinish: true, + votes: []vote{ + {option: 0}, + {option: 0}, + {option: 1, success: true}, + {option: 1}, + {option: 2}, + }, + }, + "Success: 0|2|1 votes": { + feeOptions: []uint64{1, newFee, 17}, + winningOption: 1, + votes: []vote{ + {option: 1}, + {option: 1}, + {option: 2, success: true}, + }, + }, + "Fail: 0 votes": { + feeOptions: []uint64{1}, + votes: []vote{}, + }, + "Fail: 2|1|1 votes, not reaching mostVoted threshold": { + feeOptions: []uint64{1, 2, 3}, + votes: []vote{ + {option: 0}, + {option: 0}, + {option: 1, success: true}, + {option: 2}, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + require := require.New(t) + balance := proposalBondAmount + defaultTxFee*(uint64(len(tt.votes))+1) + newFee + + // Prepare vm + vm := newCaminoVM(api.Camino{ + VerifyNodeSignature: true, + LockModeBondDeposit: true, + InitialAdmin: caminoPreFundedKeys[0].Address(), + }, []api.UTXO{ + { + Amount: json.Uint64(balance), + Address: proposerAddrStr, + }, + { + Amount: json.Uint64(defaultTxFee), + Address: caminoPreFundedKey0AddrStr, + }, + }, &defaultConfig.BanffTime) + vm.ctx.Lock.Lock() + defer func() { require.NoError(vm.Shutdown(context.Background())) }() //nolint:lint + checkBalance(t, vm.state, proposerAddr, + balance, // total + 0, 0, 0, balance, // unlocked + ) + + fee := defaultTxFee + burnedAmt := uint64(0) + + // Give proposer address role to make proposals + addrStateTx, err := vm.txBuilder.NewAddressStateTx( + proposerAddr, + false, + txs.AddressStateBitCaminoProposer, + []*secp256k1.PrivateKey{caminoPreFundedKeys[0]}, + nil, + ) + require.NoError(err) + blk := buildAndAcceptBlock(t, vm, addrStateTx) + require.Len(blk.Txs(), 1) + checkTx(t, vm, blk.ID(), addrStateTx.ID()) + + // Add proposal + chainTime := vm.state.GetTimestamp() + proposalTx := buildBaseFeeProposalTx(t, vm, proposerKey, proposalBondAmount, fee, + proposerKey, tt.feeOptions, chainTime.Add(100*time.Second), chainTime.Add(200*time.Second)) + proposalState, nextProposalIDsToExpire, nexExpirationTime, proposalIDsToFinish := makeProposalWithTx(t, vm, proposalTx) + require.EqualValues(5, proposalState.TotalAllowedVoters) // all 5 validators must vote + require.Equal([]ids.ID{proposalTx.ID()}, nextProposalIDsToExpire) // we have only one proposal + require.Equal(proposalState.EndTime(), nexExpirationTime) + require.Empty(proposalIDsToFinish) // no early-finished proposals + burnedAmt += fee + checkBalance(t, vm.state, proposerAddr, + balance-burnedAmt, // total + proposalBondAmount, // bonded + 0, 0, balance-proposalBondAmount-burnedAmt, // unlocked + ) + + // Fast-forward clock to time a bit forward, but still before proposals start + // Try to vote on proposal, expect to fail + vm.clock.Set(proposalState.StartTime().Add(-time.Second)) + addVoteTx := buildSimpleVoteTx(t, vm, proposerKey, fee, proposalTx.ID(), caminoPreFundedKeys[0], 0) + require.Error(vm.Builder.AddUnverifiedTx(addVoteTx)) + vm.clock.Set(proposalState.StartTime()) + + optionWeights := make([]uint32, len(proposalState.Options)) + for i, vote := range tt.votes { + optionWeights[vote.option]++ + voteTx := buildSimpleVoteTx(t, vm, proposerKey, fee, proposalTx.ID(), caminoPreFundedKeys[i], vote.option) + proposalIDsToFinish, proposalState = voteOnBaseFeeWithTx(t, vm, voteTx, proposalTx.ID(), optionWeights) + if tt.earlyFinish && i == len(tt.votes)-1 { + require.Equal([]ids.ID{proposalTx.ID()}, proposalIDsToFinish) // proposal has finished early + } else { + require.Empty(proposalIDsToFinish) // no early-finished proposals + } + nextProposalIDsToExpire, nexExpirationTime, err := vm.state.GetNextToExpireProposalIDsAndTime(nil) + require.NoError(err) + require.Equal([]ids.ID{proposalTx.ID()}, nextProposalIDsToExpire) + require.Equal(proposalState.EndTime(), nexExpirationTime) + require.Equal(tt.earlyFinish && i == len(tt.votes)-1, proposalState.CanBeFinished()) + require.Equal(vote.success, proposalState.IsSuccessful()) + burnedAmt += fee + checkBalance(t, vm.state, proposerAddr, + balance-burnedAmt, // total + proposalBondAmount, // bonded + 0, 0, balance-proposalBondAmount-burnedAmt, // unlocked + ) + } + + if !tt.earlyFinish { // no early finish + vm.clock.Set(proposalState.EndTime()) + } + + blk = buildAndAcceptBlock(t, vm, nil) + require.Len(blk.Txs(), 1) + checkTx(t, vm, blk.ID(), blk.Txs()[0].ID()) + _, err = vm.state.GetProposal(proposalTx.ID()) + require.ErrorIs(err, database.ErrNotFound) + _, _, err = vm.state.GetNextToExpireProposalIDsAndTime(nil) + require.ErrorIs(err, database.ErrNotFound) + proposalIDsToFinish, err = vm.state.GetProposalIDsToFinish() + require.NoError(err) + require.Empty(proposalIDsToFinish) + checkBalance(t, vm.state, proposerAddr, + balance-burnedAmt, // total + 0, 0, 0, balance-burnedAmt, // unlocked + ) + + if len(tt.votes) != 0 && tt.votes[len(tt.votes)-1].success { // last vote + fee = tt.feeOptions[tt.winningOption] + baseFee, err := vm.state.GetBaseFee() + require.NoError(err) + require.Equal(fee, baseFee) // fee has changed + } + + // Create arbitrary tx to verify which fee is used + buildAndAcceptBaseTx(t, vm, proposerKey, fee) + burnedAmt += fee + checkBalance(t, vm.state, proposerAddr, + balance-burnedAmt, // total + 0, 0, 0, balance-burnedAmt, // unlocked + ) + }) + } +} + func buildAndAcceptBlock(t *testing.T, vm *VM, tx *txs.Tx) blocks.Block { t.Helper() if tx != nil { @@ -556,3 +752,210 @@ func getUnlockedBalance(t *testing.T, db avax.UTXOReader, addr ids.ShortID) uint } return balance } + +func getBalance(t *testing.T, db avax.UTXOReader, addr ids.ShortID) (total, bonded, deposited, depositBonded, unlocked uint64) { + t.Helper() + utxos, err := avax.GetAllUTXOs(db, set.Set[ids.ShortID]{addr: struct{}{}}) + require.NoError(t, err) + for _, utxo := range utxos { + if out, ok := utxo.Out.(*secp256k1fx.TransferOutput); ok { + unlocked += out.Amount() + total += out.Amount() + } else { + out, ok := utxo.Out.(*locked.Out) + require.True(t, ok) + switch out.LockState() { + case locked.StateDepositedBonded: + depositBonded += out.Amount() + case locked.StateDeposited: + deposited += out.Amount() + case locked.StateBonded: + bonded += out.Amount() + } + total += out.Amount() + } + } + return +} + +func checkBalance( + t *testing.T, db avax.UTXOReader, addr ids.ShortID, + expectedTotal, expectedBonded, expectedDeposited, expectedDepositBonded, expectedUnlocked uint64, //nolint:unparam +) { + t.Helper() + total, bonded, deposited, depositBonded, unlocked := getBalance(t, db, addr) + require.Equal(t, expectedTotal, total) + require.Equal(t, expectedBonded, bonded) + require.Equal(t, expectedDeposited, deposited) + require.Equal(t, expectedDepositBonded, depositBonded) + require.Equal(t, expectedUnlocked, unlocked) +} + +func checkTx(t *testing.T, vm *VM, blkID, txID ids.ID) { + t.Helper() + state, ok := vm.manager.GetState(blkID) + require.True(t, ok) + _, txStatus, err := state.GetTx(txID) + require.NoError(t, err) + require.Equal(t, status.Committed, txStatus) + _, txStatus, err = vm.state.GetTx(txID) + require.NoError(t, err) + require.Equal(t, status.Committed, txStatus) +} + +func buildBaseFeeProposalTx( + t *testing.T, + vm *VM, + fundsKey *secp256k1.PrivateKey, + amountToBond uint64, + amountToBurn uint64, + proposerKey *secp256k1.PrivateKey, + options []uint64, + startTime time.Time, + endTime time.Time, +) *txs.Tx { + t.Helper() + ins, outs, signers, _, err := vm.txBuilder.Lock( + vm.state, + []*secp256k1.PrivateKey{fundsKey}, + amountToBond, + amountToBurn, + locked.StateBonded, + nil, nil, 0, + ) + require.NoError(t, err) + proposal := &txs.ProposalWrapper{Proposal: &dac.BaseFeeProposal{ + Start: uint64(startTime.Unix()), + End: uint64(endTime.Unix()), + Options: options, + }} + proposalBytes, err := txs.Codec.Marshal(txs.Version, proposal) + require.NoError(t, err) + proposalTx, err := txs.NewSigned(&txs.AddProposalTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: vm.ctx.NetworkID, + BlockchainID: vm.ctx.ChainID, + Ins: ins, + Outs: outs, + }}, + ProposalPayload: proposalBytes, + ProposerAddress: proposerKey.Address(), + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + }, txs.Codec, append(signers, []*secp256k1.PrivateKey{proposerKey})) + require.NoError(t, err) + return proposalTx +} + +func makeProposalWithTx( + t *testing.T, + vm *VM, + tx *txs.Tx, +) ( + proposal *dac.BaseFeeProposalState, + nextProposalIDsToExpire []ids.ID, + nexExpirationTime time.Time, + proposalIDsToFinish []ids.ID, +) { + t.Helper() + blk := buildAndAcceptBlock(t, vm, tx) + require.Len(t, blk.Txs(), 1) + checkTx(t, vm, blk.ID(), tx.ID()) + proposalState, err := vm.state.GetProposal(tx.ID()) + require.NoError(t, err) + baseFeeProposalState, ok := proposalState.(*dac.BaseFeeProposalState) + require.True(t, ok) + nextProposalIDsToExpire, nexExpirationTime, err = vm.state.GetNextToExpireProposalIDsAndTime(nil) + require.NoError(t, err) + proposalIDsToFinish, err = vm.state.GetProposalIDsToFinish() + require.NoError(t, err) + return baseFeeProposalState, nextProposalIDsToExpire, nexExpirationTime, proposalIDsToFinish +} + +func buildSimpleVoteTx( + t *testing.T, + vm *VM, + fundsKey *secp256k1.PrivateKey, + amountToBurn uint64, + proposalID ids.ID, + voterKey *secp256k1.PrivateKey, + votedOption uint32, +) *txs.Tx { + t.Helper() + ins, outs, signers, _, err := vm.txBuilder.Lock( + vm.state, + []*secp256k1.PrivateKey{fundsKey}, + 0, + amountToBurn, + locked.StateUnlocked, + nil, nil, 0, + ) + require.NoError(t, err) + voteBytes, err := txs.Codec.Marshal(txs.Version, &txs.VoteWrapper{Vote: &dac.SimpleVote{OptionIndex: votedOption}}) + require.NoError(t, err) + addVoteTx, err := txs.NewSigned(&txs.AddVoteTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: vm.ctx.NetworkID, + BlockchainID: vm.ctx.ChainID, + Ins: ins, + Outs: outs, + }}, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterKey.Address(), + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + }, txs.Codec, append(signers, []*secp256k1.PrivateKey{voterKey})) + require.NoError(t, err) + return addVoteTx +} + +func voteOnBaseFeeWithTx( + t *testing.T, + vm *VM, + tx *txs.Tx, + proposalID ids.ID, + expectedVoteWeights []uint32, +) (proposalIDsToFinish []ids.ID, baseFeeProposalState *dac.BaseFeeProposalState) { + t.Helper() + blk := buildAndAcceptBlock(t, vm, tx) + require.Len(t, blk.Txs(), 1) + checkTx(t, vm, blk.ID(), tx.ID()) + proposalState, err := vm.state.GetProposal(proposalID) + require.NoError(t, err) + baseFeeProposalState, ok := proposalState.(*dac.BaseFeeProposalState) + require.True(t, ok) + require.Len(t, baseFeeProposalState.Options, len(expectedVoteWeights)) + for i := range baseFeeProposalState.Options { + require.Equal(t, expectedVoteWeights[i], baseFeeProposalState.Options[i].Weight) + } + proposalIDsToFinish, err = vm.state.GetProposalIDsToFinish() + require.NoError(t, err) + return proposalIDsToFinish, baseFeeProposalState +} + +func buildAndAcceptBaseTx( + t *testing.T, + vm *VM, + fundsKey *secp256k1.PrivateKey, + amountToBurn uint64, +) { + t.Helper() + ins, outs, signers, _, err := vm.txBuilder.Lock( + vm.state, + []*secp256k1.PrivateKey{fundsKey}, + 0, + amountToBurn, + locked.StateUnlocked, + nil, nil, 0, + ) + require.NoError(t, err) + feeTestingTx, err := txs.NewSigned(&txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: vm.ctx.NetworkID, + BlockchainID: vm.ctx.ChainID, + Ins: ins, + Outs: outs, + }}, txs.Codec, signers) + require.NoError(t, err) + blk := buildAndAcceptBlock(t, vm, feeTestingTx) + require.Len(t, blk.Txs(), 1) + checkTx(t, vm, blk.ID(), feeTestingTx.ID()) +} diff --git a/vms/platformvm/dac/camino_change_base_fee_proposal.go b/vms/platformvm/dac/camino_change_base_fee_proposal.go new file mode 100644 index 000000000000..dba54fdbe0dc --- /dev/null +++ b/vms/platformvm/dac/camino_change_base_fee_proposal.go @@ -0,0 +1,193 @@ +// Copyright (C) 2023, Chain4Travel AG. All rights reserved. +// See the file LICENSE for licensing terms. + +package dac + +import ( + "bytes" + "errors" + "fmt" + "time" + + "github.com/ava-labs/avalanchego/ids" + "golang.org/x/exp/slices" +) + +const baseFeeProposalMaxOptionsCount = 3 + +var ( + _ Proposal = (*BaseFeeProposal)(nil) + _ ProposalState = (*BaseFeeProposalState)(nil) + + errZeroFee = errors.New("base-fee option is zero") + errWrongOptionsCount = errors.New("wrong options count") +) + +type BaseFeeProposal struct { + Options []uint64 `serialize:"true"` // New base fee options + Start uint64 `serialize:"true"` // Start time of proposal + End uint64 `serialize:"true"` // End time of proposal +} + +func (p *BaseFeeProposal) StartTime() time.Time { + return time.Unix(int64(p.Start), 0) +} + +func (p *BaseFeeProposal) EndTime() time.Time { + return time.Unix(int64(p.End), 0) +} + +func (p *BaseFeeProposal) GetOptions() any { + return p.Options +} + +func (p *BaseFeeProposal) Verify() error { + switch { + case len(p.Options) > baseFeeProposalMaxOptionsCount: + return fmt.Errorf("%w (expected: no more than %d, actual: %d)", errWrongOptionsCount, baseFeeProposalMaxOptionsCount, len(p.Options)) + case p.Start >= p.End: + return errEndNotAfterStart + } + for _, fee := range p.Options { + if fee == 0 { + return errZeroFee + } + } + return nil +} + +func (p *BaseFeeProposal) CreateProposalState(allowedVoters []ids.ShortID) ProposalState { + stateProposal := &BaseFeeProposalState{ + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: make([]SimpleVoteOption[uint64], len(p.Options)), + }, + Start: p.Start, + End: p.End, + AllowedVoters: allowedVoters, + TotalAllowedVoters: uint32(len(allowedVoters)), + } + for i := range p.Options { + stateProposal.Options[i].Value = p.Options[i] + } + return stateProposal +} + +func (p *BaseFeeProposal) Visit(visitor VerifierVisitor) error { + return visitor.BaseFeeProposal(p) +} + +type BaseFeeProposalState struct { + SimpleVoteOptions[uint64] `serialize:"true"` // New base fee options + // Start time of proposal + Start uint64 `serialize:"true"` + // End time of proposal + End uint64 `serialize:"true"` + // Addresses that are allowed to vote for this proposal + AllowedVoters []ids.ShortID `serialize:"true"` + // Number of addresses that were initially allowed to vote for this proposal. + // This is used to calculate thresholds like "half of total voters". + TotalAllowedVoters uint32 `serialize:"true"` +} + +func (p *BaseFeeProposalState) StartTime() time.Time { + return time.Unix(int64(p.Start), 0) +} + +func (p *BaseFeeProposalState) EndTime() time.Time { + return time.Unix(int64(p.End), 0) +} + +func (p *BaseFeeProposalState) IsActiveAt(time time.Time) bool { + timestamp := uint64(time.Unix()) + return p.Start <= timestamp && timestamp <= p.End +} + +func (p *BaseFeeProposalState) CanBeFinished() bool { + mostVotedWeight, _, unambiguous := p.GetMostVoted() + voted := p.Voted() + return voted == p.TotalAllowedVoters || unambiguous && mostVotedWeight > p.TotalAllowedVoters/2 +} + +func (p *BaseFeeProposalState) IsSuccessful() bool { + mostVotedWeight, _, unambiguous := p.GetMostVoted() + voted := p.Voted() + return unambiguous && voted > p.TotalAllowedVoters/2 && mostVotedWeight > voted/2 +} + +func (p *BaseFeeProposalState) Outcome() any { + _, mostVotedOptionIndex, unambiguous := p.GetMostVoted() + if !unambiguous { + return -1 + } + return mostVotedOptionIndex +} + +// Votes must be valid for this proposal, could panic otherwise. +func (p *BaseFeeProposalState) Result() (uint64, 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 *BaseFeeProposalState) 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 := &BaseFeeProposalState{ + Start: p.Start, + End: p.End, + AllowedVoters: make([]ids.ShortID, len(p.AllowedVoters)-1), + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: make([]SimpleVoteOption[uint64], 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 *BaseFeeProposalState) 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 := &BaseFeeProposalState{ + Start: p.Start, + End: p.End, + AllowedVoters: p.AllowedVoters, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: make([]SimpleVoteOption[uint64], 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 *BaseFeeProposalState) Visit(visitor ExecutorVisitor) error { + return visitor.BaseFeeProposal(p) +} diff --git a/vms/platformvm/dac/camino_change_base_fee_proposal_test.go b/vms/platformvm/dac/camino_change_base_fee_proposal_test.go new file mode 100644 index 000000000000..a7a353a623bc --- /dev/null +++ b/vms/platformvm/dac/camino_change_base_fee_proposal_test.go @@ -0,0 +1,324 @@ +// 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 TestBaseFeeProposalCreateProposalState(t *testing.T) { + tests := map[string]struct { + proposal *BaseFeeProposal + allowedVoters []ids.ShortID + expectedProposalState ProposalState + expectedProposal *BaseFeeProposal + }{ + "OK: even number of allowed voters": { + proposal: &BaseFeeProposal{ + Start: 100, + End: 101, + Options: []uint64{123, 555, 7}, + }, + allowedVoters: []ids.ShortID{{1}, {2}, {3}, {4}}, + expectedProposalState: &BaseFeeProposalState{ + Start: 100, + End: 101, + AllowedVoters: []ids.ShortID{{1}, {2}, {3}, {4}}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 123}, + {Value: 555}, + {Value: 7}, + }, + }, + TotalAllowedVoters: 4, + }, + expectedProposal: &BaseFeeProposal{ + Start: 100, + End: 101, + Options: []uint64{123, 555, 7}, + }, + }, + "OK: odd number of allowed voters": { + proposal: &BaseFeeProposal{ + Start: 100, + End: 101, + Options: []uint64{123, 555, 7}, + }, + allowedVoters: []ids.ShortID{{1}, {2}, {3}, {4}, {5}}, + expectedProposalState: &BaseFeeProposalState{ + Start: 100, + End: 101, + AllowedVoters: []ids.ShortID{{1}, {2}, {3}, {4}, {5}}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 123}, + {Value: 555}, + {Value: 7}, + }, + }, + TotalAllowedVoters: 5, + }, + expectedProposal: &BaseFeeProposal{ + Start: 100, + End: 101, + Options: []uint64{123, 555, 7}, + }, + }, + } + 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 TestBaseFeeProposalStateAddVote(t *testing.T) { + voterAddr1 := ids.ShortID{1} + voterAddr2 := ids.ShortID{1} + voterAddr3 := ids.ShortID{1} + + tests := map[string]struct { + proposal *BaseFeeProposalState + voterAddr ids.ShortID + vote Vote + expectedUpdatedProposal ProposalState + expectedOriginalProposal *BaseFeeProposalState + expectedErr error + }{ + "Wrong vote type": { + proposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 10, Weight: 2}, // 0 + {Value: 20, Weight: 0}, // 1 + {Value: 30, Weight: 1}, // 2 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + voterAddr: voterAddr1, + vote: &DummyVote{}, // not *SimpleVote + expectedOriginalProposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 10, Weight: 2}, // 0 + {Value: 20, Weight: 0}, // 1 + {Value: 30, Weight: 1}, // 2 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + expectedErr: ErrWrongVote, + }, + "Wrong vote option index": { + proposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 10, Weight: 2}, // 0 + {Value: 20, Weight: 0}, // 1 + {Value: 30, Weight: 1}, // 2 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + voterAddr: ids.ShortID{3}, + vote: &SimpleVote{OptionIndex: 3}, + expectedOriginalProposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 10, Weight: 2}, // 0 + {Value: 20, Weight: 0}, // 1 + {Value: 30, Weight: 1}, // 2 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + expectedErr: ErrWrongVote, + }, + "Not allowed to vote on this proposal": { + proposal: &BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{{1}, {2}}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{{Value: 1}}, + }, + }, + voterAddr: ids.ShortID{3}, + vote: &SimpleVote{OptionIndex: 0}, + expectedOriginalProposal: &BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{{1}, {2}}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{{Value: 1}}, + }, + }, + expectedErr: ErrNotAllowedToVoteOnProposal, + }, + "OK: adding vote to not voted option": { + proposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 10, Weight: 2}, // 0 + {Value: 20, Weight: 0}, // 1 + {Value: 30, Weight: 1}, // 2 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + voterAddr: voterAddr1, + vote: &SimpleVote{OptionIndex: 1}, + expectedUpdatedProposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 10, Weight: 2}, // 0 + {Value: 20, Weight: 1}, // 1 + {Value: 30, Weight: 1}, // 2 + }, + }, + }, + expectedOriginalProposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 10, Weight: 2}, // 0 + {Value: 20, Weight: 0}, // 1 + {Value: 30, Weight: 1}, // 2 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + }, + "OK: adding vote to already voted option": { + proposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 10, Weight: 2}, // 0 + {Value: 20, Weight: 0}, // 1 + {Value: 30, Weight: 1}, // 2 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + voterAddr: voterAddr1, + vote: &SimpleVote{OptionIndex: 2}, + expectedUpdatedProposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 10, Weight: 2}, // 0 + {Value: 20, Weight: 0}, // 1 + {Value: 30, Weight: 2}, // 2 + }, + }, + }, + expectedOriginalProposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{ + {Value: 10, Weight: 2}, // 0 + {Value: 20, Weight: 0}, // 1 + {Value: 30, Weight: 1}, // 2 + }, + mostVotedWeight: 2, + mostVotedOptionIndex: 0, + unambiguous: true, + }, + }, + }, + "OK: voter addr in the middle of allowedVoters array": { + proposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1, voterAddr2, voterAddr3}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{{Value: 1}}, + }, + }, + voterAddr: voterAddr2, + vote: &SimpleVote{OptionIndex: 0}, + expectedUpdatedProposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1, voterAddr3}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{{Value: 1, Weight: 1}}, + }, + }, + expectedOriginalProposal: &BaseFeeProposalState{ + Start: 100, + End: 101, + TotalAllowedVoters: 555, + AllowedVoters: []ids.ShortID{voterAddr1, voterAddr2, voterAddr3}, + SimpleVoteOptions: SimpleVoteOptions[uint64]{ + Options: []SimpleVoteOption[uint64]{{Value: 1}}, + }, + }, + }, + } + 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 7486898de839..8f368fbf26c9 100644 --- a/vms/platformvm/dac/camino_proposal.go +++ b/vms/platformvm/dac/camino_proposal.go @@ -12,13 +12,18 @@ import ( ) var ( + errEndNotAfterStart = errors.New("proposal end-time is not after start-time") 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{} +type VerifierVisitor interface { + BaseFeeProposal(*BaseFeeProposal) error +} -type ExecutorVisitor interface{} +type ExecutorVisitor interface { + BaseFeeProposal(*BaseFeeProposalState) error +} type Proposal interface { verify.Verifiable diff --git a/vms/platformvm/state/camino.go b/vms/platformvm/state/camino.go index 91694e6ac586..8c5227c0d15a 100644 --- a/vms/platformvm/state/camino.go +++ b/vms/platformvm/state/camino.go @@ -74,6 +74,10 @@ type CaminoApply interface { } type CaminoDiff interface { + // Singletones + GetBaseFee() (uint64, error) + SetBaseFee(uint64) + // Address State SetAddressStates(ids.ShortID, txs.AddressState) diff --git a/vms/platformvm/state/camino_diff.go b/vms/platformvm/state/camino_diff.go index 733c90698763..9b507820da2e 100644 --- a/vms/platformvm/state/camino_diff.go +++ b/vms/platformvm/state/camino_diff.go @@ -617,11 +617,31 @@ func (it *diffProposalsIterator) key() (ids.ID, error) { return it.parentIterator.key() // err should never happen } +func (d *diff) GetBaseFee() (uint64, error) { + if d.caminoDiff.modifiedBaseFee != nil { + return *d.caminoDiff.modifiedBaseFee, nil + } + + parentState, ok := d.stateVersions.GetState(d.parentID) + if !ok { + return 0, fmt.Errorf("%w: %s", ErrMissingParentState, d.parentID) + } + + return parentState.GetBaseFee() +} + +func (d *diff) SetBaseFee(baseFee uint64) { + d.caminoDiff.modifiedBaseFee = &baseFee +} + // Finally apply all changes func (d *diff) ApplyCaminoState(baseState State) { if d.caminoDiff.modifiedNotDistributedValidatorReward != nil { baseState.SetNotDistributedValidatorReward(*d.caminoDiff.modifiedNotDistributedValidatorReward) } + if d.caminoDiff.modifiedBaseFee != nil { + baseState.SetBaseFee(*d.caminoDiff.modifiedBaseFee) + } for k, v := range d.caminoDiff.modifiedAddressStates { baseState.SetAddressStates(k, v) diff --git a/vms/platformvm/state/camino_diff_test.go b/vms/platformvm/state/camino_diff_test.go index f63f25b8adbb..a85ddf3a7412 100644 --- a/vms/platformvm/state/camino_diff_test.go +++ b/vms/platformvm/state/camino_diff_test.go @@ -15,6 +15,7 @@ import ( "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/components/multisig" "github.com/ava-labs/avalanchego/vms/platformvm/config" + "github.com/ava-labs/avalanchego/vms/platformvm/dac" "github.com/ava-labs/avalanchego/vms/platformvm/deposit" "github.com/ava-labs/avalanchego/vms/platformvm/locked" "github.com/ava-labs/avalanchego/vms/platformvm/txs" @@ -3046,6 +3047,238 @@ func TestDiffGetDeferredStakerIterator(t *testing.T) { } } +func TestDiffGetProposal(t *testing.T) { + parentStateID := ids.ID{1} + proposalID := ids.ID{1, 1} + proposal := &dac.BaseFeeProposalState{} + testErr := errors.New("test err") + + tests := map[string]struct { + diff func(*gomock.Controller) *diff + proposalID ids.ID + expectedDiff func(*diff) *diff + expectedProposal dac.ProposalState + expectedErr error + }{ + "Fail: proposal removed": { + diff: func(c *gomock.Controller) *diff { + return &diff{ + stateVersions: NewMockVersions(c), + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal, removed: true}, + }, + }, + } + }, + proposalID: proposalID, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal, removed: true}, + }, + }, + } + }, + expectedErr: database.ErrNotFound, + }, + "OK: proposal modified": { + diff: func(c *gomock.Controller) *diff { + return &diff{ + stateVersions: NewMockVersions(c), + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal}, + }, + }, + } + }, + proposalID: proposalID, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal}, + }, + }, + } + }, + expectedProposal: proposal, + }, + "OK: proposal added": { + diff: func(c *gomock.Controller) *diff { + return &diff{ + stateVersions: NewMockVersions(c), + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal, added: true}, + }, + }, + } + }, + proposalID: proposalID, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal, added: true}, + }, + }, + } + }, + expectedProposal: proposal, + }, + "OK: proposal in parent state": { + diff: func(c *gomock.Controller) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetProposal(proposalID).Return(proposal, nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + proposalID: proposalID, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, + } + }, + expectedProposal: proposal, + }, + "Fail: parent errored": { + diff: func(c *gomock.Controller) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetProposal(proposalID).Return(nil, testErr) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + proposalID: proposalID, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, + } + }, + expectedErr: testErr, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + actualDiff := tt.diff(ctrl) + actualProposal, err := actualDiff.GetProposal(proposalID) + require.ErrorIs(t, err, tt.expectedErr) + require.Equal(t, tt.expectedProposal, actualProposal) + require.Equal(t, tt.expectedDiff(actualDiff), actualDiff) + }) + } +} + +func TestDiffAddProposal(t *testing.T) { + proposalID := ids.ID{1} + proposal := &dac.BaseFeeProposalState{} + + tests := map[string]struct { + diff *diff + proposalID ids.ID + proposal dac.ProposalState + expectedDiff *diff + }{ + "OK": { + diff: &diff{caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{}, + }}, + proposalID: proposalID, + proposal: proposal, + expectedDiff: &diff{caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal, added: true}, + }, + }}, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + tt.diff.AddProposal(tt.proposalID, tt.proposal) + require.Equal(t, tt.expectedDiff, tt.diff) + }) + } +} + +func TestDiffModifyProposal(t *testing.T) { + proposalID := ids.ID{1} + proposal := &dac.BaseFeeProposalState{} + + tests := map[string]struct { + diff *diff + proposalID ids.ID + proposal dac.ProposalState + expectedDiff *diff + }{ + "OK": { + diff: &diff{caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{}, + }}, + proposalID: proposalID, + proposal: proposal, + expectedDiff: &diff{caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal}, + }, + }}, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + tt.diff.ModifyProposal(tt.proposalID, tt.proposal) + require.Equal(t, tt.expectedDiff, tt.diff) + }) + } +} + +func TestDiffRemoveProposal(t *testing.T) { + proposalID := ids.ID{1} + proposal := &dac.BaseFeeProposalState{} + + tests := map[string]struct { + diff *diff + proposalID ids.ID + proposal dac.ProposalState + expectedDiff *diff + }{ + "OK": { + diff: &diff{caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{}, + }}, + proposalID: proposalID, + proposal: proposal, + expectedDiff: &diff{caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal, removed: true}, + }, + }}, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + tt.diff.RemoveProposal(tt.proposalID, tt.proposal) + require.Equal(t, tt.expectedDiff, tt.diff) + }) + } +} + func TestDiffAddProposalIDToFinish(t *testing.T) { proposalID1 := ids.ID{1} proposalID2 := ids.ID{2} @@ -3297,75 +3530,1685 @@ func TestDiffGetProposalIDsToFinish(t *testing.T) { } } -func TestDiffApplyCaminoState(t *testing.T) { - reward := uint64(12345) - baseFee := uint64(6789) +func TestDiffGetNextProposalExpirationTime(t *testing.T) { + parentStateID := ids.ID{1} + earlyProposalTxID1 := ids.ID{1, 1} + earlyProposalTxID2 := ids.ID{1, 2} + midProposalTxID := ids.ID{1, 11} + lateProposalTxID1 := ids.ID{1, 101} + lateProposalTxID2 := ids.ID{1, 102} + earlyProposal := &dac.BaseFeeProposalState{End: 101} + midProposal := &dac.BaseFeeProposalState{End: 102} + lateProposal := &dac.BaseFeeProposalState{End: 103} + testErr := errors.New("test err") + tests := map[string]struct { - diff *diff - state func(*gomock.Controller, *diff) *MockState - expectedDiff *diff + diff func(*gomock.Controller, set.Set[ids.ID]) *diff + removedProposalIDs set.Set[ids.ID] + expectedNextExpirationTime time.Time + expectedDiff func(*diff) *diff + expectedErr error }{ - "OK": { - diff: &diff{caminoDiff: &caminoDiff{ - modifiedAddressStates: map[ids.ShortID]txs.AddressState{ - {1}: 101, - {2}: 0, - }, - modifiedDepositOffers: map[ids.ID]*deposit.Offer{ - {3}: {ID: ids.ID{3}}, - {4}: nil, - }, - modifiedDeposits: map[ids.ID]*depositDiff{ - {5}: {Deposit: &deposit.Deposit{Amount: 105}}, - {6}: {Deposit: &deposit.Deposit{Amount: 106}, added: true}, - {7}: {Deposit: &deposit.Deposit{Amount: 107}, removed: true}, - }, - modifiedMultisigAliases: map[ids.ShortID]*multisig.AliasWithNonce{ - {8}: {Alias: multisig.Alias{ID: ids.ShortID{108}}}, - {9}: nil, - }, - modifiedShortLinks: map[ids.ID]*ids.ShortID{ - toShortLinkKey(ids.ShortID{10}, ShortLinkKeyRegisterNode): {110}, - toShortLinkKey(ids.ShortID{11}, ShortLinkKeyRegisterNode): nil, - }, - modifiedClaimables: map[ids.ID]*Claimable{ - {12}: {ValidatorReward: 112}, - {13}: nil, - }, - modifiedProposals: map[ids.ID]*proposalDiff{}, - modifiedProposalIDsToFinish: map[ids.ID]bool{ - {17}: true, {18}: false, {19}: true, {20}: false, - }, - modifiedNotDistributedValidatorReward: &reward, - modifiedBaseFee: &baseFee, - deferredStakerDiffs: diffStakers{}, - }}, - state: func(c *gomock.Controller, d *diff) *MockState { - s := NewMockState(c) - s.EXPECT().SetNotDistributedValidatorReward(*d.caminoDiff.modifiedNotDistributedValidatorReward) - s.EXPECT().SetBaseFee(*d.caminoDiff.modifiedBaseFee) - for k, v := range d.caminoDiff.modifiedAddressStates { - s.EXPECT().SetAddressStates(k, v) - } - for _, depositOffer := range d.caminoDiff.modifiedDepositOffers { - s.EXPECT().SetDepositOffer(depositOffer) - } - for depositTxID, depositDiff := range d.caminoDiff.modifiedDeposits { - switch { - case depositDiff.added: - s.EXPECT().AddDeposit(depositTxID, depositDiff.Deposit) - case depositDiff.removed: - s.EXPECT().RemoveDeposit(depositTxID, depositDiff.Deposit) - default: - s.EXPECT().ModifyDeposit(depositTxID, depositDiff.Deposit) - } + "Fail: parent errored": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(nil).Return(time.Time{}, testErr) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, } - for _, v := range d.caminoDiff.modifiedMultisigAliases { - s.EXPECT().SetMultisigAlias(v) + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, } - for fullKey, link := range d.caminoDiff.modifiedShortLinks { - id, key := fromShortLinkKey(fullKey) - s.EXPECT().SetShortIDLink(id, key, link) + }, + expectedNextExpirationTime: time.Time{}, + expectedErr: testErr, + }, + "Fail: no proposals": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(nil).Return(mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, + } + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "Fail: proposals in parent state only, but all removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1, earlyProposalTxID2) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + earlyProposalTxID2: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + earlyProposalTxID2: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "OK: proposals in parent state only, but one removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (late) and parent state (early), but all parent removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(mockable.MaxTime, nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: lateProposal.EndTime(), + }, + "OK: proposals in added (late) and parent state (early), but one parent removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but all parent removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(lateProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(mockable.MaxTime, nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but one removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(lateProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(lateProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added only": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in parent state only": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (late) and parent state (early)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early, mid) and parent state (late)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(lateProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + midProposalTxID: {Proposal: midProposal, added: true}, + }, + }, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + midProposalTxID: {Proposal: midProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "Fail: proposals in parent state only, but all removed in arg": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID1: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, + } + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "Fail: proposals in parent state only, but all removed (one in arg, one in diff)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID2: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "OK: proposals in added (late) and parent state (early), but all parent removed (one in arg, one in diff)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(mockable.MaxTime, nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID2: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: lateProposal.EndTime(), + }, + "OK: proposals in added (late) and parent state (early), but all parent removed in arg": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(mockable.MaxTime, nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID1: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: lateProposal.EndTime(), + }, + "OK: proposals in added (late) and parent state (early), but some parent removed (one in arg, one in diff)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID2: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (late) and parent state (early), but some parent removed in arg": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID1: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but all parent removed (one in arg, one in diff)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(lateProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(mockable.MaxTime, nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + lateProposalTxID2: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but all parent removed in arg": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(mockable.MaxTime, nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + lateProposalTxID1: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but some removed (one in arg, one in diff)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(lateProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(lateProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + lateProposalTxID2: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but some removed in arg": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(lateProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextProposalExpirationTime(removedProposalIDs). + Return(lateProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + lateProposalTxID1: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + actualDiff := tt.diff(ctrl, tt.removedProposalIDs) + nextExpirationTime, err := actualDiff.GetNextProposalExpirationTime(tt.removedProposalIDs) + require.ErrorIs(t, err, tt.expectedErr) + require.Equal(t, tt.expectedNextExpirationTime, nextExpirationTime) + require.Equal(t, tt.expectedDiff(actualDiff), actualDiff) + }) + } +} + +func TestDiffGetNextToExpireProposalIDsAndTime(t *testing.T) { + parentStateID := ids.ID{1} + earlyProposalTxID1 := ids.ID{1, 1} + earlyProposalTxID2 := ids.ID{1, 2} + earlyProposalTxID3 := ids.ID{1, 3} + midProposalTxID := ids.ID{1, 10} + lateProposalTxID1 := ids.ID{1, 101} + lateProposalTxID2 := ids.ID{1, 102} + lateProposalTxID3 := ids.ID{1, 103} + earlyProposal := &dac.BaseFeeProposalState{End: 101} + midProposal := &dac.BaseFeeProposalState{End: 102} + lateProposal := &dac.BaseFeeProposalState{End: 103} + testErr := errors.New("test err") + + tests := map[string]struct { + diff func(*gomock.Controller, set.Set[ids.ID]) *diff + removedProposalIDs set.Set[ids.ID] + expectedDiff func(*diff) *diff + expectedNextToExpireIDs []ids.ID + expectedNextExpirationTime time.Time + expectedErr error + }{ + "Fail: parent errored": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(nil).Return(nil, time.Time{}, testErr) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, + } + }, + expectedNextExpirationTime: time.Time{}, + expectedErr: testErr, + }, + "Fail: no proposals": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(nil). + Return(nil, mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, + } + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "Fail: proposals in parent state only, but all removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1, earlyProposalTxID2) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return(nil, mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + earlyProposalTxID2: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + earlyProposalTxID2: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "OK: proposals in parent state only, but one removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return([]ids.ID{earlyProposalTxID2}, earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID2}, + }, + "OK: proposals in added (late) and parent state (early), but all parent removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1, earlyProposalTxID2) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return(nil, mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + earlyProposalTxID2: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextToExpireIDs: []ids.ID{lateProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + earlyProposalTxID2: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: lateProposal.EndTime(), + }, + "OK: proposals in added (late) and parent state (early), but one parent removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return([]ids.ID{earlyProposalTxID2}, earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID2}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but all parent removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(lateProposalTxID1, lateProposalTxID2) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return(nil, mockable.MaxTime, nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + lateProposalTxID2: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + lateProposalTxID2: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but one parent removed": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(lateProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return([]ids.ID{lateProposalTxID2}, lateProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added only (early, late)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs).Return(nil, mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in parent state only": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs).Return( + []ids.ID{earlyProposalTxID1, earlyProposalTxID2}, + earlyProposal.EndTime(), + nil, + ) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID1, earlyProposalTxID2}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (late) and parent state (early)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs).Return( + []ids.ID{earlyProposalTxID1}, + earlyProposal.EndTime(), + nil, + ) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early, mid) and parent state (late)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs).Return( + []ids.ID{lateProposalTxID1}, + lateProposal.EndTime(), + nil, + ) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + midProposalTxID: {Proposal: midProposal, added: true}, + }, + }, + } + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + midProposalTxID: {Proposal: midProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early1, late) and parent state (early2)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs).Return( + []ids.ID{earlyProposalTxID2}, + earlyProposal.EndTime(), + nil, + ) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID1, earlyProposalTxID2}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "Fail: proposals in parent state only, but all removed in arg": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return(nil, mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID1: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, + } + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "OK: proposals in parent state only, but one removed in arg": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return([]ids.ID{earlyProposalTxID2}, earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID1: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID2}, + }, + "OK: proposals in added (late) and parent state (early), but all parent removed in arg": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return(nil, mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID1: struct{}{}, + }, + expectedNextToExpireIDs: []ids.ID{lateProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: lateProposal.EndTime(), + }, + "OK: proposals in added (late) and parent state (early), but one parent removed in arg": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return([]ids.ID{earlyProposalTxID2}, earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID1: struct{}{}, + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID2}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but all parent removed in arg": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return(nil, mockable.MaxTime, nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + lateProposalTxID1: struct{}{}, + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but one parent removed in arg": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return([]ids.ID{lateProposalTxID2}, lateProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + lateProposalTxID1: struct{}{}, + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "Fail: proposals in parent state only, but all removed (one in arg, one in diff)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return(nil, mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID2: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "OK: proposals in parent state only, but some removed (one in arg, one in diff)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return([]ids.ID{earlyProposalTxID3}, earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID2: struct{}{}, + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID3}, + }, + "OK: proposals in added (late) and parent state (early), but all parent removed (one in arg, one in diff)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return(nil, mockable.MaxTime, database.ErrNotFound) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID2: struct{}{}, + }, + expectedNextToExpireIDs: []ids.ID{lateProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: lateProposal.EndTime(), + }, + "OK: proposals in added (late) and parent state (early), but some parent removed (one in arg, one in diff)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(earlyProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return([]ids.ID{earlyProposalTxID3}, earlyProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + earlyProposalTxID2: struct{}{}, + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID3}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + lateProposalTxID1: {Proposal: lateProposal, added: true}, + earlyProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but all parent removed (one in arg, one in diff)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(lateProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return(nil, mockable.MaxTime, nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + lateProposalTxID2: struct{}{}, + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + "OK: proposals in added (early) and parent state (late), but some parent removed (one in arg, one in diff)": { + diff: func(c *gomock.Controller, removedProposalIDs set.Set[ids.ID]) *diff { + removedProposalIDs.Add(lateProposalTxID1) + parentState := NewMockChain(c) + parentState.EXPECT().GetNextToExpireProposalIDsAndTime(removedProposalIDs). + Return([]ids.ID{lateProposalTxID3}, lateProposal.EndTime(), nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + lateProposalTxID2: struct{}{}, + }, + expectedNextToExpireIDs: []ids.ID{earlyProposalTxID1}, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + earlyProposalTxID1: {Proposal: earlyProposal, added: true}, + lateProposalTxID1: {Proposal: lateProposal, removed: true}, + }, + }, + } + }, + expectedNextExpirationTime: earlyProposal.EndTime(), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + actualDiff := tt.diff(ctrl, tt.removedProposalIDs) + nextToExpireIDs, nextExpirationTime, err := actualDiff.GetNextToExpireProposalIDsAndTime(tt.removedProposalIDs) + require.ErrorIs(t, err, tt.expectedErr) + require.Equal(t, tt.expectedNextExpirationTime, nextExpirationTime) + require.Equal(t, tt.expectedNextToExpireIDs, nextToExpireIDs) + require.Equal(t, tt.expectedDiff(actualDiff), actualDiff) + }) + } +} + +func TestDiffGetBaseFee(t *testing.T) { + parentStateID := ids.ID{123} + baseFee := uint64(123) + testErr := errors.New("test err") + + tests := map[string]struct { + diff func(*gomock.Controller) *diff + expectedDiff func(actualDiff *diff) *diff + expectedBaseFee uint64 + expectedErr error + }{ + "OK: modified": { + diff: func(c *gomock.Controller) *diff { + return &diff{caminoDiff: &caminoDiff{ + modifiedBaseFee: &baseFee, + }} + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{caminoDiff: &caminoDiff{ + modifiedBaseFee: &baseFee, + }} + }, + expectedBaseFee: baseFee, + }, + "OK: in parent": { + diff: func(c *gomock.Controller) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetBaseFee().Return(baseFee, nil) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + caminoDiff: &caminoDiff{}, + parentID: parentStateID, + stateVersions: actualDiff.stateVersions, + } + }, + expectedBaseFee: baseFee, + }, + "Fail: parent errored": { + diff: func(c *gomock.Controller) *diff { + parentState := NewMockChain(c) + parentState.EXPECT().GetBaseFee().Return(uint64(0), testErr) + return &diff{ + stateVersions: newMockStateVersions(c, parentStateID, parentState), + parentID: parentStateID, + caminoDiff: &caminoDiff{}, + } + }, + expectedDiff: func(actualDiff *diff) *diff { + return &diff{ + stateVersions: actualDiff.stateVersions, + parentID: actualDiff.parentID, + caminoDiff: &caminoDiff{}, + } + }, + expectedErr: testErr, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + actualDiff := tt.diff(ctrl) + baseFee, err := actualDiff.GetBaseFee() + require.ErrorIs(t, err, tt.expectedErr) + require.Equal(t, tt.expectedBaseFee, baseFee) + require.Equal(t, tt.expectedDiff(actualDiff), actualDiff) + }) + } +} + +func TestDiffSetBaseFee(t *testing.T) { + baseFee := uint64(123) + + tests := map[string]struct { + diff *diff + baseFee uint64 + expectedDiff *diff + }{ + "OK": { + diff: &diff{caminoDiff: &caminoDiff{}}, + baseFee: baseFee, + expectedDiff: &diff{caminoDiff: &caminoDiff{ + modifiedBaseFee: &baseFee, + }}, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + tt.diff.SetBaseFee(tt.baseFee) + require.Equal(t, tt.expectedDiff, tt.diff) + }) + } +} + +func TestDiffApplyCaminoState(t *testing.T) { + reward := uint64(12345) + baseFee := uint64(6789) + tests := map[string]struct { + diff *diff + state func(*gomock.Controller, *diff) *MockState + expectedDiff *diff + }{ + "OK": { + diff: &diff{caminoDiff: &caminoDiff{ + modifiedAddressStates: map[ids.ShortID]txs.AddressState{ + {1}: 101, + {2}: 0, + }, + modifiedDepositOffers: map[ids.ID]*deposit.Offer{ + {3}: {ID: ids.ID{3}}, + {4}: nil, + }, + modifiedDeposits: map[ids.ID]*depositDiff{ + {5}: {Deposit: &deposit.Deposit{Amount: 105}}, + {6}: {Deposit: &deposit.Deposit{Amount: 106}, added: true}, + {7}: {Deposit: &deposit.Deposit{Amount: 107}, removed: true}, + }, + modifiedMultisigAliases: map[ids.ShortID]*multisig.AliasWithNonce{ + {8}: {Alias: multisig.Alias{ID: ids.ShortID{108}}}, + {9}: nil, + }, + modifiedShortLinks: map[ids.ID]*ids.ShortID{ + toShortLinkKey(ids.ShortID{10}, ShortLinkKeyRegisterNode): {110}, + toShortLinkKey(ids.ShortID{11}, ShortLinkKeyRegisterNode): nil, + }, + modifiedClaimables: map[ids.ID]*Claimable{ + {12}: {ValidatorReward: 112}, + {13}: nil, + }, + modifiedProposals: map[ids.ID]*proposalDiff{ + {14}: {Proposal: &dac.BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{}, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 1, Weight: 1}}, + }, + }}, + {15}: {Proposal: &dac.BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{{115}}, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 2}}, + }, + }, added: true}, + {16}: {Proposal: &dac.BaseFeeProposalState{ + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 3}}, + }, + }, removed: true}, + }, + modifiedProposalIDsToFinish: map[ids.ID]bool{ + {17}: true, {18}: false, {19}: true, {20}: false, + }, + modifiedNotDistributedValidatorReward: &reward, + modifiedBaseFee: &baseFee, + deferredStakerDiffs: diffStakers{}, + }}, + state: func(c *gomock.Controller, d *diff) *MockState { + s := NewMockState(c) + s.EXPECT().SetNotDistributedValidatorReward(*d.caminoDiff.modifiedNotDistributedValidatorReward) + s.EXPECT().SetBaseFee(*d.caminoDiff.modifiedBaseFee) + for k, v := range d.caminoDiff.modifiedAddressStates { + s.EXPECT().SetAddressStates(k, v) + } + for _, depositOffer := range d.caminoDiff.modifiedDepositOffers { + s.EXPECT().SetDepositOffer(depositOffer) + } + for depositTxID, depositDiff := range d.caminoDiff.modifiedDeposits { + switch { + case depositDiff.added: + s.EXPECT().AddDeposit(depositTxID, depositDiff.Deposit) + case depositDiff.removed: + s.EXPECT().RemoveDeposit(depositTxID, depositDiff.Deposit) + default: + s.EXPECT().ModifyDeposit(depositTxID, depositDiff.Deposit) + } + } + for _, v := range d.caminoDiff.modifiedMultisigAliases { + s.EXPECT().SetMultisigAlias(v) + } + for fullKey, link := range d.caminoDiff.modifiedShortLinks { + id, key := fromShortLinkKey(fullKey) + s.EXPECT().SetShortIDLink(id, key, link) } for ownerID, claimable := range d.caminoDiff.modifiedClaimables { s.EXPECT().SetClaimable(ownerID, claimable) @@ -3425,7 +5268,25 @@ func TestDiffApplyCaminoState(t *testing.T) { {12}: {ValidatorReward: 112}, {13}: nil, }, - modifiedProposals: map[ids.ID]*proposalDiff{}, + modifiedProposals: map[ids.ID]*proposalDiff{ + {14}: {Proposal: &dac.BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{}, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 1, Weight: 1}}, + }, + }}, + {15}: {Proposal: &dac.BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{{115}}, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 2}}, + }, + }, added: true}, + {16}: {Proposal: &dac.BaseFeeProposalState{ + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 3}}, + }, + }, removed: true}, + }, modifiedProposalIDsToFinish: map[ids.ID]bool{ {17}: true, {18}: false, {19}: true, {20}: false, }, diff --git a/vms/platformvm/state/camino_proposal_test.go b/vms/platformvm/state/camino_proposal_test.go index e2198a4e5a1d..38a18bc35043 100644 --- a/vms/platformvm/state/camino_proposal_test.go +++ b/vms/platformvm/state/camino_proposal_test.go @@ -4,12 +4,327 @@ package state import ( + "errors" "testing" + "time" + "github.com/ava-labs/avalanchego/cache" + "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/utils/timer/mockable" + "github.com/ava-labs/avalanchego/vms/platformvm/blocks" + "github.com/ava-labs/avalanchego/vms/platformvm/dac" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" ) +func TestGetProposal(t *testing.T) { + proposalID := ids.ID{1} + wrapper := &proposalStateWrapper{ + ProposalState: &dac.BaseFeeProposalState{ + Start: 100, + End: 100, + AllowedVoters: []ids.ShortID{{11}}, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 1234, Weight: 1}}, + }, + TotalAllowedVoters: 5, + }, + } + proposalBytes, err := blocks.GenesisCodec.Marshal(blocks.Version, wrapper) + require.NoError(t, err) + testError := errors.New("test error") + + tests := map[string]struct { + caminoState func(*gomock.Controller) *caminoState + proposalID ids.ID + expectedCaminoState func(*caminoState) *caminoState + expectedProposal dac.ProposalState + expectedErr error + }{ + "Fail: proposal removed": { + caminoState: func(c *gomock.Controller) *caminoState { + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: wrapper.ProposalState, removed: true}, + }, + }, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: wrapper.ProposalState, removed: true}, + }, + }, + } + }, + proposalID: proposalID, + expectedErr: database.ErrNotFound, + }, + "Fail: proposal in cache, but removed": { + caminoState: func(c *gomock.Controller) *caminoState { + cache := cache.NewMockCacher[ids.ID, dac.ProposalState](c) + cache.EXPECT().Get(proposalID).Return(nil, true) + return &caminoState{ + proposalsCache: cache, + caminoDiff: &caminoDiff{}, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalsCache: actualCaminoState.proposalsCache, + caminoDiff: &caminoDiff{}, + } + }, + proposalID: proposalID, + expectedErr: database.ErrNotFound, + }, + "OK: proposal added": { + caminoState: func(c *gomock.Controller) *caminoState { + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: wrapper.ProposalState, added: true}, + }, + }, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: wrapper.ProposalState, added: true}, + }, + }, + } + }, + proposalID: proposalID, + expectedProposal: wrapper.ProposalState, + }, + "OK: proposal modified": { + caminoState: func(c *gomock.Controller) *caminoState { + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: wrapper.ProposalState}, + }, + }, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: wrapper.ProposalState}, + }, + }, + } + }, + proposalID: proposalID, + expectedProposal: wrapper.ProposalState, + }, + "OK: proposal in cache": { + caminoState: func(c *gomock.Controller) *caminoState { + cache := cache.NewMockCacher[ids.ID, dac.ProposalState](c) + cache.EXPECT().Get(proposalID).Return(wrapper.ProposalState, true) + return &caminoState{ + proposalsCache: cache, + caminoDiff: &caminoDiff{}, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalsCache: actualCaminoState.proposalsCache, + caminoDiff: &caminoDiff{}, + } + }, + proposalID: proposalID, + expectedProposal: wrapper.ProposalState, + }, + "OK: proposal in db": { + caminoState: func(c *gomock.Controller) *caminoState { + cache := cache.NewMockCacher[ids.ID, dac.ProposalState](c) + cache.EXPECT().Get(proposalID).Return(nil, false) + cache.EXPECT().Put(proposalID, wrapper.ProposalState) + db := database.NewMockDatabase(c) + db.EXPECT().Get(proposalID[:]).Return(proposalBytes, nil) + return &caminoState{ + proposalsDB: db, + proposalsCache: cache, + caminoDiff: &caminoDiff{}, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalsDB: actualCaminoState.proposalsDB, + proposalsCache: actualCaminoState.proposalsCache, + caminoDiff: &caminoDiff{}, + } + }, + proposalID: proposalID, + expectedProposal: wrapper.ProposalState, + }, + "Fail: db error": { + caminoState: func(c *gomock.Controller) *caminoState { + cache := cache.NewMockCacher[ids.ID, dac.ProposalState](c) + cache.EXPECT().Get(proposalID).Return(nil, false) + db := database.NewMockDatabase(c) + db.EXPECT().Get(proposalID[:]).Return(nil, testError) + return &caminoState{ + proposalsDB: db, + proposalsCache: cache, + caminoDiff: &caminoDiff{}, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalsDB: actualCaminoState.proposalsDB, + proposalsCache: actualCaminoState.proposalsCache, + caminoDiff: &caminoDiff{}, + } + }, + proposalID: proposalID, + expectedErr: testError, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + caminoState := tt.caminoState(ctrl) + actualProposal, err := caminoState.GetProposal(tt.proposalID) + require.ErrorIs(t, err, tt.expectedErr) + require.Equal(t, tt.expectedProposal, actualProposal) + require.Equal(t, tt.expectedCaminoState(caminoState), caminoState) + }) + } +} + +func TestAddProposal(t *testing.T) { + proposalID := ids.ID{1} + proposal := &dac.BaseFeeProposalState{} + + tests := map[string]struct { + caminoState *caminoState + proposalID ids.ID + proposal dac.ProposalState + expectedCaminoState *caminoState + }{ + "OK": { + caminoState: &caminoState{ + caminoDiff: &caminoDiff{modifiedProposals: map[ids.ID]*proposalDiff{}}, + }, + proposalID: proposalID, + proposal: proposal, + expectedCaminoState: &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal, added: true}, + }, + }, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + tt.caminoState.AddProposal(tt.proposalID, tt.proposal) + require.Equal(t, tt.expectedCaminoState, tt.caminoState) + }) + } +} + +func TestModifyProposal(t *testing.T) { + proposalID := ids.ID{1} + proposal1 := &dac.BaseFeeProposalState{} + + tests := map[string]struct { + caminoState func(*gomock.Controller) *caminoState + proposalID ids.ID + proposal dac.ProposalState + expectedCaminoState func(*caminoState) *caminoState + }{ + "OK": { + caminoState: func(c *gomock.Controller) *caminoState { + proposalsCache := cache.NewMockCacher[ids.ID, dac.ProposalState](c) + proposalsCache.EXPECT().Evict(proposalID) + return &caminoState{ + proposalsCache: proposalsCache, + caminoDiff: &caminoDiff{modifiedProposals: map[ids.ID]*proposalDiff{}}, + } + }, + proposalID: proposalID, + proposal: proposal1, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalsCache: actualCaminoState.proposalsCache, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal1}, + }, + }, + } + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + actualCaminoState := tt.caminoState(ctrl) + actualCaminoState.ModifyProposal(tt.proposalID, tt.proposal) + require.Equal(t, tt.expectedCaminoState(actualCaminoState), actualCaminoState) + }) + } +} + +func TestRemoveProposal(t *testing.T) { + proposalID := ids.ID{1} + proposal := &dac.BaseFeeProposalState{} + + tests := map[string]struct { + caminoState func(*gomock.Controller) *caminoState + proposalID ids.ID + proposal dac.ProposalState + expectedCaminoState func(*caminoState) *caminoState + }{ + "OK": { + caminoState: func(c *gomock.Controller) *caminoState { + proposalsCache := cache.NewMockCacher[ids.ID, dac.ProposalState](c) + proposalsCache.EXPECT().Evict(proposalID) + return &caminoState{ + proposalsCache: proposalsCache, + caminoDiff: &caminoDiff{modifiedProposals: map[ids.ID]*proposalDiff{}}, + } + }, + proposalID: proposalID, + proposal: proposal, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalsCache: actualCaminoState.proposalsCache, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID: {Proposal: proposal, removed: true}, + }, + }, + } + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + actualCaminoState := tt.caminoState(ctrl) + actualCaminoState.RemoveProposal(tt.proposalID, tt.proposal) + require.Equal(t, tt.expectedCaminoState(actualCaminoState), actualCaminoState) + }) + } +} + func TestAddProposalIDToFinish(t *testing.T) { proposalID1 := ids.ID{1} proposalID2 := ids.ID{2} @@ -206,3 +521,629 @@ func TestGetProposalIDsToFinish(t *testing.T) { }) } } + +func TestGetNextProposalExpirationTime(t *testing.T) { + proposalID1 := ids.ID{1} + proposalID2 := ids.ID{2} + proposalID31 := ids.ID{3} + proposalID32 := ids.ID{4} + proposal2 := &dac.BaseFeeProposalState{ + End: 102, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 1}}, + }, + } + proposal31 := &dac.BaseFeeProposalState{ + End: 103, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 2}}, + }, + } + proposal32 := &dac.BaseFeeProposalState{ + End: 103, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 3}}, + }, + } + proposal1Endtime := time.Unix(100, 0) + + tests := map[string]struct { + caminoState func(c *gomock.Controller) *caminoState + removedProposalIDs set.Set[ids.ID] + expectedCaminoState func(*caminoState) *caminoState + expectedNextExpirationTime time.Time + expectedErr error + }{ + "Fail: no proposals": { + caminoState: func(c *gomock.Controller) *caminoState { + return &caminoState{} + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{} + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "Fail: no proposals (all removed)": { + caminoState: func(c *gomock.Controller) *caminoState { + it := database.NewMockIterator(c) + it.EXPECT().Next().Return(false) + it.EXPECT().Error().Return(nil) + it.EXPECT().Release() + + db := database.NewMockDatabase(c) + db.EXPECT().NewIterator().Return(it) + + return &caminoState{ + proposalIDsByEndtimeDB: db, + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + removedProposalIDs: set.Set[ids.ID]{proposalID1: struct{}{}}, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalIDsByEndtimeDB: actualCaminoState.proposalIDsByEndtimeDB, + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "OK": { + caminoState: func(c *gomock.Controller) *caminoState { + return &caminoState{ + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + expectedNextExpirationTime: proposal1Endtime, + }, + "Ok: in-mem proposals removed, but db has some": { + caminoState: func(c *gomock.Controller) *caminoState { + it := database.NewMockIterator(c) + it.EXPECT().Next().Return(true).Times(3) + it.EXPECT().Key().Return(proposalToKey(proposalID2[:], proposal2)) + it.EXPECT().Key().Return(proposalToKey(proposalID31[:], proposal31)) + it.EXPECT().Key().Return(proposalToKey(proposalID32[:], proposal32)) + it.EXPECT().Next().Return(false) + it.EXPECT().Error().Return(nil) + it.EXPECT().Release() + + db := database.NewMockDatabase(c) + db.EXPECT().NewIterator().Return(it) + + return &caminoState{ + proposalIDsByEndtimeDB: db, + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + proposalID1: struct{}{}, + proposalID2: struct{}{}, + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalIDsByEndtimeDB: actualCaminoState.proposalIDsByEndtimeDB, + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + expectedNextExpirationTime: proposal31.EndTime(), + }, + "OK: some proposals removed": { + caminoState: func(c *gomock.Controller) *caminoState { + return &caminoState{ + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1, proposalID2}, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + proposalID1: struct{}{}, + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1, proposalID2}, + } + }, + expectedNextExpirationTime: proposal1Endtime, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + caminoState := tt.caminoState(ctrl) + nextExpirationTime, err := caminoState.GetNextProposalExpirationTime(tt.removedProposalIDs) + require.ErrorIs(t, err, tt.expectedErr) + require.Equal(t, tt.expectedNextExpirationTime, nextExpirationTime) + require.Equal(t, tt.expectedCaminoState(caminoState), caminoState) + }) + } +} + +func TestGetNextToExpireProposalIDsAndTime(t *testing.T) { + proposalID1 := ids.ID{1} + proposalID2 := ids.ID{2} + proposalID31 := ids.ID{3} + proposalID32 := ids.ID{4} + proposal2 := &dac.BaseFeeProposalState{ + End: 102, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 1}}, + }, + } + proposal31 := &dac.BaseFeeProposalState{ + End: 103, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 2}}, + }, + } + proposal32 := &dac.BaseFeeProposalState{ + End: 103, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 3}}, + }, + } + proposal1Endtime := time.Unix(100, 0) + + tests := map[string]struct { + caminoState func(c *gomock.Controller) *caminoState + removedProposalIDs set.Set[ids.ID] + expectedCaminoState func(*caminoState) *caminoState + expectedNextExpirationTime time.Time + expectedNextToExpireIDs []ids.ID + expectedErr error + }{ + "Fail: no proposals": { + caminoState: func(c *gomock.Controller) *caminoState { + return &caminoState{} + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{} + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "Fail: no proposals (all removed)": { + caminoState: func(c *gomock.Controller) *caminoState { + it := database.NewMockIterator(c) + it.EXPECT().Next().Return(false) + it.EXPECT().Error().Return(nil) + it.EXPECT().Release() + + db := database.NewMockDatabase(c) + db.EXPECT().NewIterator().Return(it) + + return &caminoState{ + proposalIDsByEndtimeDB: db, + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + removedProposalIDs: set.Set[ids.ID]{proposalID1: struct{}{}}, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalIDsByEndtimeDB: actualCaminoState.proposalIDsByEndtimeDB, + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + expectedNextExpirationTime: mockable.MaxTime, + expectedErr: database.ErrNotFound, + }, + "OK": { + caminoState: func(c *gomock.Controller) *caminoState { + return &caminoState{ + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + expectedNextExpirationTime: proposal1Endtime, + expectedNextToExpireIDs: []ids.ID{proposalID1}, + }, + "Ok: in-mem proposals removed, but db has some": { + caminoState: func(c *gomock.Controller) *caminoState { + it := database.NewMockIterator(c) + it.EXPECT().Next().Return(true).Times(3) + it.EXPECT().Key().Return(proposalToKey(proposalID2[:], proposal2)) + it.EXPECT().Key().Return(proposalToKey(proposalID31[:], proposal31)) + it.EXPECT().Key().Return(proposalToKey(proposalID32[:], proposal32)) + it.EXPECT().Next().Return(false) + it.EXPECT().Error().Return(nil) + it.EXPECT().Release() + + db := database.NewMockDatabase(c) + db.EXPECT().NewIterator().Return(it) + + return &caminoState{ + proposalIDsByEndtimeDB: db, + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + proposalID1: struct{}{}, + proposalID2: struct{}{}, + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalIDsByEndtimeDB: actualCaminoState.proposalIDsByEndtimeDB, + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + expectedNextExpirationTime: proposal31.EndTime(), + expectedNextToExpireIDs: []ids.ID{proposalID31, proposalID32}, + }, + "OK: some proposals removed": { + caminoState: func(c *gomock.Controller) *caminoState { + return &caminoState{ + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1, proposalID2}, + } + }, + removedProposalIDs: set.Set[ids.ID]{ + proposalID1: struct{}{}, + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalsNextExpirationTime: &proposal1Endtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1, proposalID2}, + } + }, + expectedNextExpirationTime: proposal1Endtime, + expectedNextToExpireIDs: []ids.ID{proposalID2}, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + caminoState := tt.caminoState(ctrl) + nextToExpireIDs, nextExpirationTime, err := caminoState.GetNextToExpireProposalIDsAndTime(tt.removedProposalIDs) + require.ErrorIs(t, err, tt.expectedErr) + require.Equal(t, tt.expectedNextExpirationTime, nextExpirationTime) + require.Equal(t, tt.expectedNextToExpireIDs, nextToExpireIDs) + require.Equal(t, tt.expectedCaminoState(caminoState), caminoState) + }) + } +} + +func TestWriteProposals(t *testing.T) { + testError := errors.New("test error") + proposalID1 := ids.ID{1} + proposalID2 := ids.ID{2} + proposalID3 := ids.ID{3} + proposalID4 := ids.ID{4} + proposalID5 := ids.ID{5} + proposalID6 := ids.ID{6} + + proposalWrapper1 := &proposalStateWrapper{ProposalState: &dac.BaseFeeProposalState{ + End: 10, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 1}}, + }, + }} + proposalWrapper2 := &proposalStateWrapper{ProposalState: &dac.BaseFeeProposalState{ + End: 10, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 2}}, + }, + }} + proposalWrapper3 := &proposalStateWrapper{ProposalState: &dac.BaseFeeProposalState{ + End: 11, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{ + Options: []dac.SimpleVoteOption[uint64]{{Value: 3}}, + }, + }} + + proposalEndtime := proposalWrapper2.EndTime() + proposal1Bytes, err := blocks.GenesisCodec.Marshal(blocks.Version, proposalWrapper1) + require.NoError(t, err) + proposal2Bytes, err := blocks.GenesisCodec.Marshal(blocks.Version, proposalWrapper2) + require.NoError(t, err) + + tests := map[string]struct { + caminoState func(*gomock.Controller) *caminoState + expectedCaminoState func(*caminoState) *caminoState + expectedErr error + }{ + "Fail: db errored on modified proposal Put": { + caminoState: func(c *gomock.Controller) *caminoState { + proposalsDB := database.NewMockDatabase(c) + proposalsDB.EXPECT().Put(proposalID1[:], proposal1Bytes).Return(testError) + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID1: {Proposal: proposalWrapper1.ProposalState}, + }, + }, + proposalsDB: proposalsDB, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{}, + }, + proposalsDB: actualCaminoState.proposalsDB, + } + }, + expectedErr: testError, + }, + "Fail: db errored on added proposal Put": { + caminoState: func(c *gomock.Controller) *caminoState { + proposalsDB := database.NewMockDatabase(c) + proposalsDB.EXPECT().Put(proposalID1[:], proposal1Bytes).Return(testError) + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID1: {Proposal: proposalWrapper1.ProposalState, added: true}, + }, + }, + proposalsDB: proposalsDB, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{}, + }, + proposalsDB: actualCaminoState.proposalsDB, + } + }, + expectedErr: testError, + }, + "Fail: db errored on removed proposal Delete": { + caminoState: func(c *gomock.Controller) *caminoState { + proposalsDB := database.NewMockDatabase(c) + proposalsDB.EXPECT().Delete(proposalID1[:]).Return(testError) + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID1: {Proposal: proposalWrapper1.ProposalState, removed: true}, + }, + }, + proposalsDB: proposalsDB, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{}, + }, + proposalsDB: actualCaminoState.proposalsDB, + } + }, + expectedErr: testError, + }, + "OK: add or remove proposals-to-finish": { + caminoState: func(c *gomock.Controller) *caminoState { + proposalIDsToFinishDB := database.NewMockDatabase(c) + proposalIDsToFinishDB.EXPECT().Delete(proposalID2[:]).Return(nil) + proposalIDsToFinishDB.EXPECT().Put(proposalID4[:], nil).Return(nil) + proposalIDsToFinishDB.EXPECT().Delete(proposalID5[:]).Return(nil) + proposalIDsToFinishDB.EXPECT().Put(proposalID6[:], nil).Return(nil) + + proposalsIterator := database.NewMockIterator(c) + proposalsIterator.EXPECT().Next().Return(false) + proposalsIterator.EXPECT().Error().Return(nil) + proposalsIterator.EXPECT().Release() + + proposalIDsByEndtimeDB := database.NewMockDatabase(c) + proposalIDsByEndtimeDB.EXPECT().NewIterator().Return(proposalsIterator) + + return &caminoState{ + proposalIDsToFinishDB: proposalIDsToFinishDB, + proposalIDsByEndtimeDB: proposalIDsByEndtimeDB, + caminoDiff: &caminoDiff{ + modifiedProposalIDsToFinish: map[ids.ID]bool{ + proposalID2: false, + proposalID4: true, + proposalID5: false, + proposalID6: true, + }, + }, + proposalIDsToFinish: []ids.ID{proposalID1, proposalID2, proposalID3}, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalIDsToFinishDB: actualCaminoState.proposalIDsToFinishDB, + proposalIDsByEndtimeDB: actualCaminoState.proposalIDsByEndtimeDB, + caminoDiff: &caminoDiff{modifiedProposalIDsToFinish: map[ids.ID]bool{}}, + proposalIDsToFinish: []ids.ID{proposalID1, proposalID3, proposalID4, proposalID6}, + } + }, + }, + "OK: add, modify and delete; nextExpiration partial removal, added new": { + caminoState: func(c *gomock.Controller) *caminoState { + proposalsDB := database.NewMockDatabase(c) + proposalsDB.EXPECT().Put(proposalID1[:], proposal1Bytes).Return(nil) + proposalsDB.EXPECT().Put(proposalID2[:], proposal2Bytes).Return(nil) + proposalsDB.EXPECT().Delete(proposalID3[:]).Return(nil) + + proposalIDsByEndtimeDB := database.NewMockDatabase(c) + proposalIDsByEndtimeDB.EXPECT().Put(proposalToKey(proposalID1[:], proposalWrapper1), nil).Return(nil) + proposalIDsByEndtimeDB.EXPECT().Delete(proposalToKey(proposalID3[:], proposalWrapper3)).Return(nil) + + return &caminoState{ + proposalIDsByEndtimeDB: proposalIDsByEndtimeDB, + proposalsDB: proposalsDB, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID1: {Proposal: proposalWrapper1.ProposalState, added: true}, + proposalID2: {Proposal: proposalWrapper2.ProposalState}, + proposalID3: {Proposal: proposalWrapper3.ProposalState, removed: true}, + }, + }, + proposalsNextExpirationTime: &proposalEndtime, + proposalsNextToExpireIDs: []ids.ID{proposalID2, proposalID3}, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalIDsByEndtimeDB: actualCaminoState.proposalIDsByEndtimeDB, + proposalsDB: actualCaminoState.proposalsDB, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{}, + }, + proposalsNextExpirationTime: &proposalEndtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1, proposalID2}, + } + }, + }, + "OK: nextExpiration full removal, can't add new, peek into db": { + caminoState: func(c *gomock.Controller) *caminoState { + proposalsDB := database.NewMockDatabase(c) + proposalsDB.EXPECT().Put(proposalID1[:], proposal1Bytes).Return(nil) + proposalsDB.EXPECT().Delete(proposalID2[:]).Return(nil) + + proposalsIterator := database.NewMockIterator(c) + proposalsIterator.EXPECT().Next().Return(true) + proposalsIterator.EXPECT().Key().Return(proposalToKey(proposalID1[:], proposalWrapper1)) + proposalsIterator.EXPECT().Next().Return(false) + proposalsIterator.EXPECT().Error().Return(nil) + proposalsIterator.EXPECT().Release() + + proposalIDsByEndtimeDB := database.NewMockDatabase(c) + proposalIDsByEndtimeDB.EXPECT().Put(proposalToKey(proposalID1[:], proposalWrapper1), nil).Return(nil) + proposalIDsByEndtimeDB.EXPECT().Delete(proposalToKey(proposalID2[:], proposalWrapper2)).Return(nil) + proposalIDsByEndtimeDB.EXPECT().NewIterator().Return(proposalsIterator) + + return &caminoState{ + proposalIDsByEndtimeDB: proposalIDsByEndtimeDB, + proposalsDB: proposalsDB, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{ + proposalID1: {Proposal: proposalWrapper1.ProposalState, added: true}, + proposalID2: {Proposal: proposalWrapper2.ProposalState, removed: true}, + }, + }, + proposalsNextExpirationTime: &proposalEndtime, + proposalsNextToExpireIDs: []ids.ID{proposalID2}, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalIDsByEndtimeDB: actualCaminoState.proposalIDsByEndtimeDB, + proposalsDB: actualCaminoState.proposalsDB, + caminoDiff: &caminoDiff{ + modifiedProposals: map[ids.ID]*proposalDiff{}, + }, + proposalsNextExpirationTime: &proposalEndtime, + proposalsNextToExpireIDs: []ids.ID{proposalID1}, + } + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + actualCaminoState := tt.caminoState(ctrl) + require.ErrorIs(t, actualCaminoState.writeProposals(), tt.expectedErr) + require.Equal(t, tt.expectedCaminoState(actualCaminoState), actualCaminoState) + }) + } +} + +func TestLoadProposals(t *testing.T) { + proposalID1 := ids.ID{1} + proposalID2 := ids.ID{2} + proposalID3 := ids.ID{3} + proposalID4 := ids.ID{4} + + proposal1 := &dac.BaseFeeProposalState{End: 10} + proposal2 := &dac.BaseFeeProposalState{End: 10} + proposal3 := &dac.BaseFeeProposalState{End: 11} + + tests := map[string]struct { + caminoState func(*gomock.Controller) *caminoState + expectedCaminoState func(*caminoState) *caminoState + expectedErr error + }{ + "OK": { + caminoState: func(c *gomock.Controller) *caminoState { + expiredProposalsIterator := database.NewMockIterator(c) + expiredProposalsIterator.EXPECT().Next().Return(true).Times(3) + expiredProposalsIterator.EXPECT().Key().Return(proposalToKey(proposalID1[:], proposal1)) + expiredProposalsIterator.EXPECT().Key().Return(proposalToKey(proposalID2[:], proposal2)) + expiredProposalsIterator.EXPECT().Key().Return(proposalToKey(proposalID3[:], proposal3)) + expiredProposalsIterator.EXPECT().Error().Return(nil) + expiredProposalsIterator.EXPECT().Release() + + proposalIDsByEndtimeDB := database.NewMockDatabase(c) + proposalIDsByEndtimeDB.EXPECT().NewIterator().Return(expiredProposalsIterator) + + proposalsToFinishIterator := database.NewMockIterator(c) + proposalsToFinishIterator.EXPECT().Next().Return(true).Times(2) + proposalsToFinishIterator.EXPECT().Key().Return(proposalID2[:]) + proposalsToFinishIterator.EXPECT().Key().Return(proposalID4[:]) + proposalsToFinishIterator.EXPECT().Next().Return(false) + proposalsToFinishIterator.EXPECT().Error().Return(nil) + proposalsToFinishIterator.EXPECT().Release() + + proposalIDsToFinishDB := database.NewMockDatabase(c) + proposalIDsToFinishDB.EXPECT().NewIterator().Return(proposalsToFinishIterator) + + return &caminoState{ + proposalIDsByEndtimeDB: proposalIDsByEndtimeDB, + proposalIDsToFinishDB: proposalIDsToFinishDB, + } + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + nextTime := proposal1.EndTime() + return &caminoState{ + proposalIDsByEndtimeDB: actualCaminoState.proposalIDsByEndtimeDB, + proposalIDsToFinishDB: actualCaminoState.proposalIDsToFinishDB, + proposalsNextExpirationTime: &nextTime, + proposalsNextToExpireIDs: []ids.ID{proposalID1, proposalID2}, + proposalIDsToFinish: []ids.ID{proposalID2, proposalID4}, + } + }, + }, + "OK: no proposals": { + caminoState: func(c *gomock.Controller) *caminoState { + proposalsIterator := database.NewMockIterator(c) + proposalsIterator.EXPECT().Next().Return(false) + proposalsIterator.EXPECT().Error().Return(nil) + proposalsIterator.EXPECT().Release() + proposalIDsByEndtimeDB := database.NewMockDatabase(c) + proposalIDsByEndtimeDB.EXPECT().NewIterator().Return(proposalsIterator) + return &caminoState{proposalIDsByEndtimeDB: proposalIDsByEndtimeDB} + }, + expectedCaminoState: func(actualCaminoState *caminoState) *caminoState { + return &caminoState{ + proposalIDsByEndtimeDB: actualCaminoState.proposalIDsByEndtimeDB, + } + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + actualCaminoState := tt.caminoState(ctrl) + require.ErrorIs(t, actualCaminoState.loadProposals(), tt.expectedErr) + require.Equal(t, tt.expectedCaminoState(actualCaminoState), actualCaminoState) + }) + } +} diff --git a/vms/platformvm/state/camino_state.go b/vms/platformvm/state/camino_state.go index 392d2dc345ad..41eac7751945 100644 --- a/vms/platformvm/state/camino_state.go +++ b/vms/platformvm/state/camino_state.go @@ -181,3 +181,11 @@ func (s *state) GetNextProposalExpirationTime(removedProposalIDs set.Set[ids.ID] func (s *state) GetProposalIterator() (ProposalsIterator, error) { return s.caminoState.GetProposalIterator() } + +func (s *state) GetBaseFee() (uint64, error) { + return s.caminoState.GetBaseFee() +} + +func (s *state) SetBaseFee(baseFee uint64) { + s.caminoState.SetBaseFee(baseFee) +} diff --git a/vms/platformvm/txs/camino_add_proposal_tx_test.go b/vms/platformvm/txs/camino_add_proposal_tx_test.go new file mode 100644 index 000000000000..7b76cb99655e --- /dev/null +++ b/vms/platformvm/txs/camino_add_proposal_tx_test.go @@ -0,0 +1,123 @@ +// Copyright (C) 2023, Chain4Travel AG. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import ( + "testing" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/platformvm/dac" + "github.com/ava-labs/avalanchego/vms/platformvm/locked" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + "github.com/stretchr/testify/require" +) + +func TestAddProposalTxSyntacticVerify(t *testing.T) { + ctx := defaultContext() + owner1 := secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{{0, 0, 1}}} + + badProposal := &ProposalWrapper{Proposal: &dac.BaseFeeProposal{Options: []uint64{}}} + badProposalBytes, err := Codec.Marshal(Version, badProposal) + require.NoError(t, err) + + proposal := &ProposalWrapper{Proposal: &dac.BaseFeeProposal{ + End: 1, + Options: []uint64{1}, + }} + proposalBytes, err := Codec.Marshal(Version, proposal) + require.NoError(t, err) + + baseTx := BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + }} + + tests := map[string]struct { + tx *AddProposalTx + expectedErr error + }{ + "Nil tx": { + expectedErr: ErrNilTx, + }, + "Fail to unmarshal proposal": { + tx: &AddProposalTx{ + BaseTx: baseTx, + ProposalPayload: []byte{}, + }, + expectedErr: errBadProposal, + }, + "Bad proposal": { + tx: &AddProposalTx{ + BaseTx: baseTx, + ProposalPayload: badProposalBytes, + }, + expectedErr: errBadProposal, + }, + "Bad proposer auth": { + tx: &AddProposalTx{ + BaseTx: baseTx, + ProposalPayload: proposalBytes, + ProposerAuth: (*secp256k1fx.Input)(nil), + }, + expectedErr: errBadProposerAuth, + }, + "Stakable base tx input": { + tx: &AddProposalTx{ + BaseTx: BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: []*avax.TransferableInput{ + generateTestStakeableIn(ctx.AVAXAssetID, 1, 1, []uint32{0}), + }, + }}, + ProposalPayload: proposalBytes, + ProposerAuth: &secp256k1fx.Input{}, + }, + expectedErr: locked.ErrWrongInType, + }, + "Stakable base tx output": { + tx: &AddProposalTx{ + BaseTx: BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Outs: []*avax.TransferableOutput{ + generateTestStakeableOut(ctx.AVAXAssetID, 1, 1, owner1), + }, + }}, + ProposalPayload: proposalBytes, + ProposerAuth: &secp256k1fx.Input{}, + }, + expectedErr: locked.ErrWrongOutType, + }, + "OK": { + tx: &AddProposalTx{ + BaseTx: baseTx, + ProposalPayload: proposalBytes, + ProposerAuth: &secp256k1fx.Input{}, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + require.ErrorIs(t, tt.tx.SyntacticVerify(ctx), tt.expectedErr) + }) + } +} + +func TestAddProposalTxProposal(t *testing.T) { + expectedProposal := &ProposalWrapper{Proposal: &dac.BaseFeeProposal{ + Start: 11, End: 12, + Options: []uint64{555, 123, 7}, + }} + proposalBytes, err := Codec.Marshal(Version, expectedProposal) + require.NoError(t, err) + + tx := &AddProposalTx{ + ProposalPayload: proposalBytes, + } + txProposal, err := tx.Proposal() + require.NoError(t, err) + require.Equal(t, expectedProposal.Proposal, txProposal) +} diff --git a/vms/platformvm/txs/codec.go b/vms/platformvm/txs/codec.go index d6a3e4ddfe1f..498b2839559e 100644 --- a/vms/platformvm/txs/codec.go +++ b/vms/platformvm/txs/codec.go @@ -126,6 +126,8 @@ func RegisterUnsignedTxsTypes(targetCodec codec.CaminoRegistry) error { targetCodec.RegisterCustomType(&AddProposalTx{}), targetCodec.RegisterCustomType(&AddVoteTx{}), targetCodec.RegisterCustomType(&FinishProposalsTx{}), + targetCodec.RegisterCustomType(&dac.BaseFeeProposal{}), + targetCodec.RegisterCustomType(&dac.BaseFeeProposalState{}), targetCodec.RegisterCustomType(&dac.DummyVote{}), targetCodec.RegisterCustomType(&dac.SimpleVote{}), ) diff --git a/vms/platformvm/txs/executor/camino_dac.go b/vms/platformvm/txs/executor/camino_dac.go index da701eb42c2b..5207fcc69c8e 100644 --- a/vms/platformvm/txs/executor/camino_dac.go +++ b/vms/platformvm/txs/executor/camino_dac.go @@ -4,6 +4,8 @@ package executor import ( + "errors" + "github.com/ava-labs/avalanchego/vms/platformvm/dac" "github.com/ava-labs/avalanchego/vms/platformvm/fx" "github.com/ava-labs/avalanchego/vms/platformvm/state" @@ -13,6 +15,9 @@ import ( var ( _ dac.VerifierVisitor = (*proposalVerifier)(nil) _ dac.ExecutorVisitor = (*proposalExecutor)(nil) + + errNotPermittedToCreateProposal = errors.New("don't have permission to create proposal of this type") + errAlreadyActiveProposal = errors.New("there is already active proposal of this type") ) type proposalVerifier struct { @@ -44,3 +49,46 @@ func (e *CaminoStandardTxExecutor) proposalVerifier(tx *txs.AddProposalTx) *prop func (e *CaminoStandardTxExecutor) proposalExecutor() *proposalExecutor { return &proposalExecutor{state: e.State, fx: e.Fx} } + +// BaseFeeProposal + +func (e *proposalVerifier) BaseFeeProposal(*dac.BaseFeeProposal) error { + // verify address state (role) + proposerAddressState, err := e.state.GetAddressStates(e.addProposalTx.ProposerAddress) + if err != nil { + return err + } + + if proposerAddressState.IsNot(txs.AddressStateCaminoProposer) { + return errNotPermittedToCreateProposal + } + + // verify that there is no existing base fee proposal + proposalsIterator, err := e.state.GetProposalIterator() + if err != nil { + return err + } + defer proposalsIterator.Release() + for proposalsIterator.Next() { + proposal, err := proposalsIterator.Value() + if err != nil { + return err + } + if _, ok := proposal.(*dac.BaseFeeProposalState); ok { + return errAlreadyActiveProposal + } + } + + if err := proposalsIterator.Error(); err != nil { + return err + } + + 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 +} diff --git a/vms/platformvm/txs/executor/camino_dac_test.go b/vms/platformvm/txs/executor/camino_dac_test.go new file mode 100644 index 000000000000..609e7f3897b9 --- /dev/null +++ b/vms/platformvm/txs/executor/camino_dac_test.go @@ -0,0 +1,197 @@ +// Copyright (C) 2023, Chain4Travel AG. All rights reserved. +// See the file LICENSE for licensing terms. + +package executor + +import ( + "testing" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/crypto/secp256k1" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/platformvm/api" + "github.com/ava-labs/avalanchego/vms/platformvm/dac" + "github.com/ava-labs/avalanchego/vms/platformvm/locked" + "github.com/ava-labs/avalanchego/vms/platformvm/state" + "github.com/ava-labs/avalanchego/vms/platformvm/txs" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +func TestProposalVerifierBaseFeeProposal(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) + + 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.BaseFeeProposal{End: 1, Options: []uint64{1}}} + 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 + }{ + "Proposer isn't caminoProposer": { + state: func(c *gomock.Controller, utx *txs.AddProposalTx) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().GetAddressStates(utx.ProposerAddress).Return(txs.AddressStateEmpty, nil) // not AddressStateCaminoProposer + 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: errNotPermittedToCreateProposal, + }, + "Already active BaseFeeProposal": { + 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.BaseFeeProposalState{}, nil) + proposalsIterator.EXPECT().Release() + + s.EXPECT().GetAddressStates(utx.ProposerAddress).Return(txs.AddressStateCaminoProposer, 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(utx.ProposerAddress).Return(txs.AddressStateCaminoProposer, 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 TestProposalExecutorBaseFeeProposal(t *testing.T) { + caminoGenesisConf := api.Camino{ + VerifyNodeSignature: true, + LockModeBondDeposit: true, + } + + tests := map[string]struct { + state func(*gomock.Controller) *state.MockDiff + proposal dac.ProposalState + expectedErr error + }{ + "OK": { + state: func(c *gomock.Controller) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().SetBaseFee(uint64(123)) + return s + }, + proposal: &dac.BaseFeeProposalState{ + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{Options: []dac.SimpleVoteOption[uint64]{ + {Value: 555, Weight: 0}, + {Value: 123, Weight: 2}, + {Value: 7, 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) + }) + } +} diff --git a/vms/platformvm/txs/executor/camino_helpers_test.go b/vms/platformvm/txs/executor/camino_helpers_test.go index 5f37c438e43d..056e65d4c693 100644 --- a/vms/platformvm/txs/executor/camino_helpers_test.go +++ b/vms/platformvm/txs/executor/camino_helpers_test.go @@ -31,6 +31,7 @@ import ( "github.com/ava-labs/avalanchego/utils/formatting/address" "github.com/ava-labs/avalanchego/utils/json" "github.com/ava-labs/avalanchego/utils/nodeid" + "github.com/ava-labs/avalanchego/utils/set" "github.com/ava-labs/avalanchego/utils/timer/mockable" "github.com/ava-labs/avalanchego/utils/units" "github.com/ava-labs/avalanchego/utils/wrappers" @@ -348,7 +349,7 @@ func generateTestUTXO(txID ids.ID, assetID ids.ID, amount uint64, outputOwners s return generateTestUTXOWithIndex(txID, 0, assetID, amount, outputOwners, depositTxID, bondTxID, true) } -func generateTestUTXOWithIndex(txID ids.ID, outIndex uint32, assetID ids.ID, amount uint64, outputOwners secp256k1fx.OutputOwners, depositTxID, bondTxID ids.ID, init bool) *avax.UTXO { +func generateTestUTXOWithIndex(txID ids.ID, outIndex uint32, assetID ids.ID, amount uint64, outputOwners secp256k1fx.OutputOwners, depositTxID, bondTxID ids.ID, init bool) *avax.UTXO { //nolint:unparam var out avax.TransferableOut = &secp256k1fx.TransferOutput{ Amt: amount, OutputOwners: outputOwners, @@ -376,6 +377,34 @@ func generateTestUTXOWithIndex(txID ids.ID, outIndex uint32, assetID ids.ID, amo return testUTXO } +func generateTestOutFromUTXO(utxo *avax.UTXO, depositTxID, bondTxID ids.ID) *avax.TransferableOutput { + out := utxo.Out + if lockedOut, ok := out.(*locked.Out); ok { + out = lockedOut.TransferableOut + } + secpOut, ok := out.(*secp256k1fx.TransferOutput) + if !ok { + panic("not secp out") + } + var innerOut avax.TransferableOut = &secp256k1fx.TransferOutput{ + Amt: secpOut.Amt, + OutputOwners: secpOut.OutputOwners, + } + if depositTxID != ids.Empty || bondTxID != ids.Empty { + innerOut = &locked.Out{ + IDs: locked.IDs{ + DepositTxID: depositTxID, + BondTxID: bondTxID, + }, + TransferableOut: innerOut, + } + } + return &avax.TransferableOutput{ + Asset: avax.Asset{ID: utxo.AssetID()}, + Out: innerOut, + } +} + func generateTestOut(assetID ids.ID, amount uint64, outputOwners secp256k1fx.OutputOwners, depositTxID, bondTxID ids.ID) *avax.TransferableOutput { var out avax.TransferableOut = &secp256k1fx.TransferOutput{ Amt: amount, @@ -707,6 +736,36 @@ func expectVerifyUnlockDeposit( expectGetMultisigAliases(t, s, addrs, aliases) } +func expectUnlock( + t *testing.T, + s *state.MockDiff, + lockTxIDs []ids.ID, + addrs []ids.ShortID, + utxos []*avax.UTXO, + removedLockState locked.State, //nolint:unparam +) { + t.Helper() + lockTxIDsSet := set.NewSet[ids.ID](len(lockTxIDs)) + addrsSet := set.NewSet[ids.ShortID](len(addrs)) + lockTxIDsSet.Add(lockTxIDs...) + addrsSet.Add(addrs...) + for _, txID := range lockTxIDs { + s.EXPECT().GetTx(txID).Return(&txs.Tx{ + Unsigned: &txs.BaseTx{BaseTx: avax.BaseTx{ + Outs: []*avax.TransferableOutput{{ + Out: &locked.Out{ + IDs: locked.IDsEmpty.Lock(removedLockState), + TransferableOut: &secp256k1fx.TransferOutput{ + OutputOwners: secp256k1fx.OutputOwners{Addrs: addrs}, + }, + }, + }}, + }}, + }, status.Committed, nil) + } + s.EXPECT().LockedUTXOs(lockTxIDsSet, addrsSet, removedLockState).Return(utxos, nil) +} + func expectGetUTXOsFromInputs(t *testing.T, s *state.MockDiff, ins []*avax.TransferableInput, utxos []*avax.UTXO) { t.Helper() for i := range ins { diff --git a/vms/platformvm/txs/executor/camino_tx_executor.go b/vms/platformvm/txs/executor/camino_tx_executor.go index cacaeda8d111..a4b091718daa 100644 --- a/vms/platformvm/txs/executor/camino_tx_executor.go +++ b/vms/platformvm/txs/executor/camino_tx_executor.go @@ -738,6 +738,11 @@ func (e *CaminoStandardTxExecutor) DepositTx(tx *txs.DepositTx) error { return err } + baseFee, err := e.State.GetBaseFee() + if err != nil { + return err + } + if err := e.FlowChecker.VerifyLock( tx, e.State, @@ -745,7 +750,7 @@ func (e *CaminoStandardTxExecutor) DepositTx(tx *txs.DepositTx) error { tx.Outs, baseTxCreds, 0, - e.Config.TxFee, + baseFee, e.Ctx.AVAXAssetID, locked.StateDeposited, ); err != nil { @@ -864,7 +869,11 @@ func (e *CaminoStandardTxExecutor) UnlockDepositTx(tx *txs.UnlockDepositTx) erro amountToBurn := uint64(0) if !hasExpiredDeposits { - amountToBurn = e.Config.TxFee + baseFee, err := e.State.GetBaseFee() + if err != nil { + return err + } + amountToBurn = baseFee } if err := e.FlowChecker.VerifyUnlockDeposit( @@ -1109,6 +1118,11 @@ func (e *CaminoStandardTxExecutor) ClaimTx(tx *txs.ClaimTx) error { } // BaseTx check (fee, reward outs) + baseFee, err := e.State.GetBaseFee() + if err != nil { + return err + } + if err := e.FlowChecker.VerifyLock( tx, e.State, @@ -1116,7 +1130,7 @@ func (e *CaminoStandardTxExecutor) ClaimTx(tx *txs.ClaimTx) error { tx.Outs, e.Tx.Creds[:len(e.Tx.Creds)-len(tx.Claimables)], claimedAmount, - e.Config.TxFee, + baseFee, e.Ctx.AVAXAssetID, locked.StateUnlocked, ); err != nil { @@ -1211,6 +1225,11 @@ func (e *CaminoStandardTxExecutor) RegisterNodeTx(tx *txs.RegisterNodeTx) error } // verify the flowcheck + baseFee, err := e.State.GetBaseFee() + if err != nil { + return err + } + if err := e.FlowChecker.VerifyLock( tx, e.State, @@ -1218,7 +1237,7 @@ func (e *CaminoStandardTxExecutor) RegisterNodeTx(tx *txs.RegisterNodeTx) error tx.Outs, e.Tx.Creds[:len(e.Tx.Creds)-2], // base tx creds 0, - e.Config.TxFee, + baseFee, e.Ctx.AVAXAssetID, locked.StateUnlocked, ); err != nil { @@ -1456,6 +1475,11 @@ func (e *CaminoStandardTxExecutor) BaseTx(tx *txs.BaseTx) error { } if e.Bootstrapped.Get() { + baseFee, err := e.State.GetBaseFee() + if err != nil { + return err + } + if err := e.FlowChecker.VerifyLock( tx, e.State, @@ -1463,7 +1487,7 @@ func (e *CaminoStandardTxExecutor) BaseTx(tx *txs.BaseTx) error { tx.Outs, e.Tx.Creds, 0, - e.Config.TxFee, + baseFee, e.Ctx.AVAXAssetID, locked.StateUnlocked, ); err != nil { @@ -1530,6 +1554,11 @@ func (e *CaminoStandardTxExecutor) MultisigAliasTx(tx *txs.MultisigAliasTx) erro } // verify the flowcheck + baseFee, err := e.State.GetBaseFee() + if err != nil { + return err + } + if err := e.FlowChecker.VerifyLock( tx, e.State, @@ -1537,7 +1566,7 @@ func (e *CaminoStandardTxExecutor) MultisigAliasTx(tx *txs.MultisigAliasTx) erro tx.Outs, baseCreds, 0, - e.Config.TxFee, + baseFee, e.Ctx.AVAXAssetID, locked.StateUnlocked, ); err != nil { @@ -1579,6 +1608,11 @@ func (e *CaminoStandardTxExecutor) AddDepositOfferTx(tx *txs.AddDepositOfferTx) } // verify the flowcheck + baseFee, err := e.State.GetBaseFee() + if err != nil { + return err + } + if err := e.FlowChecker.VerifyLock( tx, e.State, @@ -1586,7 +1620,7 @@ func (e *CaminoStandardTxExecutor) AddDepositOfferTx(tx *txs.AddDepositOfferTx) tx.Outs, e.Tx.Creds[:len(e.Tx.Creds)-1], // base tx credentials 0, - e.Config.TxFee, + baseFee, e.Ctx.AVAXAssetID, locked.StateUnlocked, ); err != nil { @@ -1716,6 +1750,12 @@ func (e *CaminoStandardTxExecutor) AddProposalTx(tx *txs.AddProposalTx) error { // verify the flowcheck + lockState := locked.StateBonded + baseFee, err := e.State.GetBaseFee() + if err != nil { + return err + } + if err := e.FlowChecker.VerifyLock( tx, e.State, @@ -1723,9 +1763,9 @@ func (e *CaminoStandardTxExecutor) AddProposalTx(tx *txs.AddProposalTx) error { tx.Outs, e.Tx.Creds[:len(e.Tx.Creds)-1], // base tx creds 0, - e.Config.TxFee, + baseFee, e.Ctx.AVAXAssetID, - locked.StateBonded, + lockState, ); err != nil { return fmt.Errorf("%w: %s", errFlowCheckFailed, err) } @@ -1829,6 +1869,12 @@ func (e *CaminoStandardTxExecutor) AddVoteTx(tx *txs.AddVoteTx) error { } // verify the flowcheck + + baseFee, err := e.State.GetBaseFee() + if err != nil { + return err + } + if err := e.FlowChecker.VerifyLock( tx, e.State, @@ -1836,7 +1882,7 @@ func (e *CaminoStandardTxExecutor) AddVoteTx(tx *txs.AddVoteTx) error { tx.Outs, e.Tx.Creds[:len(e.Tx.Creds)-1], // base tx creds 0, - e.Config.TxFee, + baseFee, e.Ctx.AVAXAssetID, locked.StateUnlocked, ); err != nil { @@ -2149,6 +2195,11 @@ func (e *CaminoStandardTxExecutor) AddressStateTx(tx *txs.AddressStateTx) error } // Verify the flowcheck + baseFee, err := e.State.GetBaseFee() + if err != nil { + return err + } + if err := e.FlowChecker.VerifySpend( tx, e.State, @@ -2156,7 +2207,7 @@ func (e *CaminoStandardTxExecutor) AddressStateTx(tx *txs.AddressStateTx) error tx.Outs, creds, map[ids.ID]uint64{ - e.Ctx.AVAXAssetID: e.Config.TxFee, + e.Ctx.AVAXAssetID: baseFee, }, ); err != nil { return err diff --git a/vms/platformvm/txs/executor/camino_tx_executor_test.go b/vms/platformvm/txs/executor/camino_tx_executor_test.go index 3f51808d15ba..1917454e76a1 100644 --- a/vms/platformvm/txs/executor/camino_tx_executor_test.go +++ b/vms/platformvm/txs/executor/camino_tx_executor_test.go @@ -16,6 +16,7 @@ import ( "github.com/ava-labs/avalanchego/codec" "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils" "github.com/ava-labs/avalanchego/utils/constants" "github.com/ava-labs/avalanchego/utils/crypto/secp256k1" "github.com/ava-labs/avalanchego/utils/hashing" @@ -26,6 +27,7 @@ import ( "github.com/ava-labs/avalanchego/vms/components/verify" "github.com/ava-labs/avalanchego/vms/platformvm/api" "github.com/ava-labs/avalanchego/vms/platformvm/config" + "github.com/ava-labs/avalanchego/vms/platformvm/dac" "github.com/ava-labs/avalanchego/vms/platformvm/deposit" "github.com/ava-labs/avalanchego/vms/platformvm/locked" "github.com/ava-labs/avalanchego/vms/platformvm/reward" @@ -5815,3 +5817,1495 @@ func TestCaminoStandardTxExecutorAddDepositOfferTx(t *testing.T) { }) } } + +func TestCaminoStandardTxExecutorAddProposalTx(t *testing.T) { + ctx, _ := defaultCtx(nil) + caminoGenesisConf := api.Camino{ + VerifyNodeSignature: true, + LockModeBondDeposit: true, + } + caminoStateConf := &state.CaminoConfig{ + VerifyNodeSignature: caminoGenesisConf.VerifyNodeSignature, + LockModeBondDeposit: caminoGenesisConf.LockModeBondDeposit, + } + + feeOwnerKey, feeOwnerAddr, feeOwner := generateKeyAndOwner(t) + bondOwnerKey, bondOwnerAddr, bondOwner := generateKeyAndOwner(t) + proposerKey, proposerAddr, _ := generateKeyAndOwner(t) + + 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) + + proposalWrapper := &txs.ProposalWrapper{Proposal: &dac.BaseFeeProposal{ + Start: 100, End: 101, Options: []uint64{1}, + }} + proposalBytes, err := txs.Codec.Marshal(txs.Version, proposalWrapper) + require.NoError(t, err) + + baseTxWithBondAmt := func(bondAmt uint64) *txs.BaseTx { + return &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, bondAmt, bondOwner, ids.Empty, locked.ThisTxID), + }, + }} + } + + tests := map[string]struct { + state func(*testing.T, *gomock.Controller, *txs.AddProposalTx, ids.ID, *config.Config) *state.MockDiff + utx func(*config.Config) *txs.AddProposalTx + signers [][]*secp256k1.PrivateKey + expectedErr error + }{ + "Wrong lockModeBondDeposit flag": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddProposalTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(&state.CaminoConfig{LockModeBondDeposit: false}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.AddProposalTx { + return &txs.AddProposalTx{ + BaseTx: *baseTxWithBondAmt(cfg.CaminoConfig.DACProposalBondAmount), + ProposalPayload: proposalBytes, + ProposerAddress: proposerAddr, + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {bondOwnerKey}, {proposerKey}, + }, + expectedErr: errWrongLockMode, + }, + "Not BerlinPhase": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddProposalTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime.Add(-1 * time.Second)) + return s + }, + utx: func(cfg *config.Config) *txs.AddProposalTx { + return &txs.AddProposalTx{ + BaseTx: *baseTxWithBondAmt(cfg.CaminoConfig.DACProposalBondAmount), + ProposalPayload: proposalBytes, + ProposerAddress: proposerAddr, + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {bondOwnerKey}, {proposerKey}, + }, + expectedErr: errNotBerlinPhase, + }, + "Too small bond": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddProposalTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + return s + }, + utx: func(cfg *config.Config) *txs.AddProposalTx { + return &txs.AddProposalTx{ + BaseTx: *baseTxWithBondAmt(cfg.CaminoConfig.DACProposalBondAmount - 1), + ProposalPayload: proposalBytes, + ProposerAddress: proposerAddr, + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {bondOwnerKey}, {proposerKey}, + }, + expectedErr: errWrongProposalBondAmount, + }, + "Too big bond": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddProposalTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + return s + }, + utx: func(cfg *config.Config) *txs.AddProposalTx { + return &txs.AddProposalTx{ + BaseTx: *baseTxWithBondAmt(cfg.CaminoConfig.DACProposalBondAmount + 1), + ProposalPayload: proposalBytes, + ProposerAddress: proposerAddr, + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {bondOwnerKey}, {proposerKey}, + }, + expectedErr: errWrongProposalBondAmount, + }, + "Proposal start before chaintime": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddProposalTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + return s + }, + utx: func(cfg *config.Config) *txs.AddProposalTx { + proposalBytes, err := txs.Codec.Marshal(txs.Version, &txs.ProposalWrapper{Proposal: &dac.BaseFeeProposal{ + Start: uint64(cfg.BerlinPhaseTime.Unix()) - 1, + End: uint64(cfg.BerlinPhaseTime.Unix()) + 1, + Options: []uint64{1}, + }}) + require.NoError(t, err) + return &txs.AddProposalTx{ + BaseTx: *baseTxWithBondAmt(cfg.CaminoConfig.DACProposalBondAmount), + ProposalPayload: proposalBytes, + ProposerAddress: proposerAddr, + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {bondOwnerKey}, {proposerKey}, + }, + expectedErr: errProposalStartToEarly, + }, + "Proposal starts to far in the future": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddProposalTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + return s + }, + utx: func(cfg *config.Config) *txs.AddProposalTx { + startTime := uint64(cfg.BerlinPhaseTime.Add(MaxFutureStartTime).Unix() + 1) + proposalBytes, err := txs.Codec.Marshal(txs.Version, &txs.ProposalWrapper{Proposal: &dac.BaseFeeProposal{ + Start: startTime, + End: startTime + 1, + Options: []uint64{1}, + }}) + require.NoError(t, err) + return &txs.AddProposalTx{ + BaseTx: *baseTxWithBondAmt(cfg.CaminoConfig.DACProposalBondAmount), + ProposalPayload: proposalBytes, + ProposerAddress: proposerAddr, + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {bondOwnerKey}, {proposerKey}, + }, + expectedErr: errProposalToFarInFuture, + }, + "Wrong proposer credential": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddProposalTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + expectVerifyMultisigPermission(t, s, []ids.ShortID{utx.ProposerAddress}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.AddProposalTx { + return &txs.AddProposalTx{ + BaseTx: *baseTxWithBondAmt(cfg.CaminoConfig.DACProposalBondAmount), + ProposalPayload: proposalBytes, + ProposerAddress: proposerAddr, + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {bondOwnerKey}, {bondOwnerKey}, + }, + expectedErr: errProposerCredentialMismatch, + }, + // for more proposal specific test cases see camino_dac_test.go + "Semantically invalid proposal": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddProposalTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + expectVerifyMultisigPermission(t, s, []ids.ShortID{utx.ProposerAddress}, nil) + s.EXPECT().GetAddressStates(utx.ProposerAddress).Return(txs.AddressStateEmpty, nil) // not AddressStateCaminoProposer + return s + }, + utx: func(cfg *config.Config) *txs.AddProposalTx { + return &txs.AddProposalTx{ + BaseTx: *baseTxWithBondAmt(cfg.CaminoConfig.DACProposalBondAmount), + ProposalPayload: proposalBytes, + ProposerAddress: proposerAddr, + ProposerAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {bondOwnerKey}, {proposerKey}, + }, + expectedErr: errNotPermittedToCreateProposal, + }, + "OK": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddProposalTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + staker1 := &state.Staker{TxID: ids.ID{0, 1}, SubnetID: constants.PrimaryNetworkID} + staker2 := &state.Staker{TxID: ids.ID{0, 2}, SubnetID: ids.ID{0, 0, 1}} + staker3 := &state.Staker{TxID: ids.ID{0, 3}, SubnetID: constants.PrimaryNetworkID} + consortiumMemberAddr1 := ids.ShortID{0, 0, 0, 0, 1} + consortiumMemberAddr3 := ids.ShortID{0, 0, 0, 0, 3} + proposal, err := utx.Proposal() + require.NoError(t, err) + proposalState := proposal.CreateProposalState([]ids.ShortID{consortiumMemberAddr1, consortiumMemberAddr3}) + + currentStakerIterator := state.NewMockStakerIterator(c) + currentStakerIterator.EXPECT().Next().Return(true).Times(3) + currentStakerIterator.EXPECT().Value().Return(staker3) + currentStakerIterator.EXPECT().Value().Return(staker1) + currentStakerIterator.EXPECT().Value().Return(staker2) + currentStakerIterator.EXPECT().Next().Return(false) + currentStakerIterator.EXPECT().Release() + + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + expectVerifyMultisigPermission(t, s, []ids.ShortID{utx.ProposerAddress}, nil) + + // * proposal verifier + proposalsIterator := state.NewMockProposalsIterator(c) + proposalsIterator.EXPECT().Next().Return(false) + proposalsIterator.EXPECT().Release() + proposalsIterator.EXPECT().Error().Return(nil) + + s.EXPECT().GetAddressStates(utx.ProposerAddress).Return(txs.AddressStateCaminoProposer, nil) + s.EXPECT().GetProposalIterator().Return(proposalsIterator, nil) + // * + + s.EXPECT().GetBaseFee().Return(defaultTxFee, nil) + expectVerifyLock(t, s, utx.Ins, + []*avax.UTXO{feeUTXO, bondUTXO}, + []ids.ShortID{ + feeOwnerAddr, bondOwnerAddr, // consumed + bondOwnerAddr, // produced + }, nil) + s.EXPECT().GetCurrentStakerIterator().Return(currentStakerIterator, nil) + s.EXPECT().GetShortIDLink(ids.ShortID(staker1.NodeID), state.ShortLinkKeyRegisterNode). + Return(consortiumMemberAddr1, nil) + s.EXPECT().GetShortIDLink(ids.ShortID(staker3.NodeID), state.ShortLinkKeyRegisterNode). + Return(consortiumMemberAddr3, nil) + s.EXPECT().AddProposal(txID, proposalState) + expectConsumeUTXOs(t, s, utx.Ins) + expectProduceNewlyLockedUTXOs(t, s, utx.Outs, txID, 0, locked.StateBonded) + return s + }, + utx: func(cfg *config.Config) *txs.AddProposalTx { + return &txs.AddProposalTx{ + BaseTx: *baseTxWithBondAmt(cfg.CaminoConfig.DACProposalBondAmount), + 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)) }() + + env.config.CaminoConfig.DACProposalBondAmount = proposalBondAmt + env.config.BerlinPhaseTime = proposalWrapper.StartTime() + + utx := tt.utx(env.config) + 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) + + err = tx.Unsigned.Visit(&CaminoStandardTxExecutor{ + StandardTxExecutor{ + Backend: &env.backend, + State: tt.state(t, ctrl, utx, tx.ID(), env.config), + Tx: tx, + }, + }) + require.ErrorIs(t, err, tt.expectedErr) + }) + } +} + +func TestCaminoStandardTxExecutorAddVoteTx(t *testing.T) { + ctx, _ := defaultCtx(nil) + caminoGenesisConf := api.Camino{ + VerifyNodeSignature: true, + LockModeBondDeposit: true, + } + caminoStateConf := &state.CaminoConfig{ + VerifyNodeSignature: caminoGenesisConf.VerifyNodeSignature, + LockModeBondDeposit: caminoGenesisConf.LockModeBondDeposit, + } + + feeOwnerKey, feeOwnerAddr, feeOwner := generateKeyAndOwner(t) + voterKey1, voterAddr1, _ := generateKeyAndOwner(t) + voterKey2, voterAddr2, _ := generateKeyAndOwner(t) + _, voterAddr3, _ := generateKeyAndOwner(t) + voterKey4, voterAddr4, _ := generateKeyAndOwner(t) + + feeUTXO := generateTestUTXO(ids.ID{1, 2, 3, 4, 5}, ctx.AVAXAssetID, defaultTxFee, feeOwner, ids.Empty, ids.Empty) + + simpleVote := &txs.VoteWrapper{Vote: &dac.SimpleVote{OptionIndex: 0}} + voteBytes, err := txs.Codec.Marshal(txs.Version, simpleVote) + require.NoError(t, err) + + baseTx := txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: []*avax.TransferableInput{ + generateTestInFromUTXO(feeUTXO, []uint32{0}), + }, + }} + + proposalID := ids.ID{1, 1, 1, 1} + proposal := &dac.BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{voterAddr1, voterAddr3}, + Start: 100, End: 102, + TotalAllowedVoters: 3, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{Options: []dac.SimpleVoteOption[uint64]{ + {Value: 555}, + {Value: 123, Weight: 1}, + {Value: 7}, + }}, + } + utils.Sort(proposal.AllowedVoters) + + tests := map[string]struct { + state func(*testing.T, *gomock.Controller, *txs.AddVoteTx, *config.Config) *state.MockDiff + utx func(*config.Config) *txs.AddVoteTx + signers [][]*secp256k1.PrivateKey + expectedErr error + }{ + "Wrong lockModeBondDeposit flag": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(&state.CaminoConfig{LockModeBondDeposit: false}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr1, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey1}, + }, + expectedErr: errWrongLockMode, + }, + "Not BerlinPhase": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime.Add(-1 * time.Second)) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr1, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey1}, + }, + expectedErr: errNotBerlinPhase, + }, + "Proposal not exist": { // should be in case of already inactive or non-existing proposal + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetProposal(utx.ProposalID).Return(nil, database.ErrNotFound) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr1, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey1}, + }, + expectedErr: database.ErrNotFound, + }, + "Proposal is already inactive": { // shouldn't be possible, inactive proposals are removed + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(proposal.EndTime().Add(time.Second)) + s.EXPECT().GetProposal(utx.ProposalID).Return(proposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr1, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey1}, + }, + expectedErr: errProposalInactive, + }, + "Proposal is not active yet": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(proposal.StartTime().Add(-1 * time.Second)) + s.EXPECT().GetProposal(utx.ProposalID).Return(proposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr1, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey1}, + }, + expectedErr: errProposalInactive, + }, + "Voter isn't consortium member": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(proposal.StartTime()) + s.EXPECT().GetProposal(utx.ProposalID).Return(proposal, nil) + s.EXPECT().GetAddressStates(utx.VoterAddress).Return(txs.AddressStateEmpty, nil) // not AddressStateConsortiumMember + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr1, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey1}, + }, + expectedErr: errNotConsortiumMember, + }, + "Wrong voter credential": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(proposal.StartTime()) + s.EXPECT().GetProposal(utx.ProposalID).Return(proposal, nil) + s.EXPECT().GetAddressStates(utx.VoterAddress).Return(txs.AddressStateConsortiumMember, nil) + expectVerifyMultisigPermission(t, s, []ids.ShortID{utx.VoterAddress}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr1, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {feeOwnerKey}, + }, + expectedErr: errVoterCredentialMismatch, + }, + "Wrong vote for this proposal (bad vote type)": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(proposal.StartTime()) + s.EXPECT().GetProposal(utx.ProposalID).Return(proposal, nil) + s.EXPECT().GetAddressStates(utx.VoterAddress).Return(txs.AddressStateConsortiumMember, nil) + expectVerifyMultisigPermission(t, s, []ids.ShortID{utx.VoterAddress}, nil) + s.EXPECT().GetBaseFee().Return(defaultTxFee, nil) + expectVerifyLock(t, s, utx.Ins, []*avax.UTXO{feeUTXO}, []ids.ShortID{feeOwnerAddr}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + vote := &txs.VoteWrapper{Vote: &dac.DummyVote{}} // not SimpleVote + voteBytes, err := txs.Codec.Marshal(txs.Version, vote) + require.NoError(t, err) + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr1, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey1}, + }, + expectedErr: dac.ErrWrongVote, + }, + "Wrong vote for this proposal (bad option index)": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(proposal.StartTime()) + s.EXPECT().GetProposal(utx.ProposalID).Return(proposal, nil) + s.EXPECT().GetAddressStates(utx.VoterAddress).Return(txs.AddressStateConsortiumMember, nil) + expectVerifyMultisigPermission(t, s, []ids.ShortID{utx.VoterAddress}, nil) + s.EXPECT().GetBaseFee().Return(defaultTxFee, nil) + expectVerifyLock(t, s, utx.Ins, []*avax.UTXO{feeUTXO}, []ids.ShortID{feeOwnerAddr}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + simpleVote := &txs.VoteWrapper{Vote: &dac.SimpleVote{OptionIndex: 5}} // just 3 options in proposal + voteBytes, err := txs.Codec.Marshal(txs.Version, simpleVote) + require.NoError(t, err) + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr1, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey1}, + }, + expectedErr: dac.ErrWrongVote, + }, + "Already voted": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(proposal.StartTime()) + s.EXPECT().GetProposal(utx.ProposalID).Return(proposal, nil) + s.EXPECT().GetAddressStates(utx.VoterAddress).Return(txs.AddressStateConsortiumMember, nil) + expectVerifyMultisigPermission(t, s, []ids.ShortID{utx.VoterAddress}, nil) + s.EXPECT().GetBaseFee().Return(defaultTxFee, nil) + expectVerifyLock(t, s, utx.Ins, []*avax.UTXO{feeUTXO}, []ids.ShortID{feeOwnerAddr}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr2, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey2}, + }, + expectedErr: dac.ErrNotAllowedToVoteOnProposal, + }, + "Not allowed to vote for this proposal (wasn't active validator at proposal creation)": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(proposal.StartTime()) + s.EXPECT().GetProposal(utx.ProposalID).Return(proposal, nil) + s.EXPECT().GetAddressStates(utx.VoterAddress).Return(txs.AddressStateConsortiumMember, nil) + expectVerifyMultisigPermission(t, s, []ids.ShortID{utx.VoterAddress}, nil) + s.EXPECT().GetBaseFee().Return(defaultTxFee, nil) + expectVerifyLock(t, s, utx.Ins, []*avax.UTXO{feeUTXO}, []ids.ShortID{feeOwnerAddr}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr4, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey4}, + }, + expectedErr: dac.ErrNotAllowedToVoteOnProposal, + }, + "OK": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + voteIntf, err := utx.Vote() + require.NoError(t, err) + vote, ok := voteIntf.(*dac.SimpleVote) + require.True(t, ok) + updatedProposal, err := proposal.AddVote(utx.VoterAddress, vote) + require.NoError(t, err) + + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(proposal.StartTime()) + s.EXPECT().GetProposal(utx.ProposalID).Return(proposal, nil) + s.EXPECT().GetAddressStates(utx.VoterAddress).Return(txs.AddressStateConsortiumMember, nil) + expectVerifyMultisigPermission(t, s, []ids.ShortID{utx.VoterAddress}, nil) + s.EXPECT().GetBaseFee().Return(defaultTxFee, nil) + expectVerifyLock(t, s, utx.Ins, []*avax.UTXO{feeUTXO}, []ids.ShortID{feeOwnerAddr}, nil) + s.EXPECT().ModifyProposal(utx.ProposalID, updatedProposal) + expectConsumeUTXOs(t, s, utx.Ins) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr1, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey1}, + }, + }, + "OK: threshold is reached, proposal planned for execution": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.AddVoteTx, cfg *config.Config) *state.MockDiff { + voteIntf, err := utx.Vote() + require.NoError(t, err) + vote, ok := voteIntf.(*dac.SimpleVote) + require.True(t, ok) + updatedProposal, err := proposal.AddVote(utx.VoterAddress, vote) + require.NoError(t, err) + + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(proposal.StartTime()) + s.EXPECT().GetProposal(utx.ProposalID).Return(proposal, nil) + s.EXPECT().GetAddressStates(utx.VoterAddress).Return(txs.AddressStateConsortiumMember, nil) + expectVerifyMultisigPermission(t, s, []ids.ShortID{utx.VoterAddress}, nil) + s.EXPECT().GetBaseFee().Return(defaultTxFee, nil) + expectVerifyLock(t, s, utx.Ins, []*avax.UTXO{feeUTXO}, []ids.ShortID{feeOwnerAddr}, nil) + s.EXPECT().ModifyProposal(utx.ProposalID, updatedProposal) + s.EXPECT().AddProposalIDToFinish(utx.ProposalID) + expectConsumeUTXOs(t, s, utx.Ins) + return s + }, + utx: func(cfg *config.Config) *txs.AddVoteTx { + simpleVote := &txs.VoteWrapper{Vote: &dac.SimpleVote{OptionIndex: 1}} + voteBytes, err := txs.Codec.Marshal(txs.Version, simpleVote) + require.NoError(t, err) + return &txs.AddVoteTx{ + BaseTx: baseTx, + ProposalID: proposalID, + VotePayload: voteBytes, + VoterAddress: voterAddr1, + VoterAuth: &secp256k1fx.Input{SigIndices: []uint32{0}}, + } + }, + signers: [][]*secp256k1.PrivateKey{ + {feeOwnerKey}, {voterKey1}, + }, + }, + } + 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)) }() + + env.config.BerlinPhaseTime = proposal.StartTime().Add(-1 * time.Second) + + utx := tt.utx(env.config) + 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) + + err = tx.Unsigned.Visit(&CaminoStandardTxExecutor{ + StandardTxExecutor{ + Backend: &env.backend, + State: tt.state(t, ctrl, utx, env.config), + Tx: tx, + }, + }) + require.ErrorIs(t, err, tt.expectedErr) + }) + } +} + +func TestCaminoStandardTxExecutorFinishProposalsTx(t *testing.T) { + ctx, _ := defaultCtx(nil) + caminoGenesisConf := api.Camino{ + VerifyNodeSignature: true, + LockModeBondDeposit: true, + } + caminoStateConf := &state.CaminoConfig{ + VerifyNodeSignature: caminoGenesisConf.VerifyNodeSignature, + LockModeBondDeposit: caminoGenesisConf.LockModeBondDeposit, + } + + bondOwnerAddr1 := ids.ShortID{1} + bondOwnerAddr2 := ids.ShortID{2} + bondOwnerAddr3 := ids.ShortID{3} + bondOwnerAddr4 := ids.ShortID{4} + bondOwnerAddr5 := ids.ShortID{5} + bondOwnerAddr6 := ids.ShortID{6} + voterAddr1 := ids.ShortID{7} + voterAddr2 := ids.ShortID{8} + bond1Owner := secp256k1fx.OutputOwners{Addrs: []ids.ShortID{bondOwnerAddr1}, Threshold: 1} + bond2Owner := secp256k1fx.OutputOwners{Addrs: []ids.ShortID{bondOwnerAddr2}, Threshold: 1} + bond3Owner := secp256k1fx.OutputOwners{Addrs: []ids.ShortID{bondOwnerAddr3}, Threshold: 1} + bond4Owner := secp256k1fx.OutputOwners{Addrs: []ids.ShortID{bondOwnerAddr4}, Threshold: 1} + bond5Owner := secp256k1fx.OutputOwners{Addrs: []ids.ShortID{bondOwnerAddr5}, Threshold: 1} + bond6Owner := secp256k1fx.OutputOwners{Addrs: []ids.ShortID{bondOwnerAddr6}, Threshold: 1} + + successfulEarlyFinishedProposalID := ids.ID{1, 1} + failedEarlyFinishedProposalID := ids.ID{2, 2} + successfulExpiredProposalID := ids.ID{3, 3} + failedExpiredProposalID := ids.ID{4, 4} + successfulActiveProposalID := ids.ID{5, 5} + failedActiveProposalID := ids.ID{6, 6} + proposalBondAmt := uint64(100) + successfulEarlyFinishedProposalUTXO := generateTestUTXOWithIndex(ids.ID{1, 1, 1}, 0, ctx.AVAXAssetID, proposalBondAmt, bond1Owner, ids.Empty, locked.ThisTxID, true) + failedEarlyFinishedProposalUTXO := generateTestUTXOWithIndex(ids.ID{2, 2, 2}, 0, ctx.AVAXAssetID, proposalBondAmt, bond2Owner, ids.Empty, locked.ThisTxID, true) + successfulExpiredProposalUTXO := generateTestUTXOWithIndex(ids.ID{3, 3, 3}, 0, ctx.AVAXAssetID, proposalBondAmt, bond3Owner, ids.Empty, locked.ThisTxID, true) + failedExpiredProposalUTXO := generateTestUTXOWithIndex(ids.ID{4, 4, 4}, 0, ctx.AVAXAssetID, proposalBondAmt, bond4Owner, ids.Empty, locked.ThisTxID, true) + successfulActiveProposalUTXO := generateTestUTXOWithIndex(ids.ID{5, 5, 5}, 0, ctx.AVAXAssetID, proposalBondAmt, bond5Owner, ids.Empty, locked.ThisTxID, true) + failedActiveProposalUTXO := generateTestUTXOWithIndex(ids.ID{6, 6, 6}, 0, ctx.AVAXAssetID, proposalBondAmt, bond6Owner, ids.Empty, locked.ThisTxID, true) + + baseTx := txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, failedEarlyFinishedProposalUTXO, + successfulExpiredProposalUTXO, failedExpiredProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulEarlyFinishedProposalUTXO, ids.Empty, ids.Empty), + generateTestOutFromUTXO(failedEarlyFinishedProposalUTXO, ids.Empty, ids.Empty), + generateTestOutFromUTXO(successfulExpiredProposalUTXO, ids.Empty, ids.Empty), + generateTestOutFromUTXO(failedExpiredProposalUTXO, ids.Empty, ids.Empty), + }, + }} + + mostVotedIndex := uint32(1) + successfulEarlyFinishedProposal := &dac.BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{voterAddr1}, + Start: 100, End: 102, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{Options: []dac.SimpleVoteOption[uint64]{ + {Value: 555}, + {Value: 123, Weight: 2}, + {Value: 7}, + }}, + TotalAllowedVoters: 3, + } + + failedEarlyFinishedProposal := &dac.BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{}, + Start: 100, End: 102, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{Options: []dac.SimpleVoteOption[uint64]{ + {Value: 555, Weight: 1}, + {Value: 123, Weight: 1}, + {Value: 7, Weight: 1}, + }}, + TotalAllowedVoters: 3, + } + + successfulExpiredProposal := &dac.BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{voterAddr1}, + Start: 100, End: 102, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{Options: []dac.SimpleVoteOption[uint64]{ + {Value: 555, Weight: 1}, + {Value: 123, Weight: 2}, + {Value: 7}, + }}, + TotalAllowedVoters: 4, + } + + failedExpiredProposal := &dac.BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{voterAddr1, voterAddr2}, + Start: 100, End: 102, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{Options: []dac.SimpleVoteOption[uint64]{ + {Value: 555, Weight: 1}, + {Value: 123, Weight: 1}, + {Value: 7}, + }}, + TotalAllowedVoters: 4, + } + + successfulActiveProposal := &dac.BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{voterAddr1}, + Start: 100, End: 102, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{Options: []dac.SimpleVoteOption[uint64]{ + {Value: 555, Weight: 1}, + {Value: 123, Weight: 2}, + {Value: 7}, + }}, + TotalAllowedVoters: 4, + } + + failedActiveProposal := &dac.BaseFeeProposalState{ + AllowedVoters: []ids.ShortID{voterAddr1, voterAddr2}, + Start: 100, End: 102, + SimpleVoteOptions: dac.SimpleVoteOptions[uint64]{Options: []dac.SimpleVoteOption[uint64]{ + {Value: 555, Weight: 1}, + {Value: 123, Weight: 1}, + {Value: 7}, + }}, + TotalAllowedVoters: 4, + } + + tests := map[string]struct { + state func(*testing.T, *gomock.Controller, *txs.FinishProposalsTx, ids.ID, *config.Config) *state.MockDiff + utx func(*config.Config) *txs.FinishProposalsTx + signers [][]*secp256k1.PrivateKey + expectedErr error + }{ + "Not BerlinPhase": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime.Add(-1 * time.Second)) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil).Return([]ids.ID{}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: baseTx, + EarlyFinishedSuccessfulProposalIDs: []ids.ID{successfulEarlyFinishedProposalID}, + EarlyFinishedFailedProposalIDs: []ids.ID{failedEarlyFinishedProposalID}, + ExpiredSuccessfulProposalIDs: []ids.ID{successfulExpiredProposalID}, + ExpiredFailedProposalIDs: []ids.ID{failedExpiredProposalID}, + } + }, + expectedErr: errNotBerlinPhase, + }, + "Not zero credentials": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil).Return([]ids.ID{}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: baseTx, + EarlyFinishedSuccessfulProposalIDs: []ids.ID{successfulEarlyFinishedProposalID}, + EarlyFinishedFailedProposalIDs: []ids.ID{failedEarlyFinishedProposalID}, + ExpiredSuccessfulProposalIDs: []ids.ID{successfulExpiredProposalID}, + ExpiredFailedProposalIDs: []ids.ID{failedExpiredProposalID}, + } + }, + signers: [][]*secp256k1.PrivateKey{{}}, + expectedErr: errWrongCredentialsNumber, + }, + "Not expiration time": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil). + Return([]ids.ID{successfulActiveProposalID}, cfg.BerlinPhaseTime.Add(time.Second), nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulActiveProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulActiveProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + ExpiredSuccessfulProposalIDs: []ids.ID{successfulActiveProposalID}, + } + }, + expectedErr: errProposalsAreNotExpiredYet, + }, + "Not all expired proposals": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil). + Return([]ids.ID{successfulExpiredProposalID, failedExpiredProposalID}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulExpiredProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulExpiredProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + ExpiredSuccessfulProposalIDs: []ids.ID{successfulExpiredProposalID}, + } + }, + expectedErr: errExpiredProposalsMismatch, + }, + "Not all early finished proposals": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil).Return([]ids.ID{}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish(). + Return([]ids.ID{successfulEarlyFinishedProposalID, failedEarlyFinishedProposalID}, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulExpiredProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulExpiredProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + EarlyFinishedSuccessfulProposalIDs: []ids.ID{successfulEarlyFinishedProposalID}, + } + }, + expectedErr: errEarlyFinishedProposalsMismatch, + }, + "Invalid inputs": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil). + Return([]ids.ID{successfulExpiredProposalID}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{successfulEarlyFinishedProposalID}, nil) + lockTxIDs := append(utx.EarlyFinishedSuccessfulProposalIDs, utx.ExpiredSuccessfulProposalIDs...) //nolint:gocritic + expectUnlock(t, s, lockTxIDs, []ids.ShortID{ + bondOwnerAddr1, bondOwnerAddr3, + }, []*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, successfulExpiredProposalUTXO, + }, locked.StateBonded) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: []*avax.TransferableInput{ // missing 2nd input + generateTestInFromUTXO(successfulEarlyFinishedProposalUTXO, []uint32{}), + }, + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulEarlyFinishedProposalUTXO, ids.Empty, ids.Empty), + generateTestOutFromUTXO(successfulExpiredProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + EarlyFinishedSuccessfulProposalIDs: []ids.ID{successfulEarlyFinishedProposalID}, + ExpiredSuccessfulProposalIDs: []ids.ID{successfulExpiredProposalID}, + } + }, + expectedErr: errInvalidSystemTxBody, + }, + "Invalid outs": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil). + Return([]ids.ID{successfulExpiredProposalID}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{successfulEarlyFinishedProposalID}, nil) + lockTxIDs := append(utx.EarlyFinishedSuccessfulProposalIDs, utx.ExpiredSuccessfulProposalIDs...) //nolint:gocritic + expectUnlock(t, s, lockTxIDs, []ids.ShortID{ + bondOwnerAddr1, bondOwnerAddr3, + }, []*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, successfulExpiredProposalUTXO, + }, locked.StateBonded) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, successfulExpiredProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulEarlyFinishedProposalUTXO, ids.Empty, ids.Empty), + generateTestOut(ctx.AVAXAssetID, proposalBondAmt, bond1Owner, ids.Empty, ids.Empty), // successfulExpiredProposalUTXO with different owner + }, + }}, + EarlyFinishedSuccessfulProposalIDs: []ids.ID{successfulEarlyFinishedProposalID}, + ExpiredSuccessfulProposalIDs: []ids.ID{successfulExpiredProposalID}, + } + }, + expectedErr: errInvalidSystemTxBody, + }, + "Proposal not exist": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil).Return([]ids.ID{}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return(utx.EarlyFinishedSuccessfulProposalIDs, nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr1, + }, []*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, + }, locked.StateBonded) + s.EXPECT().GetProposal(successfulEarlyFinishedProposalID).Return(nil, database.ErrNotFound) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulEarlyFinishedProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + EarlyFinishedSuccessfulProposalIDs: []ids.ID{successfulEarlyFinishedProposalID}, + } + }, + expectedErr: database.ErrNotFound, + }, + "Early-finish check: successful early finished swapped with successful expired": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil). + Return([]ids.ID{successfulExpiredProposalID}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish(). + Return([]ids.ID{successfulEarlyFinishedProposalID}, nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr1, bondOwnerAddr3, + }, []*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, successfulExpiredProposalUTXO, + }, locked.StateBonded) + s.EXPECT().GetProposal(successfulExpiredProposalID).Return(successfulExpiredProposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, + successfulExpiredProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulEarlyFinishedProposalUTXO, ids.Empty, ids.Empty), + generateTestOutFromUTXO(successfulExpiredProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + EarlyFinishedSuccessfulProposalIDs: []ids.ID{successfulExpiredProposalID}, + ExpiredSuccessfulProposalIDs: []ids.ID{successfulEarlyFinishedProposalID}, + } + }, + expectedErr: errNotEarlyFinishedProposal, + }, + "Early-finish check: failed early finished swapped with failed expired": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil). + Return([]ids.ID{successfulExpiredProposalID}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish(). + Return([]ids.ID{successfulEarlyFinishedProposalID}, nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr1, bondOwnerAddr3, + }, []*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, successfulExpiredProposalUTXO, + }, locked.StateBonded) + s.EXPECT().GetProposal(successfulExpiredProposalID).Return(successfulExpiredProposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, + successfulExpiredProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulEarlyFinishedProposalUTXO, ids.Empty, ids.Empty), + generateTestOutFromUTXO(successfulExpiredProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + EarlyFinishedSuccessfulProposalIDs: []ids.ID{successfulExpiredProposalID}, + ExpiredSuccessfulProposalIDs: []ids.ID{successfulEarlyFinishedProposalID}, + } + }, + expectedErr: errNotEarlyFinishedProposal, + }, + "Early-finish check: successful active in successful early finished": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil).Return([]ids.ID{}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish(). + Return([]ids.ID{successfulEarlyFinishedProposalID}, nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr5, + }, []*avax.UTXO{ + successfulActiveProposalUTXO, + }, locked.StateBonded) + s.EXPECT().GetProposal(successfulActiveProposalID).Return(successfulActiveProposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulActiveProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulActiveProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + EarlyFinishedSuccessfulProposalIDs: []ids.ID{successfulActiveProposalID}, + } + }, + expectedErr: errNotEarlyFinishedProposal, + }, + "Early-finish check: failed active in failed early finished": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil).Return([]ids.ID{}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish(). + Return([]ids.ID{failedEarlyFinishedProposalID}, nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr6, + }, []*avax.UTXO{ + failedActiveProposalUTXO, + }, locked.StateBonded) + s.EXPECT().GetProposal(failedActiveProposalID).Return(failedActiveProposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + failedActiveProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(failedActiveProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + EarlyFinishedFailedProposalIDs: []ids.ID{failedActiveProposalID}, + } + }, + expectedErr: errNotEarlyFinishedProposal, + }, + "Expire check: successful active in successful expired": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil). + Return([]ids.ID{successfulExpiredProposalID}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{}, nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr5, + }, []*avax.UTXO{ + successfulActiveProposalUTXO, + }, locked.StateBonded) + s.EXPECT().GetProposal(successfulActiveProposalID).Return(successfulActiveProposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulActiveProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulActiveProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + ExpiredSuccessfulProposalIDs: []ids.ID{successfulActiveProposalID}, + } + }, + expectedErr: errNotExpiredProposal, + }, + "Expire check: failed active in failed expired": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil). + Return([]ids.ID{failedExpiredProposalID}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{}, nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr6, + }, []*avax.UTXO{ + failedActiveProposalUTXO, + }, locked.StateBonded) + s.EXPECT().GetProposal(failedActiveProposalID).Return(failedActiveProposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + failedActiveProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(failedActiveProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + ExpiredFailedProposalIDs: []ids.ID{failedActiveProposalID}, + } + }, + expectedErr: errNotExpiredProposal, + }, + "Success check: failed proposal in successful expired": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil). + Return([]ids.ID{failedExpiredProposalID}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{}, nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr4, + }, []*avax.UTXO{ + failedExpiredProposalUTXO, + }, locked.StateBonded) + + s.EXPECT().GetProposal(failedExpiredProposalID).Return(failedExpiredProposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + failedExpiredProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(failedExpiredProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + ExpiredSuccessfulProposalIDs: []ids.ID{failedExpiredProposalID}, + } + }, + expectedErr: errNotSuccessfulProposal, + }, + "Success check: failed proposal in successful early finished": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil).Return([]ids.ID{}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{failedEarlyFinishedProposalID}, nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr2, + }, []*avax.UTXO{ + failedEarlyFinishedProposalUTXO, + }, locked.StateBonded) + + s.EXPECT().GetProposal(failedEarlyFinishedProposalID).Return(failedEarlyFinishedProposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + failedEarlyFinishedProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(failedEarlyFinishedProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + EarlyFinishedSuccessfulProposalIDs: []ids.ID{failedEarlyFinishedProposalID}, + } + }, + expectedErr: errNotSuccessfulProposal, + }, + "Success check: successful proposal in failed expired": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil). + Return([]ids.ID{successfulExpiredProposalID}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{}, nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr3, + }, []*avax.UTXO{ + successfulExpiredProposalUTXO, + }, locked.StateBonded) + + s.EXPECT().GetProposal(successfulExpiredProposalID).Return(successfulExpiredProposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulExpiredProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulExpiredProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + ExpiredFailedProposalIDs: []ids.ID{successfulExpiredProposalID}, + } + }, + expectedErr: errSuccessfulProposal, + }, + "Success check: successful proposal in failed early finished": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil).Return([]ids.ID{}, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return([]ids.ID{successfulEarlyFinishedProposalID}, nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr1, + }, []*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, + }, locked.StateBonded) + + s.EXPECT().GetProposal(successfulEarlyFinishedProposalID).Return(successfulEarlyFinishedProposal, nil) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + Ins: generateInsFromUTXOsWithSigIndices([]*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, + }, []uint32{}), + Outs: []*avax.TransferableOutput{ + generateTestOutFromUTXO(successfulEarlyFinishedProposalUTXO, ids.Empty, ids.Empty), + }, + }}, + EarlyFinishedFailedProposalIDs: []ids.ID{successfulEarlyFinishedProposalID}, + } + }, + expectedErr: errSuccessfulProposal, + }, + "OK": { + state: func(t *testing.T, c *gomock.Controller, utx *txs.FinishProposalsTx, txID ids.ID, cfg *config.Config) *state.MockDiff { + s := state.NewMockDiff(c) + s.EXPECT().CaminoConfig().Return(caminoStateConf, nil) + s.EXPECT().GetTimestamp().Return(cfg.BerlinPhaseTime) + expiredProposalIDs := append(utx.ExpiredSuccessfulProposalIDs, utx.ExpiredFailedProposalIDs...) //nolint:gocritic + s.EXPECT().GetNextToExpireProposalIDsAndTime(nil).Return(expiredProposalIDs, cfg.BerlinPhaseTime, nil) + s.EXPECT().GetProposalIDsToFinish().Return(append(utx.EarlyFinishedSuccessfulProposalIDs, utx.EarlyFinishedFailedProposalIDs...), nil) + expectUnlock(t, s, utx.ProposalIDs(), []ids.ShortID{ + bondOwnerAddr1, bondOwnerAddr2, bondOwnerAddr3, bondOwnerAddr4, + }, []*avax.UTXO{ + successfulEarlyFinishedProposalUTXO, failedEarlyFinishedProposalUTXO, + successfulExpiredProposalUTXO, failedExpiredProposalUTXO, + }, locked.StateBonded) + + s.EXPECT().GetProposal(successfulEarlyFinishedProposalID).Return(successfulEarlyFinishedProposal, nil) + s.EXPECT().SetBaseFee(successfulEarlyFinishedProposal.Options[mostVotedIndex].Value) // proposal executor + s.EXPECT().RemoveProposal(successfulEarlyFinishedProposalID, successfulEarlyFinishedProposal) + s.EXPECT().RemoveProposalIDToFinish(successfulEarlyFinishedProposalID) + + s.EXPECT().GetProposal(failedEarlyFinishedProposalID).Return(failedEarlyFinishedProposal, nil) + s.EXPECT().RemoveProposal(failedEarlyFinishedProposalID, failedEarlyFinishedProposal) + s.EXPECT().RemoveProposalIDToFinish(failedEarlyFinishedProposalID) + + s.EXPECT().GetProposal(successfulExpiredProposalID).Return(successfulExpiredProposal, nil) + s.EXPECT().SetBaseFee(successfulExpiredProposal.Options[mostVotedIndex].Value) // proposal executor + s.EXPECT().RemoveProposal(successfulExpiredProposalID, successfulExpiredProposal) + + s.EXPECT().GetProposal(failedExpiredProposalID).Return(failedExpiredProposal, nil) + s.EXPECT().RemoveProposal(failedExpiredProposalID, failedExpiredProposal) + + expectConsumeUTXOs(t, s, utx.Ins) + expectProduceUTXOs(t, s, utx.Outs, txID, 0) + return s + }, + utx: func(cfg *config.Config) *txs.FinishProposalsTx { + return &txs.FinishProposalsTx{ + BaseTx: baseTx, + EarlyFinishedSuccessfulProposalIDs: []ids.ID{successfulEarlyFinishedProposalID}, + EarlyFinishedFailedProposalIDs: []ids.ID{failedEarlyFinishedProposalID}, + ExpiredSuccessfulProposalIDs: []ids.ID{successfulExpiredProposalID}, + ExpiredFailedProposalIDs: []ids.ID{failedExpiredProposalID}, + } + }, + }, + } + 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)) }() + + env.config.BerlinPhaseTime = successfulEarlyFinishedProposal.StartTime().Add(-1 * time.Second) + + utx := tt.utx(env.config) + 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) + + err = tx.Unsigned.Visit(&CaminoStandardTxExecutor{ + StandardTxExecutor{ + Backend: &env.backend, + State: tt.state(t, ctrl, utx, tx.ID(), env.config), + Tx: tx, + }, + }) + require.ErrorIs(t, err, tt.expectedErr) + }) + } +} diff --git a/vms/platformvm/txs/executor/staker_tx_verification.go b/vms/platformvm/txs/executor/staker_tx_verification.go index af70e3c9d03f..564f6a893ed7 100644 --- a/vms/platformvm/txs/executor/staker_tx_verification.go +++ b/vms/platformvm/txs/executor/staker_tx_verification.go @@ -299,6 +299,11 @@ func removeSubnetValidatorValidation( return nil, false, err } + fee, err := chainState.GetBaseFee() + if err != nil { + return nil, false, err + } + // Verify the flowcheck if err := backend.FlowChecker.VerifySpend( tx, @@ -307,7 +312,7 @@ func removeSubnetValidatorValidation( tx.Outs, baseTxCreds, map[ids.ID]uint64{ - backend.Ctx.AVAXAssetID: backend.Config.TxFee, + backend.Ctx.AVAXAssetID: fee, }, ); err != nil { return nil, false, fmt.Errorf("%w: %v", errFlowCheckFailed, err) diff --git a/vms/platformvm/txs/executor/standard_tx_executor.go b/vms/platformvm/txs/executor/standard_tx_executor.go index 3af71b9e18ef..02eec789c679 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor.go +++ b/vms/platformvm/txs/executor/standard_tx_executor.go @@ -175,6 +175,11 @@ func (e *StandardTxExecutor) ImportTx(tx *txs.ImportTx) error { copy(ins, tx.Ins) copy(ins[len(tx.Ins):], tx.ImportedInputs) + fee, err := e.State.GetBaseFee() + if err != nil { + return err + } + if err := e.FlowChecker.VerifySpendUTXOs( e.State, tx, @@ -183,7 +188,7 @@ func (e *StandardTxExecutor) ImportTx(tx *txs.ImportTx) error { tx.Outs, e.Tx.Creds, map[ids.ID]uint64{ - e.Ctx.AVAXAssetID: e.Config.TxFee, + e.Ctx.AVAXAssetID: fee, }, ); err != nil { return err @@ -220,6 +225,11 @@ func (e *StandardTxExecutor) ExportTx(tx *txs.ExportTx) error { } } + fee, err := e.State.GetBaseFee() + if err != nil { + return err + } + // Verify the flowcheck if err := e.FlowChecker.VerifySpend( tx, @@ -228,7 +238,7 @@ func (e *StandardTxExecutor) ExportTx(tx *txs.ExportTx) error { outs, e.Tx.Creds, map[ids.ID]uint64{ - e.Ctx.AVAXAssetID: e.Config.TxFee, + e.Ctx.AVAXAssetID: fee, }, ); err != nil { return fmt.Errorf("failed verifySpend: %w", err)