From 28338ffe86dad12b7b4ec2dc9593c81700d7bf13 Mon Sep 17 00:00:00 2001 From: cce <51567+cce@users.noreply.github.com> Date: Wed, 30 Oct 2024 15:08:31 -0400 Subject: [PATCH 01/14] incentives: cache top online accounts and use when building AbsentParticipationAccounts (#6085) Co-authored-by: John Jannotti --- Makefile | 3 + cmd/tealdbg/localLedger.go | 4 + daemon/algod/api/server/v2/dryrun.go | 4 + data/basics/userBalance.go | 5 + ledger/acctonline.go | 5 - ledger/acctonline_expired_test.go | 4 +- ledger/eval/appcow_test.go | 4 + ledger/eval/cow.go | 5 + ledger/eval/cow_test.go | 4 + ledger/eval/eval.go | 129 +++++++++-- ledger/eval/eval_test.go | 188 +++++++-------- .../prefetcher/prefetcher_alignment_test.go | 11 + ledger/eval_simple_test.go | 218 ++++++++++++------ ledger/ledger.go | 31 ++- ledger/ledgercore/accountdata.go | 2 + ledger/ledgercore/onlineacct.go | 2 +- ledger/ledgercore/votersForRound.go | 8 + ledger/onlineaccountscache_test.go | 9 + ledger/simple_test.go | 5 +- ledger/store/trackerdb/data.go | 11 +- ledger/store/trackerdb/data_test.go | 4 +- ledger/store/trackerdb/msgp_gen.go | 66 +++++- ledger/tracker.go | 6 +- ledger/voters.go | 25 +- ledger/voters_test.go | 82 +++++++ .../features/stateproofs/stateproofs_test.go | 3 + 26 files changed, 624 insertions(+), 214 deletions(-) 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 From 8de282949c2772152e7836bc15f05abab780d3d0 Mon Sep 17 00:00:00 2001 From: cce <51567+cce@users.noreply.github.com> Date: Tue, 5 Nov 2024 11:13:00 -0500 Subject: [PATCH 02/14] incentives: handle round 0 "top voters" lookups for easier testing (#6159) --- ledger/eval_simple_test.go | 47 ++++++++++++++++++++++++++++++++++++++ ledger/ledger.go | 12 ++++++++++ ledger/ledger_test.go | 29 +++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/ledger/eval_simple_test.go b/ledger/eval_simple_test.go index b304377fad..ee36f9dabd 100644 --- a/ledger/eval_simple_test.go +++ b/ledger/eval_simple_test.go @@ -874,6 +874,53 @@ func TestAbsenteeChallenges(t *testing.T) { }) } +func TestDoubleLedgerGetKnockoffCandidates(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + const onlineCount = 5 + genBalances, _, _ := ledgertesting.NewTestGenesis(func(cfg *ledgertesting.GenesisCfg) { + cfg.OnlineCount = onlineCount + }) + payoutsBegin := 40 + + checkAccts := func(l *Ledger, rnd basics.Round, cv protocol.ConsensusVersion) { + accts, err := l.GetKnockOfflineCandidates(rnd, config.Consensus[cv]) + require.NoError(t, err) + require.NotEmpty(t, accts) + // get online genesis accounts + onlineCnt := 0 + onlineAddrs := make(map[basics.Address]basics.OnlineAccountData) + for addr, ad := range genBalances.Balances { + if ad.Status == basics.Online { + onlineCnt++ + onlineAddrs[addr] = ad.OnlineAccountData() + } + } + require.Equal(t, onlineCount, onlineCnt) + require.Len(t, accts, onlineCnt) + require.Equal(t, onlineAddrs, accts) + + } + + ledgertesting.TestConsensusRange(t, payoutsBegin-1, 0, func(t *testing.T, ver int, cv protocol.ConsensusVersion, cfg config.Local) { + dl := NewDoubleLedger(t, genBalances, cv, cfg) + defer dl.Close() + + checkAccts(dl.generator, basics.Round(0), cv) + checkAccts(dl.validator, basics.Round(0), cv) + + // run up to round 240 + proto := config.Consensus[cv] + upToRound := basics.Round(proto.StateProofInterval - proto.StateProofVotersLookback) + require.Equal(t, basics.Round(240), upToRound) + for rnd := dl.fullBlock().Block().Round(); rnd < upToRound; rnd = dl.fullBlock().Block().Round() { + checkAccts(dl.generator, rnd, cv) + checkAccts(dl.validator, rnd, cv) + } + }) +} + // TestVoterAccess ensures that the `voter` opcode works properly when hooked up // to a real ledger. func TestVoterAccess(t *testing.T) { diff --git a/ledger/ledger.go b/ledger/ledger.go index c45dcdedf9..092aa0c55e 100644 --- a/ledger/ledger.go +++ b/ledger/ledger.go @@ -652,6 +652,18 @@ func (l *Ledger) GetKnockOfflineCandidates(rnd basics.Round, proto config.Consen if proto.StateProofInterval == 0 { return nil, nil } + + // special handling for rounds 0-240: return participating genesis accounts + if rnd < basics.Round(proto.StateProofInterval).SubSaturate(basics.Round(proto.StateProofVotersLookback)) { + ret := make(map[basics.Address]basics.OnlineAccountData) + for addr, data := range l.genesisAccounts { + if data.Status == basics.Online { + ret[addr] = data.OnlineAccountData() + } + } + return ret, 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 diff --git a/ledger/ledger_test.go b/ledger/ledger_test.go index e73d648b4d..a3d5dc2622 100644 --- a/ledger/ledger_test.go +++ b/ledger/ledger_test.go @@ -1979,6 +1979,35 @@ func TestLookupAgreement(t *testing.T) { require.Equal(t, oad, ad.OnlineAccountData()) } +func TestGetKnockOfflineCandidates(t *testing.T) { + partitiontest.PartitionTest(t) + + ver := protocol.ConsensusFuture + genesisInitState, _ := ledgertesting.GenerateInitState(t, ver, 1_000_000) + const inMem = true + log := logging.TestingLog(t) + cfg := config.GetDefaultLocal() + cfg.Archival = true + ledger, err := OpenLedger(log, t.Name(), inMem, genesisInitState, cfg) + require.NoError(t, err, "could not open ledger") + defer ledger.Close() + + accts, err := ledger.GetKnockOfflineCandidates(0, config.Consensus[ver]) + require.NoError(t, err) + require.NotEmpty(t, accts) + // get online genesis accounts + onlineCnt := 0 + onlineAddrs := make(map[basics.Address]basics.OnlineAccountData) + for addr, ad := range genesisInitState.Accounts { + if ad.Status == basics.Online { + onlineCnt++ + onlineAddrs[addr] = ad.OnlineAccountData() + } + } + require.Len(t, accts, onlineCnt) + require.Equal(t, onlineAddrs, accts) +} + func BenchmarkLedgerStartup(b *testing.B) { log := logging.TestingLog(b) tmpDir := b.TempDir() From b2cc8fe845c824cbd212887db56acdb06602a244 Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Wed, 6 Nov 2024 10:30:49 -0500 Subject: [PATCH 03/14] Incentives: Use agreement round when estimating a node's proposal interval (#6164) --- agreement/selector.go | 8 +- ledger/apply/keyreg.go | 4 +- ledger/eval/eval.go | 30 +- ledger/eval/eval_test.go | 11 +- ledger/eval_simple_test.go | 212 ++++++-------- .../features/incentives/whalejoin_test.go | 258 ++++++++++++++++++ 6 files changed, 382 insertions(+), 141 deletions(-) create mode 100644 test/e2e-go/features/incentives/whalejoin_test.go diff --git a/agreement/selector.go b/agreement/selector.go index 2d0f980ac3..1496027bd6 100644 --- a/agreement/selector.go +++ b/agreement/selector.go @@ -51,7 +51,13 @@ func (sel selector) CommitteeSize(proto config.ConsensusParams) uint64 { // looking at online stake (and status and key material). It is exported so that // AVM can provide opcodes that return the same data. func BalanceRound(r basics.Round, cparams config.ConsensusParams) basics.Round { - return r.SubSaturate(basics.Round(2 * cparams.SeedRefreshInterval * cparams.SeedLookback)) + return r.SubSaturate(BalanceLookback(cparams)) +} + +// BalanceLookback is how far back agreement looks when considering balances for +// voting stake. +func BalanceLookback(cparams config.ConsensusParams) basics.Round { + return basics.Round(2 * cparams.SeedRefreshInterval * cparams.SeedLookback) } func seedRound(r basics.Round, cparams config.ConsensusParams) basics.Round { diff --git a/ledger/apply/keyreg.go b/ledger/apply/keyreg.go index f5326f8240..d883618685 100644 --- a/ledger/apply/keyreg.go +++ b/ledger/apply/keyreg.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" + "github.com/algorand/go-algorand/agreement" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/transactions" ) @@ -79,7 +80,8 @@ func Keyreg(keyreg transactions.KeyregTxnFields, header transactions.Header, bal } record.Status = basics.Online if params.Payouts.Enabled { - record.LastHeartbeat = header.FirstValid + lookback := agreement.BalanceLookback(balances.ConsensusParams()) + record.LastHeartbeat = round + lookback } record.VoteFirstValid = keyreg.VoteFirst record.VoteLastValid = keyreg.VoteLast diff --git a/ledger/eval/eval.go b/ledger/eval/eval.go index c25d87728d..1af542967d 100644 --- a/ledger/eval/eval.go +++ b/ledger/eval/eval.go @@ -259,7 +259,7 @@ func (x *roundCowBase) onlineStake() (basics.MicroAlgos, error) { return basics.MicroAlgos{}, err } x.totalOnline = total - return x.totalOnline, err + return x.totalOnline, nil } func (x *roundCowBase) updateAssetResourceCache(aa ledgercore.AccountAsset, r ledgercore.AssetResource) { @@ -1639,6 +1639,11 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []bas updates := &eval.block.ParticipationUpdates ch := activeChallenge(&eval.proto, uint64(current), eval.state) + onlineStake, err := eval.state.onlineStake() + if err != nil { + logging.Base().Errorf("unable to fetch online stake, no knockoffs: %v", err) + return + } // Make a set of candidate addresses to check for expired or absentee status. type candidateData struct { @@ -1730,7 +1735,12 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []bas if acctData.Status == basics.Online { lastSeen := max(acctData.LastProposed, acctData.LastHeartbeat) - if isAbsent(eval.state.prevTotals.Online.Money, acctData.MicroAlgosWithRewards, lastSeen, current) || + oad, lErr := eval.state.lookupAgreement(accountAddr) + if lErr != nil { + logging.Base().Errorf("unable to check account for absenteeism: %v", accountAddr) + continue + } + if isAbsent(onlineStake, oad.VotingStake(), lastSeen, current) || failsChallenge(ch, accountAddr, lastSeen) { updates.AbsentParticipationAccounts = append( updates.AbsentParticipationAccounts, @@ -1767,7 +1777,7 @@ func isAbsent(totalOnlineStake basics.MicroAlgos, acctStake basics.MicroAlgos, l // Don't consider accounts that were online when payouts went into effect as // absent. They get noticed the next time they propose or keyreg, which // ought to be soon, if they are high stake or want to earn incentives. - if lastSeen == 0 { + if lastSeen == 0 || acctStake.Raw == 0 { return false } // See if the account has exceeded 10x their expected observation interval. @@ -1881,6 +1891,14 @@ func (eval *BlockEvaluator) validateAbsentOnlineAccounts() error { addressSet := make(map[basics.Address]bool, suspensionCount) ch := activeChallenge(&eval.proto, uint64(eval.Round()), eval.state) + totalOnlineStake, err := eval.state.onlineStake() + if err != nil { + logging.Base().Errorf("unable to fetch online stake, can't check knockoffs: %v", err) + // I suppose we can still return successfully if the absent list is empty. + if len(eval.block.ParticipationUpdates.AbsentParticipationAccounts) > 0 { + return err + } + } for _, accountAddr := range eval.block.ParticipationUpdates.AbsentParticipationAccounts { if _, exists := addressSet[accountAddr]; exists { @@ -1901,7 +1919,11 @@ func (eval *BlockEvaluator) validateAbsentOnlineAccounts() error { } lastSeen := max(acctData.LastProposed, acctData.LastHeartbeat) - if isAbsent(eval.state.prevTotals.Online.Money, acctData.MicroAlgos, lastSeen, eval.Round()) { + oad, lErr := eval.state.lookupAgreement(accountAddr) + if lErr != nil { + return fmt.Errorf("unable to check absent account: %v", accountAddr) + } + if isAbsent(totalOnlineStake, oad.VotingStake(), lastSeen, eval.Round()) { continue // ok. it's "normal absent" } if failsChallenge(ch, accountAddr, lastSeen) { diff --git a/ledger/eval/eval_test.go b/ledger/eval/eval_test.go index ed2f551ac2..df17357a10 100644 --- a/ledger/eval/eval_test.go +++ b/ledger/eval/eval_test.go @@ -804,9 +804,16 @@ func (ledger *evalTestLedger) GetKnockOfflineCandidates(rnd basics.Round, _ conf return ret, nil } -// OnlineCirculation just returns a deterministic value for a given round. +// OnlineCirculation add up the balances of all online accounts in rnd. It +// doesn't remove expired accounts. func (ledger *evalTestLedger) OnlineCirculation(rnd, voteRound basics.Round) (basics.MicroAlgos, error) { - return basics.MicroAlgos{Raw: uint64(rnd) * 1_000_000}, nil + circulation := basics.MicroAlgos{} + for _, data := range ledger.roundBalances[rnd] { + if data.Status == basics.Online { + circulation.Raw += data.MicroAlgos.Raw + } + } + return circulation, nil } func (ledger *evalTestLedger) LookupApplication(rnd basics.Round, addr basics.Address, aidx basics.AppIndex) (ledgercore.AppResource, error) { diff --git a/ledger/eval_simple_test.go b/ledger/eval_simple_test.go index ee36f9dabd..d1834d9632 100644 --- a/ledger/eval_simple_test.go +++ b/ledger/eval_simple_test.go @@ -21,6 +21,7 @@ import ( "encoding/binary" "fmt" "reflect" + "slices" "strings" "testing" @@ -439,25 +440,10 @@ func TestAbsentTracking(t *testing.T) { } checkingBegins := 40 - runTest := func(t *testing.T, cv protocol.ConsensusVersion, cfg config.Local, beforeSPInterval bool) { + runTest := func(t *testing.T, cv protocol.ConsensusVersion, cfg config.Local) { 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", @@ -504,7 +490,10 @@ func TestAbsentTracking(t *testing.T) { VotePK: [32]byte{1}, SelectionPK: [32]byte{1}, }) - require.Equal(t, baseRound+basics.Round(2), vb.Block().Round()) + addr1Keyreg := vb.Block().Round() + require.EqualValues(t, 2, addr1Keyreg) // sanity check + const lookback = 320 // keyreg puts LastHeartbeat 320 rounds into the future + require.EqualValues(t, addr1Keyreg+lookback, lookup(t, dl.generator, addrs[1]).LastHeartbeat) // as configured above, only the first two accounts should be online require.True(t, lookup(t, dl.generator, addrs[0]).Status == basics.Online) @@ -583,7 +572,7 @@ func TestAbsentTracking(t *testing.T) { require.Zero(t, regger.LastProposed) require.True(t, regger.Status == basics.Online) - // But nothing has changed, since we're not past 320 + // But nothing has changed for voter_params_get, since we're not past 320 checkState(addrs[0], true, false, 833_333_333_333_333) // #11 checkState(addrs[1], true, false, 833_333_333_333_333) // #12 checkState(addrs[2], false, false, 0) // #13 @@ -600,11 +589,12 @@ func TestAbsentTracking(t *testing.T) { SelectionPK: [32]byte{1}, }) // #14 printAbsent(vb) - twoEligible := vb.Block().Round() - require.EqualValues(t, baseRound+14, twoEligible) // sanity check + addr2Eligible := vb.Block().Round() + require.EqualValues(t, 14, addr2Eligible) // sanity check regger = lookup(t, dl.validator, addrs[2]) require.True(t, regger.IncentiveEligible) + require.EqualValues(t, 14+320, regger.LastHeartbeat) for i := 0; i < 5; i++ { printAbsent(dl.fullBlock()) // #15-19 @@ -618,70 +608,50 @@ func TestAbsentTracking(t *testing.T) { require.True(t, lookup(t, dl.generator, addrs[1]).Status == basics.Online) require.True(t, lookup(t, dl.generator, addrs[2]).Status == basics.Online) - for i := 0; i < 30; i++ { - printAbsent(dl.fullBlock()) // #20-49 - } + var addr1off basics.Round + var addr2off basics.Round + // We're at 20, skip ahead by lookback + 30 to see the knockoffs + for { + vb := dl.fullBlock() + printAbsent(vb) + + rnd := vb.Block().Round() + switch { + case slices.Contains(vb.Block().AbsentParticipationAccounts, addrs[1]): + addr1off = rnd + case slices.Contains(vb.Block().AbsentParticipationAccounts, addrs[2]): + addr2off = rnd + default: + require.Empty(t, vb.Block().AbsentParticipationAccounts) + } - // addrs 0-2 all have about 1/3 of stake, so seemingly (see next block - // of checks) become eligible for suspension after 30 rounds. We're at - // 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) - 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) + if rnd < 100 { + // `vote_params_get` sees no changes in the early going, because it looks back 320 + checkState(addrs[1], true, false, 833_333_333_333_333) // this also advances a round! + // to avoid complications from advancing an extra round, we only do this check for 100 rounds + } - // when 2 pays 0, they both get noticed but addr[0] is not considered - // absent because it is a genesis account - vb = dl.fullBlock(&txntest.Txn{ - Type: "pay", - Sender: addrs[2], - 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, 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" - 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) + // addr[1] spent 10A on a fee in rnd 3, so online stake and eligibility adjusted in 323 + if rnd == addr1Keyreg-1+lookback { + checkState(addrs[1], true, false, 833_333_333_333_333) + checkState(addrs[1], true, true, 833_333_323_333_333) + } - // 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++ { - 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{ - Type: "pay", - Sender: addrs[2], - Receiver: addrs[1], - Amount: 0, - }) // #55 - printAbsent(vb) - twoPaysOne := vb.Block().Round() - 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) + // watch the change across the round that addr2 becomes eligible (by spending 2A in keyreg) + if rnd == addr2Eligible-1+lookback { + checkState(addrs[2], true, false, 833_333_333_429_333) + checkState(addrs[2], true, true, 833_333_331_429_333) // after keyreg w/ 2A is effective + } + + if rnd > 20+lookback+30 { + break + } } - require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[0]).Status) + require.Equal(t, addr2Eligible+lookback+30, addr2off) + require.Equal(t, addr1Keyreg+lookback+31, addr1off) // addr1 paid out a little bit, extending its lag by 1 + + require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[0]).Status) // genesis account require.Equal(t, basics.Offline, lookup(t, dl.generator, addrs[1]).Status) - require.False(t, lookup(t, dl.generator, addrs[1]).IncentiveEligible) require.Equal(t, basics.Offline, lookup(t, dl.generator, addrs[2]).Status) require.False(t, lookup(t, dl.generator, addrs[2]).IncentiveEligible) @@ -691,66 +661,42 @@ func TestAbsentTracking(t *testing.T) { require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[2]).Status) require.False(t, lookup(t, dl.generator, addrs[2]).IncentiveEligible) - // "synchronize" so the loop below ends on 320 - for dl.fullBlock().Block().Round()%4 != 3 { - } - // keep in mind that each call to checkState also advances the round, so - // each loop advances by 4. - for rnd := dl.fullBlock().Block().Round(); rnd < 320; rnd = dl.fullBlock().Block().Round() { - // STILL nothing has changed, as we're under 320 - checkState(addrs[0], true, false, 833_333_333_333_333) - checkState(addrs[1], true, false, 833_333_333_333_333) - checkState(addrs[2], false, false, 0) - } - // rnd was 320 in the last fullBlock - - // We will soon see effects visible to `vote_params_get` - // In first block, addr[3] created an app. No effect on 0-2 - checkState(addrs[1], true, false, 833_333_333_333_333) // 321 - // 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 - 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() { - } - checkState(addrs[2], true, false, 833_333_333_429_333) - checkState(addrs[2], true, true, 833_333_331_429_333) // after keyreg w/ 2A is effective + // The knockoffs have happened, now skip through another lookback rounds + // to observe the changes with checkstate + addr1check, addr2check := false, false + for { + vb := dl.fullBlock() + printAbsent(vb) + rnd := vb.Block().Round() + + // observe addr1 stake going to zero 320 rounds after knockoff + if rnd == addr1off+lookback-1 { + checkState(addrs[1], true, true, 833_333_323_188_333) + checkState(addrs[1], false, false, 0) + addr1check = true + } - for rnd := dl.fullBlock().Block().Round(); rnd < 320+twoPaysZero-1; rnd = dl.fullBlock().Block().Round() { - } - // we're at the round before two's suspension kicks in - checkState(addrs[2], true, true, 833_333_331_429_333) // still "online" - checkState(addrs[0], true, false, 833_333_333_331_333) // paid fee in #5 and #11, we're at ~371 - // 2 was noticed & suspended after paying 0, eligible and amount go to 0 - checkState(addrs[2], false, false, 0) - checkState(addrs[0], true, false, 833_333_333_331_333) // addr 0 didn't get suspended (genesis) + // observe addr2 stake going to zero 320 rounds after knockoff + if rnd == addr2off+lookback-1 { + checkState(addrs[2], true, true, 833_333_331_427_333) // still "online" + checkState(addrs[2], false, false, 0) + addr2check = true + } - // roughly the same check, except for addr 1, which was genesis, but - // after doing a keyreg, became susceptible to suspension - for rnd := dl.fullBlock().Block().Round(); rnd < 320+twoPaysOne-1; rnd = dl.fullBlock().Block().Round() { - } - 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 + if rnd > 20+2*lookback+30 { + break + } } - // 1 was noticed & suspended after being paid by 2, so eligible and amount go to 0 - checkState(addrs[1], false, false, 0) - } + // sanity check that we didn't skip one because of checkstate advanacing a round + require.True(t, addr1check) + require.True(t, addr2check) - 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) }) + checkState(addrs[0], true, false, 833_333_333_331_333) // addr 0 didn't get suspended (genesis) } - ledgertesting.TestConsensusRange(t, checkingBegins, 0, testBeforeAndAfterSPInterval) + ledgertesting.TestConsensusRange(t, checkingBegins, 0, func(t *testing.T, ver int, cv protocol.ConsensusVersion, cfg config.Local) { + runTest(t, cv, cfg) + }) } // TestAbsenteeChallenges ensures that online accounts that don't (do) respond diff --git a/test/e2e-go/features/incentives/whalejoin_test.go b/test/e2e-go/features/incentives/whalejoin_test.go new file mode 100644 index 0000000000..b3e8dac479 --- /dev/null +++ b/test/e2e-go/features/incentives/whalejoin_test.go @@ -0,0 +1,258 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package suspension + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/algorand/go-algorand/daemon/algod/api/server/v2/generated/model" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/transactions" + "github.com/algorand/go-algorand/libgoal" + "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/test/framework/fixtures" + "github.com/algorand/go-algorand/test/partitiontest" +) + +// TestWhaleJoin shows a "whale" with more stake than is currently online can go +// online without immediate suspension. This tests for a bug we had where we +// calcululated expected proposal interval using the _old_ totals, rather than +// the totals following the keyreg. So big joiner could be expected to propose +// in the same block they joined. +func TestWhaleJoin(t *testing.T) { + partitiontest.PartitionTest(t) + defer fixtures.ShutdownSynchronizedTest(t) + + t.Parallel() + a := require.New(fixtures.SynchronizedTest(t)) + + var fixture fixtures.RestClientFixture + // Make rounds shorter and seed lookback smaller, otherwise we need to wait + // 320 slow rounds for particpation effects to matter. + const lookback = 32 + fixture.FasterConsensus(protocol.ConsensusFuture, time.Second, lookback) + fixture.Setup(t, filepath.Join("nettemplates", "Payouts.json")) + defer fixture.Shutdown() + + // Overview of this test: + // 1. Take wallet15 offline (but retain keys so can back online later) + // 2. Have wallet01 spend almost all their algos + // 3. Wait for balances to flow through "lookback" + // 4. Rejoin wallet15 which will have way more stake that what is online. + + clientAndAccount := func(name string) (libgoal.Client, model.Account) { + c := fixture.GetLibGoalClientForNamedNode(name) + accounts, err := fixture.GetNodeWalletsSortedByBalance(c) + a.NoError(err) + a.Len(accounts, 1) + fmt.Printf("Client %s is %v\n", name, accounts[0].Address) + return c, accounts[0] + } + + c15, account15 := clientAndAccount("Node15") + c01, account01 := clientAndAccount("Node01") + + // 1. take wallet15 offline + keys := offline(&fixture, a, c15, account15.Address) + + // 2. c01 starts with 100M, so burn 99.9M to get total online stake down + burn, err := c01.SendPaymentFromUnencryptedWallet(account01.Address, basics.Address{}.String(), + 1000, 99_900_000_000_000, nil) + a.NoError(err) + receipt, err := fixture.WaitForConfirmedTxn(uint64(burn.LastValid), burn.ID().String()) + a.NoError(err) + + // 3. Wait lookback rounds + _, err = c01.WaitForRound(*receipt.ConfirmedRound + lookback) + a.NoError(err) + + // 4. rejoin, with 1.5B against the paltry 100k that's currently online + online(&fixture, a, c15, account15.Address, keys) + + // 5. wait for agreement balances to kick in (another lookback's worth, plus some slack) + _, err = c01.WaitForRound(*receipt.ConfirmedRound + 2*lookback + 5) + a.NoError(err) + + data, err := c15.AccountData(account15.Address) + a.NoError(err) + a.Equal(basics.Online, data.Status) + + // even after being in the block to "get noticed" + txn, err := c15.SendPaymentFromUnencryptedWallet(account15.Address, basics.Address{}.String(), + 1000, 1, nil) + a.NoError(err) + _, err = fixture.WaitForConfirmedTxn(uint64(txn.LastValid), txn.ID().String()) + a.NoError(err) + data, err = c15.AccountData(account15.Address) + a.NoError(err) + a.Equal(basics.Online, data.Status) +} + +// TestBigJoin shows that even though an account can't vote during the first 320 +// rounds after joining, it is not marked absent because of that gap. This would +// be a problem for "biggish" accounts, that might already be absent after 320 +// rounds of not voting. +func TestBigJoin(t *testing.T) { + partitiontest.PartitionTest(t) + defer fixtures.ShutdownSynchronizedTest(t) + + t.Parallel() + a := require.New(fixtures.SynchronizedTest(t)) + + var fixture fixtures.RestClientFixture + // We need lookback to be fairly long, so that we can have a node join with + // 1/16 stake, and have lookback be long enough to risk absenteeism. + const lookback = 164 // > 160, which is 10x the 1/16th's interval + fixture.FasterConsensus(protocol.ConsensusFuture, time.Second/2, lookback) + fixture.Setup(t, filepath.Join("nettemplates", "Payouts.json")) + defer fixture.Shutdown() + + // Overview of this test: + // 1. Take wallet01 offline (but retain keys so can back online later) + // 2. Wait `lookback` rounds so it can't propose. + // 3. Rejoin wallet01 which will now have 1/16 of the stake + // 4. Wait 160 rounds and ensure node01 does not get knocked offline for being absent + // 5. Wait the rest of lookback to ensure it _still_ does not get knock off. + + clientAndAccount := func(name string) (libgoal.Client, model.Account) { + c := fixture.GetLibGoalClientForNamedNode(name) + accounts, err := fixture.GetNodeWalletsSortedByBalance(c) + a.NoError(err) + a.Len(accounts, 1) + fmt.Printf("Client %s is %v\n", name, accounts[0].Address) + return c, accounts[0] + } + + c01, account01 := clientAndAccount("Node01") + + // 1. take wallet01 offline + keys := offline(&fixture, a, c01, account01.Address) + + // 2. Wait lookback rounds + wait(&fixture, a, lookback) + + // 4. rejoin, with 1/16 of total stake + onRound := online(&fixture, a, c01, account01.Address, keys) + + // 5. wait for enough rounds to pass, during which c01 can't vote, that is + // could get knocked off. + wait(&fixture, a, 161) + data, err := c01.AccountData(account01.Address) + a.NoError(err) + a.Equal(basics.Online, data.Status) + + // 5a. just to be sure, do a zero pay to get it "noticed" + zeroPay(&fixture, a, c01, account01.Address) + data, err = c01.AccountData(account01.Address) + a.NoError(err) + a.Equal(basics.Online, data.Status) + + // 6. Now wait until lookback after onRound (which should just be a couple + // more rounds). Check again, to ensure that once c01 is _really_ + // online/voting, it is still safe for long enough to propose. + a.NoError(fixture.WaitForRoundWithTimeout(onRound + lookback)) + data, err = c01.AccountData(account01.Address) + a.NoError(err) + a.Equal(basics.Online, data.Status) + + zeroPay(&fixture, a, c01, account01.Address) + data, err = c01.AccountData(account01.Address) + a.NoError(err) + a.Equal(basics.Online, data.Status) + + // The node _could_ have gotten lucky and propose in first couple rounds it + // is allowed to propose, so this test is expected to be "flaky" in a + // sense. It would pass about 1/8 of the time, even if we had the problem it + // is looking for. +} + +func wait(f *fixtures.RestClientFixture, a *require.Assertions, count uint64) { + res, err := f.AlgodClient.Status() + a.NoError(err) + round := res.LastRound + count + a.NoError(f.WaitForRoundWithTimeout(round)) +} + +func zeroPay(f *fixtures.RestClientFixture, a *require.Assertions, + c libgoal.Client, address string) { + pay, err := c.SendPaymentFromUnencryptedWallet(address, address, 1000, 0, nil) + a.NoError(err) + _, err = f.WaitForConfirmedTxn(uint64(pay.LastValid), pay.ID().String()) + a.NoError(err) +} + +// Go offline, but return the key material so it's easy to go back online +func offline(f *fixtures.RestClientFixture, a *require.Assertions, client libgoal.Client, address string) transactions.KeyregTxnFields { + offTx, err := client.MakeUnsignedGoOfflineTx(address, 0, 0, 100_000, [32]byte{}) + a.NoError(err) + + data, err := client.AccountData(address) + a.NoError(err) + keys := transactions.KeyregTxnFields{ + VotePK: data.VoteID, + SelectionPK: data.SelectionID, + StateProofPK: data.StateProofID, + VoteFirst: data.VoteFirstValid, + VoteLast: data.VoteLastValid, + VoteKeyDilution: data.VoteKeyDilution, + } + + wh, err := client.GetUnencryptedWalletHandle() + a.NoError(err) + onlineTxID, err := client.SignAndBroadcastTransaction(wh, nil, offTx) + a.NoError(err) + txn, err := f.WaitForConfirmedTxn(uint64(offTx.LastValid), onlineTxID) + a.NoError(err) + // sync up with the network + _, err = client.WaitForRound(*txn.ConfirmedRound) + a.NoError(err) + data, err = client.AccountData(address) + a.NoError(err) + a.Equal(basics.Offline, data.Status) + return keys +} + +// Go online with the supplied key material +func online(f *fixtures.RestClientFixture, a *require.Assertions, client libgoal.Client, address string, keys transactions.KeyregTxnFields) uint64 { + // sanity check that we start offline + data, err := client.AccountData(address) + a.NoError(err) + a.Equal(basics.Offline, data.Status) + + // make an empty keyreg, we'll copy in the keys + onTx, err := client.MakeUnsignedGoOfflineTx(address, 0, 0, 100_000, [32]byte{}) + a.NoError(err) + + onTx.KeyregTxnFields = keys + wh, err := client.GetUnencryptedWalletHandle() + a.NoError(err) + onlineTxID, err := client.SignAndBroadcastTransaction(wh, nil, onTx) + a.NoError(err) + receipt, err := f.WaitForConfirmedTxn(uint64(onTx.LastValid), onlineTxID) + a.NoError(err) + data, err = client.AccountData(address) + a.NoError(err) + // Before bug fix, the account would be suspended in the same round of the + // keyreg, so it would not be online. + a.Equal(basics.Online, data.Status) + return *receipt.ConfirmedRound +} From 058829613be68c076f8e1d3ca8d245e2e2db847e Mon Sep 17 00:00:00 2001 From: cce <51567+cce@users.noreply.github.com> Date: Wed, 6 Nov 2024 11:15:02 -0500 Subject: [PATCH 04/14] incentives: update GetKnockOfflineCandidates to return current OnlineAccountData when rnd < 240 (#6165) --- ledger/eval_simple_test.go | 34 ++++++++++++++++++++++++++++++---- ledger/ledger.go | 24 ++++++++++++++---------- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/ledger/eval_simple_test.go b/ledger/eval_simple_test.go index d1834d9632..1e02157ae5 100644 --- a/ledger/eval_simple_test.go +++ b/ledger/eval_simple_test.go @@ -825,27 +825,49 @@ func TestDoubleLedgerGetKnockoffCandidates(t *testing.T) { t.Parallel() const onlineCount = 5 - genBalances, _, _ := ledgertesting.NewTestGenesis(func(cfg *ledgertesting.GenesisCfg) { + genBalances, addrs, _ := ledgertesting.NewTestGenesis(func(cfg *ledgertesting.GenesisCfg) { cfg.OnlineCount = onlineCount + ledgertesting.TurnOffRewards(cfg) }) payoutsBegin := 40 + // txn to send in round 1, to change the balances to be different from genesis + payTxn := &txntest.Txn{Type: "pay", Sender: addrs[1], Receiver: addrs[2], Amount: 1_000_000} + checkAccts := func(l *Ledger, rnd basics.Round, cv protocol.ConsensusVersion) { accts, err := l.GetKnockOfflineCandidates(rnd, config.Consensus[cv]) require.NoError(t, err) require.NotEmpty(t, accts) + // get online genesis accounts onlineCnt := 0 - onlineAddrs := make(map[basics.Address]basics.OnlineAccountData) + genesisOnlineAccts := make(map[basics.Address]basics.OnlineAccountData) + afterPayTxnOnlineAccts := make(map[basics.Address]basics.OnlineAccountData) for addr, ad := range genBalances.Balances { if ad.Status == basics.Online { onlineCnt++ - onlineAddrs[addr] = ad.OnlineAccountData() + genesisOnlineAccts[addr] = ad.OnlineAccountData() + afterPayTxnOnlineAccts[addr] = ad.OnlineAccountData() } } + + // calculate expected balances after applying payTxn + payTxnReceiver := afterPayTxnOnlineAccts[payTxn.Receiver] + payTxnReceiver.MicroAlgosWithRewards.Raw += payTxn.Amount + payTxnSender := afterPayTxnOnlineAccts[payTxn.Sender] + payTxnSender.MicroAlgosWithRewards.Raw -= (payTxn.Amount + config.Consensus[cv].MinTxnFee) + afterPayTxnOnlineAccts[payTxn.Receiver] = payTxnReceiver + afterPayTxnOnlineAccts[payTxn.Sender] = payTxnSender + require.Equal(t, onlineCount, onlineCnt) require.Len(t, accts, onlineCnt) - require.Equal(t, onlineAddrs, accts) + if rnd == 0 { + // balances should be same as genesis + require.Equal(t, genesisOnlineAccts, accts) + } else { + // balances > rnd 1 should reflect payTxn change + require.Equal(t, afterPayTxnOnlineAccts, accts, "rnd %d", rnd) + } } @@ -856,6 +878,10 @@ func TestDoubleLedgerGetKnockoffCandidates(t *testing.T) { checkAccts(dl.generator, basics.Round(0), cv) checkAccts(dl.validator, basics.Round(0), cv) + // change two accounts' balances to be different from genesis + payTxn.GenesisHash = crypto.Digest{} // clear if set from previous run + dl.fullBlock(payTxn) + // run up to round 240 proto := config.Consensus[cv] upToRound := basics.Round(proto.StateProofInterval - proto.StateProofVotersLookback) diff --git a/ledger/ledger.go b/ledger/ledger.go index 092aa0c55e..3f85b9e7bb 100644 --- a/ledger/ledger.go +++ b/ledger/ledger.go @@ -653,27 +653,31 @@ func (l *Ledger) GetKnockOfflineCandidates(rnd basics.Round, proto config.Consen return nil, nil } + var addrs []basics.Address + // special handling for rounds 0-240: return participating genesis accounts if rnd < basics.Round(proto.StateProofInterval).SubSaturate(basics.Round(proto.StateProofVotersLookback)) { - ret := make(map[basics.Address]basics.OnlineAccountData) for addr, data := range l.genesisAccounts { if data.Status == basics.Online { - ret[addr] = data.OnlineAccountData() + addrs = append(addrs, addr) } } - return ret, 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 + } else { + // 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 + } + addrs = make([]basics.Address, 0, len(voters.AddrToPos)) + for addr := range voters.AddrToPos { + addrs = append(addrs, addr) + } } // 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 { + for _, addr := range addrs { data, err := l.acctsOnline.lookupOnlineAccountData(rnd, addr) if err != nil { continue // skip missing / not online accounts From fb748a6d1eb77a053a26bd0df421b5fdd783b98e Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Wed, 6 Nov 2024 15:13:07 -0500 Subject: [PATCH 05/14] Incentive fixes (#6166) --- .../features/incentives/payouts_test.go | 2 +- .../features/incentives/suspension_test.go | 62 +++++-------------- 2 files changed, 17 insertions(+), 47 deletions(-) diff --git a/test/e2e-go/features/incentives/payouts_test.go b/test/e2e-go/features/incentives/payouts_test.go index 1b9f4d0ec3..730d85a186 100644 --- a/test/e2e-go/features/incentives/payouts_test.go +++ b/test/e2e-go/features/incentives/payouts_test.go @@ -48,7 +48,7 @@ func TestBasicPayouts(t *testing.T) { var fixture fixtures.RestClientFixture // Make the seed lookback shorter, otherwise we need to wait 320 rounds to become IncentiveEligible. const lookback = 32 - fixture.FasterConsensus(protocol.ConsensusFuture, time.Second, 32) + fixture.FasterConsensus(protocol.ConsensusFuture, time.Second, lookback) fmt.Printf("lookback is %d\n", lookback) fixture.Setup(t, filepath.Join("nettemplates", "Payouts.json")) defer fixture.Shutdown() diff --git a/test/e2e-go/features/incentives/suspension_test.go b/test/e2e-go/features/incentives/suspension_test.go index 6768f7926e..cb9ff47259 100644 --- a/test/e2e-go/features/incentives/suspension_test.go +++ b/test/e2e-go/features/incentives/suspension_test.go @@ -51,9 +51,11 @@ func TestBasicSuspension(t *testing.T) { const suspend20 = 55 var fixture fixtures.RestClientFixture - // Speed up rounds, but keep long lookback, so 20% node has a chance to get - // back online after being suspended. - fixture.FasterConsensus(protocol.ConsensusFuture, time.Second, 320) + // Speed up rounds. Long enough lookback, so 20% node has a chance to + // get back online after being suspended. (0.8^32 is very small) + + const lookback = 32 + fixture.FasterConsensus(protocol.ConsensusFuture, time.Second, lookback) fixture.Setup(t, filepath.Join("nettemplates", "Suspension.json")) defer fixture.Shutdown() @@ -72,71 +74,41 @@ func TestBasicSuspension(t *testing.T) { rekeyreg(&fixture, a, c10, account10.Address) rekeyreg(&fixture, a, c20, account20.Address) - // Wait until each have proposed, so they are suspendable - proposed10 := false - proposed20 := false - for !proposed10 || !proposed20 { - status, err := c10.Status() - a.NoError(err) - block, err := c10.BookkeepingBlock(status.LastRound) - a.NoError(err) - - fmt.Printf(" block %d proposed by %v\n", status.LastRound, block.Proposer()) - - fixture.WaitForRoundWithTimeout(status.LastRound + 1) - - switch block.Proposer().String() { - case account10.Address: - proposed10 = true - case account20.Address: - proposed20 = true - } - } - + // Accounts are now suspendable whether they have proposed yet or not + // because keyreg sets LastHeartbeat. Stop c20 which means account20 will be + // absent about 50 rounds after keyreg goes into effect (lookback) a.NoError(c20.FullStop()) afterStop, err := c10.Status() a.NoError(err) - // Advance 55 rounds - err = fixture.WaitForRoundWithTimeout(afterStop.LastRound + suspend20) - a.NoError(err) - - // n20 is still online after 55 rounds of absence (the node is off, but the - // account is marked online) because it has not been "noticed". - account, err := fixture.LibGoalClient.AccountData(account20.Address) + // Advance lookback+55 rounds + err = fixture.WaitForRoundWithTimeout(afterStop.LastRound + lookback + suspend20) a.NoError(err) - a.Equal(basics.Online, account.Status) - voteID := account.VoteID - a.NotZero(voteID) - - // pay n10 & n20, so both could be noticed - richAccount, err := fixture.GetRichestAccount() - a.NoError(err) - fixture.SendMoneyAndWait(afterStop.LastRound+suspend20, 5, 1000, richAccount.Address, account10.Address, "") - fixture.SendMoneyAndWait(afterStop.LastRound+suspend20, 5, 1000, richAccount.Address, account20.Address, "") // make sure c10 node is in-sync with the network status, err := fixture.LibGoalClient.Status() a.NoError(err) + fmt.Printf("status.LastRound %d\n", status.LastRound) _, err = c10.WaitForRound(status.LastRound) a.NoError(err) - // n20's account is now offline, but has voting key material (suspended) - account, err = c10.AccountData(account20.Address) + // n20's account has been suspended (offline, but has voting key material) + account, err := c10.AccountData(account20.Address) a.NoError(err) + fmt.Printf("account20 %d %d\n", account.LastProposed, account.LastHeartbeat) a.Equal(basics.Offline, account.Status) a.NotZero(account.VoteID) a.False(account.IncentiveEligible) // suspension turns off flag - // n10's account is still online, because it's got less stake, has not been absent 10 x interval. + // TODO: n10 wasn't turned off, it's still online account, err = c10.AccountData(account10.Address) a.NoError(err) a.Equal(basics.Online, account.Status) a.NotZero(account.VoteID) a.True(account.IncentiveEligible) - // Use the fixture to start the node again. Since we're only a bit past the + // Use the fixture to start node20 again. Since we're only a bit past the // suspension round, it will still be voting. It should get a chance to // propose soon (20/100 of blocks) which will put it back online. lg, err := fixture.StartNode(c20.DataDir()) @@ -172,8 +144,6 @@ func TestBasicSuspension(t *testing.T) { a.NoError(err) r.Equal(basics.Online, account.Status, i) r.Greater(account.LastProposed, restartRound, i) - - r.Equal(voteID, account.VoteID, i) r.False(account.IncentiveEligible, i) } } From de1c241ceb790a3a2a074b9f7baf24dea0757fc4 Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Wed, 27 Nov 2024 12:36:01 -0500 Subject: [PATCH 06/14] Incentives: Heartbeat transaction type (#6149) Co-authored-by: Gary Malouf <982483+gmalouf@users.noreply.github.com> --- agreement/gossip/networkFull_test.go | 2 +- catchup/universalFetcher.go | 2 +- cmd/goal/clerk.go | 3 +- cmd/loadgenerator/main.go | 11 +- config/consensus.go | 9 +- config/consensus_test.go | 5 + crypto/msgp_gen.go | 236 ++++++++++ crypto/msgp_gen_test.go | 60 +++ crypto/onetimesig.go | 39 ++ daemon/algod/api/client/restClient.go | 90 +++- data/basics/userBalance.go | 10 - data/bookkeeping/block_test.go | 24 +- data/committee/common_test.go | 67 +-- data/committee/credential_test.go | 21 +- data/transactions/heartbeat.go | 42 ++ data/transactions/logic/assembler.go | 10 + data/transactions/logic/crypto_test.go | 18 +- data/transactions/logic/eval_test.go | 2 +- data/transactions/logic/ledger_test.go | 29 +- data/transactions/msgp_gen.go | 440 ++++++++++++++---- data/transactions/msgp_gen_test.go | 60 +++ data/transactions/transaction.go | 10 + data/transactions/transaction_test.go | 15 + data/transactions/verify/txn.go | 11 +- data/transactions/verify/txn_test.go | 4 +- .../verify/verifiedTxnCache_test.go | 2 +- data/txntest/txn.go | 10 + heartbeat/README.md | 180 +++++++ heartbeat/abstractions.go | 54 +++ heartbeat/service.go | 183 ++++++++ heartbeat/service_test.go | 268 +++++++++++ ledger/apply/apply.go | 7 +- ledger/apply/challenge.go | 116 +++++ ledger/apply/challenge_test.go | 121 +++++ ledger/apply/heartbeat.go | 124 +++++ ledger/apply/heartbeat_test.go | 193 ++++++++ ledger/apply/mockBalances_test.go | 33 ++ ledger/apptxn_test.go | 6 +- ledger/eval/eval.go | 99 ++-- ledger/eval/eval_test.go | 92 ---- ledger/eval_simple_test.go | 6 +- ledger/heartbeat_test.go | 155 ++++++ ledger/ledger.go | 2 +- ledger/ledgercore/accountdata.go | 7 +- ledger/simple_test.go | 2 +- libgoal/libgoal.go | 51 +- network/connPerfMon_test.go | 4 +- node/node.go | 8 + protocol/txntype.go | 3 + stateproof/builder.go | 2 +- stateproof/worker.go | 4 +- .../features/accountPerf/sixMillion_test.go | 5 +- .../features/catchup/basicCatchup_test.go | 19 +- .../catchup/catchpointCatchup_test.go | 18 +- .../catchup/stateproofsCatchup_test.go | 12 +- .../features/followernode/syncDeltas_test.go | 6 +- .../features/followernode/syncRestart_test.go | 2 +- .../features/incentives/challenge_test.go | 188 ++++++++ .../features/incentives/whalejoin_test.go | 78 +++- .../onlineOfflineParticipation_test.go | 4 +- .../partitionRecovery_test.go | 9 +- test/e2e-go/restAPI/other/misc_test.go | 2 +- .../restAPI/simulate/simulateRestAPI_test.go | 6 +- .../upgrades/application_support_test.go | 4 +- test/e2e-go/upgrades/rekey_support_test.go | 2 +- test/framework/fixtures/libgoalFixture.go | 126 ++--- test/framework/fixtures/restClientFixture.go | 92 +--- test/testdata/nettemplates/Challenges.json | 44 ++ util/db/dbutil.go | 2 +- util/set.go | 34 ++ util/set_test.go | 75 +++ 71 files changed, 3031 insertions(+), 649 deletions(-) create mode 100644 data/transactions/heartbeat.go create mode 100644 heartbeat/README.md create mode 100644 heartbeat/abstractions.go create mode 100644 heartbeat/service.go create mode 100644 heartbeat/service_test.go create mode 100644 ledger/apply/challenge.go create mode 100644 ledger/apply/challenge_test.go create mode 100644 ledger/apply/heartbeat.go create mode 100644 ledger/apply/heartbeat_test.go create mode 100644 ledger/heartbeat_test.go create mode 100644 test/e2e-go/features/incentives/challenge_test.go create mode 100644 test/testdata/nettemplates/Challenges.json create mode 100644 util/set_test.go diff --git a/agreement/gossip/networkFull_test.go b/agreement/gossip/networkFull_test.go index 6507a6cb82..e64811d6a9 100644 --- a/agreement/gossip/networkFull_test.go +++ b/agreement/gossip/networkFull_test.go @@ -103,7 +103,7 @@ func spinNetwork(t *testing.T, nodesCount int, cfg config.Local) ([]*networkImpl break } } - log.Infof("network established, %d nodes connected in %s", nodesCount, time.Now().Sub(start).String()) + log.Infof("network established, %d nodes connected in %s", nodesCount, time.Since(start).String()) return networkImpls, msgCounters } diff --git a/catchup/universalFetcher.go b/catchup/universalFetcher.go index c7a8a9a4cf..fd99bcc612 100644 --- a/catchup/universalFetcher.go +++ b/catchup/universalFetcher.go @@ -88,7 +88,7 @@ func (uf *universalBlockFetcher) fetchBlock(ctx context.Context, round basics.Ro } else { return nil, nil, time.Duration(0), fmt.Errorf("fetchBlock: UniversalFetcher only supports HTTPPeer and UnicastPeer") } - downloadDuration = time.Now().Sub(blockDownloadStartTime) + downloadDuration = time.Since(blockDownloadStartTime) block, cert, err := processBlockBytes(fetchedBuf, round, address) if err != nil { return nil, nil, time.Duration(0), err diff --git a/cmd/goal/clerk.go b/cmd/goal/clerk.go index a69ed5be98..1a2495007d 100644 --- a/cmd/goal/clerk.go +++ b/cmd/goal/clerk.go @@ -221,8 +221,7 @@ func waitForCommit(client libgoal.Client, txid string, transactionLastValidRound } reportInfof(infoTxPending, txid, stat.LastRound) - // WaitForRound waits until round "stat.LastRound+1" is committed - stat, err = client.WaitForRound(stat.LastRound) + stat, err = client.WaitForRound(stat.LastRound + 1) if err != nil { return model.PendingTransactionResponse{}, fmt.Errorf(errorRequestFail, err) } diff --git a/cmd/loadgenerator/main.go b/cmd/loadgenerator/main.go index 6b82887695..df142de4ce 100644 --- a/cmd/loadgenerator/main.go +++ b/cmd/loadgenerator/main.go @@ -200,22 +200,23 @@ func waitForRound(restClient client.RestClient, cfg config, spendingRound bool) time.Sleep(1 * time.Second) continue } - if isSpendRound(cfg, nodeStatus.LastRound) == spendingRound { + lastRound := nodeStatus.LastRound + if isSpendRound(cfg, lastRound) == spendingRound { // time to send transactions. return } if spendingRound { - fmt.Printf("Last round %d, waiting for spending round %d\n", nodeStatus.LastRound, nextSpendRound(cfg, nodeStatus.LastRound)) + fmt.Printf("Last round %d, waiting for spending round %d\n", lastRound, nextSpendRound(cfg, nodeStatus.LastRound)) } for { // wait for the next round. - nodeStatus, err = restClient.WaitForBlock(basics.Round(nodeStatus.LastRound)) + err = restClient.WaitForRoundWithTimeout(lastRound + 1) if err != nil { fmt.Fprintf(os.Stderr, "unable to wait for next round node status : %v", err) - time.Sleep(1 * time.Second) break } - if isSpendRound(cfg, nodeStatus.LastRound) == spendingRound { + lastRound++ + if isSpendRound(cfg, lastRound) == spendingRound { // time to send transactions. return } diff --git a/config/consensus.go b/config/consensus.go index f86e45e831..58678571ab 100644 --- a/config/consensus.go +++ b/config/consensus.go @@ -540,6 +540,9 @@ type ConsensusParams struct { // occur, extra funds need to be put into the FeeSink. The bonus amount // decays exponentially. Bonus BonusPlan + + // Heartbeat support + Heartbeat bool } // ProposerPayoutRules puts several related consensus parameters in one place. The same @@ -1513,7 +1516,7 @@ func initConsensusProtocols() { vFuture.LogicSigVersion = 11 // When moving this to a release, put a new higher LogicSigVersion here vFuture.Payouts.Enabled = true - vFuture.Payouts.Percent = 75 + vFuture.Payouts.Percent = 50 vFuture.Payouts.GoOnlineFee = 2_000_000 // 2 algos vFuture.Payouts.MinBalance = 30_000_000_000 // 30,000 algos vFuture.Payouts.MaxBalance = 70_000_000_000_000 // 70M algos @@ -1524,7 +1527,9 @@ func initConsensusProtocols() { vFuture.Bonus.BaseAmount = 10_000_000 // 10 Algos // 2.9 sec rounds gives about 10.8M rounds per year. - vFuture.Bonus.DecayInterval = 250_000 // .99^(10.8/0.25) ~ .648. So 35% decay per year + vFuture.Bonus.DecayInterval = 1_000_000 // .99^(10.8M/1M) ~ .897. So ~10% decay per year + + vFuture.Heartbeat = true Consensus[protocol.ConsensusFuture] = vFuture diff --git a/config/consensus_test.go b/config/consensus_test.go index d7fbed4085..635ad5699c 100644 --- a/config/consensus_test.go +++ b/config/consensus_test.go @@ -37,6 +37,11 @@ func TestConsensusParams(t *testing.T) { if params.ApplyData && params.PaysetCommit == PaysetCommitUnsupported { t.Errorf("Protocol %s: ApplyData with PaysetCommitUnsupported", proto) } + + // To figure out challenges, nodes must be able to lookup headers up to two GracePeriods back + if 2*params.Payouts.ChallengeGracePeriod > params.MaxTxnLife+params.DeeperBlockHeaderHistory { + t.Errorf("Protocol %s: Grace period is too long", proto) + } } } diff --git a/crypto/msgp_gen.go b/crypto/msgp_gen.go index ab5bdceb88..fc279029a0 100644 --- a/crypto/msgp_gen.go +++ b/crypto/msgp_gen.go @@ -111,6 +111,16 @@ import ( // |-----> MsgIsZero // |-----> HashTypeMaxSize() // +// HeartbeatProof +// |-----> (*) MarshalMsg +// |-----> (*) CanMarshalMsg +// |-----> (*) UnmarshalMsg +// |-----> (*) UnmarshalMsgWithState +// |-----> (*) CanUnmarshalMsg +// |-----> (*) Msgsize +// |-----> (*) MsgIsZero +// |-----> HeartbeatProofMaxSize() +// // MasterDerivationKey // |-----> (*) MarshalMsg // |-----> (*) CanMarshalMsg @@ -1169,6 +1179,232 @@ func HashTypeMaxSize() (s int) { return } +// MarshalMsg implements msgp.Marshaler +func (z *HeartbeatProof) MarshalMsg(b []byte) (o []byte) { + o = msgp.Require(b, z.Msgsize()) + // omitempty: check for empty values + zb0006Len := uint32(5) + var zb0006Mask uint8 /* 6 bits */ + if (*z).PK == (ed25519PublicKey{}) { + zb0006Len-- + zb0006Mask |= 0x2 + } + if (*z).PK1Sig == (ed25519Signature{}) { + zb0006Len-- + zb0006Mask |= 0x4 + } + if (*z).PK2 == (ed25519PublicKey{}) { + zb0006Len-- + zb0006Mask |= 0x8 + } + if (*z).PK2Sig == (ed25519Signature{}) { + zb0006Len-- + zb0006Mask |= 0x10 + } + if (*z).Sig == (ed25519Signature{}) { + zb0006Len-- + zb0006Mask |= 0x20 + } + // variable map header, size zb0006Len + o = append(o, 0x80|uint8(zb0006Len)) + if zb0006Len != 0 { + if (zb0006Mask & 0x2) == 0 { // if not empty + // string "p" + o = append(o, 0xa1, 0x70) + o = msgp.AppendBytes(o, ((*z).PK)[:]) + } + if (zb0006Mask & 0x4) == 0 { // if not empty + // string "p1s" + o = append(o, 0xa3, 0x70, 0x31, 0x73) + o = msgp.AppendBytes(o, ((*z).PK1Sig)[:]) + } + if (zb0006Mask & 0x8) == 0 { // if not empty + // string "p2" + o = append(o, 0xa2, 0x70, 0x32) + o = msgp.AppendBytes(o, ((*z).PK2)[:]) + } + if (zb0006Mask & 0x10) == 0 { // if not empty + // string "p2s" + o = append(o, 0xa3, 0x70, 0x32, 0x73) + o = msgp.AppendBytes(o, ((*z).PK2Sig)[:]) + } + if (zb0006Mask & 0x20) == 0 { // if not empty + // string "s" + o = append(o, 0xa1, 0x73) + o = msgp.AppendBytes(o, ((*z).Sig)[:]) + } + } + return +} + +func (_ *HeartbeatProof) CanMarshalMsg(z interface{}) bool { + _, ok := (z).(*HeartbeatProof) + return ok +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *HeartbeatProof) UnmarshalMsgWithState(bts []byte, st msgp.UnmarshalState) (o []byte, err error) { + if st.AllowableDepth == 0 { + err = msgp.ErrMaxDepthExceeded{} + return + } + st.AllowableDepth-- + var field []byte + _ = field + var zb0006 int + var zb0007 bool + zb0006, zb0007, bts, err = msgp.ReadMapHeaderBytes(bts) + if _, ok := err.(msgp.TypeError); ok { + zb0006, zb0007, bts, err = msgp.ReadArrayHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + if zb0006 > 0 { + zb0006-- + bts, err = msgp.ReadExactBytes(bts, ((*z).Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "Sig") + return + } + } + if zb0006 > 0 { + zb0006-- + bts, err = msgp.ReadExactBytes(bts, ((*z).PK)[:]) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "PK") + return + } + } + if zb0006 > 0 { + zb0006-- + bts, err = msgp.ReadExactBytes(bts, ((*z).PK2)[:]) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "PK2") + return + } + } + if zb0006 > 0 { + zb0006-- + bts, err = msgp.ReadExactBytes(bts, ((*z).PK1Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "PK1Sig") + return + } + } + if zb0006 > 0 { + zb0006-- + bts, err = msgp.ReadExactBytes(bts, ((*z).PK2Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "PK2Sig") + return + } + } + if zb0006 > 0 { + err = msgp.ErrTooManyArrayFields(zb0006) + if err != nil { + err = msgp.WrapError(err, "struct-from-array") + return + } + } + } else { + if err != nil { + err = msgp.WrapError(err) + return + } + if zb0007 { + (*z) = HeartbeatProof{} + } + for zb0006 > 0 { + zb0006-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch string(field) { + case "s": + bts, err = msgp.ReadExactBytes(bts, ((*z).Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "Sig") + return + } + case "p": + bts, err = msgp.ReadExactBytes(bts, ((*z).PK)[:]) + if err != nil { + err = msgp.WrapError(err, "PK") + return + } + case "p2": + bts, err = msgp.ReadExactBytes(bts, ((*z).PK2)[:]) + if err != nil { + err = msgp.WrapError(err, "PK2") + return + } + case "p1s": + bts, err = msgp.ReadExactBytes(bts, ((*z).PK1Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "PK1Sig") + return + } + case "p2s": + bts, err = msgp.ReadExactBytes(bts, ((*z).PK2Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "PK2Sig") + return + } + default: + err = msgp.ErrNoField(string(field)) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + } + o = bts + return +} + +func (z *HeartbeatProof) UnmarshalMsg(bts []byte) (o []byte, err error) { + return z.UnmarshalMsgWithState(bts, msgp.DefaultUnmarshalState) +} +func (_ *HeartbeatProof) CanUnmarshalMsg(z interface{}) bool { + _, ok := (z).(*HeartbeatProof) + return ok +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *HeartbeatProof) Msgsize() (s int) { + s = 1 + 2 + msgp.ArrayHeaderSize + (64 * (msgp.ByteSize)) + 2 + msgp.ArrayHeaderSize + (32 * (msgp.ByteSize)) + 3 + msgp.ArrayHeaderSize + (32 * (msgp.ByteSize)) + 4 + msgp.ArrayHeaderSize + (64 * (msgp.ByteSize)) + 4 + msgp.ArrayHeaderSize + (64 * (msgp.ByteSize)) + return +} + +// MsgIsZero returns whether this is a zero value +func (z *HeartbeatProof) MsgIsZero() bool { + return ((*z).Sig == (ed25519Signature{})) && ((*z).PK == (ed25519PublicKey{})) && ((*z).PK2 == (ed25519PublicKey{})) && ((*z).PK1Sig == (ed25519Signature{})) && ((*z).PK2Sig == (ed25519Signature{})) +} + +// MaxSize returns a maximum valid message size for this message type +func HeartbeatProofMaxSize() (s int) { + s = 1 + 2 + // Calculating size of array: z.Sig + s += msgp.ArrayHeaderSize + ((64) * (msgp.ByteSize)) + s += 2 + // Calculating size of array: z.PK + s += msgp.ArrayHeaderSize + ((32) * (msgp.ByteSize)) + s += 3 + // Calculating size of array: z.PK2 + s += msgp.ArrayHeaderSize + ((32) * (msgp.ByteSize)) + s += 4 + // Calculating size of array: z.PK1Sig + s += msgp.ArrayHeaderSize + ((64) * (msgp.ByteSize)) + s += 4 + // Calculating size of array: z.PK2Sig + s += msgp.ArrayHeaderSize + ((64) * (msgp.ByteSize)) + return +} + // MarshalMsg implements msgp.Marshaler func (z *MasterDerivationKey) MarshalMsg(b []byte) (o []byte) { o = msgp.Require(b, z.Msgsize()) diff --git a/crypto/msgp_gen_test.go b/crypto/msgp_gen_test.go index b3fb95150b..0105a58f1d 100644 --- a/crypto/msgp_gen_test.go +++ b/crypto/msgp_gen_test.go @@ -434,6 +434,66 @@ func BenchmarkUnmarshalHashFactory(b *testing.B) { } } +func TestMarshalUnmarshalHeartbeatProof(t *testing.T) { + partitiontest.PartitionTest(t) + v := HeartbeatProof{} + bts := v.MarshalMsg(nil) + left, err := v.UnmarshalMsg(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left) + } + + left, err = msgp.Skip(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after Skip(): %q", len(left), left) + } +} + +func TestRandomizedEncodingHeartbeatProof(t *testing.T) { + protocol.RunEncodingTest(t, &HeartbeatProof{}) +} + +func BenchmarkMarshalMsgHeartbeatProof(b *testing.B) { + v := HeartbeatProof{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + v.MarshalMsg(nil) + } +} + +func BenchmarkAppendMsgHeartbeatProof(b *testing.B) { + v := HeartbeatProof{} + bts := make([]byte, 0, v.Msgsize()) + bts = v.MarshalMsg(bts[0:0]) + b.SetBytes(int64(len(bts))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + bts = v.MarshalMsg(bts[0:0]) + } +} + +func BenchmarkUnmarshalHeartbeatProof(b *testing.B) { + v := HeartbeatProof{} + bts := v.MarshalMsg(nil) + b.ReportAllocs() + b.SetBytes(int64(len(bts))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := v.UnmarshalMsg(bts) + if err != nil { + b.Fatal(err) + } + } +} + func TestMarshalUnmarshalMasterDerivationKey(t *testing.T) { partitiontest.PartitionTest(t) v := MasterDerivationKey{} diff --git a/crypto/onetimesig.go b/crypto/onetimesig.go index d05ccaa961..a817e590fe 100644 --- a/crypto/onetimesig.go +++ b/crypto/onetimesig.go @@ -57,6 +57,45 @@ type OneTimeSignature struct { PK2Sig ed25519Signature `codec:"p2s"` } +// A HeartbeatProof is functionally equivalent to a OneTimeSignature, but it has +// been cleaned up for use as a transaction field in heartbeat transactions. +type HeartbeatProof struct { + _struct struct{} `codec:",omitempty,omitemptyarray"` + + // Sig is a signature of msg under the key PK. + Sig ed25519Signature `codec:"s"` + PK ed25519PublicKey `codec:"p"` + + // PK2 is used to verify a two-level ephemeral signature. + PK2 ed25519PublicKey `codec:"p2"` + // PK1Sig is a signature of OneTimeSignatureSubkeyOffsetID(PK, Batch, Offset) under the key PK2. + PK1Sig ed25519Signature `codec:"p1s"` + // PK2Sig is a signature of OneTimeSignatureSubkeyBatchID(PK2, Batch) under the master key (OneTimeSignatureVerifier). + PK2Sig ed25519Signature `codec:"p2s"` +} + +// ToOneTimeSignature converts a HeartbeatProof to a OneTimeSignature. +func (hbp HeartbeatProof) ToOneTimeSignature() OneTimeSignature { + return OneTimeSignature{ + Sig: hbp.Sig, + PK: hbp.PK, + PK2: hbp.PK2, + PK1Sig: hbp.PK1Sig, + PK2Sig: hbp.PK2Sig, + } +} + +// ToHeartbeatProof converts a OneTimeSignature to a HeartbeatProof. +func (ots OneTimeSignature) ToHeartbeatProof() HeartbeatProof { + return HeartbeatProof{ + Sig: ots.Sig, + PK: ots.PK, + PK2: ots.PK2, + PK1Sig: ots.PK1Sig, + PK2Sig: ots.PK2Sig, + } +} + // A OneTimeSignatureSubkeyBatchID identifies an ephemeralSubkey of a batch // for the purposes of signing it with the top-level master key. type OneTimeSignatureSubkeyBatchID struct { diff --git a/daemon/algod/api/client/restClient.go b/daemon/algod/api/client/restClient.go index c349d3ecbf..e60ab1d36d 100644 --- a/daemon/algod/api/client/restClient.go +++ b/daemon/algod/api/client/restClient.go @@ -26,6 +26,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/google/go-querystring/query" @@ -39,6 +40,8 @@ import ( "github.com/algorand/go-algorand/ledger/eval" "github.com/algorand/go-algorand/ledger/ledgercore" "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/rpcs" + "github.com/algorand/go-algorand/test/e2e-go/globals" ) const ( @@ -283,12 +286,77 @@ func (client RestClient) Status() (response model.NodeStatusResponse, err error) return } -// WaitForBlock returns the node status after waiting for the given round. -func (client RestClient) WaitForBlock(round basics.Round) (response model.NodeStatusResponse, err error) { +// WaitForBlockAfter returns the node status after trying to wait for the given +// round+1. This REST API has the documented misfeatures of returning after 1 +// minute, regardless of whether the given block has been reached. +func (client RestClient) WaitForBlockAfter(round basics.Round) (response model.NodeStatusResponse, err error) { err = client.get(&response, fmt.Sprintf("/v2/status/wait-for-block-after/%d/", round), nil) return } +// WaitForRound returns the node status after waiting for the given round. +func (client RestClient) WaitForRound(round uint64, waitTime time.Duration) (status model.NodeStatusResponse, err error) { + timeout := time.NewTimer(waitTime) + for { + status, err = client.Status() + if err != nil { + return + } + + if status.LastRound >= round { + return + } + select { + case <-timeout.C: + return model.NodeStatusResponse{}, fmt.Errorf("timeout waiting for round %v with last round = %v", round, status.LastRound) + case <-time.After(200 * time.Millisecond): + } + } +} + +const singleRoundMaxTime = globals.MaxTimePerRound * 40 + +// WaitForRoundWithTimeout waits for a given round to be reached. As it +// waits, it returns early with an error if the wait time for any round exceeds +// globals.MaxTimePerRound so we can alert when we're getting "hung" waiting. +func (client RestClient) WaitForRoundWithTimeout(roundToWaitFor uint64) error { + status, err := client.Status() + if err != nil { + return err + } + lastRound := status.LastRound + + // If node is already at or past target round, we're done + if lastRound >= roundToWaitFor { + return nil + } + + roundComplete := make(chan error, 2) + + for nextRound := lastRound + 1; lastRound < roundToWaitFor; nextRound++ { + roundStarted := time.Now() + + go func(done chan error) { + stat, err := client.WaitForRound(nextRound, singleRoundMaxTime) + lastRound = stat.LastRound + done <- err + }(roundComplete) + + select { + case lastError := <-roundComplete: + if lastError != nil { + close(roundComplete) + return lastError + } + case <-time.After(singleRoundMaxTime): + // we've timed out. + time := time.Since(roundStarted) + return fmt.Errorf("fixture.WaitForRound took %3.2f seconds between round %d and %d", time.Seconds(), lastRound, nextRound) + } + } + return nil +} + // HealthCheck does a health check on the potentially running node, // returning an error if the API is down func (client RestClient) HealthCheck() error { @@ -301,14 +369,6 @@ func (client RestClient) ReadyCheck() error { return client.get(nil, "/ready", nil) } -// StatusAfterBlock waits for a block to occur then returns the StatusResponse after that block -// blocks on the node end -// Not supported -func (client RestClient) StatusAfterBlock(blockNum uint64) (response model.NodeStatusResponse, err error) { - err = client.get(&response, fmt.Sprintf("/v2/status/wait-for-block-after/%d", blockNum), nil) - return -} - type pendingTransactionsParams struct { Max uint64 `url:"max"` Format string `url:"format"` @@ -557,6 +617,16 @@ func (client RestClient) RawBlock(round uint64) (response []byte, err error) { return } +// EncodedBlockCert takes a round and returns its parsed block and certificate +func (client RestClient) EncodedBlockCert(round uint64) (blockCert rpcs.EncodedBlockCert, err error) { + resp, err := client.RawBlock(round) + if err != nil { + return + } + err = protocol.Decode(resp, &blockCert) + return +} + // Shutdown requests the node to shut itself down func (client RestClient) Shutdown() (err error) { response := 1 diff --git a/data/basics/userBalance.go b/data/basics/userBalance.go index 4db69bf22a..85167dec29 100644 --- a/data/basics/userBalance.go +++ b/data/basics/userBalance.go @@ -19,7 +19,6 @@ package basics import ( "encoding/binary" "fmt" - "reflect" "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/crypto" @@ -586,15 +585,6 @@ func (u OnlineAccountData) KeyDilution(proto config.ConsensusParams) uint64 { return proto.DefaultKeyDilution } -// IsZero checks if an AccountData value is the same as its zero value. -func (u AccountData) IsZero() bool { - if u.Assets != nil && len(u.Assets) == 0 { - u.Assets = nil - } - - return reflect.DeepEqual(u, AccountData{}) -} - // NormalizedOnlineBalance returns a “normalized” balance for this account. // // The normalization compensates for rewards that have not yet been applied, diff --git a/data/bookkeeping/block_test.go b/data/bookkeeping/block_test.go index 3c305b3c3b..bc8aec6a7a 100644 --- a/data/bookkeeping/block_test.go +++ b/data/bookkeeping/block_test.go @@ -1013,11 +1013,11 @@ func TestFirstYearsBonus(t *testing.T) { fmt.Printf("paid %d algos\n", suma) fmt.Printf("bonus start: %d end: %d\n", plan.BaseAmount, bonus) - // pays about 88M algos - a.InDelta(88_500_000, suma, 100_000) + // pays about 103.5M algos + a.InDelta(103_500_000, suma, 100_000) - // decline about 35% - a.InDelta(0.65, float64(bonus)/float64(plan.BaseAmount), 0.01) + // decline about 10% + a.InDelta(0.90, float64(bonus)/float64(plan.BaseAmount), 0.01) // year 2 for i := 0; i < yearRounds; i++ { @@ -1033,11 +1033,11 @@ func TestFirstYearsBonus(t *testing.T) { fmt.Printf("paid %d algos after 2 years\n", sum2) fmt.Printf("bonus end: %d\n", bonus) - // pays about 146M algos (total for 2 years) - a.InDelta(145_700_000, sum2, 100_000) + // pays about 196M algos (total for 2 years) + a.InDelta(196_300_000, sum2, 100_000) - // decline about 58% - a.InDelta(0.42, float64(bonus)/float64(plan.BaseAmount), 0.01) + // decline to about 81% + a.InDelta(0.81, float64(bonus)/float64(plan.BaseAmount), 0.01) // year 3 for i := 0; i < yearRounds; i++ { @@ -1053,9 +1053,9 @@ func TestFirstYearsBonus(t *testing.T) { fmt.Printf("paid %d algos after 3 years\n", sum3) fmt.Printf("bonus end: %d\n", bonus) - // pays about 182M algos (total for 3 years) - a.InDelta(182_600_000, sum3, 100_000) + // pays about 279M algos (total for 3 years) + a.InDelta(279_500_000, sum3, 100_000) - // declined to about 27% (but foundation funding probably gone anyway) - a.InDelta(0.27, float64(bonus)/float64(plan.BaseAmount), 0.01) + // declined to about 72% (but foundation funding probably gone anyway) + a.InDelta(0.72, float64(bonus)/float64(plan.BaseAmount), 0.01) } diff --git a/data/committee/common_test.go b/data/committee/common_test.go index 1f7e7bd373..8566a9cd2a 100644 --- a/data/committee/common_test.go +++ b/data/committee/common_test.go @@ -24,7 +24,6 @@ import ( "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/crypto" "github.com/algorand/go-algorand/data/basics" - "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/protocol" ) @@ -33,40 +32,33 @@ type selectionParameterListFn func(addr []basics.Address) (bool, []BalanceRecord var proto = config.Consensus[protocol.ConsensusCurrentVersion] -func newAccount(t testing.TB, gen io.Reader, latest basics.Round, keyBatchesForward uint) (basics.Address, *crypto.SignatureSecrets, *crypto.VrfPrivkey, *crypto.OneTimeSignatureSecrets) { +func newAccount(t testing.TB, gen io.Reader) (basics.Address, *crypto.SignatureSecrets, *crypto.VrfPrivkey) { var seed crypto.Seed gen.Read(seed[:]) s := crypto.GenerateSignatureSecrets(seed) _, v := crypto.VrfKeygenFromSeed(seed) - o := crypto.GenerateOneTimeSignatureSecrets(basics.OneTimeIDForRound(latest, proto.DefaultKeyDilution).Batch, uint64(keyBatchesForward)) addr := basics.Address(s.SignatureVerifier) - return addr, s, &v, o + return addr, s, &v } -func signTx(s *crypto.SignatureSecrets, t transactions.Transaction) transactions.SignedTxn { - return t.Sign(s) +// testingenv creates a random set of participating accounts and the associated +// selection parameters for use testing committee membership and credential +// validation. seedGen is provided as an external source of randomness for the +// selection seed; if the caller persists seedGen between calls to testingenv, +// each iteration that calls testingenv will exercise a new selection seed. +// formerly, testingenv, generated transactions and one-time secrets as well, +// but they were not used by the tests. +func testingenv(t testing.TB, numAccounts, numTxs int, seedGen io.Reader) (selectionParameterFn, selectionParameterListFn, basics.Round, []basics.Address, []*crypto.SignatureSecrets, []*crypto.VrfPrivkey) { + return testingenvMoreKeys(t, numAccounts, numTxs, seedGen) } -// testingenv creates a random set of participating accounts and random transactions between them, and -// the associated selection parameters for use testing committee membership and credential validation. -// seedGen is provided as an external source of randomness for the selection seed and transaction notes; -// if the caller persists seedGen between calls to testingenv, each iteration that calls testingenv will -// exercise a new selection seed. -func testingenv(t testing.TB, numAccounts, numTxs int, seedGen io.Reader) (selectionParameterFn, selectionParameterListFn, basics.Round, []basics.Address, []*crypto.SignatureSecrets, []*crypto.VrfPrivkey, []*crypto.OneTimeSignatureSecrets, []transactions.SignedTxn) { - return testingenvMoreKeys(t, numAccounts, numTxs, uint(5), seedGen) -} - -func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, keyBatchesForward uint, seedGen io.Reader) (selectionParameterFn, selectionParameterListFn, basics.Round, []basics.Address, []*crypto.SignatureSecrets, []*crypto.VrfPrivkey, []*crypto.OneTimeSignatureSecrets, []transactions.SignedTxn) { +func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, seedGen io.Reader) (selectionParameterFn, selectionParameterListFn, basics.Round, []basics.Address, []*crypto.SignatureSecrets, []*crypto.VrfPrivkey) { if seedGen == nil { seedGen = rand.New(rand.NewSource(1)) // same source as setting GODEBUG=randautoseed=0, same as pre-Go 1.20 default seed } P := numAccounts // n accounts - TXs := numTxs // n txns maxMoneyAtStart := 100000 // max money start minMoneyAtStart := 10000 // max money start - transferredMoney := 100 // max money/txn - maxFee := 10 // max maxFee/txn - E := basics.Round(50) // max round // generate accounts genesis := make(map[basics.Address]basics.AccountData) @@ -74,16 +66,14 @@ func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, keyBatchesForward addrs := make([]basics.Address, P) secrets := make([]*crypto.SignatureSecrets, P) vrfSecrets := make([]*crypto.VrfPrivkey, P) - otSecrets := make([]*crypto.OneTimeSignatureSecrets, P) proto := config.Consensus[protocol.ConsensusCurrentVersion] lookback := basics.Round(2*proto.SeedRefreshInterval + proto.SeedLookback + 1) var total basics.MicroAlgos for i := 0; i < P; i++ { - addr, sigSec, vrfSec, otSec := newAccount(t, gen, lookback, keyBatchesForward) + addr, sigSec, vrfSec := newAccount(t, gen) addrs[i] = addr secrets[i] = sigSec vrfSecrets[i] = vrfSec - otSecrets[i] = otSec startamt := uint64(minMoneyAtStart + (gen.Int() % (maxMoneyAtStart - minMoneyAtStart))) short := addr @@ -91,7 +81,6 @@ func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, keyBatchesForward Status: basics.Online, MicroAlgos: basics.MicroAlgos{Raw: startamt}, SelectionID: vrfSec.Pubkey(), - VoteID: otSec.OneTimeSignatureVerifier, } total.Raw += startamt } @@ -99,32 +88,8 @@ func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, keyBatchesForward var seed Seed seedGen.Read(seed[:]) - tx := make([]transactions.SignedTxn, TXs) - for i := 0; i < TXs; i++ { - send := gen.Int() % P - recv := gen.Int() % P - - saddr := addrs[send] - raddr := addrs[recv] - amt := basics.MicroAlgos{Raw: uint64(gen.Int() % transferredMoney)} - fee := basics.MicroAlgos{Raw: uint64(gen.Int() % maxFee)} - - t := transactions.Transaction{ - Type: protocol.PaymentTx, - Header: transactions.Header{ - Sender: saddr, - Fee: fee, - FirstValid: 0, - LastValid: E, - Note: make([]byte, 4), - }, - PaymentTxnFields: transactions.PaymentTxnFields{ - Receiver: raddr, - Amount: amt, - }, - } - seedGen.Read(t.Note) // to match output from previous versions, which shared global RNG for seed & note - tx[i] = t.Sign(secrets[send]) + for i := 0; i < numTxs; i++ { + seedGen.Read(make([]byte, 4)) // to match output from previous versions, which shared global RNG for seed & note } selParams := func(addr basics.Address) (bool, BalanceRecord, Seed, basics.MicroAlgos) { @@ -149,7 +114,7 @@ func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, keyBatchesForward return } - return selParams, selParamsList, lookback, addrs, secrets, vrfSecrets, otSecrets, tx + return selParams, selParamsList, lookback, addrs, secrets, vrfSecrets } /* TODO deprecate these types after they have been removed successfully */ diff --git a/data/committee/credential_test.go b/data/committee/credential_test.go index da2be625cd..bbabac62e9 100644 --- a/data/committee/credential_test.go +++ b/data/committee/credential_test.go @@ -35,7 +35,7 @@ func TestAccountSelected(t *testing.T) { seedGen := rand.New(rand.NewSource(1)) N := 1 for i := 0; i < N; i++ { - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, seedGen) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, seedGen) period := Period(0) leaders := uint64(0) @@ -98,7 +98,7 @@ func TestAccountSelected(t *testing.T) { func TestRichAccountSelected(t *testing.T) { partitiontest.PartitionTest(t) - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 10, 2000, nil) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 10, 2000, nil) period := Period(0) ok, record, selectionSeed, _ := selParams(addresses[0]) @@ -159,7 +159,7 @@ func TestPoorAccountSelectedLeaders(t *testing.T) { failsLeaders := 0 leaders := make([]uint64, N) for i := 0; i < N; i++ { - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, seedGen) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, seedGen) period := Period(0) for j := range addresses { ok, record, selectionSeed, _ := selParams(addresses[j]) @@ -207,7 +207,7 @@ func TestPoorAccountSelectedCommittee(t *testing.T) { N := 1 committee := uint64(0) for i := 0; i < N; i++ { - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, seedGen) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, seedGen) period := Period(0) step := Cert @@ -250,10 +250,9 @@ func TestNoMoneyAccountNotSelected(t *testing.T) { seedGen := rand.New(rand.NewSource(1)) N := 1 for i := 0; i < N; i++ { - selParams, _, round, addresses, _, _, _, _ := testingenv(t, 10, 2000, seedGen) - lookback := basics.Round(2*proto.SeedRefreshInterval + proto.SeedLookback + 1) + selParams, _, round, addresses, _, _ := testingenv(t, 10, 2000, seedGen) gen := rand.New(rand.NewSource(2)) - _, _, zeroVRFSecret, _ := newAccount(t, gen, lookback, 5) + _, _, zeroVRFSecret := newAccount(t, gen) period := Period(0) ok, record, selectionSeed, _ := selParams(addresses[i]) if !ok { @@ -281,7 +280,7 @@ func TestNoMoneyAccountNotSelected(t *testing.T) { func TestLeadersSelected(t *testing.T) { partitiontest.PartitionTest(t) - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, nil) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, nil) period := Period(0) step := Propose @@ -313,7 +312,7 @@ func TestLeadersSelected(t *testing.T) { func TestCommitteeSelected(t *testing.T) { partitiontest.PartitionTest(t) - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, nil) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, nil) period := Period(0) step := Soft @@ -345,7 +344,7 @@ func TestCommitteeSelected(t *testing.T) { func TestAccountNotSelected(t *testing.T) { partitiontest.PartitionTest(t) - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, nil) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, nil) period := Period(0) leaders := uint64(0) for i := range addresses { @@ -375,7 +374,7 @@ func TestAccountNotSelected(t *testing.T) { // TODO update to remove VRF verification overhead func BenchmarkSortition(b *testing.B) { - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(b, 100, 2000, nil) + selParams, _, round, addresses, _, vrfSecrets := testingenv(b, 100, 2000, nil) period := Period(0) step := Soft diff --git a/data/transactions/heartbeat.go b/data/transactions/heartbeat.go new file mode 100644 index 0000000000..48a4df6c69 --- /dev/null +++ b/data/transactions/heartbeat.go @@ -0,0 +1,42 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package transactions + +import ( + "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/committee" +) + +// HeartbeatTxnFields captures the fields used for an account to prove it is +// online (really, it proves that an entity with the account's part keys is able +// to submit transactions, so it should be able to propose/vote.) +type HeartbeatTxnFields struct { + _struct struct{} `codec:",omitempty,omitemptyarray"` + + // HeartbeatAddress is the account this txn is proving onlineness for. + HbAddress basics.Address `codec:"hbad"` + + // HbProof is a signature using HeartbeatAddress's partkey, thereby showing it is online. + HbProof crypto.HeartbeatProof `codec:"hbprf"` + + // HbSeed must be the block seed for the block before this transaction's + // firstValid. It is supplied in the transaction so that Proof can be + // checked at submit time without a ledger lookup, and must be checked at + // evaluation time for equality with the actual blockseed. + HbSeed committee.Seed `codec:"hbsd"` +} diff --git a/data/transactions/logic/assembler.go b/data/transactions/logic/assembler.go index a707e23f65..9c625d3d56 100644 --- a/data/transactions/logic/assembler.go +++ b/data/transactions/logic/assembler.go @@ -2738,6 +2738,16 @@ func AssembleString(text string) (*OpStream, error) { return AssembleStringWithVersion(text, assemblerNoVersion) } +// MustAssemble assembles a program and panics on error. It is useful for +// defining globals. +func MustAssemble(text string) []byte { + ops, err := AssembleString(text) + if err != nil { + panic(err) + } + return ops.Program +} + // AssembleStringWithVersion takes an entire program in a string and // assembles it to bytecode using the assembler version specified. If // version is assemblerNoVersion it uses #pragma version or fallsback diff --git a/data/transactions/logic/crypto_test.go b/data/transactions/logic/crypto_test.go index 0ba695dce5..1298f34c9f 100644 --- a/data/transactions/logic/crypto_test.go +++ b/data/transactions/logic/crypto_test.go @@ -217,13 +217,17 @@ pop // output`, "int 1"}, } } +func randSeed() crypto.Seed { + var s crypto.Seed + crypto.RandBytes(s[:]) + return s +} + func TestEd25519verify(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() - var s crypto.Seed - crypto.RandBytes(s[:]) - c := crypto.GenerateSignatureSecrets(s) + c := crypto.GenerateSignatureSecrets(randSeed()) msg := "62fdfc072182654f163f5f0f9a621d729566c74d0aa413bf009c9800418c19cd" data, err := hex.DecodeString(msg) require.NoError(t, err) @@ -262,9 +266,7 @@ func TestEd25519VerifyBare(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() - var s crypto.Seed - crypto.RandBytes(s[:]) - c := crypto.GenerateSignatureSecrets(s) + c := crypto.GenerateSignatureSecrets(randSeed()) msg := "62fdfc072182654f163f5f0f9a621d729566c74d0aa413bf009c9800418c19cd" data, err := hex.DecodeString(msg) require.NoError(t, err) @@ -743,9 +745,7 @@ func BenchmarkEd25519Verifyx1(b *testing.B) { crypto.RandBytes(buffer[:]) data = append(data, buffer) - var s crypto.Seed //generate programs and signatures - crypto.RandBytes(s[:]) - secret := crypto.GenerateSignatureSecrets(s) + secret := crypto.GenerateSignatureSecrets(randSeed()) //generate programs and signatures pk := basics.Address(secret.SignatureVerifier) pkStr := pk.String() ops, err := AssembleStringWithVersion(fmt.Sprintf(`arg 0 diff --git a/data/transactions/logic/eval_test.go b/data/transactions/logic/eval_test.go index c8f7a8bc5f..701acedaab 100644 --- a/data/transactions/logic/eval_test.go +++ b/data/transactions/logic/eval_test.go @@ -415,7 +415,7 @@ func TestBlankStackSufficient(t *testing.T) { spec := opsByOpcode[v][i] argLen := len(spec.Arg.Types) blankStackLen := len(blankStack) - require.GreaterOrEqual(t, blankStackLen, argLen) + require.GreaterOrEqual(t, blankStackLen, argLen, spec.Name) } }) } diff --git a/data/transactions/logic/ledger_test.go b/data/transactions/logic/ledger_test.go index 3dcead5e51..b16694d48c 100644 --- a/data/transactions/logic/ledger_test.go +++ b/data/transactions/logic/ledger_test.go @@ -46,9 +46,14 @@ import ( ) type balanceRecord struct { - addr basics.Address - auth basics.Address - balance uint64 + addr basics.Address + auth basics.Address + balance uint64 + voting basics.VotingData + + proposed basics.Round // The last round that this account proposed the accepted block + heartbeat basics.Round // The last round that this account sent a heartbeat to show it was online. + locals map[basics.AppIndex]basics.TealKeyValue holdings map[basics.AssetIndex]basics.AssetHolding mods map[basics.AppIndex]map[string]basics.ValueDelta @@ -119,6 +124,18 @@ func (l *Ledger) NewAccount(addr basics.Address, balance uint64) { l.balances[addr] = newBalanceRecord(addr, balance) } +// NewVoting sets VoteID on the account. Could expand to set other voting data +// if that became useful in tests. +func (l *Ledger) NewVoting(addr basics.Address, voteID crypto.OneTimeSignatureVerifier) { + br, ok := l.balances[addr] + if !ok { + br = newBalanceRecord(addr, 0) + } + br.voting.VoteID = voteID + br.voting.VoteKeyDilution = 10_000 + l.balances[addr] = br +} + // NewApp add a new AVM app to the Ledger. In most uses, it only sets up the id // and schema but no code, as testing will want to try many different code // sequences. @@ -312,7 +329,11 @@ func (l *Ledger) AccountData(addr basics.Address) (ledgercore.AccountData, error TotalBoxes: uint64(boxesTotal), TotalBoxBytes: uint64(boxBytesTotal), + + LastProposed: br.proposed, + LastHeartbeat: br.heartbeat, }, + VotingData: br.voting, }, nil } @@ -952,6 +973,8 @@ func (l *Ledger) Get(addr basics.Address, withPendingRewards bool) (basics.Accou Assets: map[basics.AssetIndex]basics.AssetHolding{}, AppLocalStates: map[basics.AppIndex]basics.AppLocalState{}, AppParams: map[basics.AppIndex]basics.AppParams{}, + LastProposed: br.proposed, + LastHeartbeat: br.heartbeat, }, nil } diff --git a/data/transactions/msgp_gen.go b/data/transactions/msgp_gen.go index 7cc22db08a..edc229bffe 100644 --- a/data/transactions/msgp_gen.go +++ b/data/transactions/msgp_gen.go @@ -12,6 +12,7 @@ import ( "github.com/algorand/go-algorand/crypto/merklesignature" "github.com/algorand/go-algorand/crypto/stateproof" "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/committee" "github.com/algorand/go-algorand/data/stateproofmsg" "github.com/algorand/go-algorand/protocol" ) @@ -97,6 +98,16 @@ import ( // |-----> (*) MsgIsZero // |-----> HeaderMaxSize() // +// HeartbeatTxnFields +// |-----> (*) MarshalMsg +// |-----> (*) CanMarshalMsg +// |-----> (*) UnmarshalMsg +// |-----> (*) UnmarshalMsgWithState +// |-----> (*) CanUnmarshalMsg +// |-----> (*) Msgsize +// |-----> (*) MsgIsZero +// |-----> HeartbeatTxnFieldsMaxSize() +// // KeyregTxnFields // |-----> (*) MarshalMsg // |-----> (*) CanMarshalMsg @@ -2907,6 +2918,172 @@ func HeaderMaxSize() (s int) { return } +// MarshalMsg implements msgp.Marshaler +func (z *HeartbeatTxnFields) MarshalMsg(b []byte) (o []byte) { + o = msgp.Require(b, z.Msgsize()) + // omitempty: check for empty values + zb0001Len := uint32(3) + var zb0001Mask uint8 /* 4 bits */ + if (*z).HbAddress.MsgIsZero() { + zb0001Len-- + zb0001Mask |= 0x2 + } + if (*z).HbProof.MsgIsZero() { + zb0001Len-- + zb0001Mask |= 0x4 + } + if (*z).HbSeed.MsgIsZero() { + zb0001Len-- + zb0001Mask |= 0x8 + } + // variable map header, size zb0001Len + o = append(o, 0x80|uint8(zb0001Len)) + if zb0001Len != 0 { + if (zb0001Mask & 0x2) == 0 { // if not empty + // string "hbad" + o = append(o, 0xa4, 0x68, 0x62, 0x61, 0x64) + o = (*z).HbAddress.MarshalMsg(o) + } + if (zb0001Mask & 0x4) == 0 { // if not empty + // string "hbprf" + o = append(o, 0xa5, 0x68, 0x62, 0x70, 0x72, 0x66) + o = (*z).HbProof.MarshalMsg(o) + } + if (zb0001Mask & 0x8) == 0 { // if not empty + // string "hbsd" + o = append(o, 0xa4, 0x68, 0x62, 0x73, 0x64) + o = (*z).HbSeed.MarshalMsg(o) + } + } + return +} + +func (_ *HeartbeatTxnFields) CanMarshalMsg(z interface{}) bool { + _, ok := (z).(*HeartbeatTxnFields) + return ok +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *HeartbeatTxnFields) UnmarshalMsgWithState(bts []byte, st msgp.UnmarshalState) (o []byte, err error) { + if st.AllowableDepth == 0 { + err = msgp.ErrMaxDepthExceeded{} + return + } + st.AllowableDepth-- + var field []byte + _ = field + var zb0001 int + var zb0002 bool + zb0001, zb0002, bts, err = msgp.ReadMapHeaderBytes(bts) + if _, ok := err.(msgp.TypeError); ok { + zb0001, zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + if zb0001 > 0 { + zb0001-- + bts, err = (*z).HbAddress.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbAddress") + return + } + } + if zb0001 > 0 { + zb0001-- + bts, err = (*z).HbProof.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbProof") + return + } + } + if zb0001 > 0 { + zb0001-- + bts, err = (*z).HbSeed.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbSeed") + return + } + } + if zb0001 > 0 { + err = msgp.ErrTooManyArrayFields(zb0001) + if err != nil { + err = msgp.WrapError(err, "struct-from-array") + return + } + } + } else { + if err != nil { + err = msgp.WrapError(err) + return + } + if zb0002 { + (*z) = HeartbeatTxnFields{} + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch string(field) { + case "hbad": + bts, err = (*z).HbAddress.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbAddress") + return + } + case "hbprf": + bts, err = (*z).HbProof.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbProof") + return + } + case "hbsd": + bts, err = (*z).HbSeed.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbSeed") + return + } + default: + err = msgp.ErrNoField(string(field)) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + } + o = bts + return +} + +func (z *HeartbeatTxnFields) UnmarshalMsg(bts []byte) (o []byte, err error) { + return z.UnmarshalMsgWithState(bts, msgp.DefaultUnmarshalState) +} +func (_ *HeartbeatTxnFields) CanUnmarshalMsg(z interface{}) bool { + _, ok := (z).(*HeartbeatTxnFields) + return ok +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *HeartbeatTxnFields) Msgsize() (s int) { + s = 1 + 5 + (*z).HbAddress.Msgsize() + 6 + (*z).HbProof.Msgsize() + 5 + (*z).HbSeed.Msgsize() + return +} + +// MsgIsZero returns whether this is a zero value +func (z *HeartbeatTxnFields) MsgIsZero() bool { + return ((*z).HbAddress.MsgIsZero()) && ((*z).HbProof.MsgIsZero()) && ((*z).HbSeed.MsgIsZero()) +} + +// MaxSize returns a maximum valid message size for this message type +func HeartbeatTxnFieldsMaxSize() (s int) { + s = 1 + 5 + basics.AddressMaxSize() + 6 + crypto.HeartbeatProofMaxSize() + 5 + committee.SeedMaxSize() + return +} + // MarshalMsg implements msgp.Marshaler func (z *KeyregTxnFields) MarshalMsg(b []byte) (o []byte) { o = msgp.Require(b, z.Msgsize()) @@ -4982,216 +5159,228 @@ func StateProofTxnFieldsMaxSize() (s int) { func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = msgp.Require(b, z.Msgsize()) // omitempty: check for empty values - zb0007Len := uint32(46) - var zb0007Mask uint64 /* 55 bits */ + zb0007Len := uint32(49) + var zb0007Mask uint64 /* 59 bits */ if (*z).AssetTransferTxnFields.AssetAmount == 0 { zb0007Len-- - zb0007Mask |= 0x200 + zb0007Mask |= 0x400 } if (*z).AssetTransferTxnFields.AssetCloseTo.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x400 + zb0007Mask |= 0x800 } if (*z).AssetFreezeTxnFields.AssetFrozen == false { zb0007Len-- - zb0007Mask |= 0x800 + zb0007Mask |= 0x1000 } if (*z).PaymentTxnFields.Amount.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x1000 + zb0007Mask |= 0x2000 } if len((*z).ApplicationCallTxnFields.ApplicationArgs) == 0 { zb0007Len-- - zb0007Mask |= 0x2000 + zb0007Mask |= 0x4000 } if (*z).ApplicationCallTxnFields.OnCompletion == 0 { zb0007Len-- - zb0007Mask |= 0x4000 + zb0007Mask |= 0x8000 } if len((*z).ApplicationCallTxnFields.ApprovalProgram) == 0 { zb0007Len-- - zb0007Mask |= 0x8000 + zb0007Mask |= 0x10000 } if (*z).AssetConfigTxnFields.AssetParams.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x10000 + zb0007Mask |= 0x20000 } if len((*z).ApplicationCallTxnFields.ForeignAssets) == 0 { zb0007Len-- - zb0007Mask |= 0x20000 + zb0007Mask |= 0x40000 } if len((*z).ApplicationCallTxnFields.Accounts) == 0 { zb0007Len-- - zb0007Mask |= 0x40000 + zb0007Mask |= 0x80000 } if len((*z).ApplicationCallTxnFields.Boxes) == 0 { zb0007Len-- - zb0007Mask |= 0x80000 + zb0007Mask |= 0x100000 } if (*z).ApplicationCallTxnFields.ExtraProgramPages == 0 { zb0007Len-- - zb0007Mask |= 0x100000 + zb0007Mask |= 0x200000 } if len((*z).ApplicationCallTxnFields.ForeignApps) == 0 { zb0007Len-- - zb0007Mask |= 0x200000 + zb0007Mask |= 0x400000 } if (*z).ApplicationCallTxnFields.GlobalStateSchema.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x400000 + zb0007Mask |= 0x800000 } if (*z).ApplicationCallTxnFields.ApplicationID.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x800000 + zb0007Mask |= 0x1000000 } if (*z).ApplicationCallTxnFields.LocalStateSchema.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x1000000 + zb0007Mask |= 0x2000000 } if len((*z).ApplicationCallTxnFields.ClearStateProgram) == 0 { zb0007Len-- - zb0007Mask |= 0x2000000 + zb0007Mask |= 0x4000000 } if (*z).AssetTransferTxnFields.AssetReceiver.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x4000000 + zb0007Mask |= 0x8000000 } if (*z).AssetTransferTxnFields.AssetSender.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x8000000 + zb0007Mask |= 0x10000000 } if (*z).AssetConfigTxnFields.ConfigAsset.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x10000000 + zb0007Mask |= 0x20000000 } if (*z).PaymentTxnFields.CloseRemainderTo.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x20000000 + zb0007Mask |= 0x40000000 } if (*z).AssetFreezeTxnFields.FreezeAccount.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x40000000 + zb0007Mask |= 0x80000000 } if (*z).AssetFreezeTxnFields.FreezeAsset.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x80000000 + zb0007Mask |= 0x100000000 } if (*z).Header.Fee.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x100000000 + zb0007Mask |= 0x200000000 } if (*z).Header.FirstValid.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x200000000 + zb0007Mask |= 0x400000000 } if (*z).Header.GenesisID == "" { zb0007Len-- - zb0007Mask |= 0x400000000 + zb0007Mask |= 0x800000000 } if (*z).Header.GenesisHash.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x800000000 + zb0007Mask |= 0x1000000000 } if (*z).Header.Group.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x1000000000 + zb0007Mask |= 0x2000000000 + } + if (*z).HeartbeatTxnFields.HbAddress.MsgIsZero() { + zb0007Len-- + zb0007Mask |= 0x4000000000 + } + if (*z).HeartbeatTxnFields.HbProof.MsgIsZero() { + zb0007Len-- + zb0007Mask |= 0x8000000000 + } + if (*z).HeartbeatTxnFields.HbSeed.MsgIsZero() { + zb0007Len-- + zb0007Mask |= 0x10000000000 } if (*z).Header.LastValid.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x2000000000 + zb0007Mask |= 0x20000000000 } if (*z).Header.Lease == ([32]byte{}) { zb0007Len-- - zb0007Mask |= 0x4000000000 + zb0007Mask |= 0x40000000000 } if (*z).KeyregTxnFields.Nonparticipation == false { zb0007Len-- - zb0007Mask |= 0x8000000000 + zb0007Mask |= 0x80000000000 } if len((*z).Header.Note) == 0 { zb0007Len-- - zb0007Mask |= 0x10000000000 + zb0007Mask |= 0x100000000000 } if (*z).PaymentTxnFields.Receiver.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x20000000000 + zb0007Mask |= 0x200000000000 } if (*z).Header.RekeyTo.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x40000000000 + zb0007Mask |= 0x400000000000 } if (*z).KeyregTxnFields.SelectionPK.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x80000000000 + zb0007Mask |= 0x800000000000 } if (*z).Header.Sender.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x100000000000 + zb0007Mask |= 0x1000000000000 } if (*z).StateProofTxnFields.StateProof.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x200000000000 + zb0007Mask |= 0x2000000000000 } if (*z).StateProofTxnFields.Message.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x400000000000 + zb0007Mask |= 0x4000000000000 } if (*z).KeyregTxnFields.StateProofPK.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x800000000000 + zb0007Mask |= 0x8000000000000 } if (*z).StateProofTxnFields.StateProofType.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x1000000000000 + zb0007Mask |= 0x10000000000000 } if (*z).Type.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x2000000000000 + zb0007Mask |= 0x20000000000000 } if (*z).KeyregTxnFields.VoteFirst.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x4000000000000 + zb0007Mask |= 0x40000000000000 } if (*z).KeyregTxnFields.VoteKeyDilution == 0 { zb0007Len-- - zb0007Mask |= 0x8000000000000 + zb0007Mask |= 0x80000000000000 } if (*z).KeyregTxnFields.VotePK.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x10000000000000 + zb0007Mask |= 0x100000000000000 } if (*z).KeyregTxnFields.VoteLast.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x20000000000000 + zb0007Mask |= 0x200000000000000 } if (*z).AssetTransferTxnFields.XferAsset.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x40000000000000 + zb0007Mask |= 0x400000000000000 } // variable map header, size zb0007Len o = msgp.AppendMapHeader(o, zb0007Len) if zb0007Len != 0 { - if (zb0007Mask & 0x200) == 0 { // if not empty + if (zb0007Mask & 0x400) == 0 { // if not empty // string "aamt" o = append(o, 0xa4, 0x61, 0x61, 0x6d, 0x74) o = msgp.AppendUint64(o, (*z).AssetTransferTxnFields.AssetAmount) } - if (zb0007Mask & 0x400) == 0 { // if not empty + if (zb0007Mask & 0x800) == 0 { // if not empty // string "aclose" o = append(o, 0xa6, 0x61, 0x63, 0x6c, 0x6f, 0x73, 0x65) o = (*z).AssetTransferTxnFields.AssetCloseTo.MarshalMsg(o) } - if (zb0007Mask & 0x800) == 0 { // if not empty + if (zb0007Mask & 0x1000) == 0 { // if not empty // string "afrz" o = append(o, 0xa4, 0x61, 0x66, 0x72, 0x7a) o = msgp.AppendBool(o, (*z).AssetFreezeTxnFields.AssetFrozen) } - if (zb0007Mask & 0x1000) == 0 { // if not empty + if (zb0007Mask & 0x2000) == 0 { // if not empty // string "amt" o = append(o, 0xa3, 0x61, 0x6d, 0x74) o = (*z).PaymentTxnFields.Amount.MarshalMsg(o) } - if (zb0007Mask & 0x2000) == 0 { // if not empty + if (zb0007Mask & 0x4000) == 0 { // if not empty // string "apaa" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x61) if (*z).ApplicationCallTxnFields.ApplicationArgs == nil { @@ -5203,22 +5392,22 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = msgp.AppendBytes(o, (*z).ApplicationCallTxnFields.ApplicationArgs[zb0002]) } } - if (zb0007Mask & 0x4000) == 0 { // if not empty + if (zb0007Mask & 0x8000) == 0 { // if not empty // string "apan" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x6e) o = msgp.AppendUint64(o, uint64((*z).ApplicationCallTxnFields.OnCompletion)) } - if (zb0007Mask & 0x8000) == 0 { // if not empty + if (zb0007Mask & 0x10000) == 0 { // if not empty // string "apap" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x70) o = msgp.AppendBytes(o, (*z).ApplicationCallTxnFields.ApprovalProgram) } - if (zb0007Mask & 0x10000) == 0 { // if not empty + if (zb0007Mask & 0x20000) == 0 { // if not empty // string "apar" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x72) o = (*z).AssetConfigTxnFields.AssetParams.MarshalMsg(o) } - if (zb0007Mask & 0x20000) == 0 { // if not empty + if (zb0007Mask & 0x40000) == 0 { // if not empty // string "apas" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x73) if (*z).ApplicationCallTxnFields.ForeignAssets == nil { @@ -5230,7 +5419,7 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = (*z).ApplicationCallTxnFields.ForeignAssets[zb0006].MarshalMsg(o) } } - if (zb0007Mask & 0x40000) == 0 { // if not empty + if (zb0007Mask & 0x80000) == 0 { // if not empty // string "apat" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x74) if (*z).ApplicationCallTxnFields.Accounts == nil { @@ -5242,7 +5431,7 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = (*z).ApplicationCallTxnFields.Accounts[zb0003].MarshalMsg(o) } } - if (zb0007Mask & 0x80000) == 0 { // if not empty + if (zb0007Mask & 0x100000) == 0 { // if not empty // string "apbx" o = append(o, 0xa4, 0x61, 0x70, 0x62, 0x78) if (*z).ApplicationCallTxnFields.Boxes == nil { @@ -5276,12 +5465,12 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { } } } - if (zb0007Mask & 0x100000) == 0 { // if not empty + if (zb0007Mask & 0x200000) == 0 { // if not empty // string "apep" o = append(o, 0xa4, 0x61, 0x70, 0x65, 0x70) o = msgp.AppendUint32(o, (*z).ApplicationCallTxnFields.ExtraProgramPages) } - if (zb0007Mask & 0x200000) == 0 { // if not empty + if (zb0007Mask & 0x400000) == 0 { // if not empty // string "apfa" o = append(o, 0xa4, 0x61, 0x70, 0x66, 0x61) if (*z).ApplicationCallTxnFields.ForeignApps == nil { @@ -5293,167 +5482,182 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = (*z).ApplicationCallTxnFields.ForeignApps[zb0004].MarshalMsg(o) } } - if (zb0007Mask & 0x400000) == 0 { // if not empty + if (zb0007Mask & 0x800000) == 0 { // if not empty // string "apgs" o = append(o, 0xa4, 0x61, 0x70, 0x67, 0x73) o = (*z).ApplicationCallTxnFields.GlobalStateSchema.MarshalMsg(o) } - if (zb0007Mask & 0x800000) == 0 { // if not empty + if (zb0007Mask & 0x1000000) == 0 { // if not empty // string "apid" o = append(o, 0xa4, 0x61, 0x70, 0x69, 0x64) o = (*z).ApplicationCallTxnFields.ApplicationID.MarshalMsg(o) } - if (zb0007Mask & 0x1000000) == 0 { // if not empty + if (zb0007Mask & 0x2000000) == 0 { // if not empty // string "apls" o = append(o, 0xa4, 0x61, 0x70, 0x6c, 0x73) o = (*z).ApplicationCallTxnFields.LocalStateSchema.MarshalMsg(o) } - if (zb0007Mask & 0x2000000) == 0 { // if not empty + if (zb0007Mask & 0x4000000) == 0 { // if not empty // string "apsu" o = append(o, 0xa4, 0x61, 0x70, 0x73, 0x75) o = msgp.AppendBytes(o, (*z).ApplicationCallTxnFields.ClearStateProgram) } - if (zb0007Mask & 0x4000000) == 0 { // if not empty + if (zb0007Mask & 0x8000000) == 0 { // if not empty // string "arcv" o = append(o, 0xa4, 0x61, 0x72, 0x63, 0x76) o = (*z).AssetTransferTxnFields.AssetReceiver.MarshalMsg(o) } - if (zb0007Mask & 0x8000000) == 0 { // if not empty + if (zb0007Mask & 0x10000000) == 0 { // if not empty // string "asnd" o = append(o, 0xa4, 0x61, 0x73, 0x6e, 0x64) o = (*z).AssetTransferTxnFields.AssetSender.MarshalMsg(o) } - if (zb0007Mask & 0x10000000) == 0 { // if not empty + if (zb0007Mask & 0x20000000) == 0 { // if not empty // string "caid" o = append(o, 0xa4, 0x63, 0x61, 0x69, 0x64) o = (*z).AssetConfigTxnFields.ConfigAsset.MarshalMsg(o) } - if (zb0007Mask & 0x20000000) == 0 { // if not empty + if (zb0007Mask & 0x40000000) == 0 { // if not empty // string "close" o = append(o, 0xa5, 0x63, 0x6c, 0x6f, 0x73, 0x65) o = (*z).PaymentTxnFields.CloseRemainderTo.MarshalMsg(o) } - if (zb0007Mask & 0x40000000) == 0 { // if not empty + if (zb0007Mask & 0x80000000) == 0 { // if not empty // string "fadd" o = append(o, 0xa4, 0x66, 0x61, 0x64, 0x64) o = (*z).AssetFreezeTxnFields.FreezeAccount.MarshalMsg(o) } - if (zb0007Mask & 0x80000000) == 0 { // if not empty + if (zb0007Mask & 0x100000000) == 0 { // if not empty // string "faid" o = append(o, 0xa4, 0x66, 0x61, 0x69, 0x64) o = (*z).AssetFreezeTxnFields.FreezeAsset.MarshalMsg(o) } - if (zb0007Mask & 0x100000000) == 0 { // if not empty + if (zb0007Mask & 0x200000000) == 0 { // if not empty // string "fee" o = append(o, 0xa3, 0x66, 0x65, 0x65) o = (*z).Header.Fee.MarshalMsg(o) } - if (zb0007Mask & 0x200000000) == 0 { // if not empty + if (zb0007Mask & 0x400000000) == 0 { // if not empty // string "fv" o = append(o, 0xa2, 0x66, 0x76) o = (*z).Header.FirstValid.MarshalMsg(o) } - if (zb0007Mask & 0x400000000) == 0 { // if not empty + if (zb0007Mask & 0x800000000) == 0 { // if not empty // string "gen" o = append(o, 0xa3, 0x67, 0x65, 0x6e) o = msgp.AppendString(o, (*z).Header.GenesisID) } - if (zb0007Mask & 0x800000000) == 0 { // if not empty + if (zb0007Mask & 0x1000000000) == 0 { // if not empty // string "gh" o = append(o, 0xa2, 0x67, 0x68) o = (*z).Header.GenesisHash.MarshalMsg(o) } - if (zb0007Mask & 0x1000000000) == 0 { // if not empty + if (zb0007Mask & 0x2000000000) == 0 { // if not empty // string "grp" o = append(o, 0xa3, 0x67, 0x72, 0x70) o = (*z).Header.Group.MarshalMsg(o) } - if (zb0007Mask & 0x2000000000) == 0 { // if not empty + if (zb0007Mask & 0x4000000000) == 0 { // if not empty + // string "hbad" + o = append(o, 0xa4, 0x68, 0x62, 0x61, 0x64) + o = (*z).HeartbeatTxnFields.HbAddress.MarshalMsg(o) + } + if (zb0007Mask & 0x8000000000) == 0 { // if not empty + // string "hbprf" + o = append(o, 0xa5, 0x68, 0x62, 0x70, 0x72, 0x66) + o = (*z).HeartbeatTxnFields.HbProof.MarshalMsg(o) + } + if (zb0007Mask & 0x10000000000) == 0 { // if not empty + // string "hbsd" + o = append(o, 0xa4, 0x68, 0x62, 0x73, 0x64) + o = (*z).HeartbeatTxnFields.HbSeed.MarshalMsg(o) + } + if (zb0007Mask & 0x20000000000) == 0 { // if not empty // string "lv" o = append(o, 0xa2, 0x6c, 0x76) o = (*z).Header.LastValid.MarshalMsg(o) } - if (zb0007Mask & 0x4000000000) == 0 { // if not empty + if (zb0007Mask & 0x40000000000) == 0 { // if not empty // string "lx" o = append(o, 0xa2, 0x6c, 0x78) o = msgp.AppendBytes(o, ((*z).Header.Lease)[:]) } - if (zb0007Mask & 0x8000000000) == 0 { // if not empty + if (zb0007Mask & 0x80000000000) == 0 { // if not empty // string "nonpart" o = append(o, 0xa7, 0x6e, 0x6f, 0x6e, 0x70, 0x61, 0x72, 0x74) o = msgp.AppendBool(o, (*z).KeyregTxnFields.Nonparticipation) } - if (zb0007Mask & 0x10000000000) == 0 { // if not empty + if (zb0007Mask & 0x100000000000) == 0 { // if not empty // string "note" o = append(o, 0xa4, 0x6e, 0x6f, 0x74, 0x65) o = msgp.AppendBytes(o, (*z).Header.Note) } - if (zb0007Mask & 0x20000000000) == 0 { // if not empty + if (zb0007Mask & 0x200000000000) == 0 { // if not empty // string "rcv" o = append(o, 0xa3, 0x72, 0x63, 0x76) o = (*z).PaymentTxnFields.Receiver.MarshalMsg(o) } - if (zb0007Mask & 0x40000000000) == 0 { // if not empty + if (zb0007Mask & 0x400000000000) == 0 { // if not empty // string "rekey" o = append(o, 0xa5, 0x72, 0x65, 0x6b, 0x65, 0x79) o = (*z).Header.RekeyTo.MarshalMsg(o) } - if (zb0007Mask & 0x80000000000) == 0 { // if not empty + if (zb0007Mask & 0x800000000000) == 0 { // if not empty // string "selkey" o = append(o, 0xa6, 0x73, 0x65, 0x6c, 0x6b, 0x65, 0x79) o = (*z).KeyregTxnFields.SelectionPK.MarshalMsg(o) } - if (zb0007Mask & 0x100000000000) == 0 { // if not empty + if (zb0007Mask & 0x1000000000000) == 0 { // if not empty // string "snd" o = append(o, 0xa3, 0x73, 0x6e, 0x64) o = (*z).Header.Sender.MarshalMsg(o) } - if (zb0007Mask & 0x200000000000) == 0 { // if not empty + if (zb0007Mask & 0x2000000000000) == 0 { // if not empty // string "sp" o = append(o, 0xa2, 0x73, 0x70) o = (*z).StateProofTxnFields.StateProof.MarshalMsg(o) } - if (zb0007Mask & 0x400000000000) == 0 { // if not empty + if (zb0007Mask & 0x4000000000000) == 0 { // if not empty // string "spmsg" o = append(o, 0xa5, 0x73, 0x70, 0x6d, 0x73, 0x67) o = (*z).StateProofTxnFields.Message.MarshalMsg(o) } - if (zb0007Mask & 0x800000000000) == 0 { // if not empty + if (zb0007Mask & 0x8000000000000) == 0 { // if not empty // string "sprfkey" o = append(o, 0xa7, 0x73, 0x70, 0x72, 0x66, 0x6b, 0x65, 0x79) o = (*z).KeyregTxnFields.StateProofPK.MarshalMsg(o) } - if (zb0007Mask & 0x1000000000000) == 0 { // if not empty + if (zb0007Mask & 0x10000000000000) == 0 { // if not empty // string "sptype" o = append(o, 0xa6, 0x73, 0x70, 0x74, 0x79, 0x70, 0x65) o = (*z).StateProofTxnFields.StateProofType.MarshalMsg(o) } - if (zb0007Mask & 0x2000000000000) == 0 { // if not empty + if (zb0007Mask & 0x20000000000000) == 0 { // if not empty // string "type" o = append(o, 0xa4, 0x74, 0x79, 0x70, 0x65) o = (*z).Type.MarshalMsg(o) } - if (zb0007Mask & 0x4000000000000) == 0 { // if not empty + if (zb0007Mask & 0x40000000000000) == 0 { // if not empty // string "votefst" o = append(o, 0xa7, 0x76, 0x6f, 0x74, 0x65, 0x66, 0x73, 0x74) o = (*z).KeyregTxnFields.VoteFirst.MarshalMsg(o) } - if (zb0007Mask & 0x8000000000000) == 0 { // if not empty + if (zb0007Mask & 0x80000000000000) == 0 { // if not empty // string "votekd" o = append(o, 0xa6, 0x76, 0x6f, 0x74, 0x65, 0x6b, 0x64) o = msgp.AppendUint64(o, (*z).KeyregTxnFields.VoteKeyDilution) } - if (zb0007Mask & 0x10000000000000) == 0 { // if not empty + if (zb0007Mask & 0x100000000000000) == 0 { // if not empty // string "votekey" o = append(o, 0xa7, 0x76, 0x6f, 0x74, 0x65, 0x6b, 0x65, 0x79) o = (*z).KeyregTxnFields.VotePK.MarshalMsg(o) } - if (zb0007Mask & 0x20000000000000) == 0 { // if not empty + if (zb0007Mask & 0x200000000000000) == 0 { // if not empty // string "votelst" o = append(o, 0xa7, 0x76, 0x6f, 0x74, 0x65, 0x6c, 0x73, 0x74) o = (*z).KeyregTxnFields.VoteLast.MarshalMsg(o) } - if (zb0007Mask & 0x40000000000000) == 0 { // if not empty + if (zb0007Mask & 0x400000000000000) == 0 { // if not empty // string "xaid" o = append(o, 0xa4, 0x78, 0x61, 0x69, 0x64) o = (*z).AssetTransferTxnFields.XferAsset.MarshalMsg(o) @@ -6086,6 +6290,30 @@ func (z *Transaction) UnmarshalMsgWithState(bts []byte, st msgp.UnmarshalState) return } } + if zb0007 > 0 { + zb0007-- + bts, err = (*z).HeartbeatTxnFields.HbAddress.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbAddress") + return + } + } + if zb0007 > 0 { + zb0007-- + bts, err = (*z).HeartbeatTxnFields.HbProof.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbProof") + return + } + } + if zb0007 > 0 { + zb0007-- + bts, err = (*z).HeartbeatTxnFields.HbSeed.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbSeed") + return + } + } if zb0007 > 0 { err = msgp.ErrTooManyArrayFields(zb0007) if err != nil { @@ -6618,6 +6846,24 @@ func (z *Transaction) UnmarshalMsgWithState(bts []byte, st msgp.UnmarshalState) err = msgp.WrapError(err, "Message") return } + case "hbad": + bts, err = (*z).HeartbeatTxnFields.HbAddress.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbAddress") + return + } + case "hbprf": + bts, err = (*z).HeartbeatTxnFields.HbProof.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbProof") + return + } + case "hbsd": + bts, err = (*z).HeartbeatTxnFields.HbSeed.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbSeed") + return + } default: err = msgp.ErrNoField(string(field)) if err != nil { @@ -6661,13 +6907,13 @@ func (z *Transaction) Msgsize() (s int) { for zb0006 := range (*z).ApplicationCallTxnFields.ForeignAssets { s += (*z).ApplicationCallTxnFields.ForeignAssets[zb0006].Msgsize() } - s += 5 + (*z).ApplicationCallTxnFields.LocalStateSchema.Msgsize() + 5 + (*z).ApplicationCallTxnFields.GlobalStateSchema.Msgsize() + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ApprovalProgram) + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ClearStateProgram) + 5 + msgp.Uint32Size + 7 + (*z).StateProofTxnFields.StateProofType.Msgsize() + 3 + (*z).StateProofTxnFields.StateProof.Msgsize() + 6 + (*z).StateProofTxnFields.Message.Msgsize() + s += 5 + (*z).ApplicationCallTxnFields.LocalStateSchema.Msgsize() + 5 + (*z).ApplicationCallTxnFields.GlobalStateSchema.Msgsize() + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ApprovalProgram) + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ClearStateProgram) + 5 + msgp.Uint32Size + 7 + (*z).StateProofTxnFields.StateProofType.Msgsize() + 3 + (*z).StateProofTxnFields.StateProof.Msgsize() + 6 + (*z).StateProofTxnFields.Message.Msgsize() + 5 + (*z).HeartbeatTxnFields.HbAddress.Msgsize() + 6 + (*z).HeartbeatTxnFields.HbProof.Msgsize() + 5 + (*z).HeartbeatTxnFields.HbSeed.Msgsize() return } // MsgIsZero returns whether this is a zero value func (z *Transaction) MsgIsZero() bool { - return ((*z).Type.MsgIsZero()) && ((*z).Header.Sender.MsgIsZero()) && ((*z).Header.Fee.MsgIsZero()) && ((*z).Header.FirstValid.MsgIsZero()) && ((*z).Header.LastValid.MsgIsZero()) && (len((*z).Header.Note) == 0) && ((*z).Header.GenesisID == "") && ((*z).Header.GenesisHash.MsgIsZero()) && ((*z).Header.Group.MsgIsZero()) && ((*z).Header.Lease == ([32]byte{})) && ((*z).Header.RekeyTo.MsgIsZero()) && ((*z).KeyregTxnFields.VotePK.MsgIsZero()) && ((*z).KeyregTxnFields.SelectionPK.MsgIsZero()) && ((*z).KeyregTxnFields.StateProofPK.MsgIsZero()) && ((*z).KeyregTxnFields.VoteFirst.MsgIsZero()) && ((*z).KeyregTxnFields.VoteLast.MsgIsZero()) && ((*z).KeyregTxnFields.VoteKeyDilution == 0) && ((*z).KeyregTxnFields.Nonparticipation == false) && ((*z).PaymentTxnFields.Receiver.MsgIsZero()) && ((*z).PaymentTxnFields.Amount.MsgIsZero()) && ((*z).PaymentTxnFields.CloseRemainderTo.MsgIsZero()) && ((*z).AssetConfigTxnFields.ConfigAsset.MsgIsZero()) && ((*z).AssetConfigTxnFields.AssetParams.MsgIsZero()) && ((*z).AssetTransferTxnFields.XferAsset.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetAmount == 0) && ((*z).AssetTransferTxnFields.AssetSender.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetReceiver.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetCloseTo.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAccount.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAsset.MsgIsZero()) && ((*z).AssetFreezeTxnFields.AssetFrozen == false) && ((*z).ApplicationCallTxnFields.ApplicationID.MsgIsZero()) && ((*z).ApplicationCallTxnFields.OnCompletion == 0) && (len((*z).ApplicationCallTxnFields.ApplicationArgs) == 0) && (len((*z).ApplicationCallTxnFields.Accounts) == 0) && (len((*z).ApplicationCallTxnFields.ForeignApps) == 0) && (len((*z).ApplicationCallTxnFields.Boxes) == 0) && (len((*z).ApplicationCallTxnFields.ForeignAssets) == 0) && ((*z).ApplicationCallTxnFields.LocalStateSchema.MsgIsZero()) && ((*z).ApplicationCallTxnFields.GlobalStateSchema.MsgIsZero()) && (len((*z).ApplicationCallTxnFields.ApprovalProgram) == 0) && (len((*z).ApplicationCallTxnFields.ClearStateProgram) == 0) && ((*z).ApplicationCallTxnFields.ExtraProgramPages == 0) && ((*z).StateProofTxnFields.StateProofType.MsgIsZero()) && ((*z).StateProofTxnFields.StateProof.MsgIsZero()) && ((*z).StateProofTxnFields.Message.MsgIsZero()) + return ((*z).Type.MsgIsZero()) && ((*z).Header.Sender.MsgIsZero()) && ((*z).Header.Fee.MsgIsZero()) && ((*z).Header.FirstValid.MsgIsZero()) && ((*z).Header.LastValid.MsgIsZero()) && (len((*z).Header.Note) == 0) && ((*z).Header.GenesisID == "") && ((*z).Header.GenesisHash.MsgIsZero()) && ((*z).Header.Group.MsgIsZero()) && ((*z).Header.Lease == ([32]byte{})) && ((*z).Header.RekeyTo.MsgIsZero()) && ((*z).KeyregTxnFields.VotePK.MsgIsZero()) && ((*z).KeyregTxnFields.SelectionPK.MsgIsZero()) && ((*z).KeyregTxnFields.StateProofPK.MsgIsZero()) && ((*z).KeyregTxnFields.VoteFirst.MsgIsZero()) && ((*z).KeyregTxnFields.VoteLast.MsgIsZero()) && ((*z).KeyregTxnFields.VoteKeyDilution == 0) && ((*z).KeyregTxnFields.Nonparticipation == false) && ((*z).PaymentTxnFields.Receiver.MsgIsZero()) && ((*z).PaymentTxnFields.Amount.MsgIsZero()) && ((*z).PaymentTxnFields.CloseRemainderTo.MsgIsZero()) && ((*z).AssetConfigTxnFields.ConfigAsset.MsgIsZero()) && ((*z).AssetConfigTxnFields.AssetParams.MsgIsZero()) && ((*z).AssetTransferTxnFields.XferAsset.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetAmount == 0) && ((*z).AssetTransferTxnFields.AssetSender.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetReceiver.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetCloseTo.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAccount.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAsset.MsgIsZero()) && ((*z).AssetFreezeTxnFields.AssetFrozen == false) && ((*z).ApplicationCallTxnFields.ApplicationID.MsgIsZero()) && ((*z).ApplicationCallTxnFields.OnCompletion == 0) && (len((*z).ApplicationCallTxnFields.ApplicationArgs) == 0) && (len((*z).ApplicationCallTxnFields.Accounts) == 0) && (len((*z).ApplicationCallTxnFields.ForeignApps) == 0) && (len((*z).ApplicationCallTxnFields.Boxes) == 0) && (len((*z).ApplicationCallTxnFields.ForeignAssets) == 0) && ((*z).ApplicationCallTxnFields.LocalStateSchema.MsgIsZero()) && ((*z).ApplicationCallTxnFields.GlobalStateSchema.MsgIsZero()) && (len((*z).ApplicationCallTxnFields.ApprovalProgram) == 0) && (len((*z).ApplicationCallTxnFields.ClearStateProgram) == 0) && ((*z).ApplicationCallTxnFields.ExtraProgramPages == 0) && ((*z).StateProofTxnFields.StateProofType.MsgIsZero()) && ((*z).StateProofTxnFields.StateProof.MsgIsZero()) && ((*z).StateProofTxnFields.Message.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbAddress.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbProof.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbSeed.MsgIsZero()) } // MaxSize returns a maximum valid message size for this message type @@ -6689,7 +6935,7 @@ func TransactionMaxSize() (s int) { s += 5 // Calculating size of slice: z.ApplicationCallTxnFields.ForeignAssets s += msgp.ArrayHeaderSize + ((encodedMaxForeignAssets) * (basics.AssetIndexMaxSize())) - s += 5 + basics.StateSchemaMaxSize() + 5 + basics.StateSchemaMaxSize() + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.Uint32Size + 7 + protocol.StateProofTypeMaxSize() + 3 + stateproof.StateProofMaxSize() + 6 + stateproofmsg.MessageMaxSize() + s += 5 + basics.StateSchemaMaxSize() + 5 + basics.StateSchemaMaxSize() + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.Uint32Size + 7 + protocol.StateProofTypeMaxSize() + 3 + stateproof.StateProofMaxSize() + 6 + stateproofmsg.MessageMaxSize() + 5 + basics.AddressMaxSize() + 6 + crypto.HeartbeatProofMaxSize() + 5 + committee.SeedMaxSize() return } diff --git a/data/transactions/msgp_gen_test.go b/data/transactions/msgp_gen_test.go index 0ce6b29c38..49ed14f6e3 100644 --- a/data/transactions/msgp_gen_test.go +++ b/data/transactions/msgp_gen_test.go @@ -494,6 +494,66 @@ func BenchmarkUnmarshalHeader(b *testing.B) { } } +func TestMarshalUnmarshalHeartbeatTxnFields(t *testing.T) { + partitiontest.PartitionTest(t) + v := HeartbeatTxnFields{} + bts := v.MarshalMsg(nil) + left, err := v.UnmarshalMsg(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left) + } + + left, err = msgp.Skip(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after Skip(): %q", len(left), left) + } +} + +func TestRandomizedEncodingHeartbeatTxnFields(t *testing.T) { + protocol.RunEncodingTest(t, &HeartbeatTxnFields{}) +} + +func BenchmarkMarshalMsgHeartbeatTxnFields(b *testing.B) { + v := HeartbeatTxnFields{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + v.MarshalMsg(nil) + } +} + +func BenchmarkAppendMsgHeartbeatTxnFields(b *testing.B) { + v := HeartbeatTxnFields{} + bts := make([]byte, 0, v.Msgsize()) + bts = v.MarshalMsg(bts[0:0]) + b.SetBytes(int64(len(bts))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + bts = v.MarshalMsg(bts[0:0]) + } +} + +func BenchmarkUnmarshalHeartbeatTxnFields(b *testing.B) { + v := HeartbeatTxnFields{} + bts := v.MarshalMsg(nil) + b.ReportAllocs() + b.SetBytes(int64(len(bts))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := v.UnmarshalMsg(bts) + if err != nil { + b.Fatal(err) + } + } +} + func TestMarshalUnmarshalKeyregTxnFields(t *testing.T) { partitiontest.PartitionTest(t) v := KeyregTxnFields{} diff --git a/data/transactions/transaction.go b/data/transactions/transaction.go index 4a6d5b6603..363747a996 100644 --- a/data/transactions/transaction.go +++ b/data/transactions/transaction.go @@ -100,6 +100,7 @@ type Transaction struct { AssetFreezeTxnFields ApplicationCallTxnFields StateProofTxnFields + HeartbeatTxnFields } // ApplyData contains information about the transaction's execution. @@ -565,6 +566,11 @@ func (tx Transaction) WellFormed(spec SpecialAddresses, proto config.ConsensusPa return errLeaseMustBeZeroInStateproofTxn } + case protocol.HeartbeatTx: + if !proto.Heartbeat { + return fmt.Errorf("heartbeat transaction not supported") + } + default: return fmt.Errorf("unknown tx type %v", tx.Type) } @@ -598,6 +604,10 @@ func (tx Transaction) WellFormed(spec SpecialAddresses, proto config.ConsensusPa nonZeroFields[protocol.StateProofTx] = true } + if tx.HeartbeatTxnFields != (HeartbeatTxnFields{}) { + nonZeroFields[protocol.HeartbeatTx] = true + } + for t, nonZero := range nonZeroFields { if nonZero && t != tx.Type { return fmt.Errorf("transaction of type %v has non-zero fields for type %v", tx.Type, t) diff --git a/data/transactions/transaction_test.go b/data/transactions/transaction_test.go index 08dd145a8c..1dbb2e316a 100644 --- a/data/transactions/transaction_test.go +++ b/data/transactions/transaction_test.go @@ -591,6 +591,21 @@ func TestWellFormedErrors(t *testing.T) { proto: protoV36, expectedError: nil, }, + { + tx: Transaction{ + Type: protocol.HeartbeatTx, + Header: okHeader, + }, + proto: protoV36, + expectedError: fmt.Errorf("heartbeat transaction not supported"), + }, + { + tx: Transaction{ + Type: protocol.HeartbeatTx, + Header: okHeader, + }, + proto: futureProto, + }, } for _, usecase := range usecases { err := usecase.tx.WellFormed(SpecialAddresses{}, usecase.proto) diff --git a/data/transactions/verify/txn.go b/data/transactions/verify/txn.go index d0f38bca90..518528a3bc 100644 --- a/data/transactions/verify/txn.go +++ b/data/transactions/verify/txn.go @@ -220,10 +220,15 @@ func txnGroupBatchPrep(stxs []transactions.SignedTxn, contextHdr *bookkeeping.Bl prepErr.err = fmt.Errorf("transaction %+v invalid : %w", stxn, prepErr.err) return nil, prepErr } - if stxn.Txn.Type != protocol.StateProofTx { - minFeeCount++ - } feesPaid = basics.AddSaturate(feesPaid, stxn.Txn.Fee.Raw) + if stxn.Txn.Type == protocol.StateProofTx { + continue + } + if stxn.Txn.Type == protocol.HeartbeatTx && stxn.Txn.Group.IsZero() { + // in apply.Heartbeat, we further confirm that the heartbeat is for a challenged node + continue + } + minFeeCount++ } feeNeeded, overflow := basics.OMul(groupCtx.consensusParams.MinTxnFee, minFeeCount) if overflow { diff --git a/data/transactions/verify/txn_test.go b/data/transactions/verify/txn_test.go index 282a031097..5946399d0b 100644 --- a/data/transactions/verify/txn_test.go +++ b/data/transactions/verify/txn_test.go @@ -575,7 +575,7 @@ func TestPaysetGroups(t *testing.T) { startPaysetGroupsTime := time.Now() err := PaysetGroups(context.Background(), txnGroups, blkHdr, verificationPool, MakeVerifiedTransactionCache(50000), nil) require.NoError(t, err) - paysetGroupDuration := time.Now().Sub(startPaysetGroupsTime) + paysetGroupDuration := time.Since(startPaysetGroupsTime) // break the signature and see if it fails. txnGroups[0][0].Sig[0] = txnGroups[0][0].Sig[0] + 1 @@ -609,7 +609,7 @@ func TestPaysetGroups(t *testing.T) { // channel is closed without a return require.Failf(t, "Channel got closed ?!", "") } else { - actualDuration := time.Now().Sub(startPaysetGroupsTime) + actualDuration := time.Since(startPaysetGroupsTime) if err == nil { if actualDuration > 4*time.Second { // it took at least 2.5 seconds more than it should have had! diff --git a/data/transactions/verify/verifiedTxnCache_test.go b/data/transactions/verify/verifiedTxnCache_test.go index d27510fe6a..03f5cac288 100644 --- a/data/transactions/verify/verifiedTxnCache_test.go +++ b/data/transactions/verify/verifiedTxnCache_test.go @@ -127,7 +127,7 @@ func BenchmarkGetUnverifiedTransactionGroups50(b *testing.B) { for i := 0; i < measuringMultipler; i++ { impl.GetUnverifiedTransactionGroups(queryTxnGroups, spec, protocol.ConsensusCurrentVersion) } - duration := time.Now().Sub(startTime) + duration := time.Since(startTime) // calculate time per 10K verified entries: t := int(duration*10000) / (measuringMultipler * b.N) b.ReportMetric(float64(t)/float64(time.Millisecond), "ms/10K_cache_compares") diff --git a/data/txntest/txn.go b/data/txntest/txn.go index aea4de005b..d734f47576 100644 --- a/data/txntest/txn.go +++ b/data/txntest/txn.go @@ -26,6 +26,7 @@ import ( "github.com/algorand/go-algorand/crypto/merklesignature" "github.com/algorand/go-algorand/crypto/stateproof" "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/committee" "github.com/algorand/go-algorand/data/stateproofmsg" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/data/transactions/logic" @@ -91,6 +92,10 @@ type Txn struct { StateProofType protocol.StateProofType StateProof stateproof.StateProof StateProofMsg stateproofmsg.Message + + HbAddress basics.Address + HbProof crypto.HeartbeatProof + HbSeed committee.Seed } // internalCopy "finishes" a shallow copy done by a simple Go assignment by @@ -281,6 +286,11 @@ func (tx Txn) Txn() transactions.Transaction { StateProof: tx.StateProof, Message: tx.StateProofMsg, }, + HeartbeatTxnFields: transactions.HeartbeatTxnFields{ + HbAddress: tx.HbAddress, + HbProof: tx.HbProof, + HbSeed: tx.HbSeed, + }, } } diff --git a/heartbeat/README.md b/heartbeat/README.md new file mode 100644 index 0000000000..f1a522928d --- /dev/null +++ b/heartbeat/README.md @@ -0,0 +1,180 @@ +# Block Payouts, Suspensions, and Heartbeats + +Running a validator node on Algorand is a relatively lightweight operation. Therefore, participation +in consensus was not compensated. There was an expectation that financial motivated holders of Algos +would run nodes in order to help secure their holdings. + +Although simple participation is not terribly resource intensive, running _any_ service with high +uptime becomes expensive when one considers that it should be monitored for uptime, be somewhat +over-provisioned to handle unexpected load spikes, and plans need to be in place to restart in the +face of hardware failure (or the accounts should leave consensus properly). + +With those burdens in mind, fewer Algo holders chose to run participation nodes than would be +preferred to provide security against well-financed bad actors. To alleviate this problem, a +mechanism to reward block proposers has been created. With these _block payouts_ in place, large +Algo holders are incentivized to run participation nodes in order to earn more Algos, increasing +security for the entire Algorand network. + +With the financial incentive to run participation nodes comes the risk that some nodes may be +operated without sufficient care. Therefore, a mechanism to _suspend_ nodes that appear to be +performing poorly (or not at all). Appearances can be deceiving, however. Since Algorand is a +probabilistic consensus protocol, pure chance might lead to a node appearing to be delinquent. A new +transaction type, the _heartbeat_, allows a node to explicitly indicate that it is online even if it +does not propose blocks due to "bad luck". + +# Payouts + +Payouts are made in every block, if the proposer has opted into receiving them, has an Algo balance +in an appropriate range, and has not been suspended for poor behavior since opting-in. The size of +the payout is indicated in the block header, and comes from the `FeeSink`. The block payout consist +of two components. First, a portion of the block fees (currently 50%) are paid to the proposer. +This component incentives fuller blocks which lead to larger payouts. Second, a _bonus_ payout is +made according to a exponentially decaying formula. This bonus is (intentionally) unsustainable +from protocol fees. It is expected that the Algorand Foundation will seed the `FeeSink` with +sufficient funds to allow the bonuses to be paid out according to the formula for several years. If +the `FeeSink` has insufficient funds for the sum of these components, the payout will be as high as +possible while maintaining the `FeeSink`'s minimum balance. These calculations are performed in +`endOfBlock` in `eval/eval.go`. + +To opt-in to receiving block payouts, an account includes an extra fee in the `keyreg` +transaction. The amount is controlled by the consensus parameter `Payouts.GoOnlineFee`. When such a +fee is included, a new account state bit, `IncentiveEligible` is set to true. + +Even when an account is `IncentiveEligible` there is a proposal-time check of the account's online +stake. If the account has too much or too little, no payout is performed (though +`IncentiveEligible` remains true). As explained below, this check occurs in `agreement` code in +`payoutEligible()`. The balance check is performed on the _online_ stake, that is the stake from 320 +rounds earlier, so a clever proposer can not move Algos in the round it proposes in order to receive +the payout. Finally, in an interesting corner case, a proposing account could be closed at proposal +time, since voting is based on the earlier balance. Such an account receives no payout, even if its +balances was in the proper range 320 rounds ago. + +A surprising complication in the implementation of these payouts is that when a block is prepared by +a node, it does not know which account is the proposer. Until now, `algod` could prepare a single +block which would be used by any of the accounts it was participating for. The block would be +handed off to `agreement` which would manipulate the block only to add the appropriate block seed +(which depended upon the proposer). That interaction between `eval` and `agreement` was widened +(see `WithProposer()`) to allow `agreement` to modify the block to include the proper `Proposer`, +and to zero the `ProposerPayout` if the account that proposed was not actually eligible to receive a +payout. + +# Suspensions + +Accounts can be _suspended_ for poor behavior. There are two forms of poor behavior that can lead +to suspension. First, an account is considered _absent_ if it fails to propose as often as it +should. Second, an account can be suspended for failing to respond to a _challenge_ issued by the +network at random. + +## Absenteeism + +An account can be expected to propose once every `n = TotalOnlineStake/AccountOnlineStake` rounds. +For example, a node with 2% of online stake ought to propose once every 50 rounds. Of course the +actual proposer is chosen by random sortition. To make false positive suspensions unlikely, a node +is considered absent if it fails to produce a block over the course of `10n` rounds. + +The suspension mechanism is implemented in `generateKnockOfflineAccountsList` in `eval/eval.go`. It +is closely modeled on the mechanism that knocks accounts offline if their voting keys have expired. +An absent account is added to the `AbsentParticipationAccounts` list of the block header. When +evaluating a block, accounts in `AbsentParticipationAccounts` are suspended by changing their +`Status` to `Offline` and setting `IncentiveEligible` to false, but retaining their voting keys. + +### Keyreg and `LastHeartbeat` + +As described so far, 320 rounds after a `keyreg` to go online, an account suddenly is expected to +have proposed more recently than 10 times its new expected interval. That would be impossible, since +it was not online until that round. Therefore, when a `keyreg` is used to go online and become +`IncentiveEligible`, the account's `LastHeartbeat` field is set 320 rounds into the future. In +effect, the account is treated as though it proposed in the first round it is online. + +### Large Algo increases and `LastHeartbeat` + +A similar problem can occur when an online account receives Algos. 320 rounds after receiving the +new Algos, the account's expected proposal interval will shrink. If, for example, such an account +increases by a factor of 10, then it is reasonably likely that it will not have proposed recently +enough, and will be suspended immediately. To mitigate this risk, any time an online, +`IncentiveEligible` account balance doubles from a single `Pay`, its `LastHeartbeat` is incremented +to 320 rounds past the current round. + +## Challenges + +The absenteeism checks quickly suspend a high-value account if it becomes inoperative. For example, +and account with 2% of stake can be marked absent after 500 rounds (about 24 minutes). After +suspension, the effect on consensus is mitigated after 320 more rounds (about 15 +minutes). Therefore, the suspension mechanism makes Algorand significantly more robust in the face +of operational errors. + +However, the absenteeism mechanism is very slow to notice small accounts. An account with 30,000 +Algos might represent 1/100,000 or less of total stake. It would only be considered absent after a +million or more rounds without a proposal. At current network speeds, this is about a month. With such +slow detection, a financially motived entity might make the decision to run a node even if they lack +the wherewithal to run the node with excellent uptime. A worst case scenario might be a node that is +turned off daily, overnight. Such a node would generate profit for the runner, would probably never +be marked offline by the absenteeism mechanism, yet would impact consensus negatively. Algorand +can't make progress with 1/3 of nodes offline at any given time for a nightly rest. + +To combat this scenario, the network generates random _challenges_ periodically. Every +`Payouts.ChallengeInterval` rounds (currently 1000), a random selected portion (currently 1/32) of +all online accounts are challenged. They must _heartbeat_ within `Payouts.ChallengeGracePeriod` +rounds (currently 200), or they will be subject to suspension. With the current consensus +parameters, nodes can be expected to be challenged daily. When suspended, accounts must `keyreg` +with the `GoOnlineFee` in order to receive block payouts again, so it becomes unprofitable for +these low-stake nodes to operate with poor uptimes. + +# Heartbeats + +The absenteeism mechanism is subject to rare false positives. The challenge mechanism explicitly +requires an affirmative response from nodes to indicate they are operating properly on behalf of a +challenged account. Both of these needs are addressed by a new transaction type --- _Heartbeat_. A +Heartbeat transaction contains a signature (`HbProof`) of the blockseed (`HbSeed`) of the +transaction's FirstValid block under the participation key of the account (`HbAddress`) in +question. Note that the account being heartbeat for is _not_ the `Sender` of the transaction, which +can be any address. Signing a recent block seed makes it more difficult to pre-sign heartbeats that +another machine might send on your behalf. Signing the FirstValid's blockseed (rather than +FirstValid-1) simply enforces a best practice: emit a transaction with FirstValid set to a committed +round, not a future round, avoiding a race. The node you send transactions to might not have +committed your latest round yet. + +It is relatively easy for a bad actor to emit Heartbeats for its accounts without actually +participating. However, there is no financial incentive to do so. Pretending to be operational when +offline does not earn block payouts. Furthermore, running a server to monitor the block chain to +notice challenges and gather the recent blockseed is not significantly cheaper that simply running a +functional node. It is _already_ possible for malicious, well-resourced accounts to cause consensus +difficulties by putting significant stake online without actually participating. Heartbeats do not +mitigate that risk. But these mechanisms have been designed to avoid _motivating_ such behavior, so +that they can accomplish their actual goal of noticing poor behavior stemming from _inadvertent_ +operational problems. + +## Free Heartbeats + +Challenges occur frequently, so it important that `algod` can easily send Heartbeats as +required. How should these transactions be paid for? Many accounts, especially high-value accounts, +would not want to keep their spending keys available for automatic use by `algod`. Further, creating +(and keeping funded) a low-value side account to pay for Heartbeats would be an annoying operational +overhead. Therefore, when required by challenges, heartbeat transactions do not require a fee. +Therefore, any account, even an unfunded logigsig, can send heartbeats for an account under +challenge. + +The conditions for a free Heartbeat are: + +1. The Heartbeat is not part of a larger group, and has a zero `GroupID`. +1. The `HbAddress` is Online and under challenge with the grace period at least half over. +1. The `HbAddress` is `IncentiveEligible`. +1. There is no `Note`, `Lease`, or `RekeyTo`. + +## Heartbeat Service + +The Heartbeat Service (`heartbeat/service.go`) watches the state of all acounts for which `algod` +has participation keys. If any of those accounts meets the requirements above, a heartbeat +transaction is sent, starting with the round following half a grace period from the challenge. It +uses the (presumably unfunded) logicsig that does nothing except preclude rekey operations. + +The heartbeat service does _not_ heartbeat if an account is unlucky and threatened to be considered +absent. We presume such false postives to be so unlikely that, if they occur, the node must be +brought back online manually. It would be reasonable to consider in the future: + +1. Making heartbeats free for accounts that are "nearly absent". + +or + +2. Allowing for paid heartbeats by the heartbeat service when configured with access to a funded + account's spending key. diff --git a/heartbeat/abstractions.go b/heartbeat/abstractions.go new file mode 100644 index 0000000000..a03aa26b6e --- /dev/null +++ b/heartbeat/abstractions.go @@ -0,0 +1,54 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package heartbeat + +import ( + "github.com/algorand/go-algorand/data/account" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/transactions" + "github.com/algorand/go-algorand/ledger/ledgercore" +) + +// txnBroadcaster is an interface that captures the node's ability to broadcast +// a new transaction. +type txnBroadcaster interface { + BroadcastInternalSignedTxGroup([]transactions.SignedTxn) error +} + +// ledger represents the aspects of the "real" Ledger that the heartbeat service +// needs to interact with +type ledger interface { + // LastRound tells the round is ready for checking + LastRound() basics.Round + + // WaitMem allows the Service to wait for the results of a round to be available + WaitMem(r basics.Round) chan struct{} + + // BlockHdr allows the service access to consensus values + BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) + + // LookupAccount allows the Service to observe accounts for suspension + LookupAccount(round basics.Round, addr basics.Address) (data ledgercore.AccountData, validThrough basics.Round, withoutRewards basics.MicroAlgos, err error) +} + +// participants captures the aspects of the AccountManager that are used by this +// package. Service must be able to find out which accounts to monitor and have +// access to their part keys to construct heartbeats. +type participants interface { + Keys(rnd basics.Round) []account.ParticipationRecordForRound +} diff --git a/heartbeat/service.go b/heartbeat/service.go new file mode 100644 index 0000000000..c3d66239e2 --- /dev/null +++ b/heartbeat/service.go @@ -0,0 +1,183 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package heartbeat + +import ( + "context" + "sync" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/data/account" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/transactions" + "github.com/algorand/go-algorand/data/transactions/logic" + "github.com/algorand/go-algorand/ledger/apply" + "github.com/algorand/go-algorand/logging" + "github.com/algorand/go-algorand/protocol" +) + +// Service emits keep-alive heartbeats for accts that are in danger of +// suspension. +type Service struct { + // addresses that should be monitored for suspension + accts participants + // current status and balances + ledger ledger + // where to send the heartbeats + bcast txnBroadcaster + + // infrastructure + ctx context.Context + shutdown context.CancelFunc + wg sync.WaitGroup + log logging.Logger +} + +// NewService creates a heartbeat service. It will need to know which accounts +// to emit heartbeats for, and how to create the heartbeats. +func NewService(accts participants, ledger ledger, bcast txnBroadcaster, log logging.Logger) *Service { + return &Service{ + accts: accts, + ledger: ledger, + bcast: bcast, + log: log.With("Context", "heartbeat"), + } +} + +// Start starts the goroutines for the Service. +func (s *Service) Start() { + s.ctx, s.shutdown = context.WithCancel(context.Background()) + s.wg.Add(1) + s.log.Info("starting heartbeat service") + go s.loop() +} + +// Stop any goroutines associated with this worker. +func (s *Service) Stop() { + s.log.Debug("heartbeat service is stopping") + defer s.log.Debug("heartbeat service has stopped") + s.shutdown() + s.wg.Wait() +} + +// findChallenged() returns a list of accounts that need a heartbeat because +// they have been challenged. +func (s *Service) findChallenged(rules config.ProposerPayoutRules, current basics.Round) []account.ParticipationRecordForRound { + ch := apply.FindChallenge(rules, current, s.ledger, apply.ChRisky) + if ch.IsZero() { + return nil + } + + var found []account.ParticipationRecordForRound + for _, pr := range s.accts.Keys(current + 1) { // only look at accounts we have part keys for + acct, _, _, err := s.ledger.LookupAccount(current, pr.Account) + if err != nil { + s.log.Errorf("error looking up %v: %v", pr.Account, err) + continue + } + if acct.Status == basics.Online { + if ch.Failed(pr.Account, acct.LastSeen()) { + s.log.Infof(" %v needs a heartbeat\n", pr.Account) + found = append(found, pr) + } + } + /* If we add a grace period to suspension for absenteeism, then we could + also make it free to heartbeat during that period. */ + } + return found +} + +// loop monitors for any of Service's participants being suspended. If they are, +// it tries to being them back online by emitting a heartbeat transaction. It +// could try to predict an upcoming suspension, which would prevent the +// suspension from ever occurring, but that would be considerably more complex +// both to avoid emitting repeated heartbeats, and to ensure the prediction and +// the suspension logic match. This feels like a cleaner end-to-end test, at +// the cost of lost couple rounds of participation. (Though suspension is +// designed to be extremely unlikely anyway.) +func (s *Service) loop() { + defer s.wg.Done() + suppress := make(map[basics.Address]basics.Round) + latest := s.ledger.LastRound() + for { + // exit if Done, else wait for next round + select { + case <-s.ctx.Done(): + return + case <-s.ledger.WaitMem(latest + 1): + } + + latest = s.ledger.LastRound() + + lastHdr, err := s.ledger.BlockHdr(latest) + if err != nil { + s.log.Errorf("heartbeat service could not fetch block header for round %d: %v", latest, err) + continue // Try again next round, I guess? + } + proto := config.Consensus[lastHdr.CurrentProtocol] + + for _, pr := range s.findChallenged(proto.Payouts, latest) { + if suppress[pr.Account] > latest { + continue + } + stxn := s.prepareHeartbeat(pr, lastHdr) + s.log.Infof("sending heartbeat %v for %v\n", stxn.Txn.HeartbeatTxnFields, pr.Account) + err = s.bcast.BroadcastInternalSignedTxGroup([]transactions.SignedTxn{stxn}) + if err != nil { + s.log.Errorf("error broadcasting heartbeat %v for %v: %v", stxn, pr.Account, err) + } else { + // Don't bother heartbeating again until the last one expires. + // If it is accepted, we won't need to (because we won't be + // under challenge any more). + suppress[pr.Account] = stxn.Txn.LastValid + } + } + } +} + +// acceptingByteCode is the byte code to a logic signature that will accept anything (except rekeying). +var acceptingByteCode = logic.MustAssemble(` +#pragma version 11 +txn RekeyTo; global ZeroAddress; == +`) +var acceptingSender = basics.Address(logic.HashProgram(acceptingByteCode)) + +// hbLifetime is somewhat short. It seems better to try several times during the +// grace period than to try a single time with a longer lifetime. +const hbLifetime = 10 + +func (s *Service) prepareHeartbeat(pr account.ParticipationRecordForRound, latest bookkeeping.BlockHeader) transactions.SignedTxn { + var stxn transactions.SignedTxn + stxn.Lsig = transactions.LogicSig{Logic: acceptingByteCode} + stxn.Txn.Type = protocol.HeartbeatTx + stxn.Txn.Header = transactions.Header{ + Sender: acceptingSender, + FirstValid: latest.Round, + LastValid: latest.Round + hbLifetime, + GenesisHash: latest.GenesisHash, + } + + id := basics.OneTimeIDForRound(stxn.Txn.LastValid, pr.KeyDilution) + stxn.Txn.HeartbeatTxnFields = transactions.HeartbeatTxnFields{ + HbAddress: pr.Account, + HbProof: pr.Voting.Sign(id, latest.Seed).ToHeartbeatProof(), + HbSeed: latest.Seed, + } + + return stxn +} diff --git a/heartbeat/service_test.go b/heartbeat/service_test.go new file mode 100644 index 0000000000..3422ffdea4 --- /dev/null +++ b/heartbeat/service_test.go @@ -0,0 +1,268 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package heartbeat + +import ( + "fmt" + "testing" + "time" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/data/account" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/committee" + "github.com/algorand/go-algorand/data/transactions" + "github.com/algorand/go-algorand/ledger/ledgercore" + "github.com/algorand/go-algorand/logging" + "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/test/partitiontest" + "github.com/algorand/go-deadlock" + "github.com/stretchr/testify/require" +) + +type table map[basics.Address]ledgercore.AccountData + +type mockedLedger struct { + mu deadlock.Mutex + waiters map[basics.Round]chan struct{} + history []table + hdr bookkeeping.BlockHeader + + participants map[basics.Address]*crypto.OneTimeSignatureSecrets +} + +func newMockedLedger() mockedLedger { + return mockedLedger{ + waiters: make(map[basics.Round]chan struct{}), + history: []table{nil}, // some genesis accounts could go here + hdr: bookkeeping.BlockHeader{ + UpgradeState: bookkeeping.UpgradeState{ + CurrentProtocol: protocol.ConsensusFuture, + }, + }, + } +} + +func (l *mockedLedger) LastRound() basics.Round { + l.mu.Lock() + defer l.mu.Unlock() + return l.lastRound() +} +func (l *mockedLedger) lastRound() basics.Round { + return basics.Round(len(l.history) - 1) +} + +func (l *mockedLedger) WaitMem(r basics.Round) chan struct{} { + l.mu.Lock() + defer l.mu.Unlock() + + if l.waiters[r] == nil { + l.waiters[r] = make(chan struct{}) + } + + // Return an already-closed channel if we already have the block. + if r <= l.lastRound() { + close(l.waiters[r]) + retChan := l.waiters[r] + delete(l.waiters, r) + return retChan + } + + return l.waiters[r] +} + +// BlockHdr allows the service access to consensus values +func (l *mockedLedger) BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) { + if r > l.LastRound() { + return bookkeeping.BlockHeader{}, fmt.Errorf("%d is beyond current block (%d)", r, l.LastRound()) + } + // return the template hdr, with round + hdr := l.hdr + hdr.Round = r + return hdr, nil +} + +func (l *mockedLedger) addBlock(delta table) error { + l.mu.Lock() + defer l.mu.Unlock() + + fmt.Printf("addBlock %d\n", l.lastRound()+1) + l.history = append(l.history, delta) + + for r, ch := range l.waiters { + switch { + case r < l.lastRound(): + fmt.Printf("%d < %d\n", r, l.lastRound()) + panic("why is there a waiter for an old block?") + case r == l.lastRound(): + close(ch) + delete(l.waiters, r) + case r > l.lastRound(): + /* waiter keeps waiting */ + } + } + return nil +} + +func (l *mockedLedger) LookupAccount(round basics.Round, addr basics.Address) (ledgercore.AccountData, basics.Round, basics.MicroAlgos, error) { + l.mu.Lock() + defer l.mu.Unlock() + + if round > l.lastRound() { + panic("mockedLedger.LookupAccount: future round") + } + + for r := round; r <= round; r-- { + if acct, ok := l.history[r][addr]; ok { + more := basics.MicroAlgos{Raw: acct.MicroAlgos.Raw + 1} + return acct, round, more, nil + } + } + return ledgercore.AccountData{}, round, basics.MicroAlgos{}, nil +} + +// waitFor confirms that the Service made it through the last block in the +// ledger and is waiting for the next. The Service is written such that it +// operates properly without this sort of wait, but for testing, we often want +// to wait so that we can confirm that the Service *didn't* do something. +func (l *mockedLedger) waitFor(s *Service, a *require.Assertions) { + a.Eventually(func() bool { // delay and confirm that the service advances to wait for next block + _, ok := l.waiters[l.LastRound()+1] + return ok + }, time.Second, 10*time.Millisecond) +} + +func (l *mockedLedger) Keys(rnd basics.Round) []account.ParticipationRecordForRound { + var ret []account.ParticipationRecordForRound + for addr, secrets := range l.participants { + if rnd > l.LastRound() { // Usually we're looking for key material for a future round + rnd = l.LastRound() + } + acct, _, _, err := l.LookupAccount(rnd, addr) + if err != nil { + panic(err.Error()) + } + + ret = append(ret, account.ParticipationRecordForRound{ + ParticipationRecord: account.ParticipationRecord{ + ParticipationID: [32]byte{}, + Account: addr, + Voting: secrets, + FirstValid: acct.VoteFirstValid, + LastValid: acct.VoteLastValid, + KeyDilution: acct.VoteKeyDilution, + }, + }) + } + return ret +} + +func (l *mockedLedger) addParticipant(addr basics.Address, otss *crypto.OneTimeSignatureSecrets) { + if l.participants == nil { + l.participants = make(map[basics.Address]*crypto.OneTimeSignatureSecrets) + } + l.participants[addr] = otss +} + +type txnSink [][]transactions.SignedTxn + +func (ts *txnSink) BroadcastInternalSignedTxGroup(group []transactions.SignedTxn) error { + fmt.Printf("sinking %+v\n", group[0].Txn.Header) + *ts = append(*ts, group) + return nil +} + +func TestStartStop(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + a := require.New(t) + sink := txnSink{} + ledger := newMockedLedger() + s := NewService(&ledger, &ledger, &sink, logging.TestingLog(t)) + a.NotNil(s) + a.NoError(ledger.addBlock(nil)) + s.Start() + a.NoError(ledger.addBlock(nil)) + s.Stop() +} + +func makeBlock(r basics.Round) bookkeeping.Block { + return bookkeeping.Block{ + BlockHeader: bookkeeping.BlockHeader{Round: r}, + Payset: []transactions.SignedTxnInBlock{}, + } +} + +func TestHeartbeatOnlyWhenChallenged(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + a := require.New(t) + sink := txnSink{} + ledger := newMockedLedger() + s := NewService(&ledger, &ledger, &sink, logging.TestingLog(t)) + s.Start() + + joe := basics.Address{0xcc} // 0xcc will matter when we set the challenge + mary := basics.Address{0xaa} // 0xaa will matter when we set the challenge + ledger.addParticipant(joe, nil) + ledger.addParticipant(mary, nil) + + acct := ledgercore.AccountData{} + + a.NoError(ledger.addBlock(table{joe: acct})) + ledger.waitFor(s, a) + a.Empty(sink) + + // now they are online, but not challenged, so no heartbeat + acct.Status = basics.Online + acct.VoteKeyDilution = 100 + startBatch := basics.OneTimeIDForRound(ledger.LastRound(), acct.VoteKeyDilution).Batch + const batches = 50 // gives 50 * kd rounds = 5000 + otss := crypto.GenerateOneTimeSignatureSecrets(startBatch, batches) + acct.VoteID = otss.OneTimeSignatureVerifier + ledger.addParticipant(joe, otss) + ledger.addParticipant(mary, otss) + + a.NoError(ledger.addBlock(table{joe: acct, mary: acct})) + a.Empty(sink) + + // now we have to make it seem like joe has been challenged. We obtain the + // payout rules to find the first challenge round, skip forward to it, then + // go forward half a grace period. Only then should the service heartbeat + hdr, err := ledger.BlockHdr(ledger.LastRound()) + ledger.hdr.Seed = committee.Seed{0xc8} // share 5 bits with 0xcc + a.NoError(err) + rules := config.Consensus[hdr.CurrentProtocol].Payouts + for ledger.LastRound() < basics.Round(rules.ChallengeInterval+rules.ChallengeGracePeriod/2) { + a.NoError(ledger.addBlock(table{})) + ledger.waitFor(s, a) + a.Empty(sink) + } + + a.NoError(ledger.addBlock(table{joe: acct})) + ledger.waitFor(s, a) + a.Len(sink, 1) // only one heartbeat (for joe) + a.Len(sink[0], 1) + a.Equal(sink[0][0].Txn.Type, protocol.HeartbeatTx) + a.Equal(sink[0][0].Txn.HbAddress, joe) + + s.Stop() +} diff --git a/ledger/apply/apply.go b/ledger/apply/apply.go index dfa61b2632..ecc96c967f 100644 --- a/ledger/apply/apply.go +++ b/ledger/apply/apply.go @@ -25,9 +25,14 @@ import ( "github.com/algorand/go-algorand/ledger/ledgercore" ) +// HdrProvider allows fetching old block headers +type HdrProvider interface { + BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) +} + // StateProofsApplier allows fetching and updating state-proofs state on the ledger type StateProofsApplier interface { - BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) + HdrProvider GetStateProofNextRound() basics.Round SetStateProofNextRound(rnd basics.Round) GetStateProofVerificationContext(stateProofLastAttestedRound basics.Round) (*ledgercore.StateProofVerificationContext, error) diff --git a/ledger/apply/challenge.go b/ledger/apply/challenge.go new file mode 100644 index 0000000000..fa060879e6 --- /dev/null +++ b/ledger/apply/challenge.go @@ -0,0 +1,116 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package apply + +import ( + "math/bits" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/committee" +) + +// ChallengePeriod indicates which part of the challenge period is under discussion. +type ChallengePeriod int + +const ( + // ChRisky indicates that a challenge is in effect, and the initial grace period is running out. + ChRisky ChallengePeriod = iota + // ChActive indicates that a challenege is in effect, and the grace period + // has run out, so accounts can be suspended + ChActive +) + +type challenge struct { + // round is when the challenge occurred. 0 means this is not a challenge. + round basics.Round + // accounts that match the first `bits` of `seed` must propose or heartbeat to stay online + seed committee.Seed + bits int +} + +type headerSource interface { + BlockHdr(round basics.Round) (bookkeeping.BlockHeader, error) +} + +// FindChallenge returns the Challenge that was last issued if it's in the period requested. +func FindChallenge(rules config.ProposerPayoutRules, current basics.Round, headers headerSource, period ChallengePeriod) challenge { + // are challenges active? + interval := basics.Round(rules.ChallengeInterval) + if rules.ChallengeInterval == 0 || current < interval { + return challenge{} + } + lastChallenge := current - (current % interval) + grace := basics.Round(rules.ChallengeGracePeriod) + // FindChallenge is structured this way, instead of returning the challenge + // and letting the caller determine the period it cares about, to avoid + // using BlockHdr unnecessarily. + switch period { + case ChRisky: + if current <= lastChallenge+grace/2 || current > lastChallenge+grace { + return challenge{} + } + case ChActive: + if current <= lastChallenge+grace || current > lastChallenge+2*grace { + return challenge{} + } + } + challengeHdr, err := headers.BlockHdr(lastChallenge) + if err != nil { + panic(err) + } + challengeProto := config.Consensus[challengeHdr.CurrentProtocol] + // challenge is not considered if rules have changed since that round + if challengeProto.Payouts != rules { + return challenge{} + } + return challenge{lastChallenge, challengeHdr.Seed, rules.ChallengeBits} +} + +// IsZero returns true if the challenge is empty (used to indicate no challenege) +func (ch challenge) IsZero() bool { + return ch == challenge{} +} + +// Failed returns true iff ch is in effect, matches address, and lastSeen is +// before the challenge issue. +func (ch challenge) Failed(address basics.Address, lastSeen basics.Round) bool { + return ch.round != 0 && bitsMatch(ch.seed[:], address[:], ch.bits) && lastSeen < ch.round +} + +// 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. +func bitsMatch(a, b []byte, n int) bool { + // Ensure n is a valid number of bits to compare + if n < 0 || n > len(a)*8 || n > len(b)*8 { + return false + } + + // Compare entire bytes when n is bigger than 8 + for i := 0; i < n/8; i++ { + if a[i] != b[i] { + return false + } + } + remaining := n % 8 + if remaining == 0 { + return true + } + return bits.LeadingZeros8(a[n/8]^b[n/8]) >= remaining +} diff --git a/ledger/apply/challenge_test.go b/ledger/apply/challenge_test.go new file mode 100644 index 0000000000..3114b6f935 --- /dev/null +++ b/ledger/apply/challenge_test.go @@ -0,0 +1,121 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package apply + +import ( + "testing" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/test/partitiontest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBitsMatch(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + for b := 0; b <= 6; b++ { + require.True(t, bitsMatch([]byte{0x1}, []byte{0x2}, b), "%d", b) + } + require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 7)) + require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 8)) + require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 9)) + + for b := 0; b <= 12; b++ { + require.True(t, bitsMatch([]byte{0x1, 0xff, 0xaa}, []byte{0x1, 0xf0}, b), "%d", b) + } + require.False(t, bitsMatch([]byte{0x1, 0xff, 0xaa}, []byte{0x1, 0xf0}, 13)) + + // on a byte boundary + require.True(t, bitsMatch([]byte{0x1}, []byte{0x1}, 8)) + require.False(t, bitsMatch([]byte{0x1}, []byte{0x1}, 9)) + require.True(t, bitsMatch([]byte{0x1, 0xff}, []byte{0x1, 0x00}, 8)) + require.False(t, bitsMatch([]byte{0x1, 0xff}, []byte{0x1, 00}, 9)) +} + +func TestFailsChallenge(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + a := assert.New(t) + + // a valid challenge, with 4 matching bits, and an old last seen + a.True(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}.Failed(basics.Address{0xbf, 0x34}, 10)) + + // challenge isn't "on" + a.False(challenge{round: 0, seed: [32]byte{0xb0, 0xb4}, bits: 4}.Failed(basics.Address{0xbf, 0x34}, 10)) + // node has appeared more recently + a.False(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}.Failed(basics.Address{0xbf, 0x34}, 12)) + // bits don't match + a.False(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}.Failed(basics.Address{0xcf, 0x34}, 10)) + // no enough bits match + a.False(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 5}.Failed(basics.Address{0xbf, 0x34}, 10)) +} + +type singleSource bookkeeping.BlockHeader + +func (ss singleSource) BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) { + return bookkeeping.BlockHeader(ss), nil +} + +func TestActiveChallenge(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + a := assert.New(t) + + nowHeader := bookkeeping.BlockHeader{ + UpgradeState: bookkeeping.UpgradeState{ + // Here the rules are on, so they certainly differ from rules in oldHeader's params + CurrentProtocol: protocol.ConsensusFuture, + }, + } + rules := config.Consensus[nowHeader.CurrentProtocol].Payouts + + // simplest test. when interval=X and grace=G, X+G+1 is a challenge + inChallenge := basics.Round(rules.ChallengeInterval + rules.ChallengeGracePeriod + 1) + ch := FindChallenge(rules, inChallenge, singleSource(nowHeader), ChActive) + a.NotZero(ch.round) + + // all rounds before that have no challenge + for r := basics.Round(1); r < inChallenge; r++ { + ch := FindChallenge(rules, r, singleSource(nowHeader), ChActive) + a.Zero(ch.round, r) + } + + // ChallengeGracePeriod rounds allow challenges starting with inChallenge + for r := inChallenge; r < inChallenge+basics.Round(rules.ChallengeGracePeriod); r++ { + ch := FindChallenge(rules, r, singleSource(nowHeader), ChActive) + a.EqualValues(ch.round, rules.ChallengeInterval) + } + + // And the next round is again challenge-less + ch = FindChallenge(rules, inChallenge+basics.Round(rules.ChallengeGracePeriod), singleSource(nowHeader), ChActive) + a.Zero(ch.round) + + // ignore challenge if upgrade happened + oldHeader := bookkeeping.BlockHeader{ + UpgradeState: bookkeeping.UpgradeState{ + // We need a version from before payouts got turned on + CurrentProtocol: protocol.ConsensusV39, + }, + } + ch = FindChallenge(rules, inChallenge, singleSource(oldHeader), ChActive) + a.Zero(ch.round) +} diff --git a/ledger/apply/heartbeat.go b/ledger/apply/heartbeat.go new file mode 100644 index 0000000000..9e1056dc3c --- /dev/null +++ b/ledger/apply/heartbeat.go @@ -0,0 +1,124 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package apply + +import ( + "fmt" + + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/transactions" +) + +// Heartbeat applies a Heartbeat transaction using the Balances interface. +func Heartbeat(hb transactions.HeartbeatTxnFields, header transactions.Header, balances Balances, provider HdrProvider, round basics.Round) error { + // Get the account's balance entry + account, err := balances.Get(hb.HbAddress, false) + if err != nil { + return err + } + + // In txnGroupBatchPrep, we do not charge for singleton (Group.IsZero) + // heartbeats. But we only _want_ to allow free heartbeats if the account is + // under challenge. If this is an underpaid singleton heartbeat, reject it + // unless the account is under challenge. + + proto := balances.ConsensusParams() + if header.Fee.Raw < proto.MinTxnFee && header.Group.IsZero() { + kind := "free" + if header.Fee.Raw > 0 { + kind = "cheap" + } + + // These first checks are a little draconian. The idea is not let these + // free transactions do anything except their exact intended purpose. + if len(header.Note) > 0 { + return fmt.Errorf("%s heartbeat is not allowed to have a note", kind) + } + if header.Lease != [32]byte{} { + return fmt.Errorf("%s heartbeat is not allowed to have a lease", kind) + } + if !header.RekeyTo.IsZero() { + return fmt.Errorf("%s heartbeat is not allowed to rekey", kind) + } + + if account.Status != basics.Online { + return fmt.Errorf("%s heartbeat is not allowed for %s %+v", kind, account.Status, hb.HbAddress) + } + if !account.IncentiveEligible { + return fmt.Errorf("%s heartbeat is not allowed when not IncentiveEligible %+v", kind, hb.HbAddress) + } + ch := FindChallenge(proto.Payouts, round, provider, ChRisky) + if ch.round == 0 { + return fmt.Errorf("%s heartbeat for %s is not allowed with no challenge", kind, hb.HbAddress) + } + if !ch.Failed(hb.HbAddress, account.LastSeen()) { + return fmt.Errorf("%s heartbeat for %s is not challenged by %+v", kind, hb.HbAddress, ch) + } + } + + // Note the contrast with agreement. We are using the account's _current_ + // partkey to verify the heartbeat. This is required because we can only + // look 320 rounds back for voting information. If a heartbeat was delayed a + // few rounds (even 1), we could not ask "what partkey was in effect at + // firstValid-320?" Using the current keys means that an account that + // changes keys would invalidate any heartbeats it has already sent out + // (that haven't been evaluated yet). Maybe more importantly, after going + // offline, an account can no longer heartbeat, since it has no _current_ + // keys. Yet it is still expected to vote for 320 rounds. Therefore, + // challenges do not apply to accounts that are offline (even if they should + // still be voting). + + // Conjure up an OnlineAccountData from current state, for convenience of + // oad.KeyDilution(). + oad := basics.OnlineAccountData{ + VotingData: account.VotingData, + } + + sv := oad.VoteID + if sv.IsEmpty() { + return fmt.Errorf("heartbeat address %s has no voting keys", hb.HbAddress) + } + kd := oad.KeyDilution(proto) + + // heartbeats are expected to sign with the partkey for their last-valid round + id := basics.OneTimeIDForRound(header.LastValid, kd) + + // heartbeats sign a message consisting of the BlockSeed of the first-valid + // round, to discourage unsavory behaviour like presigning a bunch of + // heartbeats for later use keeping an unavailable account online. + hdr, err := provider.BlockHdr(header.FirstValid) + if err != nil { + return err + } + if hdr.Seed != hb.HbSeed { + return fmt.Errorf("provided seed %v does not match round %d's seed %v", hb.HbSeed, header.FirstValid, hdr.Seed) + } + + if !sv.Verify(id, hdr.Seed, hb.HbProof.ToOneTimeSignature()) { + return fmt.Errorf("heartbeat failed verification with VoteID %v", sv) + } + + account.LastHeartbeat = round + + // Write the updated entry + err = balances.Put(hb.HbAddress, account) + if err != nil { + return err + } + + return nil +} diff --git a/ledger/apply/heartbeat_test.go b/ledger/apply/heartbeat_test.go new file mode 100644 index 0000000000..f7d3845b12 --- /dev/null +++ b/ledger/apply/heartbeat_test.go @@ -0,0 +1,193 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package apply + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/committee" + "github.com/algorand/go-algorand/data/txntest" + "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/test/partitiontest" +) + +func TestHeartbeat(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + // Creator + sender := basics.Address{0x01} + voter := basics.Address{0x02} + const keyDilution = 777 + + fv := basics.Round(100) + lv := basics.Round(1000) + + id := basics.OneTimeIDForRound(lv, keyDilution) + otss := crypto.GenerateOneTimeSignatureSecrets(1, 2) // This will cover rounds 1-2*777 + + mockBal := makeMockBalancesWithAccounts(protocol.ConsensusFuture, map[basics.Address]basics.AccountData{ + sender: { + MicroAlgos: basics.MicroAlgos{Raw: 10_000_000}, + }, + voter: { + Status: basics.Online, + MicroAlgos: basics.MicroAlgos{Raw: 100_000_000}, + VoteID: otss.OneTimeSignatureVerifier, + VoteKeyDilution: keyDilution, + IncentiveEligible: true, + }, + }) + + seed := committee.Seed{0x01, 0x02, 0x03} + mockHdr := makeMockHeaders(bookkeeping.BlockHeader{ + Round: fv, + Seed: seed, + }) + + test := txntest.Txn{ + Type: protocol.HeartbeatTx, + Sender: sender, + Fee: basics.MicroAlgos{Raw: 1}, + FirstValid: fv, + LastValid: lv, + HbAddress: voter, + HbProof: otss.Sign(id, seed).ToHeartbeatProof(), + HbSeed: seed, + } + + tx := test.Txn() + + rnd := basics.Round(150) + // no fee + err := Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) + require.ErrorContains(t, err, "cheap heartbeat") + + test.Fee = basics.MicroAlgos{Raw: 10} + tx = test.Txn() + // just as bad: cheap + err = Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) + require.ErrorContains(t, err, "cheap heartbeat") + + test.Fee = 1000 + tx = test.Txn() + err = Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) + require.NoError(t, err) + + after, err := mockBal.Get(voter, false) + require.NoError(t, err) + require.Equal(t, rnd, after.LastHeartbeat) + require.Zero(t, after.LastProposed) // unchanged +} + +// TestCheapRules ensures a heartbeat can only have a low fee if the account +// being heartbeat for is online, under risk of suspension by challenge, and +// incentive eligible. +func TestCheapRules(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + type tcase struct { + rnd basics.Round + addrStart byte + status basics.Status + incentiveEligble bool + note []byte + lease [32]byte + rekey [32]byte + err string + } + empty := [32]byte{} + // Grace period is 200. For the second half of the grace period (1101-1200), + // the heartbeat is free for online, incentive eligible, challenged accounts. + cases := []tcase{ + // test of range + {1100, 0x01, basics.Online, true, nil, empty, empty, "no challenge"}, + {1101, 0x01, basics.Online, true, nil, empty, empty, ""}, + {1200, 0x01, basics.Online, true, nil, empty, empty, ""}, + {1201, 0x01, basics.Online, true, nil, empty, empty, "no challenge"}, + + // test of the other requirements + {1101, 0x01, basics.Online, true, []byte("note"), empty, empty, "not allowed to have a note"}, + {1101, 0x01, basics.Online, true, nil, [32]byte{'l', 'e', 'a', 's', 'e'}, empty, "not allowed to have a lease"}, + {1101, 0x01, basics.Online, true, nil, empty, [32]byte{'r', 'e', 'k', 'e', 'y'}, "not allowed to rekey"}, + {1101, 0xf1, basics.Online, true, nil, empty, empty, "not challenged by"}, + {1101, 0x01, basics.Offline, true, nil, empty, empty, "not allowed for Offline"}, + {1101, 0x01, basics.Online, false, nil, empty, empty, "not allowed when not IncentiveEligible"}, + } + for _, tc := range cases { + const keyDilution = 777 + + lv := basics.Round(tc.rnd + 10) + + id := basics.OneTimeIDForRound(lv, keyDilution) + otss := crypto.GenerateOneTimeSignatureSecrets(1, 10) // This will cover rounds 1-10*777 + + sender := basics.Address{0x01} + voter := basics.Address{tc.addrStart} + mockBal := makeMockBalancesWithAccounts(protocol.ConsensusFuture, map[basics.Address]basics.AccountData{ + sender: { + MicroAlgos: basics.MicroAlgos{Raw: 10_000_000}, + }, + voter: { + Status: tc.status, + MicroAlgos: basics.MicroAlgos{Raw: 100_000_000}, + VoteID: otss.OneTimeSignatureVerifier, + VoteKeyDilution: keyDilution, + IncentiveEligible: tc.incentiveEligble, + }, + }) + + seed := committee.Seed{0x01, 0x02, 0x03} + mockHdr := makeMockHeaders() + mockHdr.setFallback(bookkeeping.BlockHeader{ + UpgradeState: bookkeeping.UpgradeState{ + CurrentProtocol: protocol.ConsensusFuture, + }, + Seed: seed, + }) + txn := txntest.Txn{ + Type: protocol.HeartbeatTx, + Sender: sender, + Fee: basics.MicroAlgos{Raw: 1}, + FirstValid: tc.rnd - 10, + LastValid: tc.rnd + 10, + Lease: tc.lease, + Note: tc.note, + RekeyTo: tc.rekey, + HbAddress: voter, + HbProof: otss.Sign(id, seed).ToHeartbeatProof(), + HbSeed: seed, + } + + tx := txn.Txn() + fmt.Printf("tc %+v\n", tc) + err := Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, tc.rnd) + if tc.err == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.err, "%+v", tc) + } + } +} diff --git a/ledger/apply/mockBalances_test.go b/ledger/apply/mockBalances_test.go index 43af5fa11d..a18500341e 100644 --- a/ledger/apply/mockBalances_test.go +++ b/ledger/apply/mockBalances_test.go @@ -17,8 +17,11 @@ package apply import ( + "fmt" + "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/data/transactions/logic" "github.com/algorand/go-algorand/ledger/ledgercore" @@ -270,3 +273,33 @@ func (b *mockCreatableBalances) HasAssetParams(addr basics.Address, aidx basics. _, ok = acct.AssetParams[aidx] return } + +type mockHeaders struct { + perRound map[basics.Round]bookkeeping.BlockHeader + fallback *bookkeeping.BlockHeader +} + +// makeMockHeaders takes a bunch of BlockHeaders and returns a HdrProivder for them. +func makeMockHeaders(hdrs ...bookkeeping.BlockHeader) mockHeaders { + b := make(map[basics.Round]bookkeeping.BlockHeader) + for _, hdr := range hdrs { + b[hdr.Round] = hdr + } + return mockHeaders{perRound: b} +} + +func (m mockHeaders) BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) { + if hdr, ok := m.perRound[r]; ok { + return hdr, nil + } + if m.fallback != nil { + copy := *m.fallback + copy.Round = r + return copy, nil + } + return bookkeeping.BlockHeader{}, fmt.Errorf("round %v is not present", r) +} + +func (m *mockHeaders) setFallback(hdr bookkeeping.BlockHeader) { + m.fallback = &hdr +} diff --git a/ledger/apptxn_test.go b/ledger/apptxn_test.go index a7b3b15214..fce41b00a3 100644 --- a/ledger/apptxn_test.go +++ b/ledger/apptxn_test.go @@ -104,9 +104,9 @@ func TestPayAction(t *testing.T) { dl.t.Log("postsink", postsink, "postprop", postprop) if ver >= payoutsVer { - bonus := 10_000_000 // config/consensus.go - assert.EqualValues(t, bonus-500, presink-postsink) // based on 75% in config/consensus.go - require.EqualValues(t, bonus+1500, postprop-preprop) + bonus := 10_000_000 // config/consensus.go + assert.EqualValues(t, bonus-1000, presink-postsink) // based on 50% in config/consensus.go + require.EqualValues(t, bonus+1000, postprop-preprop) } else { require.EqualValues(t, 2000, postsink-presink) // no payouts yet } diff --git a/ledger/eval/eval.go b/ledger/eval/eval.go index 1af542967d..75404d36fe 100644 --- a/ledger/eval/eval.go +++ b/ledger/eval/eval.go @@ -21,7 +21,6 @@ import ( "errors" "fmt" "math" - "math/bits" "sync" "github.com/algorand/go-algorand/agreement" @@ -29,7 +28,6 @@ import ( "github.com/algorand/go-algorand/crypto" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/bookkeeping" - "github.com/algorand/go-algorand/data/committee" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/data/transactions/logic" "github.com/algorand/go-algorand/data/transactions/verify" @@ -610,6 +608,7 @@ func (cs *roundCowState) Move(from basics.Address, to basics.Address, amt basics if overflowed { return fmt.Errorf("overspend (account %v, data %+v, tried to spend %v)", from, fromBal, amt) } + fromBalNew = cs.autoHeartbeat(fromBal, fromBalNew) err = cs.putAccount(from, fromBalNew) if err != nil { return err @@ -638,6 +637,7 @@ func (cs *roundCowState) Move(from basics.Address, to basics.Address, amt basics if overflowed { return fmt.Errorf("balance overflow (account %v, data %+v, was going to receive %v)", to, toBal, amt) } + toBalNew = cs.autoHeartbeat(toBal, toBalNew) err = cs.putAccount(to, toBalNew) if err != nil { return err @@ -647,6 +647,24 @@ func (cs *roundCowState) Move(from basics.Address, to basics.Address, amt basics return nil } +// autoHeartbeat compares `before` and `after`, returning a new AccountData +// based on `after` but with an updated `LastHeartbeat` if `after` shows enough +// balance increase to risk a false positive suspension for absenteeism. +func (cs *roundCowState) autoHeartbeat(before, after ledgercore.AccountData) ledgercore.AccountData { + // No need to adjust unless account is suspendable + if after.Status != basics.Online || !after.IncentiveEligible { + return after + } + + // Adjust only if balance has doubled + twice, o := basics.OMul(before.MicroAlgos.Raw, 2) + if !o && twice < after.MicroAlgos.Raw { + lookback := agreement.BalanceLookback(cs.ConsensusParams()) + after.LastHeartbeat = cs.Round() + lookback + } + return after +} + func (cs *roundCowState) ConsensusParams() config.ConsensusParams { return cs.proto } @@ -1291,6 +1309,9 @@ func (eval *BlockEvaluator) applyTransaction(tx transactions.Transaction, cow *r // Validation of the StateProof transaction before applying will only occur in validate mode. err = apply.StateProof(tx.StateProofTxnFields, tx.Header.FirstValid, cow, eval.validate) + case protocol.HeartbeatTx: + err = apply.Heartbeat(tx.HeartbeatTxnFields, tx.Header, cow, cow, cow.Round()) + default: err = fmt.Errorf("unknown transaction type %v", tx.Type) } @@ -1606,14 +1627,6 @@ func (eval *BlockEvaluator) proposerPayout() (basics.MicroAlgos, error) { return basics.MinA(total, available), nil } -type challenge struct { - // round is when the challenge occurred. 0 means this is not a challenge. - round basics.Round - // accounts that match the first `bits` of `seed` must propose or heartbeat to stay online - seed committee.Seed - bits int -} - // generateKnockOfflineAccountsList creates the lists of expired or absent // participation accounts by traversing over the modified accounts in the state // deltas and testing if any of them needs to be reset/suspended. Expiration @@ -1638,7 +1651,7 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []bas updates := &eval.block.ParticipationUpdates - ch := activeChallenge(&eval.proto, uint64(current), eval.state) + ch := apply.FindChallenge(eval.proto.Payouts, current, eval.state, apply.ChActive) onlineStake, err := eval.state.onlineStake() if err != nil { logging.Base().Errorf("unable to fetch online stake, no knockoffs: %v", err) @@ -1741,7 +1754,7 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []bas continue } if isAbsent(onlineStake, oad.VotingStake(), lastSeen, current) || - failsChallenge(ch, accountAddr, lastSeen) { + ch.Failed(accountAddr, lastSeen) { updates.AbsentParticipationAccounts = append( updates.AbsentParticipationAccounts, accountAddr, @@ -1751,28 +1764,6 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []bas } } -// 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. -func bitsMatch(a, b []byte, n int) bool { - // Ensure n is a valid number of bits to compare - if n < 0 || n > len(a)*8 || n > len(b)*8 { - return false - } - - // Compare entire bytes when n is bigger than 8 - for i := 0; i < n/8; i++ { - if a[i] != b[i] { - return false - } - } - remaining := n % 8 - if remaining == 0 { - return true - } - return bits.LeadingZeros8(a[n/8]^b[n/8]) >= remaining -} - func isAbsent(totalOnlineStake basics.MicroAlgos, acctStake basics.MicroAlgos, lastSeen basics.Round, current basics.Round) bool { // Don't consider accounts that were online when payouts went into effect as // absent. They get noticed the next time they propose or keyreg, which @@ -1790,39 +1781,6 @@ func isAbsent(totalOnlineStake basics.MicroAlgos, acctStake basics.MicroAlgos, l return lastSeen+basics.Round(allowableLag) < current } -type headerSource interface { - BlockHdr(round basics.Round) (bookkeeping.BlockHeader, error) -} - -func activeChallenge(proto *config.ConsensusParams, current uint64, headers headerSource) challenge { - rules := proto.Payouts - // are challenges active? - if rules.ChallengeInterval == 0 || current < rules.ChallengeInterval { - return challenge{} - } - lastChallenge := current - (current % rules.ChallengeInterval) - // challenge is in effect if we're after one grace period, but before the 2nd ends. - if current <= lastChallenge+rules.ChallengeGracePeriod || - current > lastChallenge+2*rules.ChallengeGracePeriod { - return challenge{} - } - round := basics.Round(lastChallenge) - challengeHdr, err := headers.BlockHdr(round) - if err != nil { - panic(err) - } - challengeProto := config.Consensus[challengeHdr.CurrentProtocol] - // challenge is not considered if rules have changed since that round - if challengeProto.Payouts != rules { - return challenge{} - } - return challenge{round, challengeHdr.Seed, rules.ChallengeBits} -} - -func failsChallenge(ch challenge, address basics.Address, lastSeen basics.Round) bool { - return ch.round != 0 && bitsMatch(ch.seed[:], address[:], ch.bits) && lastSeen < ch.round -} - // validateExpiredOnlineAccounts tests the expired online accounts specified in ExpiredParticipationAccounts, and verify // that they have all expired and need to be reset. func (eval *BlockEvaluator) validateExpiredOnlineAccounts() error { @@ -1890,7 +1848,7 @@ func (eval *BlockEvaluator) validateAbsentOnlineAccounts() error { // For consistency with expired account handling, we preclude duplicates addressSet := make(map[basics.Address]bool, suspensionCount) - ch := activeChallenge(&eval.proto, uint64(eval.Round()), eval.state) + ch := apply.FindChallenge(eval.proto.Payouts, eval.Round(), eval.state, apply.ChActive) totalOnlineStake, err := eval.state.onlineStake() if err != nil { logging.Base().Errorf("unable to fetch online stake, can't check knockoffs: %v", err) @@ -1918,15 +1876,14 @@ func (eval *BlockEvaluator) validateAbsentOnlineAccounts() error { return fmt.Errorf("proposed absent account %v with zero algos", accountAddr) } - lastSeen := max(acctData.LastProposed, acctData.LastHeartbeat) oad, lErr := eval.state.lookupAgreement(accountAddr) if lErr != nil { return fmt.Errorf("unable to check absent account: %v", accountAddr) } - if isAbsent(totalOnlineStake, oad.VotingStake(), lastSeen, eval.Round()) { + if isAbsent(totalOnlineStake, oad.VotingStake(), acctData.LastSeen(), eval.Round()) { continue // ok. it's "normal absent" } - if failsChallenge(ch, accountAddr, lastSeen) { + if ch.Failed(accountAddr, acctData.LastSeen()) { continue // ok. it's "challenge absent" } return fmt.Errorf("proposed absent account %v is not absent in %d, %d", diff --git a/ledger/eval/eval_test.go b/ledger/eval/eval_test.go index df17357a10..d33996b2d8 100644 --- a/ledger/eval/eval_test.go +++ b/ledger/eval/eval_test.go @@ -1605,29 +1605,6 @@ func TestExpiredAccountGeneration(t *testing.T) { require.NotZero(t, propAcct.StateProofID) } -func TestBitsMatch(t *testing.T) { - partitiontest.PartitionTest(t) - t.Parallel() - - for b := 0; b <= 6; b++ { - require.True(t, bitsMatch([]byte{0x1}, []byte{0x2}, b), "%d", b) - } - require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 7)) - require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 8)) - require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 9)) - - for b := 0; b <= 12; b++ { - require.True(t, bitsMatch([]byte{0x1, 0xff, 0xaa}, []byte{0x1, 0xf0}, b), "%d", b) - } - require.False(t, bitsMatch([]byte{0x1, 0xff, 0xaa}, []byte{0x1, 0xf0}, 13)) - - // on a byte boundary - require.True(t, bitsMatch([]byte{0x1}, []byte{0x1}, 8)) - require.False(t, bitsMatch([]byte{0x1}, []byte{0x1}, 9)) - require.True(t, bitsMatch([]byte{0x1, 0xff}, []byte{0x1, 0x00}, 8)) - require.False(t, bitsMatch([]byte{0x1, 0xff}, []byte{0x1, 00}, 9)) -} - func TestIsAbsent(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() @@ -1646,72 +1623,3 @@ func TestIsAbsent(t *testing.T) { a.False(absent(1000, 10, 0, 6000)) a.False(absent(1000, 10, 0, 6001)) } - -func TestFailsChallenge(t *testing.T) { - partitiontest.PartitionTest(t) - t.Parallel() - a := assert.New(t) - - // a valid challenge, with 4 matching bits, and an old last seen - a.True(failsChallenge(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}, basics.Address{0xbf, 0x34}, 10)) - - // challenge isn't "on" - a.False(failsChallenge(challenge{round: 0, seed: [32]byte{0xb0, 0xb4}, bits: 4}, basics.Address{0xbf, 0x34}, 10)) - // node has appeared more recently - a.False(failsChallenge(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}, basics.Address{0xbf, 0x34}, 12)) - // bits don't match - a.False(failsChallenge(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}, basics.Address{0xcf, 0x34}, 10)) - // no enough bits match - a.False(failsChallenge(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 5}, basics.Address{0xbf, 0x34}, 10)) -} - -type singleSource bookkeeping.BlockHeader - -func (ss singleSource) BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) { - return bookkeeping.BlockHeader(ss), nil -} - -func TestActiveChallenge(t *testing.T) { - partitiontest.PartitionTest(t) - t.Parallel() - a := assert.New(t) - - nowHeader := bookkeeping.BlockHeader{ - UpgradeState: bookkeeping.UpgradeState{ - // Here the rules are on, so they certainly differ from rules in oldHeader's params - CurrentProtocol: protocol.ConsensusFuture, - }, - } - now := config.Consensus[nowHeader.CurrentProtocol] - - // simplest test. when interval=X and grace=G, X+G+1 is a challenge - inChallenge := now.Payouts.ChallengeInterval + now.Payouts.ChallengeGracePeriod + 1 - ch := activeChallenge(&now, inChallenge, singleSource(nowHeader)) - a.NotZero(ch.round) - - // all rounds before that have no challenge - for r := uint64(1); r < inChallenge; r++ { - ch := activeChallenge(&now, r, singleSource(nowHeader)) - a.Zero(ch.round, r) - } - - // ChallengeGracePeriod rounds allow challenges starting with inChallenge - for r := inChallenge; r < inChallenge+now.Payouts.ChallengeGracePeriod; r++ { - ch := activeChallenge(&now, r, singleSource(nowHeader)) - a.EqualValues(ch.round, now.Payouts.ChallengeInterval) - } - - // And the next round is again challenge-less - ch = activeChallenge(&now, inChallenge+now.Payouts.ChallengeGracePeriod, singleSource(nowHeader)) - a.Zero(ch.round) - - // ignore challenge if upgrade happened - oldHeader := bookkeeping.BlockHeader{ - UpgradeState: bookkeeping.UpgradeState{ - // We need a version from before payouts got turned on - CurrentProtocol: protocol.ConsensusV39, - }, - } - ch = activeChallenge(&now, inChallenge, singleSource(oldHeader)) - a.Zero(ch.round) -} diff --git a/ledger/eval_simple_test.go b/ledger/eval_simple_test.go index 1e02157ae5..191ee6e1c0 100644 --- a/ledger/eval_simple_test.go +++ b/ledger/eval_simple_test.go @@ -281,15 +281,15 @@ func TestPayoutFees(t *testing.T) { // new fields are in the header require.EqualValues(t, 2000, vb.Block().FeesCollected.Raw) require.EqualValues(t, bonus1, vb.Block().Bonus.Raw) - require.EqualValues(t, bonus1+1_500, vb.Block().ProposerPayout().Raw) + require.EqualValues(t, bonus1+1_000, vb.Block().ProposerPayout().Raw) // This last one is really only testing the "fake" agreement that // happens in dl.endBlock(). require.EqualValues(t, proposer, vb.Block().Proposer()) // At the end of the block, part of the fees + bonus have been moved to // the proposer. - require.EqualValues(t, bonus1+1500, postprop-preprop) // based on 75% in config/consensus.go - require.EqualValues(t, bonus1-500, presink-postsink) + require.EqualValues(t, bonus1+1_000, postprop-preprop) // based on 75% in config/consensus.go + require.EqualValues(t, bonus1-1_000, presink-postsink) require.Equal(t, prp.LastProposed, dl.generator.Latest()) } else { require.False(t, dl.generator.GenesisProto().Payouts.Enabled) diff --git a/ledger/heartbeat_test.go b/ledger/heartbeat_test.go new file mode 100644 index 0000000000..2af0310fef --- /dev/null +++ b/ledger/heartbeat_test.go @@ -0,0 +1,155 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package ledger + +import ( + "testing" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/txntest" + ledgertesting "github.com/algorand/go-algorand/ledger/testing" + "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/test/partitiontest" + "github.com/stretchr/testify/require" +) + +/* Tests within the `apply` package test the effects of heartbeats, while test + here are closer to integration tests, they test heartbeats in the context of + a more realistic ledger. */ + +// TestHearbeat exercises heartbeat transactions +func TestHeartbeat(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + genBalances, addrs, _ := ledgertesting.NewTestGenesis(func(cfg *ledgertesting.GenesisCfg) { + cfg.OnlineCount = 2 // addrs[0] and addrs[1] will be online + }) + heartbeatsBegin := 40 + + ledgertesting.TestConsensusRange(t, heartbeatsBegin, 0, + func(t *testing.T, ver int, cv protocol.ConsensusVersion, cfg config.Local) { + dl := NewDoubleLedger(t, genBalances, cv, cfg) + defer dl.Close() + + dl.txns() // tests involving seed are easier if we have the first block in ledger + + // empty HbAddress means ZeroAddress, and it's not online + dl.txn(&txntest.Txn{Type: "hb", Sender: addrs[1]}, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ has no voting keys") + + // addrs[2] is not online, it has no voting keys + dl.txn(&txntest.Txn{Type: "hb", Sender: addrs[1], HbAddress: addrs[2]}, + addrs[2].String()+" has no voting keys") + + // addrs[1] is online, it has voting keys, but seed is missing + dl.txn(&txntest.Txn{Type: "hb", Sender: addrs[1], HbAddress: addrs[1], FirstValid: 1}, + "does not match round 1's seed") + + // NewTestGenesis creates random VoterID. Verification will fail. + b1, err := dl.generator.BlockHdr(1) + require.NoError(t, err) + dl.txn(&txntest.Txn{ + Type: "hb", + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: b1.Seed, + FirstValid: 1, + }, + "heartbeat failed verification with") + + // keyreg addr[1] so we have a valid VoterID + const kd = 10 + firstID := basics.OneTimeIDForRound(1, kd) + otss := crypto.GenerateOneTimeSignatureSecrets(firstID.Batch, 5) + dl.txn(&txntest.Txn{ + Type: "keyreg", + Sender: addrs[1], + VotePK: otss.OneTimeSignatureVerifier, + SelectionPK: crypto.VrfPubkey([32]byte{0x01}), // must be non-zero + VoteKeyDilution: kd, + }) + + // Supply and sign the wrong HbSeed + wrong := b1.Seed + wrong[0]++ + dl.txn(&txntest.Txn{ + Type: "hb", + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: wrong, + HbProof: otss.Sign(firstID, wrong).ToHeartbeatProof(), + FirstValid: 1, + }, + "does not match round 1's seed") + + b2, err := dl.generator.BlockHdr(2) + require.NoError(t, err) + + // Supply the right seed, but sign something else. We're also now + // setting LastValid and the proper OneTimeIDForRound, so that these + // tests are failing for the reasons described, not that. + dl.txn(&txntest.Txn{ + Type: "hb", + LastValid: 30, + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: b2.Seed, + HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), wrong).ToHeartbeatProof(), + FirstValid: 2, + }, + "failed verification") + + // Sign the right seed, but supply something else + dl.txn(&txntest.Txn{ + Type: "hb", + LastValid: 30, + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: wrong, + HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), b2.Seed).ToHeartbeatProof(), + FirstValid: 2, + }, + "does not match round 2's") + + // Mismatch the last valid and OneTimeIDForRound + dl.txn(&txntest.Txn{ + Type: "hb", + LastValid: 29, + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: b2.Seed, + HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), b2.Seed).ToHeartbeatProof(), + FirstValid: 2, + }, + "failed verification") + + // now we can make a real heartbeat, with a properly signed blockseed + dl.txn(&txntest.Txn{ + Type: "hb", + LastValid: 30, + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: b2.Seed, + HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), b2.Seed).ToHeartbeatProof(), + FirstValid: 2, + }) + + }) +} diff --git a/ledger/ledger.go b/ledger/ledger.go index 3f85b9e7bb..2f1f1f2027 100644 --- a/ledger/ledger.go +++ b/ledger/ledger.go @@ -762,7 +762,7 @@ func (l *Ledger) Block(rnd basics.Round) (blk bookkeeping.Block, err error) { func (l *Ledger) BlockHdr(rnd basics.Round) (blk bookkeeping.BlockHeader, err error) { // Expected availability range in txTail.blockHeader is [Latest - MaxTxnLife, Latest] - // allowing (MaxTxnLife + 1) = 1001 rounds back loopback. + // allowing (MaxTxnLife + 1) = 1001 rounds lookback. // The depth besides the MaxTxnLife is controlled by DeeperBlockHeaderHistory parameter // and currently set to 1. // Explanation: diff --git a/ledger/ledgercore/accountdata.go b/ledger/ledgercore/accountdata.go index 5b17730122..ea7b150a6e 100644 --- a/ledger/ledgercore/accountdata.go +++ b/ledger/ledgercore/accountdata.go @@ -135,10 +135,15 @@ func (u *AccountData) Suspend() { } // Suspended returns true if the account is suspended (offline with keys) -func (u *AccountData) Suspended() bool { +func (u AccountData) Suspended() bool { return u.Status == basics.Offline && !u.VoteID.IsEmpty() } +// LastSeen returns the last round that the account was seen online +func (u AccountData) LastSeen() basics.Round { + return max(u.LastProposed, u.LastHeartbeat) +} + // MinBalance computes the minimum balance requirements for an account based on // some consensus parameters. MinBalance should correspond roughly to how much // storage the account is allowed to store on disk. diff --git a/ledger/simple_test.go b/ledger/simple_test.go index 0995f88ecc..328f6f1528 100644 --- a/ledger/simple_test.go +++ b/ledger/simple_test.go @@ -129,7 +129,7 @@ func txn(t testing.TB, ledger *Ledger, eval *eval.BlockEvaluator, txn *txntest.T } return } - require.True(t, len(problem) == 0 || problem[0] == "") + require.True(t, len(problem) == 0 || problem[0] == "", "Transaction did not fail. Expected: %v", problem) } func txgroup(t testing.TB, ledger *Ledger, eval *eval.BlockEvaluator, txns ...*txntest.Txn) error { diff --git a/libgoal/libgoal.go b/libgoal/libgoal.go index f3f1c67192..e7739e085c 100644 --- a/libgoal/libgoal.go +++ b/libgoal/libgoal.go @@ -28,7 +28,6 @@ import ( v2 "github.com/algorand/go-algorand/daemon/algod/api/server/v2" kmdclient "github.com/algorand/go-algorand/daemon/kmd/client" "github.com/algorand/go-algorand/ledger/ledgercore" - "github.com/algorand/go-algorand/rpcs" "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/crypto" @@ -831,53 +830,43 @@ func (c *Client) Block(round uint64) (resp v2.BlockResponseJSON, err error) { // RawBlock takes a round and returns its block func (c *Client) RawBlock(round uint64) (resp []byte, err error) { algod, err := c.ensureAlgodClient() - if err == nil { - resp, err = algod.RawBlock(round) - } - return -} - -// EncodedBlockCert takes a round and returns its parsed block and certificate -func (c *Client) EncodedBlockCert(round uint64) (blockCert rpcs.EncodedBlockCert, err error) { - algod, err := c.ensureAlgodClient() - if err == nil { - var resp []byte - resp, err = algod.RawBlock(round) - if err == nil { - err = protocol.Decode(resp, &blockCert) - if err != nil { - return - } - } + if err != nil { + return } - return + return algod.RawBlock(round) } // BookkeepingBlock takes a round and returns its block func (c *Client) BookkeepingBlock(round uint64) (block bookkeeping.Block, err error) { - blockCert, err := c.EncodedBlockCert(round) - if err == nil { - return blockCert.Block, nil + algod, err := c.ensureAlgodClient() + if err != nil { + return } - return + blockCert, err := algod.EncodedBlockCert(round) + if err != nil { + return + } + return blockCert.Block, nil } // HealthCheck returns an error if something is wrong func (c *Client) HealthCheck() error { algod, err := c.ensureAlgodClient() - if err == nil { - err = algod.HealthCheck() + if err != nil { + return err } - return err + return algod.HealthCheck() } -// WaitForRound takes a round, waits until it appears and returns its status. This function blocks. +// WaitForRound takes a round, waits up to one minute, for it to appear and +// returns the node status. This function blocks and fails if the block does not +// appear in one minute. func (c *Client) WaitForRound(round uint64) (resp model.NodeStatusResponse, err error) { algod, err := c.ensureAlgodClient() - if err == nil { - resp, err = algod.StatusAfterBlock(round) + if err != nil { + return } - return + return algod.WaitForRound(round, time.Minute) } // GetBalance takes an address and returns its total balance; if the address doesn't exist, it returns 0. diff --git a/network/connPerfMon_test.go b/network/connPerfMon_test.go index 560be72a96..4c2bc5f034 100644 --- a/network/connPerfMon_test.go +++ b/network/connPerfMon_test.go @@ -103,14 +103,14 @@ func TestConnMonitorStageTiming(t *testing.T) { startTestTime := time.Now().UnixNano() perfMonitor := makeConnectionPerformanceMonitor([]Tag{protocol.AgreementVoteTag}) // measure measuring overhead. - measuringOverhead := time.Now().Sub(time.Now()) + measuringOverhead := time.Since(time.Now()) perfMonitor.Reset(peers) for msgIdx, msg := range msgPool { msg.Received += startTestTime beforeNotify := time.Now() beforeNotifyStage := perfMonitor.stage perfMonitor.Notify(&msg) - notifyTime := time.Now().Sub(beforeNotify) + notifyTime := time.Since(beforeNotify) stageTimings[beforeNotifyStage] += notifyTime stageNotifyCalls[beforeNotifyStage]++ if perfMonitor.GetPeersStatistics() != nil { diff --git a/node/node.go b/node/node.go index dddb3203e3..f536742c22 100644 --- a/node/node.go +++ b/node/node.go @@ -43,6 +43,7 @@ import ( "github.com/algorand/go-algorand/data/pools" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/data/transactions/verify" + "github.com/algorand/go-algorand/heartbeat" "github.com/algorand/go-algorand/ledger" "github.com/algorand/go-algorand/ledger/ledgercore" "github.com/algorand/go-algorand/ledger/simulation" @@ -155,6 +156,8 @@ type AlgorandFullNode struct { stateProofWorker *stateproof.Worker partHandles []db.Accessor + + heartbeatService *heartbeat.Service } // TxnWithStatus represents information about a single transaction, @@ -338,6 +341,8 @@ func MakeFull(log logging.Logger, rootDir string, cfg config.Local, phonebookAdd node.stateProofWorker = stateproof.NewWorker(node.genesisDirs.StateproofGenesisDir, node.log, node.accountManager, node.ledger.Ledger, node.net, node) + node.heartbeatService = heartbeat.NewService(node.accountManager, node.ledger, node, node.log) + return node, err } @@ -380,6 +385,7 @@ func (node *AlgorandFullNode) Start() error { node.ledgerService.Start() node.txHandler.Start() node.stateProofWorker.Start() + node.heartbeatService.Start() err := startNetwork() if err != nil { return err @@ -1221,6 +1227,7 @@ func (node *AlgorandFullNode) SetCatchpointCatchupMode(catchpointCatchupMode boo node.net.ClearHandlers() node.net.ClearValidatorHandlers() node.stateProofWorker.Stop() + node.heartbeatService.Stop() node.txHandler.Stop() node.agreementService.Shutdown() node.catchupService.Stop() @@ -1248,6 +1255,7 @@ func (node *AlgorandFullNode) SetCatchpointCatchupMode(catchpointCatchupMode boo node.ledgerService.Start() node.txHandler.Start() node.stateProofWorker.Start() + node.heartbeatService.Start() // Set up a context we can use to cancel goroutines on Stop() node.ctx, node.cancelCtx = context.WithCancel(context.Background()) diff --git a/protocol/txntype.go b/protocol/txntype.go index 76cb2dc406..ee2d085dcb 100644 --- a/protocol/txntype.go +++ b/protocol/txntype.go @@ -47,6 +47,9 @@ const ( // StateProofTx records a state proof StateProofTx TxType = "stpf" + // HeartbeatTx demonstrates the account is alive + HeartbeatTx TxType = "hb" + // UnknownTx signals an error UnknownTx TxType = "unknown" ) diff --git a/stateproof/builder.go b/stateproof/builder.go index 317e813602..a97ec752c6 100644 --- a/stateproof/builder.go +++ b/stateproof/builder.go @@ -669,7 +669,7 @@ func (spw *Worker) tryBroadcast() { latestHeader, err := spw.ledger.BlockHdr(firstValid) if err != nil { - spw.log.Warnf("spw.tryBroadcast: could not fetch block header for round %d failed: %v", firstValid, err) + spw.log.Warnf("spw.tryBroadcast: could not fetch block header for round %d: %v", firstValid, err) break } diff --git a/stateproof/worker.go b/stateproof/worker.go index f74e118f58..163ec214e0 100644 --- a/stateproof/worker.go +++ b/stateproof/worker.go @@ -95,9 +95,7 @@ func NewWorker(genesisDir string, log logging.Logger, accts Accounts, ledger Led // Start starts the goroutines for the worker. func (spw *Worker) Start() { - ctx, cancel := context.WithCancel(context.Background()) - spw.ctx = ctx - spw.shutdown = cancel + spw.ctx, spw.shutdown = context.WithCancel(context.Background()) spw.signedCh = make(chan struct{}, 1) err := spw.initDb(spw.inMemory) diff --git a/test/e2e-go/features/accountPerf/sixMillion_test.go b/test/e2e-go/features/accountPerf/sixMillion_test.go index 946d1b24b6..94feb3e9eb 100644 --- a/test/e2e-go/features/accountPerf/sixMillion_test.go +++ b/test/e2e-go/features/accountPerf/sixMillion_test.go @@ -1024,13 +1024,10 @@ func checkPoint(counter, firstValid, tLife uint64, force bool, fixture *fixtures if verbose { fmt.Printf("Waiting for round %d...", int(lastRound)) } - nodeStat, err := fixture.AlgodClient.WaitForBlock(basics.Round(lastRound - 1)) + nodeStat, err := fixture.AlgodClient.WaitForRound(lastRound, time.Minute) if err != nil { return 0, 0, fmt.Errorf("failed to wait for block %d : %w", lastRound, err) } - if nodeStat.LastRound < lastRound { - return 0, 0, fmt.Errorf("failed to wait for block %d : node is at round %d", lastRound, nodeStat.LastRound) - } return 0, nodeStat.LastRound + 1, nil } return counter, firstValid, nil diff --git a/test/e2e-go/features/catchup/basicCatchup_test.go b/test/e2e-go/features/catchup/basicCatchup_test.go index 2e3ac87943..adc8c43f18 100644 --- a/test/e2e-go/features/catchup/basicCatchup_test.go +++ b/test/e2e-go/features/catchup/basicCatchup_test.go @@ -56,9 +56,8 @@ func TestBasicCatchup(t *testing.T) { a.NoError(err) // Let the network make some progress - a.NoError(err) waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(3) a.NoError(err) // Now spin up third node @@ -71,7 +70,7 @@ func TestBasicCatchup(t *testing.T) { defer shutdownClonedNode(cloneDataDir, &fixture, t) // Now, catch up - err = fixture.LibGoalFixture.ClientWaitForRoundWithTimeout(cloneClient, waitForRound) + _, err = cloneClient.WaitForRound(waitForRound) a.NoError(err) } @@ -155,7 +154,7 @@ func runCatchupOverGossip(t fixtures.TestingTB, // Let the secondary make progress up to round 3, while the primary was never startred ( hence, it's on round = 0) waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // stop the secondary, which is on round 3 or more. @@ -167,7 +166,7 @@ func runCatchupOverGossip(t fixtures.TestingTB, a.NoError(err) // Now, catch up - err = fixture.LibGoalFixture.ClientWaitForRoundWithTimeout(lg, waitForRound) + _, err = lg.WaitForRound(waitForRound) a.NoError(err) waitStart := time.Now() @@ -184,7 +183,7 @@ func runCatchupOverGossip(t fixtures.TestingTB, break } - if time.Now().Sub(waitStart) > time.Minute { + if time.Since(waitStart) > time.Minute { // it's taking too long. a.FailNow("Waiting too long for catchup to complete") } @@ -258,7 +257,7 @@ func TestStoppedCatchupOnUnsupported(t *testing.T) { // Let the network make some progress a.NoError(err) waitForRound := uint64(3) // UpgradeVoteRounds + DefaultUpgradeWaitRounds - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Now spin up third node @@ -274,7 +273,7 @@ func TestStoppedCatchupOnUnsupported(t *testing.T) { defer shutdownClonedNode(cloneDataDir, &fixture, t) // Now, catch up - err = fixture.LibGoalFixture.ClientWaitForRoundWithTimeout(cloneClient, waitForRound) + _, err = cloneClient.WaitForRound(waitForRound) a.NoError(err) timeout := time.NewTimer(20 * time.Second) @@ -374,7 +373,7 @@ func TestBasicCatchupCompletes(t *testing.T) { a.NoError(err) // Wait for the network to make some progess. - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Start the third node to catchup. @@ -384,7 +383,7 @@ func TestBasicCatchupCompletes(t *testing.T) { defer shutdownClonedNode(cloneDataDir, &fixture, t) // Wait for it to catchup - err = fixture.LibGoalFixture.ClientWaitForRoundWithTimeout(cloneClient, waitForRound) + _, err = cloneClient.WaitForRound(waitForRound) a.NoError(err) // Calculate the catchup time diff --git a/test/e2e-go/features/catchup/catchpointCatchup_test.go b/test/e2e-go/features/catchup/catchpointCatchup_test.go index 3a1eefedc4..0a1d522cac 100644 --- a/test/e2e-go/features/catchup/catchpointCatchup_test.go +++ b/test/e2e-go/features/catchup/catchpointCatchup_test.go @@ -46,7 +46,7 @@ import ( const basicTestCatchpointInterval = 4 func waitForCatchpointGeneration(t *testing.T, fixture *fixtures.RestClientFixture, client client.RestClient, catchpointRound basics.Round) string { - err := fixture.ClientWaitForRoundWithTimeout(client, uint64(catchpointRound+1)) + err := client.WaitForRoundWithTimeout(uint64(catchpointRound + 1)) if err != nil { return "" } @@ -212,7 +212,7 @@ func startCatchpointGeneratingNode(a *require.Assertions, fixture *fixtures.Rest restClient := fixture.GetAlgodClientForController(nodeController) // We don't want to start using the node without it being properly initialized. - err = fixture.ClientWaitForRoundWithTimeout(restClient, 1) + err = restClient.WaitForRoundWithTimeout(1) a.NoError(err) return nodeController, restClient, &errorsCollector @@ -239,7 +239,7 @@ func startCatchpointUsingNode(a *require.Assertions, fixture *fixtures.RestClien restClient := fixture.GetAlgodClientForController(nodeController) // We don't want to start using the node without it being properly initialized. - err = fixture.ClientWaitForRoundWithTimeout(restClient, 1) + err = restClient.WaitForRoundWithTimeout(1) a.NoError(err) return nodeController, restClient, wp, &errorsCollector @@ -263,7 +263,7 @@ func startCatchpointNormalNode(a *require.Assertions, fixture *fixtures.RestClie restClient := fixture.GetAlgodClientForController(nodeController) // We don't want to start using the node without it being properly initialized. - err = fixture.ClientWaitForRoundWithTimeout(restClient, 1) + err = restClient.WaitForRoundWithTimeout(1) a.NoError(err) return nodeController, restClient, &errorsCollector @@ -365,7 +365,7 @@ func TestBasicCatchpointCatchup(t *testing.T) { _, err = usingNodeRestClient.Catchup(catchpointLabel, 0) a.NoError(err) - err = fixture.ClientWaitForRoundWithTimeout(usingNodeRestClient, uint64(targetCatchpointRound+1)) + err = usingNodeRestClient.WaitForRoundWithTimeout(uint64(targetCatchpointRound + 1)) a.NoError(err) // ensure the raw block can be downloaded (including cert) @@ -438,7 +438,7 @@ func TestCatchpointLabelGeneration(t *testing.T) { primaryNodeRestClient := fixture.GetAlgodClientForController(primaryNode) log.Infof("Building ledger history..") for { - err = fixture.ClientWaitForRound(primaryNodeRestClient, currentRound, 45*time.Second) + _, err = primaryNodeRestClient.WaitForRound(currentRound+1, 45*time.Second) a.NoError(err) if targetRound <= currentRound { break @@ -553,8 +553,7 @@ func TestNodeTxHandlerRestart(t *testing.T) { // Wait for the network to start making progress again primaryNodeRestClient := fixture.GetAlgodClientForController(primaryNode) - err = fixture.ClientWaitForRound(primaryNodeRestClient, targetRound, - 10*catchpointCatchupProtocol.AgreementFilterTimeout) + _, err = primaryNodeRestClient.WaitForRound(targetRound, 10*catchpointCatchupProtocol.AgreementFilterTimeout) a.NoError(err) // let the 2nd client send a transaction @@ -674,8 +673,7 @@ func TestReadyEndpoint(t *testing.T) { // Wait for the network to start making progress again primaryNodeRestClient := fixture.GetAlgodClientForController(primaryNode) - err = fixture.ClientWaitForRound(primaryNodeRestClient, targetRound, - 10*catchpointCatchupProtocol.AgreementFilterTimeout) + _, err = primaryNodeRestClient.WaitForRound(targetRound, 10*catchpointCatchupProtocol.AgreementFilterTimeout) a.NoError(err) // The primary node has reached the target round, diff --git a/test/e2e-go/features/catchup/stateproofsCatchup_test.go b/test/e2e-go/features/catchup/stateproofsCatchup_test.go index 5dcbc11452..f9639abeb1 100644 --- a/test/e2e-go/features/catchup/stateproofsCatchup_test.go +++ b/test/e2e-go/features/catchup/stateproofsCatchup_test.go @@ -115,7 +115,7 @@ func TestStateProofInReplayCatchpoint(t *testing.T) { } // wait for fastcatchup to complete and the node is synced - err = fixture.ClientWaitForRoundWithTimeout(usingNodeRestClient, uint64(targetCatchpointRound+1)) + err = usingNodeRestClient.WaitForRoundWithTimeout(uint64(targetCatchpointRound + 1)) a.NoError(err) primaryLibGoal := fixture.GetLibGoalClientFromNodeController(primaryNode) @@ -174,7 +174,7 @@ func TestStateProofAfterCatchpoint(t *testing.T) { roundAfterSPGeneration := targetCatchpointRound.RoundUpToMultipleOf(basics.Round(consensusParams.StateProofInterval)) + basics.Round(consensusParams.StateProofInterval/2) - err = fixture.ClientWaitForRoundWithTimeout(usingNodeRestClient, uint64(roundAfterSPGeneration)) + err = usingNodeRestClient.WaitForRoundWithTimeout(uint64(roundAfterSPGeneration)) a.NoError(err) primaryLibGoal := fixture.GetLibGoalClientFromNodeController(primaryNode) @@ -234,14 +234,14 @@ func TestSendSigsAfterCatchpointCatchup(t *testing.T) { primaryNodeAddr, err := primaryNode.GetListeningAddress() a.NoError(err) - err = fixture.ClientWaitForRoundWithTimeout(primaryNodeRestClient, 3) + err = primaryNodeRestClient.WaitForRoundWithTimeout(3) a.NoError(err) normalNode, normalNodeRestClient, normalNodeEC := startCatchpointNormalNode(a, &fixture, "Node1", primaryNodeAddr) defer normalNodeEC.Print() defer normalNode.StopAlgod() - err = fixture.ClientWaitForRoundWithTimeout(normalNodeRestClient, 3) + err = normalNodeRestClient.WaitForRoundWithTimeout(3) a.NoError(err) // at this point PrimaryNode and Node1 would pass round 3. Before running Node2 we remove block 2 from Primary database. @@ -267,7 +267,7 @@ func TestSendSigsAfterCatchpointCatchup(t *testing.T) { _, err = usingNodeRestClient.Catchup(catchpointLabel, 0) a.NoError(err) - err = fixture.ClientWaitForRoundWithTimeout(usingNodeRestClient, uint64(targetCatchpointRound)+1) + err = usingNodeRestClient.WaitForRoundWithTimeout(uint64(targetCatchpointRound) + 1) a.NoError(err) lastNormalRound, err := fixture.GetLibGoalClientFromNodeController(normalNode).CurrentRound() @@ -280,7 +280,7 @@ func TestSendSigsAfterCatchpointCatchup(t *testing.T) { lastNormalNodeSignedRound := basics.Round(lastNormalRound).RoundDownToMultipleOf(basics.Round(consensusParams.StateProofInterval)) lastNormalNextStateProofRound := lastNormalNodeSignedRound + basics.Round(consensusParams.StateProofInterval) targetRound := lastNormalNextStateProofRound + basics.Round(consensusParams.StateProofInterval*2) - err = fixture.ClientWaitForRoundWithTimeout(usingNodeRestClient, uint64(targetRound)) + err = usingNodeRestClient.WaitForRoundWithTimeout(uint64(targetRound)) a.NoError(err) primaryClient := fixture.GetLibGoalClientFromNodeController(primaryNode) diff --git a/test/e2e-go/features/followernode/syncDeltas_test.go b/test/e2e-go/features/followernode/syncDeltas_test.go index af27c7dda7..d1458b7451 100644 --- a/test/e2e-go/features/followernode/syncDeltas_test.go +++ b/test/e2e-go/features/followernode/syncDeltas_test.go @@ -74,7 +74,7 @@ func TestBasicSyncMode(t *testing.T) { // Let the network make some progress waitForRound := uint64(5) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Get the follower client, and exercise the sync/ledger functionality @@ -88,7 +88,7 @@ func TestBasicSyncMode(t *testing.T) { a.NoError(err) a.Equal(round, rResp.Round) // make some progress to round - err = fixture.ClientWaitForRoundWithTimeout(followClient, round) + err = followClient.WaitForRoundWithTimeout(round) a.NoError(err) // retrieve state delta gResp, err := followClient.GetLedgerStateDelta(round) @@ -113,6 +113,6 @@ func TestBasicSyncMode(t *testing.T) { err = followClient.SetSyncRound(round + 1) a.NoError(err) } - err = fixture.LibGoalFixture.ClientWaitForRoundWithTimeout(fixture.LibGoalClient, waitForRound) + err = fixture.WaitForRoundWithTimeout(waitForRound) a.NoError(err) } diff --git a/test/e2e-go/features/followernode/syncRestart_test.go b/test/e2e-go/features/followernode/syncRestart_test.go index 589bb7b53c..1aa5b2560d 100644 --- a/test/e2e-go/features/followernode/syncRestart_test.go +++ b/test/e2e-go/features/followernode/syncRestart_test.go @@ -62,7 +62,7 @@ func TestSyncRestart(t *testing.T) { waitTill := func(node string, round uint64) { controller, err := fixture.GetNodeController(node) a.NoError(err) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(controller), round) + err = fixture.GetAlgodClientForController(controller).WaitForRoundWithTimeout(round) a.NoError(err) } diff --git a/test/e2e-go/features/incentives/challenge_test.go b/test/e2e-go/features/incentives/challenge_test.go new file mode 100644 index 0000000000..51586eab76 --- /dev/null +++ b/test/e2e-go/features/incentives/challenge_test.go @@ -0,0 +1,188 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package suspension + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/daemon/algod/api/server/v2/generated/model" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/libgoal" + "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/test/framework/fixtures" + "github.com/algorand/go-algorand/test/partitiontest" + "github.com/algorand/go-algorand/util" +) + +// TestChallenges ensures that accounts are knocked off if they don't respond to +// a challenge, and that algod responds for accounts it knows (keepign them online) +func TestChallenges(t *testing.T) { + partitiontest.PartitionTest(t) + defer fixtures.ShutdownSynchronizedTest(t) + + t.Parallel() + a := require.New(fixtures.SynchronizedTest(t)) + + // Overview of this test: + // Use a consensus protocol with challenge interval=50, grace period=10, bits=2. + // Start a three-node network. One relay, two nodes with 4 accounts each + // At round 50, ~2 nodes will be challenged. + + const lookback = 32 + const interval = 50 + const grace = 10 + const mask = 0x80 + + var fixture fixtures.RestClientFixture + // Speed up rounds, keep lookback > 2 * grace period + fixture.FasterConsensus(protocol.ConsensusFuture, time.Second, lookback) + fixture.AlterConsensus(protocol.ConsensusFuture, + func(cp config.ConsensusParams) config.ConsensusParams { + cp.Payouts.ChallengeInterval = 50 + cp.Payouts.ChallengeGracePeriod = 10 + cp.Payouts.ChallengeBits = 1 // half of nodes should get challenged + return cp + }) + fixture.Setup(t, filepath.Join("nettemplates", "Challenges.json")) + defer fixture.Shutdown() + + clientAndAccounts := func(name string) (libgoal.Client, []model.Account) { + c := fixture.GetLibGoalClientForNamedNode(name) + accounts, err := fixture.GetNodeWalletsSortedByBalance(c) + a.NoError(err) + a.Len(accounts, 4) + fmt.Printf("Client %s has %v\n", name, accounts) + return c, accounts + } + + c1, accounts1 := clientAndAccounts("Node1") + c2, accounts2 := clientAndAccounts("Node2") + + // By re-regging, we become eligible for suspension (normal + challenges) + // TODO: Confirm that rereg is required for challenge suspensions + + err := fixture.WaitForRoundWithTimeout(interval - lookback) // Make all LastHeartbeats > interval, < 2*interval + a.NoError(err) + + for _, account := range accounts1 { + rekeyreg(&fixture, a, c1, account.Address) + } + for _, account := range accounts2 { + rekeyreg(&fixture, a, c2, account.Address) + } + + // turn off node 1, so it can't heartbeat + a.NoError(c1.FullStop()) + + current, err := c2.CurrentRound() + a.NoError(err) + // Get them all done so that their inflated LastHeartbeat comes before the + // next challenge. + a.Less(current+lookback, 2*uint64(interval)) + + // We need to wait for the first challenge that happens after the keyreg + // LastHeartbeat has passed. Example: current is 40, so the lastPossible + // LastHeartbeat is 72. Interval is 50, so challengeRound is 100. + + // 100 = 40 + 32 + (50-22) = 72 + 28 + lastPossible := current + lookback + challengeRound := lastPossible + (interval - lastPossible%interval) + + // Advance to challenge round, check the blockseed + err = fixture.WaitForRoundWithTimeout(challengeRound) + a.NoError(err) + blk, err := c2.BookkeepingBlock(challengeRound) + a.NoError(err) + challenge := blk.BlockHeader.Seed[0] & mask // high bit + + challenged1 := util.MakeSet[basics.Address]() + for _, account := range accounts1 { + address, err := basics.UnmarshalChecksumAddress(account.Address) + a.NoError(err) + if address[0]&mask == challenge { + fmt.Printf("%v of node 1 was challenged %v by %v\n", address, address[0], challenge) + challenged1.Add(address) + } + } + require.NotEmpty(t, challenged1, "rerun the test") // TODO: remove. + + challenged2 := util.MakeSet[basics.Address]() + for _, account := range accounts2 { + address, err := basics.UnmarshalChecksumAddress(account.Address) + a.NoError(err) + if address[0]&mask == challenge { + fmt.Printf("%v of node 2 was challenged %v by %v\n", address, address[0], challenge) + challenged2.Add(address) + } + } + require.NotEmpty(t, challenged2, "rerun the test") // TODO: remove. + + allChallenged := util.Union(challenged1, challenged2) + + // All challenged nodes are still online + for address := range allChallenged { + data, err := c2.AccountData(address.String()) + a.NoError(err) + a.Equal(basics.Online, data.Status, "%v %d", address.String(), data.LastHeartbeat) + a.NotZero(data.VoteID) + a.True(data.IncentiveEligible) + } + + // In the second half of the grace period, Node 2 should heartbeat for its accounts + beated := util.MakeSet[basics.Address]() + fixture.WithEveryBlock(challengeRound+grace/2, challengeRound+grace, func(block bookkeeping.Block) { + for _, txn := range block.Payset { + hb := txn.Txn.HeartbeatTxnFields + fmt.Printf("Heartbeat txn %v\n", hb) + a.True(challenged2.Contains(hb.HbAddress)) // only Node 2 is alive + a.False(beated.Contains(hb.HbAddress)) // beat only once + beated.Add(hb.HbAddress) + } + a.Empty(block.AbsentParticipationAccounts) // nobody suspended during grace + }) + a.Equal(challenged2, beated) + + blk, err = fixture.WaitForBlockWithTimeout(challengeRound + grace + 1) + a.NoError(err) + a.Equal(challenged1, util.MakeSet(blk.AbsentParticipationAccounts...)) + + // node 1 challenged accounts are suspended because node 1 is off + for address := range challenged1 { + data, err := c2.AccountData(address.String()) + a.NoError(err) + a.Equal(basics.Offline, data.Status, address) + a.NotZero(data.VoteID, address) + a.False(data.IncentiveEligible, address) // suspension turns off flag + } + + // node 2 challenged accounts are not suspended (saved by heartbeat) + for address := range challenged2 { + data, err := c2.AccountData(address.String()) + a.NoError(err) + a.Equal(basics.Online, data.Status, address) + a.NotZero(data.VoteID, address) + a.True(data.IncentiveEligible, address) + } + +} diff --git a/test/e2e-go/features/incentives/whalejoin_test.go b/test/e2e-go/features/incentives/whalejoin_test.go index b3e8dac479..783a01ca33 100644 --- a/test/e2e-go/features/incentives/whalejoin_test.go +++ b/test/e2e-go/features/incentives/whalejoin_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/require" + v2 "github.com/algorand/go-algorand/daemon/algod/api/server/v2" "github.com/algorand/go-algorand/daemon/algod/api/server/v2/generated/model" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/transactions" @@ -36,8 +37,8 @@ import ( // TestWhaleJoin shows a "whale" with more stake than is currently online can go // online without immediate suspension. This tests for a bug we had where we // calcululated expected proposal interval using the _old_ totals, rather than -// the totals following the keyreg. So big joiner could be expected to propose -// in the same block they joined. +// the totals following the keyreg. So big joiner was being expected to propose +// in the same block it joined. func TestWhaleJoin(t *testing.T) { partitiontest.PartitionTest(t) defer fixtures.ShutdownSynchronizedTest(t) @@ -185,6 +186,65 @@ func TestBigJoin(t *testing.T) { // is looking for. } +// TestBigIncrease shows when an incentive eligible account receives a lot of +// algos, they are not immediately suspended. We also check the details of the +// mechanism - that LastHeartbeat is incremented when such an account doubles +// its balance in a single pay. +func TestBigIncrease(t *testing.T) { + partitiontest.PartitionTest(t) + defer fixtures.ShutdownSynchronizedTest(t) + + t.Parallel() + a := require.New(fixtures.SynchronizedTest(t)) + + var fixture fixtures.RestClientFixture + const lookback = 32 + fixture.FasterConsensus(protocol.ConsensusFuture, time.Second/2, lookback) + fixture.Setup(t, filepath.Join("nettemplates", "Payouts.json")) + defer fixture.Shutdown() + + // Overview of this test: + // 0. spend wallet01 down so it has a very small percent of stake + // 1. rereg wallet01 so it is suspendable + // 2. move almost all of wallet15's money to wallet01 + // 3. check that c1.LastHeart is set to 32 rounds later + // 4. wait 40 rounds ensure c1 stays online + + clientAndAccount := func(name string) (libgoal.Client, model.Account) { + c := fixture.GetLibGoalClientForNamedNode(name) + accounts, err := fixture.GetNodeWalletsSortedByBalance(c) + a.NoError(err) + a.Len(accounts, 1) + fmt.Printf("Client %s is %v\n", name, accounts[0].Address) + return c, accounts[0] + } + + c1, account01 := clientAndAccount("Node01") + c15, account15 := clientAndAccount("Node15") + + // We need to spend 01 down so that it has nearly no stake. That way, it + // certainly will not have proposed by pure luck just before the critical + // round. If we don't do that, 1/16 of stake is enough that it will probably + // have a fairly recent proposal, and not get knocked off. + pay(&fixture, a, c1, account01.Address, account15.Address, 99*account01.Amount/100) + + rekeyreg(&fixture, a, c1, account01.Address) + + // 2. Wait lookback rounds + wait(&fixture, a, lookback) + + tx := pay(&fixture, a, c15, account15.Address, account01.Address, 50*account15.Amount/100) + data, err := c15.AccountData(account01.Address) + a.NoError(err) + a.EqualValues(*tx.ConfirmedRound+lookback, data.LastHeartbeat) + + wait(&fixture, a, lookback+5) + data, err = c15.AccountData(account01.Address) + a.NoError(err) + a.Equal(basics.Online, data.Status) + a.True(data.IncentiveEligible) +} + func wait(f *fixtures.RestClientFixture, a *require.Assertions, count uint64) { res, err := f.AlgodClient.Status() a.NoError(err) @@ -192,12 +252,18 @@ func wait(f *fixtures.RestClientFixture, a *require.Assertions, count uint64) { a.NoError(f.WaitForRoundWithTimeout(round)) } -func zeroPay(f *fixtures.RestClientFixture, a *require.Assertions, - c libgoal.Client, address string) { - pay, err := c.SendPaymentFromUnencryptedWallet(address, address, 1000, 0, nil) +func pay(f *fixtures.RestClientFixture, a *require.Assertions, + c libgoal.Client, from string, to string, amount uint64) v2.PreEncodedTxInfo { + pay, err := c.SendPaymentFromUnencryptedWallet(from, to, 1000, amount, nil) a.NoError(err) - _, err = f.WaitForConfirmedTxn(uint64(pay.LastValid), pay.ID().String()) + tx, err := f.WaitForConfirmedTxn(uint64(pay.LastValid), pay.ID().String()) a.NoError(err) + return tx +} + +func zeroPay(f *fixtures.RestClientFixture, a *require.Assertions, + c libgoal.Client, address string) { + pay(f, a, c, address, address, 0) } // Go offline, but return the key material so it's easy to go back online diff --git a/test/e2e-go/features/participation/onlineOfflineParticipation_test.go b/test/e2e-go/features/participation/onlineOfflineParticipation_test.go index 0b38fe76ff..21a701139a 100644 --- a/test/e2e-go/features/participation/onlineOfflineParticipation_test.go +++ b/test/e2e-go/features/participation/onlineOfflineParticipation_test.go @@ -216,7 +216,7 @@ func TestNewAccountCanGoOnlineAndParticipate(t *testing.T) { // Need to wait for funding to take effect on selection, then we can see if we're participating // Stop before the account should become eligible for selection so we can ensure it wasn't - err = fixture.ClientWaitForRound(fixture.AlgodClient, uint64(accountProposesStarting-1), + err = fixture.WaitForRound(uint64(accountProposesStarting-1), time.Duration(uint64(globals.MaxTimePerRound)*uint64(accountProposesStarting-1))) a.NoError(err) @@ -226,7 +226,7 @@ func TestNewAccountCanGoOnlineAndParticipate(t *testing.T) { a.False(blockWasProposed, "account should not be selected until BalLookback (round %d) passes", int(accountProposesStarting-1)) // Now wait until the round where the funded account will be used. - err = fixture.ClientWaitForRound(fixture.AlgodClient, uint64(accountProposesStarting), 10*globals.MaxTimePerRound) + err = fixture.WaitForRound(uint64(accountProposesStarting), 10*globals.MaxTimePerRound) a.NoError(err) blockWasProposedByNewAccountRecently := fixture.VerifyBlockProposedRange(newAccount, int(accountProposesStarting), 1) diff --git a/test/e2e-go/features/partitionRecovery/partitionRecovery_test.go b/test/e2e-go/features/partitionRecovery/partitionRecovery_test.go index 21ce3bdf0d..e3429490c4 100644 --- a/test/e2e-go/features/partitionRecovery/partitionRecovery_test.go +++ b/test/e2e-go/features/partitionRecovery/partitionRecovery_test.go @@ -57,7 +57,7 @@ func TestBasicPartitionRecovery(t *testing.T) { // Let the network make some progress waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Now stop 2nd node @@ -133,7 +133,7 @@ func runTestWithStaggeredStopStart(t *testing.T, fixture *fixtures.RestClientFix // Let the network make some progress waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc1), waitForRound) + err = fixture.GetAlgodClientForController(nc1).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Stop Node1 @@ -196,7 +196,7 @@ func TestBasicPartitionRecoveryPartOffline(t *testing.T) { // Let the network make some progress waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc1), waitForRound) + err = fixture.GetAlgodClientForController(nc1).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Stop Node1 @@ -264,8 +264,7 @@ func TestPartitionHalfOffline(t *testing.T) { // Let the network make some progress client := fixture.LibGoalClient - waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc1), waitForRound) + err = fixture.GetAlgodClientForController(nc1).WaitForRoundWithTimeout(3) a.NoError(err) // Stop nodes with 50% of stake diff --git a/test/e2e-go/restAPI/other/misc_test.go b/test/e2e-go/restAPI/other/misc_test.go index eeaff9fcd1..23e805dc25 100644 --- a/test/e2e-go/restAPI/other/misc_test.go +++ b/test/e2e-go/restAPI/other/misc_test.go @@ -62,7 +62,7 @@ func TestDisabledAPIConfig(t *testing.T) { a.NoError(err) testClient := client.MakeRestClient(url, "") // empty token - _, err = testClient.WaitForBlock(1) + err = testClient.WaitForRoundWithTimeout(1) assert.NoError(t, err) _, err = testClient.Block(1) assert.NoError(t, err) diff --git a/test/e2e-go/restAPI/simulate/simulateRestAPI_test.go b/test/e2e-go/restAPI/simulate/simulateRestAPI_test.go index 66601c1737..b058b510e4 100644 --- a/test/e2e-go/restAPI/simulate/simulateRestAPI_test.go +++ b/test/e2e-go/restAPI/simulate/simulateRestAPI_test.go @@ -53,7 +53,7 @@ func TestSimulateTxnTracerDevMode(t *testing.T) { testClient := localFixture.LibGoalClient - _, err := testClient.WaitForRound(1) + _, err := testClient.Status() a.NoError(err) wh, err := testClient.GetUnencryptedWalletHandle() @@ -288,11 +288,11 @@ int 1` // Let the primary node make some progress primaryClient := fixture.GetAlgodClientForController(nc) - err = fixture.ClientWaitForRoundWithTimeout(primaryClient, followerSyncRound+uint64(cfg.MaxAcctLookback)) + err = primaryClient.WaitForRoundWithTimeout(followerSyncRound + uint64(cfg.MaxAcctLookback)) a.NoError(err) // Let follower node progress as far as it can - err = fixture.ClientWaitForRoundWithTimeout(followClient, followerSyncRound+uint64(cfg.MaxAcctLookback)-1) + err = followClient.WaitForRoundWithTimeout(followerSyncRound + uint64(cfg.MaxAcctLookback) - 1) a.NoError(err) simulateRequest := v2.PreEncodedSimulateRequest{ diff --git a/test/e2e-go/upgrades/application_support_test.go b/test/e2e-go/upgrades/application_support_test.go index 549a82c5ab..c41ad84166 100644 --- a/test/e2e-go/upgrades/application_support_test.go +++ b/test/e2e-go/upgrades/application_support_test.go @@ -180,7 +180,7 @@ int 1 curStatus, err = client.Status() a.NoError(err) - a.Less(int64(time.Now().Sub(startLoopTime)), int64(3*time.Minute)) + a.Less(int64(time.Since(startLoopTime)), int64(3*time.Minute)) time.Sleep(time.Duration(smallLambdaMs) * time.Millisecond) } @@ -438,7 +438,7 @@ int 1 curStatus, err = client.Status() a.NoError(err) - a.Less(int64(time.Now().Sub(startLoopTime)), int64(3*time.Minute)) + a.Less(int64(time.Since(startLoopTime)), int64(3*time.Minute)) time.Sleep(time.Duration(smallLambdaMs) * time.Millisecond) round = curStatus.LastRound } diff --git a/test/e2e-go/upgrades/rekey_support_test.go b/test/e2e-go/upgrades/rekey_support_test.go index 0dcec41545..cc3eca018c 100644 --- a/test/e2e-go/upgrades/rekey_support_test.go +++ b/test/e2e-go/upgrades/rekey_support_test.go @@ -150,7 +150,7 @@ func TestRekeyUpgrade(t *testing.T) { curStatus, err = client.Status() a.NoError(err) - a.Less(int64(time.Now().Sub(startLoopTime)), int64(3*time.Minute)) + a.Less(int64(time.Since(startLoopTime)), int64(3*time.Minute)) time.Sleep(time.Duration(smallLambdaMs) * time.Millisecond) round = curStatus.LastRound } diff --git a/test/framework/fixtures/libgoalFixture.go b/test/framework/fixtures/libgoalFixture.go index bd4f615ae7..c05a59ff1f 100644 --- a/test/framework/fixtures/libgoalFixture.go +++ b/test/framework/fixtures/libgoalFixture.go @@ -42,7 +42,6 @@ import ( "github.com/algorand/go-algorand/netdeploy" "github.com/algorand/go-algorand/nodecontrol" "github.com/algorand/go-algorand/protocol" - "github.com/algorand/go-algorand/test/e2e-go/globals" "github.com/algorand/go-algorand/util/db" ) @@ -67,26 +66,32 @@ func (f *RestClientFixture) SetConsensus(consensus config.ConsensusProtocols) { f.consensus = consensus } +// AlterConsensus allows the caller to modify the consensus settings for a given version. +func (f *RestClientFixture) AlterConsensus(ver protocol.ConsensusVersion, alter func(config.ConsensusParams) config.ConsensusParams) { + if f.consensus == nil { + f.consensus = make(config.ConsensusProtocols) + } + f.consensus[ver] = alter(f.ConsensusParamsFromVer(ver)) +} + // FasterConsensus speeds up the given consensus version in two ways. The seed // refresh lookback is set to 8 (instead of 80), so the 320 round balance // lookback becomes 32. And, if the architecture implies it can be handled, // round times are shortened by lowering vote timeouts. func (f *RestClientFixture) FasterConsensus(ver protocol.ConsensusVersion, timeout time.Duration, lookback basics.Round) { - if f.consensus == nil { - f.consensus = make(config.ConsensusProtocols) - } - fast := config.Consensus[ver] - // balanceRound is 4 * SeedRefreshInterval - if lookback%4 != 0 { - panic(fmt.Sprintf("lookback must be a multiple of 4, got %d", lookback)) - } - fast.SeedRefreshInterval = uint64(lookback) / 4 - // and speed up the rounds while we're at it - if runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" { - fast.AgreementFilterTimeoutPeriod0 = timeout - fast.AgreementFilterTimeout = timeout - } - f.consensus[ver] = fast + f.AlterConsensus(ver, func(fast config.ConsensusParams) config.ConsensusParams { + // balanceRound is 4 * SeedRefreshInterval + if lookback%4 != 0 { + panic(fmt.Sprintf("lookback must be a multiple of 4, got %d", lookback)) + } + fast.SeedRefreshInterval = uint64(lookback) / 4 + // and speed up the rounds while we're at it + if runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" { + fast.AgreementFilterTimeoutPeriod0 = timeout + fast.AgreementFilterTimeout = timeout + } + return fast + }) } // Setup is called to initialize the test fixture for the test(s) @@ -452,75 +457,6 @@ func (f *LibGoalFixture) GetParticipationOnlyAccounts(lg libgoal.Client) []accou return f.clientPartKeys[lg.DataDir()] } -// WaitForRoundWithTimeout waits for a given round to reach. The implementation also ensures to limit the wait time for each round to the -// globals.MaxTimePerRound so we can alert when we're getting "hung" before waiting for all the expected rounds to reach. -func (f *LibGoalFixture) WaitForRoundWithTimeout(roundToWaitFor uint64) error { - return f.ClientWaitForRoundWithTimeout(f.LibGoalClient, roundToWaitFor) -} - -// ClientWaitForRoundWithTimeout waits for a given round to be reached by the specific client/node. The implementation -// also ensures to limit the wait time for each round to the globals.MaxTimePerRound so we can alert when we're -// getting "hung" before waiting for all the expected rounds to reach. -func (f *LibGoalFixture) ClientWaitForRoundWithTimeout(client libgoal.Client, roundToWaitFor uint64) error { - status, err := client.Status() - require.NoError(f.t, err) - lastRound := status.LastRound - - // If node is already at or past target round, we're done - if lastRound >= roundToWaitFor { - return nil - } - - roundTime := globals.MaxTimePerRound * 10 // For first block, we wait much longer - roundComplete := make(chan error, 2) - - for nextRound := lastRound + 1; lastRound < roundToWaitFor; { - roundStarted := time.Now() - - go func(done chan error) { - err := f.ClientWaitForRound(client, nextRound, roundTime) - done <- err - }(roundComplete) - - select { - case lastError := <-roundComplete: - if lastError != nil { - close(roundComplete) - return lastError - } - case <-time.After(roundTime): - // we've timed out. - time := time.Now().Sub(roundStarted) - return fmt.Errorf("fixture.WaitForRound took %3.2f seconds between round %d and %d", time.Seconds(), lastRound, nextRound) - } - - roundTime = singleRoundMaxTime - lastRound++ - nextRound++ - } - return nil -} - -// ClientWaitForRound waits up to the specified amount of time for -// the network to reach or pass the specified round, on the specific client/node -func (f *LibGoalFixture) ClientWaitForRound(client libgoal.Client, round uint64, waitTime time.Duration) error { - timeout := time.NewTimer(waitTime) - for { - status, err := client.Status() - if err != nil { - return err - } - if status.LastRound >= round { - return nil - } - select { - case <-timeout.C: - return fmt.Errorf("timeout waiting for round %v", round) - case <-time.After(200 * time.Millisecond): - } - } -} - // CurrentConsensusParams returns the consensus parameters for the currently active protocol func (f *LibGoalFixture) CurrentConsensusParams() (consensus config.ConsensusParams, err error) { status, err := f.LibGoalClient.Status() @@ -532,20 +468,20 @@ func (f *LibGoalFixture) CurrentConsensusParams() (consensus config.ConsensusPar } // ConsensusParams returns the consensus parameters for the protocol from the specified round -func (f *LibGoalFixture) ConsensusParams(round uint64) (consensus config.ConsensusParams, err error) { +func (f *LibGoalFixture) ConsensusParams(round uint64) (config.ConsensusParams, error) { block, err := f.LibGoalClient.BookkeepingBlock(round) if err != nil { - return + return config.ConsensusParams{}, err } - version := protocol.ConsensusVersion(block.CurrentProtocol) - if f.consensus != nil { - consensus, has := f.consensus[version] - if has { - return consensus, nil - } + return f.ConsensusParamsFromVer(block.CurrentProtocol), nil +} + +// ConsensusParamsFromVer looks up a consensus version, allowing for override +func (f *LibGoalFixture) ConsensusParamsFromVer(cv protocol.ConsensusVersion) config.ConsensusParams { + if consensus, has := f.consensus[cv]; has { + return consensus } - consensus = config.Consensus[version] - return + return config.Consensus[cv] } // CurrentMinFeeAndBalance returns the MinTxnFee and MinBalance for the currently active protocol diff --git a/test/framework/fixtures/restClientFixture.go b/test/framework/fixtures/restClientFixture.go index 473df25d38..fb1a26d31b 100644 --- a/test/framework/fixtures/restClientFixture.go +++ b/test/framework/fixtures/restClientFixture.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/require" "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" "github.com/algorand/go-algorand/netdeploy" "github.com/algorand/go-algorand/protocol" @@ -34,7 +35,6 @@ import ( "github.com/algorand/go-algorand/libgoal" "github.com/algorand/go-algorand/nodecontrol" - "github.com/algorand/go-algorand/test/e2e-go/globals" "github.com/algorand/go-algorand/util/tokens" ) @@ -80,79 +80,37 @@ func (f *RestClientFixture) GetAlgodClientForController(nc nodecontrol.NodeContr // WaitForRound waits up to the specified amount of time for // the network to reach or pass the specified round func (f *RestClientFixture) WaitForRound(round uint64, waitTime time.Duration) error { - return f.ClientWaitForRound(f.AlgodClient, round, waitTime) + _, err := f.AlgodClient.WaitForRound(round, waitTime) + return err } -// ClientWaitForRound waits up to the specified amount of time for -// the network to reach or pass the specified round, on the specific client/node -func (f *RestClientFixture) ClientWaitForRound(client client.RestClient, round uint64, waitTime time.Duration) error { - timeout := time.NewTimer(waitTime) - for { - status, err := client.Status() - if err != nil { - return err - } - - if status.LastRound >= round { - return nil - } - select { - case <-timeout.C: - return fmt.Errorf("timeout waiting for round %v with last round = %v", round, status.LastRound) - case <-time.After(200 * time.Millisecond): - } +// WithEveryBlock calls the provided function for every block from first to last. +func (f *RestClientFixture) WithEveryBlock(first, last uint64, visit func(bookkeeping.Block)) { + for round := first; round <= last; round++ { + err := f.WaitForRoundWithTimeout(round) + require.NoError(f.t, err) + block, err := f.AlgodClient.Block(round) + require.NoError(f.t, err) + visit(block.Block) } } // WaitForRoundWithTimeout waits for a given round to reach. The implementation also ensures to limit the wait time for each round to the // globals.MaxTimePerRound so we can alert when we're getting "hung" before waiting for all the expected rounds to reach. func (f *RestClientFixture) WaitForRoundWithTimeout(roundToWaitFor uint64) error { - return f.ClientWaitForRoundWithTimeout(f.AlgodClient, roundToWaitFor) + return f.AlgodClient.WaitForRoundWithTimeout(roundToWaitFor) } -const singleRoundMaxTime = globals.MaxTimePerRound * 40 - -// ClientWaitForRoundWithTimeout waits for a given round to be reached by the specific client/node. The implementation -// also ensures to limit the wait time for each round to the globals.MaxTimePerRound so we can alert when we're -// getting "hung" before waiting for all the expected rounds to reach. -func (f *RestClientFixture) ClientWaitForRoundWithTimeout(client client.RestClient, roundToWaitFor uint64) error { - status, err := client.Status() - require.NoError(f.t, err) - lastRound := status.LastRound - - // If node is already at or past target round, we're done - if lastRound >= roundToWaitFor { - return nil +// WaitForBlockWithTimeout waits for a given round and returns its block. +func (f *RestClientFixture) WaitForBlockWithTimeout(roundToWaitFor uint64) (bookkeeping.Block, error) { + if err := f.AlgodClient.WaitForRoundWithTimeout(roundToWaitFor); err != nil { + return bookkeeping.Block{}, err } - - roundTime := globals.MaxTimePerRound * 10 // For first block, we wait much longer - roundComplete := make(chan error, 2) - - for nextRound := lastRound + 1; lastRound < roundToWaitFor; { - roundStarted := time.Now() - - go func(done chan error) { - err := f.ClientWaitForRound(client, nextRound, roundTime) - done <- err - }(roundComplete) - - select { - case lastError := <-roundComplete: - if lastError != nil { - close(roundComplete) - return lastError - } - case <-time.After(roundTime): - // we've timed out. - time := time.Now().Sub(roundStarted) - return fmt.Errorf("fixture.WaitForRound took %3.2f seconds between round %d and %d", time.Seconds(), lastRound, nextRound) - } - - roundTime = singleRoundMaxTime - lastRound++ - nextRound++ + both, err := f.AlgodClient.EncodedBlockCert(roundToWaitFor) + if err != nil { + return bookkeeping.Block{}, err } - return nil + return both.Block, nil } // GetFirstAccount returns the first account from listing local accounts @@ -367,17 +325,15 @@ func (f *RestClientFixture) SendMoneyAndWaitFromWallet(walletHandle, walletPassw // VerifyBlockProposedRange checks the rounds starting at fromRounds and moving backwards checking countDownNumRounds rounds if any // blocks were proposed by address -func (f *RestClientFixture) VerifyBlockProposedRange(account string, fromRound, countDownNumRounds int) (blockWasProposed bool) { - c := f.LibGoalClient +func (f *RestClientFixture) VerifyBlockProposedRange(account string, fromRound, countDownNumRounds int) bool { for i := 0; i < countDownNumRounds; i++ { - cert, err := c.EncodedBlockCert(uint64(fromRound - i)) + cert, err := f.AlgodClient.EncodedBlockCert(uint64(fromRound - i)) require.NoError(f.t, err, "client failed to get block %d", fromRound-i) if cert.Certificate.Proposal.OriginalProposer.GetUserAddress() == account { - blockWasProposed = true - break + return true } } - return + return false } // VerifyBlockProposed checks the last searchRange blocks to see if any blocks were proposed by address diff --git a/test/testdata/nettemplates/Challenges.json b/test/testdata/nettemplates/Challenges.json new file mode 100644 index 0000000000..6519033e9c --- /dev/null +++ b/test/testdata/nettemplates/Challenges.json @@ -0,0 +1,44 @@ +{ + "Genesis": { + "NetworkName": "tbd", + "ConsensusProtocol": "future", + "LastPartKeyRound": 500, + "Wallets": [ + { "Name": "Relay", "Stake": 92, "Online": true }, + { "Name": "Wallet0", "Stake": 1, "Online": true }, + { "Name": "Wallet1", "Stake": 1, "Online": true }, + { "Name": "Wallet2", "Stake": 1, "Online": true }, + { "Name": "Wallet3", "Stake": 1, "Online": true }, + { "Name": "Wallet4", "Stake": 1, "Online": true }, + { "Name": "Wallet5", "Stake": 1, "Online": true }, + { "Name": "Wallet6", "Stake": 1, "Online": true }, + { "Name": "Wallet7", "Stake": 1, "Online": true } + ], + "RewardsPoolBalance": 0 + }, + "Nodes": [ + { + "Name": "Relay", + "Wallets": [{ "Name": "Relay", "ParticipationOnly": false }], + "IsRelay": true + }, + { + "Name": "Node1", + "Wallets": [ + { "Name": "Wallet0", "ParticipationOnly": false }, + { "Name": "Wallet1", "ParticipationOnly": false }, + { "Name": "Wallet2", "ParticipationOnly": false }, + { "Name": "Wallet3", "ParticipationOnly": false } + ] + }, + { + "Name": "Node2", + "Wallets": [ + { "Name": "Wallet4", "ParticipationOnly": false }, + { "Name": "Wallet5", "ParticipationOnly": false }, + { "Name": "Wallet6", "ParticipationOnly": false }, + { "Name": "Wallet7", "ParticipationOnly": false } + ] + } + ] +} diff --git a/util/db/dbutil.go b/util/db/dbutil.go index a6e524464d..8a52862c54 100644 --- a/util/db/dbutil.go +++ b/util/db/dbutil.go @@ -311,7 +311,7 @@ func (db *Accessor) AtomicContext(ctx context.Context, fn idemFn, extras ...inte } if time.Now().After(atomicDeadline) { - db.getDecoratedLogger(fn, extras).Warnf("dbatomic: tx surpassed expected deadline by %v", time.Now().Sub(atomicDeadline)) + db.getDecoratedLogger(fn, extras).Warnf("dbatomic: tx surpassed expected deadline by %v", time.Since(atomicDeadline)) } return } diff --git a/util/set.go b/util/set.go index 6851299c46..54ee92a050 100644 --- a/util/set.go +++ b/util/set.go @@ -40,3 +40,37 @@ func (s Set[T]) Contains(elem T) (exists bool) { _, exists = s[elem] return } + +// Union constructs a new set, containing all elements from the given sets. nil +// is never returned +func Union[T comparable](sets ...Set[T]) Set[T] { + union := make(Set[T]) + for _, set := range sets { + for elem := range set { + union.Add(elem) + } + } + return union +} + +// Intersection constructs a new set, containing all elements that appear in all +// given sets. nil is never returned. +func Intersection[T comparable](sets ...Set[T]) Set[T] { + var intersection = make(Set[T]) + if len(sets) == 0 { + return intersection + } + for elem := range sets[0] { + inAll := true + for _, set := range sets[1:] { + if _, exists := set[elem]; !exists { + inAll = false + break + } + } + if inAll { + intersection.Add(elem) + } + } + return intersection +} diff --git a/util/set_test.go b/util/set_test.go new file mode 100644 index 0000000000..86df9c5464 --- /dev/null +++ b/util/set_test.go @@ -0,0 +1,75 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package util + +import ( + "testing" + + "github.com/algorand/go-algorand/test/partitiontest" + "github.com/stretchr/testify/require" +) + +func TestMakeSet(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + s := MakeSet(1, 2, 3) + require.True(t, s.Contains(1)) + require.True(t, s.Contains(2)) + require.True(t, s.Contains(3)) + require.False(t, s.Contains(4)) + + s = MakeSet[int]() + require.NotNil(t, s) + require.False(t, s.Contains(1)) + require.False(t, s.Contains(4)) +} + +func TestSetAdd(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + s := MakeSet[int]() + s.Add(6) + require.False(t, s.Contains(1)) + require.True(t, s.Contains(6)) + s.Add(6) + require.False(t, s.Contains(1)) + require.True(t, s.Contains(6)) +} + +func TestSetOps(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + empty := MakeSet[string]() + abc := MakeSet("a", "b", "c") + cde := MakeSet("c", "d", "e") + + require.Equal(t, abc, Union(abc)) + require.Equal(t, abc, Union(empty, abc)) + require.Equal(t, abc, Union(abc, empty, abc)) + require.NotNil(t, Union(empty, empty, empty)) + require.Equal(t, empty, Union(empty, empty, empty)) + + require.Equal(t, abc, Intersection(abc, abc)) + require.NotNil(t, Intersection(abc, empty)) + require.Equal(t, empty, Intersection(abc, empty)) + require.Equal(t, empty, Intersection(empty, abc)) + require.Equal(t, MakeSet("c"), Intersection(abc, cde)) + require.Equal(t, MakeSet("c"), Intersection(cde, abc, cde)) +} From d2954e537722f681ac86d53a05d689bce4d418e8 Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Tue, 3 Dec 2024 15:37:00 -0500 Subject: [PATCH 07/14] Eval: Prefetching for heartbeat transactions (#6182) --- daemon/algod/api/algod.oas2.json | 2 +- daemon/algod/api/algod.oas3.yml | 2 +- .../nonparticipating/public/routes.go | 188 +++++++++--------- data/transactions/logic/eval_test.go | 16 +- data/transactions/transaction.go | 9 +- ledger/eval/eval.go | 4 + ledger/eval/prefetcher/prefetcher.go | 4 +- .../prefetcher/prefetcher_alignment_test.go | 52 +++++ 8 files changed, 175 insertions(+), 102 deletions(-) diff --git a/daemon/algod/api/algod.oas2.json b/daemon/algod/api/algod.oas2.json index 9b11f26642..1e5db8d9bc 100644 --- a/daemon/algod/api/algod.oas2.json +++ b/daemon/algod/api/algod.oas2.json @@ -245,7 +245,7 @@ }, "/v2/accounts/{address}": { "get": { - "description": "Given a specific account public key, this call returns the accounts status, balance and spendable amounts", + "description": "Given a specific account public key, this call returns the account's status, balance and spendable amounts", "tags": [ "public", "nonparticipating" diff --git a/daemon/algod/api/algod.oas3.yml b/daemon/algod/api/algod.oas3.yml index 9b7ec0a37d..57bc22ce87 100644 --- a/daemon/algod/api/algod.oas3.yml +++ b/daemon/algod/api/algod.oas3.yml @@ -2978,7 +2978,7 @@ }, "/v2/accounts/{address}": { "get": { - "description": "Given a specific account public key, this call returns the accounts status, balance and spendable amounts", + "description": "Given a specific account public key, this call returns the account's status, balance and spendable amounts", "operationId": "AccountInformation", "parameters": [ { diff --git a/daemon/algod/api/server/v2/generated/nonparticipating/public/routes.go b/daemon/algod/api/server/v2/generated/nonparticipating/public/routes.go index b2f8e4a070..ed6e87f99f 100644 --- a/daemon/algod/api/server/v2/generated/nonparticipating/public/routes.go +++ b/daemon/algod/api/server/v2/generated/nonparticipating/public/routes.go @@ -954,100 +954,100 @@ var swaggerSpec = []string{ "mf/p/+9P2DQXq2Z4/+uR6890tNS6Us+Oj6+urqbxJ8cLTP3PtKjz5bGfB7sNtuSV16chRt/G4eCONtZj", "3FRHCif47M03Z+fk5PXptCGYo2dHj6aPpo9da2tOK3b07Ogz/AlPzxL3/Rjrax4rVzr/OORqvZv0nlWV", "LaxvHjkadX8tgZZYYMf8sQItWe4fSaDFxv1fXdHFAuQUszfsT5dPjr00cvyXq5zwzgCWdBvaOutRcW0f", - "iFjVs5LlvkYZU9Z+bAPsVdxc1hrWazUhM9t+2Mfw8gIjlGwxAhW34D4tDJ7t56cNr/MdlNGtfPTs10Q1", - "K5/44Rv7xjFnUTTa/zn76RURkjit6DXNL0LSi89yajK74iQn8+XUk/2/apCbhiwdw5wcqdAdHHi9MrzH", - "Zc+s1KJqF3ZthLGUsaiHaz+zoaboPIQ6Jw2/Q8tgBEnDvQ1HfpR9+dtfn//j3dEIQLDojgLs8/gHLcs/", - "rHUN1hhY2wm8mQyFRE2auhn4QbOTEzRkhafR58077Xrof3DB4Y+hbXCAJfeBlqV5UXBI7cFv2IkQiQWP", - "6pNHjzx/ctJ/BN2xO1PRLKNaAFjnQhjFk8Q1BurzMfvoTSiNKWllz+KJDx/eVMG9Y1+aGnb19IALbRfw", - "vPFyu8P1Fv01LYh06cu4lMef7FJOuQ0FNfeRvTffTY4+/4T35pQbnkNLgm9GbX77F83P/IKLK+7fNDJT", - "vVpRuUGJSAde2O1LQxcKfarIIu3Zjqqv8cXRb+8Gb73jOObx+K9W6aTiRneidbK0ujrtuCbvqSHOiWPZ", - "pDT3w/2TqsKQz7Pw/KSqbNdwDCMAhrcfrJnS6sGUfBd/3fKNWEisa6SVE+CbaPvW3C1XedSOM3lpt4oS", - "3N3fH/b+PmnbSFgBXLM5Q3k9BUzrFGyFqResdNMLtJ8jFJVI2jceOpTHdqJF5lqvjRzDNeE/XF/BEZVR", - "7Ey/pTTInYz6DncDuBsSkyJ4g8TUNDW8HdbsK+2Gm6R1ZbxHxv2JC30/0tLQSbTcTkeb0xd3wuDfShgM", - "FTkXVjqrqgOIhz5xY9crx3+5KpOHkBpRPR4lL8aad/RtFHt/v8NxHkzJSfed67EVV6VzpyRo3ruTAT8G", - "GdCWOd0l/Tk6/qByX5z2tU8WVktgMb+P+vgTF/T+xsgalOwMpLtlumuwz5685pj1e2Or/5ZymkPanYT2", - "t5bQQu3sG8locejrsatCEElsNzLwdQ14TAdJrF0/PeJsWG4E8/HtEZ40Yf6Gxdj4ZRe5rCZeeURHrdUr", - "7WZNeqplX8T6DmId9uvN6Ytd0tUnZAoa3QY5cQuk9+Z989KkZ+LN7XgmxvGmp4+e3h4E8S68Epp8i7f4", - "e+aQ75WlpclqXxa2jSMdz8R6F1fiHbYUCtSZQ9viUaEO6SR6bt628R/3MeO33TjrwZR87V5tqoC4jPaF", - "MIzKZ4pRubAfGV5nkEHu+T+f4fj3puRbzH/UaoJhbJhYgS8yrp89fvLZU/eKpFc2Sqz73uyLp89OvvrK", - "vVZJxjWGDFg9p/e60vLZEspSuA/cHdEf1zx49n//87+m0+m9nWxVrL/evLKddj8W3jpJVTwMBDC0W5/4", - "JqW0ddcBeSfqbsXD/7VYJ28Bsb67hT7YLWSw/29x+8zaZOQU0WDsbPXiOeBtZI/JPvfRxN0/mMQRLpMp", - "eSVcW7S6pNLWh8ESuoosaiop1wDF1FMqZuApW8guLxmWDpBEgbwEmSkWSlXXEkIRk0rCJUbfN0VeWxDs", - "ZvQYo/vRMvkf6TpKm5+Fa1oLt2Q0e67ommCfD00U6ImtoLYmX31FHk0a7aUszQBZQEyKua7o+ugWrX6B", - "2MaWBXrhsCPk7tBfHHuMBamRfkJ9yUbV+Ltz7k9Wcrfk7jb2QJxzb8dP49iJ7Qiu+dhWC4IV7DRWQ1Z1", - "VZWbpg6ukfK8CJVmcWaGscaBj9hHsNM0nVRCu+i9O8R3RoAbsZIuQe3JNjCfVR3/hXp5zDN65xbz8f5e", - "7tLIdyTFyjuPBJmDzpcuFbiD+gR7ki4dcZg3rRhnKwPlo8l7l2pwF/v1j+PezwW1Cfhj2otFWZrowAOZ", - "IOKf8D+0xJp6bG5Lu/uGH76aIbqmXHXs0HDVKt+2BbML+fcZwxVtNZDdDeXzZvK+QIZoOYT/8w7B+yG4", - "xxy/cdUO7PFyi/h3SArwqmRGXokmId1qUP+Wrsf3ebO/7wW9Ehysj91IvpYW79ypQewwjMMixVcisfpL", - "017ruiLIsa/gs1UO+d68tEMWGXN7YzWgT/EK/z5Z56h1y5i1TXeWWWhGG8OczYu2H0JcCGX6IbWYD8JP", - "P0LV5kNwrNthMXhIPZ9xYgE/LNPB4j6WmI8rX4lpiAO9NC9HcpmtdzSaG2kRwtAgUVWIzKAUfKE+Tla0", - "jTrSeElQia1hZduq9NY//Rue3eeu54lv5u8qSSnGcyBKrABVBiOju4LUFsJ/3B6Emq18524ep7d+YO7y", - "+aPPbm/6M5CXLAdyDqtKSCpZuSE/89Db5CbcThHq9jy2BieYA+PobWpXHMvj8kg3YIKuc37aauzs1k3N", - "RGXlKlFrkLZaXqeFFesx6ZQ9GBnGSzP1AeS5Uiw+NXHOY31skefntCwRXbucTDjwqCjlsrT7CSumddMy", - "Ir5dyTc0X4a9nTTWvdDYz9cWn3SqUeLIrsubTfVXYPZZA4lWE1krQNoW5Rr7Nq0oBiyv6lKzqmx/Ezpf", - "YiegRBiSpc24icDpC78665wV82boLv36SuRu8KmZ2z3Cmbmwi6MSkHcH20qnudS0BbTtieXDr6NORq4f", - "kyt0yGSn8mQTO1NVQGXzsaX8+5WEzA0h6SVIRfGwdhb14E5U/zhE9bUrdfyRCOpJH+VNef31r6JWFPVf", - "es2Kd7vl8qha8J4iOeORSB6zC3vWri+L746i6HYWP30RJ6qIUE/LCwgDoBgU7Zmr9b+ORrpAsEiLmDs9", - "rOYWUF/i0kmsLotEzCchTtMopGL+jLzlD4laUl+B2f355PMvBpw4Zh5Xma7vxmkGMo/tMGN8OZ+0Z+qw", - "EkfA77Pb3u39NnFyxIp1osw8L2AddTZpdz529+E9RSq68RkdvUqLVbraclBM42FXYK4ptWTV7Vf0VZrN", - "0iXNvSUudNA/5V8Hg6wtO2ukhupDVHKdHGkJUECllzsLPONbzW6CK/XMlGvKY8vwTgibwtRWqW2apxUL", - "cBcTJSXQeeiCJsSYPL6IzxhC81QRYT1eyBhJOkk/KPMiUd6+nbTJd7MXnUdeVyj+oEKY/lBCWNaRwtpo", - "+XAyGbZzmESRV5UUWuSitGGUdVUJqcPpVtNRlgcYEvRahochwr2RMLdmhdrp0jnHtw5gA2hTtvpkXDrn", - "Hk0pn05qUdcsO9vMNYalnYuK9Dr3GxA+KF+7UypT/Kzj/vnUvT96kPQO7AzKqc6XdXX8F/4Hy+6+a3J2", - "sSGJOtZrfowtKI//2hpdiyy1NLKJtL1MWibdXkPLZIzsS/y86ZvyrZDdZuE7o2c7SJt0L33bThPDcBPs", - "8f1ok39rJWyr66yz4TePBkmM2DuvoSRF1IQv0G7UjcdXmbAtOBMkfBe99HEtqPEnzhkvCI22sWNrCm37", - "vQ7wj0920R/CRXn7IVuff8Ln7JXQ5HRVlbACrqG4WeA76XI4f3tsvW73Ewzc1d+Pju/f+fGN73N6giyy", - "84LfQ++JqhiBn45KLCtk7ur3o+7c3eQf903+PHhbYzK8u5c/nXtZ+kykuyv447+CP/tkV/MeY5hGXsnX", - "cA63r+FGE9/zQu4JA86G1TEcbPMro+rdXaX6Vkjfc+7uFv9EnaJ2J0cHYo2x0OyyxLopD5F19lFBP87O", - "UJYJS8PQQZ2EWC+G9RpFzrA7z2mhJi6ozBon3Cm+E3w+asEn2us7uefO9PCJmR4GpByn9ZflGEFjXwHo", - "ciUK8I5VMZ+7+shD0k+7IaQhT6XpqiL2y+lgHPY5W8GZefMnO8VBr9gG7I5Y1AHPIEtBLnihRkRxuFGv", - "ew+ho2kYgFv3bIYd8LC4yknTa5Psm6j8Yo8SSBf5Cht5+jrRDhkFXBJDgNMDkO3xX/ZfNKdVQiVWc+YJ", - "uLcx99222MLXdtwWgOQ1CqG2grb/SszJI1v/uuaY5N507Ka8IFpujKDqy/1JoCXJW8mtAY7+yTkbPDk7", - "VYHe6gbWlNYFRHNCDxnB0Cks8MOtH4DnlDuS7yNIC0IJhwXV7BK8y396V4zq2reZKwW1hQFOCC0Kexqb", - "TYBLkBui6pkysg5v5yjdU+3zsgfDgHUFkpkrmpaNA96qCce20tS2OKIz+8YNL60OL7L1rWQ7atHfrK76", - "lZiTH1kuxUm5ECEWXm2UhlWvH7b79PeBfgXekNCPWRW8ZByyleCpLs0/4dMf8WHqa6zWNfTxuXk49G3n", - "vm3D3wGrPc+YO/mm+P1ITv+NAl06q5VQCWm029nG5l8g/e95lPyh2fC8f5I2PI+cWu5hNFDcnLn187FP", - "R2i1ak6++VfrT1eRzr2plrUuxFU0C9oAbDjjmGJUKHzvmeTR2Nza2ZNMvV+r2/v0NkV4SJ2t8DTRgrd5", - "ONyF92+ahO2cMzGRuJzGS5Cqo8jdZWL/W2Vij973vbixbTm/i6PV6rCyyytRgB23Scc1Rz/VBIWLAlxn", - "/L7IEsIi0ylD/v5q3uskceS0Xiw1qSuiRSpdpPkwo7llsplVhNITRmWHrbqE0y3pJRBaSqCFUV6BEzEz", - "i25uUlwkVVj42eecuODPpNAUwVVJkYNSUGS+6csu0Px7NlRdb8ETAo4Ah1mIEmRO5Y2BvbjcCecFbDJU", - "hhW5/8MvRrW+dXit0LgdsbbcbAK93bTrPtTjpt9GcN3JY7KzCd2WajFFTqyqElySXAKFe+FkcP+6EPV2", - "8eZowSwy9p4p3k9yMwIKoL5ner8ptHWVmfu7D+Jz+/ScrVAS45QLb4FMDVZSpbNdbNm8FK9FmRVEnDDF", - "iXHgAdX0JVX6jcuXLrDMo71OcB4rY5sphgE2t6jVLRIj/2IfpsbOzX3IVa2IG8HnQEGRWgOH9Za5XsE6", - "zIW1U/zYIcnK2gJ3jTyEpWh8h6yo8w2hOvL7m+ESi0NLJXWmjD4qW0A0iNgGyJl/K8Ju7PAfAISpBtGW", - "cLCSf0w5MyFKoNzmqoqqMtxCZzUP3w2h6cy+faJ/bt7tE5ethWHv7UKAihPgHORXFrMKTblLqoiDg6zo", - "hcuRW7hOpn2YzWHMsMxSto3y0bhr3oqPwM5DWlcLSQvICihpwujys31M7ONtA+COe/LMLoWGbIY1UtKb", - "3lCyHDQmhaEFjqdSwiPBJyQ3R9Aozw2BuK93jFwAjp1iTo6O7oWhcK7kFvnxcNl2qwcMWGYMs+OOHhBk", - "x9HHADyAhzD09VGBH2eN+aA7xX+CchMEOWL/STaghpbQjL/XArqGv/gCa90UHfbe4cBJtjnIxnbwkaEj", - "mzI1fpJugW6U03tMsmubWiMFcHod5fb4ijKdzYW0gnRG5xrkztD5f1LmHec+fVe4qisER3D3phsHmXzc", - "T85xEQsCcdeFIRFXScrcYZQ8JivGa22fiFpPbPlrCTRfGqE9tsHakbAjsCvSJGFBZVFit9h5uDeFtEWf", - "dOeCR6AT+Yhtjd+s+1shRxXVb5eOpEyTmmtWRo2Fgt7+8Vkv7ywSdxaJO4vEnUXiziJxZ5G4s0jcWSTu", - "LBJ3Fok7i8SdReLva5H4UGWSMi9x+IqNXPCsG0x5F0v5b1VVPlxV3kCC1okryrRrk++rFAzbLfYwBGmg", - "JeKAlTAc3W2DTs+/OXlJlKhlDiQ3EDJOqpIa1QDWOjRtnlEFXzz1qYb26qQr2/kd71fzwmdPyNn3J77i", - "6NJVxmy/e//ExqsRpTclPHBt0YAXVhL1/dGAG6S79mjUXwm+ubNrdc1KjIxX5Bt8+wVcQikqkLaYIdGy", - "hr7F5xxo+dzhZofB559mchdq+4cZ7Y9Jy+jl0LailRfz/VqpItRmXJIXUQ7mH3NaKvhjKA3TjreiVaq/", - "crj4rCkImcnXoth0TojZtWPcwPbZaOqOMk7lJlElqp8C0SUNLQy7coTVt2W9O3h13D7R9slsF4WlpHVb", - "Bj89+hCVJ8vChg3rDWUTdecdOjlK5Zh2a6EeBQBHFQbENAm7J+SN/e7DlgFEiNwRa5j5RxPF2H4zMA18", - "1ygRjvV8qrkEHvHJ04tnf2IIu6hzIEwr4gvs7r5eJkfrzIy0AJ45BpTNRLHJWuzrqHULFUxRpWA1230T", - "xfwTT1y4fMyT7ffUh7lGXkSL28aTY6JZZ44BD3DnjYbRvDlgC0d07DnC+Ptm0UNsNAaBOP6UMip1eN++", - "TK+ZZnPH+O4YX3QaOxIB464geZeJTN8j45MbWfNhnvfNGvLaABef5PtonUeXHKx1y8lawKxeLIy20PfR", - "YRsdHI8J/oFYoV3uWC64HwXZwUO3/JsmqXeH63OXKG/8vq/M+AC3g/INOjNWFeUb7/KFTLFVXVoc2qbS", - "h2W0tmZ4qsR0Y/sbsmq/9ia/yHbrrtr27xYt5IoqYvcXClLzwmU89Wpbr/n4Oid26PM1b9j01pomdr2J", - "1bl5x1wRfpfbqeaKVCAzveb2QLUOk+tgYE/uB62lfXdt3N61YRPVYYDB9qvxNwzhQLeHjPgaXh9Rz6Um", - "Ma/ViYm20wlbz9CiMZziEjdnsm8eNLCkN3w7vqQxtzj/KZQVoSQvGXpXBVda1rl+yyn6b6KFTfuxJ95Q", - "Pcz7nvtX0i7EhIfPDfWWUwwyCl6dJA+cQ8KF8S2AZ7GqXixAGT4aE9Ac4C13bzFOam60MDEnK5ZLkdnU", - "WnO+jOwytW+u6IbMsaKJIH+CFGRmbv1o160tWWlWli7YxUxDxPwtp5qUQJUmPzLDgc1wvpxCCDkDfSXk", - "RcBCulfPAjgoprK0YeY7+xTb4bjlewMgGjPt46aNxe32wfGws2IQ8tMXGKOG1ZhLpuL+i13Yb803vmI8", - "SxLZ+RKICxfr0ha5jzXgHAE9aDuO9BLecnP7aUGQ41N9PXLoeoB6Z9Gejg7VtDai4yjyax2l/h2Ey5AE", - "k7lzu/wbpZBGdOA9m7jxtr5+Z+/3dLG0rlzA1qBDF7J96tonDrzkFIiWkaxT4Ma9cd4Ceav/4tMvK3l4", - "XdKj8WDaZH/APrtqN8hDvPkNnxBaCr6wdRWNdilwnxivao0B4O/TgAeXtMzEJUjJClAjV8oE/+aSlj+F", - "z95NjmANeaYlzSGzFoWxWDs331g6xUaDnGlGywy16rEAwan96sx+tOM+jrqNrlZQMKqh3JBKQg6FLUTG", - "FGn0+akt0EDyJeULvLqlqBdL+5od5wokhMaMRoXuDpEuBLPmmS1K14fxxDVqjuv2As2XicYxeMEZnd0T", - "VNHqSTVyD1olR4eU9MnRoKBtkHrZhM5Z5LTZzAgpoiUPRPhpJj5EjdY7or8j+k+d6FMlFRF18461wuIr", - "3pb3bNZ63wVEb9FK9kGqC9+V6P93L9HvOZAilEja0kHSveGoIkyTKyyLNANi7q8arfOu4Z7T1zHTLjrq", - "rtKmcu358iVl3NXUCXkNCIdRiVcrprVvT/teDJuWmaFF06AD8loyvUGthVbs9wsw///NiP0K5KVXaGpZ", - "Hj07WmpdPTs+LkVOy6VQ+vjo3SR+pjoPfwvw/+V1kUqyS6NfvUOwhWQLxs2de0UXC5CNCfHoyfTR0bv/", - "LwAA///BlGwvCb8BAA==", + "iFjVs5LlvkYZU9Z+bAPsVdxc1lnWazUhM9t/2Afx8gJDlGw1AhX34D4tDKLt96cNs/MtlNGvfPTs10Q5", + "K5/54Tv7xkFnUTja/zn76RURkji16DXNL0LWi09zalK74iwn8+XU0/2/apCbhi4dx5wcqdAeHHi9MszH", + "pc+s1KJqV3ZtpLGUtaiHbD+zIafoQIRCJw3DQ9NgBEnDvg1LfpR9+dtfn//j3dEIQLDqjgJs9PgHLcs/", + "rHkN1hhZ24m8mQzFRE2awhn4QbOTE7RkhafR58077YLof3DB4Y+hbXCAJfeBlqV5UXBI7cFv2IoQiQXP", + "6pNHjzyDcuJ/BN2xO1TRLKN6AFjvQhjFk8Q1BuozMvvoTaiNKWllD+OJjx/eVMG/Y1+aGn719IALbVfw", + "vPFyu8P1Fv01LYh0+cu4lMef7FJOuY0FNReSvTjfTY4+/4T35pQbnkNLgm9GfX77N83P/IKLK+7fNEJT", + "vVpRuUGRSAde2G1MQxcKnarIIu3Zjsqv8cXRb+8Gr73jOOjx+K9W7aTiRpei9bK02jrtvicHOCeOZbPS", + "3A/3T6oKYz7PwvOTqrJtwzGOABjefrBmSqsHU/Jd/HXLOWIhsb6RVlKA76Lte3O3fOVRP87kpd2qSnB3", + "f3/Y+/ukbSRhBXDN5gwF9hQwrVOwFaZetNJNL9B+klBUI2nfgOhQH9uJFpnrvTZyDNeF/3CNBUeURrEz", + "/ZZSIXcy6jvcDeBuSEyK4A0SU9PV8HZYsy+1G26S1pXxHhn3Jy70/UhLQyfRcjstbU5f3AmDfythMJTk", + "XFjprKoOIB76zI1drxz/5cpMHkJqRPV4lLwYa97Rt1Hw/f0Ox3kwJSfdd67HVlyZzp2SoHnvTgb8GGRA", + "W+d0l/Tn6PiDyn1x3tc+aVgtgcX8PurjT1zQ+xsja1CyM5DulumuwT578ppj1u+Nrf5bymkOaXcS2t9a", + "QgvFs28ko8Wxr8euDEEksd3IwNc14DEdJLF2AfWIs2G9EUzIt0d40sT5GxZjA5hd6LKaeOURPbVWr7Sb", + "Nempln0R6zuIddivN6cvdklXn5ApaHQf5MQtkN6b981Lk56JN7fjmRjHm54+enp7EMS78Epo8i3e4u+Z", + "Q75XlpYmq31Z2DaOdDwT611ciXfYUqhQZw5ti0eFQqST6Ll52waA3MeU33bnrAdT8rV7tSkD4lLaF8Iw", + "Kp8qRuXCfmR4nUEGuef/fIbj35uSbzEBUqsJxrFhZgW+yLh+9vjJZ0/dK5Je2TCx7nuzL54+O/nqK/da", + "JRnXGDJg9Zze60rLZ0soS+E+cHdEf1zz4Nn//c//mk6n93ayVbH+evPKttr9WHjrJFXyMBDA0G594puU", + "0tZdC+SdqLsVD//XYp28BcT67hb6YLeQwf6/xe0za5ORU0SDsbPVjOeAt5E9JvvcRxN3/2AWR7hMpuSV", + "cH3R6pJKWyAGa+gqsqippFwDFFNPqZiCp2wlu7xkWDtAEgXyEmSmWKhVXUsIVUwqCZcYft9UeW1BsJvR", + "Y5DuR8vkf6TrKG9+Fq5pLdyS0ey5omuCjT40UaAntoTamnz1FXk0abSXsjQDZAExKea6ouujW7T6BWIb", + "WxfohcOOkLtjf3HsMRakRvoJBSYbVePvzrk/Wcndkrvb2ANxzr0dP41jJ7YjuO5jWy0IVrDTWA5Z1VVV", + "bppCuEbK8yJUmsWZGcYaBz5iH8FO03RSCe2i9+4Q3xkBbsRKugS1J9vAhFZ1/Bfq5THP6J1bTMj7e7lL", + "I9+RFCvvPBJkDjpfulzgDuoT7Em6fMRh3rRinK0MlI8m712qwV3sF0COmz8X1Gbgj+kvFqVpogMPZIKI", + "f8L/0BKL6rG5re3uO374cobomnLlsUPHVat82x7MLuTfpwxXtNVBdjeUz5vJ+wIZouUQ/s87BO+H4B5z", + "/MaVO7DHyy3i3yEpwKuSGXklmox0q0H9W7oe3+fN/r4X9EpwsD52I/laWrxzpwaxwzAOixRfisTqL01/", + "reuKIMe+hM9WOeR789IOWWTM7Y3lgD7FK/z7ZKGj1i1j1jbdWWehGW0MczYv2oYIcSWU6YfUYj4IP/0I", + "VZsPwbFuh8XgIfV8xokF/LBMB6v7WGI+rnwppiEO9NK8HMlltuDRaG6kRQhDg0RZITKDUvCF+jhZ0Tbq", + "SOMlQSW2iJXtq9Jb//RveHafu6Ynvpu/KyWlGM+BKLECVBmMjO4qUlsI/3F7EGq28q27eZze+oG5y+eP", + "Pru96c9AXrIcyDmsKiGpZOWG/MxDc5ObcDtFqNvz2BqcYA6Mo7epXXIsj+sj3YAJutb5aauxs1s3RROV", + "latErUHacnmdHlasx6RT9mBkGC/N1AeQ50qx+NTEOY/1sVWen9OyRHTtcjLhwKOilMvS7iesmNZNz4j4", + "diXf0HwZ9nbSWPdCZz9fXHzSKUeJI7s2bzbVX4HZZw0kWk1krQBpe5RrbNy0ohiwvKpLzaqy/U1ofYmt", + "gBJhSJY24y4Cpy/86qxzVsybobv060uRu8GnZm73CGfmwi6OSkDeHWwrne5S0xbQtimWD7+OWhm5hkyu", + "0iGTndKTTexMVQGVzceW8u9XEjI3hKSXIBXFw9pZ1IM7Uf3jENXXrtbxRyKoJ32UN+X117+KWlHUf+k1", + "K97tlsujcsF7iuSMRyJ5zC7sWbu+LL47iqLbWvz0RZyoIkJBLS8gDIBiULRnrtb/OhrpAsEiLWLu9LCa", + "W0B9jUsnsbosEjGfhDhNo5CK+TPylj8kakl9CWb355PPvxhw4ph5XGm6vhunGcg8tsOM8eV80p6pw0oc", + "Ab/Pbnu399vEyREr1ok687yAddTapN362N2H9xSp6MZndPRKLVbpcstBMY2HXYG5ptSSVbdf0ldpNkvX", + "NPeWuNBC/5R/HQyytu6skRqqD1HKdXKkJUABlV7urPCMbzW7Ca7WM1OuK4+twzshbApTW6a26Z5WLMBd", + "TJSUQOehDZoQY/L4Ij5jCM1TRYT1eCFjJOkk/aDMi0R5+3bSJt/NXnQeeV2h+IMKYfpDCWFZRwpro+XD", + "yWTYz2ESRV5VUmiRi9KGUdZVJaQOp1tNR1keYEjQaxkehgj3RsLcmhVqp0vnHN86gA2gTdnqk3HpnHs0", + "pXw6qUVds+5sM9cYlnYuKtJr3W9A+KB87U6pTPGzjvvnU/f+6EHSO7AzKKc6X9bV8V/4H6y7+67J2cWO", + "JOpYr/kx9qA8/mtrdC2y1NLIJtI2M2mZdHsdLZMxsi/x86ZxyrdCdruF74ye7SBt0r30bT9NDMNNsMf3", + "o03+rZWwra6zzobfPBokMWLvvIaSFFEXvkC7UTseX2XC9uBMkPBd9NLHtaDGnzhnvCA02saOrSn07fc6", + "wD8+2UV/CBfl7Ydsff4Jn7NXQpPTVVXCCriG4maB76TL4fztsfW63U8wcFd/Pzq+f+fHN77P6QmyyM4L", + "fg+9J6piBH46KrGskLmr34+6c3eTf9w3+fPgbY3J8O5e/nTuZekzke6u4I//Cv7sk13Ne4xhGnklX8M5", + "3L6GG018zwu5Jww4G1bHcLDNr4yqd3eV6lshfdO5u1v8E3WK2p0cHYg1xkKzyxLrpjxE1tlHBf04O0NZ", + "JiwNQwd1EmK9GNZrFDnD7jynhZq4oDJrnHCn+E7w+agFn2iv7+SeO9PDJ2Z6GJBynNZflmMEjX0FoMuV", + "KMA7VsV87uojD0k/7Y6QhjyVpquK2C+ng3HY52wFZ+bNn+wUB71iG7A7YlEHPIMsBbnghRoRxeFGve49", + "hI6mYQBu3bMZdsDD4ionTa9Nsm+i8os9SiBd5Cvs5OnrRDtkFHBJDAFOD0C2x3/Zf9GcVgmVWM2ZJ+De", + "xtx322ILX9txWwCS1yiE2gra/isxJ49s/euaY5J707Kb8oJouTGCqi/3J4GWJG8ltwY4+ifnbPDk7FQF", + "eqsbWFNaFxDNCT1kBEOnsMAPt34AnlPuSL6PIC0IJRwWVLNL8C7/6V0xqmvfZq4U1BYGOCG0KOxpbDYB", + "LkFuiKpnysg6vJ2jdE+1z8seDAPWFUhmrmhaNg54qyYc20pT2+KIzuwbN7y0OrzI1reS7ahFf7O66ldi", + "Tn5kuRQn5UKEWHi1URpWvYbY7tPfB/oVeENCP2ZV8JJxyFaCp9o0/4RPf8SHqa+xWtfQx+fm4dC3nfu2", + "DX8HrPY8Y+7km+L3Izn9Nwp06axWQiWk0W5nG5t/gfS/51Hyh2bD8/5J2vA8cmq5h9FAcXfm1s/HPh2h", + "1as5+eZfrT9dRTr3plrWuhBX0SxoA7DhjGOKUaHwvWeSR2Nza2dPMvV+rW7v09sU4SF1tsLTRAve5uFw", + "F96/aRK2c87EROJyGi9Bqo4id5eJ/W+ViT163/fixrbl/C6OVqvDyi6vRAF23CYd1xz9VBMULgpwnfH7", + "IksIi0ynDPn7q3mvk8SR03qx1KSuiBapdJHmw4zmlslmVhFKTxiVHbbqEk63pJdAaCmBFkZ5BU7EzCy6", + "uUlxkVRh4Wefc+KCP5NCUwRXJUUOSkGR+aYvu0Dz79lQdb0FTwg4AhxmIUqQOZU3BvbiciecF7DJUBlW", + "5P4PvxjV+tbhtULjdsTacrMJ9HbTrvtQj5t+G8F1J4/JziZ0W6rFFDmxqkpwSXIJFO6Fk8H960LU28Wb", + "owWzyNh7png/yc0IKID6nun9ptDWVWbu7z6Iz+3Tc7ZCSYxTLrwFMjVYSZXOdrFl81K8FmVWEHHCFCfG", + "gQdU05dU6TcuX7rAMo/2OsF5rIxtphgG2NyiVrdIjPyLfZgaOzf3IVe1Im4EnwMFRWoNHNZb5noF6zAX", + "1k7xY4ckK2sL3DXyEJai8R2yos43hOrI72+GSywOLZXUmTL6qGwB0SBiGyBn/q0Iu7HDfwAQphpEW8LB", + "Sv4x5cyEKIFym6sqqspwC53VPHw3hKYz+/aJ/rl5t09cthaGvbcLASpOgHOQX1nMKjTlLqkiDg6yohcu", + "R27hOpn2YTaHMcMyS9k2ykfjrnkrPgI7D2ldLSQtICugpAmjy8/2MbGPtw2AO+7JM7sUGrIZ1khJb3pD", + "yXLQmBSGFjieSgmPBJ+Q3BxBozw3BOK+3jFyATh2ijk5OroXhsK5klvkx8Nl260eMGCZMcyOO3pAkB1H", + "HwPwAB7C0NdHBX6cNeaD7hT/CcpNEOSI/SfZgBpaQjP+XgvoGv7iC6x1U3TYe4cDJ9nmIBvbwUeGjmzK", + "1PhJugW6UU7vMcmubWqNFMDpdZTb4yvKdDYX0grSGZ1rkDtD5/9JmXec+/Rd4aquEBzB3ZtuHGTycT85", + "x0UsCMRdF4ZEXCUpc4dR8pisGK+1fSJqPbHlryXQfGmE9tgGa0fCjsCuSJOEBZVFid1i5+HeFNIWfdKd", + "Cx6BTuQjtjV+s+5vhRxVVL9dOpIyTWquWRk1Fgp6+8dnvbyzSNxZJO4sEncWiTuLxJ1F4s4icWeRuLNI", + "3Fkk7iwSdxaJv69F4kOVScq8xOErNnLBs24w5V0s5b9VVflwVXkDCVonrijTrk2+r1IwbLfYwxCkgZaI", + "A1bCcHS3DTo9/+bkJVGiljmQ3EDIOKlKalQDWOvQtHlGFXzx1Kca2quTrmznd7xfzQufPSFn35/4iqNL", + "Vxmz/e79ExuvRpTelPDAtUUDXlhJ1PdHA26Q7tqjUX8l+ObOrtU1KzEyXpFv8O0XcAmlqEDaYoZEyxr6", + "Fp9zoOVzh5sdBp9/msldqO0fZrQ/Ji2jl0PbilZezPdrpYpQm3FJXkQ5mH/Maangj6E0TDveilap/srh", + "4rOmIGQmX4ti0zkhZteOcQPbZ6OpO8o4lZtElah+CkSXNLQw7MoRVt+W9e7g1XH7RNsns10UlpLWbRn8", + "9OhDVJ4sCxs2rDeUTdSdd+jkKJVj2q2FehQAHFUYENMk7J6QN/a7D1sGECFyR6xh5h9NFGP7zcA08F2j", + "RDjW86nmEnjEJ08vnv2JIeyizoEwrYgvsLv7epkcrTMz0gJ45hhQNhPFJmuxr6PWLVQwRZWC1Wz3TRTz", + "Tzxx4fIxT7bfUx/mGnkRLW4bT46JZp05BjzAnTcaRvPmgC0c0bHnCOPvm0UPsdEYBOL4U8qo1OF9+zK9", + "ZprNHeO7Y3zRaexIBIy7guRdJjJ9j4xPbmTNh3neN2vIawNcfJLvo3UeXXKw1i0nawGzerEw2kLfR4dt", + "dHA8JvgHYoV2uWO54H4UZAcP3fJvmqTeHa7PXaK88fu+MuMD3A7KN+jMWFWUb7zLFzLFVnVpcWibSh+W", + "0dqa4akS043tb8iq/dqb/CLbrbtq279btJArqojdXyhIzQuX8dSrbb3m4+uc2KHP17xh01trmtj1Jlbn", + "5h1zRfhdbqeaK1KBzPSa2wPVOkyug4E9uR+0lvbdtXF714ZNVIcBBtuvxt8whAPdHjLia3h9RD2XmsS8", + "Vicm2k4nbD1Di8ZwikvcnMm+edDAkt7w7fiSxtzi/KdQVoSSvGToXRVcaVnn+i2n6L+JFjbtx554Q/Uw", + "73vuX0m7EBMePjfUW04xyCh4dZI8cA4JF8a3AJ7FqnqxAGX4aExAc4C33L3FOKm50cLEnKxYLkVmU2vN", + "+TKyy9S+uaIbMseKJoL8CVKQmbn1o123tmSlWVm6YBczDRHzt5xqUgJVmvzIDAc2w/lyCiHkDPSVkBcB", + "C+lePQvgoJjK0oaZ7+xTbIfjlu8NgGjMtI+bNha32wfHw86KQchPX2CMGlZjLpmK+y92Yb813/iK8SxJ", + "ZOdLIC5crEtb5D7WgHME9KDtONJLeMvN7acFQY5P9fXIoesB6p1Fezo6VNPaiI6jyK91lPp3EC5DEkzm", + "zu3yb5RCGtGB92zixtv6+p2939PF0rpyAVuDDl3I9qlrnzjwklMgWkayToEb98Z5C+St/otPv6zk4XVJ", + "j8aDaZP9Afvsqt0gD/HmN3xCaCn4wtZVNNqlwH1ivKo1BoC/TwMeXNIyE5cgJStAjVwpE/ybS1r+FD57", + "NzmCNeSZljSHzFoUxmLt3Hxj6RQbDXKmGS0z1KrHAgSn9qsz+9GO+zjqNrpaQcGohnJDKgk5FLYQGVOk", + "0eentkADyZeUL/DqlqJeLO1rdpwrkBAaMxoVujtEuhDMmme2KF0fxhPXqDmu2ws0XyYax+AFZ3R2T1BF", + "qyfVyD1olRwdUtInR4OCtkHqZRM6Z5HTZjMjpIiWPBDhp5n4EDVa74j+jug/daJPlVRE1M071gqLr3hb", + "3rNZ630XEL1FK9kHqS58V6L/371Ev+dAilAiaUsHSfeGo4owTa6wLNIMiLm/arTOu4Z7Tl/HTLvoqLtK", + "m8q158uXlHFXUyfkNSAcRiVerZjWvj3tezFsWmaGFk2DDshryfQGtRZasd8vwPz/NyP2K5CXXqGpZXn0", + "7GipdfXs+LgUOS2XQunjo3eT+JnqPPwtwP+X10UqyS6NfvUOwRaSLRg3d+4VXSxANibEoyfTR0fv/r8A", + "AAD///qwGHIKvwEA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/data/transactions/logic/eval_test.go b/data/transactions/logic/eval_test.go index 701acedaab..6b664de684 100644 --- a/data/transactions/logic/eval_test.go +++ b/data/transactions/logic/eval_test.go @@ -3218,7 +3218,21 @@ func TestIllegalOp(t *testing.T) { } } -func TestShortProgram(t *testing.T) { +func TestShortSimple(t *testing.T) { + partitiontest.PartitionTest(t) + + t.Parallel() + for v := uint64(1); v <= AssemblerMaxVersion; v++ { + t.Run(fmt.Sprintf("v=%d", v), func(t *testing.T) { + ops := testProg(t, `int 8; store 7`, v) + testLogicBytes(t, ops.Program[:len(ops.Program)-1], nil, + "program ends short of immediate values", + "program ends short of immediate values") + }) + } +} + +func TestShortBranch(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() diff --git a/data/transactions/transaction.go b/data/transactions/transaction.go index 363747a996..65b9a4bbe2 100644 --- a/data/transactions/transaction.go +++ b/data/transactions/transaction.go @@ -325,7 +325,7 @@ func (tx Header) Alive(tc TxnContext) error { // MatchAddress checks if the transaction touches a given address. func (tx Transaction) MatchAddress(addr basics.Address, spec SpecialAddresses) bool { - return slices.Contains(tx.RelevantAddrs(spec), addr) + return slices.Contains(tx.relevantAddrs(spec), addr) } var errKeyregTxnFirstVotingRoundGreaterThanLastVotingRound = errors.New("transaction first voting round need to be less than its last voting round") @@ -714,9 +714,8 @@ func (tx Header) Last() basics.Round { return tx.LastValid } -// RelevantAddrs returns the addresses whose balance records this transaction will need to access. -// The header's default is to return just the sender and the fee sink. -func (tx Transaction) RelevantAddrs(spec SpecialAddresses) []basics.Address { +// relevantAddrs returns the addresses whose balance records this transaction will need to access. +func (tx Transaction) relevantAddrs(spec SpecialAddresses) []basics.Address { addrs := []basics.Address{tx.Sender, spec.FeeSink} switch tx.Type { @@ -733,6 +732,8 @@ func (tx Transaction) RelevantAddrs(spec SpecialAddresses) []basics.Address { if !tx.AssetTransferTxnFields.AssetSender.IsZero() { addrs = append(addrs, tx.AssetTransferTxnFields.AssetSender) } + case protocol.HeartbeatTx: + addrs = append(addrs, tx.HeartbeatTxnFields.HbAddress) } return addrs diff --git a/ledger/eval/eval.go b/ledger/eval/eval.go index 75404d36fe..7bd8577b9f 100644 --- a/ledger/eval/eval.go +++ b/ledger/eval/eval.go @@ -1612,6 +1612,10 @@ func (eval *BlockEvaluator) recordProposal() error { return nil } +// proposerPayout determines how much the proposer should be paid, assuming it +// gets paid at all. It may not examine the actual proposer because it is +// called before the proposer is known. Agreement might zero out this value +// when the actual proposer is decided, if that proposer is ineligible. func (eval *BlockEvaluator) proposerPayout() (basics.MicroAlgos, error) { incentive, _ := basics.NewPercent(eval.proto.Payouts.Percent).DivvyAlgos(eval.block.FeesCollected) total, o := basics.OAddA(incentive, eval.block.Bonus) diff --git a/ledger/eval/prefetcher/prefetcher.go b/ledger/eval/prefetcher/prefetcher.go index 487d370524..765b6ea9c2 100644 --- a/ledger/eval/prefetcher/prefetcher.go +++ b/ledger/eval/prefetcher/prefetcher.go @@ -343,7 +343,9 @@ func (p *accountPrefetcher) prefetch(ctx context.Context) { // since they might be non-used arbitrary values case protocol.StateProofTx: - case protocol.KeyRegistrationTx: + case protocol.KeyRegistrationTx: // No extra accounts besides the sender + case protocol.HeartbeatTx: + loadAccountsAddAccountTask(&stxn.Txn.HbAddress, task, accountTasks, queue) } // If you add new addresses here, also add them in getTxnAddresses(). diff --git a/ledger/eval/prefetcher/prefetcher_alignment_test.go b/ledger/eval/prefetcher/prefetcher_alignment_test.go index 5f61f4938f..6cd6dc9247 100644 --- a/ledger/eval/prefetcher/prefetcher_alignment_test.go +++ b/ledger/eval/prefetcher/prefetcher_alignment_test.go @@ -30,6 +30,7 @@ import ( "github.com/algorand/go-algorand/crypto/stateproof" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/committee" "github.com/algorand/go-algorand/data/stateproofmsg" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/ledger/eval" @@ -1422,3 +1423,54 @@ func TestEvaluatorPrefetcherAlignmentStateProof(t *testing.T) { prefetched.pretend(rewardsPool()) require.Equal(t, requested, prefetched) } + +func TestEvaluatorPrefetcherAlignmentHeartbeat(t *testing.T) { + partitiontest.PartitionTest(t) + + // We need valid part keys to evaluate the Heartbeat. + const kd = 10 + firstID := basics.OneTimeIDForRound(0, kd) + otss := crypto.GenerateOneTimeSignatureSecrets(firstID.Batch, 5) + + l := &prefetcherAlignmentTestLedger{ + balances: map[basics.Address]ledgercore.AccountData{ + rewardsPool(): { + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: 1234567890}, + }, + }, + makeAddress(1): { + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: 1000001}, + }, + }, + makeAddress(2): { + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: 100_000}, + }, + VotingData: basics.VotingData{ + VoteID: otss.OneTimeSignatureVerifier, + }, + }, + }, + } + + txn := transactions.Transaction{ + Type: protocol.HeartbeatTx, + Header: transactions.Header{ + Sender: makeAddress(1), + GenesisHash: genesisHash(), + Fee: basics.Algos(1), // Heartbeat txn is unusual in that it checks fees a bit. + }, + HeartbeatTxnFields: transactions.HeartbeatTxnFields{ + HbAddress: makeAddress(2), + HbProof: otss.Sign(firstID, committee.Seed(genesisHash())).ToHeartbeatProof(), + HbSeed: committee.Seed(genesisHash()), + }, + } + + requested, prefetched := run(t, l, txn) + + prefetched.pretend(rewardsPool()) + require.Equal(t, requested, prefetched) +} From d55951a9bb6608f5fe94e39cdb23d7d25c502b53 Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Wed, 4 Dec 2024 13:13:41 -0500 Subject: [PATCH 08/14] Eval: Make the absenteeism factor = 20 instead of 10 (#6186) --- ledger/apply/heartbeat.go | 4 ++-- ledger/eval/eval.go | 11 +++++++---- ledger/eval/eval_test.go | 18 +++++++++--------- ledger/eval_simple_test.go | 14 ++++++++------ .../features/incentives/suspension_test.go | 7 +++---- 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/ledger/apply/heartbeat.go b/ledger/apply/heartbeat.go index 9e1056dc3c..ec6bc3a768 100644 --- a/ledger/apply/heartbeat.go +++ b/ledger/apply/heartbeat.go @@ -43,8 +43,8 @@ func Heartbeat(hb transactions.HeartbeatTxnFields, header transactions.Header, b kind = "cheap" } - // These first checks are a little draconian. The idea is not let these - // free transactions do anything except their exact intended purpose. + // These first checks are a little draconian. Don't let these free + // transactions do anything except their exact intended purpose. if len(header.Note) > 0 { return fmt.Errorf("%s heartbeat is not allowed to have a note", kind) } diff --git a/ledger/eval/eval.go b/ledger/eval/eval.go index 7bd8577b9f..c3eececc88 100644 --- a/ledger/eval/eval.go +++ b/ledger/eval/eval.go @@ -1768,6 +1768,8 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []bas } } +const absentFactor = 20 + func isAbsent(totalOnlineStake basics.MicroAlgos, acctStake basics.MicroAlgos, lastSeen basics.Round, current basics.Round) bool { // Don't consider accounts that were online when payouts went into effect as // absent. They get noticed the next time they propose or keyreg, which @@ -1775,11 +1777,12 @@ func isAbsent(totalOnlineStake basics.MicroAlgos, acctStake basics.MicroAlgos, l if lastSeen == 0 || acctStake.Raw == 0 { return false } - // See if the account has exceeded 10x their expected observation interval. - allowableLag, o := basics.Muldiv(10, totalOnlineStake.Raw, acctStake.Raw) + // See if the account has exceeded their expected observation interval. + allowableLag, o := basics.Muldiv(absentFactor, totalOnlineStake.Raw, acctStake.Raw) if o { - // This can't happen with 10B total possible stake, but if we imagine - // another algorand network with huge possible stake, this seems reasonable. + // This can't happen with 10B total possible stake and a reasonable + // absentFactor, but if we imagine another algorand network with huge + // possible stake, this seems reasonable. allowableLag = math.MaxInt64 / acctStake.Raw } return lastSeen+basics.Round(allowableLag) < current diff --git a/ledger/eval/eval_test.go b/ledger/eval/eval_test.go index d33996b2d8..d92ff31f1b 100644 --- a/ledger/eval/eval_test.go +++ b/ledger/eval/eval_test.go @@ -1385,7 +1385,7 @@ func TestAbsenteeChecks(t *testing.T) { blkEval = l.nextBlock(t) switch vb.Block().Round() { - case 102: // 2 out of 10 genesis accounts are now absent + case 202: // 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: @@ -1613,13 +1613,13 @@ func TestIsAbsent(t *testing.T) { var absent = func(total uint64, acct uint64, last uint64, current uint64) bool { return isAbsent(basics.Algos(total), basics.Algos(acct), basics.Round(last), basics.Round(current)) } - // 1% of stake, absent for 1000 rounds - a.False(absent(1000, 10, 5000, 6000)) - a.True(absent(1000, 10, 5000, 6001)) // longer - a.True(absent(1000, 11, 5000, 6001)) // more acct stake - a.False(absent(1000, 9, 5000, 6001)) // less acct stake - a.False(absent(1001, 10, 5000, 6001)) // more online stake + a.False(absent(1000, 10, 5000, 6000)) // 1% of stake, absent for 1000 rounds + a.False(absent(1000, 10, 5000, 7000)) // 1% of stake, absent for 2000 rounds + a.True(absent(1000, 10, 5000, 7001)) // 2001 + a.True(absent(1000, 11, 5000, 7000)) // more acct stake drives percent down, makes it absent + a.False(absent(1000, 9, 5000, 7001)) // less acct stake + a.False(absent(1001, 10, 5000, 7001)) // more online stake // not absent if never seen - a.False(absent(1000, 10, 0, 6000)) - a.False(absent(1000, 10, 0, 6001)) + a.False(absent(1000, 10, 0, 2001)) + a.True(absent(1000, 10, 1, 2002)) } diff --git a/ledger/eval_simple_test.go b/ledger/eval_simple_test.go index 191ee6e1c0..94ff28bbdc 100644 --- a/ledger/eval_simple_test.go +++ b/ledger/eval_simple_test.go @@ -610,7 +610,9 @@ func TestAbsentTracking(t *testing.T) { var addr1off basics.Round var addr2off basics.Round - // We're at 20, skip ahead by lookback + 30 to see the knockoffs + // We're at 20, skip ahead by lookback + 60 to see the knockoffs + const absentFactor = 20 + skip := basics.Round(3) * absentFactor for { vb := dl.fullBlock() printAbsent(vb) @@ -643,12 +645,12 @@ func TestAbsentTracking(t *testing.T) { checkState(addrs[2], true, true, 833_333_331_429_333) // after keyreg w/ 2A is effective } - if rnd > 20+lookback+30 { + if rnd > 20+lookback+skip { break } } - require.Equal(t, addr2Eligible+lookback+30, addr2off) - require.Equal(t, addr1Keyreg+lookback+31, addr1off) // addr1 paid out a little bit, extending its lag by 1 + require.Equal(t, addr2Eligible+lookback+skip, addr2off) + require.Equal(t, addr1Keyreg+lookback+skip+1, addr1off) // addr1 paid out a little bit, extending its lag by 1 require.Equal(t, basics.Online, lookup(t, dl.generator, addrs[0]).Status) // genesis account require.Equal(t, basics.Offline, lookup(t, dl.generator, addrs[1]).Status) @@ -683,11 +685,11 @@ func TestAbsentTracking(t *testing.T) { addr2check = true } - if rnd > 20+2*lookback+30 { + if rnd > 20+2*lookback+skip { break } } - // sanity check that we didn't skip one because of checkstate advanacing a round + // sanity check that we didn't skip one because of checkstate advancing a round require.True(t, addr1check) require.True(t, addr2check) diff --git a/test/e2e-go/features/incentives/suspension_test.go b/test/e2e-go/features/incentives/suspension_test.go index cb9ff47259..0191f6613c 100644 --- a/test/e2e-go/features/incentives/suspension_test.go +++ b/test/e2e-go/features/incentives/suspension_test.go @@ -45,10 +45,10 @@ func TestBasicSuspension(t *testing.T) { // Start a three-node network (70,20,10), all online // Wait for 10 and 20% nodes to propose (we never suspend accounts with lastProposed=lastHeartbeat=0) // Stop them both - // Run for 55 rounds, which is enough for 20% node to be suspended, but not 10% + // Run for 105 rounds, which is enough for 20% node to be suspended, but not 10% // check neither suspended, send a tx from 20% to 10%, only 20% gets suspended - // TODO once we have heartbeats: bring them back up, make sure 20% gets back online - const suspend20 = 55 + // bring n20 back up, make sure it gets back online by proposing during the lookback + const suspend20 = 105 // 1.00/0.20 * absentFactor var fixture fixtures.RestClientFixture // Speed up rounds. Long enough lookback, so 20% node has a chance to @@ -101,7 +101,6 @@ func TestBasicSuspension(t *testing.T) { a.NotZero(account.VoteID) a.False(account.IncentiveEligible) // suspension turns off flag - // TODO: n10 wasn't turned off, it's still online account, err = c10.AccountData(account10.Address) a.NoError(err) a.Equal(basics.Online, account.Status) From 6eb6ad29808a8a0638ab3ecf05eff8cb1b761171 Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Wed, 4 Dec 2024 14:22:03 -0500 Subject: [PATCH 09/14] Only heartbeat with current VoterID (#6188) --- heartbeat/service.go | 9 +++++- heartbeat/service_test.go | 67 +++++++++++++++------------------------ 2 files changed, 34 insertions(+), 42 deletions(-) diff --git a/heartbeat/service.go b/heartbeat/service.go index c3d66239e2..655fab59a9 100644 --- a/heartbeat/service.go +++ b/heartbeat/service.go @@ -84,12 +84,19 @@ func (s *Service) findChallenged(rules config.ProposerPayoutRules, current basic } var found []account.ParticipationRecordForRound - for _, pr := range s.accts.Keys(current + 1) { // only look at accounts we have part keys for + for _, pr := range s.accts.Keys(current) { // only look at accounts we have part keys for acct, _, _, err := s.ledger.LookupAccount(current, pr.Account) if err != nil { s.log.Errorf("error looking up %v: %v", pr.Account, err) continue } + // There can be more than one `pr` for a single Account in the case of + // overlapping partkey validity windows. Heartbeats are validated with + // the _current_ VoterID (see apply/heartbeat.go), so we only care about + // a ParticipationRecordForRound if it is for the VoterID in `acct`. + if acct.VoteID != pr.Voting.OneTimeSignatureVerifier { + continue + } if acct.Status == basics.Online { if ch.Failed(pr.Account, acct.LastSeen()) { s.log.Infof(" %v needs a heartbeat\n", pr.Account) diff --git a/heartbeat/service_test.go b/heartbeat/service_test.go index 3422ffdea4..5ee518616c 100644 --- a/heartbeat/service_test.go +++ b/heartbeat/service_test.go @@ -43,8 +43,6 @@ type mockedLedger struct { waiters map[basics.Round]chan struct{} history []table hdr bookkeeping.BlockHeader - - participants map[basics.Address]*crypto.OneTimeSignatureSecrets } func newMockedLedger() mockedLedger { @@ -102,7 +100,6 @@ func (l *mockedLedger) addBlock(delta table) error { l.mu.Lock() defer l.mu.Unlock() - fmt.Printf("addBlock %d\n", l.lastRound()+1) l.history = append(l.history, delta) for r, ch := range l.waiters { @@ -148,36 +145,23 @@ func (l *mockedLedger) waitFor(s *Service, a *require.Assertions) { }, time.Second, 10*time.Millisecond) } -func (l *mockedLedger) Keys(rnd basics.Round) []account.ParticipationRecordForRound { - var ret []account.ParticipationRecordForRound - for addr, secrets := range l.participants { - if rnd > l.LastRound() { // Usually we're looking for key material for a future round - rnd = l.LastRound() - } - acct, _, _, err := l.LookupAccount(rnd, addr) - if err != nil { - panic(err.Error()) - } +type mockedAcctManager []account.ParticipationRecordForRound - ret = append(ret, account.ParticipationRecordForRound{ - ParticipationRecord: account.ParticipationRecord{ - ParticipationID: [32]byte{}, - Account: addr, - Voting: secrets, - FirstValid: acct.VoteFirstValid, - LastValid: acct.VoteLastValid, - KeyDilution: acct.VoteKeyDilution, - }, - }) - } - return ret +func (am *mockedAcctManager) Keys(rnd basics.Round) []account.ParticipationRecordForRound { + return *am } -func (l *mockedLedger) addParticipant(addr basics.Address, otss *crypto.OneTimeSignatureSecrets) { - if l.participants == nil { - l.participants = make(map[basics.Address]*crypto.OneTimeSignatureSecrets) - } - l.participants[addr] = otss +func (am *mockedAcctManager) addParticipant(addr basics.Address, otss *crypto.OneTimeSignatureSecrets) { + *am = append(*am, account.ParticipationRecordForRound{ + ParticipationRecord: account.ParticipationRecord{ + ParticipationID: [32]byte{}, + Account: addr, + Voting: otss, + FirstValid: 0, + LastValid: 1_000_000, + KeyDilution: 7, + }, + }) } type txnSink [][]transactions.SignedTxn @@ -195,7 +179,7 @@ func TestStartStop(t *testing.T) { a := require.New(t) sink := txnSink{} ledger := newMockedLedger() - s := NewService(&ledger, &ledger, &sink, logging.TestingLog(t)) + s := NewService(&mockedAcctManager{}, &ledger, &sink, logging.TestingLog(t)) a.NotNil(s) a.NoError(ledger.addBlock(nil)) s.Start() @@ -217,13 +201,12 @@ func TestHeartbeatOnlyWhenChallenged(t *testing.T) { a := require.New(t) sink := txnSink{} ledger := newMockedLedger() - s := NewService(&ledger, &ledger, &sink, logging.TestingLog(t)) + participants := &mockedAcctManager{} + s := NewService(participants, &ledger, &sink, logging.TestingLog(t)) s.Start() joe := basics.Address{0xcc} // 0xcc will matter when we set the challenge mary := basics.Address{0xaa} // 0xaa will matter when we set the challenge - ledger.addParticipant(joe, nil) - ledger.addParticipant(mary, nil) acct := ledgercore.AccountData{} @@ -236,12 +219,14 @@ func TestHeartbeatOnlyWhenChallenged(t *testing.T) { acct.VoteKeyDilution = 100 startBatch := basics.OneTimeIDForRound(ledger.LastRound(), acct.VoteKeyDilution).Batch const batches = 50 // gives 50 * kd rounds = 5000 - otss := crypto.GenerateOneTimeSignatureSecrets(startBatch, batches) - acct.VoteID = otss.OneTimeSignatureVerifier - ledger.addParticipant(joe, otss) - ledger.addParticipant(mary, otss) - - a.NoError(ledger.addBlock(table{joe: acct, mary: acct})) + otss1 := crypto.GenerateOneTimeSignatureSecrets(startBatch, batches) + otss2 := crypto.GenerateOneTimeSignatureSecrets(startBatch, batches) + participants.addParticipant(joe, otss1) + participants.addParticipant(joe, otss2) // Simulate overlapping part keys, so Keys() returns both + participants.addParticipant(mary, otss1) + + acct.VoteID = otss1.OneTimeSignatureVerifier + a.NoError(ledger.addBlock(table{joe: acct, mary: acct})) // in effect, "keyreg" with otss1 a.Empty(sink) // now we have to make it seem like joe has been challenged. We obtain the @@ -259,7 +244,7 @@ func TestHeartbeatOnlyWhenChallenged(t *testing.T) { a.NoError(ledger.addBlock(table{joe: acct})) ledger.waitFor(s, a) - a.Len(sink, 1) // only one heartbeat (for joe) + a.Len(sink, 1) // only one heartbeat (for joe) despite having two part records a.Len(sink[0], 1) a.Equal(sink[0][0].Txn.Type, protocol.HeartbeatTx) a.Equal(sink[0][0].Txn.HbAddress, joe) From 35d32b06e85b866354c5b375c7055859f8c452f4 Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Thu, 5 Dec 2024 13:09:12 -0500 Subject: [PATCH 10/14] code review --- data/transactions/heartbeat.go | 8 ++++---- ledger/apply/challenge.go | 2 +- ledger/apply/heartbeat.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/data/transactions/heartbeat.go b/data/transactions/heartbeat.go index 48a4df6c69..5a36343339 100644 --- a/data/transactions/heartbeat.go +++ b/data/transactions/heartbeat.go @@ -34,9 +34,9 @@ type HeartbeatTxnFields struct { // HbProof is a signature using HeartbeatAddress's partkey, thereby showing it is online. HbProof crypto.HeartbeatProof `codec:"hbprf"` - // HbSeed must be the block seed for the block before this transaction's - // firstValid. It is supplied in the transaction so that Proof can be - // checked at submit time without a ledger lookup, and must be checked at - // evaluation time for equality with the actual blockseed. + // HbSeed must be the block seed for the this transaction's firstValid + // block. It is supplied in the transaction so that Proof can be checked at + // submit time without a ledger lookup, and must be checked at evaluation + // time for equality with the actual blockseed. HbSeed committee.Seed `codec:"hbsd"` } diff --git a/ledger/apply/challenge.go b/ledger/apply/challenge.go index fa060879e6..6dc5fb1a2b 100644 --- a/ledger/apply/challenge.go +++ b/ledger/apply/challenge.go @@ -72,7 +72,7 @@ func FindChallenge(rules config.ProposerPayoutRules, current basics.Round, heade } challengeHdr, err := headers.BlockHdr(lastChallenge) if err != nil { - panic(err) + return challenge{} } challengeProto := config.Consensus[challengeHdr.CurrentProtocol] // challenge is not considered if rules have changed since that round diff --git a/ledger/apply/heartbeat.go b/ledger/apply/heartbeat.go index ec6bc3a768..806c79e2cf 100644 --- a/ledger/apply/heartbeat.go +++ b/ledger/apply/heartbeat.go @@ -62,7 +62,7 @@ func Heartbeat(hb transactions.HeartbeatTxnFields, header transactions.Header, b return fmt.Errorf("%s heartbeat is not allowed when not IncentiveEligible %+v", kind, hb.HbAddress) } ch := FindChallenge(proto.Payouts, round, provider, ChRisky) - if ch.round == 0 { + if ch.IsZero() { return fmt.Errorf("%s heartbeat for %s is not allowed with no challenge", kind, hb.HbAddress) } if !ch.Failed(hb.HbAddress, account.LastSeen()) { From 957364a258d67491c6cef442a408c9a718e07225 Mon Sep 17 00:00:00 2001 From: Gary Malouf <982483+gmalouf@users.noreply.github.com> Date: Thu, 5 Dec 2024 17:47:12 -0500 Subject: [PATCH 11/14] Comment: Payout Comment Tweak (#6191) --- data/bookkeeping/block.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/bookkeeping/block.go b/data/bookkeeping/block.go index 7f2632f3f0..af2068a1fe 100644 --- a/data/bookkeeping/block.go +++ b/data/bookkeeping/block.go @@ -72,8 +72,8 @@ type ( // begins as a consensus parameter value, and decays periodically. Bonus basics.MicroAlgos `codec:"bi"` - // ProposerPayout is the amount that should be moved from the FeeSink to - // the Proposer at the start of the next block. It is basically the + // ProposerPayout is the amount that is moved from the FeeSink to + // the Proposer in this block. It is basically the // bonus + the payouts percent of FeesCollected, but may be zero'd by // proposer ineligibility. ProposerPayout basics.MicroAlgos `codec:"pp"` From d5286e310959aeae988b2b5a0714d5bbc4fa81ed Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Mon, 9 Dec 2024 11:42:47 -0500 Subject: [PATCH 12/14] Do not suspend !IncentiveEligible --- ledger/eval/eval.go | 21 +++++++++++++-------- ledger/eval/eval_test.go | 21 ++++++++++++++++----- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/ledger/eval/eval.go b/ledger/eval/eval.go index c3eececc88..ba57ed1348 100644 --- a/ledger/eval/eval.go +++ b/ledger/eval/eval.go @@ -1632,13 +1632,15 @@ func (eval *BlockEvaluator) proposerPayout() (basics.MicroAlgos, error) { } // generateKnockOfflineAccountsList creates the lists of expired or absent -// participation accounts by traversing over the modified accounts in the state -// 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. +// participation accounts to be suspended. It examines the accounts that appear +// in the current block and high-stake accounts being tracked for state +// proofs. 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. // -// Different ndoes may propose different list of addresses based on node state. -// Block validators only check whether ExpiredParticipationAccounts or +// Different nodes may propose different list of addresses based on node state, +// the protocol does not enforce which accounts must appear. Block validators +// only check whether ExpiredParticipationAccounts or // AbsentParticipationAccounts meet the criteria for expiration or suspension, // not whether the lists are complete. // @@ -1740,7 +1742,7 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []bas updates.ExpiredParticipationAccounts, accountAddr, ) - continue // if marking expired, do not also suspend + continue // if marking expired, do not consider suspension } } @@ -1750,7 +1752,7 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []bas continue // no more room (don't break the loop, since we may have more expiries) } - if acctData.Status == basics.Online { + if acctData.Status == basics.Online && acctData.IncentiveEligible { lastSeen := max(acctData.LastProposed, acctData.LastHeartbeat) oad, lErr := eval.state.lookupAgreement(accountAddr) if lErr != nil { @@ -1882,6 +1884,9 @@ func (eval *BlockEvaluator) validateAbsentOnlineAccounts() error { if acctData.MicroAlgos.IsZero() { return fmt.Errorf("proposed absent account %v with zero algos", accountAddr) } + if !acctData.IncentiveEligible { + return fmt.Errorf("proposed absent account %v not IncentiveEligible", accountAddr) + } oad, lErr := eval.state.lookupAgreement(accountAddr) if lErr != nil { diff --git a/ledger/eval/eval_test.go b/ledger/eval/eval_test.go index 6ff50de2cd..494341e859 100644 --- a/ledger/eval/eval_test.go +++ b/ledger/eval/eval_test.go @@ -1336,6 +1336,7 @@ func TestAbsenteeChecks(t *testing.T) { crypto.RandBytes(tmp.StateProofID[:]) crypto.RandBytes(tmp.SelectionID[:]) crypto.RandBytes(tmp.VoteID[:]) + tmp.IncentiveEligible = true // make suspendable tmp.VoteFirstValid = 1 tmp.VoteLastValid = 1500 // large enough to avoid EXPIRATION, so we can see SUSPENSION switch i { @@ -1345,16 +1346,25 @@ func TestAbsenteeChecks(t *testing.T) { 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 + case 5: + tmp.LastHeartbeat = 1 // like addr[1] but !IncentiveEligible, no suspend + tmp.IncentiveEligible = false + case 6: + tmp.LastProposed = 1 // like addr[2] but !IncentiveEligible, no suspend + tmp.IncentiveEligible = false default: - if i < 10 { // make the other 8 genesis wallets unsuspendable - if i%2 == 0 { + if i < 10 { // make 0,3,4,7,8,9 unsuspendable + switch i % 3 { + case 0: tmp.LastProposed = 1200 - } else { + case 1: tmp.LastHeartbeat = 1200 + case 2: + tmp.IncentiveEligible = false } } else { - // ensure non-zero balance for new accounts, but a small balance - // so they will not be absent, just challenged. + // ensure non-zero balance for the 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 } @@ -1385,6 +1395,7 @@ func TestAbsenteeChecks(t *testing.T) { switch vb.Block().Round() { case 202: // 2 out of 10 genesis accounts are now absent + require.Len(t, vb.Block().AbsentParticipationAccounts, 2, addrs) require.Contains(t, vb.Block().AbsentParticipationAccounts, addrs[1]) require.Contains(t, vb.Block().AbsentParticipationAccounts, addrs[2]) case 1000: From 573ec73603918d2d114e62aabb2b2210341c700e Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Wed, 11 Dec 2024 12:39:32 -0500 Subject: [PATCH 13/14] Eval: Move heartbeat verification earlier (#6194) --- data/transactions/heartbeat.go | 15 +- data/transactions/msgp_gen.go | 196 +++++++++++++----- data/transactions/transaction.go | 32 +++ data/transactions/transaction_test.go | 135 +++++++++++- data/transactions/verify/txn.go | 9 + data/transactions/verify/txn_test.go | 95 +++++++-- data/txntest/txn.go | 16 +- heartbeat/service.go | 8 +- heartbeat/service_test.go | 15 +- ledger/apply/apply.go | 6 +- ledger/apply/challenge.go | 7 +- ledger/apply/heartbeat.go | 54 ++--- ledger/apply/heartbeat_test.go | 59 ++++-- .../prefetcher/prefetcher_alignment_test.go | 11 +- ledger/heartbeat_test.go | 155 -------------- .../features/incentives/challenge_test.go | 20 +- test/testdata/nettemplates/Challenges.json | 30 ++- util/execpool/stream.go | 2 +- 18 files changed, 545 insertions(+), 320 deletions(-) delete mode 100644 ledger/heartbeat_test.go diff --git a/data/transactions/heartbeat.go b/data/transactions/heartbeat.go index 5a36343339..33b1e19ac2 100644 --- a/data/transactions/heartbeat.go +++ b/data/transactions/heartbeat.go @@ -28,15 +28,22 @@ import ( type HeartbeatTxnFields struct { _struct struct{} `codec:",omitempty,omitemptyarray"` - // HeartbeatAddress is the account this txn is proving onlineness for. + // HbAddress is the account this txn is proving onlineness for. HbAddress basics.Address `codec:"hbad"` // HbProof is a signature using HeartbeatAddress's partkey, thereby showing it is online. HbProof crypto.HeartbeatProof `codec:"hbprf"` + // The final three fields are included to allow early, concurrent check of + // the HbProof. + // HbSeed must be the block seed for the this transaction's firstValid - // block. It is supplied in the transaction so that Proof can be checked at - // submit time without a ledger lookup, and must be checked at evaluation - // time for equality with the actual blockseed. + // block. It is the message that must be signed with HbAddress's part key. HbSeed committee.Seed `codec:"hbsd"` + + // HbVoteID must match the HbAddress account's current VoteID. + HbVoteID crypto.OneTimeSignatureVerifier `codec:"hbvid"` + + // HbKeyDilution must match HbAddress account's current KeyDilution. + HbKeyDilution uint64 `codec:"hbkd"` } diff --git a/data/transactions/msgp_gen.go b/data/transactions/msgp_gen.go index 0b644da818..9e901342c8 100644 --- a/data/transactions/msgp_gen.go +++ b/data/transactions/msgp_gen.go @@ -2922,20 +2922,28 @@ func HeaderMaxSize() (s int) { func (z *HeartbeatTxnFields) MarshalMsg(b []byte) (o []byte) { o = msgp.Require(b, z.Msgsize()) // omitempty: check for empty values - zb0001Len := uint32(3) - var zb0001Mask uint8 /* 4 bits */ + zb0001Len := uint32(5) + var zb0001Mask uint8 /* 6 bits */ if (*z).HbAddress.MsgIsZero() { zb0001Len-- zb0001Mask |= 0x2 } - if (*z).HbProof.MsgIsZero() { + if (*z).HbKeyDilution == 0 { zb0001Len-- zb0001Mask |= 0x4 } - if (*z).HbSeed.MsgIsZero() { + if (*z).HbProof.MsgIsZero() { zb0001Len-- zb0001Mask |= 0x8 } + if (*z).HbSeed.MsgIsZero() { + zb0001Len-- + zb0001Mask |= 0x10 + } + if (*z).HbVoteID.MsgIsZero() { + zb0001Len-- + zb0001Mask |= 0x20 + } // variable map header, size zb0001Len o = append(o, 0x80|uint8(zb0001Len)) if zb0001Len != 0 { @@ -2945,15 +2953,25 @@ func (z *HeartbeatTxnFields) MarshalMsg(b []byte) (o []byte) { o = (*z).HbAddress.MarshalMsg(o) } if (zb0001Mask & 0x4) == 0 { // if not empty + // string "hbkd" + o = append(o, 0xa4, 0x68, 0x62, 0x6b, 0x64) + o = msgp.AppendUint64(o, (*z).HbKeyDilution) + } + if (zb0001Mask & 0x8) == 0 { // if not empty // string "hbprf" o = append(o, 0xa5, 0x68, 0x62, 0x70, 0x72, 0x66) o = (*z).HbProof.MarshalMsg(o) } - if (zb0001Mask & 0x8) == 0 { // if not empty + if (zb0001Mask & 0x10) == 0 { // if not empty // string "hbsd" o = append(o, 0xa4, 0x68, 0x62, 0x73, 0x64) o = (*z).HbSeed.MarshalMsg(o) } + if (zb0001Mask & 0x20) == 0 { // if not empty + // string "hbvid" + o = append(o, 0xa5, 0x68, 0x62, 0x76, 0x69, 0x64) + o = (*z).HbVoteID.MarshalMsg(o) + } } return } @@ -3005,6 +3023,22 @@ func (z *HeartbeatTxnFields) UnmarshalMsgWithState(bts []byte, st msgp.Unmarshal return } } + if zb0001 > 0 { + zb0001-- + bts, err = (*z).HbVoteID.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbVoteID") + return + } + } + if zb0001 > 0 { + zb0001-- + (*z).HbKeyDilution, bts, err = msgp.ReadUint64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbKeyDilution") + return + } + } if zb0001 > 0 { err = msgp.ErrTooManyArrayFields(zb0001) if err != nil { @@ -3046,6 +3080,18 @@ func (z *HeartbeatTxnFields) UnmarshalMsgWithState(bts []byte, st msgp.Unmarshal err = msgp.WrapError(err, "HbSeed") return } + case "hbvid": + bts, err = (*z).HbVoteID.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbVoteID") + return + } + case "hbkd": + (*z).HbKeyDilution, bts, err = msgp.ReadUint64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "HbKeyDilution") + return + } default: err = msgp.ErrNoField(string(field)) if err != nil { @@ -3069,18 +3115,18 @@ func (_ *HeartbeatTxnFields) CanUnmarshalMsg(z interface{}) bool { // Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message func (z *HeartbeatTxnFields) Msgsize() (s int) { - s = 1 + 5 + (*z).HbAddress.Msgsize() + 6 + (*z).HbProof.Msgsize() + 5 + (*z).HbSeed.Msgsize() + s = 1 + 5 + (*z).HbAddress.Msgsize() + 6 + (*z).HbProof.Msgsize() + 5 + (*z).HbSeed.Msgsize() + 6 + (*z).HbVoteID.Msgsize() + 5 + msgp.Uint64Size return } // MsgIsZero returns whether this is a zero value func (z *HeartbeatTxnFields) MsgIsZero() bool { - return ((*z).HbAddress.MsgIsZero()) && ((*z).HbProof.MsgIsZero()) && ((*z).HbSeed.MsgIsZero()) + return ((*z).HbAddress.MsgIsZero()) && ((*z).HbProof.MsgIsZero()) && ((*z).HbSeed.MsgIsZero()) && ((*z).HbVoteID.MsgIsZero()) && ((*z).HbKeyDilution == 0) } // MaxSize returns a maximum valid message size for this message type func HeartbeatTxnFieldsMaxSize() (s int) { - s = 1 + 5 + basics.AddressMaxSize() + 6 + crypto.HeartbeatProofMaxSize() + 5 + committee.SeedMaxSize() + s = 1 + 5 + basics.AddressMaxSize() + 6 + crypto.HeartbeatProofMaxSize() + 5 + committee.SeedMaxSize() + 6 + crypto.OneTimeSignatureVerifierMaxSize() + 5 + msgp.Uint64Size return } @@ -5159,8 +5205,8 @@ func StateProofTxnFieldsMaxSize() (s int) { func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = msgp.Require(b, z.Msgsize()) // omitempty: check for empty values - zb0007Len := uint32(49) - var zb0007Mask uint64 /* 59 bits */ + zb0007Len := uint32(51) + var zb0007Mask uint64 /* 61 bits */ if (*z).AssetTransferTxnFields.AssetAmount == 0 { zb0007Len-- zb0007Mask |= 0x400 @@ -5277,86 +5323,94 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { zb0007Len-- zb0007Mask |= 0x4000000000 } - if (*z).HeartbeatTxnFields.HbProof.MsgIsZero() { + if (*z).HeartbeatTxnFields.HbKeyDilution == 0 { zb0007Len-- zb0007Mask |= 0x8000000000 } - if (*z).HeartbeatTxnFields.HbSeed.MsgIsZero() { + if (*z).HeartbeatTxnFields.HbProof.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x10000000000 } - if (*z).Header.LastValid.MsgIsZero() { + if (*z).HeartbeatTxnFields.HbSeed.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x20000000000 } - if (*z).Header.Lease == ([32]byte{}) { + if (*z).HeartbeatTxnFields.HbVoteID.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x40000000000 } - if (*z).KeyregTxnFields.Nonparticipation == false { + if (*z).Header.LastValid.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x80000000000 } - if len((*z).Header.Note) == 0 { + if (*z).Header.Lease == ([32]byte{}) { zb0007Len-- zb0007Mask |= 0x100000000000 } - if (*z).PaymentTxnFields.Receiver.MsgIsZero() { + if (*z).KeyregTxnFields.Nonparticipation == false { zb0007Len-- zb0007Mask |= 0x200000000000 } - if (*z).Header.RekeyTo.MsgIsZero() { + if len((*z).Header.Note) == 0 { zb0007Len-- zb0007Mask |= 0x400000000000 } - if (*z).KeyregTxnFields.SelectionPK.MsgIsZero() { + if (*z).PaymentTxnFields.Receiver.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x800000000000 } - if (*z).Header.Sender.MsgIsZero() { + if (*z).Header.RekeyTo.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x1000000000000 } - if (*z).StateProofTxnFields.StateProof.MsgIsZero() { + if (*z).KeyregTxnFields.SelectionPK.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x2000000000000 } - if (*z).StateProofTxnFields.Message.MsgIsZero() { + if (*z).Header.Sender.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x4000000000000 } - if (*z).KeyregTxnFields.StateProofPK.MsgIsZero() { + if (*z).StateProofTxnFields.StateProof.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x8000000000000 } - if (*z).StateProofTxnFields.StateProofType.MsgIsZero() { + if (*z).StateProofTxnFields.Message.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x10000000000000 } - if (*z).Type.MsgIsZero() { + if (*z).KeyregTxnFields.StateProofPK.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x20000000000000 } - if (*z).KeyregTxnFields.VoteFirst.MsgIsZero() { + if (*z).StateProofTxnFields.StateProofType.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x40000000000000 } - if (*z).KeyregTxnFields.VoteKeyDilution == 0 { + if (*z).Type.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x80000000000000 } - if (*z).KeyregTxnFields.VotePK.MsgIsZero() { + if (*z).KeyregTxnFields.VoteFirst.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x100000000000000 } - if (*z).KeyregTxnFields.VoteLast.MsgIsZero() { + if (*z).KeyregTxnFields.VoteKeyDilution == 0 { zb0007Len-- zb0007Mask |= 0x200000000000000 } - if (*z).AssetTransferTxnFields.XferAsset.MsgIsZero() { + if (*z).KeyregTxnFields.VotePK.MsgIsZero() { zb0007Len-- zb0007Mask |= 0x400000000000000 } + if (*z).KeyregTxnFields.VoteLast.MsgIsZero() { + zb0007Len-- + zb0007Mask |= 0x800000000000000 + } + if (*z).AssetTransferTxnFields.XferAsset.MsgIsZero() { + zb0007Len-- + zb0007Mask |= 0x1000000000000000 + } // variable map header, size zb0007Len o = msgp.AppendMapHeader(o, zb0007Len) if zb0007Len != 0 { @@ -5563,101 +5617,111 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = (*z).HeartbeatTxnFields.HbAddress.MarshalMsg(o) } if (zb0007Mask & 0x8000000000) == 0 { // if not empty + // string "hbkd" + o = append(o, 0xa4, 0x68, 0x62, 0x6b, 0x64) + o = msgp.AppendUint64(o, (*z).HeartbeatTxnFields.HbKeyDilution) + } + if (zb0007Mask & 0x10000000000) == 0 { // if not empty // string "hbprf" o = append(o, 0xa5, 0x68, 0x62, 0x70, 0x72, 0x66) o = (*z).HeartbeatTxnFields.HbProof.MarshalMsg(o) } - if (zb0007Mask & 0x10000000000) == 0 { // if not empty + if (zb0007Mask & 0x20000000000) == 0 { // if not empty // string "hbsd" o = append(o, 0xa4, 0x68, 0x62, 0x73, 0x64) o = (*z).HeartbeatTxnFields.HbSeed.MarshalMsg(o) } - if (zb0007Mask & 0x20000000000) == 0 { // if not empty + if (zb0007Mask & 0x40000000000) == 0 { // if not empty + // string "hbvid" + o = append(o, 0xa5, 0x68, 0x62, 0x76, 0x69, 0x64) + o = (*z).HeartbeatTxnFields.HbVoteID.MarshalMsg(o) + } + if (zb0007Mask & 0x80000000000) == 0 { // if not empty // string "lv" o = append(o, 0xa2, 0x6c, 0x76) o = (*z).Header.LastValid.MarshalMsg(o) } - if (zb0007Mask & 0x40000000000) == 0 { // if not empty + if (zb0007Mask & 0x100000000000) == 0 { // if not empty // string "lx" o = append(o, 0xa2, 0x6c, 0x78) o = msgp.AppendBytes(o, ((*z).Header.Lease)[:]) } - if (zb0007Mask & 0x80000000000) == 0 { // if not empty + if (zb0007Mask & 0x200000000000) == 0 { // if not empty // string "nonpart" o = append(o, 0xa7, 0x6e, 0x6f, 0x6e, 0x70, 0x61, 0x72, 0x74) o = msgp.AppendBool(o, (*z).KeyregTxnFields.Nonparticipation) } - if (zb0007Mask & 0x100000000000) == 0 { // if not empty + if (zb0007Mask & 0x400000000000) == 0 { // if not empty // string "note" o = append(o, 0xa4, 0x6e, 0x6f, 0x74, 0x65) o = msgp.AppendBytes(o, (*z).Header.Note) } - if (zb0007Mask & 0x200000000000) == 0 { // if not empty + if (zb0007Mask & 0x800000000000) == 0 { // if not empty // string "rcv" o = append(o, 0xa3, 0x72, 0x63, 0x76) o = (*z).PaymentTxnFields.Receiver.MarshalMsg(o) } - if (zb0007Mask & 0x400000000000) == 0 { // if not empty + if (zb0007Mask & 0x1000000000000) == 0 { // if not empty // string "rekey" o = append(o, 0xa5, 0x72, 0x65, 0x6b, 0x65, 0x79) o = (*z).Header.RekeyTo.MarshalMsg(o) } - if (zb0007Mask & 0x800000000000) == 0 { // if not empty + if (zb0007Mask & 0x2000000000000) == 0 { // if not empty // string "selkey" o = append(o, 0xa6, 0x73, 0x65, 0x6c, 0x6b, 0x65, 0x79) o = (*z).KeyregTxnFields.SelectionPK.MarshalMsg(o) } - if (zb0007Mask & 0x1000000000000) == 0 { // if not empty + if (zb0007Mask & 0x4000000000000) == 0 { // if not empty // string "snd" o = append(o, 0xa3, 0x73, 0x6e, 0x64) o = (*z).Header.Sender.MarshalMsg(o) } - if (zb0007Mask & 0x2000000000000) == 0 { // if not empty + if (zb0007Mask & 0x8000000000000) == 0 { // if not empty // string "sp" o = append(o, 0xa2, 0x73, 0x70) o = (*z).StateProofTxnFields.StateProof.MarshalMsg(o) } - if (zb0007Mask & 0x4000000000000) == 0 { // if not empty + if (zb0007Mask & 0x10000000000000) == 0 { // if not empty // string "spmsg" o = append(o, 0xa5, 0x73, 0x70, 0x6d, 0x73, 0x67) o = (*z).StateProofTxnFields.Message.MarshalMsg(o) } - if (zb0007Mask & 0x8000000000000) == 0 { // if not empty + if (zb0007Mask & 0x20000000000000) == 0 { // if not empty // string "sprfkey" o = append(o, 0xa7, 0x73, 0x70, 0x72, 0x66, 0x6b, 0x65, 0x79) o = (*z).KeyregTxnFields.StateProofPK.MarshalMsg(o) } - if (zb0007Mask & 0x10000000000000) == 0 { // if not empty + if (zb0007Mask & 0x40000000000000) == 0 { // if not empty // string "sptype" o = append(o, 0xa6, 0x73, 0x70, 0x74, 0x79, 0x70, 0x65) o = (*z).StateProofTxnFields.StateProofType.MarshalMsg(o) } - if (zb0007Mask & 0x20000000000000) == 0 { // if not empty + if (zb0007Mask & 0x80000000000000) == 0 { // if not empty // string "type" o = append(o, 0xa4, 0x74, 0x79, 0x70, 0x65) o = (*z).Type.MarshalMsg(o) } - if (zb0007Mask & 0x40000000000000) == 0 { // if not empty + if (zb0007Mask & 0x100000000000000) == 0 { // if not empty // string "votefst" o = append(o, 0xa7, 0x76, 0x6f, 0x74, 0x65, 0x66, 0x73, 0x74) o = (*z).KeyregTxnFields.VoteFirst.MarshalMsg(o) } - if (zb0007Mask & 0x80000000000000) == 0 { // if not empty + if (zb0007Mask & 0x200000000000000) == 0 { // if not empty // string "votekd" o = append(o, 0xa6, 0x76, 0x6f, 0x74, 0x65, 0x6b, 0x64) o = msgp.AppendUint64(o, (*z).KeyregTxnFields.VoteKeyDilution) } - if (zb0007Mask & 0x100000000000000) == 0 { // if not empty + if (zb0007Mask & 0x400000000000000) == 0 { // if not empty // string "votekey" o = append(o, 0xa7, 0x76, 0x6f, 0x74, 0x65, 0x6b, 0x65, 0x79) o = (*z).KeyregTxnFields.VotePK.MarshalMsg(o) } - if (zb0007Mask & 0x200000000000000) == 0 { // if not empty + if (zb0007Mask & 0x800000000000000) == 0 { // if not empty // string "votelst" o = append(o, 0xa7, 0x76, 0x6f, 0x74, 0x65, 0x6c, 0x73, 0x74) o = (*z).KeyregTxnFields.VoteLast.MarshalMsg(o) } - if (zb0007Mask & 0x400000000000000) == 0 { // if not empty + if (zb0007Mask & 0x1000000000000000) == 0 { // if not empty // string "xaid" o = append(o, 0xa4, 0x78, 0x61, 0x69, 0x64) o = (*z).AssetTransferTxnFields.XferAsset.MarshalMsg(o) @@ -6314,6 +6378,22 @@ func (z *Transaction) UnmarshalMsgWithState(bts []byte, st msgp.UnmarshalState) return } } + if zb0007 > 0 { + zb0007-- + bts, err = (*z).HeartbeatTxnFields.HbVoteID.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbVoteID") + return + } + } + if zb0007 > 0 { + zb0007-- + (*z).HeartbeatTxnFields.HbKeyDilution, bts, err = msgp.ReadUint64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbKeyDilution") + return + } + } if zb0007 > 0 { err = msgp.ErrTooManyArrayFields(zb0007) if err != nil { @@ -6864,6 +6944,18 @@ func (z *Transaction) UnmarshalMsgWithState(bts []byte, st msgp.UnmarshalState) err = msgp.WrapError(err, "HbSeed") return } + case "hbvid": + bts, err = (*z).HeartbeatTxnFields.HbVoteID.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbVoteID") + return + } + case "hbkd": + (*z).HeartbeatTxnFields.HbKeyDilution, bts, err = msgp.ReadUint64Bytes(bts) + if err != nil { + err = msgp.WrapError(err, "HbKeyDilution") + return + } default: err = msgp.ErrNoField(string(field)) if err != nil { @@ -6907,13 +6999,13 @@ func (z *Transaction) Msgsize() (s int) { for zb0006 := range (*z).ApplicationCallTxnFields.ForeignAssets { s += (*z).ApplicationCallTxnFields.ForeignAssets[zb0006].Msgsize() } - s += 5 + (*z).ApplicationCallTxnFields.LocalStateSchema.Msgsize() + 5 + (*z).ApplicationCallTxnFields.GlobalStateSchema.Msgsize() + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ApprovalProgram) + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ClearStateProgram) + 5 + msgp.Uint32Size + 7 + (*z).StateProofTxnFields.StateProofType.Msgsize() + 3 + (*z).StateProofTxnFields.StateProof.Msgsize() + 6 + (*z).StateProofTxnFields.Message.Msgsize() + 5 + (*z).HeartbeatTxnFields.HbAddress.Msgsize() + 6 + (*z).HeartbeatTxnFields.HbProof.Msgsize() + 5 + (*z).HeartbeatTxnFields.HbSeed.Msgsize() + s += 5 + (*z).ApplicationCallTxnFields.LocalStateSchema.Msgsize() + 5 + (*z).ApplicationCallTxnFields.GlobalStateSchema.Msgsize() + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ApprovalProgram) + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ClearStateProgram) + 5 + msgp.Uint32Size + 7 + (*z).StateProofTxnFields.StateProofType.Msgsize() + 3 + (*z).StateProofTxnFields.StateProof.Msgsize() + 6 + (*z).StateProofTxnFields.Message.Msgsize() + 5 + (*z).HeartbeatTxnFields.HbAddress.Msgsize() + 6 + (*z).HeartbeatTxnFields.HbProof.Msgsize() + 5 + (*z).HeartbeatTxnFields.HbSeed.Msgsize() + 6 + (*z).HeartbeatTxnFields.HbVoteID.Msgsize() + 5 + msgp.Uint64Size return } // MsgIsZero returns whether this is a zero value func (z *Transaction) MsgIsZero() bool { - return ((*z).Type.MsgIsZero()) && ((*z).Header.Sender.MsgIsZero()) && ((*z).Header.Fee.MsgIsZero()) && ((*z).Header.FirstValid.MsgIsZero()) && ((*z).Header.LastValid.MsgIsZero()) && (len((*z).Header.Note) == 0) && ((*z).Header.GenesisID == "") && ((*z).Header.GenesisHash.MsgIsZero()) && ((*z).Header.Group.MsgIsZero()) && ((*z).Header.Lease == ([32]byte{})) && ((*z).Header.RekeyTo.MsgIsZero()) && ((*z).KeyregTxnFields.VotePK.MsgIsZero()) && ((*z).KeyregTxnFields.SelectionPK.MsgIsZero()) && ((*z).KeyregTxnFields.StateProofPK.MsgIsZero()) && ((*z).KeyregTxnFields.VoteFirst.MsgIsZero()) && ((*z).KeyregTxnFields.VoteLast.MsgIsZero()) && ((*z).KeyregTxnFields.VoteKeyDilution == 0) && ((*z).KeyregTxnFields.Nonparticipation == false) && ((*z).PaymentTxnFields.Receiver.MsgIsZero()) && ((*z).PaymentTxnFields.Amount.MsgIsZero()) && ((*z).PaymentTxnFields.CloseRemainderTo.MsgIsZero()) && ((*z).AssetConfigTxnFields.ConfigAsset.MsgIsZero()) && ((*z).AssetConfigTxnFields.AssetParams.MsgIsZero()) && ((*z).AssetTransferTxnFields.XferAsset.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetAmount == 0) && ((*z).AssetTransferTxnFields.AssetSender.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetReceiver.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetCloseTo.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAccount.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAsset.MsgIsZero()) && ((*z).AssetFreezeTxnFields.AssetFrozen == false) && ((*z).ApplicationCallTxnFields.ApplicationID.MsgIsZero()) && ((*z).ApplicationCallTxnFields.OnCompletion == 0) && (len((*z).ApplicationCallTxnFields.ApplicationArgs) == 0) && (len((*z).ApplicationCallTxnFields.Accounts) == 0) && (len((*z).ApplicationCallTxnFields.ForeignApps) == 0) && (len((*z).ApplicationCallTxnFields.Boxes) == 0) && (len((*z).ApplicationCallTxnFields.ForeignAssets) == 0) && ((*z).ApplicationCallTxnFields.LocalStateSchema.MsgIsZero()) && ((*z).ApplicationCallTxnFields.GlobalStateSchema.MsgIsZero()) && (len((*z).ApplicationCallTxnFields.ApprovalProgram) == 0) && (len((*z).ApplicationCallTxnFields.ClearStateProgram) == 0) && ((*z).ApplicationCallTxnFields.ExtraProgramPages == 0) && ((*z).StateProofTxnFields.StateProofType.MsgIsZero()) && ((*z).StateProofTxnFields.StateProof.MsgIsZero()) && ((*z).StateProofTxnFields.Message.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbAddress.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbProof.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbSeed.MsgIsZero()) + return ((*z).Type.MsgIsZero()) && ((*z).Header.Sender.MsgIsZero()) && ((*z).Header.Fee.MsgIsZero()) && ((*z).Header.FirstValid.MsgIsZero()) && ((*z).Header.LastValid.MsgIsZero()) && (len((*z).Header.Note) == 0) && ((*z).Header.GenesisID == "") && ((*z).Header.GenesisHash.MsgIsZero()) && ((*z).Header.Group.MsgIsZero()) && ((*z).Header.Lease == ([32]byte{})) && ((*z).Header.RekeyTo.MsgIsZero()) && ((*z).KeyregTxnFields.VotePK.MsgIsZero()) && ((*z).KeyregTxnFields.SelectionPK.MsgIsZero()) && ((*z).KeyregTxnFields.StateProofPK.MsgIsZero()) && ((*z).KeyregTxnFields.VoteFirst.MsgIsZero()) && ((*z).KeyregTxnFields.VoteLast.MsgIsZero()) && ((*z).KeyregTxnFields.VoteKeyDilution == 0) && ((*z).KeyregTxnFields.Nonparticipation == false) && ((*z).PaymentTxnFields.Receiver.MsgIsZero()) && ((*z).PaymentTxnFields.Amount.MsgIsZero()) && ((*z).PaymentTxnFields.CloseRemainderTo.MsgIsZero()) && ((*z).AssetConfigTxnFields.ConfigAsset.MsgIsZero()) && ((*z).AssetConfigTxnFields.AssetParams.MsgIsZero()) && ((*z).AssetTransferTxnFields.XferAsset.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetAmount == 0) && ((*z).AssetTransferTxnFields.AssetSender.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetReceiver.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetCloseTo.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAccount.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAsset.MsgIsZero()) && ((*z).AssetFreezeTxnFields.AssetFrozen == false) && ((*z).ApplicationCallTxnFields.ApplicationID.MsgIsZero()) && ((*z).ApplicationCallTxnFields.OnCompletion == 0) && (len((*z).ApplicationCallTxnFields.ApplicationArgs) == 0) && (len((*z).ApplicationCallTxnFields.Accounts) == 0) && (len((*z).ApplicationCallTxnFields.ForeignApps) == 0) && (len((*z).ApplicationCallTxnFields.Boxes) == 0) && (len((*z).ApplicationCallTxnFields.ForeignAssets) == 0) && ((*z).ApplicationCallTxnFields.LocalStateSchema.MsgIsZero()) && ((*z).ApplicationCallTxnFields.GlobalStateSchema.MsgIsZero()) && (len((*z).ApplicationCallTxnFields.ApprovalProgram) == 0) && (len((*z).ApplicationCallTxnFields.ClearStateProgram) == 0) && ((*z).ApplicationCallTxnFields.ExtraProgramPages == 0) && ((*z).StateProofTxnFields.StateProofType.MsgIsZero()) && ((*z).StateProofTxnFields.StateProof.MsgIsZero()) && ((*z).StateProofTxnFields.Message.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbAddress.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbProof.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbSeed.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbVoteID.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbKeyDilution == 0) } // MaxSize returns a maximum valid message size for this message type @@ -6935,7 +7027,7 @@ func TransactionMaxSize() (s int) { s += 5 // Calculating size of slice: z.ApplicationCallTxnFields.ForeignAssets s += msgp.ArrayHeaderSize + ((encodedMaxForeignAssets) * (basics.AssetIndexMaxSize())) - s += 5 + basics.StateSchemaMaxSize() + 5 + basics.StateSchemaMaxSize() + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.Uint32Size + 7 + protocol.StateProofTypeMaxSize() + 3 + stateproof.StateProofMaxSize() + 6 + stateproofmsg.MessageMaxSize() + 5 + basics.AddressMaxSize() + 6 + crypto.HeartbeatProofMaxSize() + 5 + committee.SeedMaxSize() + s += 5 + basics.StateSchemaMaxSize() + 5 + basics.StateSchemaMaxSize() + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.Uint32Size + 7 + protocol.StateProofTypeMaxSize() + 3 + stateproof.StateProofMaxSize() + 6 + stateproofmsg.MessageMaxSize() + 5 + basics.AddressMaxSize() + 6 + crypto.HeartbeatProofMaxSize() + 5 + committee.SeedMaxSize() + 6 + crypto.OneTimeSignatureVerifierMaxSize() + 5 + msgp.Uint64Size return } diff --git a/data/transactions/transaction.go b/data/transactions/transaction.go index 83f9bc6f62..cd7aab7373 100644 --- a/data/transactions/transaction.go +++ b/data/transactions/transaction.go @@ -27,6 +27,7 @@ import ( "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/crypto" "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/committee" "github.com/algorand/go-algorand/protocol" ) @@ -571,6 +572,37 @@ func (tx Transaction) WellFormed(spec SpecialAddresses, proto config.ConsensusPa return fmt.Errorf("heartbeat transaction not supported") } + // If this is a free/cheap heartbeat, it must be very simple. + if tx.Fee.Raw < proto.MinTxnFee && tx.Group.IsZero() { + kind := "free" + if tx.Fee.Raw > 0 { + kind = "cheap" + } + + if len(tx.Note) > 0 { + return fmt.Errorf("tx.Note is set in %s heartbeat", kind) + } + if tx.Lease != [32]byte{} { + return fmt.Errorf("tx.Lease is set in %s heartbeat", kind) + } + if !tx.RekeyTo.IsZero() { + return fmt.Errorf("tx.RekeyTo is set in %s heartbeat", kind) + } + } + + if (tx.HbProof == crypto.HeartbeatProof{}) { + return errors.New("tx.HbProof is empty") + } + if (tx.HbSeed == committee.Seed{}) { + return errors.New("tx.HbSeed is empty") + } + if tx.HbVoteID.IsEmpty() { + return errors.New("tx.HbVoteID is empty") + } + if tx.HbKeyDilution == 0 { + return errors.New("tx.HbKeyDilution is zero") + } + default: return fmt.Errorf("unknown tx type %v", tx.Type) } diff --git a/data/transactions/transaction_test.go b/data/transactions/transaction_test.go index 1dbb2e316a..6218c9820e 100644 --- a/data/transactions/transaction_test.go +++ b/data/transactions/transaction_test.go @@ -22,6 +22,7 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/algorand/go-algorand/config" @@ -29,6 +30,7 @@ import ( "github.com/algorand/go-algorand/crypto/merklesignature" "github.com/algorand/go-algorand/crypto/stateproof" "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/committee" "github.com/algorand/go-algorand/data/stateproofmsg" "github.com/algorand/go-algorand/protocol" "github.com/algorand/go-algorand/test/partitiontest" @@ -603,13 +605,144 @@ func TestWellFormedErrors(t *testing.T) { tx: Transaction{ Type: protocol.HeartbeatTx, Header: okHeader, + HeartbeatTxnFields: HeartbeatTxnFields{ + HbSeed: committee.Seed{0x02}, + HbVoteID: crypto.OneTimeSignatureVerifier{0x03}, + HbKeyDilution: 10, + }, + }, + proto: futureProto, + expectedError: fmt.Errorf("tx.HbProof is empty"), + }, + { + tx: Transaction{ + Type: protocol.HeartbeatTx, + Header: okHeader, + HeartbeatTxnFields: HeartbeatTxnFields{ + HbProof: crypto.HeartbeatProof{ + Sig: [64]byte{0x01}, + }, + HbVoteID: crypto.OneTimeSignatureVerifier{0x03}, + HbKeyDilution: 10, + }, + }, + proto: futureProto, + expectedError: fmt.Errorf("tx.HbSeed is empty"), + }, + { + tx: Transaction{ + Type: protocol.HeartbeatTx, + Header: okHeader, + HeartbeatTxnFields: HeartbeatTxnFields{ + HbProof: crypto.HeartbeatProof{ + Sig: [64]byte{0x01}, + }, + HbSeed: committee.Seed{0x02}, + HbKeyDilution: 10, + }, + }, + proto: futureProto, + expectedError: fmt.Errorf("tx.HbVoteID is empty"), + }, + { + tx: Transaction{ + Type: protocol.HeartbeatTx, + Header: okHeader, + HeartbeatTxnFields: HeartbeatTxnFields{ + HbProof: crypto.HeartbeatProof{ + Sig: [64]byte{0x01}, + }, + HbSeed: committee.Seed{0x02}, + HbVoteID: crypto.OneTimeSignatureVerifier{0x03}, + }, + }, + proto: futureProto, + expectedError: fmt.Errorf("tx.HbKeyDilution is zero"), + }, + { + tx: Transaction{ + Type: protocol.HeartbeatTx, + Header: okHeader, + HeartbeatTxnFields: HeartbeatTxnFields{ + HbProof: crypto.HeartbeatProof{ + Sig: [64]byte{0x01}, + }, + HbSeed: committee.Seed{0x02}, + HbVoteID: crypto.OneTimeSignatureVerifier{0x03}, + HbKeyDilution: 10, + }, }, proto: futureProto, }, + { + tx: Transaction{ + Type: protocol.HeartbeatTx, + Header: Header{ + Sender: addr1, + Fee: basics.MicroAlgos{Raw: 100}, + LastValid: 105, + FirstValid: 100, + Note: []byte{0x01}, + }, + HeartbeatTxnFields: HeartbeatTxnFields{ + HbProof: crypto.HeartbeatProof{ + Sig: [64]byte{0x01}, + }, + HbSeed: committee.Seed{0x02}, + HbVoteID: crypto.OneTimeSignatureVerifier{0x03}, + HbKeyDilution: 10, + }, + }, + proto: futureProto, + expectedError: fmt.Errorf("tx.Note is set in cheap heartbeat"), + }, + { + tx: Transaction{ + Type: protocol.HeartbeatTx, + Header: Header{ + Sender: addr1, + Fee: basics.MicroAlgos{Raw: 100}, + LastValid: 105, + FirstValid: 100, + Lease: [32]byte{0x01}, + }, + HeartbeatTxnFields: HeartbeatTxnFields{ + HbProof: crypto.HeartbeatProof{ + Sig: [64]byte{0x01}, + }, + HbSeed: committee.Seed{0x02}, + HbVoteID: crypto.OneTimeSignatureVerifier{0x03}, + HbKeyDilution: 10, + }, + }, + proto: futureProto, + expectedError: fmt.Errorf("tx.Lease is set in cheap heartbeat"), + }, + { + tx: Transaction{ + Type: protocol.HeartbeatTx, + Header: Header{ + Sender: addr1, + LastValid: 105, + FirstValid: 100, + RekeyTo: [32]byte{0x01}, + }, + HeartbeatTxnFields: HeartbeatTxnFields{ + HbProof: crypto.HeartbeatProof{ + Sig: [64]byte{0x01}, + }, + HbSeed: committee.Seed{0x02}, + HbVoteID: crypto.OneTimeSignatureVerifier{0x03}, + HbKeyDilution: 10, + }, + }, + proto: futureProto, + expectedError: fmt.Errorf("tx.RekeyTo is set in free heartbeat"), + }, } for _, usecase := range usecases { err := usecase.tx.WellFormed(SpecialAddresses{}, usecase.proto) - require.Equal(t, usecase.expectedError, err) + assert.Equal(t, usecase.expectedError, err) } } diff --git a/data/transactions/verify/txn.go b/data/transactions/verify/txn.go index 7862d71b89..d5a9f68a97 100644 --- a/data/transactions/verify/txn.go +++ b/data/transactions/verify/txn.go @@ -310,6 +310,15 @@ func stxnCoreChecks(gi int, groupCtx *GroupContext, batchVerifier crypto.BatchVe return err } + if s.Txn.Type == protocol.HeartbeatTx { + id := basics.OneTimeIDForRound(s.Txn.LastValid, s.Txn.HbKeyDilution) + offsetID := crypto.OneTimeSignatureSubkeyOffsetID{SubKeyPK: s.Txn.HbProof.PK, Batch: id.Batch, Offset: id.Offset} + batchID := crypto.OneTimeSignatureSubkeyBatchID{SubKeyPK: s.Txn.HbProof.PK2, Batch: id.Batch} + batchVerifier.EnqueueSignature(crypto.PublicKey(s.Txn.HbVoteID), batchID, crypto.Signature(s.Txn.HbProof.PK2Sig)) + batchVerifier.EnqueueSignature(crypto.PublicKey(batchID.SubKeyPK), offsetID, crypto.Signature(s.Txn.HbProof.PK1Sig)) + batchVerifier.EnqueueSignature(crypto.PublicKey(offsetID.SubKeyPK), s.Txn.HbSeed, crypto.Signature(s.Txn.HbProof.Sig)) + } + switch sigType { case regularSig: batchVerifier.EnqueueSignature(crypto.SignatureVerifier(s.Authorizer()), s.Txn, s.Sig) diff --git a/data/transactions/verify/txn_test.go b/data/transactions/verify/txn_test.go index 7320151565..96e58118c3 100644 --- a/data/transactions/verify/txn_test.go +++ b/data/transactions/verify/txn_test.go @@ -30,6 +30,7 @@ import ( "github.com/algorand/go-algorand/crypto" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/committee" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/data/transactions/logic" "github.com/algorand/go-algorand/data/transactions/logic/mocktracer" @@ -94,6 +95,38 @@ func keypair() *crypto.SignatureSecrets { return s } +func createHeartbeatTxn(fv basics.Round, t *testing.T) transactions.SignedTxn { + secrets, addrs, _ := generateAccounts(1) + + kd := uint64(111) + lv := fv + 15 + id := basics.OneTimeIDForRound(lv, kd) + + seed := committee.Seed{0x33} + otss := crypto.GenerateOneTimeSignatureSecrets(0, kd) + + txn := transactions.Transaction{ + Type: "hb", + Header: transactions.Header{ + Sender: addrs[0], + FirstValid: fv, + LastValid: lv, + }, + HeartbeatTxnFields: transactions.HeartbeatTxnFields{ + HbProof: otss.Sign(id, seed).ToHeartbeatProof(), + HbSeed: seed, + HbVoteID: otss.OneTimeSignatureVerifier, + HbKeyDilution: kd, + }, + } + + hb := transactions.SignedTxn{ + Sig: secrets[0].Sign(txn), + Txn: txn, + } + return hb +} + func generateMultiSigTxn(numTxs, numAccs int, blockRound basics.Round, t *testing.T) ([]transactions.Transaction, []transactions.SignedTxn, []*crypto.SignatureSecrets, []basics.Address) { secrets, addresses, pks, multiAddress := generateMultiSigAccounts(t, numAccs) @@ -864,6 +897,38 @@ func TestTxnGroupCacheUpdateMultiSig(t *testing.T) { verifyGroup(t, txnGroups, &blkHdr, breakSignatureFunc, restoreSignatureFunc, crypto.ErrBatchHasFailedSigs.Error()) } +// TestTxnHeartbeat makes sure that a heartbeat transaction is valid (and added +// to the cache) only if the normal outer signature is valid AND the inner +// HbProof is valid. +func TestTxnHeartbeat(t *testing.T) { + partitiontest.PartitionTest(t) + + blkHdr := createDummyBlockHeader(protocol.ConsensusFuture) + + txnGroups := make([][]transactions.SignedTxn, 2) // verifyGroup requires at least 2 + for i := 0; i < len(txnGroups); i++ { + txnGroups[i] = make([]transactions.SignedTxn, 1) + txnGroups[i][0] = createHeartbeatTxn(blkHdr.Round-1, t) + } + breakSignatureFunc := func(txn *transactions.SignedTxn) { + txn.Sig[0]++ + } + restoreSignatureFunc := func(txn *transactions.SignedTxn) { + txn.Sig[0]-- + } + // This shows the outer signature must be correct + verifyGroup(t, txnGroups, &blkHdr, breakSignatureFunc, restoreSignatureFunc, crypto.ErrBatchHasFailedSigs.Error()) + + breakHbProofFunc := func(txn *transactions.SignedTxn) { + txn.Txn.HeartbeatTxnFields.HbProof.Sig[0]++ + } + restoreHbProofFunc := func(txn *transactions.SignedTxn) { + txn.Txn.HeartbeatTxnFields.HbProof.Sig[0]-- + } + // This shows the inner signature must be correct + verifyGroup(t, txnGroups, &blkHdr, breakHbProofFunc, restoreHbProofFunc, crypto.ErrBatchHasFailedSigs.Error()) +} + // TestTxnGroupCacheUpdateFailLogic test makes sure that a payment transaction contains a logic (and no signature) // is valid (and added to the cache) only if logic passes func TestTxnGroupCacheUpdateFailLogic(t *testing.T) { @@ -1028,12 +1093,18 @@ byte base64 5rZMNsevs5sULO+54aN+OvU6lQ503z2X+SSYUABIx7E= verifyGroup(t, txnGroups, &blkHdr, breakSignatureFunc, restoreSignatureFunc, "rejected by logic") } -func createDummyBlockHeader() bookkeeping.BlockHeader { +func createDummyBlockHeader(optVer ...protocol.ConsensusVersion) bookkeeping.BlockHeader { + // Most tests in this file were written to use current. Future is probably + // the better test, but I don't want to make that choice now, so optVer. + proto := protocol.ConsensusCurrentVersion + if len(optVer) > 0 { + proto = optVer[0] + } return bookkeeping.BlockHeader{ Round: 50, GenesisHash: crypto.Hash([]byte{1, 2, 3, 4, 5}), UpgradeState: bookkeeping.UpgradeState{ - CurrentProtocol: protocol.ConsensusCurrentVersion, + CurrentProtocol: proto, }, RewardsState: bookkeeping.RewardsState{ FeeSink: feeSink, @@ -1067,32 +1138,32 @@ func verifyGroup(t *testing.T, txnGroups [][]transactions.SignedTxn, blkHdr *boo breakSig(&txnGroups[0][0]) - dummeyLedger := DummyLedgerForSignature{} - _, err := TxnGroup(txnGroups[0], blkHdr, cache, &dummeyLedger) + dummyLedger := DummyLedgerForSignature{} + _, err := TxnGroup(txnGroups[0], blkHdr, cache, &dummyLedger) require.Error(t, err) require.Contains(t, err.Error(), errorString) // The txns should not be in the cache - unverifiedGroups := cache.GetUnverifiedTransactionGroups(txnGroups[:1], spec, protocol.ConsensusCurrentVersion) + unverifiedGroups := cache.GetUnverifiedTransactionGroups(txnGroups[:1], spec, blkHdr.CurrentProtocol) require.Len(t, unverifiedGroups, 1) - unverifiedGroups = cache.GetUnverifiedTransactionGroups(txnGroups[:2], spec, protocol.ConsensusCurrentVersion) + unverifiedGroups = cache.GetUnverifiedTransactionGroups(txnGroups[:2], spec, blkHdr.CurrentProtocol) require.Len(t, unverifiedGroups, 2) - _, err = TxnGroup(txnGroups[1], blkHdr, cache, &dummeyLedger) + _, err = TxnGroup(txnGroups[1], blkHdr, cache, &dummyLedger) require.NoError(t, err) // Only the second txn should be in the cache - unverifiedGroups = cache.GetUnverifiedTransactionGroups(txnGroups[:2], spec, protocol.ConsensusCurrentVersion) + unverifiedGroups = cache.GetUnverifiedTransactionGroups(txnGroups[:2], spec, blkHdr.CurrentProtocol) require.Len(t, unverifiedGroups, 1) restoreSig(&txnGroups[0][0]) - _, err = TxnGroup(txnGroups[0], blkHdr, cache, &dummeyLedger) + _, err = TxnGroup(txnGroups[0], blkHdr, cache, &dummyLedger) require.NoError(t, err) // Both transactions should be in the cache - unverifiedGroups = cache.GetUnverifiedTransactionGroups(txnGroups[:2], spec, protocol.ConsensusCurrentVersion) + unverifiedGroups = cache.GetUnverifiedTransactionGroups(txnGroups[:2], spec, blkHdr.CurrentProtocol) require.Len(t, unverifiedGroups, 0) cache = MakeVerifiedTransactionCache(1000) @@ -1105,7 +1176,7 @@ func verifyGroup(t *testing.T, txnGroups [][]transactions.SignedTxn, blkHdr *boo // Add them to the cache by verifying them for _, txng := range txnGroups { - _, err = TxnGroup(txng, blkHdr, cache, &dummeyLedger) + _, err = TxnGroup(txng, blkHdr, cache, &dummyLedger) if err != nil { require.Error(t, err) require.Contains(t, err.Error(), errorString) @@ -1115,7 +1186,7 @@ func verifyGroup(t *testing.T, txnGroups [][]transactions.SignedTxn, blkHdr *boo require.Equal(t, 1, numFailed) // Only one transaction should not be in cache - unverifiedGroups = cache.GetUnverifiedTransactionGroups(txnGroups, spec, protocol.ConsensusCurrentVersion) + unverifiedGroups = cache.GetUnverifiedTransactionGroups(txnGroups, spec, blkHdr.CurrentProtocol) require.Len(t, unverifiedGroups, 1) require.Equal(t, unverifiedGroups[0], txnGroups[txgIdx]) diff --git a/data/txntest/txn.go b/data/txntest/txn.go index d734f47576..5c6baaba6b 100644 --- a/data/txntest/txn.go +++ b/data/txntest/txn.go @@ -93,9 +93,11 @@ type Txn struct { StateProof stateproof.StateProof StateProofMsg stateproofmsg.Message - HbAddress basics.Address - HbProof crypto.HeartbeatProof - HbSeed committee.Seed + HbAddress basics.Address + HbProof crypto.HeartbeatProof + HbSeed committee.Seed + HbVoteID crypto.OneTimeSignatureVerifier + HbKeyDilution uint64 } // internalCopy "finishes" a shallow copy done by a simple Go assignment by @@ -287,9 +289,11 @@ func (tx Txn) Txn() transactions.Transaction { Message: tx.StateProofMsg, }, HeartbeatTxnFields: transactions.HeartbeatTxnFields{ - HbAddress: tx.HbAddress, - HbProof: tx.HbProof, - HbSeed: tx.HbSeed, + HbAddress: tx.HbAddress, + HbProof: tx.HbProof, + HbSeed: tx.HbSeed, + HbVoteID: tx.HbVoteID, + HbKeyDilution: tx.HbKeyDilution, }, } } diff --git a/heartbeat/service.go b/heartbeat/service.go index 655fab59a9..d915fa3399 100644 --- a/heartbeat/service.go +++ b/heartbeat/service.go @@ -181,9 +181,11 @@ func (s *Service) prepareHeartbeat(pr account.ParticipationRecordForRound, lates id := basics.OneTimeIDForRound(stxn.Txn.LastValid, pr.KeyDilution) stxn.Txn.HeartbeatTxnFields = transactions.HeartbeatTxnFields{ - HbAddress: pr.Account, - HbProof: pr.Voting.Sign(id, latest.Seed).ToHeartbeatProof(), - HbSeed: latest.Seed, + HbAddress: pr.Account, + HbProof: pr.Voting.Sign(id, latest.Seed).ToHeartbeatProof(), + HbSeed: latest.Seed, + HbVoteID: pr.Voting.OneTimeSignatureVerifier, + HbKeyDilution: pr.KeyDilution, } return stxn diff --git a/heartbeat/service_test.go b/heartbeat/service_test.go index 5ee518616c..bdd06e62df 100644 --- a/heartbeat/service_test.go +++ b/heartbeat/service_test.go @@ -87,7 +87,10 @@ func (l *mockedLedger) WaitMem(r basics.Round) chan struct{} { // BlockHdr allows the service access to consensus values func (l *mockedLedger) BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) { - if r > l.LastRound() { + l.mu.Lock() + defer l.mu.Unlock() + + if r > l.lastRound() { return bookkeeping.BlockHeader{}, fmt.Errorf("%d is beyond current block (%d)", r, l.LastRound()) } // return the template hdr, with round @@ -96,6 +99,14 @@ func (l *mockedLedger) BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) return hdr, nil } +// setSeed allows the mock to return a specific seed +func (l *mockedLedger) setSeed(seed committee.Seed) { + l.mu.Lock() + defer l.mu.Unlock() + + l.hdr.Seed = seed +} + func (l *mockedLedger) addBlock(delta table) error { l.mu.Lock() defer l.mu.Unlock() @@ -232,8 +243,8 @@ func TestHeartbeatOnlyWhenChallenged(t *testing.T) { // now we have to make it seem like joe has been challenged. We obtain the // payout rules to find the first challenge round, skip forward to it, then // go forward half a grace period. Only then should the service heartbeat + ledger.setSeed(committee.Seed{0xc8}) // share 5 bits with 0xcc hdr, err := ledger.BlockHdr(ledger.LastRound()) - ledger.hdr.Seed = committee.Seed{0xc8} // share 5 bits with 0xcc a.NoError(err) rules := config.Consensus[hdr.CurrentProtocol].Payouts for ledger.LastRound() < basics.Round(rules.ChallengeInterval+rules.ChallengeGracePeriod/2) { diff --git a/ledger/apply/apply.go b/ledger/apply/apply.go index ecc96c967f..5bbe482f38 100644 --- a/ledger/apply/apply.go +++ b/ledger/apply/apply.go @@ -25,14 +25,14 @@ import ( "github.com/algorand/go-algorand/ledger/ledgercore" ) -// HdrProvider allows fetching old block headers -type HdrProvider interface { +// hdrProvider allows fetching old block headers +type hdrProvider interface { BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) } // StateProofsApplier allows fetching and updating state-proofs state on the ledger type StateProofsApplier interface { - HdrProvider + hdrProvider GetStateProofNextRound() basics.Round SetStateProofNextRound(rnd basics.Round) GetStateProofVerificationContext(stateProofLastAttestedRound basics.Round) (*ledgercore.StateProofVerificationContext, error) diff --git a/ledger/apply/challenge.go b/ledger/apply/challenge.go index 6dc5fb1a2b..18dadae894 100644 --- a/ledger/apply/challenge.go +++ b/ledger/apply/challenge.go @@ -21,7 +21,6 @@ import ( "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/data/basics" - "github.com/algorand/go-algorand/data/bookkeeping" "github.com/algorand/go-algorand/data/committee" ) @@ -44,12 +43,8 @@ type challenge struct { bits int } -type headerSource interface { - BlockHdr(round basics.Round) (bookkeeping.BlockHeader, error) -} - // FindChallenge returns the Challenge that was last issued if it's in the period requested. -func FindChallenge(rules config.ProposerPayoutRules, current basics.Round, headers headerSource, period ChallengePeriod) challenge { +func FindChallenge(rules config.ProposerPayoutRules, current basics.Round, headers hdrProvider, period ChallengePeriod) challenge { // are challenges active? interval := basics.Round(rules.ChallengeInterval) if rules.ChallengeInterval == 0 || current < interval { diff --git a/ledger/apply/heartbeat.go b/ledger/apply/heartbeat.go index 806c79e2cf..a37c8238a4 100644 --- a/ledger/apply/heartbeat.go +++ b/ledger/apply/heartbeat.go @@ -24,7 +24,7 @@ import ( ) // Heartbeat applies a Heartbeat transaction using the Balances interface. -func Heartbeat(hb transactions.HeartbeatTxnFields, header transactions.Header, balances Balances, provider HdrProvider, round basics.Round) error { +func Heartbeat(hb transactions.HeartbeatTxnFields, header transactions.Header, balances Balances, provider hdrProvider, round basics.Round) error { // Get the account's balance entry account, err := balances.Get(hb.HbAddress, false) if err != nil { @@ -43,18 +43,6 @@ func Heartbeat(hb transactions.HeartbeatTxnFields, header transactions.Header, b kind = "cheap" } - // These first checks are a little draconian. Don't let these free - // transactions do anything except their exact intended purpose. - if len(header.Note) > 0 { - return fmt.Errorf("%s heartbeat is not allowed to have a note", kind) - } - if header.Lease != [32]byte{} { - return fmt.Errorf("%s heartbeat is not allowed to have a lease", kind) - } - if !header.RekeyTo.IsZero() { - return fmt.Errorf("%s heartbeat is not allowed to rekey", kind) - } - if account.Status != basics.Online { return fmt.Errorf("%s heartbeat is not allowed for %s %+v", kind, account.Status, hb.HbAddress) } @@ -70,33 +58,18 @@ func Heartbeat(hb transactions.HeartbeatTxnFields, header transactions.Header, b } } - // Note the contrast with agreement. We are using the account's _current_ - // partkey to verify the heartbeat. This is required because we can only - // look 320 rounds back for voting information. If a heartbeat was delayed a - // few rounds (even 1), we could not ask "what partkey was in effect at - // firstValid-320?" Using the current keys means that an account that - // changes keys would invalidate any heartbeats it has already sent out + // Note the contrast with agreement. We require the account's _current_ + // partkey be used to sign the heartbeat. This is required because we can + // only look 320 rounds back for voting information. If a heartbeat was + // delayed a few rounds (even 1), we could not ask "what partkey was in + // effect at firstValid-320?" Using the current keys means that an account + // that changes keys would invalidate any heartbeats it has already sent out // (that haven't been evaluated yet). Maybe more importantly, after going // offline, an account can no longer heartbeat, since it has no _current_ // keys. Yet it is still expected to vote for 320 rounds. Therefore, // challenges do not apply to accounts that are offline (even if they should // still be voting). - // Conjure up an OnlineAccountData from current state, for convenience of - // oad.KeyDilution(). - oad := basics.OnlineAccountData{ - VotingData: account.VotingData, - } - - sv := oad.VoteID - if sv.IsEmpty() { - return fmt.Errorf("heartbeat address %s has no voting keys", hb.HbAddress) - } - kd := oad.KeyDilution(proto) - - // heartbeats are expected to sign with the partkey for their last-valid round - id := basics.OneTimeIDForRound(header.LastValid, kd) - // heartbeats sign a message consisting of the BlockSeed of the first-valid // round, to discourage unsavory behaviour like presigning a bunch of // heartbeats for later use keeping an unavailable account online. @@ -105,11 +78,16 @@ func Heartbeat(hb transactions.HeartbeatTxnFields, header transactions.Header, b return err } if hdr.Seed != hb.HbSeed { - return fmt.Errorf("provided seed %v does not match round %d's seed %v", hb.HbSeed, header.FirstValid, hdr.Seed) + return fmt.Errorf("provided seed %v does not match round %d's seed %v", + hb.HbSeed, header.FirstValid, hdr.Seed) } - - if !sv.Verify(id, hdr.Seed, hb.HbProof.ToOneTimeSignature()) { - return fmt.Errorf("heartbeat failed verification with VoteID %v", sv) + if account.VotingData.VoteID != hb.HbVoteID { + return fmt.Errorf("provided voter ID %v does not match %v's voter ID %v", + hb.HbVoteID, hb.HbAddress, account.VotingData.VoteID) + } + if account.VotingData.VoteKeyDilution != hb.HbKeyDilution { + return fmt.Errorf("provided key dilution %d does not match %v's key dilution %d", + hb.HbKeyDilution, hb.HbAddress, account.VotingData.VoteKeyDilution) } account.LastHeartbeat = round diff --git a/ledger/apply/heartbeat_test.go b/ledger/apply/heartbeat_test.go index f7d3845b12..6620ad8470 100644 --- a/ledger/apply/heartbeat_test.go +++ b/ledger/apply/heartbeat_test.go @@ -69,12 +69,10 @@ func TestHeartbeat(t *testing.T) { test := txntest.Txn{ Type: protocol.HeartbeatTx, Sender: sender, - Fee: basics.MicroAlgos{Raw: 1}, FirstValid: fv, LastValid: lv, HbAddress: voter, HbProof: otss.Sign(id, seed).ToHeartbeatProof(), - HbSeed: seed, } tx := test.Txn() @@ -82,19 +80,37 @@ func TestHeartbeat(t *testing.T) { rnd := basics.Round(150) // no fee err := Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) - require.ErrorContains(t, err, "cheap heartbeat") + require.ErrorContains(t, err, "free heartbeat") - test.Fee = basics.MicroAlgos{Raw: 10} - tx = test.Txn() // just as bad: cheap + tx.Fee = basics.MicroAlgos{Raw: 10} err = Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) require.ErrorContains(t, err, "cheap heartbeat") - test.Fee = 1000 - tx = test.Txn() + // address fee + tx.Fee = basics.MicroAlgos{Raw: 1000} + + // Seed is missing err = Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) - require.NoError(t, err) + require.ErrorContains(t, err, "provided seed") + tx.HbSeed = seed + // VoterID is missing + err = Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) + require.ErrorContains(t, err, "provided voter ID") + + tx.HbVoteID = otss.OneTimeSignatureVerifier + // still no key dilution + err = Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) + require.ErrorContains(t, err, "provided key dilution 0") + + tx.HbKeyDilution = keyDilution + 1 + err = Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) + require.ErrorContains(t, err, "provided key dilution 778") + + tx.HbKeyDilution = keyDilution + err = Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) + require.NoError(t, err) after, err := mockBal.Get(voter, false) require.NoError(t, err) require.Equal(t, rnd, after.LastHeartbeat) @@ -129,9 +145,6 @@ func TestCheapRules(t *testing.T) { {1201, 0x01, basics.Online, true, nil, empty, empty, "no challenge"}, // test of the other requirements - {1101, 0x01, basics.Online, true, []byte("note"), empty, empty, "not allowed to have a note"}, - {1101, 0x01, basics.Online, true, nil, [32]byte{'l', 'e', 'a', 's', 'e'}, empty, "not allowed to have a lease"}, - {1101, 0x01, basics.Online, true, nil, empty, [32]byte{'r', 'e', 'k', 'e', 'y'}, "not allowed to rekey"}, {1101, 0xf1, basics.Online, true, nil, empty, empty, "not challenged by"}, {1101, 0x01, basics.Offline, true, nil, empty, empty, "not allowed for Offline"}, {1101, 0x01, basics.Online, false, nil, empty, empty, "not allowed when not IncentiveEligible"}, @@ -168,17 +181,19 @@ func TestCheapRules(t *testing.T) { Seed: seed, }) txn := txntest.Txn{ - Type: protocol.HeartbeatTx, - Sender: sender, - Fee: basics.MicroAlgos{Raw: 1}, - FirstValid: tc.rnd - 10, - LastValid: tc.rnd + 10, - Lease: tc.lease, - Note: tc.note, - RekeyTo: tc.rekey, - HbAddress: voter, - HbProof: otss.Sign(id, seed).ToHeartbeatProof(), - HbSeed: seed, + Type: protocol.HeartbeatTx, + Sender: sender, + Fee: basics.MicroAlgos{Raw: 1}, + FirstValid: tc.rnd - 10, + LastValid: tc.rnd + 10, + Lease: tc.lease, + Note: tc.note, + RekeyTo: tc.rekey, + HbAddress: voter, + HbProof: otss.Sign(id, seed).ToHeartbeatProof(), + HbSeed: seed, + HbVoteID: otss.OneTimeSignatureVerifier, + HbKeyDilution: keyDilution, } tx := txn.Txn() diff --git a/ledger/eval/prefetcher/prefetcher_alignment_test.go b/ledger/eval/prefetcher/prefetcher_alignment_test.go index 6cd6dc9247..ed43a6e7c4 100644 --- a/ledger/eval/prefetcher/prefetcher_alignment_test.go +++ b/ledger/eval/prefetcher/prefetcher_alignment_test.go @@ -1449,7 +1449,8 @@ func TestEvaluatorPrefetcherAlignmentHeartbeat(t *testing.T) { MicroAlgos: basics.MicroAlgos{Raw: 100_000}, }, VotingData: basics.VotingData{ - VoteID: otss.OneTimeSignatureVerifier, + VoteID: otss.OneTimeSignatureVerifier, + VoteKeyDilution: 123, }, }, }, @@ -1463,9 +1464,11 @@ func TestEvaluatorPrefetcherAlignmentHeartbeat(t *testing.T) { Fee: basics.Algos(1), // Heartbeat txn is unusual in that it checks fees a bit. }, HeartbeatTxnFields: transactions.HeartbeatTxnFields{ - HbAddress: makeAddress(2), - HbProof: otss.Sign(firstID, committee.Seed(genesisHash())).ToHeartbeatProof(), - HbSeed: committee.Seed(genesisHash()), + HbAddress: makeAddress(2), + HbProof: otss.Sign(firstID, committee.Seed(genesisHash())).ToHeartbeatProof(), + HbSeed: committee.Seed(genesisHash()), + HbVoteID: otss.OneTimeSignatureVerifier, + HbKeyDilution: 123, }, } diff --git a/ledger/heartbeat_test.go b/ledger/heartbeat_test.go deleted file mode 100644 index 2af0310fef..0000000000 --- a/ledger/heartbeat_test.go +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (C) 2019-2024 Algorand, Inc. -// This file is part of go-algorand -// -// go-algorand is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// go-algorand is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with go-algorand. If not, see . - -package ledger - -import ( - "testing" - - "github.com/algorand/go-algorand/config" - "github.com/algorand/go-algorand/crypto" - "github.com/algorand/go-algorand/data/basics" - "github.com/algorand/go-algorand/data/txntest" - ledgertesting "github.com/algorand/go-algorand/ledger/testing" - "github.com/algorand/go-algorand/protocol" - "github.com/algorand/go-algorand/test/partitiontest" - "github.com/stretchr/testify/require" -) - -/* Tests within the `apply` package test the effects of heartbeats, while test - here are closer to integration tests, they test heartbeats in the context of - a more realistic ledger. */ - -// TestHearbeat exercises heartbeat transactions -func TestHeartbeat(t *testing.T) { - partitiontest.PartitionTest(t) - t.Parallel() - - genBalances, addrs, _ := ledgertesting.NewTestGenesis(func(cfg *ledgertesting.GenesisCfg) { - cfg.OnlineCount = 2 // addrs[0] and addrs[1] will be online - }) - heartbeatsBegin := 40 - - ledgertesting.TestConsensusRange(t, heartbeatsBegin, 0, - func(t *testing.T, ver int, cv protocol.ConsensusVersion, cfg config.Local) { - dl := NewDoubleLedger(t, genBalances, cv, cfg) - defer dl.Close() - - dl.txns() // tests involving seed are easier if we have the first block in ledger - - // empty HbAddress means ZeroAddress, and it's not online - dl.txn(&txntest.Txn{Type: "hb", Sender: addrs[1]}, - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ has no voting keys") - - // addrs[2] is not online, it has no voting keys - dl.txn(&txntest.Txn{Type: "hb", Sender: addrs[1], HbAddress: addrs[2]}, - addrs[2].String()+" has no voting keys") - - // addrs[1] is online, it has voting keys, but seed is missing - dl.txn(&txntest.Txn{Type: "hb", Sender: addrs[1], HbAddress: addrs[1], FirstValid: 1}, - "does not match round 1's seed") - - // NewTestGenesis creates random VoterID. Verification will fail. - b1, err := dl.generator.BlockHdr(1) - require.NoError(t, err) - dl.txn(&txntest.Txn{ - Type: "hb", - Sender: addrs[1], - HbAddress: addrs[1], - HbSeed: b1.Seed, - FirstValid: 1, - }, - "heartbeat failed verification with") - - // keyreg addr[1] so we have a valid VoterID - const kd = 10 - firstID := basics.OneTimeIDForRound(1, kd) - otss := crypto.GenerateOneTimeSignatureSecrets(firstID.Batch, 5) - dl.txn(&txntest.Txn{ - Type: "keyreg", - Sender: addrs[1], - VotePK: otss.OneTimeSignatureVerifier, - SelectionPK: crypto.VrfPubkey([32]byte{0x01}), // must be non-zero - VoteKeyDilution: kd, - }) - - // Supply and sign the wrong HbSeed - wrong := b1.Seed - wrong[0]++ - dl.txn(&txntest.Txn{ - Type: "hb", - Sender: addrs[1], - HbAddress: addrs[1], - HbSeed: wrong, - HbProof: otss.Sign(firstID, wrong).ToHeartbeatProof(), - FirstValid: 1, - }, - "does not match round 1's seed") - - b2, err := dl.generator.BlockHdr(2) - require.NoError(t, err) - - // Supply the right seed, but sign something else. We're also now - // setting LastValid and the proper OneTimeIDForRound, so that these - // tests are failing for the reasons described, not that. - dl.txn(&txntest.Txn{ - Type: "hb", - LastValid: 30, - Sender: addrs[1], - HbAddress: addrs[1], - HbSeed: b2.Seed, - HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), wrong).ToHeartbeatProof(), - FirstValid: 2, - }, - "failed verification") - - // Sign the right seed, but supply something else - dl.txn(&txntest.Txn{ - Type: "hb", - LastValid: 30, - Sender: addrs[1], - HbAddress: addrs[1], - HbSeed: wrong, - HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), b2.Seed).ToHeartbeatProof(), - FirstValid: 2, - }, - "does not match round 2's") - - // Mismatch the last valid and OneTimeIDForRound - dl.txn(&txntest.Txn{ - Type: "hb", - LastValid: 29, - Sender: addrs[1], - HbAddress: addrs[1], - HbSeed: b2.Seed, - HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), b2.Seed).ToHeartbeatProof(), - FirstValid: 2, - }, - "failed verification") - - // now we can make a real heartbeat, with a properly signed blockseed - dl.txn(&txntest.Txn{ - Type: "hb", - LastValid: 30, - Sender: addrs[1], - HbAddress: addrs[1], - HbSeed: b2.Seed, - HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), b2.Seed).ToHeartbeatProof(), - FirstValid: 2, - }) - - }) -} diff --git a/test/e2e-go/features/incentives/challenge_test.go b/test/e2e-go/features/incentives/challenge_test.go index 51586eab76..e6c880c611 100644 --- a/test/e2e-go/features/incentives/challenge_test.go +++ b/test/e2e-go/features/incentives/challenge_test.go @@ -71,7 +71,7 @@ func TestChallenges(t *testing.T) { c := fixture.GetLibGoalClientForNamedNode(name) accounts, err := fixture.GetNodeWalletsSortedByBalance(c) a.NoError(err) - a.Len(accounts, 4) + a.Len(accounts, 8) fmt.Printf("Client %s has %v\n", name, accounts) return c, accounts } @@ -149,19 +149,31 @@ func TestChallenges(t *testing.T) { a.True(data.IncentiveEligible) } + // Watch the first half grace period for proposals from challenged nodes, since they won't have to heartbeat. + lucky := util.MakeSet[basics.Address]() + fixture.WithEveryBlock(challengeRound, challengeRound+grace/2, func(block bookkeeping.Block) { + if challenged2.Contains(block.Proposer()) { + lucky.Add(block.Proposer()) + } + }) + // In the second half of the grace period, Node 2 should heartbeat for its accounts beated := util.MakeSet[basics.Address]() fixture.WithEveryBlock(challengeRound+grace/2, challengeRound+grace, func(block bookkeeping.Block) { - for _, txn := range block.Payset { + if challenged2.Contains(block.Proposer()) { + lucky.Add(block.Proposer()) + } + for i, txn := range block.Payset { hb := txn.Txn.HeartbeatTxnFields - fmt.Printf("Heartbeat txn %v\n", hb) + fmt.Printf("Heartbeat txn %v in position %d round %d\n", hb, i, block.Round()) a.True(challenged2.Contains(hb.HbAddress)) // only Node 2 is alive a.False(beated.Contains(hb.HbAddress)) // beat only once beated.Add(hb.HbAddress) + a.False(lucky.Contains(hb.HbAddress)) // we should not see a heartbeat from an account that proposed } a.Empty(block.AbsentParticipationAccounts) // nobody suspended during grace }) - a.Equal(challenged2, beated) + a.Equal(challenged2, util.Union(beated, lucky)) blk, err = fixture.WaitForBlockWithTimeout(challengeRound + grace + 1) a.NoError(err) diff --git a/test/testdata/nettemplates/Challenges.json b/test/testdata/nettemplates/Challenges.json index 6519033e9c..1d9944937c 100644 --- a/test/testdata/nettemplates/Challenges.json +++ b/test/testdata/nettemplates/Challenges.json @@ -4,7 +4,7 @@ "ConsensusProtocol": "future", "LastPartKeyRound": 500, "Wallets": [ - { "Name": "Relay", "Stake": 92, "Online": true }, + { "Name": "Relay", "Stake": 84, "Online": true }, { "Name": "Wallet0", "Stake": 1, "Online": true }, { "Name": "Wallet1", "Stake": 1, "Online": true }, { "Name": "Wallet2", "Stake": 1, "Online": true }, @@ -12,7 +12,15 @@ { "Name": "Wallet4", "Stake": 1, "Online": true }, { "Name": "Wallet5", "Stake": 1, "Online": true }, { "Name": "Wallet6", "Stake": 1, "Online": true }, - { "Name": "Wallet7", "Stake": 1, "Online": true } + { "Name": "Wallet7", "Stake": 1, "Online": true }, + { "Name": "Wallet8", "Stake": 1, "Online": true }, + { "Name": "Wallet9", "Stake": 1, "Online": true }, + { "Name": "WalletA", "Stake": 1, "Online": true }, + { "Name": "WalletB", "Stake": 1, "Online": true }, + { "Name": "WalletC", "Stake": 1, "Online": true }, + { "Name": "WalletD", "Stake": 1, "Online": true }, + { "Name": "WalletE", "Stake": 1, "Online": true }, + { "Name": "WalletF", "Stake": 1, "Online": true } ], "RewardsPoolBalance": 0 }, @@ -28,16 +36,24 @@ { "Name": "Wallet0", "ParticipationOnly": false }, { "Name": "Wallet1", "ParticipationOnly": false }, { "Name": "Wallet2", "ParticipationOnly": false }, - { "Name": "Wallet3", "ParticipationOnly": false } + { "Name": "Wallet3", "ParticipationOnly": false }, + { "Name": "Wallet4", "ParticipationOnly": false }, + { "Name": "Wallet5", "ParticipationOnly": false }, + { "Name": "Wallet6", "ParticipationOnly": false }, + { "Name": "Wallet7", "ParticipationOnly": false } ] }, { "Name": "Node2", "Wallets": [ - { "Name": "Wallet4", "ParticipationOnly": false }, - { "Name": "Wallet5", "ParticipationOnly": false }, - { "Name": "Wallet6", "ParticipationOnly": false }, - { "Name": "Wallet7", "ParticipationOnly": false } + { "Name": "Wallet8", "ParticipationOnly": false }, + { "Name": "Wallet9", "ParticipationOnly": false }, + { "Name": "WalletA", "ParticipationOnly": false }, + { "Name": "WalletB", "ParticipationOnly": false }, + { "Name": "WalletC", "ParticipationOnly": false }, + { "Name": "WalletD", "ParticipationOnly": false }, + { "Name": "WalletE", "ParticipationOnly": false }, + { "Name": "WalletF", "ParticipationOnly": false } ] } ] diff --git a/util/execpool/stream.go b/util/execpool/stream.go index 29ec4613f1..f6017a0af1 100644 --- a/util/execpool/stream.go +++ b/util/execpool/stream.go @@ -87,7 +87,7 @@ func (sv *StreamToBatch) Start(ctx context.Context) { go sv.batchingLoop() } -// WaitForStop waits until the batching loop terminates afer the ctx is canceled +// WaitForStop waits until the batching loop terminates after the ctx is canceled func (sv *StreamToBatch) WaitForStop() { sv.activeLoopWg.Wait() } From e3fbe16fab1810a4140df005b447e4d648305ddb Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Wed, 11 Dec 2024 15:39:06 -0500 Subject: [PATCH 14/14] Only heartbeat for IncentiveEligible accounts And include tests that !IncentiveEligible accounts won't be suspended, despite lack of heartbeat. --- heartbeat/service.go | 2 +- heartbeat/service_test.go | 17 +++- .../features/incentives/challenge_test.go | 78 ++++++++++++------- .../features/incentives/payouts_test.go | 15 ++-- .../features/incentives/suspension_test.go | 5 +- .../features/incentives/whalejoin_test.go | 2 +- 6 files changed, 77 insertions(+), 42 deletions(-) diff --git a/heartbeat/service.go b/heartbeat/service.go index d915fa3399..7c7f320493 100644 --- a/heartbeat/service.go +++ b/heartbeat/service.go @@ -97,7 +97,7 @@ func (s *Service) findChallenged(rules config.ProposerPayoutRules, current basic if acct.VoteID != pr.Voting.OneTimeSignatureVerifier { continue } - if acct.Status == basics.Online { + if acct.Status == basics.Online && acct.IncentiveEligible { if ch.Failed(pr.Account, acct.LastSeen()) { s.log.Infof(" %v needs a heartbeat\n", pr.Account) found = append(found, pr) diff --git a/heartbeat/service_test.go b/heartbeat/service_test.go index bdd06e62df..69dd2ea39a 100644 --- a/heartbeat/service_test.go +++ b/heartbeat/service_test.go @@ -225,10 +225,9 @@ func TestHeartbeatOnlyWhenChallenged(t *testing.T) { ledger.waitFor(s, a) a.Empty(sink) - // now they are online, but not challenged, so no heartbeat - acct.Status = basics.Online - acct.VoteKeyDilution = 100 - startBatch := basics.OneTimeIDForRound(ledger.LastRound(), acct.VoteKeyDilution).Batch + // make "part keys" and install them + kd := uint64(100) + startBatch := basics.OneTimeIDForRound(ledger.LastRound(), kd).Batch const batches = 50 // gives 50 * kd rounds = 5000 otss1 := crypto.GenerateOneTimeSignatureSecrets(startBatch, batches) otss2 := crypto.GenerateOneTimeSignatureSecrets(startBatch, batches) @@ -236,8 +235,12 @@ func TestHeartbeatOnlyWhenChallenged(t *testing.T) { participants.addParticipant(joe, otss2) // Simulate overlapping part keys, so Keys() returns both participants.addParticipant(mary, otss1) + // now they are online, but not challenged, so no heartbeat + acct.Status = basics.Online + acct.VoteKeyDilution = kd acct.VoteID = otss1.OneTimeSignatureVerifier a.NoError(ledger.addBlock(table{joe: acct, mary: acct})) // in effect, "keyreg" with otss1 + ledger.waitFor(s, a) a.Empty(sink) // now we have to make it seem like joe has been challenged. We obtain the @@ -255,6 +258,12 @@ func TestHeartbeatOnlyWhenChallenged(t *testing.T) { a.NoError(ledger.addBlock(table{joe: acct})) ledger.waitFor(s, a) + a.Empty(sink) // Just kidding, no heartbeat yet, joe isn't eligible + + acct.IncentiveEligible = true + a.NoError(ledger.addBlock(table{joe: acct})) + ledger.waitFor(s, a) + // challenge is already in place, it counts immediately, so service will heartbeat a.Len(sink, 1) // only one heartbeat (for joe) despite having two part records a.Len(sink[0], 1) a.Equal(sink[0][0].Txn.Type, protocol.HeartbeatTx) diff --git a/test/e2e-go/features/incentives/challenge_test.go b/test/e2e-go/features/incentives/challenge_test.go index e6c880c611..661bc7b40c 100644 --- a/test/e2e-go/features/incentives/challenge_test.go +++ b/test/e2e-go/features/incentives/challenge_test.go @@ -35,6 +35,13 @@ import ( "github.com/algorand/go-algorand/util" ) +// eligible is just a dumb 50/50 choice of whether to mark an address +// incentiveELigible or not, so we get a diversity of testing. Ineligible +// accounts should not be challenged or try to heartbeat. +func eligible(address string) bool { + return address[0]&0x01 == 0 +} + // TestChallenges ensures that accounts are knocked off if they don't respond to // a challenge, and that algod responds for accounts it knows (keepign them online) func TestChallenges(t *testing.T) { @@ -79,17 +86,16 @@ func TestChallenges(t *testing.T) { c1, accounts1 := clientAndAccounts("Node1") c2, accounts2 := clientAndAccounts("Node2") - // By re-regging, we become eligible for suspension (normal + challenges) - // TODO: Confirm that rereg is required for challenge suspensions - err := fixture.WaitForRoundWithTimeout(interval - lookback) // Make all LastHeartbeats > interval, < 2*interval a.NoError(err) + // eligible accounts1 will get challenged with node offline, and suspended for _, account := range accounts1 { - rekeyreg(&fixture, a, c1, account.Address) + rekeyreg(&fixture, a, c1, account.Address, eligible(account.Address)) } + // eligible accounts2 will get challenged, but node2 will heartbeat for them for _, account := range accounts2 { - rekeyreg(&fixture, a, c2, account.Address) + rekeyreg(&fixture, a, c2, account.Address, eligible(account.Address)) } // turn off node 1, so it can't heartbeat @@ -116,85 +122,101 @@ func TestChallenges(t *testing.T) { a.NoError(err) challenge := blk.BlockHeader.Seed[0] & mask // high bit - challenged1 := util.MakeSet[basics.Address]() + // match1 are the accounts from node1 that match the challenge, but only + // eligible ones are truly challenged and could be suspended. + match1 := util.MakeSet[basics.Address]() + eligible1 := util.MakeSet[basics.Address]() // matched AND eligible for _, account := range accounts1 { address, err := basics.UnmarshalChecksumAddress(account.Address) a.NoError(err) if address[0]&mask == challenge { fmt.Printf("%v of node 1 was challenged %v by %v\n", address, address[0], challenge) - challenged1.Add(address) + match1.Add(address) + if eligible(address.String()) { + eligible1.Add(address) + } } } - require.NotEmpty(t, challenged1, "rerun the test") // TODO: remove. + require.NotEmpty(t, match1, "rerun the test") // TODO: remove. - challenged2 := util.MakeSet[basics.Address]() + match2 := util.MakeSet[basics.Address]() + eligible2 := util.MakeSet[basics.Address]() // matched AND eligible for _, account := range accounts2 { address, err := basics.UnmarshalChecksumAddress(account.Address) a.NoError(err) if address[0]&mask == challenge { fmt.Printf("%v of node 2 was challenged %v by %v\n", address, address[0], challenge) - challenged2.Add(address) + match2.Add(address) + if eligible(address.String()) { + eligible2.Add(address) + } } } - require.NotEmpty(t, challenged2, "rerun the test") // TODO: remove. + require.NotEmpty(t, match2, "rerun the test") // TODO: remove. - allChallenged := util.Union(challenged1, challenged2) + allMatches := util.Union(match1, match2) - // All challenged nodes are still online - for address := range allChallenged { + // All nodes are online to start + for address := range allMatches { data, err := c2.AccountData(address.String()) a.NoError(err) a.Equal(basics.Online, data.Status, "%v %d", address.String(), data.LastHeartbeat) a.NotZero(data.VoteID) - a.True(data.IncentiveEligible) + a.Equal(eligible(address.String()), data.IncentiveEligible) } // Watch the first half grace period for proposals from challenged nodes, since they won't have to heartbeat. lucky := util.MakeSet[basics.Address]() fixture.WithEveryBlock(challengeRound, challengeRound+grace/2, func(block bookkeeping.Block) { - if challenged2.Contains(block.Proposer()) { + if eligible2.Contains(block.Proposer()) { lucky.Add(block.Proposer()) } + a.Empty(block.AbsentParticipationAccounts) // nobody suspended during grace }) - // In the second half of the grace period, Node 2 should heartbeat for its accounts + // In the second half of the grace period, Node 2 should heartbeat for its eligible accounts beated := util.MakeSet[basics.Address]() fixture.WithEveryBlock(challengeRound+grace/2, challengeRound+grace, func(block bookkeeping.Block) { - if challenged2.Contains(block.Proposer()) { + if eligible2.Contains(block.Proposer()) { lucky.Add(block.Proposer()) } for i, txn := range block.Payset { hb := txn.Txn.HeartbeatTxnFields fmt.Printf("Heartbeat txn %v in position %d round %d\n", hb, i, block.Round()) - a.True(challenged2.Contains(hb.HbAddress)) // only Node 2 is alive - a.False(beated.Contains(hb.HbAddress)) // beat only once + a.True(match2.Contains(hb.HbAddress)) // only Node 2 is alive + a.True(eligible2.Contains(hb.HbAddress)) // only eligible accounts get heartbeat + a.False(beated.Contains(hb.HbAddress)) // beat only once beated.Add(hb.HbAddress) a.False(lucky.Contains(hb.HbAddress)) // we should not see a heartbeat from an account that proposed } a.Empty(block.AbsentParticipationAccounts) // nobody suspended during grace }) - a.Equal(challenged2, util.Union(beated, lucky)) + a.Equal(eligible2, util.Union(beated, lucky)) blk, err = fixture.WaitForBlockWithTimeout(challengeRound + grace + 1) a.NoError(err) - a.Equal(challenged1, util.MakeSet(blk.AbsentParticipationAccounts...)) + a.Equal(eligible1, util.MakeSet(blk.AbsentParticipationAccounts...)) - // node 1 challenged accounts are suspended because node 1 is off - for address := range challenged1 { + // node 1 challenged (eligible) accounts are suspended because node 1 is off + for address := range match1 { data, err := c2.AccountData(address.String()) a.NoError(err) - a.Equal(basics.Offline, data.Status, address) + if eligible1.Contains(address) { + a.Equal(basics.Offline, data.Status, address) + } else { + a.Equal(basics.Online, data.Status, address) // not eligible, so not suspended + } a.NotZero(data.VoteID, address) a.False(data.IncentiveEligible, address) // suspension turns off flag } - // node 2 challenged accounts are not suspended (saved by heartbeat) - for address := range challenged2 { + // node 2 challenged accounts are not suspended (saved by heartbeat or weren't eligible) + for address := range match2 { data, err := c2.AccountData(address.String()) a.NoError(err) a.Equal(basics.Online, data.Status, address) a.NotZero(data.VoteID, address) - a.True(data.IncentiveEligible, address) + a.Equal(data.IncentiveEligible, eligible(address.String())) } } diff --git a/test/e2e-go/features/incentives/payouts_test.go b/test/e2e-go/features/incentives/payouts_test.go index 730d85a186..bf8b2e20e2 100644 --- a/test/e2e-go/features/incentives/payouts_test.go +++ b/test/e2e-go/features/incentives/payouts_test.go @@ -71,8 +71,8 @@ func TestBasicPayouts(t *testing.T) { c01, account01 := clientAndAccount("Node01") relay, _ := clientAndAccount("Relay") - data01 := rekeyreg(&fixture, a, c01, account01.Address) - data15 := rekeyreg(&fixture, a, c15, account15.Address) + data01 := rekeyreg(&fixture, a, c01, account01.Address, true) + data15 := rekeyreg(&fixture, a, c15, account15.Address, true) // have account01 burn some money to get below the eligibility cap // Starts with 100M, so burn 60M and get under 70M cap. @@ -317,14 +317,19 @@ func getblock(client libgoal.Client, round uint64) (bookkeeping.Block, error) { return client.BookkeepingBlock(round) } -func rekeyreg(f *fixtures.RestClientFixture, a *require.Assertions, client libgoal.Client, address string) basics.AccountData { +func rekeyreg(f *fixtures.RestClientFixture, a *require.Assertions, client libgoal.Client, address string, becomeEligible bool) basics.AccountData { // we start by making an _offline_ tx here, because we want to populate the // key material ourself with a copy of the account's existing material. That // makes it an _online_ keyreg. That allows the running node to chug along // without new part keys. We overpay the fee, which makes us // IncentiveEligible, and to get some funds into FeeSink because we will // watch it drain toward bottom of test. - reReg, err := client.MakeUnsignedGoOfflineTx(address, 0, 0, 12_000_000, [32]byte{}) + + fee := uint64(1000) + if becomeEligible { + fee = 12_000_000 + } + reReg, err := client.MakeUnsignedGoOfflineTx(address, 0, 0, fee, [32]byte{}) a.NoError(err) data, err := client.AccountData(address) @@ -354,7 +359,7 @@ func rekeyreg(f *fixtures.RestClientFixture, a *require.Assertions, client libgo a.NoError(err) a.Equal(basics.Online, data.Status) a.True(data.LastHeartbeat > 0) - a.True(data.IncentiveEligible) + a.Equal(becomeEligible, data.IncentiveEligible) fmt.Printf(" %v has %v in round %d\n", address, data.MicroAlgos.Raw, *txn.ConfirmedRound) return data } diff --git a/test/e2e-go/features/incentives/suspension_test.go b/test/e2e-go/features/incentives/suspension_test.go index 0191f6613c..4a3709d96e 100644 --- a/test/e2e-go/features/incentives/suspension_test.go +++ b/test/e2e-go/features/incentives/suspension_test.go @@ -33,7 +33,6 @@ import ( ) // TestBasicSuspension confirms that accounts that don't propose get suspended -// (when a tx naming them occurs) func TestBasicSuspension(t *testing.T) { partitiontest.PartitionTest(t) defer fixtures.ShutdownSynchronizedTest(t) @@ -71,8 +70,8 @@ func TestBasicSuspension(t *testing.T) { c10, account10 := clientAndAccount("Node10") c20, account20 := clientAndAccount("Node20") - rekeyreg(&fixture, a, c10, account10.Address) - rekeyreg(&fixture, a, c20, account20.Address) + rekeyreg(&fixture, a, c10, account10.Address, true) + rekeyreg(&fixture, a, c20, account20.Address, true) // Accounts are now suspendable whether they have proposed yet or not // because keyreg sets LastHeartbeat. Stop c20 which means account20 will be diff --git a/test/e2e-go/features/incentives/whalejoin_test.go b/test/e2e-go/features/incentives/whalejoin_test.go index 783a01ca33..90a67450e2 100644 --- a/test/e2e-go/features/incentives/whalejoin_test.go +++ b/test/e2e-go/features/incentives/whalejoin_test.go @@ -228,7 +228,7 @@ func TestBigIncrease(t *testing.T) { // have a fairly recent proposal, and not get knocked off. pay(&fixture, a, c1, account01.Address, account15.Address, 99*account01.Amount/100) - rekeyreg(&fixture, a, c1, account01.Address) + rekeyreg(&fixture, a, c1, account01.Address, true) // 2. Wait lookback rounds wait(&fixture, a, lookback)