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 +}