diff --git a/Makefile b/Makefile index b6823e665a..60606cf4c6 100644 --- a/Makefile +++ b/Makefile @@ -293,6 +293,9 @@ $(GOPATH1)/bin/%: test: build $(GOTESTCOMMAND) $(GOTAGS) -race $(UNIT_TEST_SOURCES) -timeout 1h -coverprofile=coverage.txt -covermode=atomic +testc: + echo $(UNIT_TEST_SOURCES) | xargs -P8 -n1 go test -c + benchcheck: build $(GOTESTCOMMAND) $(GOTAGS) -race $(UNIT_TEST_SOURCES) -run ^NOTHING -bench Benchmark -benchtime 1x -timeout 1h diff --git a/cmd/tealdbg/localLedger.go b/cmd/tealdbg/localLedger.go index d495fbb328..16f28fd904 100644 --- a/cmd/tealdbg/localLedger.go +++ b/cmd/tealdbg/localLedger.go @@ -359,6 +359,10 @@ func (l *localLedger) LookupAgreement(rnd basics.Round, addr basics.Address) (ba }, nil } +func (l *localLedger) GetKnockOfflineCandidates(basics.Round, config.ConsensusParams) (map[basics.Address]basics.OnlineAccountData, error) { + return nil, nil +} + func (l *localLedger) OnlineCirculation(rnd basics.Round, voteRound basics.Round) (basics.MicroAlgos, error) { // A constant is fine for tealdbg return basics.Algos(1_000_000_000), nil // 1B diff --git a/daemon/algod/api/server/v2/dryrun.go b/daemon/algod/api/server/v2/dryrun.go index d3924eaf1d..25b3365f4a 100644 --- a/daemon/algod/api/server/v2/dryrun.go +++ b/daemon/algod/api/server/v2/dryrun.go @@ -329,6 +329,10 @@ func (dl *dryrunLedger) LookupAgreement(rnd basics.Round, addr basics.Address) ( }, nil } +func (dl *dryrunLedger) GetKnockOfflineCandidates(basics.Round, config.ConsensusParams) (map[basics.Address]basics.OnlineAccountData, error) { + return nil, nil +} + func (dl *dryrunLedger) OnlineCirculation(rnd basics.Round, voteRnd basics.Round) (basics.MicroAlgos, error) { // dryrun doesn't support setting the global online stake, so we'll just return a constant return basics.Algos(1_000_000_000), nil // 1B diff --git a/data/basics/userBalance.go b/data/basics/userBalance.go index d8f86aea54..4db69bf22a 100644 --- a/data/basics/userBalance.go +++ b/data/basics/userBalance.go @@ -111,7 +111,10 @@ type VotingData struct { type OnlineAccountData struct { MicroAlgosWithRewards MicroAlgos VotingData + IncentiveEligible bool + LastProposed Round + LastHeartbeat Round } // AccountData contains the data associated with a given address. @@ -561,6 +564,8 @@ func (u AccountData) OnlineAccountData() OnlineAccountData { VoteKeyDilution: u.VoteKeyDilution, }, IncentiveEligible: u.IncentiveEligible, + LastProposed: u.LastProposed, + LastHeartbeat: u.LastHeartbeat, } } diff --git a/ledger/acctonline.go b/ledger/acctonline.go index 0db04e92ad..f82da850d1 100644 --- a/ledger/acctonline.go +++ b/ledger/acctonline.go @@ -622,11 +622,6 @@ func (ao *onlineAccounts) onlineTotals(rnd basics.Round) (basics.MicroAlgos, pro return basics.MicroAlgos{Raw: onlineRoundParams.OnlineSupply}, onlineRoundParams.CurrentProtocol, nil } -// LookupOnlineAccountData returns the online account data for a given address at a given round. -func (ao *onlineAccounts) LookupOnlineAccountData(rnd basics.Round, addr basics.Address) (data basics.OnlineAccountData, err error) { - return ao.lookupOnlineAccountData(rnd, addr) -} - // roundOffset calculates the offset of the given round compared to the current dbRound. Requires that the lock would be taken. func (ao *onlineAccounts) roundOffset(rnd basics.Round) (offset uint64, err error) { if rnd < ao.cachedDBRoundOnline { diff --git a/ledger/acctonline_expired_test.go b/ledger/acctonline_expired_test.go index 25bfe3b11b..cb82ae856f 100644 --- a/ledger/acctonline_expired_test.go +++ b/ledger/acctonline_expired_test.go @@ -458,7 +458,7 @@ func TestOnlineAcctModelSimple(t *testing.T) { }) // test same scenario on double ledger t.Run("DoubleLedger", func(t *testing.T) { - m := newDoubleLedgerAcctModel(t, protocol.ConsensusFuture, true) + m := newDoubleLedgerAcctModel(t, protocol.ConsensusV39, true) // TODO simulate heartbeats defer m.teardown() testOnlineAcctModelSimple(t, m) }) @@ -626,7 +626,7 @@ func TestOnlineAcctModelScenario(t *testing.T) { }) // test same scenario on double ledger t.Run("DoubleLedger", func(t *testing.T) { - m := newDoubleLedgerAcctModel(t, protocol.ConsensusFuture, true) + m := newDoubleLedgerAcctModel(t, protocol.ConsensusV39, true) // TODO simulate heartbeats defer m.teardown() runScenario(t, m, tc.scenario) }) diff --git a/ledger/eval/appcow_test.go b/ledger/eval/appcow_test.go index 6f5e39b305..54aea0ece4 100644 --- a/ledger/eval/appcow_test.go +++ b/ledger/eval/appcow_test.go @@ -56,6 +56,10 @@ func (ml *emptyLedger) onlineStake() (basics.MicroAlgos, error) { return basics.MicroAlgos{}, nil } +func (ml *emptyLedger) knockOfflineCandidates() (map[basics.Address]basics.OnlineAccountData, error) { + return nil, nil +} + func (ml *emptyLedger) lookupAppParams(addr basics.Address, aidx basics.AppIndex, cacheOnly bool) (ledgercore.AppParamsDelta, bool, error) { return ledgercore.AppParamsDelta{}, true, nil } diff --git a/ledger/eval/cow.go b/ledger/eval/cow.go index 9511af7ce7..c628b15224 100644 --- a/ledger/eval/cow.go +++ b/ledger/eval/cow.go @@ -47,6 +47,7 @@ type roundCowParent interface { // lookup retrieves agreement data about an address, querying the ledger if necessary. lookupAgreement(basics.Address) (basics.OnlineAccountData, error) onlineStake() (basics.MicroAlgos, error) + knockOfflineCandidates() (map[basics.Address]basics.OnlineAccountData, error) // lookupAppParams, lookupAssetParams, lookupAppLocalState, and lookupAssetHolding retrieve data for a given address and ID. // If cacheOnly is set, the ledger DB will not be queried, and only the cache will be consulted. @@ -192,6 +193,10 @@ func (cb *roundCowState) lookupAgreement(addr basics.Address) (data basics.Onlin return cb.lookupParent.lookupAgreement(addr) } +func (cb *roundCowState) knockOfflineCandidates() (map[basics.Address]basics.OnlineAccountData, error) { + return cb.lookupParent.knockOfflineCandidates() +} + func (cb *roundCowState) lookupAppParams(addr basics.Address, aidx basics.AppIndex, cacheOnly bool) (ledgercore.AppParamsDelta, bool, error) { params, ok := cb.mods.Accts.GetAppParams(addr, aidx) if ok { diff --git a/ledger/eval/cow_test.go b/ledger/eval/cow_test.go index 138e2562ad..2192272bf1 100644 --- a/ledger/eval/cow_test.go +++ b/ledger/eval/cow_test.go @@ -73,6 +73,10 @@ func (ml *mockLedger) onlineStake() (basics.MicroAlgos, error) { return basics.Algos(55_555), nil } +func (ml *mockLedger) knockOfflineCandidates() (map[basics.Address]basics.OnlineAccountData, error) { + return nil, nil +} + func (ml *mockLedger) lookupAppParams(addr basics.Address, aidx basics.AppIndex, cacheOnly bool) (ledgercore.AppParamsDelta, bool, error) { params, ok := ml.balanceMap[addr].AppParams[aidx] return ledgercore.AppParamsDelta{Params: ¶ms}, ok, nil // XXX make a copy? diff --git a/ledger/eval/eval.go b/ledger/eval/eval.go index 859b62922f..c25d87728d 100644 --- a/ledger/eval/eval.go +++ b/ledger/eval/eval.go @@ -38,6 +38,7 @@ import ( "github.com/algorand/go-algorand/ledger/ledgercore" "github.com/algorand/go-algorand/logging" "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/util" "github.com/algorand/go-algorand/util/execpool" ) @@ -48,6 +49,7 @@ type LedgerForCowBase interface { CheckDup(config.ConsensusParams, basics.Round, basics.Round, basics.Round, transactions.Txid, ledgercore.Txlease) error LookupWithoutRewards(basics.Round, basics.Address) (ledgercore.AccountData, basics.Round, error) LookupAgreement(basics.Round, basics.Address) (basics.OnlineAccountData, error) + GetKnockOfflineCandidates(basics.Round, config.ConsensusParams) (map[basics.Address]basics.OnlineAccountData, error) LookupAsset(basics.Round, basics.Address, basics.AssetIndex) (ledgercore.AssetResource, error) LookupApplication(basics.Round, basics.Address, basics.AppIndex) (ledgercore.AppResource, error) LookupKv(basics.Round, string) ([]byte, error) @@ -237,6 +239,10 @@ func (x *roundCowBase) lookupAgreement(addr basics.Address) (basics.OnlineAccoun return ad, err } +func (x *roundCowBase) knockOfflineCandidates() (map[basics.Address]basics.OnlineAccountData, error) { + return x.l.GetKnockOfflineCandidates(x.rnd, x.proto) +} + // onlineStake returns the total online stake as of the start of the round. It // caches the result to prevent repeated calls to the ledger. func (x *roundCowBase) onlineStake() (basics.MicroAlgos, error) { @@ -1339,7 +1345,13 @@ func (eval *BlockEvaluator) TestingTxnCounter() uint64 { } // Call "endOfBlock" after all the block's rewards and transactions are processed. -func (eval *BlockEvaluator) endOfBlock() error { +// When generating a block, participating addresses are passed to prevent a +// proposer from suspending itself. +func (eval *BlockEvaluator) endOfBlock(participating ...basics.Address) error { + if participating != nil && !eval.generate { + panic("logic error: only pass partAddresses to endOfBlock when generating") + } + if eval.generate { var err error eval.block.TxnCommitments, err = eval.block.PaysetCommit() @@ -1364,7 +1376,7 @@ func (eval *BlockEvaluator) endOfBlock() error { } } - eval.generateKnockOfflineAccountsList() + eval.generateKnockOfflineAccountsList(participating) if eval.proto.StateProofInterval > 0 { var basicStateProof bookkeeping.StateProofTrackingData @@ -1607,25 +1619,94 @@ type challenge struct { // deltas and testing if any of them needs to be reset/suspended. Expiration // takes precedence - if an account is expired, it should be knocked offline and // key material deleted. If it is only suspended, the key material will remain. -func (eval *BlockEvaluator) generateKnockOfflineAccountsList() { +// +// Different ndoes may propose different list of addresses based on node state. +// Block validators only check whether ExpiredParticipationAccounts or +// AbsentParticipationAccounts meet the criteria for expiration or suspension, +// not whether the lists are complete. +// +// This function is passed a list of participating addresses so a node will not +// propose a block that suspends or expires itself. +func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []basics.Address) { if !eval.generate { return } - current := eval.Round() + current := eval.Round() maxExpirations := eval.proto.MaxProposedExpiredOnlineAccounts maxSuspensions := eval.proto.Payouts.MaxMarkAbsent updates := &eval.block.ParticipationUpdates - ch := activeChallenge(&eval.proto, uint64(eval.Round()), eval.state) + ch := activeChallenge(&eval.proto, uint64(current), eval.state) + + // Make a set of candidate addresses to check for expired or absentee status. + type candidateData struct { + VoteLastValid basics.Round + VoteID crypto.OneTimeSignatureVerifier + Status basics.Status + LastProposed basics.Round + LastHeartbeat basics.Round + MicroAlgosWithRewards basics.MicroAlgos + IncentiveEligible bool // currently unused below, but may be needed in the future + } + candidates := make(map[basics.Address]candidateData) + partAddrs := util.MakeSet(participating...) + + // First, ask the ledger for the top N online accounts, with their latest + // online account data, current up to the previous round. + if maxSuspensions > 0 { + knockOfflineCandidates, err := eval.state.knockOfflineCandidates() + if err != nil { + // Log an error and keep going; generating lists of absent and expired + // accounts is not required by block validation rules. + logging.Base().Warnf("error fetching knockOfflineCandidates: %v", err) + knockOfflineCandidates = nil + } + for accountAddr, acctData := range knockOfflineCandidates { + // acctData is from previous block: doesn't include any updates in mods + candidates[accountAddr] = candidateData{ + VoteLastValid: acctData.VoteLastValid, + VoteID: acctData.VoteID, + Status: basics.Online, // from lookupOnlineAccountData, which only returns online accounts + LastProposed: acctData.LastProposed, + LastHeartbeat: acctData.LastHeartbeat, + MicroAlgosWithRewards: acctData.MicroAlgosWithRewards, + IncentiveEligible: acctData.IncentiveEligible, + } + } + } + // Then add any accounts modified in this block, with their state at the + // end of the round. for _, accountAddr := range eval.state.modifiedAccounts() { acctData, found := eval.state.mods.Accts.GetData(accountAddr) if !found { continue } + // This will overwrite data from the knockOfflineCandidates() list, if they were modified in the current block. + candidates[accountAddr] = candidateData{ + VoteLastValid: acctData.VoteLastValid, + VoteID: acctData.VoteID, + Status: acctData.Status, + LastProposed: acctData.LastProposed, + LastHeartbeat: acctData.LastHeartbeat, + MicroAlgosWithRewards: acctData.WithUpdatedRewards(eval.proto, eval.state.rewardsLevel()).MicroAlgos, + IncentiveEligible: acctData.IncentiveEligible, + } + } + + // Now, check these candidate accounts to see if they are expired or absent. + for accountAddr, acctData := range candidates { + if acctData.MicroAlgosWithRewards.IsZero() { + continue // don't check accounts that are being closed + } + + if _, ok := partAddrs[accountAddr]; ok { + continue // don't check our own participation accounts + } + // Expired check: are this account's voting keys no longer valid? // Regardless of being online or suspended, if voting data exists, the // account can be expired to remove it. This means an offline account // can be expired (because it was already suspended). @@ -1641,13 +1722,15 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList() { } } + // Absent check: has it been too long since the last heartbeat/proposal, or + // has this online account failed a challenge? if len(updates.AbsentParticipationAccounts) >= maxSuspensions { continue // no more room (don't break the loop, since we may have more expiries) } if acctData.Status == basics.Online { lastSeen := max(acctData.LastProposed, acctData.LastHeartbeat) - if isAbsent(eval.state.prevTotals.Online.Money, acctData.MicroAlgos, lastSeen, current) || + if isAbsent(eval.state.prevTotals.Online.Money, acctData.MicroAlgosWithRewards, lastSeen, current) || failsChallenge(ch, accountAddr, lastSeen) { updates.AbsentParticipationAccounts = append( updates.AbsentParticipationAccounts, @@ -1658,14 +1741,6 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList() { } } -// delete me in Go 1.21 -func max(a, b basics.Round) basics.Round { - if a > b { - return a - } - return b -} - // bitsMatch checks if the first n bits of two byte slices match. Written to // work on arbitrary slices, but we expect that n is small. Only user today // calls with n=5. @@ -1821,6 +1896,9 @@ func (eval *BlockEvaluator) validateAbsentOnlineAccounts() error { if acctData.Status != basics.Online { return fmt.Errorf("proposed absent account %v was %v, not Online", accountAddr, acctData.Status) } + if acctData.MicroAlgos.IsZero() { + return fmt.Errorf("proposed absent account %v with zero algos", accountAddr) + } lastSeen := max(acctData.LastProposed, acctData.LastHeartbeat) if isAbsent(eval.state.prevTotals.Online.Money, acctData.MicroAlgos, lastSeen, eval.Round()) { @@ -1890,7 +1968,16 @@ func (eval *BlockEvaluator) suspendAbsentAccounts() error { // After a call to GenerateBlock, the BlockEvaluator can still be used to // accept transactions. However, to guard against reuse, subsequent calls // to GenerateBlock on the same BlockEvaluator will fail. -func (eval *BlockEvaluator) GenerateBlock(addrs []basics.Address) (*ledgercore.UnfinishedBlock, error) { +// +// A list of participating addresses is passed to GenerateBlock. This lets +// the BlockEvaluator know which of this node's participating addresses might +// be proposing this block. This information is used when: +// - generating lists of absent accounts (don't suspend yourself) +// - preparing a ledgercore.UnfinishedBlock, which contains the end-of-block +// state of each potential proposer. This allows for a final check in +// UnfinishedBlock.FinishBlock to ensure the proposer hasn't closed its +// account before setting the ProposerPayout header. +func (eval *BlockEvaluator) GenerateBlock(participating []basics.Address) (*ledgercore.UnfinishedBlock, error) { if !eval.generate { logging.Base().Panicf("GenerateBlock() called but generate is false") } @@ -1899,19 +1986,19 @@ func (eval *BlockEvaluator) GenerateBlock(addrs []basics.Address) (*ledgercore.U return nil, fmt.Errorf("GenerateBlock already called on this BlockEvaluator") } - err := eval.endOfBlock() + err := eval.endOfBlock(participating...) if err != nil { return nil, err } - // look up set of participation accounts passed to GenerateBlock (possible proposers) - finalAccounts := make(map[basics.Address]ledgercore.AccountData, len(addrs)) - for i := range addrs { - acct, err := eval.state.lookup(addrs[i]) + // look up end-of-block state of possible proposers passed to GenerateBlock + finalAccounts := make(map[basics.Address]ledgercore.AccountData, len(participating)) + for i := range participating { + acct, err := eval.state.lookup(participating[i]) if err != nil { return nil, err } - finalAccounts[addrs[i]] = acct + finalAccounts[participating[i]] = acct } vb := ledgercore.MakeUnfinishedBlock(eval.block, eval.state.deltas(), finalAccounts) diff --git a/ledger/eval/eval_test.go b/ledger/eval/eval_test.go index 77a477b3c0..ed2f551ac2 100644 --- a/ledger/eval/eval_test.go +++ b/ledger/eval/eval_test.go @@ -793,6 +793,17 @@ func (ledger *evalTestLedger) LookupAgreement(rnd basics.Round, addr basics.Addr return convertToOnline(ad), err } +func (ledger *evalTestLedger) GetKnockOfflineCandidates(rnd basics.Round, _ config.ConsensusParams) (map[basics.Address]basics.OnlineAccountData, error) { + // simulate by returning all online accounts known by the test ledger + ret := make(map[basics.Address]basics.OnlineAccountData) + for addr, data := range ledger.roundBalances[rnd] { + if data.Status == basics.Online && !data.MicroAlgos.IsZero() { + ret[addr] = data.OnlineAccountData() + } + } + return ret, nil +} + // OnlineCirculation just returns a deterministic value for a given round. func (ledger *evalTestLedger) OnlineCirculation(rnd, voteRound basics.Round) (basics.MicroAlgos, error) { return basics.MicroAlgos{Raw: uint64(rnd) * 1_000_000}, nil @@ -948,8 +959,8 @@ func (ledger *evalTestLedger) nextBlock(t testing.TB) *BlockEvaluator { } // endBlock completes the block being created, returns the ValidatedBlock for inspection -func (ledger *evalTestLedger) endBlock(t testing.TB, eval *BlockEvaluator) *ledgercore.ValidatedBlock { - unfinishedBlock, err := eval.GenerateBlock(nil) +func (ledger *evalTestLedger) endBlock(t testing.TB, eval *BlockEvaluator, proposers ...basics.Address) *ledgercore.ValidatedBlock { + unfinishedBlock, err := eval.GenerateBlock(proposers) require.NoError(t, err) // fake agreement's setting of header fields so later validates work. seed := committee.Seed{} @@ -1025,6 +1036,10 @@ func (l *testCowBaseLedger) LookupAgreement(rnd basics.Round, addr basics.Addres return basics.OnlineAccountData{}, errors.New("not implemented") } +func (l *testCowBaseLedger) GetKnockOfflineCandidates(basics.Round, config.ConsensusParams) (map[basics.Address]basics.OnlineAccountData, error) { + return nil, errors.New("not implemented") +} + func (l *testCowBaseLedger) OnlineCirculation(rnd, voteRnd basics.Round) (basics.MicroAlgos, error) { return basics.MicroAlgos{}, errors.New("not implemented") } @@ -1099,7 +1114,7 @@ func TestEvalFunctionForExpiredAccounts(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() - genesisInitState, addrs, keys := ledgertesting.GenesisWithProto(10, protocol.ConsensusFuture) + genesisInitState, addrs, _ := ledgertesting.GenesisWithProto(10, protocol.ConsensusFuture) sendAddr := addrs[0] recvAddr := addrs[1] @@ -1145,11 +1160,12 @@ func TestEvalFunctionForExpiredAccounts(t *testing.T) { // Advance the evaluator a couple rounds, watching for lack of expiration for i := uint64(0); i < uint64(targetRound); i++ { - vb := l.endBlock(t, blkEval) + vb := l.endBlock(t, blkEval, recvAddr) blkEval = l.nextBlock(t) + //require.Empty(t, vb.Block().ExpiredParticipationAccounts) for _, acct := range vb.Block().ExpiredParticipationAccounts { if acct == recvAddr { - // won't happen, because recvAddr didn't appear in block + // won't happen, because recvAddr was proposer require.Fail(t, "premature expiration") } } @@ -1157,26 +1173,6 @@ func TestEvalFunctionForExpiredAccounts(t *testing.T) { require.Greater(t, uint64(blkEval.Round()), uint64(recvAddrLastValidRound)) - genHash := l.GenesisHash() - txn := transactions.Transaction{ - Type: protocol.PaymentTx, - Header: transactions.Header{ - Sender: sendAddr, - Fee: minFee, - FirstValid: newBlock.Round(), - LastValid: blkEval.Round(), - GenesisHash: genHash, - }, - PaymentTxnFields: transactions.PaymentTxnFields{ - Receiver: recvAddr, - Amount: basics.MicroAlgos{Raw: 100}, - }, - } - - st := txn.Sign(keys[0]) - err = blkEval.Transaction(st, transactions.ApplyData{}) - require.NoError(t, err) - // Make sure we validate our block as well blkEval.validate = true @@ -1251,7 +1247,7 @@ func TestExpiredAccountGenerationWithDiskFailure(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() - genesisInitState, addrs, keys := ledgertesting.GenesisWithProto(10, protocol.ConsensusFuture) + genesisInitState, addrs, _ := ledgertesting.GenesisWithProto(10, protocol.ConsensusFuture) sendAddr := addrs[0] recvAddr := addrs[1] @@ -1297,26 +1293,6 @@ func TestExpiredAccountGenerationWithDiskFailure(t *testing.T) { eval = l.nextBlock(t) } - genHash := l.GenesisHash() - txn := transactions.Transaction{ - Type: protocol.PaymentTx, - Header: transactions.Header{ - Sender: sendAddr, - Fee: minFee, - FirstValid: newBlock.Round(), - LastValid: eval.Round(), - GenesisHash: genHash, - }, - PaymentTxnFields: transactions.PaymentTxnFields{ - Receiver: recvAddr, - Amount: basics.MicroAlgos{Raw: 100}, - }, - } - - st := txn.Sign(keys[0]) - err = eval.Transaction(st, transactions.ApplyData{}) - require.NoError(t, err) - eval.validate = true eval.generate = false @@ -1356,17 +1332,34 @@ func TestAbsenteeChecks(t *testing.T) { crypto.RandBytes(tmp.VoteID[:]) tmp.VoteFirstValid = 1 tmp.VoteLastValid = 1500 // large enough to avoid EXPIRATION, so we can see SUSPENSION - tmp.LastHeartbeat = 1 // non-zero allows suspensions switch i { case 1: - tmp.LastHeartbeat = 1150 // lie here so that addr[1] won't be suspended + tmp.LastHeartbeat = 1 // we want addrs[1] to be suspended earlier than others case 2: - tmp.LastProposed = 1150 // lie here so that addr[2] won't be suspended + tmp.LastProposed = 1 // we want addrs[2] to be suspended earlier than others + case 3: + tmp.LastProposed = 1 // we want addrs[3] to be a proposer, and never suspend itself + default: + if i < 10 { // make the other 8 genesis wallets unsuspendable + if i%2 == 0 { + tmp.LastProposed = 1200 + } else { + tmp.LastHeartbeat = 1200 + } + } else { + // ensure non-zero balance for new accounts, but a small balance + // so they will not be absent, just challenged. + tmp.MicroAlgos = basics.MicroAlgos{Raw: 1_000_000} + tmp.LastHeartbeat = 1 // non-zero allows suspensions + } } genesisInitState.Accounts[addr] = tmp } + // pretend this node is participating on behalf of addrs[3] and addrs[4] + proposers := []basics.Address{addrs[3], addrs[4]} + l := newTestLedger(t, bookkeeping.GenesisBalances{ Balances: genesisInitState.Accounts, FeeSink: testSinkAddr, @@ -1378,15 +1371,20 @@ func TestAbsenteeChecks(t *testing.T) { blkEval, err := l.StartEvaluator(newBlock.BlockHeader, 0, 0, nil) require.NoError(t, err) - // Advance the evaluator, watching for lack of suspensions since we don't - // suspend until a txn with a suspendable account appears + // Advance the evaluator, watching for suspensions as they appear challenge := byte(0) - for i := uint64(0); i < uint64(1210); i++ { // A bit past one grace period (200) past challenge at 1000. - vb := l.endBlock(t, blkEval) + for i := uint64(0); i < uint64(1200); i++ { // Just before first suspension at 1171 + vb := l.endBlock(t, blkEval, proposers...) blkEval = l.nextBlock(t) - require.Zero(t, vb.Block().AbsentParticipationAccounts) - if vb.Block().Round() == 1000 { + + switch vb.Block().Round() { + case 102: // 2 out of 10 genesis accounts are now absent + require.Contains(t, vb.Block().AbsentParticipationAccounts, addrs[1]) + require.Contains(t, vb.Block().AbsentParticipationAccounts, addrs[2]) + case 1000: challenge = vb.Block().BlockHeader.Seed[0] + default: + require.Zero(t, vb.Block().AbsentParticipationAccounts, "round %v", vb.Block().Round()) } } challenged := basics.Address{(challenge >> 3) << 3, 0xaa} @@ -1422,26 +1420,32 @@ func TestAbsenteeChecks(t *testing.T) { // Make sure we validate our block as well blkEval.validate = true - unfinishedBlock, err := blkEval.GenerateBlock(nil) + unfinishedBlock, err := blkEval.GenerateBlock(proposers) require.NoError(t, err) // fake agreement's setting of header fields so later validates work validatedBlock := ledgercore.MakeValidatedBlock(unfinishedBlock.UnfinishedBlock().WithProposer(committee.Seed{}, testPoolAddr, true), unfinishedBlock.UnfinishedDeltas()) - require.Zero(t, validatedBlock.Block().ExpiredParticipationAccounts) - require.Contains(t, validatedBlock.Block().AbsentParticipationAccounts, addrs[0], addrs[0].String()) - require.NotContains(t, validatedBlock.Block().AbsentParticipationAccounts, addrs[1], addrs[1].String()) - require.NotContains(t, validatedBlock.Block().AbsentParticipationAccounts, addrs[2], addrs[2].String()) + require.Equal(t, basics.Round(1201), validatedBlock.Block().Round()) + require.Empty(t, validatedBlock.Block().ExpiredParticipationAccounts) // Of the 32 extra accounts, make sure only the one matching the challenge is suspended + require.Len(t, validatedBlock.Block().AbsentParticipationAccounts, 1) require.Contains(t, validatedBlock.Block().AbsentParticipationAccounts, challenged, challenged.String()) + foundChallenged := false for i := byte(0); i < 32; i++ { if i == challenge>>3 { + rnd := validatedBlock.Block().Round() + ad := basics.Address{i << 3, 0xaa} + t.Logf("extra account %d %s is challenged, balance rnd %d %d", i, ad, + rnd, l.roundBalances[rnd][ad].MicroAlgos.Raw) require.Equal(t, basics.Address{i << 3, 0xaa}, challenged) + foundChallenged = true continue } require.NotContains(t, validatedBlock.Block().AbsentParticipationAccounts, basics.Address{i << 3, 0xaa}) } + require.True(t, foundChallenged) _, err = Eval(context.Background(), l, validatedBlock.Block(), false, nil, nil, l.tracer) require.NoError(t, err) @@ -1459,7 +1463,7 @@ func TestAbsenteeChecks(t *testing.T) { // Introduce an address that shouldn't be suspended badBlock := goodBlock - badBlock.AbsentParticipationAccounts = append(badBlock.AbsentParticipationAccounts, addrs[1]) + badBlock.AbsentParticipationAccounts = append(badBlock.AbsentParticipationAccounts, addrs[9]) _, err = Eval(context.Background(), l, badBlock, true, verify.GetMockedCache(true), nil, l.tracer) require.ErrorContains(t, err, "not absent") @@ -1497,16 +1501,21 @@ func TestExpiredAccountGeneration(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() - genesisInitState, addrs, keys := ledgertesting.GenesisWithProto(10, protocol.ConsensusFuture) + genesisInitState, addrs, _ := ledgertesting.GenesisWithProto(10, protocol.ConsensusFuture) sendAddr := addrs[0] recvAddr := addrs[1] + propAddr := addrs[2] + otherPropAddr := addrs[3] // not expiring, but part of proposer addresses passed to GenerateBlock - // the last round that the recvAddr is valid for - recvAddrLastValidRound := basics.Round(2) + // pretend this node is participating on behalf of addrs[2] and addrs[3] + proposers := []basics.Address{propAddr, otherPropAddr} + + // the last round that the recvAddr and propAddr are valid for + testAddrLastValidRound := basics.Round(2) // the target round we want to advance the evaluator to - targetRound := basics.Round(4) + targetRound := basics.Round(2) // Set all to online except the sending address for _, addr := range addrs { @@ -1527,11 +1536,11 @@ func TestExpiredAccountGeneration(t *testing.T) { genesisInitState.Accounts[addr] = tmp } - // Choose recvAddr to have a last valid round less than genesis block round - { - tmp := genesisInitState.Accounts[recvAddr] - tmp.VoteLastValid = recvAddrLastValidRound - genesisInitState.Accounts[recvAddr] = tmp + // Choose recvAddr and propAddr to have a last valid round less than genesis block round + for _, addr := range []basics.Address{recvAddr, propAddr} { + tmp := genesisInitState.Accounts[addr] + tmp.VoteLastValid = testAddrLastValidRound + genesisInitState.Accounts[addr] = tmp } l := newTestLedger(t, bookkeeping.GenesisBalances{ @@ -1548,36 +1557,18 @@ func TestExpiredAccountGeneration(t *testing.T) { // Advance the evaluator a couple rounds... for i := uint64(0); i < uint64(targetRound); i++ { - l.endBlock(t, eval) + vb := l.endBlock(t, eval) eval = l.nextBlock(t) + require.Empty(t, vb.Block().ExpiredParticipationAccounts) } - require.Greater(t, uint64(eval.Round()), uint64(recvAddrLastValidRound)) - - genHash := l.GenesisHash() - txn := transactions.Transaction{ - Type: protocol.PaymentTx, - Header: transactions.Header{ - Sender: sendAddr, - Fee: minFee, - FirstValid: newBlock.Round(), - LastValid: eval.Round(), - GenesisHash: genHash, - }, - PaymentTxnFields: transactions.PaymentTxnFields{ - Receiver: recvAddr, - Amount: basics.MicroAlgos{Raw: 100}, - }, - } - - st := txn.Sign(keys[0]) - err = eval.Transaction(st, transactions.ApplyData{}) - require.NoError(t, err) + require.Greater(t, uint64(eval.Round()), uint64(testAddrLastValidRound)) // Make sure we validate our block as well eval.validate = true - unfinishedBlock, err := eval.GenerateBlock(nil) + // GenerateBlock will not mark its own proposer addresses as expired + unfinishedBlock, err := eval.GenerateBlock(proposers) require.NoError(t, err) listOfExpiredAccounts := unfinishedBlock.UnfinishedBlock().ParticipationUpdates.ExpiredParticipationAccounts @@ -1594,6 +1585,17 @@ func TestExpiredAccountGeneration(t *testing.T) { require.Zero(t, recvAcct.VoteID) require.Zero(t, recvAcct.SelectionID) require.Zero(t, recvAcct.StateProofID) + + // propAddr not marked expired + propAcct, err := eval.state.lookup(propAddr) + require.NoError(t, err) + require.Equal(t, basics.Online, propAcct.Status) + require.NotZero(t, propAcct.VoteFirstValid) + require.NotZero(t, propAcct.VoteLastValid) + require.NotZero(t, propAcct.VoteKeyDilution) + require.NotZero(t, propAcct.VoteID) + require.NotZero(t, propAcct.SelectionID) + require.NotZero(t, propAcct.StateProofID) } func TestBitsMatch(t *testing.T) { diff --git a/ledger/eval/prefetcher/prefetcher_alignment_test.go b/ledger/eval/prefetcher/prefetcher_alignment_test.go index 734d84a661..5f61f4938f 100644 --- a/ledger/eval/prefetcher/prefetcher_alignment_test.go +++ b/ledger/eval/prefetcher/prefetcher_alignment_test.go @@ -119,6 +119,7 @@ func (l *prefetcherAlignmentTestLedger) LookupWithoutRewards(_ basics.Round, add } return ledgercore.AccountData{}, 0, nil } + func (l *prefetcherAlignmentTestLedger) LookupAgreement(_ basics.Round, addr basics.Address) (basics.OnlineAccountData, error) { // prefetch alignment tests do not check for prefetching of online account data // because it's quite different and can only occur in AVM opcodes, which @@ -126,9 +127,15 @@ func (l *prefetcherAlignmentTestLedger) LookupAgreement(_ basics.Round, addr bas // will be accessed in AVM.) return basics.OnlineAccountData{}, errors.New("not implemented") } + func (l *prefetcherAlignmentTestLedger) OnlineCirculation(rnd, voteRnd basics.Round) (basics.MicroAlgos, error) { return basics.MicroAlgos{}, errors.New("not implemented") } + +func (l *prefetcherAlignmentTestLedger) GetKnockOfflineCandidates(basics.Round, config.ConsensusParams) (map[basics.Address]basics.OnlineAccountData, error) { + return nil, errors.New("not implemented") +} + func (l *prefetcherAlignmentTestLedger) LookupApplication(rnd basics.Round, addr basics.Address, aidx basics.AppIndex) (ledgercore.AppResource, error) { l.mu.Lock() if l.requestedApps == nil { @@ -144,6 +151,7 @@ func (l *prefetcherAlignmentTestLedger) LookupApplication(rnd basics.Round, addr return l.apps[addr][aidx], nil } + func (l *prefetcherAlignmentTestLedger) LookupAsset(rnd basics.Round, addr basics.Address, aidx basics.AssetIndex) (ledgercore.AssetResource, error) { l.mu.Lock() if l.requestedAssets == nil { @@ -159,9 +167,11 @@ func (l *prefetcherAlignmentTestLedger) LookupAsset(rnd basics.Round, addr basic return l.assets[addr][aidx], nil } + func (l *prefetcherAlignmentTestLedger) LookupKv(rnd basics.Round, key string) ([]byte, error) { panic("not implemented") } + func (l *prefetcherAlignmentTestLedger) GetCreatorForRound(_ basics.Round, cidx basics.CreatableIndex, ctype basics.CreatableType) (basics.Address, bool, error) { l.mu.Lock() if l.requestedCreators == nil { @@ -175,6 +185,7 @@ func (l *prefetcherAlignmentTestLedger) GetCreatorForRound(_ basics.Round, cidx } return basics.Address{}, false, nil } + func (l *prefetcherAlignmentTestLedger) GenesisHash() crypto.Digest { return crypto.Digest{} } diff --git a/ledger/eval_simple_test.go b/ledger/eval_simple_test.go index 972821c26c..b304377fad 100644 --- a/ledger/eval_simple_test.go +++ b/ledger/eval_simple_test.go @@ -412,11 +412,52 @@ func TestAbsentTracking(t *testing.T) { int 0; voter_params_get VoterIncentiveEligible; itob; log; itob; log; int 1` + addrIndexes := make(map[basics.Address]int) + for i, addr := range addrs { + addrIndexes[addr] = i + } + prettyAddrs := func(inAddrs []basics.Address) []string { + ret := make([]string, len(inAddrs)) + for i, addr := range inAddrs { + if idx, ok := addrIndexes[addr]; ok { + ret[i] = fmt.Sprintf("addrs[%d]", idx) + } else { + ret[i] = addr.String() + } + } + return ret + } + + printAbsent := func(vb *ledgercore.ValidatedBlock) { + t.Helper() + absent := vb.Block().AbsentParticipationAccounts + expired := vb.Block().ExpiredParticipationAccounts + if len(expired) > 0 || len(absent) > 0 { + t.Logf("rnd %d: expired %d, absent %d (exp %v abs %v)", vb.Block().Round(), + len(expired), len(absent), prettyAddrs(expired), prettyAddrs(absent)) + } + } + checkingBegins := 40 - ledgertesting.TestConsensusRange(t, checkingBegins, 0, func(t *testing.T, ver int, cv protocol.ConsensusVersion, cfg config.Local) { + runTest := func(t *testing.T, cv protocol.ConsensusVersion, cfg config.Local, beforeSPInterval bool) { dl := NewDoubleLedger(t, genBalances, cv, cfg) defer dl.Close() + const spIntervalRounds = 240 + + baseRound := basics.Round(0) + if !beforeSPInterval { + var vb *ledgercore.ValidatedBlock + for i := 0; i < spIntervalRounds; i++ { // run up to block 240 (state proof interval) + vb = dl.fullBlock() + printAbsent(vb) + require.Empty(t, vb.Block().AbsentParticipationAccounts) + require.Empty(t, vb.Block().ExpiredParticipationAccounts) + } + require.Equal(t, basics.Round(spIntervalRounds), vb.Block().Round()) + baseRound += spIntervalRounds + } + // we use stakeChecker for testing `voter_params_get` on suspended accounts stib := dl.txn(&txntest.Txn{ // #1 Type: "appl", @@ -456,13 +497,14 @@ func TestAbsentTracking(t *testing.T) { // have addrs[1] go online explicitly, which makes it eligible for suspension. // use a large fee, so we can see IncentiveEligible change - dl.txn(&txntest.Txn{ // #2 + vb := dl.fullBlock(&txntest.Txn{ // #2 Type: "keyreg", Fee: 10_000_000, Sender: addrs[1], VotePK: [32]byte{1}, SelectionPK: [32]byte{1}, }) + require.Equal(t, baseRound+basics.Round(2), vb.Block().Round()) // as configured above, only the first two accounts should be online require.True(t, lookup(t, dl.generator, addrs[0]).Status == basics.Online) @@ -480,7 +522,8 @@ func TestAbsentTracking(t *testing.T) { require.True(t, lookup(t, dl.generator, addrs[1]).IncentiveEligible) require.False(t, lookup(t, dl.generator, addrs[2]).IncentiveEligible) - vb := dl.fullBlock() // #6 + vb = dl.fullBlock() // #6 + printAbsent(vb) totals, err := dl.generator.Totals(vb.Block().Round()) require.NoError(t, err) require.NotZero(t, totals.Online.Money.Raw) @@ -494,7 +537,7 @@ func TestAbsentTracking(t *testing.T) { Receiver: addrs[2], Amount: 100_000, }) - dl.endBlock(proposer) // #7 + printAbsent(dl.endBlock(proposer)) // #7 prp := lookup(t, dl.validator, proposer) require.Equal(t, prp.LastProposed, dl.validator.Latest()) @@ -508,7 +551,7 @@ func TestAbsentTracking(t *testing.T) { require.Equal(t, totals.Online.Money.Raw-100_000-1000, newtotals.Online.Money.Raw) totals = newtotals - dl.fullBlock() + printAbsent(dl.fullBlock()) // addrs[2] was already offline dl.txns(&txntest.Txn{Type: "keyreg", Sender: addrs[2]}) // OFFLINE keyreg #9 @@ -524,12 +567,13 @@ func TestAbsentTracking(t *testing.T) { require.Zero(t, regger.LastHeartbeat) // ONLINE keyreg without extra fee - dl.txns(&txntest.Txn{ + vb = dl.fullBlock(&txntest.Txn{ Type: "keyreg", Sender: addrs[2], VotePK: [32]byte{1}, SelectionPK: [32]byte{1}, }) // #10 + printAbsent(vb) // online totals have grown, addr[2] was added newtotals, err = dl.generator.Totals(dl.generator.Latest()) require.NoError(t, err) @@ -555,14 +599,15 @@ func TestAbsentTracking(t *testing.T) { VotePK: [32]byte{1}, SelectionPK: [32]byte{1}, }) // #14 + printAbsent(vb) twoEligible := vb.Block().Round() - require.EqualValues(t, 14, twoEligible) // sanity check + require.EqualValues(t, baseRound+14, twoEligible) // sanity check regger = lookup(t, dl.validator, addrs[2]) require.True(t, regger.IncentiveEligible) for i := 0; i < 5; i++ { - dl.fullBlock() // #15-19 + printAbsent(dl.fullBlock()) // #15-19 require.True(t, lookup(t, dl.generator, addrs[0]).Status == basics.Online) require.True(t, lookup(t, dl.generator, addrs[1]).Status == basics.Online) require.True(t, lookup(t, dl.generator, addrs[2]).Status == basics.Online) @@ -574,7 +619,7 @@ func TestAbsentTracking(t *testing.T) { require.True(t, lookup(t, dl.generator, addrs[2]).Status == basics.Online) for i := 0; i < 30; i++ { - dl.fullBlock() // #20-49 + printAbsent(dl.fullBlock()) // #20-49 } // addrs 0-2 all have about 1/3 of stake, so seemingly (see next block @@ -582,7 +627,11 @@ func TestAbsentTracking(t *testing.T) { // about 35. But, since blocks are empty, nobody's suspendible account // is noticed. require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[0]).Status) - require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[1]).Status) + if beforeSPInterval { + require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[1]).Status) + } else { + require.Equal(t, basics.Offline, lookup(t, dl.generator, addrs[1]).Status) + } require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[2]).Status) require.True(t, lookup(t, dl.generator, addrs[2]).IncentiveEligible) @@ -594,21 +643,26 @@ func TestAbsentTracking(t *testing.T) { Receiver: addrs[0], Amount: 0, }) // #50 + printAbsent(vb) require.Equal(t, vb.Block().AbsentParticipationAccounts, []basics.Address{addrs[2]}) twoPaysZero := vb.Block().Round() - require.EqualValues(t, 50, twoPaysZero) + require.EqualValues(t, baseRound+50, twoPaysZero) // addr[0] has never proposed or heartbeat so it is not considered absent require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[0]).Status) // addr[1] still hasn't been "noticed" - require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[1]).Status) + if beforeSPInterval { + require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[1]).Status) + } else { + require.Equal(t, basics.Offline, lookup(t, dl.generator, addrs[1]).Status) + } require.Equal(t, basics.Offline, lookup(t, dl.generator, addrs[2]).Status) require.False(t, lookup(t, dl.generator, addrs[2]).IncentiveEligible) // separate the payments by a few blocks so it will be easier to test // when the changes go into effect for i := 0; i < 4; i++ { - dl.fullBlock() // #51-54 + printAbsent(dl.fullBlock()) // #51-54 } // now, when 2 pays 1, 1 gets suspended (unlike 0, we had 1 keyreg early on, so LastHeartbeat>0) vb = dl.fullBlock(&txntest.Txn{ @@ -617,9 +671,14 @@ func TestAbsentTracking(t *testing.T) { Receiver: addrs[1], Amount: 0, }) // #55 + printAbsent(vb) twoPaysOne := vb.Block().Round() - require.EqualValues(t, 55, twoPaysOne) - require.Equal(t, vb.Block().AbsentParticipationAccounts, []basics.Address{addrs[1]}) + require.EqualValues(t, baseRound+55, twoPaysOne) + if beforeSPInterval { + require.Equal(t, vb.Block().AbsentParticipationAccounts, []basics.Address{addrs[1]}) + } else { + require.Empty(t, vb.Block().AbsentParticipationAccounts) + } require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[0]).Status) require.Equal(t, basics.Offline, lookup(t, dl.generator, addrs[1]).Status) require.False(t, lookup(t, dl.generator, addrs[1]).IncentiveEligible) @@ -628,7 +687,7 @@ func TestAbsentTracking(t *testing.T) { // now, addrs[2] proposes, so it gets back online, but stays ineligible dl.proposer = addrs[2] - dl.fullBlock() + printAbsent(dl.fullBlock()) require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[2]).Status) require.False(t, lookup(t, dl.generator, addrs[2]).IncentiveEligible) @@ -651,7 +710,11 @@ func TestAbsentTracking(t *testing.T) { // in second block, the checkstate app was created checkState(addrs[1], true, false, 833_333_333_333_333) // 322 // addr[1] spent 10A on a fee in rnd 3, so online stake and eligibility adjusted in 323 - checkState(addrs[1], true, true, 833_333_323_333_333) // 323 + if beforeSPInterval { + checkState(addrs[1], true, true, 833_333_323_333_333) // 323 + } else { + checkState(addrs[1], true, false, 833_333_333_333_333) // 323 + } for rnd := dl.fullBlock().Block().Round(); rnd < 320+twoEligible-1; rnd = dl.fullBlock().Block().Round() { } @@ -671,10 +734,23 @@ func TestAbsentTracking(t *testing.T) { // after doing a keyreg, became susceptible to suspension for rnd := dl.fullBlock().Block().Round(); rnd < 320+twoPaysOne-1; rnd = dl.fullBlock().Block().Round() { } - checkState(addrs[1], true, true, 833_333_323_230_333) // still online, balance irrelevant + if beforeSPInterval { + checkState(addrs[1], true, true, 833_333_323_230_333) // still online, balance irrelevant + } else { + checkState(addrs[1], false, false, 0) // suspended already + } // 1 was noticed & suspended after being paid by 2, so eligible and amount go to 0 checkState(addrs[1], false, false, 0) - }) + } + + testBeforeAndAfterSPInterval := func(t *testing.T, ver int, cv protocol.ConsensusVersion, cfg config.Local) { + // before the first state proof interval (240 rounds), no cached voters data is available, so only accounts + // noticed in blocks will be suspended. + t.Run("beforeSPInterval", func(t *testing.T) { runTest(t, cv, cfg, false) }) + t.Run("afterSPInterval", func(t *testing.T) { runTest(t, cv, cfg, true) }) + } + + ledgertesting.TestConsensusRange(t, checkingBegins, 0, testBeforeAndAfterSPInterval) } // TestAbsenteeChallenges ensures that online accounts that don't (do) respond @@ -736,71 +812,65 @@ func TestAbsenteeChallenges(t *testing.T) { dl.beginBlock() dl.endBlock(seedAndProp) // This becomes the seed, which is used for the challenge - for vb := dl.fullBlock(); vb.Block().Round() < 1200; vb = dl.fullBlock() { - // advance through first grace period - } - dl.beginBlock() - dl.endBlock(propguy) // propose, which is a fine (though less likely) way to respond - - // All still online, unchanged eligibility - for _, guy := range []basics.Address{propguy, regguy, badguy} { - acct := lookup(t, dl.generator, guy) - require.Equal(t, basics.Online, acct.Status) - require.Equal(t, ver >= checkingBegins, acct.IncentiveEligible, guy) + for vb := dl.fullBlock(); vb.Block().Round() < 1199; vb = dl.fullBlock() { + // advance through first grace period: no one marked absent + require.Empty(t, vb.Block().AbsentParticipationAccounts) } - for vb := dl.fullBlock(); vb.Block().Round() < 1220; vb = dl.fullBlock() { - // advance into knockoff period. but no transactions means - // unresponsive accounts go unnoticed. - } - // All still online, same eligibility - for _, guy := range []basics.Address{propguy, regguy, badguy} { - acct := lookup(t, dl.generator, guy) - require.Equal(t, basics.Online, acct.Status) - require.Equal(t, ver >= checkingBegins, acct.IncentiveEligible, guy) - } - - // badguy never responded, he gets knocked off when paid - vb := dl.fullBlock(&txntest.Txn{ - Type: "pay", - Sender: addrs[0], - Receiver: badguy, - }) - if ver >= checkingBegins { - require.Equal(t, vb.Block().AbsentParticipationAccounts, []basics.Address{badguy}) - } - acct := lookup(t, dl.generator, badguy) - require.Equal(t, ver >= checkingBegins, basics.Offline == acct.Status) // if checking, badguy fails - require.False(t, acct.IncentiveEligible) - - // propguy proposed during the grace period, he stays on even when paid - dl.txns(&txntest.Txn{ - Type: "pay", - Sender: addrs[0], - Receiver: propguy, - }) - acct = lookup(t, dl.generator, propguy) - require.Equal(t, basics.Online, acct.Status) - require.Equal(t, ver >= checkingBegins, acct.IncentiveEligible) - // regguy keyregs before he's caught, which is a heartbeat, he stays on as well - dl.txns(&txntest.Txn{ + vb := dl.fullBlock(&txntest.Txn{ Type: "keyreg", // Does not pay extra fee, since he's still eligible Sender: regguy, VotePK: [32]byte{1}, SelectionPK: [32]byte{1}, }) - acct = lookup(t, dl.generator, regguy) - require.Equal(t, basics.Online, acct.Status) - require.Equal(t, ver >= checkingBegins, acct.IncentiveEligible) - dl.txns(&txntest.Txn{ - Type: "pay", - Sender: addrs[0], - Receiver: regguy, - }) - acct = lookup(t, dl.generator, regguy) + require.Equal(t, basics.Round(1200), vb.Block().Round()) + require.Empty(t, vb.Block().AbsentParticipationAccounts) + acct := lookup(t, dl.generator, regguy) require.Equal(t, basics.Online, acct.Status) require.Equal(t, ver >= checkingBegins, acct.IncentiveEligible) + + dl.beginBlock() + vb = dl.endBlock(propguy) // propose, which is a fine (though less likely) way to respond + + // propguy could be suspended in 1201 here, but won't, because they are proposer + require.Equal(t, basics.Round(1201), vb.Block().Round()) + + require.NotContains(t, vb.Block().AbsentParticipationAccounts, []basics.Address{propguy}) + require.NotContains(t, vb.Block().AbsentParticipationAccounts, regguy) + if ver >= checkingBegins { + // badguy and regguy will both be suspended in 1201 + require.Contains(t, vb.Block().AbsentParticipationAccounts, badguy) + } + + // propguy & regguy still online, badguy suspended (depending on consensus version) + for _, guy := range []basics.Address{propguy, regguy, badguy} { + acct := lookup(t, dl.generator, guy) + switch guy { + case propguy, regguy: + require.Equal(t, basics.Online, acct.Status) + require.Equal(t, ver >= checkingBegins, acct.IncentiveEligible) + require.False(t, acct.VoteID.IsEmpty()) + case badguy: + // if checking, badguy fails + require.Equal(t, ver >= checkingBegins, basics.Offline == acct.Status) + require.False(t, acct.IncentiveEligible) + } + // whether suspended or online, all still have VoteID + require.False(t, acct.VoteID.IsEmpty()) + } + + if ver < checkingBegins { + for vb := dl.fullBlock(); vb.Block().Round() < 1220; vb = dl.fullBlock() { + // advance into knockoff period. + } + // All still online, same eligibility + for _, guy := range []basics.Address{propguy, regguy, badguy} { + acct := lookup(t, dl.generator, guy) + require.Equal(t, basics.Online, acct.Status) + require.False(t, acct.IncentiveEligible) + } + } }) } diff --git a/ledger/ledger.go b/ledger/ledger.go index 2f10724fee..c45dcdedf9 100644 --- a/ledger/ledger.go +++ b/ledger/ledger.go @@ -638,10 +638,39 @@ func (l *Ledger) LookupAgreement(rnd basics.Round, addr basics.Address) (basics. defer l.trackerMu.RUnlock() // Intentionally apply (pending) rewards up to rnd. - data, err := l.acctsOnline.LookupOnlineAccountData(rnd, addr) + data, err := l.acctsOnline.lookupOnlineAccountData(rnd, addr) return data, err } +// GetKnockOfflineCandidates retrieves a list of online accounts who will be +// checked to a recent proposal or heartbeat. Large accounts are the ones worth checking. +func (l *Ledger) GetKnockOfflineCandidates(rnd basics.Round, proto config.ConsensusParams) (map[basics.Address]basics.OnlineAccountData, error) { + l.trackerMu.RLock() + defer l.trackerMu.RUnlock() + + // get state proof worker's most recent list for top N addresses + if proto.StateProofInterval == 0 { + return nil, nil + } + // get latest state proof voters information, up to rnd, without calling cond.Wait() + _, voters := l.acctsOnline.voters.LatestCompletedVotersUpTo(rnd) + if voters == nil { // no cached voters found < rnd + return nil, nil + } + + // fetch fresh data up to this round from online account cache. These accounts should all + // be in cache, as long as proto.StateProofTopVoters < onlineAccountsCacheMaxSize. + ret := make(map[basics.Address]basics.OnlineAccountData) + for addr := range voters.AddrToPos { + data, err := l.acctsOnline.lookupOnlineAccountData(rnd, addr) + if err != nil { + continue // skip missing / not online accounts + } + ret[addr] = data + } + return ret, nil +} + // LookupWithoutRewards is like Lookup but does not apply pending rewards up // to the requested round rnd. func (l *Ledger) LookupWithoutRewards(rnd basics.Round, addr basics.Address) (ledgercore.AccountData, basics.Round, error) { diff --git a/ledger/ledgercore/accountdata.go b/ledger/ledgercore/accountdata.go index 081fbffde6..5b17730122 100644 --- a/ledger/ledgercore/accountdata.go +++ b/ledger/ledgercore/accountdata.go @@ -187,6 +187,8 @@ func (u AccountData) OnlineAccountData(proto config.ConsensusParams, rewardsLeve MicroAlgosWithRewards: microAlgos, VotingData: u.VotingData, IncentiveEligible: u.IncentiveEligible, + LastProposed: u.LastProposed, + LastHeartbeat: u.LastHeartbeat, } } diff --git a/ledger/ledgercore/onlineacct.go b/ledger/ledgercore/onlineacct.go index 8a6b771aad..f5b29c789e 100644 --- a/ledger/ledgercore/onlineacct.go +++ b/ledger/ledgercore/onlineacct.go @@ -22,7 +22,7 @@ import ( ) // An OnlineAccount corresponds to an account whose AccountData.Status -// is Online. This is used for a Merkle tree commitment of online +// is Online. This is used for a Merkle tree commitment of online // accounts, which is subsequently used to validate participants for // a state proof. type OnlineAccount struct { diff --git a/ledger/ledgercore/votersForRound.go b/ledger/ledgercore/votersForRound.go index 7ab103dcd1..957ec08a52 100644 --- a/ledger/ledgercore/votersForRound.go +++ b/ledger/ledgercore/votersForRound.go @@ -183,3 +183,11 @@ func (tr *VotersForRound) Wait() error { } return nil } + +// Completed returns true if the tree has finished being constructed. +// If there was an error constructing the tree, the error is also returned. +func (tr *VotersForRound) Completed() (bool, error) { + tr.mu.Lock() + defer tr.mu.Unlock() + return tr.Tree != nil || tr.loadTreeError != nil, tr.loadTreeError +} diff --git a/ledger/onlineaccountscache_test.go b/ledger/onlineaccountscache_test.go index b64d18aabf..fa66d67a9f 100644 --- a/ledger/onlineaccountscache_test.go +++ b/ledger/onlineaccountscache_test.go @@ -189,6 +189,15 @@ func TestOnlineAccountsCacheMaxEntries(t *testing.T) { require.Equal(t, 2, oac.accounts[addr].Len()) } +// TestOnlineAccountsCacheSizeBiggerThanStateProofTopVoters asserts that the online accounts cache +// is bigger than the number of top online accounts tracked by the state proof system. +func TestOnlineAccountsCacheSizeBiggerThanStateProofTopVoters(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + require.Greater(t, uint64(onlineAccountsCacheMaxSize), config.Consensus[protocol.ConsensusFuture].StateProofTopVoters) +} + var benchmarkOnlineAccountsCacheReadResult cachedOnlineAccount func benchmarkOnlineAccountsCacheRead(b *testing.B, historyLength int) { diff --git a/ledger/simple_test.go b/ledger/simple_test.go index 8af40eaaf3..0995f88ecc 100644 --- a/ledger/simple_test.go +++ b/ledger/simple_test.go @@ -146,10 +146,11 @@ func txgroup(t testing.TB, ledger *Ledger, eval *eval.BlockEvaluator, txns ...*t // inspection. Proposer is optional - if unset, blocks will be finished with // ZeroAddress proposer. func endBlock(t testing.TB, ledger *Ledger, eval *eval.BlockEvaluator, proposer ...basics.Address) *ledgercore.ValidatedBlock { - ub, err := eval.GenerateBlock(nil) + // pass proposers to GenerateBlock, if provided + ub, err := eval.GenerateBlock(proposer) require.NoError(t, err) - // We fake some thigns that agreement would do, like setting proposer + // We fake some things that agreement would do, like setting proposer validatedBlock := ledgercore.MakeValidatedBlock(ub.UnfinishedBlock(), ub.UnfinishedDeltas()) gvb := &validatedBlock diff --git a/ledger/store/trackerdb/data.go b/ledger/store/trackerdb/data.go index 8e69f2fc69..1649d1f82d 100644 --- a/ledger/store/trackerdb/data.go +++ b/ledger/store/trackerdb/data.go @@ -152,6 +152,8 @@ type BaseOnlineAccountData struct { BaseVotingData + LastProposed basics.Round `codec:"V"` + LastHeartbeat basics.Round `codec:"W"` IncentiveEligible bool `codec:"X"` MicroAlgos basics.MicroAlgos `codec:"Y"` RewardsBase uint64 `codec:"Z"` @@ -456,7 +458,10 @@ func (bo *BaseOnlineAccountData) IsVotingEmpty() bool { func (bo *BaseOnlineAccountData) IsEmpty() bool { return bo.IsVotingEmpty() && bo.MicroAlgos.Raw == 0 && - bo.RewardsBase == 0 && !bo.IncentiveEligible + bo.RewardsBase == 0 && + bo.LastHeartbeat == 0 && + bo.LastProposed == 0 && + !bo.IncentiveEligible } // GetOnlineAccount returns ledgercore.OnlineAccount for top online accounts / voters @@ -491,6 +496,8 @@ func (bo *BaseOnlineAccountData) GetOnlineAccountData(proto config.ConsensusPara VoteKeyDilution: bo.VoteKeyDilution, }, IncentiveEligible: bo.IncentiveEligible, + LastProposed: bo.LastProposed, + LastHeartbeat: bo.LastHeartbeat, } } @@ -507,6 +514,8 @@ func (bo *BaseOnlineAccountData) SetCoreAccountData(ad *ledgercore.AccountData) bo.MicroAlgos = ad.MicroAlgos bo.RewardsBase = ad.RewardsBase bo.IncentiveEligible = ad.IncentiveEligible + bo.LastProposed = ad.LastProposed + bo.LastHeartbeat = ad.LastHeartbeat } // MakeResourcesData returns a new empty instance of resourcesData. diff --git a/ledger/store/trackerdb/data_test.go b/ledger/store/trackerdb/data_test.go index edc0d0dc9e..b256fa4e76 100644 --- a/ledger/store/trackerdb/data_test.go +++ b/ledger/store/trackerdb/data_test.go @@ -1152,7 +1152,7 @@ func TestBaseOnlineAccountDataIsEmpty(t *testing.T) { structureTesting := func(t *testing.T) { encoding, err := json.Marshal(&empty) zeros32 := "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0" - expectedEncoding := `{"VoteID":[` + zeros32 + `],"SelectionID":[` + zeros32 + `],"VoteFirstValid":0,"VoteLastValid":0,"VoteKeyDilution":0,"StateProofID":[` + zeros32 + `,` + zeros32 + `],"IncentiveEligible":false,"MicroAlgos":{"Raw":0},"RewardsBase":0}` + expectedEncoding := `{"VoteID":[` + zeros32 + `],"SelectionID":[` + zeros32 + `],"VoteFirstValid":0,"VoteLastValid":0,"VoteKeyDilution":0,"StateProofID":[` + zeros32 + `,` + zeros32 + `],"LastProposed":0,"LastHeartbeat":0,"IncentiveEligible":false,"MicroAlgos":{"Raw":0},"RewardsBase":0}` require.NoError(t, err) require.Equal(t, expectedEncoding, string(encoding)) } @@ -1249,7 +1249,7 @@ func TestBaseOnlineAccountDataReflect(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() - require.Equal(t, 5, reflect.TypeOf(BaseOnlineAccountData{}).NumField(), "update all getters and setters for baseOnlineAccountData and change the field count") + require.Equal(t, 7, reflect.TypeOf(BaseOnlineAccountData{}).NumField(), "update all getters and setters for baseOnlineAccountData and change the field count") } func TestBaseVotingDataReflect(t *testing.T) { diff --git a/ledger/store/trackerdb/msgp_gen.go b/ledger/store/trackerdb/msgp_gen.go index 465248e93d..98f35bf519 100644 --- a/ledger/store/trackerdb/msgp_gen.go +++ b/ledger/store/trackerdb/msgp_gen.go @@ -749,8 +749,8 @@ func BaseAccountDataMaxSize() (s int) { func (z *BaseOnlineAccountData) MarshalMsg(b []byte) (o []byte) { o = msgp.Require(b, z.Msgsize()) // omitempty: check for empty values - zb0001Len := uint32(9) - var zb0001Mask uint16 /* 11 bits */ + zb0001Len := uint32(11) + var zb0001Mask uint16 /* 13 bits */ if (*z).BaseVotingData.VoteID.MsgIsZero() { zb0001Len-- zb0001Mask |= 0x1 @@ -775,18 +775,26 @@ func (z *BaseOnlineAccountData) MarshalMsg(b []byte) (o []byte) { zb0001Len-- zb0001Mask |= 0x20 } - if (*z).IncentiveEligible == false { + if (*z).LastProposed.MsgIsZero() { zb0001Len-- zb0001Mask |= 0x40 } - if (*z).MicroAlgos.MsgIsZero() { + if (*z).LastHeartbeat.MsgIsZero() { zb0001Len-- zb0001Mask |= 0x80 } - if (*z).RewardsBase == 0 { + if (*z).IncentiveEligible == false { zb0001Len-- zb0001Mask |= 0x100 } + if (*z).MicroAlgos.MsgIsZero() { + zb0001Len-- + zb0001Mask |= 0x200 + } + if (*z).RewardsBase == 0 { + zb0001Len-- + zb0001Mask |= 0x400 + } // variable map header, size zb0001Len o = append(o, 0x80|uint8(zb0001Len)) if zb0001Len != 0 { @@ -821,16 +829,26 @@ func (z *BaseOnlineAccountData) MarshalMsg(b []byte) (o []byte) { o = (*z).BaseVotingData.StateProofID.MarshalMsg(o) } if (zb0001Mask & 0x40) == 0 { // if not empty + // string "V" + o = append(o, 0xa1, 0x56) + o = (*z).LastProposed.MarshalMsg(o) + } + if (zb0001Mask & 0x80) == 0 { // if not empty + // string "W" + o = append(o, 0xa1, 0x57) + o = (*z).LastHeartbeat.MarshalMsg(o) + } + if (zb0001Mask & 0x100) == 0 { // if not empty // string "X" o = append(o, 0xa1, 0x58) o = msgp.AppendBool(o, (*z).IncentiveEligible) } - if (zb0001Mask & 0x80) == 0 { // if not empty + if (zb0001Mask & 0x200) == 0 { // if not empty // string "Y" o = append(o, 0xa1, 0x59) o = (*z).MicroAlgos.MarshalMsg(o) } - if (zb0001Mask & 0x100) == 0 { // if not empty + if (zb0001Mask & 0x400) == 0 { // if not empty // string "Z" o = append(o, 0xa1, 0x5a) o = msgp.AppendUint64(o, (*z).RewardsBase) @@ -910,6 +928,22 @@ func (z *BaseOnlineAccountData) UnmarshalMsgWithState(bts []byte, st msgp.Unmars return } } + if zb0001 > 0 { + zb0001-- + bts, err = (*z).LastProposed.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "LastProposed") + return + } + } + if zb0001 > 0 { + zb0001-- + bts, err = (*z).LastHeartbeat.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "LastHeartbeat") + return + } + } if zb0001 > 0 { zb0001-- (*z).IncentiveEligible, bts, err = msgp.ReadBoolBytes(bts) @@ -993,6 +1027,18 @@ func (z *BaseOnlineAccountData) UnmarshalMsgWithState(bts []byte, st msgp.Unmars err = msgp.WrapError(err, "StateProofID") return } + case "V": + bts, err = (*z).LastProposed.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "LastProposed") + return + } + case "W": + bts, err = (*z).LastHeartbeat.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "LastHeartbeat") + return + } case "X": (*z).IncentiveEligible, bts, err = msgp.ReadBoolBytes(bts) if err != nil { @@ -1034,18 +1080,18 @@ func (_ *BaseOnlineAccountData) CanUnmarshalMsg(z interface{}) bool { // Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message func (z *BaseOnlineAccountData) Msgsize() (s int) { - s = 1 + 2 + (*z).BaseVotingData.VoteID.Msgsize() + 2 + (*z).BaseVotingData.SelectionID.Msgsize() + 2 + (*z).BaseVotingData.VoteFirstValid.Msgsize() + 2 + (*z).BaseVotingData.VoteLastValid.Msgsize() + 2 + msgp.Uint64Size + 2 + (*z).BaseVotingData.StateProofID.Msgsize() + 2 + msgp.BoolSize + 2 + (*z).MicroAlgos.Msgsize() + 2 + msgp.Uint64Size + s = 1 + 2 + (*z).BaseVotingData.VoteID.Msgsize() + 2 + (*z).BaseVotingData.SelectionID.Msgsize() + 2 + (*z).BaseVotingData.VoteFirstValid.Msgsize() + 2 + (*z).BaseVotingData.VoteLastValid.Msgsize() + 2 + msgp.Uint64Size + 2 + (*z).BaseVotingData.StateProofID.Msgsize() + 2 + (*z).LastProposed.Msgsize() + 2 + (*z).LastHeartbeat.Msgsize() + 2 + msgp.BoolSize + 2 + (*z).MicroAlgos.Msgsize() + 2 + msgp.Uint64Size return } // MsgIsZero returns whether this is a zero value func (z *BaseOnlineAccountData) MsgIsZero() bool { - return ((*z).BaseVotingData.VoteID.MsgIsZero()) && ((*z).BaseVotingData.SelectionID.MsgIsZero()) && ((*z).BaseVotingData.VoteFirstValid.MsgIsZero()) && ((*z).BaseVotingData.VoteLastValid.MsgIsZero()) && ((*z).BaseVotingData.VoteKeyDilution == 0) && ((*z).BaseVotingData.StateProofID.MsgIsZero()) && ((*z).IncentiveEligible == false) && ((*z).MicroAlgos.MsgIsZero()) && ((*z).RewardsBase == 0) + return ((*z).BaseVotingData.VoteID.MsgIsZero()) && ((*z).BaseVotingData.SelectionID.MsgIsZero()) && ((*z).BaseVotingData.VoteFirstValid.MsgIsZero()) && ((*z).BaseVotingData.VoteLastValid.MsgIsZero()) && ((*z).BaseVotingData.VoteKeyDilution == 0) && ((*z).BaseVotingData.StateProofID.MsgIsZero()) && ((*z).LastProposed.MsgIsZero()) && ((*z).LastHeartbeat.MsgIsZero()) && ((*z).IncentiveEligible == false) && ((*z).MicroAlgos.MsgIsZero()) && ((*z).RewardsBase == 0) } // MaxSize returns a maximum valid message size for this message type func BaseOnlineAccountDataMaxSize() (s int) { - s = 1 + 2 + crypto.OneTimeSignatureVerifierMaxSize() + 2 + crypto.VRFVerifierMaxSize() + 2 + basics.RoundMaxSize() + 2 + basics.RoundMaxSize() + 2 + msgp.Uint64Size + 2 + merklesignature.CommitmentMaxSize() + 2 + msgp.BoolSize + 2 + basics.MicroAlgosMaxSize() + 2 + msgp.Uint64Size + s = 1 + 2 + crypto.OneTimeSignatureVerifierMaxSize() + 2 + crypto.VRFVerifierMaxSize() + 2 + basics.RoundMaxSize() + 2 + basics.RoundMaxSize() + 2 + msgp.Uint64Size + 2 + merklesignature.CommitmentMaxSize() + 2 + basics.RoundMaxSize() + 2 + basics.RoundMaxSize() + 2 + msgp.BoolSize + 2 + basics.MicroAlgosMaxSize() + 2 + msgp.Uint64Size return } diff --git a/ledger/tracker.go b/ledger/tracker.go index 96e42e949f..051d045205 100644 --- a/ledger/tracker.go +++ b/ledger/tracker.go @@ -921,7 +921,11 @@ func (aul *accountUpdatesLedgerEvaluator) LookupWithoutRewards(rnd basics.Round, } func (aul *accountUpdatesLedgerEvaluator) LookupAgreement(rnd basics.Round, addr basics.Address) (basics.OnlineAccountData, error) { - return aul.ao.LookupOnlineAccountData(rnd, addr) + return aul.ao.lookupOnlineAccountData(rnd, addr) +} + +func (aul *accountUpdatesLedgerEvaluator) GetKnockOfflineCandidates(basics.Round, config.ConsensusParams) (map[basics.Address]basics.OnlineAccountData, error) { + return nil, nil } func (aul *accountUpdatesLedgerEvaluator) OnlineCirculation(rnd basics.Round, voteRnd basics.Round) (basics.MicroAlgos, error) { diff --git a/ledger/voters.go b/ledger/voters.go index 63e0722a6f..49d7adf457 100644 --- a/ledger/voters.go +++ b/ledger/voters.go @@ -291,7 +291,30 @@ func (vt *votersTracker) lowestRound(base basics.Round) basics.Round { return minRound } -// VotersForStateProof returns the top online participants from round r. +// LatestCompletedVotersUpTo returns the highest round <= r for which information about the top online +// participants has already been collected, and the completed VotersForRound for that round. +// If none is found, it returns 0, nil. Unlike VotersForStateProof, this function does not wait. +func (vt *votersTracker) LatestCompletedVotersUpTo(r basics.Round) (basics.Round, *ledgercore.VotersForRound) { + vt.votersMu.RLock() + defer vt.votersMu.RUnlock() + + var latestRound basics.Round + var latestVoters *ledgercore.VotersForRound + + for round, voters := range vt.votersForRoundCache { + if round <= r && round > latestRound { + if completed, err := voters.Completed(); completed && err == nil { + latestRound = round + latestVoters = voters + } + } + } + + return latestRound, latestVoters +} + +// VotersForStateProof returns the top online participants from round r. If this data is still being +// constructed in another goroutine, this function will wait until it is ready. func (vt *votersTracker) VotersForStateProof(r basics.Round) (*ledgercore.VotersForRound, error) { tr, exists := vt.getVoters(r) if !exists { diff --git a/ledger/voters_test.go b/ledger/voters_test.go index a4913c4999..083492c610 100644 --- a/ledger/voters_test.go +++ b/ledger/voters_test.go @@ -17,6 +17,7 @@ package ledger import ( + "fmt" "testing" "github.com/algorand/go-algorand/config" @@ -273,3 +274,84 @@ func TestTopNAccountsThatHaveNoMssKeys(t *testing.T) { a.Equal(merklesignature.NoKeysCommitment, top.Participants[j].PK.Commitment) } } + +// implements ledgercore.OnlineAccountsFetcher +type testOnlineAccountsFetcher struct { + topAccts []*ledgercore.OnlineAccount + totalStake basics.MicroAlgos + err error +} + +func (o testOnlineAccountsFetcher) TopOnlineAccounts(rnd basics.Round, voteRnd basics.Round, n uint64, params *config.ConsensusParams, rewardsLevel uint64) (topOnlineAccounts []*ledgercore.OnlineAccount, totalOnlineStake basics.MicroAlgos, err error) { + return o.topAccts, o.totalStake, o.err +} + +func TestLatestCompletedVotersUpToWithError(t *testing.T) { + partitiontest.PartitionTest(t) + a := require.New(t) + + // Set up mock ledger with initial data + accts := []map[basics.Address]basics.AccountData{makeRandomOnlineAccounts(20)} + ml := makeMockLedgerForTracker(t, true, 1, protocol.ConsensusCurrentVersion, accts) + defer ml.Close() + + conf := config.GetDefaultLocal() + _, ao := newAcctUpdates(t, ml, conf) + + // Add several blocks + for i := uint64(1); i < 10; i++ { + addRandomBlock(t, ml) + } + commitAll(t, ml) + + // Populate votersForRoundCache with test data + for r := basics.Round(1); r <= 9; r += 2 { // simulate every odd round + vr := ledgercore.MakeVotersForRound() + if r%4 == 1 { // Simulate an error for rounds 1, 5, and 9 + vr.BroadcastError(fmt.Errorf("error loading data for round %d", r)) + } else { + // Simulate a successful load of voter data + hdr := bookkeeping.BlockHeader{Round: r} + oaf := testOnlineAccountsFetcher{nil, basics.MicroAlgos{Raw: 1_000_000}, nil} + require.NoError(t, vr.LoadTree(oaf, hdr)) + } + + ao.voters.setVoters(r, vr) + } + + // LastCompletedVotersUpTo retrieves the highest round less than or equal to + // the requested round where data is complete, ignoring rounds with errors. + for _, tc := range []struct { + reqRound, retRound uint64 + completed bool + }{ + {0, 0, false}, + {1, 0, false}, + {2, 0, false}, // requested 2, no completed rounds <= 2 + {3, 3, true}, + {4, 3, true}, + {5, 3, true}, // requested 5, got 3 (round 5 had error) + {6, 3, true}, + {7, 7, true}, // requested 7, got 7 (last completed <= 8) + {8, 7, true}, // requested 8, got 7 (last completed <= 8) + {9, 7, true}, // requested 9, got 7 (err at 9) + {10, 7, true}, + {11, 7, true}, + } { + completedRound, voters := ao.voters.LatestCompletedVotersUpTo(basics.Round(tc.reqRound)) + a.Equal(completedRound, basics.Round(tc.retRound)) // No completed rounds before 2 + a.Equal(voters != nil, tc.completed) + } + + // Test with errors in all rounds + ao.voters.votersForRoundCache = make(map[basics.Round]*ledgercore.VotersForRound) // reset map + for r := basics.Round(1); r <= 9; r += 2 { + vr := ledgercore.MakeVotersForRound() + vr.BroadcastError(fmt.Errorf("error loading data for round %d", r)) + ao.voters.setVoters(r, vr) + } + + completedRound, voters := ao.voters.LatestCompletedVotersUpTo(basics.Round(9)) + a.Equal(basics.Round(0), completedRound) // No completed rounds due to errors + a.Nil(voters) +} diff --git a/test/e2e-go/features/stateproofs/stateproofs_test.go b/test/e2e-go/features/stateproofs/stateproofs_test.go index 85d7d5e127..318cc4a075 100644 --- a/test/e2e-go/features/stateproofs/stateproofs_test.go +++ b/test/e2e-go/features/stateproofs/stateproofs_test.go @@ -810,6 +810,9 @@ func TestTotalWeightChanges(t *testing.T) { a := require.New(fixtures.SynchronizedTest(t)) consensusParams := getDefaultStateProofConsensusParams() + consensusParams.Payouts = config.ProposerPayoutRules{} // TODO re-enable payouts when nodes aren't suspended + consensusParams.Bonus = config.BonusPlan{} + consensusParams.StateProofWeightThreshold = (1 << 32) * 90 / 100 consensusParams.StateProofStrengthTarget = 4 consensusParams.StateProofTopVoters = 4