diff --git a/ledger/eval/eval.go b/ledger/eval/eval.go index 1a05c80bb8..1ccd4b260b 100644 --- a/ledger/eval/eval.go +++ b/ledger/eval/eval.go @@ -1321,13 +1321,17 @@ func (eval *BlockEvaluator) endOfBlock() error { } } - err := eval.validateExpiredOnlineAccounts() - if err != nil { + if err := eval.validateExpiredOnlineAccounts(); err != nil { + return err + } + if err := eval.resetExpiredOnlineAccountsParticipationKeys(); err != nil { return err } - err = eval.resetExpiredOnlineAccountsParticipationKeys() - if err != nil { + if err := eval.validateAbsentOnlineAccounts(); err != nil { + return err + } + if err := eval.suspendAbsentAccounts(); err != nil { return err } @@ -1399,8 +1403,7 @@ func (eval *BlockEvaluator) endOfBlock() error { } } - err = eval.state.CalculateTotals() - if err != nil { + if err := eval.state.CalculateTotals(); err != nil { return err } @@ -1449,15 +1452,8 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList() { } if acctDelta.Status == basics.Online { - // TODO: 1000 rounds is obviously a placeholder. Probably needs to - // be something like 10x the "expected" interval. Further - // complications: (1) At keyreg, LastProposed=0 (or is old). (2) - // Accounts should be allowed to "heartbeat" to stay online. (3) - // "Expected" interval varies with balance and onlinestake. - allowableLag := basics.Round(1000) - absent := acctDelta.LastProposed+allowableLag < currentRound && - acctDelta.LastHeartbeat+allowableLag < currentRound - if absent && + lastSeen := max(acctDelta.LastHeartbeat, acctDelta.LastHeartbeat) + if isAbsent(eval.state.prevTotals.Online.Money, acctDelta.MicroAlgos, lastSeen, currentRound) && len(updates.AbsentParticipationAccounts) < expectedMaxNumberOfAbsentAccounts { updates.AbsentParticipationAccounts = append( updates.AbsentParticipationAccounts, @@ -1468,6 +1464,21 @@ 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 +} + +func isAbsent(totalOnlineStake basics.MicroAlgos, acctStake basics.MicroAlgos, lastSeen basics.Round, current basics.Round) bool { + // See if the account has exceeded 10x their expected observation interval. + allowableLag := basics.Round(10 * totalOnlineStake.Raw / acctStake.Raw) + fmt.Printf("%d / %d -> %d \n", acctStake, totalOnlineStake, allowableLag) + return lastSeen+allowableLag < current +} + // 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 { @@ -1553,13 +1564,8 @@ func (eval *BlockEvaluator) validateAbsentOnlineAccounts() error { return fmt.Errorf("proposed absent acct %v was not online but %v", accountAddr, acctData.Status) } - // TODO: 1000 rounds is obviously a placeholder. Probably needs to be - // something like 10x the "expected" interval. Further complications: - // (1) At first keyreg, LastProposed=0. (2) Accounts should be allowed - // to "heartbeat" to stay online. - allowableLag := basics.Round(1000) - if acctData.LastProposed+allowableLag >= currentRound || - acctData.LastHeartbeat+allowableLag >= currentRound { + lastSeen := max(acctData.LastHeartbeat, acctData.LastHeartbeat) + if !isAbsent(eval.state.prevTotals.Online.Money, acctData.MicroAlgos, lastSeen, currentRound) { return fmt.Errorf("proposed absent account %v is not absent in %d, %d", accountAddr, acctData.LastProposed, acctData.LastHeartbeat) } diff --git a/ledger/eval_simple_test.go b/ledger/eval_simple_test.go index 4f929df2c3..a45241dbad 100644 --- a/ledger/eval_simple_test.go +++ b/ledger/eval_simple_test.go @@ -278,26 +278,42 @@ func TestAbsentTracking(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() - genBalances, addrs, _ := ledgertesting.NewTestGenesis() + genBalances, addrs, _ := ledgertesting.NewTestGenesis(func(cfg *ledgertesting.GenesisCfg) { + cfg.OnlineCount = 2 // So we know proposer should propose every 2 rounds, on average + }) // Absentee checking begins in v39. Start checking in v38 to test that is unchanged. ledgertesting.TestConsensusRange(t, 38, 0, func(t *testing.T, ver int, cv protocol.ConsensusVersion, cfg config.Local) { dl := NewDoubleLedger(t, genBalances, cv, cfg) defer dl.Close() + totals, err := dl.generator.Totals(0) + require.NoError(t, err) + require.NotZero(t, totals.Online.Money.Raw) + for i, addr := range addrs { + fmt.Printf("addrs[%d] == %v\n", i, addr) + } + 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.False(t, lookup(t, dl.generator, addrs[2]).Status == basics.Online) + dl.fullBlock() - pay := txntest.Txn{ + proposer := addrs[7] + dl.beginBlock() + dl.txns(&txntest.Txn{ Type: "pay", Sender: addrs[1], Receiver: addrs[2], - Amount: 100000, - } - - proposer := addrs[7] - dl.beginBlock() - dl.txns(&pay) + Amount: 100_000, + }) dl.endBlock(proposer) + newtotals, err := dl.generator.Totals(dl.generator.Latest()) + require.NoError(t, err) + // payment and fee left the online account + require.Equal(t, totals.Online.Money.Raw-100_000-1000, newtotals.Online.Money.Raw) + totals = newtotals + dl.fullBlock() prp := lookup(t, dl.validator, proposer) @@ -313,8 +329,13 @@ func TestAbsentTracking(t *testing.T) { require.Zero(t, prp.LastHeartbeat) } - dl.txns(&txntest.Txn{Type: "keyreg", Sender: addrs[1]}) // OFFLINE keyreg - regger := lookup(t, dl.validator, addrs[1]) + // addrs[2] was already offline + dl.txns(&txntest.Txn{Type: "keyreg", Sender: addrs[2]}) // OFFLINE keyreg + regger := lookup(t, dl.validator, addrs[2]) + + newtotals, err = dl.generator.Totals(dl.generator.Latest()) + require.NoError(t, err) + require.Equal(t, totals.Online.Money.Raw, newtotals.Online.Money.Raw) // offline transaction records nothing require.Zero(t, regger.LastProposed) @@ -323,19 +344,58 @@ func TestAbsentTracking(t *testing.T) { // ONLINE keyreg dl.txns(&txntest.Txn{ Type: "keyreg", - Sender: addrs[1], + Sender: addrs[2], VotePK: [32]byte{1}, SelectionPK: [32]byte{1}, }) - regger = lookup(t, dl.validator, addrs[1]) + newtotals, err = dl.generator.Totals(dl.generator.Latest()) + require.NoError(t, err) + require.Greater(t, newtotals.Online.Money.Raw, totals.Online.Money.Raw) + + regger = lookup(t, dl.validator, addrs[2]) + require.Zero(t, regger.LastProposed) + require.True(t, regger.Status == basics.Online) if ver >= 39 { - require.Zero(t, regger.LastProposed) require.NotZero(t, regger.LastHeartbeat) // online keyreg caused update } else { - require.Zero(t, regger.LastProposed) require.Zero(t, regger.LastHeartbeat) } + + for i := 0; i < 5; i++ { + dl.fullBlock() + 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) + } + + // all are still online after a few blocks + 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) + + for i := 0; i < 30; i++ { + dl.fullBlock() + } + + // addrs 0-2 all have about 1/3 of stake, so become eligible for + // suspension after 30 rounds. We're at about 35. But, since blocks are + // empty, nobody's susspendible account is noticed. + 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) + + // when 2 pays 0, they both get noticed and get suspended + dl.txns(&txntest.Txn{ + Type: "pay", + Sender: addrs[2], + Receiver: addrs[0], + Amount: 0, + }) + require.Equal(t, ver < 39, lookup(t, dl.generator, addrs[0]).Status == basics.Online) + require.True(t, lookup(t, dl.generator, addrs[1]).Status == basics.Online) + require.Equal(t, ver < 39, lookup(t, dl.generator, addrs[2]).Status == basics.Online) + }) } diff --git a/ledger/testing/testGenesis.go b/ledger/testing/testGenesis.go index 98a41d06d5..9911af343a 100644 --- a/ledger/testing/testGenesis.go +++ b/ledger/testing/testGenesis.go @@ -25,17 +25,18 @@ import ( "github.com/algorand/go-algorand/protocol" ) -// testGenesisCfg provides a configuration object for NewTestGenesis. -type testGenesisCfg struct { +// GenesisCfg provides a configuration object for NewTestGenesis. +type GenesisCfg struct { rewardsPoolAmount basics.MicroAlgos + OnlineCount int } // TestGenesisOption provides functional options for testGenesisCfg. -type TestGenesisOption func(*testGenesisCfg) +type TestGenesisOption func(*GenesisCfg) // TestGenesisRewardsPoolSize configures the rewards pool size in the genesis block. func TestGenesisRewardsPoolSize(amount basics.MicroAlgos) TestGenesisOption { - return func(cfg *testGenesisCfg) { cfg.rewardsPoolAmount = amount } + return func(cfg *GenesisCfg) { cfg.rewardsPoolAmount = amount } } // NewTestGenesis creates a bunch of accounts, splits up 10B algos @@ -43,7 +44,7 @@ func TestGenesisRewardsPoolSize(amount basics.MicroAlgos) TestGenesisOption { // addresses and secrets it creates to enable tests. For special // scenarios, manipulate these return values before using newTestLedger. func NewTestGenesis(opts ...TestGenesisOption) (bookkeeping.GenesisBalances, []basics.Address, []*crypto.SignatureSecrets) { - var cfg testGenesisCfg + var cfg GenesisCfg for _, opt := range opts { opt(&cfg) } @@ -75,6 +76,15 @@ func NewTestGenesis(opts ...TestGenesisOption) (bookkeeping.GenesisBalances, []b adata := basics.AccountData{ MicroAlgos: basics.MicroAlgos{Raw: amount}, + Status: basics.Offline, + } + if i < cfg.OnlineCount { + adata.Status = basics.Online + adata.VoteFirstValid = 0 + adata.VoteLastValid = 1_000_000 + crypto.RandBytes(adata.VoteID[:]) + crypto.RandBytes(adata.SelectionID[:]) + crypto.RandBytes(adata.StateProofID[:]) } accts[addrs[i]] = adata }