From eb2acd436ce822a30e84ab0533c7169470d122b6 Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Tue, 8 Oct 2024 13:29:17 -0400 Subject: [PATCH 1/3] Heartbeat transaction type Some TODOS: 1) Lock it down. Singleton group? Top-level? 2) Make it free for challenged nodes. 3) Get the heartbeat service going to keep those nodes online. 4) Move the crypto earlier --- data/basics/userBalance.go | 10 - 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/transaction.go | 5 + heartbeat/abstractions.go | 54 ++++ heartbeat/service.go | 159 +++++++++++ heartbeat/service_test.go | 347 +++++++++++++++++++++++++ ledger/apply/apply.go | 7 +- ledger/apply/heartbeat.go | 56 ++++ ledger/apply/heartbeat_test.go | 90 +++++++ ledger/apply/mockBalances_test.go | 23 ++ ledger/eval/eval.go | 30 ++- ledger/eval/eval_test.go | 28 +- ledger/ledger.go | 2 +- node/node.go | 5 + protocol/txntype.go | 3 + stateproof/builder.go | 2 +- 19 files changed, 842 insertions(+), 51 deletions(-) create mode 100644 data/transactions/heartbeat.go create mode 100644 heartbeat/abstractions.go create mode 100644 heartbeat/service.go create mode 100644 heartbeat/service_test.go create mode 100644 ledger/apply/heartbeat.go create mode 100644 ledger/apply/heartbeat_test.go diff --git a/data/basics/userBalance.go b/data/basics/userBalance.go index d8f86aea54..11457d8d57 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" @@ -581,15 +580,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/transactions/heartbeat.go b/data/transactions/heartbeat.go new file mode 100644 index 0000000000..9acf847734 --- /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. + HeartbeatAddress basics.Address `codec:"hbad"` + + // Proof is a signature using HeartbeatAddress's partkey, thereby showing it is online. + Proof crypto.OneTimeSignature `codec:"hbprf"` + + // Seed 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. + Seed committee.Seed `codec:"hbsd"` +} diff --git a/data/transactions/logic/assembler.go b/data/transactions/logic/assembler.go index a707e23f65..c321e981ae 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 an 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/transaction.go b/data/transactions/transaction.go index 4a6d5b6603..a17066c3a5 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. @@ -598,6 +599,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/heartbeat/abstractions.go b/heartbeat/abstractions.go new file mode 100644 index 0000000000..52206a0ef7 --- /dev/null +++ b/heartbeat/abstractions.go @@ -0,0 +1,54 @@ +// Copyright (C) 2019-2023 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 heartbeat 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) +} + +// partipants 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..8f9775cc50 --- /dev/null +++ b/heartbeat/service.go @@ -0,0 +1,159 @@ +// Copyright (C) 2019-2023 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" + "fmt" + "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/transactions" + "github.com/algorand/go-algorand/data/transactions/logic" + "github.com/algorand/go-algorand/ledger/eval" + "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 + stop 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) (s *Service) { + s = &Service{ + accts: accts, + ledger: ledger, + bcast: bcast, + log: log.With("Context", "heartbeat"), + } + return s +} + +// Start starts the goroutines for the Service. +func (s *Service) Start() { + s.ctx, s.stop = context.WithCancel(context.Background()) + s.wg.Add(1) + go s.loop() +} + +// Stop any goroutines associated with this worker. +func (s *Service) Stop() { + s.stop() + 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) []account.ParticipationRecordForRound { + current := s.ledger.LastRound() + var found []account.ParticipationRecordForRound + + ch := eval.ActiveChallenge(rules, current, s.ledger) + 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) + fmt.Printf(" %v is %s at %d\n", pr.Account, acct.Status, current) + if err != nil { + s.log.Errorf("error looking up %v: %v", pr.Account, err) + continue + } + if acct.Status == basics.Online { + lastSeen := max(acct.LastProposed, acct.LastHeartbeat) + if eval.FailsChallenge(ch, pr.Account, lastSeen) { + 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() + 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() + + hdr, 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[hdr.CurrentProtocol] + + for _, pr := range s.findChallenged(proto.Payouts) { + stxn := s.prepareHeartbeat(pr.Account, latest, hdr.GenesisHash) + err = s.bcast.BroadcastInternalSignedTxGroup([]transactions.SignedTxn{stxn}) + if err != nil { + s.log.Errorf("error broadcasting heartbeat %v for %v: %v", stxn, pr.Account, err) + } + } + } +} + +// AcceptingByteCode is the source 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)) + +func (s *Service) prepareHeartbeat(address basics.Address, latest basics.Round, genHash [32]byte) 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 + 1, + LastValid: latest + 1 + 100, // maybe use the grace period? + GenesisHash: genHash, + } + + return stxn +} diff --git a/heartbeat/service_test.go b/heartbeat/service_test.go new file mode 100644 index 0000000000..aef54dd29f --- /dev/null +++ b/heartbeat/service_test.go @@ -0,0 +1,347 @@ +// Copyright (C) 2019-2023 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/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" + "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 mockParticipants struct { + accts map[basics.Address]struct{} +} + +func (p *mockParticipants) Keys(rnd basics.Round) []account.ParticipationRecordForRound { + var ret []account.ParticipationRecordForRound + for addr, _ := range p.accts { + ret = append(ret, account.ParticipationRecordForRound{ + ParticipationRecord: account.ParticipationRecord{ + ParticipationID: [32]byte{}, + Account: addr, + FirstValid: 0, + LastValid: 0, + KeyDilution: 0, + LastVote: 0, + LastBlockProposal: 0, + }, + }) + } + return ret +} + +func (p *mockParticipants) add(addr basics.Address) { + if p.accts == nil { + p.accts = make(map[basics.Address]struct{}) + } + p.accts[addr] = struct{}{} +} + +type table map[basics.Address]ledgercore.AccountData + +type mockedLedger struct { + mu deadlock.Mutex + waiters map[basics.Round]chan struct{} + history []table + version protocol.ConsensusVersion +} + +func newMockedLedger() mockedLedger { + return mockedLedger{ + waiters: make(map[basics.Round]chan struct{}), + history: []table{nil}, // some genesis accounts could go here + version: 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) { + var hdr bookkeeping.BlockHeader + hdr.Round = r + hdr.CurrentProtocol = l.version + 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) +} + +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{} + accts := &mockParticipants{} + ledger := newMockedLedger() + s := NewService(accts, &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 TestHeartBeatOnlyWhenSuspended(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + a := require.New(t) + sink := txnSink{} + accts := &mockParticipants{} + ledger := newMockedLedger() + s := NewService(accts, &ledger, &sink, logging.TestingLog(t)) + s.Start() + + // ensure Donor can pay + a.NoError(ledger.addBlock(table{Donor: ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: 1_000_000}, + }, + }})) + a.Empty(sink) + + joe := basics.Address{1, 1} + accts.add(joe) + + acct := ledgercore.AccountData{} + + a.NoError(ledger.addBlock(table{joe: acct})) + ledger.waitFor(s, a) + a.Empty(sink) + + acct.Status = basics.Online + + a.NoError(ledger.addBlock(table{joe: acct})) + a.Empty(sink) + + acct.Status = basics.Suspended + + a.NoError(ledger.addBlock(table{joe: acct})) + ledger.waitFor(s, a) + a.Len(sink, 1) // only one heartbeat so far + a.Len(sink[0], 1) // will probably end up being 3 to pay for `heartbeat` opcode + + s.Stop() +} + +func TestHeartBeatOnlyWhenDonorFunded(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + a := require.New(t) + sink := txnSink{} + accts := &mockParticipants{} + ledger := newMockedLedger() + s := NewService(accts, &ledger, &sink, logging.TestingLog(t)) + s.Start() + + joe := basics.Address{1, 1} + accts.add(joe) + + acct := ledgercore.AccountData{} + + a.NoError(ledger.addBlock(table{joe: acct})) + a.Empty(sink) + + acct.Status = basics.Suspended + + a.NoError(ledger.addBlock(table{joe: acct})) + ledger.waitFor(s, a) + a.Empty(sink) // no funded donor, no heartbeat + + // Donor exists, has enough for fee, but not enough when MBR is considered + a.NoError(ledger.addBlock(table{Donor: ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: 100_000}, + }, + }})) + a.NoError(ledger.addBlock(table{joe: acct})) + ledger.waitFor(s, a) + a.Empty(sink) + + a.NoError(ledger.addBlock(table{Donor: ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: 200_000}, + }, + }})) + ledger.waitFor(s, a) + a.Len(sink, 1) // only one heartbeat so far + a.Len(sink[0], 1) // will probably end up being 3 to pay for `heartbeat` opcode + s.Stop() +} + +func TestHeartBeatForm(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + a := require.New(t) + sink := txnSink{} + accts := &mockParticipants{} + ledger := newMockedLedger() + s := NewService(accts, &ledger, &sink, logging.TestingLog(t)) + s.Start() + + joe := basics.Address{1, 1} + accts.add(joe) + + // Fund the donor, suspend joe + a.NoError(ledger.addBlock(table{ + Donor: ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + MicroAlgos: basics.MicroAlgos{Raw: 200_000}, + }, + }, + joe: ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + Status: basics.Suspended, + MicroAlgos: basics.MicroAlgos{Raw: 2_000_000}, + }, + }, + })) + ledger.waitFor(s, a) + a.Len(sink, 1) // only one heartbeat so far + a.Len(sink[0], 1) // will probably end up being 3 to pay for `heartbeat` opcode + + grp := sink[0] + require.Equal(t, grp[0].Txn.Sender, Donor) + require.Equal(t, grp[0].Lsig, transactions.LogicSig{Logic: DonorByteCode}) + + a.NoError(ledger.addBlock(nil)) + ledger.waitFor(s, a) + a.Len(sink, 2) // still suspended, another heartbeat + inc := sink[0] + inc[0].Txn.FirstValid++ + inc[0].Txn.LastValid++ + a.Equal(inc, sink[1]) + + // mark joe online again + a.NoError(ledger.addBlock(table{ + joe: ledgercore.AccountData{ + AccountBaseData: ledgercore.AccountBaseData{ + Status: basics.Online, + MicroAlgos: basics.MicroAlgos{Raw: 2_000_000}, + }, + }, + })) + ledger.waitFor(s, a) + a.Len(sink, 2) // no further heartbeat + + 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/heartbeat.go b/ledger/apply/heartbeat.go new file mode 100644 index 0000000000..1c8a55f4b9 --- /dev/null +++ b/ledger/apply/heartbeat.go @@ -0,0 +1,56 @@ +// 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 ( + "errors" + "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.HeartbeatAddress, false) + + sv := account.VoteID + if sv.IsEmpty() { + return fmt.Errorf("HeartbeatAddress %s has has no voting keys\n", hb.HeartbeatAddress) + } + id := basics.OneTimeIDForRound(header.LastValid, account.VoteKeyDilution) + + hdr, err := provider.BlockHdr(header.FirstValid - 1) + if err != nil { + return err + } + + if !sv.Verify(id, hdr.Seed, hb.Proof) { + return errors.New("Improper heartbeat") + } + + account.LastHeartbeat = round + + // Write the updated entry + err = balances.Put(hb.HeartbeatAddress, 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..e6fe7d66a9 --- /dev/null +++ b/ledger/apply/heartbeat_test.go @@ -0,0 +1,90 @@ +// 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/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/transactions" + "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 - 1, + Seed: seed, + }) + + tx := transactions.Transaction{ + Type: protocol.HeartbeatTx, + Header: transactions.Header{ + Sender: sender, + Fee: basics.MicroAlgos{Raw: 1}, + FirstValid: fv, + LastValid: lv, + }, + HeartbeatTxnFields: transactions.HeartbeatTxnFields{ + HeartbeatAddress: voter, + Proof: otss.Sign(id, seed), + Seed: seed, + }, + } + + rnd := basics.Round(150) + 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 +} diff --git a/ledger/apply/mockBalances_test.go b/ledger/apply/mockBalances_test.go index 43af5fa11d..dd77b66e7f 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,23 @@ func (b *mockCreatableBalances) HasAssetParams(addr basics.Address, aidx basics. _, ok = acct.AssetParams[aidx] return } + +type mockHeaders struct { + b map[basics.Round]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{b: b} +} + +func (m mockHeaders) BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) { + if hdr, ok := m.b[r]; ok { + return hdr, nil + } + return bookkeeping.BlockHeader{}, fmt.Errorf("Round %v is not present\n", r) +} diff --git a/ledger/eval/eval.go b/ledger/eval/eval.go index 859b62922f..419a960692 100644 --- a/ledger/eval/eval.go +++ b/ledger/eval/eval.go @@ -1285,6 +1285,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) } @@ -1618,7 +1621,7 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList() { updates := &eval.block.ParticipationUpdates - ch := activeChallenge(&eval.proto, uint64(eval.Round()), eval.state) + ch := ActiveChallenge(eval.proto.Payouts, eval.Round(), eval.state) for _, accountAddr := range eval.state.modifiedAccounts() { acctData, found := eval.state.mods.Accts.GetData(accountAddr) @@ -1648,7 +1651,7 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList() { if acctData.Status == basics.Online { lastSeen := max(acctData.LastProposed, acctData.LastHeartbeat) if isAbsent(eval.state.prevTotals.Online.Money, acctData.MicroAlgos, lastSeen, current) || - failsChallenge(ch, accountAddr, lastSeen) { + FailsChallenge(ch, accountAddr, lastSeen) { updates.AbsentParticipationAccounts = append( updates.AbsentParticipationAccounts, accountAddr, @@ -1709,20 +1712,19 @@ type headerSource interface { BlockHdr(round basics.Round) (bookkeeping.BlockHeader, error) } -func activeChallenge(proto *config.ConsensusParams, current uint64, headers headerSource) challenge { - rules := proto.Payouts +func ActiveChallenge(rules config.ProposerPayoutRules, current basics.Round, headers headerSource) challenge { // are challenges active? - if rules.ChallengeInterval == 0 || current < rules.ChallengeInterval { + interval := basics.Round(rules.ChallengeInterval) + if rules.ChallengeInterval == 0 || current < interval { return challenge{} } - lastChallenge := current - (current % rules.ChallengeInterval) + lastChallenge := current - (current % interval) + grace := basics.Round(rules.ChallengeGracePeriod) // 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 { + if current <= lastChallenge+grace || current > lastChallenge+2*grace { return challenge{} } - round := basics.Round(lastChallenge) - challengeHdr, err := headers.BlockHdr(round) + challengeHdr, err := headers.BlockHdr(lastChallenge) if err != nil { panic(err) } @@ -1731,10 +1733,10 @@ func activeChallenge(proto *config.ConsensusParams, current uint64, headers head if challengeProto.Payouts != rules { return challenge{} } - return challenge{round, challengeHdr.Seed, rules.ChallengeBits} + return challenge{lastChallenge, challengeHdr.Seed, rules.ChallengeBits} } -func failsChallenge(ch challenge, address basics.Address, lastSeen basics.Round) bool { +func FailsChallenge(ch challenge, address basics.Address, lastSeen basics.Round) bool { return ch.round != 0 && bitsMatch(ch.seed[:], address[:], ch.bits) && lastSeen < ch.round } @@ -1805,7 +1807,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 := ActiveChallenge(eval.proto.Payouts, eval.Round(), eval.state) for _, accountAddr := range eval.block.ParticipationUpdates.AbsentParticipationAccounts { if _, exists := addressSet[accountAddr]; exists { @@ -1826,7 +1828,7 @@ func (eval *BlockEvaluator) validateAbsentOnlineAccounts() error { if isAbsent(eval.state.prevTotals.Online.Money, acctData.MicroAlgos, lastSeen, eval.Round()) { continue // ok. it's "normal absent" } - if failsChallenge(ch, accountAddr, lastSeen) { + if FailsChallenge(ch, accountAddr, 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 77a477b3c0..620770326b 100644 --- a/ledger/eval/eval_test.go +++ b/ledger/eval/eval_test.go @@ -1644,16 +1644,16 @@ func TestFailsChallenge(t *testing.T) { 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)) + 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)) + 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)) + 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)) + 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)) + a.False(FailsChallenge(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 5}, basics.Address{0xbf, 0x34}, 10)) } type singleSource bookkeeping.BlockHeader @@ -1673,27 +1673,27 @@ func TestActiveChallenge(t *testing.T) { CurrentProtocol: protocol.ConsensusFuture, }, } - now := config.Consensus[nowHeader.CurrentProtocol] + rules := config.Consensus[nowHeader.CurrentProtocol].Payouts // 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)) + inChallenge := rules.ChallengeInterval + rules.ChallengeGracePeriod + 1 + ch := ActiveChallenge(rules, 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)) + ch := ActiveChallenge(rules, 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) + for r := inChallenge; r < inChallenge+rules.ChallengeGracePeriod; r++ { + ch := ActiveChallenge(rules, r, singleSource(nowHeader)) + a.EqualValues(ch.round, rules.ChallengeInterval) } // And the next round is again challenge-less - ch = activeChallenge(&now, inChallenge+now.Payouts.ChallengeGracePeriod, singleSource(nowHeader)) + ch = ActiveChallenge(rules, inChallenge+rules.ChallengeGracePeriod, singleSource(nowHeader)) a.Zero(ch.round) // ignore challenge if upgrade happened @@ -1703,6 +1703,6 @@ func TestActiveChallenge(t *testing.T) { CurrentProtocol: protocol.ConsensusV39, }, } - ch = activeChallenge(&now, inChallenge, singleSource(oldHeader)) + ch = ActiveChallenge(rules, inChallenge, singleSource(oldHeader)) a.Zero(ch.round) } diff --git a/ledger/ledger.go b/ledger/ledger.go index 2f10724fee..6832ac9ec7 100644 --- a/ledger/ledger.go +++ b/ledger/ledger.go @@ -717,7 +717,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/node/node.go b/node/node.go index dddb3203e3..d3ce99901b 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 } 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 } From 6f3f3c15e2eee5bc9a45eb92893ea21053bb83ad Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Tue, 15 Oct 2024 11:53:59 -0400 Subject: [PATCH 2/3] Small CI fixups --- data/transactions/logic/ledger_test.go | 29 ++++- heartbeat/abstractions.go | 6 +- heartbeat/service.go | 2 +- heartbeat/service_test.go | 155 ++++++------------------- 4 files changed, 65 insertions(+), 127 deletions(-) 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/heartbeat/abstractions.go b/heartbeat/abstractions.go index 52206a0ef7..9ccecb6fb9 100644 --- a/heartbeat/abstractions.go +++ b/heartbeat/abstractions.go @@ -1,4 +1,4 @@ -// Copyright (C) 2019-2023 Algorand, Inc. +// 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 @@ -30,8 +30,8 @@ type txnBroadcaster interface { BroadcastInternalSignedTxGroup([]transactions.SignedTxn) error } -// ledger represents the aspects of the "real" Ledger that heartbeat needs. -// to interact with. +// 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 diff --git a/heartbeat/service.go b/heartbeat/service.go index 8f9775cc50..48d99cb959 100644 --- a/heartbeat/service.go +++ b/heartbeat/service.go @@ -1,4 +1,4 @@ -// Copyright (C) 2019-2023 Algorand, Inc. +// 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 diff --git a/heartbeat/service_test.go b/heartbeat/service_test.go index aef54dd29f..2246bde174 100644 --- a/heartbeat/service_test.go +++ b/heartbeat/service_test.go @@ -1,4 +1,4 @@ -// Copyright (C) 2019-2023 Algorand, Inc. +// 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 @@ -21,6 +21,7 @@ import ( "testing" "time" + "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" @@ -69,6 +70,7 @@ type mockedLedger struct { waiters map[basics.Round]chan struct{} history []table version protocol.ConsensusVersion + hdrs map[basics.Round]bookkeeping.BlockHeader } func newMockedLedger() mockedLedger { @@ -110,12 +112,25 @@ 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() { + return bookkeeping.BlockHeader{}, fmt.Errorf("%d is beyond current block (%d)", r, l.LastRound()) + } + if hdr, ok := l.hdrs[r]; ok { + return hdr, nil + } + // just return a simple hdr var hdr bookkeeping.BlockHeader hdr.Round = r hdr.CurrentProtocol = l.version return hdr, nil } +// addHeader places a block header into the ledger's history. It is used to make +// challenges occur as we'd like. +func (l *mockedLedger) addHeader(hdr bookkeeping.BlockHeader) { + l.hdrs[hdr.Round] = hdr +} + func (l *mockedLedger) addBlock(delta table) error { l.mu.Lock() defer l.mu.Unlock() @@ -197,7 +212,7 @@ func makeBlock(r basics.Round) bookkeeping.Block { } } -func TestHeartBeatOnlyWhenSuspended(t *testing.T) { +func TestHeartBeatOnlyWhenChallenged(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() @@ -208,15 +223,8 @@ func TestHeartBeatOnlyWhenSuspended(t *testing.T) { s := NewService(accts, &ledger, &sink, logging.TestingLog(t)) s.Start() - // ensure Donor can pay - a.NoError(ledger.addBlock(table{Donor: ledgercore.AccountData{ - AccountBaseData: ledgercore.AccountBaseData{ - MicroAlgos: basics.MicroAlgos{Raw: 1_000_000}, - }, - }})) - a.Empty(sink) - - joe := basics.Address{1, 1} + // joe is a simple, non-online account, service will not heartbeat + joe := basics.Address{0xcc} // 0xcc will matter when we set the challenge accts.add(joe) acct := ledgercore.AccountData{} @@ -225,123 +233,30 @@ func TestHeartBeatOnlyWhenSuspended(t *testing.T) { ledger.waitFor(s, a) a.Empty(sink) + // now joe is online, but not challenged, so no heartbeat acct.Status = basics.Online a.NoError(ledger.addBlock(table{joe: acct})) a.Empty(sink) - acct.Status = basics.Suspended + // 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()) + a.NoError(err) + rules := config.Consensus[hdr.CurrentProtocol].Payouts + for ledger.LastRound() < basics.Round(rules.ChallengeInterval) { + 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 so far - a.Len(sink[0], 1) // will probably end up being 3 to pay for `heartbeat` opcode + a.Len(sink, 1) // only one heartbeat so far + a.Len(sink[0], 1) + a.Equal(sink[0][0].Txn.Type, protocol.HeartbeatTx) + a.Equal(sink[0][0].Txn.HeartbeatAddress, joe) s.Stop() } - -func TestHeartBeatOnlyWhenDonorFunded(t *testing.T) { - partitiontest.PartitionTest(t) - t.Parallel() - - a := require.New(t) - sink := txnSink{} - accts := &mockParticipants{} - ledger := newMockedLedger() - s := NewService(accts, &ledger, &sink, logging.TestingLog(t)) - s.Start() - - joe := basics.Address{1, 1} - accts.add(joe) - - acct := ledgercore.AccountData{} - - a.NoError(ledger.addBlock(table{joe: acct})) - a.Empty(sink) - - acct.Status = basics.Suspended - - a.NoError(ledger.addBlock(table{joe: acct})) - ledger.waitFor(s, a) - a.Empty(sink) // no funded donor, no heartbeat - - // Donor exists, has enough for fee, but not enough when MBR is considered - a.NoError(ledger.addBlock(table{Donor: ledgercore.AccountData{ - AccountBaseData: ledgercore.AccountBaseData{ - MicroAlgos: basics.MicroAlgos{Raw: 100_000}, - }, - }})) - a.NoError(ledger.addBlock(table{joe: acct})) - ledger.waitFor(s, a) - a.Empty(sink) - - a.NoError(ledger.addBlock(table{Donor: ledgercore.AccountData{ - AccountBaseData: ledgercore.AccountBaseData{ - MicroAlgos: basics.MicroAlgos{Raw: 200_000}, - }, - }})) - ledger.waitFor(s, a) - a.Len(sink, 1) // only one heartbeat so far - a.Len(sink[0], 1) // will probably end up being 3 to pay for `heartbeat` opcode - s.Stop() -} - -func TestHeartBeatForm(t *testing.T) { - partitiontest.PartitionTest(t) - t.Parallel() - - a := require.New(t) - sink := txnSink{} - accts := &mockParticipants{} - ledger := newMockedLedger() - s := NewService(accts, &ledger, &sink, logging.TestingLog(t)) - s.Start() - - joe := basics.Address{1, 1} - accts.add(joe) - - // Fund the donor, suspend joe - a.NoError(ledger.addBlock(table{ - Donor: ledgercore.AccountData{ - AccountBaseData: ledgercore.AccountBaseData{ - MicroAlgos: basics.MicroAlgos{Raw: 200_000}, - }, - }, - joe: ledgercore.AccountData{ - AccountBaseData: ledgercore.AccountBaseData{ - Status: basics.Suspended, - MicroAlgos: basics.MicroAlgos{Raw: 2_000_000}, - }, - }, - })) - ledger.waitFor(s, a) - a.Len(sink, 1) // only one heartbeat so far - a.Len(sink[0], 1) // will probably end up being 3 to pay for `heartbeat` opcode - - grp := sink[0] - require.Equal(t, grp[0].Txn.Sender, Donor) - require.Equal(t, grp[0].Lsig, transactions.LogicSig{Logic: DonorByteCode}) - - a.NoError(ledger.addBlock(nil)) - ledger.waitFor(s, a) - a.Len(sink, 2) // still suspended, another heartbeat - inc := sink[0] - inc[0].Txn.FirstValid++ - inc[0].Txn.LastValid++ - a.Equal(inc, sink[1]) - - // mark joe online again - a.NoError(ledger.addBlock(table{ - joe: ledgercore.AccountData{ - AccountBaseData: ledgercore.AccountBaseData{ - Status: basics.Online, - MicroAlgos: basics.MicroAlgos{Raw: 2_000_000}, - }, - }, - })) - ledger.waitFor(s, a) - a.Len(sink, 2) // no further heartbeat - - s.Stop() - -} From 018c8e2cf5f5d021b8e83c183f553375eea4dd08 Mon Sep 17 00:00:00 2001 From: John Jannotti Date: Tue, 15 Oct 2024 13:19:40 -0400 Subject: [PATCH 3/3] break dependency on transactions package --- data/committee/common_test.go | 65 +++++++------------------------ data/committee/credential_test.go | 20 +++++----- ledger/eval/eval_test.go | 8 ++-- 3 files changed, 29 insertions(+), 64 deletions(-) diff --git a/data/committee/common_test.go b/data/committee/common_test.go index 1f7e7bd373..eef035b8e2 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, latest basics.Round, keyBatchesForward uint) (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 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) { +// 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, 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, keyBatchesForward uint, 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, lookback, keyBatchesForward) 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..b646efdf0a 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,10 @@ 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) + selParams, _, round, addresses, _, _ := testingenv(t, 10, 2000, seedGen) lookback := basics.Round(2*proto.SeedRefreshInterval + proto.SeedLookback + 1) gen := rand.New(rand.NewSource(2)) - _, _, zeroVRFSecret, _ := newAccount(t, gen, lookback, 5) + _, _, zeroVRFSecret := newAccount(t, gen, lookback, 5) period := Period(0) ok, record, selectionSeed, _ := selParams(addresses[i]) if !ok { @@ -281,7 +281,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 +313,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 +345,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 +375,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/ledger/eval/eval_test.go b/ledger/eval/eval_test.go index 620770326b..d2cf2e2e6e 100644 --- a/ledger/eval/eval_test.go +++ b/ledger/eval/eval_test.go @@ -1676,24 +1676,24 @@ func TestActiveChallenge(t *testing.T) { rules := config.Consensus[nowHeader.CurrentProtocol].Payouts // simplest test. when interval=X and grace=G, X+G+1 is a challenge - inChallenge := rules.ChallengeInterval + rules.ChallengeGracePeriod + 1 + inChallenge := basics.Round(rules.ChallengeInterval + rules.ChallengeGracePeriod + 1) ch := ActiveChallenge(rules, inChallenge, singleSource(nowHeader)) a.NotZero(ch.round) // all rounds before that have no challenge - for r := uint64(1); r < inChallenge; r++ { + for r := basics.Round(1); r < inChallenge; r++ { ch := ActiveChallenge(rules, r, singleSource(nowHeader)) a.Zero(ch.round, r) } // ChallengeGracePeriod rounds allow challenges starting with inChallenge - for r := inChallenge; r < inChallenge+rules.ChallengeGracePeriod; r++ { + for r := inChallenge; r < inChallenge+basics.Round(rules.ChallengeGracePeriod); r++ { ch := ActiveChallenge(rules, r, singleSource(nowHeader)) a.EqualValues(ch.round, rules.ChallengeInterval) } // And the next round is again challenge-less - ch = ActiveChallenge(rules, inChallenge+rules.ChallengeGracePeriod, singleSource(nowHeader)) + ch = ActiveChallenge(rules, inChallenge+basics.Round(rules.ChallengeGracePeriod), singleSource(nowHeader)) a.Zero(ch.round) // ignore challenge if upgrade happened