diff --git a/agreement/gossip/networkFull_test.go b/agreement/gossip/networkFull_test.go index 6507a6cb82..e64811d6a9 100644 --- a/agreement/gossip/networkFull_test.go +++ b/agreement/gossip/networkFull_test.go @@ -103,7 +103,7 @@ func spinNetwork(t *testing.T, nodesCount int, cfg config.Local) ([]*networkImpl break } } - log.Infof("network established, %d nodes connected in %s", nodesCount, time.Now().Sub(start).String()) + log.Infof("network established, %d nodes connected in %s", nodesCount, time.Since(start).String()) return networkImpls, msgCounters } diff --git a/catchup/universalFetcher.go b/catchup/universalFetcher.go index c7a8a9a4cf..fd99bcc612 100644 --- a/catchup/universalFetcher.go +++ b/catchup/universalFetcher.go @@ -88,7 +88,7 @@ func (uf *universalBlockFetcher) fetchBlock(ctx context.Context, round basics.Ro } else { return nil, nil, time.Duration(0), fmt.Errorf("fetchBlock: UniversalFetcher only supports HTTPPeer and UnicastPeer") } - downloadDuration = time.Now().Sub(blockDownloadStartTime) + downloadDuration = time.Since(blockDownloadStartTime) block, cert, err := processBlockBytes(fetchedBuf, round, address) if err != nil { return nil, nil, time.Duration(0), err diff --git a/cmd/goal/clerk.go b/cmd/goal/clerk.go index a69ed5be98..1a2495007d 100644 --- a/cmd/goal/clerk.go +++ b/cmd/goal/clerk.go @@ -221,8 +221,7 @@ func waitForCommit(client libgoal.Client, txid string, transactionLastValidRound } reportInfof(infoTxPending, txid, stat.LastRound) - // WaitForRound waits until round "stat.LastRound+1" is committed - stat, err = client.WaitForRound(stat.LastRound) + stat, err = client.WaitForRound(stat.LastRound + 1) if err != nil { return model.PendingTransactionResponse{}, fmt.Errorf(errorRequestFail, err) } diff --git a/cmd/loadgenerator/main.go b/cmd/loadgenerator/main.go index 6b82887695..df142de4ce 100644 --- a/cmd/loadgenerator/main.go +++ b/cmd/loadgenerator/main.go @@ -200,22 +200,23 @@ func waitForRound(restClient client.RestClient, cfg config, spendingRound bool) time.Sleep(1 * time.Second) continue } - if isSpendRound(cfg, nodeStatus.LastRound) == spendingRound { + lastRound := nodeStatus.LastRound + if isSpendRound(cfg, lastRound) == spendingRound { // time to send transactions. return } if spendingRound { - fmt.Printf("Last round %d, waiting for spending round %d\n", nodeStatus.LastRound, nextSpendRound(cfg, nodeStatus.LastRound)) + fmt.Printf("Last round %d, waiting for spending round %d\n", lastRound, nextSpendRound(cfg, nodeStatus.LastRound)) } for { // wait for the next round. - nodeStatus, err = restClient.WaitForBlock(basics.Round(nodeStatus.LastRound)) + err = restClient.WaitForRoundWithTimeout(lastRound + 1) if err != nil { fmt.Fprintf(os.Stderr, "unable to wait for next round node status : %v", err) - time.Sleep(1 * time.Second) break } - if isSpendRound(cfg, nodeStatus.LastRound) == spendingRound { + lastRound++ + if isSpendRound(cfg, lastRound) == spendingRound { // time to send transactions. return } diff --git a/config/consensus.go b/config/consensus.go index f86e45e831..58678571ab 100644 --- a/config/consensus.go +++ b/config/consensus.go @@ -540,6 +540,9 @@ type ConsensusParams struct { // occur, extra funds need to be put into the FeeSink. The bonus amount // decays exponentially. Bonus BonusPlan + + // Heartbeat support + Heartbeat bool } // ProposerPayoutRules puts several related consensus parameters in one place. The same @@ -1513,7 +1516,7 @@ func initConsensusProtocols() { vFuture.LogicSigVersion = 11 // When moving this to a release, put a new higher LogicSigVersion here vFuture.Payouts.Enabled = true - vFuture.Payouts.Percent = 75 + vFuture.Payouts.Percent = 50 vFuture.Payouts.GoOnlineFee = 2_000_000 // 2 algos vFuture.Payouts.MinBalance = 30_000_000_000 // 30,000 algos vFuture.Payouts.MaxBalance = 70_000_000_000_000 // 70M algos @@ -1524,7 +1527,9 @@ func initConsensusProtocols() { vFuture.Bonus.BaseAmount = 10_000_000 // 10 Algos // 2.9 sec rounds gives about 10.8M rounds per year. - vFuture.Bonus.DecayInterval = 250_000 // .99^(10.8/0.25) ~ .648. So 35% decay per year + vFuture.Bonus.DecayInterval = 1_000_000 // .99^(10.8M/1M) ~ .897. So ~10% decay per year + + vFuture.Heartbeat = true Consensus[protocol.ConsensusFuture] = vFuture diff --git a/config/consensus_test.go b/config/consensus_test.go index d7fbed4085..635ad5699c 100644 --- a/config/consensus_test.go +++ b/config/consensus_test.go @@ -37,6 +37,11 @@ func TestConsensusParams(t *testing.T) { if params.ApplyData && params.PaysetCommit == PaysetCommitUnsupported { t.Errorf("Protocol %s: ApplyData with PaysetCommitUnsupported", proto) } + + // To figure out challenges, nodes must be able to lookup headers up to two GracePeriods back + if 2*params.Payouts.ChallengeGracePeriod > params.MaxTxnLife+params.DeeperBlockHeaderHistory { + t.Errorf("Protocol %s: Grace period is too long", proto) + } } } diff --git a/crypto/msgp_gen.go b/crypto/msgp_gen.go index ab5bdceb88..fc279029a0 100644 --- a/crypto/msgp_gen.go +++ b/crypto/msgp_gen.go @@ -111,6 +111,16 @@ import ( // |-----> MsgIsZero // |-----> HashTypeMaxSize() // +// HeartbeatProof +// |-----> (*) MarshalMsg +// |-----> (*) CanMarshalMsg +// |-----> (*) UnmarshalMsg +// |-----> (*) UnmarshalMsgWithState +// |-----> (*) CanUnmarshalMsg +// |-----> (*) Msgsize +// |-----> (*) MsgIsZero +// |-----> HeartbeatProofMaxSize() +// // MasterDerivationKey // |-----> (*) MarshalMsg // |-----> (*) CanMarshalMsg @@ -1169,6 +1179,232 @@ func HashTypeMaxSize() (s int) { return } +// MarshalMsg implements msgp.Marshaler +func (z *HeartbeatProof) MarshalMsg(b []byte) (o []byte) { + o = msgp.Require(b, z.Msgsize()) + // omitempty: check for empty values + zb0006Len := uint32(5) + var zb0006Mask uint8 /* 6 bits */ + if (*z).PK == (ed25519PublicKey{}) { + zb0006Len-- + zb0006Mask |= 0x2 + } + if (*z).PK1Sig == (ed25519Signature{}) { + zb0006Len-- + zb0006Mask |= 0x4 + } + if (*z).PK2 == (ed25519PublicKey{}) { + zb0006Len-- + zb0006Mask |= 0x8 + } + if (*z).PK2Sig == (ed25519Signature{}) { + zb0006Len-- + zb0006Mask |= 0x10 + } + if (*z).Sig == (ed25519Signature{}) { + zb0006Len-- + zb0006Mask |= 0x20 + } + // variable map header, size zb0006Len + o = append(o, 0x80|uint8(zb0006Len)) + if zb0006Len != 0 { + if (zb0006Mask & 0x2) == 0 { // if not empty + // string "p" + o = append(o, 0xa1, 0x70) + o = msgp.AppendBytes(o, ((*z).PK)[:]) + } + if (zb0006Mask & 0x4) == 0 { // if not empty + // string "p1s" + o = append(o, 0xa3, 0x70, 0x31, 0x73) + o = msgp.AppendBytes(o, ((*z).PK1Sig)[:]) + } + if (zb0006Mask & 0x8) == 0 { // if not empty + // string "p2" + o = append(o, 0xa2, 0x70, 0x32) + o = msgp.AppendBytes(o, ((*z).PK2)[:]) + } + if (zb0006Mask & 0x10) == 0 { // if not empty + // string "p2s" + o = append(o, 0xa3, 0x70, 0x32, 0x73) + o = msgp.AppendBytes(o, ((*z).PK2Sig)[:]) + } + if (zb0006Mask & 0x20) == 0 { // if not empty + // string "s" + o = append(o, 0xa1, 0x73) + o = msgp.AppendBytes(o, ((*z).Sig)[:]) + } + } + return +} + +func (_ *HeartbeatProof) CanMarshalMsg(z interface{}) bool { + _, ok := (z).(*HeartbeatProof) + return ok +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *HeartbeatProof) UnmarshalMsgWithState(bts []byte, st msgp.UnmarshalState) (o []byte, err error) { + if st.AllowableDepth == 0 { + err = msgp.ErrMaxDepthExceeded{} + return + } + st.AllowableDepth-- + var field []byte + _ = field + var zb0006 int + var zb0007 bool + zb0006, zb0007, bts, err = msgp.ReadMapHeaderBytes(bts) + if _, ok := err.(msgp.TypeError); ok { + zb0006, zb0007, bts, err = msgp.ReadArrayHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + if zb0006 > 0 { + zb0006-- + bts, err = msgp.ReadExactBytes(bts, ((*z).Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "Sig") + return + } + } + if zb0006 > 0 { + zb0006-- + bts, err = msgp.ReadExactBytes(bts, ((*z).PK)[:]) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "PK") + return + } + } + if zb0006 > 0 { + zb0006-- + bts, err = msgp.ReadExactBytes(bts, ((*z).PK2)[:]) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "PK2") + return + } + } + if zb0006 > 0 { + zb0006-- + bts, err = msgp.ReadExactBytes(bts, ((*z).PK1Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "PK1Sig") + return + } + } + if zb0006 > 0 { + zb0006-- + bts, err = msgp.ReadExactBytes(bts, ((*z).PK2Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "PK2Sig") + return + } + } + if zb0006 > 0 { + err = msgp.ErrTooManyArrayFields(zb0006) + if err != nil { + err = msgp.WrapError(err, "struct-from-array") + return + } + } + } else { + if err != nil { + err = msgp.WrapError(err) + return + } + if zb0007 { + (*z) = HeartbeatProof{} + } + for zb0006 > 0 { + zb0006-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch string(field) { + case "s": + bts, err = msgp.ReadExactBytes(bts, ((*z).Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "Sig") + return + } + case "p": + bts, err = msgp.ReadExactBytes(bts, ((*z).PK)[:]) + if err != nil { + err = msgp.WrapError(err, "PK") + return + } + case "p2": + bts, err = msgp.ReadExactBytes(bts, ((*z).PK2)[:]) + if err != nil { + err = msgp.WrapError(err, "PK2") + return + } + case "p1s": + bts, err = msgp.ReadExactBytes(bts, ((*z).PK1Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "PK1Sig") + return + } + case "p2s": + bts, err = msgp.ReadExactBytes(bts, ((*z).PK2Sig)[:]) + if err != nil { + err = msgp.WrapError(err, "PK2Sig") + return + } + default: + err = msgp.ErrNoField(string(field)) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + } + o = bts + return +} + +func (z *HeartbeatProof) UnmarshalMsg(bts []byte) (o []byte, err error) { + return z.UnmarshalMsgWithState(bts, msgp.DefaultUnmarshalState) +} +func (_ *HeartbeatProof) CanUnmarshalMsg(z interface{}) bool { + _, ok := (z).(*HeartbeatProof) + return ok +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *HeartbeatProof) Msgsize() (s int) { + s = 1 + 2 + msgp.ArrayHeaderSize + (64 * (msgp.ByteSize)) + 2 + msgp.ArrayHeaderSize + (32 * (msgp.ByteSize)) + 3 + msgp.ArrayHeaderSize + (32 * (msgp.ByteSize)) + 4 + msgp.ArrayHeaderSize + (64 * (msgp.ByteSize)) + 4 + msgp.ArrayHeaderSize + (64 * (msgp.ByteSize)) + return +} + +// MsgIsZero returns whether this is a zero value +func (z *HeartbeatProof) MsgIsZero() bool { + return ((*z).Sig == (ed25519Signature{})) && ((*z).PK == (ed25519PublicKey{})) && ((*z).PK2 == (ed25519PublicKey{})) && ((*z).PK1Sig == (ed25519Signature{})) && ((*z).PK2Sig == (ed25519Signature{})) +} + +// MaxSize returns a maximum valid message size for this message type +func HeartbeatProofMaxSize() (s int) { + s = 1 + 2 + // Calculating size of array: z.Sig + s += msgp.ArrayHeaderSize + ((64) * (msgp.ByteSize)) + s += 2 + // Calculating size of array: z.PK + s += msgp.ArrayHeaderSize + ((32) * (msgp.ByteSize)) + s += 3 + // Calculating size of array: z.PK2 + s += msgp.ArrayHeaderSize + ((32) * (msgp.ByteSize)) + s += 4 + // Calculating size of array: z.PK1Sig + s += msgp.ArrayHeaderSize + ((64) * (msgp.ByteSize)) + s += 4 + // Calculating size of array: z.PK2Sig + s += msgp.ArrayHeaderSize + ((64) * (msgp.ByteSize)) + return +} + // MarshalMsg implements msgp.Marshaler func (z *MasterDerivationKey) MarshalMsg(b []byte) (o []byte) { o = msgp.Require(b, z.Msgsize()) diff --git a/crypto/msgp_gen_test.go b/crypto/msgp_gen_test.go index b3fb95150b..0105a58f1d 100644 --- a/crypto/msgp_gen_test.go +++ b/crypto/msgp_gen_test.go @@ -434,6 +434,66 @@ func BenchmarkUnmarshalHashFactory(b *testing.B) { } } +func TestMarshalUnmarshalHeartbeatProof(t *testing.T) { + partitiontest.PartitionTest(t) + v := HeartbeatProof{} + bts := v.MarshalMsg(nil) + left, err := v.UnmarshalMsg(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left) + } + + left, err = msgp.Skip(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after Skip(): %q", len(left), left) + } +} + +func TestRandomizedEncodingHeartbeatProof(t *testing.T) { + protocol.RunEncodingTest(t, &HeartbeatProof{}) +} + +func BenchmarkMarshalMsgHeartbeatProof(b *testing.B) { + v := HeartbeatProof{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + v.MarshalMsg(nil) + } +} + +func BenchmarkAppendMsgHeartbeatProof(b *testing.B) { + v := HeartbeatProof{} + bts := make([]byte, 0, v.Msgsize()) + bts = v.MarshalMsg(bts[0:0]) + b.SetBytes(int64(len(bts))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + bts = v.MarshalMsg(bts[0:0]) + } +} + +func BenchmarkUnmarshalHeartbeatProof(b *testing.B) { + v := HeartbeatProof{} + bts := v.MarshalMsg(nil) + b.ReportAllocs() + b.SetBytes(int64(len(bts))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := v.UnmarshalMsg(bts) + if err != nil { + b.Fatal(err) + } + } +} + func TestMarshalUnmarshalMasterDerivationKey(t *testing.T) { partitiontest.PartitionTest(t) v := MasterDerivationKey{} diff --git a/crypto/onetimesig.go b/crypto/onetimesig.go index d05ccaa961..a817e590fe 100644 --- a/crypto/onetimesig.go +++ b/crypto/onetimesig.go @@ -57,6 +57,45 @@ type OneTimeSignature struct { PK2Sig ed25519Signature `codec:"p2s"` } +// A HeartbeatProof is functionally equivalent to a OneTimeSignature, but it has +// been cleaned up for use as a transaction field in heartbeat transactions. +type HeartbeatProof struct { + _struct struct{} `codec:",omitempty,omitemptyarray"` + + // Sig is a signature of msg under the key PK. + Sig ed25519Signature `codec:"s"` + PK ed25519PublicKey `codec:"p"` + + // PK2 is used to verify a two-level ephemeral signature. + PK2 ed25519PublicKey `codec:"p2"` + // PK1Sig is a signature of OneTimeSignatureSubkeyOffsetID(PK, Batch, Offset) under the key PK2. + PK1Sig ed25519Signature `codec:"p1s"` + // PK2Sig is a signature of OneTimeSignatureSubkeyBatchID(PK2, Batch) under the master key (OneTimeSignatureVerifier). + PK2Sig ed25519Signature `codec:"p2s"` +} + +// ToOneTimeSignature converts a HeartbeatProof to a OneTimeSignature. +func (hbp HeartbeatProof) ToOneTimeSignature() OneTimeSignature { + return OneTimeSignature{ + Sig: hbp.Sig, + PK: hbp.PK, + PK2: hbp.PK2, + PK1Sig: hbp.PK1Sig, + PK2Sig: hbp.PK2Sig, + } +} + +// ToHeartbeatProof converts a OneTimeSignature to a HeartbeatProof. +func (ots OneTimeSignature) ToHeartbeatProof() HeartbeatProof { + return HeartbeatProof{ + Sig: ots.Sig, + PK: ots.PK, + PK2: ots.PK2, + PK1Sig: ots.PK1Sig, + PK2Sig: ots.PK2Sig, + } +} + // A OneTimeSignatureSubkeyBatchID identifies an ephemeralSubkey of a batch // for the purposes of signing it with the top-level master key. type OneTimeSignatureSubkeyBatchID struct { diff --git a/daemon/algod/api/client/restClient.go b/daemon/algod/api/client/restClient.go index c349d3ecbf..e60ab1d36d 100644 --- a/daemon/algod/api/client/restClient.go +++ b/daemon/algod/api/client/restClient.go @@ -26,6 +26,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/google/go-querystring/query" @@ -39,6 +40,8 @@ import ( "github.com/algorand/go-algorand/ledger/eval" "github.com/algorand/go-algorand/ledger/ledgercore" "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/rpcs" + "github.com/algorand/go-algorand/test/e2e-go/globals" ) const ( @@ -283,12 +286,77 @@ func (client RestClient) Status() (response model.NodeStatusResponse, err error) return } -// WaitForBlock returns the node status after waiting for the given round. -func (client RestClient) WaitForBlock(round basics.Round) (response model.NodeStatusResponse, err error) { +// WaitForBlockAfter returns the node status after trying to wait for the given +// round+1. This REST API has the documented misfeatures of returning after 1 +// minute, regardless of whether the given block has been reached. +func (client RestClient) WaitForBlockAfter(round basics.Round) (response model.NodeStatusResponse, err error) { err = client.get(&response, fmt.Sprintf("/v2/status/wait-for-block-after/%d/", round), nil) return } +// WaitForRound returns the node status after waiting for the given round. +func (client RestClient) WaitForRound(round uint64, waitTime time.Duration) (status model.NodeStatusResponse, err error) { + timeout := time.NewTimer(waitTime) + for { + status, err = client.Status() + if err != nil { + return + } + + if status.LastRound >= round { + return + } + select { + case <-timeout.C: + return model.NodeStatusResponse{}, fmt.Errorf("timeout waiting for round %v with last round = %v", round, status.LastRound) + case <-time.After(200 * time.Millisecond): + } + } +} + +const singleRoundMaxTime = globals.MaxTimePerRound * 40 + +// WaitForRoundWithTimeout waits for a given round to be reached. As it +// waits, it returns early with an error if the wait time for any round exceeds +// globals.MaxTimePerRound so we can alert when we're getting "hung" waiting. +func (client RestClient) WaitForRoundWithTimeout(roundToWaitFor uint64) error { + status, err := client.Status() + if err != nil { + return err + } + lastRound := status.LastRound + + // If node is already at or past target round, we're done + if lastRound >= roundToWaitFor { + return nil + } + + roundComplete := make(chan error, 2) + + for nextRound := lastRound + 1; lastRound < roundToWaitFor; nextRound++ { + roundStarted := time.Now() + + go func(done chan error) { + stat, err := client.WaitForRound(nextRound, singleRoundMaxTime) + lastRound = stat.LastRound + done <- err + }(roundComplete) + + select { + case lastError := <-roundComplete: + if lastError != nil { + close(roundComplete) + return lastError + } + case <-time.After(singleRoundMaxTime): + // we've timed out. + time := time.Since(roundStarted) + return fmt.Errorf("fixture.WaitForRound took %3.2f seconds between round %d and %d", time.Seconds(), lastRound, nextRound) + } + } + return nil +} + // HealthCheck does a health check on the potentially running node, // returning an error if the API is down func (client RestClient) HealthCheck() error { @@ -301,14 +369,6 @@ func (client RestClient) ReadyCheck() error { return client.get(nil, "/ready", nil) } -// StatusAfterBlock waits for a block to occur then returns the StatusResponse after that block -// blocks on the node end -// Not supported -func (client RestClient) StatusAfterBlock(blockNum uint64) (response model.NodeStatusResponse, err error) { - err = client.get(&response, fmt.Sprintf("/v2/status/wait-for-block-after/%d", blockNum), nil) - return -} - type pendingTransactionsParams struct { Max uint64 `url:"max"` Format string `url:"format"` @@ -557,6 +617,16 @@ func (client RestClient) RawBlock(round uint64) (response []byte, err error) { return } +// EncodedBlockCert takes a round and returns its parsed block and certificate +func (client RestClient) EncodedBlockCert(round uint64) (blockCert rpcs.EncodedBlockCert, err error) { + resp, err := client.RawBlock(round) + if err != nil { + return + } + err = protocol.Decode(resp, &blockCert) + return +} + // Shutdown requests the node to shut itself down func (client RestClient) Shutdown() (err error) { response := 1 diff --git a/data/basics/userBalance.go b/data/basics/userBalance.go index 4db69bf22a..85167dec29 100644 --- a/data/basics/userBalance.go +++ b/data/basics/userBalance.go @@ -19,7 +19,6 @@ package basics import ( "encoding/binary" "fmt" - "reflect" "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/crypto" @@ -586,15 +585,6 @@ func (u OnlineAccountData) KeyDilution(proto config.ConsensusParams) uint64 { return proto.DefaultKeyDilution } -// IsZero checks if an AccountData value is the same as its zero value. -func (u AccountData) IsZero() bool { - if u.Assets != nil && len(u.Assets) == 0 { - u.Assets = nil - } - - return reflect.DeepEqual(u, AccountData{}) -} - // NormalizedOnlineBalance returns a “normalized” balance for this account. // // The normalization compensates for rewards that have not yet been applied, diff --git a/data/bookkeeping/block_test.go b/data/bookkeeping/block_test.go index 3c305b3c3b..bc8aec6a7a 100644 --- a/data/bookkeeping/block_test.go +++ b/data/bookkeeping/block_test.go @@ -1013,11 +1013,11 @@ func TestFirstYearsBonus(t *testing.T) { fmt.Printf("paid %d algos\n", suma) fmt.Printf("bonus start: %d end: %d\n", plan.BaseAmount, bonus) - // pays about 88M algos - a.InDelta(88_500_000, suma, 100_000) + // pays about 103.5M algos + a.InDelta(103_500_000, suma, 100_000) - // decline about 35% - a.InDelta(0.65, float64(bonus)/float64(plan.BaseAmount), 0.01) + // decline about 10% + a.InDelta(0.90, float64(bonus)/float64(plan.BaseAmount), 0.01) // year 2 for i := 0; i < yearRounds; i++ { @@ -1033,11 +1033,11 @@ func TestFirstYearsBonus(t *testing.T) { fmt.Printf("paid %d algos after 2 years\n", sum2) fmt.Printf("bonus end: %d\n", bonus) - // pays about 146M algos (total for 2 years) - a.InDelta(145_700_000, sum2, 100_000) + // pays about 196M algos (total for 2 years) + a.InDelta(196_300_000, sum2, 100_000) - // decline about 58% - a.InDelta(0.42, float64(bonus)/float64(plan.BaseAmount), 0.01) + // decline to about 81% + a.InDelta(0.81, float64(bonus)/float64(plan.BaseAmount), 0.01) // year 3 for i := 0; i < yearRounds; i++ { @@ -1053,9 +1053,9 @@ func TestFirstYearsBonus(t *testing.T) { fmt.Printf("paid %d algos after 3 years\n", sum3) fmt.Printf("bonus end: %d\n", bonus) - // pays about 182M algos (total for 3 years) - a.InDelta(182_600_000, sum3, 100_000) + // pays about 279M algos (total for 3 years) + a.InDelta(279_500_000, sum3, 100_000) - // declined to about 27% (but foundation funding probably gone anyway) - a.InDelta(0.27, float64(bonus)/float64(plan.BaseAmount), 0.01) + // declined to about 72% (but foundation funding probably gone anyway) + a.InDelta(0.72, float64(bonus)/float64(plan.BaseAmount), 0.01) } diff --git a/data/committee/common_test.go b/data/committee/common_test.go index 1f7e7bd373..8566a9cd2a 100644 --- a/data/committee/common_test.go +++ b/data/committee/common_test.go @@ -24,7 +24,6 @@ import ( "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/crypto" "github.com/algorand/go-algorand/data/basics" - "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/protocol" ) @@ -33,40 +32,33 @@ type selectionParameterListFn func(addr []basics.Address) (bool, []BalanceRecord var proto = config.Consensus[protocol.ConsensusCurrentVersion] -func newAccount(t testing.TB, gen io.Reader, latest basics.Round, keyBatchesForward uint) (basics.Address, *crypto.SignatureSecrets, *crypto.VrfPrivkey, *crypto.OneTimeSignatureSecrets) { +func newAccount(t testing.TB, gen io.Reader) (basics.Address, *crypto.SignatureSecrets, *crypto.VrfPrivkey) { var seed crypto.Seed gen.Read(seed[:]) s := crypto.GenerateSignatureSecrets(seed) _, v := crypto.VrfKeygenFromSeed(seed) - o := crypto.GenerateOneTimeSignatureSecrets(basics.OneTimeIDForRound(latest, proto.DefaultKeyDilution).Batch, uint64(keyBatchesForward)) addr := basics.Address(s.SignatureVerifier) - return addr, s, &v, o + return addr, s, &v } -func signTx(s *crypto.SignatureSecrets, t transactions.Transaction) transactions.SignedTxn { - return t.Sign(s) +// testingenv creates a random set of participating accounts and the associated +// selection parameters for use testing committee membership and credential +// validation. seedGen is provided as an external source of randomness for the +// selection seed; if the caller persists seedGen between calls to testingenv, +// each iteration that calls testingenv will exercise a new selection seed. +// formerly, testingenv, generated transactions and one-time secrets as well, +// but they were not used by the tests. +func testingenv(t testing.TB, numAccounts, numTxs int, seedGen io.Reader) (selectionParameterFn, selectionParameterListFn, basics.Round, []basics.Address, []*crypto.SignatureSecrets, []*crypto.VrfPrivkey) { + return testingenvMoreKeys(t, numAccounts, numTxs, seedGen) } -// testingenv creates a random set of participating accounts and random transactions between them, and -// the associated selection parameters for use testing committee membership and credential validation. -// seedGen is provided as an external source of randomness for the selection seed and transaction notes; -// if the caller persists seedGen between calls to testingenv, each iteration that calls testingenv will -// exercise a new selection seed. -func testingenv(t testing.TB, numAccounts, numTxs int, seedGen io.Reader) (selectionParameterFn, selectionParameterListFn, basics.Round, []basics.Address, []*crypto.SignatureSecrets, []*crypto.VrfPrivkey, []*crypto.OneTimeSignatureSecrets, []transactions.SignedTxn) { - return testingenvMoreKeys(t, numAccounts, numTxs, uint(5), seedGen) -} - -func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, keyBatchesForward uint, seedGen io.Reader) (selectionParameterFn, selectionParameterListFn, basics.Round, []basics.Address, []*crypto.SignatureSecrets, []*crypto.VrfPrivkey, []*crypto.OneTimeSignatureSecrets, []transactions.SignedTxn) { +func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, seedGen io.Reader) (selectionParameterFn, selectionParameterListFn, basics.Round, []basics.Address, []*crypto.SignatureSecrets, []*crypto.VrfPrivkey) { if seedGen == nil { seedGen = rand.New(rand.NewSource(1)) // same source as setting GODEBUG=randautoseed=0, same as pre-Go 1.20 default seed } P := numAccounts // n accounts - TXs := numTxs // n txns maxMoneyAtStart := 100000 // max money start minMoneyAtStart := 10000 // max money start - transferredMoney := 100 // max money/txn - maxFee := 10 // max maxFee/txn - E := basics.Round(50) // max round // generate accounts genesis := make(map[basics.Address]basics.AccountData) @@ -74,16 +66,14 @@ func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, keyBatchesForward addrs := make([]basics.Address, P) secrets := make([]*crypto.SignatureSecrets, P) vrfSecrets := make([]*crypto.VrfPrivkey, P) - otSecrets := make([]*crypto.OneTimeSignatureSecrets, P) proto := config.Consensus[protocol.ConsensusCurrentVersion] lookback := basics.Round(2*proto.SeedRefreshInterval + proto.SeedLookback + 1) var total basics.MicroAlgos for i := 0; i < P; i++ { - addr, sigSec, vrfSec, otSec := newAccount(t, gen, lookback, keyBatchesForward) + addr, sigSec, vrfSec := newAccount(t, gen) addrs[i] = addr secrets[i] = sigSec vrfSecrets[i] = vrfSec - otSecrets[i] = otSec startamt := uint64(minMoneyAtStart + (gen.Int() % (maxMoneyAtStart - minMoneyAtStart))) short := addr @@ -91,7 +81,6 @@ func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, keyBatchesForward Status: basics.Online, MicroAlgos: basics.MicroAlgos{Raw: startamt}, SelectionID: vrfSec.Pubkey(), - VoteID: otSec.OneTimeSignatureVerifier, } total.Raw += startamt } @@ -99,32 +88,8 @@ func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, keyBatchesForward var seed Seed seedGen.Read(seed[:]) - tx := make([]transactions.SignedTxn, TXs) - for i := 0; i < TXs; i++ { - send := gen.Int() % P - recv := gen.Int() % P - - saddr := addrs[send] - raddr := addrs[recv] - amt := basics.MicroAlgos{Raw: uint64(gen.Int() % transferredMoney)} - fee := basics.MicroAlgos{Raw: uint64(gen.Int() % maxFee)} - - t := transactions.Transaction{ - Type: protocol.PaymentTx, - Header: transactions.Header{ - Sender: saddr, - Fee: fee, - FirstValid: 0, - LastValid: E, - Note: make([]byte, 4), - }, - PaymentTxnFields: transactions.PaymentTxnFields{ - Receiver: raddr, - Amount: amt, - }, - } - seedGen.Read(t.Note) // to match output from previous versions, which shared global RNG for seed & note - tx[i] = t.Sign(secrets[send]) + for i := 0; i < numTxs; i++ { + seedGen.Read(make([]byte, 4)) // to match output from previous versions, which shared global RNG for seed & note } selParams := func(addr basics.Address) (bool, BalanceRecord, Seed, basics.MicroAlgos) { @@ -149,7 +114,7 @@ func testingenvMoreKeys(t testing.TB, numAccounts, numTxs int, keyBatchesForward return } - return selParams, selParamsList, lookback, addrs, secrets, vrfSecrets, otSecrets, tx + return selParams, selParamsList, lookback, addrs, secrets, vrfSecrets } /* TODO deprecate these types after they have been removed successfully */ diff --git a/data/committee/credential_test.go b/data/committee/credential_test.go index da2be625cd..bbabac62e9 100644 --- a/data/committee/credential_test.go +++ b/data/committee/credential_test.go @@ -35,7 +35,7 @@ func TestAccountSelected(t *testing.T) { seedGen := rand.New(rand.NewSource(1)) N := 1 for i := 0; i < N; i++ { - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, seedGen) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, seedGen) period := Period(0) leaders := uint64(0) @@ -98,7 +98,7 @@ func TestAccountSelected(t *testing.T) { func TestRichAccountSelected(t *testing.T) { partitiontest.PartitionTest(t) - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 10, 2000, nil) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 10, 2000, nil) period := Period(0) ok, record, selectionSeed, _ := selParams(addresses[0]) @@ -159,7 +159,7 @@ func TestPoorAccountSelectedLeaders(t *testing.T) { failsLeaders := 0 leaders := make([]uint64, N) for i := 0; i < N; i++ { - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, seedGen) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, seedGen) period := Period(0) for j := range addresses { ok, record, selectionSeed, _ := selParams(addresses[j]) @@ -207,7 +207,7 @@ func TestPoorAccountSelectedCommittee(t *testing.T) { N := 1 committee := uint64(0) for i := 0; i < N; i++ { - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, seedGen) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, seedGen) period := Period(0) step := Cert @@ -250,10 +250,9 @@ func TestNoMoneyAccountNotSelected(t *testing.T) { seedGen := rand.New(rand.NewSource(1)) N := 1 for i := 0; i < N; i++ { - selParams, _, round, addresses, _, _, _, _ := testingenv(t, 10, 2000, seedGen) - lookback := basics.Round(2*proto.SeedRefreshInterval + proto.SeedLookback + 1) + selParams, _, round, addresses, _, _ := testingenv(t, 10, 2000, seedGen) gen := rand.New(rand.NewSource(2)) - _, _, zeroVRFSecret, _ := newAccount(t, gen, lookback, 5) + _, _, zeroVRFSecret := newAccount(t, gen) period := Period(0) ok, record, selectionSeed, _ := selParams(addresses[i]) if !ok { @@ -281,7 +280,7 @@ func TestNoMoneyAccountNotSelected(t *testing.T) { func TestLeadersSelected(t *testing.T) { partitiontest.PartitionTest(t) - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, nil) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, nil) period := Period(0) step := Propose @@ -313,7 +312,7 @@ func TestLeadersSelected(t *testing.T) { func TestCommitteeSelected(t *testing.T) { partitiontest.PartitionTest(t) - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, nil) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, nil) period := Period(0) step := Soft @@ -345,7 +344,7 @@ func TestCommitteeSelected(t *testing.T) { func TestAccountNotSelected(t *testing.T) { partitiontest.PartitionTest(t) - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(t, 100, 2000, nil) + selParams, _, round, addresses, _, vrfSecrets := testingenv(t, 100, 2000, nil) period := Period(0) leaders := uint64(0) for i := range addresses { @@ -375,7 +374,7 @@ func TestAccountNotSelected(t *testing.T) { // TODO update to remove VRF verification overhead func BenchmarkSortition(b *testing.B) { - selParams, _, round, addresses, _, vrfSecrets, _, _ := testingenv(b, 100, 2000, nil) + selParams, _, round, addresses, _, vrfSecrets := testingenv(b, 100, 2000, nil) period := Period(0) step := Soft diff --git a/data/transactions/heartbeat.go b/data/transactions/heartbeat.go new file mode 100644 index 0000000000..48a4df6c69 --- /dev/null +++ b/data/transactions/heartbeat.go @@ -0,0 +1,42 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package transactions + +import ( + "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/committee" +) + +// HeartbeatTxnFields captures the fields used for an account to prove it is +// online (really, it proves that an entity with the account's part keys is able +// to submit transactions, so it should be able to propose/vote.) +type HeartbeatTxnFields struct { + _struct struct{} `codec:",omitempty,omitemptyarray"` + + // HeartbeatAddress is the account this txn is proving onlineness for. + HbAddress basics.Address `codec:"hbad"` + + // HbProof is a signature using HeartbeatAddress's partkey, thereby showing it is online. + HbProof crypto.HeartbeatProof `codec:"hbprf"` + + // HbSeed must be the block seed for the block before this transaction's + // firstValid. It is supplied in the transaction so that Proof can be + // checked at submit time without a ledger lookup, and must be checked at + // evaluation time for equality with the actual blockseed. + HbSeed committee.Seed `codec:"hbsd"` +} diff --git a/data/transactions/logic/assembler.go b/data/transactions/logic/assembler.go index a707e23f65..9c625d3d56 100644 --- a/data/transactions/logic/assembler.go +++ b/data/transactions/logic/assembler.go @@ -2738,6 +2738,16 @@ func AssembleString(text string) (*OpStream, error) { return AssembleStringWithVersion(text, assemblerNoVersion) } +// MustAssemble assembles a program and panics on error. It is useful for +// defining globals. +func MustAssemble(text string) []byte { + ops, err := AssembleString(text) + if err != nil { + panic(err) + } + return ops.Program +} + // AssembleStringWithVersion takes an entire program in a string and // assembles it to bytecode using the assembler version specified. If // version is assemblerNoVersion it uses #pragma version or fallsback diff --git a/data/transactions/logic/crypto_test.go b/data/transactions/logic/crypto_test.go index 0ba695dce5..1298f34c9f 100644 --- a/data/transactions/logic/crypto_test.go +++ b/data/transactions/logic/crypto_test.go @@ -217,13 +217,17 @@ pop // output`, "int 1"}, } } +func randSeed() crypto.Seed { + var s crypto.Seed + crypto.RandBytes(s[:]) + return s +} + func TestEd25519verify(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() - var s crypto.Seed - crypto.RandBytes(s[:]) - c := crypto.GenerateSignatureSecrets(s) + c := crypto.GenerateSignatureSecrets(randSeed()) msg := "62fdfc072182654f163f5f0f9a621d729566c74d0aa413bf009c9800418c19cd" data, err := hex.DecodeString(msg) require.NoError(t, err) @@ -262,9 +266,7 @@ func TestEd25519VerifyBare(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() - var s crypto.Seed - crypto.RandBytes(s[:]) - c := crypto.GenerateSignatureSecrets(s) + c := crypto.GenerateSignatureSecrets(randSeed()) msg := "62fdfc072182654f163f5f0f9a621d729566c74d0aa413bf009c9800418c19cd" data, err := hex.DecodeString(msg) require.NoError(t, err) @@ -743,9 +745,7 @@ func BenchmarkEd25519Verifyx1(b *testing.B) { crypto.RandBytes(buffer[:]) data = append(data, buffer) - var s crypto.Seed //generate programs and signatures - crypto.RandBytes(s[:]) - secret := crypto.GenerateSignatureSecrets(s) + secret := crypto.GenerateSignatureSecrets(randSeed()) //generate programs and signatures pk := basics.Address(secret.SignatureVerifier) pkStr := pk.String() ops, err := AssembleStringWithVersion(fmt.Sprintf(`arg 0 diff --git a/data/transactions/logic/eval_test.go b/data/transactions/logic/eval_test.go index c8f7a8bc5f..701acedaab 100644 --- a/data/transactions/logic/eval_test.go +++ b/data/transactions/logic/eval_test.go @@ -415,7 +415,7 @@ func TestBlankStackSufficient(t *testing.T) { spec := opsByOpcode[v][i] argLen := len(spec.Arg.Types) blankStackLen := len(blankStack) - require.GreaterOrEqual(t, blankStackLen, argLen) + require.GreaterOrEqual(t, blankStackLen, argLen, spec.Name) } }) } diff --git a/data/transactions/logic/ledger_test.go b/data/transactions/logic/ledger_test.go index 3dcead5e51..b16694d48c 100644 --- a/data/transactions/logic/ledger_test.go +++ b/data/transactions/logic/ledger_test.go @@ -46,9 +46,14 @@ import ( ) type balanceRecord struct { - addr basics.Address - auth basics.Address - balance uint64 + addr basics.Address + auth basics.Address + balance uint64 + voting basics.VotingData + + proposed basics.Round // The last round that this account proposed the accepted block + heartbeat basics.Round // The last round that this account sent a heartbeat to show it was online. + locals map[basics.AppIndex]basics.TealKeyValue holdings map[basics.AssetIndex]basics.AssetHolding mods map[basics.AppIndex]map[string]basics.ValueDelta @@ -119,6 +124,18 @@ func (l *Ledger) NewAccount(addr basics.Address, balance uint64) { l.balances[addr] = newBalanceRecord(addr, balance) } +// NewVoting sets VoteID on the account. Could expand to set other voting data +// if that became useful in tests. +func (l *Ledger) NewVoting(addr basics.Address, voteID crypto.OneTimeSignatureVerifier) { + br, ok := l.balances[addr] + if !ok { + br = newBalanceRecord(addr, 0) + } + br.voting.VoteID = voteID + br.voting.VoteKeyDilution = 10_000 + l.balances[addr] = br +} + // NewApp add a new AVM app to the Ledger. In most uses, it only sets up the id // and schema but no code, as testing will want to try many different code // sequences. @@ -312,7 +329,11 @@ func (l *Ledger) AccountData(addr basics.Address) (ledgercore.AccountData, error TotalBoxes: uint64(boxesTotal), TotalBoxBytes: uint64(boxBytesTotal), + + LastProposed: br.proposed, + LastHeartbeat: br.heartbeat, }, + VotingData: br.voting, }, nil } @@ -952,6 +973,8 @@ func (l *Ledger) Get(addr basics.Address, withPendingRewards bool) (basics.Accou Assets: map[basics.AssetIndex]basics.AssetHolding{}, AppLocalStates: map[basics.AppIndex]basics.AppLocalState{}, AppParams: map[basics.AppIndex]basics.AppParams{}, + LastProposed: br.proposed, + LastHeartbeat: br.heartbeat, }, nil } diff --git a/data/transactions/msgp_gen.go b/data/transactions/msgp_gen.go index 7cc22db08a..edc229bffe 100644 --- a/data/transactions/msgp_gen.go +++ b/data/transactions/msgp_gen.go @@ -12,6 +12,7 @@ import ( "github.com/algorand/go-algorand/crypto/merklesignature" "github.com/algorand/go-algorand/crypto/stateproof" "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/committee" "github.com/algorand/go-algorand/data/stateproofmsg" "github.com/algorand/go-algorand/protocol" ) @@ -97,6 +98,16 @@ import ( // |-----> (*) MsgIsZero // |-----> HeaderMaxSize() // +// HeartbeatTxnFields +// |-----> (*) MarshalMsg +// |-----> (*) CanMarshalMsg +// |-----> (*) UnmarshalMsg +// |-----> (*) UnmarshalMsgWithState +// |-----> (*) CanUnmarshalMsg +// |-----> (*) Msgsize +// |-----> (*) MsgIsZero +// |-----> HeartbeatTxnFieldsMaxSize() +// // KeyregTxnFields // |-----> (*) MarshalMsg // |-----> (*) CanMarshalMsg @@ -2907,6 +2918,172 @@ func HeaderMaxSize() (s int) { return } +// MarshalMsg implements msgp.Marshaler +func (z *HeartbeatTxnFields) MarshalMsg(b []byte) (o []byte) { + o = msgp.Require(b, z.Msgsize()) + // omitempty: check for empty values + zb0001Len := uint32(3) + var zb0001Mask uint8 /* 4 bits */ + if (*z).HbAddress.MsgIsZero() { + zb0001Len-- + zb0001Mask |= 0x2 + } + if (*z).HbProof.MsgIsZero() { + zb0001Len-- + zb0001Mask |= 0x4 + } + if (*z).HbSeed.MsgIsZero() { + zb0001Len-- + zb0001Mask |= 0x8 + } + // variable map header, size zb0001Len + o = append(o, 0x80|uint8(zb0001Len)) + if zb0001Len != 0 { + if (zb0001Mask & 0x2) == 0 { // if not empty + // string "hbad" + o = append(o, 0xa4, 0x68, 0x62, 0x61, 0x64) + o = (*z).HbAddress.MarshalMsg(o) + } + if (zb0001Mask & 0x4) == 0 { // if not empty + // string "hbprf" + o = append(o, 0xa5, 0x68, 0x62, 0x70, 0x72, 0x66) + o = (*z).HbProof.MarshalMsg(o) + } + if (zb0001Mask & 0x8) == 0 { // if not empty + // string "hbsd" + o = append(o, 0xa4, 0x68, 0x62, 0x73, 0x64) + o = (*z).HbSeed.MarshalMsg(o) + } + } + return +} + +func (_ *HeartbeatTxnFields) CanMarshalMsg(z interface{}) bool { + _, ok := (z).(*HeartbeatTxnFields) + return ok +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *HeartbeatTxnFields) UnmarshalMsgWithState(bts []byte, st msgp.UnmarshalState) (o []byte, err error) { + if st.AllowableDepth == 0 { + err = msgp.ErrMaxDepthExceeded{} + return + } + st.AllowableDepth-- + var field []byte + _ = field + var zb0001 int + var zb0002 bool + zb0001, zb0002, bts, err = msgp.ReadMapHeaderBytes(bts) + if _, ok := err.(msgp.TypeError); ok { + zb0001, zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + if zb0001 > 0 { + zb0001-- + bts, err = (*z).HbAddress.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbAddress") + return + } + } + if zb0001 > 0 { + zb0001-- + bts, err = (*z).HbProof.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbProof") + return + } + } + if zb0001 > 0 { + zb0001-- + bts, err = (*z).HbSeed.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbSeed") + return + } + } + if zb0001 > 0 { + err = msgp.ErrTooManyArrayFields(zb0001) + if err != nil { + err = msgp.WrapError(err, "struct-from-array") + return + } + } + } else { + if err != nil { + err = msgp.WrapError(err) + return + } + if zb0002 { + (*z) = HeartbeatTxnFields{} + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch string(field) { + case "hbad": + bts, err = (*z).HbAddress.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbAddress") + return + } + case "hbprf": + bts, err = (*z).HbProof.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbProof") + return + } + case "hbsd": + bts, err = (*z).HbSeed.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbSeed") + return + } + default: + err = msgp.ErrNoField(string(field)) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + } + o = bts + return +} + +func (z *HeartbeatTxnFields) UnmarshalMsg(bts []byte) (o []byte, err error) { + return z.UnmarshalMsgWithState(bts, msgp.DefaultUnmarshalState) +} +func (_ *HeartbeatTxnFields) CanUnmarshalMsg(z interface{}) bool { + _, ok := (z).(*HeartbeatTxnFields) + return ok +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *HeartbeatTxnFields) Msgsize() (s int) { + s = 1 + 5 + (*z).HbAddress.Msgsize() + 6 + (*z).HbProof.Msgsize() + 5 + (*z).HbSeed.Msgsize() + return +} + +// MsgIsZero returns whether this is a zero value +func (z *HeartbeatTxnFields) MsgIsZero() bool { + return ((*z).HbAddress.MsgIsZero()) && ((*z).HbProof.MsgIsZero()) && ((*z).HbSeed.MsgIsZero()) +} + +// MaxSize returns a maximum valid message size for this message type +func HeartbeatTxnFieldsMaxSize() (s int) { + s = 1 + 5 + basics.AddressMaxSize() + 6 + crypto.HeartbeatProofMaxSize() + 5 + committee.SeedMaxSize() + return +} + // MarshalMsg implements msgp.Marshaler func (z *KeyregTxnFields) MarshalMsg(b []byte) (o []byte) { o = msgp.Require(b, z.Msgsize()) @@ -4982,216 +5159,228 @@ func StateProofTxnFieldsMaxSize() (s int) { func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = msgp.Require(b, z.Msgsize()) // omitempty: check for empty values - zb0007Len := uint32(46) - var zb0007Mask uint64 /* 55 bits */ + zb0007Len := uint32(49) + var zb0007Mask uint64 /* 59 bits */ if (*z).AssetTransferTxnFields.AssetAmount == 0 { zb0007Len-- - zb0007Mask |= 0x200 + zb0007Mask |= 0x400 } if (*z).AssetTransferTxnFields.AssetCloseTo.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x400 + zb0007Mask |= 0x800 } if (*z).AssetFreezeTxnFields.AssetFrozen == false { zb0007Len-- - zb0007Mask |= 0x800 + zb0007Mask |= 0x1000 } if (*z).PaymentTxnFields.Amount.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x1000 + zb0007Mask |= 0x2000 } if len((*z).ApplicationCallTxnFields.ApplicationArgs) == 0 { zb0007Len-- - zb0007Mask |= 0x2000 + zb0007Mask |= 0x4000 } if (*z).ApplicationCallTxnFields.OnCompletion == 0 { zb0007Len-- - zb0007Mask |= 0x4000 + zb0007Mask |= 0x8000 } if len((*z).ApplicationCallTxnFields.ApprovalProgram) == 0 { zb0007Len-- - zb0007Mask |= 0x8000 + zb0007Mask |= 0x10000 } if (*z).AssetConfigTxnFields.AssetParams.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x10000 + zb0007Mask |= 0x20000 } if len((*z).ApplicationCallTxnFields.ForeignAssets) == 0 { zb0007Len-- - zb0007Mask |= 0x20000 + zb0007Mask |= 0x40000 } if len((*z).ApplicationCallTxnFields.Accounts) == 0 { zb0007Len-- - zb0007Mask |= 0x40000 + zb0007Mask |= 0x80000 } if len((*z).ApplicationCallTxnFields.Boxes) == 0 { zb0007Len-- - zb0007Mask |= 0x80000 + zb0007Mask |= 0x100000 } if (*z).ApplicationCallTxnFields.ExtraProgramPages == 0 { zb0007Len-- - zb0007Mask |= 0x100000 + zb0007Mask |= 0x200000 } if len((*z).ApplicationCallTxnFields.ForeignApps) == 0 { zb0007Len-- - zb0007Mask |= 0x200000 + zb0007Mask |= 0x400000 } if (*z).ApplicationCallTxnFields.GlobalStateSchema.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x400000 + zb0007Mask |= 0x800000 } if (*z).ApplicationCallTxnFields.ApplicationID.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x800000 + zb0007Mask |= 0x1000000 } if (*z).ApplicationCallTxnFields.LocalStateSchema.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x1000000 + zb0007Mask |= 0x2000000 } if len((*z).ApplicationCallTxnFields.ClearStateProgram) == 0 { zb0007Len-- - zb0007Mask |= 0x2000000 + zb0007Mask |= 0x4000000 } if (*z).AssetTransferTxnFields.AssetReceiver.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x4000000 + zb0007Mask |= 0x8000000 } if (*z).AssetTransferTxnFields.AssetSender.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x8000000 + zb0007Mask |= 0x10000000 } if (*z).AssetConfigTxnFields.ConfigAsset.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x10000000 + zb0007Mask |= 0x20000000 } if (*z).PaymentTxnFields.CloseRemainderTo.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x20000000 + zb0007Mask |= 0x40000000 } if (*z).AssetFreezeTxnFields.FreezeAccount.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x40000000 + zb0007Mask |= 0x80000000 } if (*z).AssetFreezeTxnFields.FreezeAsset.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x80000000 + zb0007Mask |= 0x100000000 } if (*z).Header.Fee.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x100000000 + zb0007Mask |= 0x200000000 } if (*z).Header.FirstValid.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x200000000 + zb0007Mask |= 0x400000000 } if (*z).Header.GenesisID == "" { zb0007Len-- - zb0007Mask |= 0x400000000 + zb0007Mask |= 0x800000000 } if (*z).Header.GenesisHash.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x800000000 + zb0007Mask |= 0x1000000000 } if (*z).Header.Group.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x1000000000 + zb0007Mask |= 0x2000000000 + } + if (*z).HeartbeatTxnFields.HbAddress.MsgIsZero() { + zb0007Len-- + zb0007Mask |= 0x4000000000 + } + if (*z).HeartbeatTxnFields.HbProof.MsgIsZero() { + zb0007Len-- + zb0007Mask |= 0x8000000000 + } + if (*z).HeartbeatTxnFields.HbSeed.MsgIsZero() { + zb0007Len-- + zb0007Mask |= 0x10000000000 } if (*z).Header.LastValid.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x2000000000 + zb0007Mask |= 0x20000000000 } if (*z).Header.Lease == ([32]byte{}) { zb0007Len-- - zb0007Mask |= 0x4000000000 + zb0007Mask |= 0x40000000000 } if (*z).KeyregTxnFields.Nonparticipation == false { zb0007Len-- - zb0007Mask |= 0x8000000000 + zb0007Mask |= 0x80000000000 } if len((*z).Header.Note) == 0 { zb0007Len-- - zb0007Mask |= 0x10000000000 + zb0007Mask |= 0x100000000000 } if (*z).PaymentTxnFields.Receiver.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x20000000000 + zb0007Mask |= 0x200000000000 } if (*z).Header.RekeyTo.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x40000000000 + zb0007Mask |= 0x400000000000 } if (*z).KeyregTxnFields.SelectionPK.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x80000000000 + zb0007Mask |= 0x800000000000 } if (*z).Header.Sender.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x100000000000 + zb0007Mask |= 0x1000000000000 } if (*z).StateProofTxnFields.StateProof.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x200000000000 + zb0007Mask |= 0x2000000000000 } if (*z).StateProofTxnFields.Message.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x400000000000 + zb0007Mask |= 0x4000000000000 } if (*z).KeyregTxnFields.StateProofPK.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x800000000000 + zb0007Mask |= 0x8000000000000 } if (*z).StateProofTxnFields.StateProofType.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x1000000000000 + zb0007Mask |= 0x10000000000000 } if (*z).Type.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x2000000000000 + zb0007Mask |= 0x20000000000000 } if (*z).KeyregTxnFields.VoteFirst.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x4000000000000 + zb0007Mask |= 0x40000000000000 } if (*z).KeyregTxnFields.VoteKeyDilution == 0 { zb0007Len-- - zb0007Mask |= 0x8000000000000 + zb0007Mask |= 0x80000000000000 } if (*z).KeyregTxnFields.VotePK.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x10000000000000 + zb0007Mask |= 0x100000000000000 } if (*z).KeyregTxnFields.VoteLast.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x20000000000000 + zb0007Mask |= 0x200000000000000 } if (*z).AssetTransferTxnFields.XferAsset.MsgIsZero() { zb0007Len-- - zb0007Mask |= 0x40000000000000 + zb0007Mask |= 0x400000000000000 } // variable map header, size zb0007Len o = msgp.AppendMapHeader(o, zb0007Len) if zb0007Len != 0 { - if (zb0007Mask & 0x200) == 0 { // if not empty + if (zb0007Mask & 0x400) == 0 { // if not empty // string "aamt" o = append(o, 0xa4, 0x61, 0x61, 0x6d, 0x74) o = msgp.AppendUint64(o, (*z).AssetTransferTxnFields.AssetAmount) } - if (zb0007Mask & 0x400) == 0 { // if not empty + if (zb0007Mask & 0x800) == 0 { // if not empty // string "aclose" o = append(o, 0xa6, 0x61, 0x63, 0x6c, 0x6f, 0x73, 0x65) o = (*z).AssetTransferTxnFields.AssetCloseTo.MarshalMsg(o) } - if (zb0007Mask & 0x800) == 0 { // if not empty + if (zb0007Mask & 0x1000) == 0 { // if not empty // string "afrz" o = append(o, 0xa4, 0x61, 0x66, 0x72, 0x7a) o = msgp.AppendBool(o, (*z).AssetFreezeTxnFields.AssetFrozen) } - if (zb0007Mask & 0x1000) == 0 { // if not empty + if (zb0007Mask & 0x2000) == 0 { // if not empty // string "amt" o = append(o, 0xa3, 0x61, 0x6d, 0x74) o = (*z).PaymentTxnFields.Amount.MarshalMsg(o) } - if (zb0007Mask & 0x2000) == 0 { // if not empty + if (zb0007Mask & 0x4000) == 0 { // if not empty // string "apaa" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x61) if (*z).ApplicationCallTxnFields.ApplicationArgs == nil { @@ -5203,22 +5392,22 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = msgp.AppendBytes(o, (*z).ApplicationCallTxnFields.ApplicationArgs[zb0002]) } } - if (zb0007Mask & 0x4000) == 0 { // if not empty + if (zb0007Mask & 0x8000) == 0 { // if not empty // string "apan" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x6e) o = msgp.AppendUint64(o, uint64((*z).ApplicationCallTxnFields.OnCompletion)) } - if (zb0007Mask & 0x8000) == 0 { // if not empty + if (zb0007Mask & 0x10000) == 0 { // if not empty // string "apap" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x70) o = msgp.AppendBytes(o, (*z).ApplicationCallTxnFields.ApprovalProgram) } - if (zb0007Mask & 0x10000) == 0 { // if not empty + if (zb0007Mask & 0x20000) == 0 { // if not empty // string "apar" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x72) o = (*z).AssetConfigTxnFields.AssetParams.MarshalMsg(o) } - if (zb0007Mask & 0x20000) == 0 { // if not empty + if (zb0007Mask & 0x40000) == 0 { // if not empty // string "apas" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x73) if (*z).ApplicationCallTxnFields.ForeignAssets == nil { @@ -5230,7 +5419,7 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = (*z).ApplicationCallTxnFields.ForeignAssets[zb0006].MarshalMsg(o) } } - if (zb0007Mask & 0x40000) == 0 { // if not empty + if (zb0007Mask & 0x80000) == 0 { // if not empty // string "apat" o = append(o, 0xa4, 0x61, 0x70, 0x61, 0x74) if (*z).ApplicationCallTxnFields.Accounts == nil { @@ -5242,7 +5431,7 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = (*z).ApplicationCallTxnFields.Accounts[zb0003].MarshalMsg(o) } } - if (zb0007Mask & 0x80000) == 0 { // if not empty + if (zb0007Mask & 0x100000) == 0 { // if not empty // string "apbx" o = append(o, 0xa4, 0x61, 0x70, 0x62, 0x78) if (*z).ApplicationCallTxnFields.Boxes == nil { @@ -5276,12 +5465,12 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { } } } - if (zb0007Mask & 0x100000) == 0 { // if not empty + if (zb0007Mask & 0x200000) == 0 { // if not empty // string "apep" o = append(o, 0xa4, 0x61, 0x70, 0x65, 0x70) o = msgp.AppendUint32(o, (*z).ApplicationCallTxnFields.ExtraProgramPages) } - if (zb0007Mask & 0x200000) == 0 { // if not empty + if (zb0007Mask & 0x400000) == 0 { // if not empty // string "apfa" o = append(o, 0xa4, 0x61, 0x70, 0x66, 0x61) if (*z).ApplicationCallTxnFields.ForeignApps == nil { @@ -5293,167 +5482,182 @@ func (z *Transaction) MarshalMsg(b []byte) (o []byte) { o = (*z).ApplicationCallTxnFields.ForeignApps[zb0004].MarshalMsg(o) } } - if (zb0007Mask & 0x400000) == 0 { // if not empty + if (zb0007Mask & 0x800000) == 0 { // if not empty // string "apgs" o = append(o, 0xa4, 0x61, 0x70, 0x67, 0x73) o = (*z).ApplicationCallTxnFields.GlobalStateSchema.MarshalMsg(o) } - if (zb0007Mask & 0x800000) == 0 { // if not empty + if (zb0007Mask & 0x1000000) == 0 { // if not empty // string "apid" o = append(o, 0xa4, 0x61, 0x70, 0x69, 0x64) o = (*z).ApplicationCallTxnFields.ApplicationID.MarshalMsg(o) } - if (zb0007Mask & 0x1000000) == 0 { // if not empty + if (zb0007Mask & 0x2000000) == 0 { // if not empty // string "apls" o = append(o, 0xa4, 0x61, 0x70, 0x6c, 0x73) o = (*z).ApplicationCallTxnFields.LocalStateSchema.MarshalMsg(o) } - if (zb0007Mask & 0x2000000) == 0 { // if not empty + if (zb0007Mask & 0x4000000) == 0 { // if not empty // string "apsu" o = append(o, 0xa4, 0x61, 0x70, 0x73, 0x75) o = msgp.AppendBytes(o, (*z).ApplicationCallTxnFields.ClearStateProgram) } - if (zb0007Mask & 0x4000000) == 0 { // if not empty + if (zb0007Mask & 0x8000000) == 0 { // if not empty // string "arcv" o = append(o, 0xa4, 0x61, 0x72, 0x63, 0x76) o = (*z).AssetTransferTxnFields.AssetReceiver.MarshalMsg(o) } - if (zb0007Mask & 0x8000000) == 0 { // if not empty + if (zb0007Mask & 0x10000000) == 0 { // if not empty // string "asnd" o = append(o, 0xa4, 0x61, 0x73, 0x6e, 0x64) o = (*z).AssetTransferTxnFields.AssetSender.MarshalMsg(o) } - if (zb0007Mask & 0x10000000) == 0 { // if not empty + if (zb0007Mask & 0x20000000) == 0 { // if not empty // string "caid" o = append(o, 0xa4, 0x63, 0x61, 0x69, 0x64) o = (*z).AssetConfigTxnFields.ConfigAsset.MarshalMsg(o) } - if (zb0007Mask & 0x20000000) == 0 { // if not empty + if (zb0007Mask & 0x40000000) == 0 { // if not empty // string "close" o = append(o, 0xa5, 0x63, 0x6c, 0x6f, 0x73, 0x65) o = (*z).PaymentTxnFields.CloseRemainderTo.MarshalMsg(o) } - if (zb0007Mask & 0x40000000) == 0 { // if not empty + if (zb0007Mask & 0x80000000) == 0 { // if not empty // string "fadd" o = append(o, 0xa4, 0x66, 0x61, 0x64, 0x64) o = (*z).AssetFreezeTxnFields.FreezeAccount.MarshalMsg(o) } - if (zb0007Mask & 0x80000000) == 0 { // if not empty + if (zb0007Mask & 0x100000000) == 0 { // if not empty // string "faid" o = append(o, 0xa4, 0x66, 0x61, 0x69, 0x64) o = (*z).AssetFreezeTxnFields.FreezeAsset.MarshalMsg(o) } - if (zb0007Mask & 0x100000000) == 0 { // if not empty + if (zb0007Mask & 0x200000000) == 0 { // if not empty // string "fee" o = append(o, 0xa3, 0x66, 0x65, 0x65) o = (*z).Header.Fee.MarshalMsg(o) } - if (zb0007Mask & 0x200000000) == 0 { // if not empty + if (zb0007Mask & 0x400000000) == 0 { // if not empty // string "fv" o = append(o, 0xa2, 0x66, 0x76) o = (*z).Header.FirstValid.MarshalMsg(o) } - if (zb0007Mask & 0x400000000) == 0 { // if not empty + if (zb0007Mask & 0x800000000) == 0 { // if not empty // string "gen" o = append(o, 0xa3, 0x67, 0x65, 0x6e) o = msgp.AppendString(o, (*z).Header.GenesisID) } - if (zb0007Mask & 0x800000000) == 0 { // if not empty + if (zb0007Mask & 0x1000000000) == 0 { // if not empty // string "gh" o = append(o, 0xa2, 0x67, 0x68) o = (*z).Header.GenesisHash.MarshalMsg(o) } - if (zb0007Mask & 0x1000000000) == 0 { // if not empty + if (zb0007Mask & 0x2000000000) == 0 { // if not empty // string "grp" o = append(o, 0xa3, 0x67, 0x72, 0x70) o = (*z).Header.Group.MarshalMsg(o) } - if (zb0007Mask & 0x2000000000) == 0 { // if not empty + if (zb0007Mask & 0x4000000000) == 0 { // if not empty + // string "hbad" + o = append(o, 0xa4, 0x68, 0x62, 0x61, 0x64) + o = (*z).HeartbeatTxnFields.HbAddress.MarshalMsg(o) + } + if (zb0007Mask & 0x8000000000) == 0 { // if not empty + // string "hbprf" + o = append(o, 0xa5, 0x68, 0x62, 0x70, 0x72, 0x66) + o = (*z).HeartbeatTxnFields.HbProof.MarshalMsg(o) + } + if (zb0007Mask & 0x10000000000) == 0 { // if not empty + // string "hbsd" + o = append(o, 0xa4, 0x68, 0x62, 0x73, 0x64) + o = (*z).HeartbeatTxnFields.HbSeed.MarshalMsg(o) + } + if (zb0007Mask & 0x20000000000) == 0 { // if not empty // string "lv" o = append(o, 0xa2, 0x6c, 0x76) o = (*z).Header.LastValid.MarshalMsg(o) } - if (zb0007Mask & 0x4000000000) == 0 { // if not empty + if (zb0007Mask & 0x40000000000) == 0 { // if not empty // string "lx" o = append(o, 0xa2, 0x6c, 0x78) o = msgp.AppendBytes(o, ((*z).Header.Lease)[:]) } - if (zb0007Mask & 0x8000000000) == 0 { // if not empty + if (zb0007Mask & 0x80000000000) == 0 { // if not empty // string "nonpart" o = append(o, 0xa7, 0x6e, 0x6f, 0x6e, 0x70, 0x61, 0x72, 0x74) o = msgp.AppendBool(o, (*z).KeyregTxnFields.Nonparticipation) } - if (zb0007Mask & 0x10000000000) == 0 { // if not empty + if (zb0007Mask & 0x100000000000) == 0 { // if not empty // string "note" o = append(o, 0xa4, 0x6e, 0x6f, 0x74, 0x65) o = msgp.AppendBytes(o, (*z).Header.Note) } - if (zb0007Mask & 0x20000000000) == 0 { // if not empty + if (zb0007Mask & 0x200000000000) == 0 { // if not empty // string "rcv" o = append(o, 0xa3, 0x72, 0x63, 0x76) o = (*z).PaymentTxnFields.Receiver.MarshalMsg(o) } - if (zb0007Mask & 0x40000000000) == 0 { // if not empty + if (zb0007Mask & 0x400000000000) == 0 { // if not empty // string "rekey" o = append(o, 0xa5, 0x72, 0x65, 0x6b, 0x65, 0x79) o = (*z).Header.RekeyTo.MarshalMsg(o) } - if (zb0007Mask & 0x80000000000) == 0 { // if not empty + if (zb0007Mask & 0x800000000000) == 0 { // if not empty // string "selkey" o = append(o, 0xa6, 0x73, 0x65, 0x6c, 0x6b, 0x65, 0x79) o = (*z).KeyregTxnFields.SelectionPK.MarshalMsg(o) } - if (zb0007Mask & 0x100000000000) == 0 { // if not empty + if (zb0007Mask & 0x1000000000000) == 0 { // if not empty // string "snd" o = append(o, 0xa3, 0x73, 0x6e, 0x64) o = (*z).Header.Sender.MarshalMsg(o) } - if (zb0007Mask & 0x200000000000) == 0 { // if not empty + if (zb0007Mask & 0x2000000000000) == 0 { // if not empty // string "sp" o = append(o, 0xa2, 0x73, 0x70) o = (*z).StateProofTxnFields.StateProof.MarshalMsg(o) } - if (zb0007Mask & 0x400000000000) == 0 { // if not empty + if (zb0007Mask & 0x4000000000000) == 0 { // if not empty // string "spmsg" o = append(o, 0xa5, 0x73, 0x70, 0x6d, 0x73, 0x67) o = (*z).StateProofTxnFields.Message.MarshalMsg(o) } - if (zb0007Mask & 0x800000000000) == 0 { // if not empty + if (zb0007Mask & 0x8000000000000) == 0 { // if not empty // string "sprfkey" o = append(o, 0xa7, 0x73, 0x70, 0x72, 0x66, 0x6b, 0x65, 0x79) o = (*z).KeyregTxnFields.StateProofPK.MarshalMsg(o) } - if (zb0007Mask & 0x1000000000000) == 0 { // if not empty + if (zb0007Mask & 0x10000000000000) == 0 { // if not empty // string "sptype" o = append(o, 0xa6, 0x73, 0x70, 0x74, 0x79, 0x70, 0x65) o = (*z).StateProofTxnFields.StateProofType.MarshalMsg(o) } - if (zb0007Mask & 0x2000000000000) == 0 { // if not empty + if (zb0007Mask & 0x20000000000000) == 0 { // if not empty // string "type" o = append(o, 0xa4, 0x74, 0x79, 0x70, 0x65) o = (*z).Type.MarshalMsg(o) } - if (zb0007Mask & 0x4000000000000) == 0 { // if not empty + if (zb0007Mask & 0x40000000000000) == 0 { // if not empty // string "votefst" o = append(o, 0xa7, 0x76, 0x6f, 0x74, 0x65, 0x66, 0x73, 0x74) o = (*z).KeyregTxnFields.VoteFirst.MarshalMsg(o) } - if (zb0007Mask & 0x8000000000000) == 0 { // if not empty + if (zb0007Mask & 0x80000000000000) == 0 { // if not empty // string "votekd" o = append(o, 0xa6, 0x76, 0x6f, 0x74, 0x65, 0x6b, 0x64) o = msgp.AppendUint64(o, (*z).KeyregTxnFields.VoteKeyDilution) } - if (zb0007Mask & 0x10000000000000) == 0 { // if not empty + if (zb0007Mask & 0x100000000000000) == 0 { // if not empty // string "votekey" o = append(o, 0xa7, 0x76, 0x6f, 0x74, 0x65, 0x6b, 0x65, 0x79) o = (*z).KeyregTxnFields.VotePK.MarshalMsg(o) } - if (zb0007Mask & 0x20000000000000) == 0 { // if not empty + if (zb0007Mask & 0x200000000000000) == 0 { // if not empty // string "votelst" o = append(o, 0xa7, 0x76, 0x6f, 0x74, 0x65, 0x6c, 0x73, 0x74) o = (*z).KeyregTxnFields.VoteLast.MarshalMsg(o) } - if (zb0007Mask & 0x40000000000000) == 0 { // if not empty + if (zb0007Mask & 0x400000000000000) == 0 { // if not empty // string "xaid" o = append(o, 0xa4, 0x78, 0x61, 0x69, 0x64) o = (*z).AssetTransferTxnFields.XferAsset.MarshalMsg(o) @@ -6086,6 +6290,30 @@ func (z *Transaction) UnmarshalMsgWithState(bts []byte, st msgp.UnmarshalState) return } } + if zb0007 > 0 { + zb0007-- + bts, err = (*z).HeartbeatTxnFields.HbAddress.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbAddress") + return + } + } + if zb0007 > 0 { + zb0007-- + bts, err = (*z).HeartbeatTxnFields.HbProof.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbProof") + return + } + } + if zb0007 > 0 { + zb0007-- + bts, err = (*z).HeartbeatTxnFields.HbSeed.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "struct-from-array", "HbSeed") + return + } + } if zb0007 > 0 { err = msgp.ErrTooManyArrayFields(zb0007) if err != nil { @@ -6618,6 +6846,24 @@ func (z *Transaction) UnmarshalMsgWithState(bts []byte, st msgp.UnmarshalState) err = msgp.WrapError(err, "Message") return } + case "hbad": + bts, err = (*z).HeartbeatTxnFields.HbAddress.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbAddress") + return + } + case "hbprf": + bts, err = (*z).HeartbeatTxnFields.HbProof.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbProof") + return + } + case "hbsd": + bts, err = (*z).HeartbeatTxnFields.HbSeed.UnmarshalMsgWithState(bts, st) + if err != nil { + err = msgp.WrapError(err, "HbSeed") + return + } default: err = msgp.ErrNoField(string(field)) if err != nil { @@ -6661,13 +6907,13 @@ func (z *Transaction) Msgsize() (s int) { for zb0006 := range (*z).ApplicationCallTxnFields.ForeignAssets { s += (*z).ApplicationCallTxnFields.ForeignAssets[zb0006].Msgsize() } - s += 5 + (*z).ApplicationCallTxnFields.LocalStateSchema.Msgsize() + 5 + (*z).ApplicationCallTxnFields.GlobalStateSchema.Msgsize() + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ApprovalProgram) + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ClearStateProgram) + 5 + msgp.Uint32Size + 7 + (*z).StateProofTxnFields.StateProofType.Msgsize() + 3 + (*z).StateProofTxnFields.StateProof.Msgsize() + 6 + (*z).StateProofTxnFields.Message.Msgsize() + s += 5 + (*z).ApplicationCallTxnFields.LocalStateSchema.Msgsize() + 5 + (*z).ApplicationCallTxnFields.GlobalStateSchema.Msgsize() + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ApprovalProgram) + 5 + msgp.BytesPrefixSize + len((*z).ApplicationCallTxnFields.ClearStateProgram) + 5 + msgp.Uint32Size + 7 + (*z).StateProofTxnFields.StateProofType.Msgsize() + 3 + (*z).StateProofTxnFields.StateProof.Msgsize() + 6 + (*z).StateProofTxnFields.Message.Msgsize() + 5 + (*z).HeartbeatTxnFields.HbAddress.Msgsize() + 6 + (*z).HeartbeatTxnFields.HbProof.Msgsize() + 5 + (*z).HeartbeatTxnFields.HbSeed.Msgsize() return } // MsgIsZero returns whether this is a zero value func (z *Transaction) MsgIsZero() bool { - return ((*z).Type.MsgIsZero()) && ((*z).Header.Sender.MsgIsZero()) && ((*z).Header.Fee.MsgIsZero()) && ((*z).Header.FirstValid.MsgIsZero()) && ((*z).Header.LastValid.MsgIsZero()) && (len((*z).Header.Note) == 0) && ((*z).Header.GenesisID == "") && ((*z).Header.GenesisHash.MsgIsZero()) && ((*z).Header.Group.MsgIsZero()) && ((*z).Header.Lease == ([32]byte{})) && ((*z).Header.RekeyTo.MsgIsZero()) && ((*z).KeyregTxnFields.VotePK.MsgIsZero()) && ((*z).KeyregTxnFields.SelectionPK.MsgIsZero()) && ((*z).KeyregTxnFields.StateProofPK.MsgIsZero()) && ((*z).KeyregTxnFields.VoteFirst.MsgIsZero()) && ((*z).KeyregTxnFields.VoteLast.MsgIsZero()) && ((*z).KeyregTxnFields.VoteKeyDilution == 0) && ((*z).KeyregTxnFields.Nonparticipation == false) && ((*z).PaymentTxnFields.Receiver.MsgIsZero()) && ((*z).PaymentTxnFields.Amount.MsgIsZero()) && ((*z).PaymentTxnFields.CloseRemainderTo.MsgIsZero()) && ((*z).AssetConfigTxnFields.ConfigAsset.MsgIsZero()) && ((*z).AssetConfigTxnFields.AssetParams.MsgIsZero()) && ((*z).AssetTransferTxnFields.XferAsset.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetAmount == 0) && ((*z).AssetTransferTxnFields.AssetSender.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetReceiver.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetCloseTo.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAccount.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAsset.MsgIsZero()) && ((*z).AssetFreezeTxnFields.AssetFrozen == false) && ((*z).ApplicationCallTxnFields.ApplicationID.MsgIsZero()) && ((*z).ApplicationCallTxnFields.OnCompletion == 0) && (len((*z).ApplicationCallTxnFields.ApplicationArgs) == 0) && (len((*z).ApplicationCallTxnFields.Accounts) == 0) && (len((*z).ApplicationCallTxnFields.ForeignApps) == 0) && (len((*z).ApplicationCallTxnFields.Boxes) == 0) && (len((*z).ApplicationCallTxnFields.ForeignAssets) == 0) && ((*z).ApplicationCallTxnFields.LocalStateSchema.MsgIsZero()) && ((*z).ApplicationCallTxnFields.GlobalStateSchema.MsgIsZero()) && (len((*z).ApplicationCallTxnFields.ApprovalProgram) == 0) && (len((*z).ApplicationCallTxnFields.ClearStateProgram) == 0) && ((*z).ApplicationCallTxnFields.ExtraProgramPages == 0) && ((*z).StateProofTxnFields.StateProofType.MsgIsZero()) && ((*z).StateProofTxnFields.StateProof.MsgIsZero()) && ((*z).StateProofTxnFields.Message.MsgIsZero()) + return ((*z).Type.MsgIsZero()) && ((*z).Header.Sender.MsgIsZero()) && ((*z).Header.Fee.MsgIsZero()) && ((*z).Header.FirstValid.MsgIsZero()) && ((*z).Header.LastValid.MsgIsZero()) && (len((*z).Header.Note) == 0) && ((*z).Header.GenesisID == "") && ((*z).Header.GenesisHash.MsgIsZero()) && ((*z).Header.Group.MsgIsZero()) && ((*z).Header.Lease == ([32]byte{})) && ((*z).Header.RekeyTo.MsgIsZero()) && ((*z).KeyregTxnFields.VotePK.MsgIsZero()) && ((*z).KeyregTxnFields.SelectionPK.MsgIsZero()) && ((*z).KeyregTxnFields.StateProofPK.MsgIsZero()) && ((*z).KeyregTxnFields.VoteFirst.MsgIsZero()) && ((*z).KeyregTxnFields.VoteLast.MsgIsZero()) && ((*z).KeyregTxnFields.VoteKeyDilution == 0) && ((*z).KeyregTxnFields.Nonparticipation == false) && ((*z).PaymentTxnFields.Receiver.MsgIsZero()) && ((*z).PaymentTxnFields.Amount.MsgIsZero()) && ((*z).PaymentTxnFields.CloseRemainderTo.MsgIsZero()) && ((*z).AssetConfigTxnFields.ConfigAsset.MsgIsZero()) && ((*z).AssetConfigTxnFields.AssetParams.MsgIsZero()) && ((*z).AssetTransferTxnFields.XferAsset.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetAmount == 0) && ((*z).AssetTransferTxnFields.AssetSender.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetReceiver.MsgIsZero()) && ((*z).AssetTransferTxnFields.AssetCloseTo.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAccount.MsgIsZero()) && ((*z).AssetFreezeTxnFields.FreezeAsset.MsgIsZero()) && ((*z).AssetFreezeTxnFields.AssetFrozen == false) && ((*z).ApplicationCallTxnFields.ApplicationID.MsgIsZero()) && ((*z).ApplicationCallTxnFields.OnCompletion == 0) && (len((*z).ApplicationCallTxnFields.ApplicationArgs) == 0) && (len((*z).ApplicationCallTxnFields.Accounts) == 0) && (len((*z).ApplicationCallTxnFields.ForeignApps) == 0) && (len((*z).ApplicationCallTxnFields.Boxes) == 0) && (len((*z).ApplicationCallTxnFields.ForeignAssets) == 0) && ((*z).ApplicationCallTxnFields.LocalStateSchema.MsgIsZero()) && ((*z).ApplicationCallTxnFields.GlobalStateSchema.MsgIsZero()) && (len((*z).ApplicationCallTxnFields.ApprovalProgram) == 0) && (len((*z).ApplicationCallTxnFields.ClearStateProgram) == 0) && ((*z).ApplicationCallTxnFields.ExtraProgramPages == 0) && ((*z).StateProofTxnFields.StateProofType.MsgIsZero()) && ((*z).StateProofTxnFields.StateProof.MsgIsZero()) && ((*z).StateProofTxnFields.Message.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbAddress.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbProof.MsgIsZero()) && ((*z).HeartbeatTxnFields.HbSeed.MsgIsZero()) } // MaxSize returns a maximum valid message size for this message type @@ -6689,7 +6935,7 @@ func TransactionMaxSize() (s int) { s += 5 // Calculating size of slice: z.ApplicationCallTxnFields.ForeignAssets s += msgp.ArrayHeaderSize + ((encodedMaxForeignAssets) * (basics.AssetIndexMaxSize())) - s += 5 + basics.StateSchemaMaxSize() + 5 + basics.StateSchemaMaxSize() + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.Uint32Size + 7 + protocol.StateProofTypeMaxSize() + 3 + stateproof.StateProofMaxSize() + 6 + stateproofmsg.MessageMaxSize() + s += 5 + basics.StateSchemaMaxSize() + 5 + basics.StateSchemaMaxSize() + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.BytesPrefixSize + config.MaxAvailableAppProgramLen + 5 + msgp.Uint32Size + 7 + protocol.StateProofTypeMaxSize() + 3 + stateproof.StateProofMaxSize() + 6 + stateproofmsg.MessageMaxSize() + 5 + basics.AddressMaxSize() + 6 + crypto.HeartbeatProofMaxSize() + 5 + committee.SeedMaxSize() return } diff --git a/data/transactions/msgp_gen_test.go b/data/transactions/msgp_gen_test.go index 0ce6b29c38..49ed14f6e3 100644 --- a/data/transactions/msgp_gen_test.go +++ b/data/transactions/msgp_gen_test.go @@ -494,6 +494,66 @@ func BenchmarkUnmarshalHeader(b *testing.B) { } } +func TestMarshalUnmarshalHeartbeatTxnFields(t *testing.T) { + partitiontest.PartitionTest(t) + v := HeartbeatTxnFields{} + bts := v.MarshalMsg(nil) + left, err := v.UnmarshalMsg(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left) + } + + left, err = msgp.Skip(bts) + if err != nil { + t.Fatal(err) + } + if len(left) > 0 { + t.Errorf("%d bytes left over after Skip(): %q", len(left), left) + } +} + +func TestRandomizedEncodingHeartbeatTxnFields(t *testing.T) { + protocol.RunEncodingTest(t, &HeartbeatTxnFields{}) +} + +func BenchmarkMarshalMsgHeartbeatTxnFields(b *testing.B) { + v := HeartbeatTxnFields{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + v.MarshalMsg(nil) + } +} + +func BenchmarkAppendMsgHeartbeatTxnFields(b *testing.B) { + v := HeartbeatTxnFields{} + bts := make([]byte, 0, v.Msgsize()) + bts = v.MarshalMsg(bts[0:0]) + b.SetBytes(int64(len(bts))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + bts = v.MarshalMsg(bts[0:0]) + } +} + +func BenchmarkUnmarshalHeartbeatTxnFields(b *testing.B) { + v := HeartbeatTxnFields{} + bts := v.MarshalMsg(nil) + b.ReportAllocs() + b.SetBytes(int64(len(bts))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := v.UnmarshalMsg(bts) + if err != nil { + b.Fatal(err) + } + } +} + func TestMarshalUnmarshalKeyregTxnFields(t *testing.T) { partitiontest.PartitionTest(t) v := KeyregTxnFields{} diff --git a/data/transactions/transaction.go b/data/transactions/transaction.go index 4a6d5b6603..363747a996 100644 --- a/data/transactions/transaction.go +++ b/data/transactions/transaction.go @@ -100,6 +100,7 @@ type Transaction struct { AssetFreezeTxnFields ApplicationCallTxnFields StateProofTxnFields + HeartbeatTxnFields } // ApplyData contains information about the transaction's execution. @@ -565,6 +566,11 @@ func (tx Transaction) WellFormed(spec SpecialAddresses, proto config.ConsensusPa return errLeaseMustBeZeroInStateproofTxn } + case protocol.HeartbeatTx: + if !proto.Heartbeat { + return fmt.Errorf("heartbeat transaction not supported") + } + default: return fmt.Errorf("unknown tx type %v", tx.Type) } @@ -598,6 +604,10 @@ func (tx Transaction) WellFormed(spec SpecialAddresses, proto config.ConsensusPa nonZeroFields[protocol.StateProofTx] = true } + if tx.HeartbeatTxnFields != (HeartbeatTxnFields{}) { + nonZeroFields[protocol.HeartbeatTx] = true + } + for t, nonZero := range nonZeroFields { if nonZero && t != tx.Type { return fmt.Errorf("transaction of type %v has non-zero fields for type %v", tx.Type, t) diff --git a/data/transactions/transaction_test.go b/data/transactions/transaction_test.go index 08dd145a8c..1dbb2e316a 100644 --- a/data/transactions/transaction_test.go +++ b/data/transactions/transaction_test.go @@ -591,6 +591,21 @@ func TestWellFormedErrors(t *testing.T) { proto: protoV36, expectedError: nil, }, + { + tx: Transaction{ + Type: protocol.HeartbeatTx, + Header: okHeader, + }, + proto: protoV36, + expectedError: fmt.Errorf("heartbeat transaction not supported"), + }, + { + tx: Transaction{ + Type: protocol.HeartbeatTx, + Header: okHeader, + }, + proto: futureProto, + }, } for _, usecase := range usecases { err := usecase.tx.WellFormed(SpecialAddresses{}, usecase.proto) diff --git a/data/transactions/verify/txn.go b/data/transactions/verify/txn.go index d0f38bca90..518528a3bc 100644 --- a/data/transactions/verify/txn.go +++ b/data/transactions/verify/txn.go @@ -220,10 +220,15 @@ func txnGroupBatchPrep(stxs []transactions.SignedTxn, contextHdr *bookkeeping.Bl prepErr.err = fmt.Errorf("transaction %+v invalid : %w", stxn, prepErr.err) return nil, prepErr } - if stxn.Txn.Type != protocol.StateProofTx { - minFeeCount++ - } feesPaid = basics.AddSaturate(feesPaid, stxn.Txn.Fee.Raw) + if stxn.Txn.Type == protocol.StateProofTx { + continue + } + if stxn.Txn.Type == protocol.HeartbeatTx && stxn.Txn.Group.IsZero() { + // in apply.Heartbeat, we further confirm that the heartbeat is for a challenged node + continue + } + minFeeCount++ } feeNeeded, overflow := basics.OMul(groupCtx.consensusParams.MinTxnFee, minFeeCount) if overflow { diff --git a/data/transactions/verify/txn_test.go b/data/transactions/verify/txn_test.go index 282a031097..5946399d0b 100644 --- a/data/transactions/verify/txn_test.go +++ b/data/transactions/verify/txn_test.go @@ -575,7 +575,7 @@ func TestPaysetGroups(t *testing.T) { startPaysetGroupsTime := time.Now() err := PaysetGroups(context.Background(), txnGroups, blkHdr, verificationPool, MakeVerifiedTransactionCache(50000), nil) require.NoError(t, err) - paysetGroupDuration := time.Now().Sub(startPaysetGroupsTime) + paysetGroupDuration := time.Since(startPaysetGroupsTime) // break the signature and see if it fails. txnGroups[0][0].Sig[0] = txnGroups[0][0].Sig[0] + 1 @@ -609,7 +609,7 @@ func TestPaysetGroups(t *testing.T) { // channel is closed without a return require.Failf(t, "Channel got closed ?!", "") } else { - actualDuration := time.Now().Sub(startPaysetGroupsTime) + actualDuration := time.Since(startPaysetGroupsTime) if err == nil { if actualDuration > 4*time.Second { // it took at least 2.5 seconds more than it should have had! diff --git a/data/transactions/verify/verifiedTxnCache_test.go b/data/transactions/verify/verifiedTxnCache_test.go index d27510fe6a..03f5cac288 100644 --- a/data/transactions/verify/verifiedTxnCache_test.go +++ b/data/transactions/verify/verifiedTxnCache_test.go @@ -127,7 +127,7 @@ func BenchmarkGetUnverifiedTransactionGroups50(b *testing.B) { for i := 0; i < measuringMultipler; i++ { impl.GetUnverifiedTransactionGroups(queryTxnGroups, spec, protocol.ConsensusCurrentVersion) } - duration := time.Now().Sub(startTime) + duration := time.Since(startTime) // calculate time per 10K verified entries: t := int(duration*10000) / (measuringMultipler * b.N) b.ReportMetric(float64(t)/float64(time.Millisecond), "ms/10K_cache_compares") diff --git a/data/txntest/txn.go b/data/txntest/txn.go index aea4de005b..d734f47576 100644 --- a/data/txntest/txn.go +++ b/data/txntest/txn.go @@ -26,6 +26,7 @@ import ( "github.com/algorand/go-algorand/crypto/merklesignature" "github.com/algorand/go-algorand/crypto/stateproof" "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/committee" "github.com/algorand/go-algorand/data/stateproofmsg" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/data/transactions/logic" @@ -91,6 +92,10 @@ type Txn struct { StateProofType protocol.StateProofType StateProof stateproof.StateProof StateProofMsg stateproofmsg.Message + + HbAddress basics.Address + HbProof crypto.HeartbeatProof + HbSeed committee.Seed } // internalCopy "finishes" a shallow copy done by a simple Go assignment by @@ -281,6 +286,11 @@ func (tx Txn) Txn() transactions.Transaction { StateProof: tx.StateProof, Message: tx.StateProofMsg, }, + HeartbeatTxnFields: transactions.HeartbeatTxnFields{ + HbAddress: tx.HbAddress, + HbProof: tx.HbProof, + HbSeed: tx.HbSeed, + }, } } diff --git a/heartbeat/README.md b/heartbeat/README.md new file mode 100644 index 0000000000..f1a522928d --- /dev/null +++ b/heartbeat/README.md @@ -0,0 +1,180 @@ +# Block Payouts, Suspensions, and Heartbeats + +Running a validator node on Algorand is a relatively lightweight operation. Therefore, participation +in consensus was not compensated. There was an expectation that financial motivated holders of Algos +would run nodes in order to help secure their holdings. + +Although simple participation is not terribly resource intensive, running _any_ service with high +uptime becomes expensive when one considers that it should be monitored for uptime, be somewhat +over-provisioned to handle unexpected load spikes, and plans need to be in place to restart in the +face of hardware failure (or the accounts should leave consensus properly). + +With those burdens in mind, fewer Algo holders chose to run participation nodes than would be +preferred to provide security against well-financed bad actors. To alleviate this problem, a +mechanism to reward block proposers has been created. With these _block payouts_ in place, large +Algo holders are incentivized to run participation nodes in order to earn more Algos, increasing +security for the entire Algorand network. + +With the financial incentive to run participation nodes comes the risk that some nodes may be +operated without sufficient care. Therefore, a mechanism to _suspend_ nodes that appear to be +performing poorly (or not at all). Appearances can be deceiving, however. Since Algorand is a +probabilistic consensus protocol, pure chance might lead to a node appearing to be delinquent. A new +transaction type, the _heartbeat_, allows a node to explicitly indicate that it is online even if it +does not propose blocks due to "bad luck". + +# Payouts + +Payouts are made in every block, if the proposer has opted into receiving them, has an Algo balance +in an appropriate range, and has not been suspended for poor behavior since opting-in. The size of +the payout is indicated in the block header, and comes from the `FeeSink`. The block payout consist +of two components. First, a portion of the block fees (currently 50%) are paid to the proposer. +This component incentives fuller blocks which lead to larger payouts. Second, a _bonus_ payout is +made according to a exponentially decaying formula. This bonus is (intentionally) unsustainable +from protocol fees. It is expected that the Algorand Foundation will seed the `FeeSink` with +sufficient funds to allow the bonuses to be paid out according to the formula for several years. If +the `FeeSink` has insufficient funds for the sum of these components, the payout will be as high as +possible while maintaining the `FeeSink`'s minimum balance. These calculations are performed in +`endOfBlock` in `eval/eval.go`. + +To opt-in to receiving block payouts, an account includes an extra fee in the `keyreg` +transaction. The amount is controlled by the consensus parameter `Payouts.GoOnlineFee`. When such a +fee is included, a new account state bit, `IncentiveEligible` is set to true. + +Even when an account is `IncentiveEligible` there is a proposal-time check of the account's online +stake. If the account has too much or too little, no payout is performed (though +`IncentiveEligible` remains true). As explained below, this check occurs in `agreement` code in +`payoutEligible()`. The balance check is performed on the _online_ stake, that is the stake from 320 +rounds earlier, so a clever proposer can not move Algos in the round it proposes in order to receive +the payout. Finally, in an interesting corner case, a proposing account could be closed at proposal +time, since voting is based on the earlier balance. Such an account receives no payout, even if its +balances was in the proper range 320 rounds ago. + +A surprising complication in the implementation of these payouts is that when a block is prepared by +a node, it does not know which account is the proposer. Until now, `algod` could prepare a single +block which would be used by any of the accounts it was participating for. The block would be +handed off to `agreement` which would manipulate the block only to add the appropriate block seed +(which depended upon the proposer). That interaction between `eval` and `agreement` was widened +(see `WithProposer()`) to allow `agreement` to modify the block to include the proper `Proposer`, +and to zero the `ProposerPayout` if the account that proposed was not actually eligible to receive a +payout. + +# Suspensions + +Accounts can be _suspended_ for poor behavior. There are two forms of poor behavior that can lead +to suspension. First, an account is considered _absent_ if it fails to propose as often as it +should. Second, an account can be suspended for failing to respond to a _challenge_ issued by the +network at random. + +## Absenteeism + +An account can be expected to propose once every `n = TotalOnlineStake/AccountOnlineStake` rounds. +For example, a node with 2% of online stake ought to propose once every 50 rounds. Of course the +actual proposer is chosen by random sortition. To make false positive suspensions unlikely, a node +is considered absent if it fails to produce a block over the course of `10n` rounds. + +The suspension mechanism is implemented in `generateKnockOfflineAccountsList` in `eval/eval.go`. It +is closely modeled on the mechanism that knocks accounts offline if their voting keys have expired. +An absent account is added to the `AbsentParticipationAccounts` list of the block header. When +evaluating a block, accounts in `AbsentParticipationAccounts` are suspended by changing their +`Status` to `Offline` and setting `IncentiveEligible` to false, but retaining their voting keys. + +### Keyreg and `LastHeartbeat` + +As described so far, 320 rounds after a `keyreg` to go online, an account suddenly is expected to +have proposed more recently than 10 times its new expected interval. That would be impossible, since +it was not online until that round. Therefore, when a `keyreg` is used to go online and become +`IncentiveEligible`, the account's `LastHeartbeat` field is set 320 rounds into the future. In +effect, the account is treated as though it proposed in the first round it is online. + +### Large Algo increases and `LastHeartbeat` + +A similar problem can occur when an online account receives Algos. 320 rounds after receiving the +new Algos, the account's expected proposal interval will shrink. If, for example, such an account +increases by a factor of 10, then it is reasonably likely that it will not have proposed recently +enough, and will be suspended immediately. To mitigate this risk, any time an online, +`IncentiveEligible` account balance doubles from a single `Pay`, its `LastHeartbeat` is incremented +to 320 rounds past the current round. + +## Challenges + +The absenteeism checks quickly suspend a high-value account if it becomes inoperative. For example, +and account with 2% of stake can be marked absent after 500 rounds (about 24 minutes). After +suspension, the effect on consensus is mitigated after 320 more rounds (about 15 +minutes). Therefore, the suspension mechanism makes Algorand significantly more robust in the face +of operational errors. + +However, the absenteeism mechanism is very slow to notice small accounts. An account with 30,000 +Algos might represent 1/100,000 or less of total stake. It would only be considered absent after a +million or more rounds without a proposal. At current network speeds, this is about a month. With such +slow detection, a financially motived entity might make the decision to run a node even if they lack +the wherewithal to run the node with excellent uptime. A worst case scenario might be a node that is +turned off daily, overnight. Such a node would generate profit for the runner, would probably never +be marked offline by the absenteeism mechanism, yet would impact consensus negatively. Algorand +can't make progress with 1/3 of nodes offline at any given time for a nightly rest. + +To combat this scenario, the network generates random _challenges_ periodically. Every +`Payouts.ChallengeInterval` rounds (currently 1000), a random selected portion (currently 1/32) of +all online accounts are challenged. They must _heartbeat_ within `Payouts.ChallengeGracePeriod` +rounds (currently 200), or they will be subject to suspension. With the current consensus +parameters, nodes can be expected to be challenged daily. When suspended, accounts must `keyreg` +with the `GoOnlineFee` in order to receive block payouts again, so it becomes unprofitable for +these low-stake nodes to operate with poor uptimes. + +# Heartbeats + +The absenteeism mechanism is subject to rare false positives. The challenge mechanism explicitly +requires an affirmative response from nodes to indicate they are operating properly on behalf of a +challenged account. Both of these needs are addressed by a new transaction type --- _Heartbeat_. A +Heartbeat transaction contains a signature (`HbProof`) of the blockseed (`HbSeed`) of the +transaction's FirstValid block under the participation key of the account (`HbAddress`) in +question. Note that the account being heartbeat for is _not_ the `Sender` of the transaction, which +can be any address. Signing a recent block seed makes it more difficult to pre-sign heartbeats that +another machine might send on your behalf. Signing the FirstValid's blockseed (rather than +FirstValid-1) simply enforces a best practice: emit a transaction with FirstValid set to a committed +round, not a future round, avoiding a race. The node you send transactions to might not have +committed your latest round yet. + +It is relatively easy for a bad actor to emit Heartbeats for its accounts without actually +participating. However, there is no financial incentive to do so. Pretending to be operational when +offline does not earn block payouts. Furthermore, running a server to monitor the block chain to +notice challenges and gather the recent blockseed is not significantly cheaper that simply running a +functional node. It is _already_ possible for malicious, well-resourced accounts to cause consensus +difficulties by putting significant stake online without actually participating. Heartbeats do not +mitigate that risk. But these mechanisms have been designed to avoid _motivating_ such behavior, so +that they can accomplish their actual goal of noticing poor behavior stemming from _inadvertent_ +operational problems. + +## Free Heartbeats + +Challenges occur frequently, so it important that `algod` can easily send Heartbeats as +required. How should these transactions be paid for? Many accounts, especially high-value accounts, +would not want to keep their spending keys available for automatic use by `algod`. Further, creating +(and keeping funded) a low-value side account to pay for Heartbeats would be an annoying operational +overhead. Therefore, when required by challenges, heartbeat transactions do not require a fee. +Therefore, any account, even an unfunded logigsig, can send heartbeats for an account under +challenge. + +The conditions for a free Heartbeat are: + +1. The Heartbeat is not part of a larger group, and has a zero `GroupID`. +1. The `HbAddress` is Online and under challenge with the grace period at least half over. +1. The `HbAddress` is `IncentiveEligible`. +1. There is no `Note`, `Lease`, or `RekeyTo`. + +## Heartbeat Service + +The Heartbeat Service (`heartbeat/service.go`) watches the state of all acounts for which `algod` +has participation keys. If any of those accounts meets the requirements above, a heartbeat +transaction is sent, starting with the round following half a grace period from the challenge. It +uses the (presumably unfunded) logicsig that does nothing except preclude rekey operations. + +The heartbeat service does _not_ heartbeat if an account is unlucky and threatened to be considered +absent. We presume such false postives to be so unlikely that, if they occur, the node must be +brought back online manually. It would be reasonable to consider in the future: + +1. Making heartbeats free for accounts that are "nearly absent". + +or + +2. Allowing for paid heartbeats by the heartbeat service when configured with access to a funded + account's spending key. diff --git a/heartbeat/abstractions.go b/heartbeat/abstractions.go new file mode 100644 index 0000000000..a03aa26b6e --- /dev/null +++ b/heartbeat/abstractions.go @@ -0,0 +1,54 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package heartbeat + +import ( + "github.com/algorand/go-algorand/data/account" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/transactions" + "github.com/algorand/go-algorand/ledger/ledgercore" +) + +// txnBroadcaster is an interface that captures the node's ability to broadcast +// a new transaction. +type txnBroadcaster interface { + BroadcastInternalSignedTxGroup([]transactions.SignedTxn) error +} + +// ledger represents the aspects of the "real" Ledger that the heartbeat service +// needs to interact with +type ledger interface { + // LastRound tells the round is ready for checking + LastRound() basics.Round + + // WaitMem allows the Service to wait for the results of a round to be available + WaitMem(r basics.Round) chan struct{} + + // BlockHdr allows the service access to consensus values + BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) + + // LookupAccount allows the Service to observe accounts for suspension + LookupAccount(round basics.Round, addr basics.Address) (data ledgercore.AccountData, validThrough basics.Round, withoutRewards basics.MicroAlgos, err error) +} + +// participants captures the aspects of the AccountManager that are used by this +// package. Service must be able to find out which accounts to monitor and have +// access to their part keys to construct heartbeats. +type participants interface { + Keys(rnd basics.Round) []account.ParticipationRecordForRound +} diff --git a/heartbeat/service.go b/heartbeat/service.go new file mode 100644 index 0000000000..c3d66239e2 --- /dev/null +++ b/heartbeat/service.go @@ -0,0 +1,183 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package heartbeat + +import ( + "context" + "sync" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/data/account" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/transactions" + "github.com/algorand/go-algorand/data/transactions/logic" + "github.com/algorand/go-algorand/ledger/apply" + "github.com/algorand/go-algorand/logging" + "github.com/algorand/go-algorand/protocol" +) + +// Service emits keep-alive heartbeats for accts that are in danger of +// suspension. +type Service struct { + // addresses that should be monitored for suspension + accts participants + // current status and balances + ledger ledger + // where to send the heartbeats + bcast txnBroadcaster + + // infrastructure + ctx context.Context + shutdown context.CancelFunc + wg sync.WaitGroup + log logging.Logger +} + +// NewService creates a heartbeat service. It will need to know which accounts +// to emit heartbeats for, and how to create the heartbeats. +func NewService(accts participants, ledger ledger, bcast txnBroadcaster, log logging.Logger) *Service { + return &Service{ + accts: accts, + ledger: ledger, + bcast: bcast, + log: log.With("Context", "heartbeat"), + } +} + +// Start starts the goroutines for the Service. +func (s *Service) Start() { + s.ctx, s.shutdown = context.WithCancel(context.Background()) + s.wg.Add(1) + s.log.Info("starting heartbeat service") + go s.loop() +} + +// Stop any goroutines associated with this worker. +func (s *Service) Stop() { + s.log.Debug("heartbeat service is stopping") + defer s.log.Debug("heartbeat service has stopped") + s.shutdown() + s.wg.Wait() +} + +// findChallenged() returns a list of accounts that need a heartbeat because +// they have been challenged. +func (s *Service) findChallenged(rules config.ProposerPayoutRules, current basics.Round) []account.ParticipationRecordForRound { + ch := apply.FindChallenge(rules, current, s.ledger, apply.ChRisky) + if ch.IsZero() { + return nil + } + + var found []account.ParticipationRecordForRound + for _, pr := range s.accts.Keys(current + 1) { // only look at accounts we have part keys for + acct, _, _, err := s.ledger.LookupAccount(current, pr.Account) + if err != nil { + s.log.Errorf("error looking up %v: %v", pr.Account, err) + continue + } + if acct.Status == basics.Online { + if ch.Failed(pr.Account, acct.LastSeen()) { + s.log.Infof(" %v needs a heartbeat\n", pr.Account) + found = append(found, pr) + } + } + /* If we add a grace period to suspension for absenteeism, then we could + also make it free to heartbeat during that period. */ + } + return found +} + +// loop monitors for any of Service's participants being suspended. If they are, +// it tries to being them back online by emitting a heartbeat transaction. It +// could try to predict an upcoming suspension, which would prevent the +// suspension from ever occurring, but that would be considerably more complex +// both to avoid emitting repeated heartbeats, and to ensure the prediction and +// the suspension logic match. This feels like a cleaner end-to-end test, at +// the cost of lost couple rounds of participation. (Though suspension is +// designed to be extremely unlikely anyway.) +func (s *Service) loop() { + defer s.wg.Done() + suppress := make(map[basics.Address]basics.Round) + latest := s.ledger.LastRound() + for { + // exit if Done, else wait for next round + select { + case <-s.ctx.Done(): + return + case <-s.ledger.WaitMem(latest + 1): + } + + latest = s.ledger.LastRound() + + lastHdr, err := s.ledger.BlockHdr(latest) + if err != nil { + s.log.Errorf("heartbeat service could not fetch block header for round %d: %v", latest, err) + continue // Try again next round, I guess? + } + proto := config.Consensus[lastHdr.CurrentProtocol] + + for _, pr := range s.findChallenged(proto.Payouts, latest) { + if suppress[pr.Account] > latest { + continue + } + stxn := s.prepareHeartbeat(pr, lastHdr) + s.log.Infof("sending heartbeat %v for %v\n", stxn.Txn.HeartbeatTxnFields, pr.Account) + err = s.bcast.BroadcastInternalSignedTxGroup([]transactions.SignedTxn{stxn}) + if err != nil { + s.log.Errorf("error broadcasting heartbeat %v for %v: %v", stxn, pr.Account, err) + } else { + // Don't bother heartbeating again until the last one expires. + // If it is accepted, we won't need to (because we won't be + // under challenge any more). + suppress[pr.Account] = stxn.Txn.LastValid + } + } + } +} + +// acceptingByteCode is the byte code to a logic signature that will accept anything (except rekeying). +var acceptingByteCode = logic.MustAssemble(` +#pragma version 11 +txn RekeyTo; global ZeroAddress; == +`) +var acceptingSender = basics.Address(logic.HashProgram(acceptingByteCode)) + +// hbLifetime is somewhat short. It seems better to try several times during the +// grace period than to try a single time with a longer lifetime. +const hbLifetime = 10 + +func (s *Service) prepareHeartbeat(pr account.ParticipationRecordForRound, latest bookkeeping.BlockHeader) transactions.SignedTxn { + var stxn transactions.SignedTxn + stxn.Lsig = transactions.LogicSig{Logic: acceptingByteCode} + stxn.Txn.Type = protocol.HeartbeatTx + stxn.Txn.Header = transactions.Header{ + Sender: acceptingSender, + FirstValid: latest.Round, + LastValid: latest.Round + hbLifetime, + GenesisHash: latest.GenesisHash, + } + + id := basics.OneTimeIDForRound(stxn.Txn.LastValid, pr.KeyDilution) + stxn.Txn.HeartbeatTxnFields = transactions.HeartbeatTxnFields{ + HbAddress: pr.Account, + HbProof: pr.Voting.Sign(id, latest.Seed).ToHeartbeatProof(), + HbSeed: latest.Seed, + } + + return stxn +} diff --git a/heartbeat/service_test.go b/heartbeat/service_test.go new file mode 100644 index 0000000000..3422ffdea4 --- /dev/null +++ b/heartbeat/service_test.go @@ -0,0 +1,268 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package heartbeat + +import ( + "fmt" + "testing" + "time" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/data/account" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/committee" + "github.com/algorand/go-algorand/data/transactions" + "github.com/algorand/go-algorand/ledger/ledgercore" + "github.com/algorand/go-algorand/logging" + "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/test/partitiontest" + "github.com/algorand/go-deadlock" + "github.com/stretchr/testify/require" +) + +type table map[basics.Address]ledgercore.AccountData + +type mockedLedger struct { + mu deadlock.Mutex + waiters map[basics.Round]chan struct{} + history []table + hdr bookkeeping.BlockHeader + + participants map[basics.Address]*crypto.OneTimeSignatureSecrets +} + +func newMockedLedger() mockedLedger { + return mockedLedger{ + waiters: make(map[basics.Round]chan struct{}), + history: []table{nil}, // some genesis accounts could go here + hdr: bookkeeping.BlockHeader{ + UpgradeState: bookkeeping.UpgradeState{ + CurrentProtocol: protocol.ConsensusFuture, + }, + }, + } +} + +func (l *mockedLedger) LastRound() basics.Round { + l.mu.Lock() + defer l.mu.Unlock() + return l.lastRound() +} +func (l *mockedLedger) lastRound() basics.Round { + return basics.Round(len(l.history) - 1) +} + +func (l *mockedLedger) WaitMem(r basics.Round) chan struct{} { + l.mu.Lock() + defer l.mu.Unlock() + + if l.waiters[r] == nil { + l.waiters[r] = make(chan struct{}) + } + + // Return an already-closed channel if we already have the block. + if r <= l.lastRound() { + close(l.waiters[r]) + retChan := l.waiters[r] + delete(l.waiters, r) + return retChan + } + + return l.waiters[r] +} + +// BlockHdr allows the service access to consensus values +func (l *mockedLedger) BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) { + if r > l.LastRound() { + return bookkeeping.BlockHeader{}, fmt.Errorf("%d is beyond current block (%d)", r, l.LastRound()) + } + // return the template hdr, with round + hdr := l.hdr + hdr.Round = r + return hdr, nil +} + +func (l *mockedLedger) addBlock(delta table) error { + l.mu.Lock() + defer l.mu.Unlock() + + fmt.Printf("addBlock %d\n", l.lastRound()+1) + l.history = append(l.history, delta) + + for r, ch := range l.waiters { + switch { + case r < l.lastRound(): + fmt.Printf("%d < %d\n", r, l.lastRound()) + panic("why is there a waiter for an old block?") + case r == l.lastRound(): + close(ch) + delete(l.waiters, r) + case r > l.lastRound(): + /* waiter keeps waiting */ + } + } + return nil +} + +func (l *mockedLedger) LookupAccount(round basics.Round, addr basics.Address) (ledgercore.AccountData, basics.Round, basics.MicroAlgos, error) { + l.mu.Lock() + defer l.mu.Unlock() + + if round > l.lastRound() { + panic("mockedLedger.LookupAccount: future round") + } + + for r := round; r <= round; r-- { + if acct, ok := l.history[r][addr]; ok { + more := basics.MicroAlgos{Raw: acct.MicroAlgos.Raw + 1} + return acct, round, more, nil + } + } + return ledgercore.AccountData{}, round, basics.MicroAlgos{}, nil +} + +// waitFor confirms that the Service made it through the last block in the +// ledger and is waiting for the next. The Service is written such that it +// operates properly without this sort of wait, but for testing, we often want +// to wait so that we can confirm that the Service *didn't* do something. +func (l *mockedLedger) waitFor(s *Service, a *require.Assertions) { + a.Eventually(func() bool { // delay and confirm that the service advances to wait for next block + _, ok := l.waiters[l.LastRound()+1] + return ok + }, time.Second, 10*time.Millisecond) +} + +func (l *mockedLedger) Keys(rnd basics.Round) []account.ParticipationRecordForRound { + var ret []account.ParticipationRecordForRound + for addr, secrets := range l.participants { + if rnd > l.LastRound() { // Usually we're looking for key material for a future round + rnd = l.LastRound() + } + acct, _, _, err := l.LookupAccount(rnd, addr) + if err != nil { + panic(err.Error()) + } + + ret = append(ret, account.ParticipationRecordForRound{ + ParticipationRecord: account.ParticipationRecord{ + ParticipationID: [32]byte{}, + Account: addr, + Voting: secrets, + FirstValid: acct.VoteFirstValid, + LastValid: acct.VoteLastValid, + KeyDilution: acct.VoteKeyDilution, + }, + }) + } + return ret +} + +func (l *mockedLedger) addParticipant(addr basics.Address, otss *crypto.OneTimeSignatureSecrets) { + if l.participants == nil { + l.participants = make(map[basics.Address]*crypto.OneTimeSignatureSecrets) + } + l.participants[addr] = otss +} + +type txnSink [][]transactions.SignedTxn + +func (ts *txnSink) BroadcastInternalSignedTxGroup(group []transactions.SignedTxn) error { + fmt.Printf("sinking %+v\n", group[0].Txn.Header) + *ts = append(*ts, group) + return nil +} + +func TestStartStop(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + a := require.New(t) + sink := txnSink{} + ledger := newMockedLedger() + s := NewService(&ledger, &ledger, &sink, logging.TestingLog(t)) + a.NotNil(s) + a.NoError(ledger.addBlock(nil)) + s.Start() + a.NoError(ledger.addBlock(nil)) + s.Stop() +} + +func makeBlock(r basics.Round) bookkeeping.Block { + return bookkeeping.Block{ + BlockHeader: bookkeeping.BlockHeader{Round: r}, + Payset: []transactions.SignedTxnInBlock{}, + } +} + +func TestHeartbeatOnlyWhenChallenged(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + a := require.New(t) + sink := txnSink{} + ledger := newMockedLedger() + s := NewService(&ledger, &ledger, &sink, logging.TestingLog(t)) + s.Start() + + joe := basics.Address{0xcc} // 0xcc will matter when we set the challenge + mary := basics.Address{0xaa} // 0xaa will matter when we set the challenge + ledger.addParticipant(joe, nil) + ledger.addParticipant(mary, nil) + + acct := ledgercore.AccountData{} + + a.NoError(ledger.addBlock(table{joe: acct})) + ledger.waitFor(s, a) + a.Empty(sink) + + // now they are online, but not challenged, so no heartbeat + acct.Status = basics.Online + acct.VoteKeyDilution = 100 + startBatch := basics.OneTimeIDForRound(ledger.LastRound(), acct.VoteKeyDilution).Batch + const batches = 50 // gives 50 * kd rounds = 5000 + otss := crypto.GenerateOneTimeSignatureSecrets(startBatch, batches) + acct.VoteID = otss.OneTimeSignatureVerifier + ledger.addParticipant(joe, otss) + ledger.addParticipant(mary, otss) + + a.NoError(ledger.addBlock(table{joe: acct, mary: acct})) + a.Empty(sink) + + // now we have to make it seem like joe has been challenged. We obtain the + // payout rules to find the first challenge round, skip forward to it, then + // go forward half a grace period. Only then should the service heartbeat + hdr, err := ledger.BlockHdr(ledger.LastRound()) + ledger.hdr.Seed = committee.Seed{0xc8} // share 5 bits with 0xcc + a.NoError(err) + rules := config.Consensus[hdr.CurrentProtocol].Payouts + for ledger.LastRound() < basics.Round(rules.ChallengeInterval+rules.ChallengeGracePeriod/2) { + a.NoError(ledger.addBlock(table{})) + ledger.waitFor(s, a) + a.Empty(sink) + } + + a.NoError(ledger.addBlock(table{joe: acct})) + ledger.waitFor(s, a) + a.Len(sink, 1) // only one heartbeat (for joe) + a.Len(sink[0], 1) + a.Equal(sink[0][0].Txn.Type, protocol.HeartbeatTx) + a.Equal(sink[0][0].Txn.HbAddress, joe) + + s.Stop() +} diff --git a/ledger/apply/apply.go b/ledger/apply/apply.go index dfa61b2632..ecc96c967f 100644 --- a/ledger/apply/apply.go +++ b/ledger/apply/apply.go @@ -25,9 +25,14 @@ import ( "github.com/algorand/go-algorand/ledger/ledgercore" ) +// HdrProvider allows fetching old block headers +type HdrProvider interface { + BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) +} + // StateProofsApplier allows fetching and updating state-proofs state on the ledger type StateProofsApplier interface { - BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) + HdrProvider GetStateProofNextRound() basics.Round SetStateProofNextRound(rnd basics.Round) GetStateProofVerificationContext(stateProofLastAttestedRound basics.Round) (*ledgercore.StateProofVerificationContext, error) diff --git a/ledger/apply/challenge.go b/ledger/apply/challenge.go new file mode 100644 index 0000000000..fa060879e6 --- /dev/null +++ b/ledger/apply/challenge.go @@ -0,0 +1,116 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package apply + +import ( + "math/bits" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/committee" +) + +// ChallengePeriod indicates which part of the challenge period is under discussion. +type ChallengePeriod int + +const ( + // ChRisky indicates that a challenge is in effect, and the initial grace period is running out. + ChRisky ChallengePeriod = iota + // ChActive indicates that a challenege is in effect, and the grace period + // has run out, so accounts can be suspended + ChActive +) + +type challenge struct { + // round is when the challenge occurred. 0 means this is not a challenge. + round basics.Round + // accounts that match the first `bits` of `seed` must propose or heartbeat to stay online + seed committee.Seed + bits int +} + +type headerSource interface { + BlockHdr(round basics.Round) (bookkeeping.BlockHeader, error) +} + +// FindChallenge returns the Challenge that was last issued if it's in the period requested. +func FindChallenge(rules config.ProposerPayoutRules, current basics.Round, headers headerSource, period ChallengePeriod) challenge { + // are challenges active? + interval := basics.Round(rules.ChallengeInterval) + if rules.ChallengeInterval == 0 || current < interval { + return challenge{} + } + lastChallenge := current - (current % interval) + grace := basics.Round(rules.ChallengeGracePeriod) + // FindChallenge is structured this way, instead of returning the challenge + // and letting the caller determine the period it cares about, to avoid + // using BlockHdr unnecessarily. + switch period { + case ChRisky: + if current <= lastChallenge+grace/2 || current > lastChallenge+grace { + return challenge{} + } + case ChActive: + if current <= lastChallenge+grace || current > lastChallenge+2*grace { + return challenge{} + } + } + challengeHdr, err := headers.BlockHdr(lastChallenge) + if err != nil { + panic(err) + } + challengeProto := config.Consensus[challengeHdr.CurrentProtocol] + // challenge is not considered if rules have changed since that round + if challengeProto.Payouts != rules { + return challenge{} + } + return challenge{lastChallenge, challengeHdr.Seed, rules.ChallengeBits} +} + +// IsZero returns true if the challenge is empty (used to indicate no challenege) +func (ch challenge) IsZero() bool { + return ch == challenge{} +} + +// Failed returns true iff ch is in effect, matches address, and lastSeen is +// before the challenge issue. +func (ch challenge) Failed(address basics.Address, lastSeen basics.Round) bool { + return ch.round != 0 && bitsMatch(ch.seed[:], address[:], ch.bits) && lastSeen < ch.round +} + +// bitsMatch checks if the first n bits of two byte slices match. Written to +// work on arbitrary slices, but we expect that n is small. Only user today +// calls with n=5. +func bitsMatch(a, b []byte, n int) bool { + // Ensure n is a valid number of bits to compare + if n < 0 || n > len(a)*8 || n > len(b)*8 { + return false + } + + // Compare entire bytes when n is bigger than 8 + for i := 0; i < n/8; i++ { + if a[i] != b[i] { + return false + } + } + remaining := n % 8 + if remaining == 0 { + return true + } + return bits.LeadingZeros8(a[n/8]^b[n/8]) >= remaining +} diff --git a/ledger/apply/challenge_test.go b/ledger/apply/challenge_test.go new file mode 100644 index 0000000000..3114b6f935 --- /dev/null +++ b/ledger/apply/challenge_test.go @@ -0,0 +1,121 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package apply + +import ( + "testing" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/test/partitiontest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBitsMatch(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + for b := 0; b <= 6; b++ { + require.True(t, bitsMatch([]byte{0x1}, []byte{0x2}, b), "%d", b) + } + require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 7)) + require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 8)) + require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 9)) + + for b := 0; b <= 12; b++ { + require.True(t, bitsMatch([]byte{0x1, 0xff, 0xaa}, []byte{0x1, 0xf0}, b), "%d", b) + } + require.False(t, bitsMatch([]byte{0x1, 0xff, 0xaa}, []byte{0x1, 0xf0}, 13)) + + // on a byte boundary + require.True(t, bitsMatch([]byte{0x1}, []byte{0x1}, 8)) + require.False(t, bitsMatch([]byte{0x1}, []byte{0x1}, 9)) + require.True(t, bitsMatch([]byte{0x1, 0xff}, []byte{0x1, 0x00}, 8)) + require.False(t, bitsMatch([]byte{0x1, 0xff}, []byte{0x1, 00}, 9)) +} + +func TestFailsChallenge(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + a := assert.New(t) + + // a valid challenge, with 4 matching bits, and an old last seen + a.True(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}.Failed(basics.Address{0xbf, 0x34}, 10)) + + // challenge isn't "on" + a.False(challenge{round: 0, seed: [32]byte{0xb0, 0xb4}, bits: 4}.Failed(basics.Address{0xbf, 0x34}, 10)) + // node has appeared more recently + a.False(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}.Failed(basics.Address{0xbf, 0x34}, 12)) + // bits don't match + a.False(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}.Failed(basics.Address{0xcf, 0x34}, 10)) + // no enough bits match + a.False(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 5}.Failed(basics.Address{0xbf, 0x34}, 10)) +} + +type singleSource bookkeeping.BlockHeader + +func (ss singleSource) BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) { + return bookkeeping.BlockHeader(ss), nil +} + +func TestActiveChallenge(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + a := assert.New(t) + + nowHeader := bookkeeping.BlockHeader{ + UpgradeState: bookkeeping.UpgradeState{ + // Here the rules are on, so they certainly differ from rules in oldHeader's params + CurrentProtocol: protocol.ConsensusFuture, + }, + } + rules := config.Consensus[nowHeader.CurrentProtocol].Payouts + + // simplest test. when interval=X and grace=G, X+G+1 is a challenge + inChallenge := basics.Round(rules.ChallengeInterval + rules.ChallengeGracePeriod + 1) + ch := FindChallenge(rules, inChallenge, singleSource(nowHeader), ChActive) + a.NotZero(ch.round) + + // all rounds before that have no challenge + for r := basics.Round(1); r < inChallenge; r++ { + ch := FindChallenge(rules, r, singleSource(nowHeader), ChActive) + a.Zero(ch.round, r) + } + + // ChallengeGracePeriod rounds allow challenges starting with inChallenge + for r := inChallenge; r < inChallenge+basics.Round(rules.ChallengeGracePeriod); r++ { + ch := FindChallenge(rules, r, singleSource(nowHeader), ChActive) + a.EqualValues(ch.round, rules.ChallengeInterval) + } + + // And the next round is again challenge-less + ch = FindChallenge(rules, inChallenge+basics.Round(rules.ChallengeGracePeriod), singleSource(nowHeader), ChActive) + a.Zero(ch.round) + + // ignore challenge if upgrade happened + oldHeader := bookkeeping.BlockHeader{ + UpgradeState: bookkeeping.UpgradeState{ + // We need a version from before payouts got turned on + CurrentProtocol: protocol.ConsensusV39, + }, + } + ch = FindChallenge(rules, inChallenge, singleSource(oldHeader), ChActive) + a.Zero(ch.round) +} diff --git a/ledger/apply/heartbeat.go b/ledger/apply/heartbeat.go new file mode 100644 index 0000000000..9e1056dc3c --- /dev/null +++ b/ledger/apply/heartbeat.go @@ -0,0 +1,124 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package apply + +import ( + "fmt" + + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/transactions" +) + +// Heartbeat applies a Heartbeat transaction using the Balances interface. +func Heartbeat(hb transactions.HeartbeatTxnFields, header transactions.Header, balances Balances, provider HdrProvider, round basics.Round) error { + // Get the account's balance entry + account, err := balances.Get(hb.HbAddress, false) + if err != nil { + return err + } + + // In txnGroupBatchPrep, we do not charge for singleton (Group.IsZero) + // heartbeats. But we only _want_ to allow free heartbeats if the account is + // under challenge. If this is an underpaid singleton heartbeat, reject it + // unless the account is under challenge. + + proto := balances.ConsensusParams() + if header.Fee.Raw < proto.MinTxnFee && header.Group.IsZero() { + kind := "free" + if header.Fee.Raw > 0 { + kind = "cheap" + } + + // These first checks are a little draconian. The idea is not let these + // free transactions do anything except their exact intended purpose. + if len(header.Note) > 0 { + return fmt.Errorf("%s heartbeat is not allowed to have a note", kind) + } + if header.Lease != [32]byte{} { + return fmt.Errorf("%s heartbeat is not allowed to have a lease", kind) + } + if !header.RekeyTo.IsZero() { + return fmt.Errorf("%s heartbeat is not allowed to rekey", kind) + } + + if account.Status != basics.Online { + return fmt.Errorf("%s heartbeat is not allowed for %s %+v", kind, account.Status, hb.HbAddress) + } + if !account.IncentiveEligible { + return fmt.Errorf("%s heartbeat is not allowed when not IncentiveEligible %+v", kind, hb.HbAddress) + } + ch := FindChallenge(proto.Payouts, round, provider, ChRisky) + if ch.round == 0 { + return fmt.Errorf("%s heartbeat for %s is not allowed with no challenge", kind, hb.HbAddress) + } + if !ch.Failed(hb.HbAddress, account.LastSeen()) { + return fmt.Errorf("%s heartbeat for %s is not challenged by %+v", kind, hb.HbAddress, ch) + } + } + + // Note the contrast with agreement. We are using the account's _current_ + // partkey to verify the heartbeat. This is required because we can only + // look 320 rounds back for voting information. If a heartbeat was delayed a + // few rounds (even 1), we could not ask "what partkey was in effect at + // firstValid-320?" Using the current keys means that an account that + // changes keys would invalidate any heartbeats it has already sent out + // (that haven't been evaluated yet). Maybe more importantly, after going + // offline, an account can no longer heartbeat, since it has no _current_ + // keys. Yet it is still expected to vote for 320 rounds. Therefore, + // challenges do not apply to accounts that are offline (even if they should + // still be voting). + + // Conjure up an OnlineAccountData from current state, for convenience of + // oad.KeyDilution(). + oad := basics.OnlineAccountData{ + VotingData: account.VotingData, + } + + sv := oad.VoteID + if sv.IsEmpty() { + return fmt.Errorf("heartbeat address %s has no voting keys", hb.HbAddress) + } + kd := oad.KeyDilution(proto) + + // heartbeats are expected to sign with the partkey for their last-valid round + id := basics.OneTimeIDForRound(header.LastValid, kd) + + // heartbeats sign a message consisting of the BlockSeed of the first-valid + // round, to discourage unsavory behaviour like presigning a bunch of + // heartbeats for later use keeping an unavailable account online. + hdr, err := provider.BlockHdr(header.FirstValid) + if err != nil { + return err + } + if hdr.Seed != hb.HbSeed { + return fmt.Errorf("provided seed %v does not match round %d's seed %v", hb.HbSeed, header.FirstValid, hdr.Seed) + } + + if !sv.Verify(id, hdr.Seed, hb.HbProof.ToOneTimeSignature()) { + return fmt.Errorf("heartbeat failed verification with VoteID %v", sv) + } + + account.LastHeartbeat = round + + // Write the updated entry + err = balances.Put(hb.HbAddress, account) + if err != nil { + return err + } + + return nil +} diff --git a/ledger/apply/heartbeat_test.go b/ledger/apply/heartbeat_test.go new file mode 100644 index 0000000000..f7d3845b12 --- /dev/null +++ b/ledger/apply/heartbeat_test.go @@ -0,0 +1,193 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package apply + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/data/committee" + "github.com/algorand/go-algorand/data/txntest" + "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/test/partitiontest" +) + +func TestHeartbeat(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + // Creator + sender := basics.Address{0x01} + voter := basics.Address{0x02} + const keyDilution = 777 + + fv := basics.Round(100) + lv := basics.Round(1000) + + id := basics.OneTimeIDForRound(lv, keyDilution) + otss := crypto.GenerateOneTimeSignatureSecrets(1, 2) // This will cover rounds 1-2*777 + + mockBal := makeMockBalancesWithAccounts(protocol.ConsensusFuture, map[basics.Address]basics.AccountData{ + sender: { + MicroAlgos: basics.MicroAlgos{Raw: 10_000_000}, + }, + voter: { + Status: basics.Online, + MicroAlgos: basics.MicroAlgos{Raw: 100_000_000}, + VoteID: otss.OneTimeSignatureVerifier, + VoteKeyDilution: keyDilution, + IncentiveEligible: true, + }, + }) + + seed := committee.Seed{0x01, 0x02, 0x03} + mockHdr := makeMockHeaders(bookkeeping.BlockHeader{ + Round: fv, + Seed: seed, + }) + + test := txntest.Txn{ + Type: protocol.HeartbeatTx, + Sender: sender, + Fee: basics.MicroAlgos{Raw: 1}, + FirstValid: fv, + LastValid: lv, + HbAddress: voter, + HbProof: otss.Sign(id, seed).ToHeartbeatProof(), + HbSeed: seed, + } + + tx := test.Txn() + + rnd := basics.Round(150) + // no fee + err := Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) + require.ErrorContains(t, err, "cheap heartbeat") + + test.Fee = basics.MicroAlgos{Raw: 10} + tx = test.Txn() + // just as bad: cheap + err = Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) + require.ErrorContains(t, err, "cheap heartbeat") + + test.Fee = 1000 + tx = test.Txn() + err = Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, rnd) + require.NoError(t, err) + + after, err := mockBal.Get(voter, false) + require.NoError(t, err) + require.Equal(t, rnd, after.LastHeartbeat) + require.Zero(t, after.LastProposed) // unchanged +} + +// TestCheapRules ensures a heartbeat can only have a low fee if the account +// being heartbeat for is online, under risk of suspension by challenge, and +// incentive eligible. +func TestCheapRules(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + type tcase struct { + rnd basics.Round + addrStart byte + status basics.Status + incentiveEligble bool + note []byte + lease [32]byte + rekey [32]byte + err string + } + empty := [32]byte{} + // Grace period is 200. For the second half of the grace period (1101-1200), + // the heartbeat is free for online, incentive eligible, challenged accounts. + cases := []tcase{ + // test of range + {1100, 0x01, basics.Online, true, nil, empty, empty, "no challenge"}, + {1101, 0x01, basics.Online, true, nil, empty, empty, ""}, + {1200, 0x01, basics.Online, true, nil, empty, empty, ""}, + {1201, 0x01, basics.Online, true, nil, empty, empty, "no challenge"}, + + // test of the other requirements + {1101, 0x01, basics.Online, true, []byte("note"), empty, empty, "not allowed to have a note"}, + {1101, 0x01, basics.Online, true, nil, [32]byte{'l', 'e', 'a', 's', 'e'}, empty, "not allowed to have a lease"}, + {1101, 0x01, basics.Online, true, nil, empty, [32]byte{'r', 'e', 'k', 'e', 'y'}, "not allowed to rekey"}, + {1101, 0xf1, basics.Online, true, nil, empty, empty, "not challenged by"}, + {1101, 0x01, basics.Offline, true, nil, empty, empty, "not allowed for Offline"}, + {1101, 0x01, basics.Online, false, nil, empty, empty, "not allowed when not IncentiveEligible"}, + } + for _, tc := range cases { + const keyDilution = 777 + + lv := basics.Round(tc.rnd + 10) + + id := basics.OneTimeIDForRound(lv, keyDilution) + otss := crypto.GenerateOneTimeSignatureSecrets(1, 10) // This will cover rounds 1-10*777 + + sender := basics.Address{0x01} + voter := basics.Address{tc.addrStart} + mockBal := makeMockBalancesWithAccounts(protocol.ConsensusFuture, map[basics.Address]basics.AccountData{ + sender: { + MicroAlgos: basics.MicroAlgos{Raw: 10_000_000}, + }, + voter: { + Status: tc.status, + MicroAlgos: basics.MicroAlgos{Raw: 100_000_000}, + VoteID: otss.OneTimeSignatureVerifier, + VoteKeyDilution: keyDilution, + IncentiveEligible: tc.incentiveEligble, + }, + }) + + seed := committee.Seed{0x01, 0x02, 0x03} + mockHdr := makeMockHeaders() + mockHdr.setFallback(bookkeeping.BlockHeader{ + UpgradeState: bookkeeping.UpgradeState{ + CurrentProtocol: protocol.ConsensusFuture, + }, + Seed: seed, + }) + txn := txntest.Txn{ + Type: protocol.HeartbeatTx, + Sender: sender, + Fee: basics.MicroAlgos{Raw: 1}, + FirstValid: tc.rnd - 10, + LastValid: tc.rnd + 10, + Lease: tc.lease, + Note: tc.note, + RekeyTo: tc.rekey, + HbAddress: voter, + HbProof: otss.Sign(id, seed).ToHeartbeatProof(), + HbSeed: seed, + } + + tx := txn.Txn() + fmt.Printf("tc %+v\n", tc) + err := Heartbeat(tx.HeartbeatTxnFields, tx.Header, mockBal, mockHdr, tc.rnd) + if tc.err == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.err, "%+v", tc) + } + } +} diff --git a/ledger/apply/mockBalances_test.go b/ledger/apply/mockBalances_test.go index 43af5fa11d..a18500341e 100644 --- a/ledger/apply/mockBalances_test.go +++ b/ledger/apply/mockBalances_test.go @@ -17,8 +17,11 @@ package apply import ( + "fmt" + "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/data/transactions/logic" "github.com/algorand/go-algorand/ledger/ledgercore" @@ -270,3 +273,33 @@ func (b *mockCreatableBalances) HasAssetParams(addr basics.Address, aidx basics. _, ok = acct.AssetParams[aidx] return } + +type mockHeaders struct { + perRound map[basics.Round]bookkeeping.BlockHeader + fallback *bookkeeping.BlockHeader +} + +// makeMockHeaders takes a bunch of BlockHeaders and returns a HdrProivder for them. +func makeMockHeaders(hdrs ...bookkeeping.BlockHeader) mockHeaders { + b := make(map[basics.Round]bookkeeping.BlockHeader) + for _, hdr := range hdrs { + b[hdr.Round] = hdr + } + return mockHeaders{perRound: b} +} + +func (m mockHeaders) BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) { + if hdr, ok := m.perRound[r]; ok { + return hdr, nil + } + if m.fallback != nil { + copy := *m.fallback + copy.Round = r + return copy, nil + } + return bookkeeping.BlockHeader{}, fmt.Errorf("round %v is not present", r) +} + +func (m *mockHeaders) setFallback(hdr bookkeeping.BlockHeader) { + m.fallback = &hdr +} diff --git a/ledger/apptxn_test.go b/ledger/apptxn_test.go index a7b3b15214..fce41b00a3 100644 --- a/ledger/apptxn_test.go +++ b/ledger/apptxn_test.go @@ -104,9 +104,9 @@ func TestPayAction(t *testing.T) { dl.t.Log("postsink", postsink, "postprop", postprop) if ver >= payoutsVer { - bonus := 10_000_000 // config/consensus.go - assert.EqualValues(t, bonus-500, presink-postsink) // based on 75% in config/consensus.go - require.EqualValues(t, bonus+1500, postprop-preprop) + bonus := 10_000_000 // config/consensus.go + assert.EqualValues(t, bonus-1000, presink-postsink) // based on 50% in config/consensus.go + require.EqualValues(t, bonus+1000, postprop-preprop) } else { require.EqualValues(t, 2000, postsink-presink) // no payouts yet } diff --git a/ledger/eval/eval.go b/ledger/eval/eval.go index 1af542967d..75404d36fe 100644 --- a/ledger/eval/eval.go +++ b/ledger/eval/eval.go @@ -21,7 +21,6 @@ import ( "errors" "fmt" "math" - "math/bits" "sync" "github.com/algorand/go-algorand/agreement" @@ -29,7 +28,6 @@ import ( "github.com/algorand/go-algorand/crypto" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/bookkeeping" - "github.com/algorand/go-algorand/data/committee" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/data/transactions/logic" "github.com/algorand/go-algorand/data/transactions/verify" @@ -610,6 +608,7 @@ func (cs *roundCowState) Move(from basics.Address, to basics.Address, amt basics if overflowed { return fmt.Errorf("overspend (account %v, data %+v, tried to spend %v)", from, fromBal, amt) } + fromBalNew = cs.autoHeartbeat(fromBal, fromBalNew) err = cs.putAccount(from, fromBalNew) if err != nil { return err @@ -638,6 +637,7 @@ func (cs *roundCowState) Move(from basics.Address, to basics.Address, amt basics if overflowed { return fmt.Errorf("balance overflow (account %v, data %+v, was going to receive %v)", to, toBal, amt) } + toBalNew = cs.autoHeartbeat(toBal, toBalNew) err = cs.putAccount(to, toBalNew) if err != nil { return err @@ -647,6 +647,24 @@ func (cs *roundCowState) Move(from basics.Address, to basics.Address, amt basics return nil } +// autoHeartbeat compares `before` and `after`, returning a new AccountData +// based on `after` but with an updated `LastHeartbeat` if `after` shows enough +// balance increase to risk a false positive suspension for absenteeism. +func (cs *roundCowState) autoHeartbeat(before, after ledgercore.AccountData) ledgercore.AccountData { + // No need to adjust unless account is suspendable + if after.Status != basics.Online || !after.IncentiveEligible { + return after + } + + // Adjust only if balance has doubled + twice, o := basics.OMul(before.MicroAlgos.Raw, 2) + if !o && twice < after.MicroAlgos.Raw { + lookback := agreement.BalanceLookback(cs.ConsensusParams()) + after.LastHeartbeat = cs.Round() + lookback + } + return after +} + func (cs *roundCowState) ConsensusParams() config.ConsensusParams { return cs.proto } @@ -1291,6 +1309,9 @@ func (eval *BlockEvaluator) applyTransaction(tx transactions.Transaction, cow *r // Validation of the StateProof transaction before applying will only occur in validate mode. err = apply.StateProof(tx.StateProofTxnFields, tx.Header.FirstValid, cow, eval.validate) + case protocol.HeartbeatTx: + err = apply.Heartbeat(tx.HeartbeatTxnFields, tx.Header, cow, cow, cow.Round()) + default: err = fmt.Errorf("unknown transaction type %v", tx.Type) } @@ -1606,14 +1627,6 @@ func (eval *BlockEvaluator) proposerPayout() (basics.MicroAlgos, error) { return basics.MinA(total, available), nil } -type challenge struct { - // round is when the challenge occurred. 0 means this is not a challenge. - round basics.Round - // accounts that match the first `bits` of `seed` must propose or heartbeat to stay online - seed committee.Seed - bits int -} - // generateKnockOfflineAccountsList creates the lists of expired or absent // participation accounts by traversing over the modified accounts in the state // deltas and testing if any of them needs to be reset/suspended. Expiration @@ -1638,7 +1651,7 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []bas updates := &eval.block.ParticipationUpdates - ch := activeChallenge(&eval.proto, uint64(current), eval.state) + ch := apply.FindChallenge(eval.proto.Payouts, current, eval.state, apply.ChActive) onlineStake, err := eval.state.onlineStake() if err != nil { logging.Base().Errorf("unable to fetch online stake, no knockoffs: %v", err) @@ -1741,7 +1754,7 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []bas continue } if isAbsent(onlineStake, oad.VotingStake(), lastSeen, current) || - failsChallenge(ch, accountAddr, lastSeen) { + ch.Failed(accountAddr, lastSeen) { updates.AbsentParticipationAccounts = append( updates.AbsentParticipationAccounts, accountAddr, @@ -1751,28 +1764,6 @@ func (eval *BlockEvaluator) generateKnockOfflineAccountsList(participating []bas } } -// bitsMatch checks if the first n bits of two byte slices match. Written to -// work on arbitrary slices, but we expect that n is small. Only user today -// calls with n=5. -func bitsMatch(a, b []byte, n int) bool { - // Ensure n is a valid number of bits to compare - if n < 0 || n > len(a)*8 || n > len(b)*8 { - return false - } - - // Compare entire bytes when n is bigger than 8 - for i := 0; i < n/8; i++ { - if a[i] != b[i] { - return false - } - } - remaining := n % 8 - if remaining == 0 { - return true - } - return bits.LeadingZeros8(a[n/8]^b[n/8]) >= remaining -} - func isAbsent(totalOnlineStake basics.MicroAlgos, acctStake basics.MicroAlgos, lastSeen basics.Round, current basics.Round) bool { // Don't consider accounts that were online when payouts went into effect as // absent. They get noticed the next time they propose or keyreg, which @@ -1790,39 +1781,6 @@ func isAbsent(totalOnlineStake basics.MicroAlgos, acctStake basics.MicroAlgos, l return lastSeen+basics.Round(allowableLag) < current } -type headerSource interface { - BlockHdr(round basics.Round) (bookkeeping.BlockHeader, error) -} - -func activeChallenge(proto *config.ConsensusParams, current uint64, headers headerSource) challenge { - rules := proto.Payouts - // are challenges active? - if rules.ChallengeInterval == 0 || current < rules.ChallengeInterval { - return challenge{} - } - lastChallenge := current - (current % rules.ChallengeInterval) - // challenge is in effect if we're after one grace period, but before the 2nd ends. - if current <= lastChallenge+rules.ChallengeGracePeriod || - current > lastChallenge+2*rules.ChallengeGracePeriod { - return challenge{} - } - round := basics.Round(lastChallenge) - challengeHdr, err := headers.BlockHdr(round) - if err != nil { - panic(err) - } - challengeProto := config.Consensus[challengeHdr.CurrentProtocol] - // challenge is not considered if rules have changed since that round - if challengeProto.Payouts != rules { - return challenge{} - } - return challenge{round, challengeHdr.Seed, rules.ChallengeBits} -} - -func failsChallenge(ch challenge, address basics.Address, lastSeen basics.Round) bool { - return ch.round != 0 && bitsMatch(ch.seed[:], address[:], ch.bits) && lastSeen < ch.round -} - // validateExpiredOnlineAccounts tests the expired online accounts specified in ExpiredParticipationAccounts, and verify // that they have all expired and need to be reset. func (eval *BlockEvaluator) validateExpiredOnlineAccounts() error { @@ -1890,7 +1848,7 @@ func (eval *BlockEvaluator) validateAbsentOnlineAccounts() error { // For consistency with expired account handling, we preclude duplicates addressSet := make(map[basics.Address]bool, suspensionCount) - ch := activeChallenge(&eval.proto, uint64(eval.Round()), eval.state) + ch := apply.FindChallenge(eval.proto.Payouts, eval.Round(), eval.state, apply.ChActive) totalOnlineStake, err := eval.state.onlineStake() if err != nil { logging.Base().Errorf("unable to fetch online stake, can't check knockoffs: %v", err) @@ -1918,15 +1876,14 @@ func (eval *BlockEvaluator) validateAbsentOnlineAccounts() error { return fmt.Errorf("proposed absent account %v with zero algos", accountAddr) } - lastSeen := max(acctData.LastProposed, acctData.LastHeartbeat) oad, lErr := eval.state.lookupAgreement(accountAddr) if lErr != nil { return fmt.Errorf("unable to check absent account: %v", accountAddr) } - if isAbsent(totalOnlineStake, oad.VotingStake(), lastSeen, eval.Round()) { + if isAbsent(totalOnlineStake, oad.VotingStake(), acctData.LastSeen(), eval.Round()) { continue // ok. it's "normal absent" } - if failsChallenge(ch, accountAddr, lastSeen) { + if ch.Failed(accountAddr, acctData.LastSeen()) { continue // ok. it's "challenge absent" } return fmt.Errorf("proposed absent account %v is not absent in %d, %d", diff --git a/ledger/eval/eval_test.go b/ledger/eval/eval_test.go index df17357a10..d33996b2d8 100644 --- a/ledger/eval/eval_test.go +++ b/ledger/eval/eval_test.go @@ -1605,29 +1605,6 @@ func TestExpiredAccountGeneration(t *testing.T) { require.NotZero(t, propAcct.StateProofID) } -func TestBitsMatch(t *testing.T) { - partitiontest.PartitionTest(t) - t.Parallel() - - for b := 0; b <= 6; b++ { - require.True(t, bitsMatch([]byte{0x1}, []byte{0x2}, b), "%d", b) - } - require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 7)) - require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 8)) - require.False(t, bitsMatch([]byte{0x1}, []byte{0x2}, 9)) - - for b := 0; b <= 12; b++ { - require.True(t, bitsMatch([]byte{0x1, 0xff, 0xaa}, []byte{0x1, 0xf0}, b), "%d", b) - } - require.False(t, bitsMatch([]byte{0x1, 0xff, 0xaa}, []byte{0x1, 0xf0}, 13)) - - // on a byte boundary - require.True(t, bitsMatch([]byte{0x1}, []byte{0x1}, 8)) - require.False(t, bitsMatch([]byte{0x1}, []byte{0x1}, 9)) - require.True(t, bitsMatch([]byte{0x1, 0xff}, []byte{0x1, 0x00}, 8)) - require.False(t, bitsMatch([]byte{0x1, 0xff}, []byte{0x1, 00}, 9)) -} - func TestIsAbsent(t *testing.T) { partitiontest.PartitionTest(t) t.Parallel() @@ -1646,72 +1623,3 @@ func TestIsAbsent(t *testing.T) { a.False(absent(1000, 10, 0, 6000)) a.False(absent(1000, 10, 0, 6001)) } - -func TestFailsChallenge(t *testing.T) { - partitiontest.PartitionTest(t) - t.Parallel() - a := assert.New(t) - - // a valid challenge, with 4 matching bits, and an old last seen - a.True(failsChallenge(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}, basics.Address{0xbf, 0x34}, 10)) - - // challenge isn't "on" - a.False(failsChallenge(challenge{round: 0, seed: [32]byte{0xb0, 0xb4}, bits: 4}, basics.Address{0xbf, 0x34}, 10)) - // node has appeared more recently - a.False(failsChallenge(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}, basics.Address{0xbf, 0x34}, 12)) - // bits don't match - a.False(failsChallenge(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 4}, basics.Address{0xcf, 0x34}, 10)) - // no enough bits match - a.False(failsChallenge(challenge{round: 11, seed: [32]byte{0xb0, 0xb4}, bits: 5}, basics.Address{0xbf, 0x34}, 10)) -} - -type singleSource bookkeeping.BlockHeader - -func (ss singleSource) BlockHdr(r basics.Round) (bookkeeping.BlockHeader, error) { - return bookkeeping.BlockHeader(ss), nil -} - -func TestActiveChallenge(t *testing.T) { - partitiontest.PartitionTest(t) - t.Parallel() - a := assert.New(t) - - nowHeader := bookkeeping.BlockHeader{ - UpgradeState: bookkeeping.UpgradeState{ - // Here the rules are on, so they certainly differ from rules in oldHeader's params - CurrentProtocol: protocol.ConsensusFuture, - }, - } - now := config.Consensus[nowHeader.CurrentProtocol] - - // simplest test. when interval=X and grace=G, X+G+1 is a challenge - inChallenge := now.Payouts.ChallengeInterval + now.Payouts.ChallengeGracePeriod + 1 - ch := activeChallenge(&now, inChallenge, singleSource(nowHeader)) - a.NotZero(ch.round) - - // all rounds before that have no challenge - for r := uint64(1); r < inChallenge; r++ { - ch := activeChallenge(&now, r, singleSource(nowHeader)) - a.Zero(ch.round, r) - } - - // ChallengeGracePeriod rounds allow challenges starting with inChallenge - for r := inChallenge; r < inChallenge+now.Payouts.ChallengeGracePeriod; r++ { - ch := activeChallenge(&now, r, singleSource(nowHeader)) - a.EqualValues(ch.round, now.Payouts.ChallengeInterval) - } - - // And the next round is again challenge-less - ch = activeChallenge(&now, inChallenge+now.Payouts.ChallengeGracePeriod, singleSource(nowHeader)) - a.Zero(ch.round) - - // ignore challenge if upgrade happened - oldHeader := bookkeeping.BlockHeader{ - UpgradeState: bookkeeping.UpgradeState{ - // We need a version from before payouts got turned on - CurrentProtocol: protocol.ConsensusV39, - }, - } - ch = activeChallenge(&now, inChallenge, singleSource(oldHeader)) - a.Zero(ch.round) -} diff --git a/ledger/eval_simple_test.go b/ledger/eval_simple_test.go index 1e02157ae5..191ee6e1c0 100644 --- a/ledger/eval_simple_test.go +++ b/ledger/eval_simple_test.go @@ -281,15 +281,15 @@ func TestPayoutFees(t *testing.T) { // new fields are in the header require.EqualValues(t, 2000, vb.Block().FeesCollected.Raw) require.EqualValues(t, bonus1, vb.Block().Bonus.Raw) - require.EqualValues(t, bonus1+1_500, vb.Block().ProposerPayout().Raw) + require.EqualValues(t, bonus1+1_000, vb.Block().ProposerPayout().Raw) // This last one is really only testing the "fake" agreement that // happens in dl.endBlock(). require.EqualValues(t, proposer, vb.Block().Proposer()) // At the end of the block, part of the fees + bonus have been moved to // the proposer. - require.EqualValues(t, bonus1+1500, postprop-preprop) // based on 75% in config/consensus.go - require.EqualValues(t, bonus1-500, presink-postsink) + require.EqualValues(t, bonus1+1_000, postprop-preprop) // based on 75% in config/consensus.go + require.EqualValues(t, bonus1-1_000, presink-postsink) require.Equal(t, prp.LastProposed, dl.generator.Latest()) } else { require.False(t, dl.generator.GenesisProto().Payouts.Enabled) diff --git a/ledger/heartbeat_test.go b/ledger/heartbeat_test.go new file mode 100644 index 0000000000..2af0310fef --- /dev/null +++ b/ledger/heartbeat_test.go @@ -0,0 +1,155 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package ledger + +import ( + "testing" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/txntest" + ledgertesting "github.com/algorand/go-algorand/ledger/testing" + "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/test/partitiontest" + "github.com/stretchr/testify/require" +) + +/* Tests within the `apply` package test the effects of heartbeats, while test + here are closer to integration tests, they test heartbeats in the context of + a more realistic ledger. */ + +// TestHearbeat exercises heartbeat transactions +func TestHeartbeat(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + genBalances, addrs, _ := ledgertesting.NewTestGenesis(func(cfg *ledgertesting.GenesisCfg) { + cfg.OnlineCount = 2 // addrs[0] and addrs[1] will be online + }) + heartbeatsBegin := 40 + + ledgertesting.TestConsensusRange(t, heartbeatsBegin, 0, + func(t *testing.T, ver int, cv protocol.ConsensusVersion, cfg config.Local) { + dl := NewDoubleLedger(t, genBalances, cv, cfg) + defer dl.Close() + + dl.txns() // tests involving seed are easier if we have the first block in ledger + + // empty HbAddress means ZeroAddress, and it's not online + dl.txn(&txntest.Txn{Type: "hb", Sender: addrs[1]}, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ has no voting keys") + + // addrs[2] is not online, it has no voting keys + dl.txn(&txntest.Txn{Type: "hb", Sender: addrs[1], HbAddress: addrs[2]}, + addrs[2].String()+" has no voting keys") + + // addrs[1] is online, it has voting keys, but seed is missing + dl.txn(&txntest.Txn{Type: "hb", Sender: addrs[1], HbAddress: addrs[1], FirstValid: 1}, + "does not match round 1's seed") + + // NewTestGenesis creates random VoterID. Verification will fail. + b1, err := dl.generator.BlockHdr(1) + require.NoError(t, err) + dl.txn(&txntest.Txn{ + Type: "hb", + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: b1.Seed, + FirstValid: 1, + }, + "heartbeat failed verification with") + + // keyreg addr[1] so we have a valid VoterID + const kd = 10 + firstID := basics.OneTimeIDForRound(1, kd) + otss := crypto.GenerateOneTimeSignatureSecrets(firstID.Batch, 5) + dl.txn(&txntest.Txn{ + Type: "keyreg", + Sender: addrs[1], + VotePK: otss.OneTimeSignatureVerifier, + SelectionPK: crypto.VrfPubkey([32]byte{0x01}), // must be non-zero + VoteKeyDilution: kd, + }) + + // Supply and sign the wrong HbSeed + wrong := b1.Seed + wrong[0]++ + dl.txn(&txntest.Txn{ + Type: "hb", + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: wrong, + HbProof: otss.Sign(firstID, wrong).ToHeartbeatProof(), + FirstValid: 1, + }, + "does not match round 1's seed") + + b2, err := dl.generator.BlockHdr(2) + require.NoError(t, err) + + // Supply the right seed, but sign something else. We're also now + // setting LastValid and the proper OneTimeIDForRound, so that these + // tests are failing for the reasons described, not that. + dl.txn(&txntest.Txn{ + Type: "hb", + LastValid: 30, + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: b2.Seed, + HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), wrong).ToHeartbeatProof(), + FirstValid: 2, + }, + "failed verification") + + // Sign the right seed, but supply something else + dl.txn(&txntest.Txn{ + Type: "hb", + LastValid: 30, + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: wrong, + HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), b2.Seed).ToHeartbeatProof(), + FirstValid: 2, + }, + "does not match round 2's") + + // Mismatch the last valid and OneTimeIDForRound + dl.txn(&txntest.Txn{ + Type: "hb", + LastValid: 29, + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: b2.Seed, + HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), b2.Seed).ToHeartbeatProof(), + FirstValid: 2, + }, + "failed verification") + + // now we can make a real heartbeat, with a properly signed blockseed + dl.txn(&txntest.Txn{ + Type: "hb", + LastValid: 30, + Sender: addrs[1], + HbAddress: addrs[1], + HbSeed: b2.Seed, + HbProof: otss.Sign(basics.OneTimeIDForRound(30, kd), b2.Seed).ToHeartbeatProof(), + FirstValid: 2, + }) + + }) +} diff --git a/ledger/ledger.go b/ledger/ledger.go index 3f85b9e7bb..2f1f1f2027 100644 --- a/ledger/ledger.go +++ b/ledger/ledger.go @@ -762,7 +762,7 @@ func (l *Ledger) Block(rnd basics.Round) (blk bookkeeping.Block, err error) { func (l *Ledger) BlockHdr(rnd basics.Round) (blk bookkeeping.BlockHeader, err error) { // Expected availability range in txTail.blockHeader is [Latest - MaxTxnLife, Latest] - // allowing (MaxTxnLife + 1) = 1001 rounds back loopback. + // allowing (MaxTxnLife + 1) = 1001 rounds lookback. // The depth besides the MaxTxnLife is controlled by DeeperBlockHeaderHistory parameter // and currently set to 1. // Explanation: diff --git a/ledger/ledgercore/accountdata.go b/ledger/ledgercore/accountdata.go index 5b17730122..ea7b150a6e 100644 --- a/ledger/ledgercore/accountdata.go +++ b/ledger/ledgercore/accountdata.go @@ -135,10 +135,15 @@ func (u *AccountData) Suspend() { } // Suspended returns true if the account is suspended (offline with keys) -func (u *AccountData) Suspended() bool { +func (u AccountData) Suspended() bool { return u.Status == basics.Offline && !u.VoteID.IsEmpty() } +// LastSeen returns the last round that the account was seen online +func (u AccountData) LastSeen() basics.Round { + return max(u.LastProposed, u.LastHeartbeat) +} + // MinBalance computes the minimum balance requirements for an account based on // some consensus parameters. MinBalance should correspond roughly to how much // storage the account is allowed to store on disk. diff --git a/ledger/simple_test.go b/ledger/simple_test.go index 0995f88ecc..328f6f1528 100644 --- a/ledger/simple_test.go +++ b/ledger/simple_test.go @@ -129,7 +129,7 @@ func txn(t testing.TB, ledger *Ledger, eval *eval.BlockEvaluator, txn *txntest.T } return } - require.True(t, len(problem) == 0 || problem[0] == "") + require.True(t, len(problem) == 0 || problem[0] == "", "Transaction did not fail. Expected: %v", problem) } func txgroup(t testing.TB, ledger *Ledger, eval *eval.BlockEvaluator, txns ...*txntest.Txn) error { diff --git a/libgoal/libgoal.go b/libgoal/libgoal.go index f3f1c67192..e7739e085c 100644 --- a/libgoal/libgoal.go +++ b/libgoal/libgoal.go @@ -28,7 +28,6 @@ import ( v2 "github.com/algorand/go-algorand/daemon/algod/api/server/v2" kmdclient "github.com/algorand/go-algorand/daemon/kmd/client" "github.com/algorand/go-algorand/ledger/ledgercore" - "github.com/algorand/go-algorand/rpcs" "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/crypto" @@ -831,53 +830,43 @@ func (c *Client) Block(round uint64) (resp v2.BlockResponseJSON, err error) { // RawBlock takes a round and returns its block func (c *Client) RawBlock(round uint64) (resp []byte, err error) { algod, err := c.ensureAlgodClient() - if err == nil { - resp, err = algod.RawBlock(round) - } - return -} - -// EncodedBlockCert takes a round and returns its parsed block and certificate -func (c *Client) EncodedBlockCert(round uint64) (blockCert rpcs.EncodedBlockCert, err error) { - algod, err := c.ensureAlgodClient() - if err == nil { - var resp []byte - resp, err = algod.RawBlock(round) - if err == nil { - err = protocol.Decode(resp, &blockCert) - if err != nil { - return - } - } + if err != nil { + return } - return + return algod.RawBlock(round) } // BookkeepingBlock takes a round and returns its block func (c *Client) BookkeepingBlock(round uint64) (block bookkeeping.Block, err error) { - blockCert, err := c.EncodedBlockCert(round) - if err == nil { - return blockCert.Block, nil + algod, err := c.ensureAlgodClient() + if err != nil { + return } - return + blockCert, err := algod.EncodedBlockCert(round) + if err != nil { + return + } + return blockCert.Block, nil } // HealthCheck returns an error if something is wrong func (c *Client) HealthCheck() error { algod, err := c.ensureAlgodClient() - if err == nil { - err = algod.HealthCheck() + if err != nil { + return err } - return err + return algod.HealthCheck() } -// WaitForRound takes a round, waits until it appears and returns its status. This function blocks. +// WaitForRound takes a round, waits up to one minute, for it to appear and +// returns the node status. This function blocks and fails if the block does not +// appear in one minute. func (c *Client) WaitForRound(round uint64) (resp model.NodeStatusResponse, err error) { algod, err := c.ensureAlgodClient() - if err == nil { - resp, err = algod.StatusAfterBlock(round) + if err != nil { + return } - return + return algod.WaitForRound(round, time.Minute) } // GetBalance takes an address and returns its total balance; if the address doesn't exist, it returns 0. diff --git a/network/connPerfMon_test.go b/network/connPerfMon_test.go index 560be72a96..4c2bc5f034 100644 --- a/network/connPerfMon_test.go +++ b/network/connPerfMon_test.go @@ -103,14 +103,14 @@ func TestConnMonitorStageTiming(t *testing.T) { startTestTime := time.Now().UnixNano() perfMonitor := makeConnectionPerformanceMonitor([]Tag{protocol.AgreementVoteTag}) // measure measuring overhead. - measuringOverhead := time.Now().Sub(time.Now()) + measuringOverhead := time.Since(time.Now()) perfMonitor.Reset(peers) for msgIdx, msg := range msgPool { msg.Received += startTestTime beforeNotify := time.Now() beforeNotifyStage := perfMonitor.stage perfMonitor.Notify(&msg) - notifyTime := time.Now().Sub(beforeNotify) + notifyTime := time.Since(beforeNotify) stageTimings[beforeNotifyStage] += notifyTime stageNotifyCalls[beforeNotifyStage]++ if perfMonitor.GetPeersStatistics() != nil { diff --git a/node/node.go b/node/node.go index dddb3203e3..f536742c22 100644 --- a/node/node.go +++ b/node/node.go @@ -43,6 +43,7 @@ import ( "github.com/algorand/go-algorand/data/pools" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/data/transactions/verify" + "github.com/algorand/go-algorand/heartbeat" "github.com/algorand/go-algorand/ledger" "github.com/algorand/go-algorand/ledger/ledgercore" "github.com/algorand/go-algorand/ledger/simulation" @@ -155,6 +156,8 @@ type AlgorandFullNode struct { stateProofWorker *stateproof.Worker partHandles []db.Accessor + + heartbeatService *heartbeat.Service } // TxnWithStatus represents information about a single transaction, @@ -338,6 +341,8 @@ func MakeFull(log logging.Logger, rootDir string, cfg config.Local, phonebookAdd node.stateProofWorker = stateproof.NewWorker(node.genesisDirs.StateproofGenesisDir, node.log, node.accountManager, node.ledger.Ledger, node.net, node) + node.heartbeatService = heartbeat.NewService(node.accountManager, node.ledger, node, node.log) + return node, err } @@ -380,6 +385,7 @@ func (node *AlgorandFullNode) Start() error { node.ledgerService.Start() node.txHandler.Start() node.stateProofWorker.Start() + node.heartbeatService.Start() err := startNetwork() if err != nil { return err @@ -1221,6 +1227,7 @@ func (node *AlgorandFullNode) SetCatchpointCatchupMode(catchpointCatchupMode boo node.net.ClearHandlers() node.net.ClearValidatorHandlers() node.stateProofWorker.Stop() + node.heartbeatService.Stop() node.txHandler.Stop() node.agreementService.Shutdown() node.catchupService.Stop() @@ -1248,6 +1255,7 @@ func (node *AlgorandFullNode) SetCatchpointCatchupMode(catchpointCatchupMode boo node.ledgerService.Start() node.txHandler.Start() node.stateProofWorker.Start() + node.heartbeatService.Start() // Set up a context we can use to cancel goroutines on Stop() node.ctx, node.cancelCtx = context.WithCancel(context.Background()) diff --git a/protocol/txntype.go b/protocol/txntype.go index 76cb2dc406..ee2d085dcb 100644 --- a/protocol/txntype.go +++ b/protocol/txntype.go @@ -47,6 +47,9 @@ const ( // StateProofTx records a state proof StateProofTx TxType = "stpf" + // HeartbeatTx demonstrates the account is alive + HeartbeatTx TxType = "hb" + // UnknownTx signals an error UnknownTx TxType = "unknown" ) diff --git a/stateproof/builder.go b/stateproof/builder.go index 317e813602..a97ec752c6 100644 --- a/stateproof/builder.go +++ b/stateproof/builder.go @@ -669,7 +669,7 @@ func (spw *Worker) tryBroadcast() { latestHeader, err := spw.ledger.BlockHdr(firstValid) if err != nil { - spw.log.Warnf("spw.tryBroadcast: could not fetch block header for round %d failed: %v", firstValid, err) + spw.log.Warnf("spw.tryBroadcast: could not fetch block header for round %d: %v", firstValid, err) break } diff --git a/stateproof/worker.go b/stateproof/worker.go index f74e118f58..163ec214e0 100644 --- a/stateproof/worker.go +++ b/stateproof/worker.go @@ -95,9 +95,7 @@ func NewWorker(genesisDir string, log logging.Logger, accts Accounts, ledger Led // Start starts the goroutines for the worker. func (spw *Worker) Start() { - ctx, cancel := context.WithCancel(context.Background()) - spw.ctx = ctx - spw.shutdown = cancel + spw.ctx, spw.shutdown = context.WithCancel(context.Background()) spw.signedCh = make(chan struct{}, 1) err := spw.initDb(spw.inMemory) diff --git a/test/e2e-go/features/accountPerf/sixMillion_test.go b/test/e2e-go/features/accountPerf/sixMillion_test.go index 946d1b24b6..94feb3e9eb 100644 --- a/test/e2e-go/features/accountPerf/sixMillion_test.go +++ b/test/e2e-go/features/accountPerf/sixMillion_test.go @@ -1024,13 +1024,10 @@ func checkPoint(counter, firstValid, tLife uint64, force bool, fixture *fixtures if verbose { fmt.Printf("Waiting for round %d...", int(lastRound)) } - nodeStat, err := fixture.AlgodClient.WaitForBlock(basics.Round(lastRound - 1)) + nodeStat, err := fixture.AlgodClient.WaitForRound(lastRound, time.Minute) if err != nil { return 0, 0, fmt.Errorf("failed to wait for block %d : %w", lastRound, err) } - if nodeStat.LastRound < lastRound { - return 0, 0, fmt.Errorf("failed to wait for block %d : node is at round %d", lastRound, nodeStat.LastRound) - } return 0, nodeStat.LastRound + 1, nil } return counter, firstValid, nil diff --git a/test/e2e-go/features/catchup/basicCatchup_test.go b/test/e2e-go/features/catchup/basicCatchup_test.go index 2e3ac87943..adc8c43f18 100644 --- a/test/e2e-go/features/catchup/basicCatchup_test.go +++ b/test/e2e-go/features/catchup/basicCatchup_test.go @@ -56,9 +56,8 @@ func TestBasicCatchup(t *testing.T) { a.NoError(err) // Let the network make some progress - a.NoError(err) waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(3) a.NoError(err) // Now spin up third node @@ -71,7 +70,7 @@ func TestBasicCatchup(t *testing.T) { defer shutdownClonedNode(cloneDataDir, &fixture, t) // Now, catch up - err = fixture.LibGoalFixture.ClientWaitForRoundWithTimeout(cloneClient, waitForRound) + _, err = cloneClient.WaitForRound(waitForRound) a.NoError(err) } @@ -155,7 +154,7 @@ func runCatchupOverGossip(t fixtures.TestingTB, // Let the secondary make progress up to round 3, while the primary was never startred ( hence, it's on round = 0) waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // stop the secondary, which is on round 3 or more. @@ -167,7 +166,7 @@ func runCatchupOverGossip(t fixtures.TestingTB, a.NoError(err) // Now, catch up - err = fixture.LibGoalFixture.ClientWaitForRoundWithTimeout(lg, waitForRound) + _, err = lg.WaitForRound(waitForRound) a.NoError(err) waitStart := time.Now() @@ -184,7 +183,7 @@ func runCatchupOverGossip(t fixtures.TestingTB, break } - if time.Now().Sub(waitStart) > time.Minute { + if time.Since(waitStart) > time.Minute { // it's taking too long. a.FailNow("Waiting too long for catchup to complete") } @@ -258,7 +257,7 @@ func TestStoppedCatchupOnUnsupported(t *testing.T) { // Let the network make some progress a.NoError(err) waitForRound := uint64(3) // UpgradeVoteRounds + DefaultUpgradeWaitRounds - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Now spin up third node @@ -274,7 +273,7 @@ func TestStoppedCatchupOnUnsupported(t *testing.T) { defer shutdownClonedNode(cloneDataDir, &fixture, t) // Now, catch up - err = fixture.LibGoalFixture.ClientWaitForRoundWithTimeout(cloneClient, waitForRound) + _, err = cloneClient.WaitForRound(waitForRound) a.NoError(err) timeout := time.NewTimer(20 * time.Second) @@ -374,7 +373,7 @@ func TestBasicCatchupCompletes(t *testing.T) { a.NoError(err) // Wait for the network to make some progess. - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Start the third node to catchup. @@ -384,7 +383,7 @@ func TestBasicCatchupCompletes(t *testing.T) { defer shutdownClonedNode(cloneDataDir, &fixture, t) // Wait for it to catchup - err = fixture.LibGoalFixture.ClientWaitForRoundWithTimeout(cloneClient, waitForRound) + _, err = cloneClient.WaitForRound(waitForRound) a.NoError(err) // Calculate the catchup time diff --git a/test/e2e-go/features/catchup/catchpointCatchup_test.go b/test/e2e-go/features/catchup/catchpointCatchup_test.go index 3a1eefedc4..0a1d522cac 100644 --- a/test/e2e-go/features/catchup/catchpointCatchup_test.go +++ b/test/e2e-go/features/catchup/catchpointCatchup_test.go @@ -46,7 +46,7 @@ import ( const basicTestCatchpointInterval = 4 func waitForCatchpointGeneration(t *testing.T, fixture *fixtures.RestClientFixture, client client.RestClient, catchpointRound basics.Round) string { - err := fixture.ClientWaitForRoundWithTimeout(client, uint64(catchpointRound+1)) + err := client.WaitForRoundWithTimeout(uint64(catchpointRound + 1)) if err != nil { return "" } @@ -212,7 +212,7 @@ func startCatchpointGeneratingNode(a *require.Assertions, fixture *fixtures.Rest restClient := fixture.GetAlgodClientForController(nodeController) // We don't want to start using the node without it being properly initialized. - err = fixture.ClientWaitForRoundWithTimeout(restClient, 1) + err = restClient.WaitForRoundWithTimeout(1) a.NoError(err) return nodeController, restClient, &errorsCollector @@ -239,7 +239,7 @@ func startCatchpointUsingNode(a *require.Assertions, fixture *fixtures.RestClien restClient := fixture.GetAlgodClientForController(nodeController) // We don't want to start using the node without it being properly initialized. - err = fixture.ClientWaitForRoundWithTimeout(restClient, 1) + err = restClient.WaitForRoundWithTimeout(1) a.NoError(err) return nodeController, restClient, wp, &errorsCollector @@ -263,7 +263,7 @@ func startCatchpointNormalNode(a *require.Assertions, fixture *fixtures.RestClie restClient := fixture.GetAlgodClientForController(nodeController) // We don't want to start using the node without it being properly initialized. - err = fixture.ClientWaitForRoundWithTimeout(restClient, 1) + err = restClient.WaitForRoundWithTimeout(1) a.NoError(err) return nodeController, restClient, &errorsCollector @@ -365,7 +365,7 @@ func TestBasicCatchpointCatchup(t *testing.T) { _, err = usingNodeRestClient.Catchup(catchpointLabel, 0) a.NoError(err) - err = fixture.ClientWaitForRoundWithTimeout(usingNodeRestClient, uint64(targetCatchpointRound+1)) + err = usingNodeRestClient.WaitForRoundWithTimeout(uint64(targetCatchpointRound + 1)) a.NoError(err) // ensure the raw block can be downloaded (including cert) @@ -438,7 +438,7 @@ func TestCatchpointLabelGeneration(t *testing.T) { primaryNodeRestClient := fixture.GetAlgodClientForController(primaryNode) log.Infof("Building ledger history..") for { - err = fixture.ClientWaitForRound(primaryNodeRestClient, currentRound, 45*time.Second) + _, err = primaryNodeRestClient.WaitForRound(currentRound+1, 45*time.Second) a.NoError(err) if targetRound <= currentRound { break @@ -553,8 +553,7 @@ func TestNodeTxHandlerRestart(t *testing.T) { // Wait for the network to start making progress again primaryNodeRestClient := fixture.GetAlgodClientForController(primaryNode) - err = fixture.ClientWaitForRound(primaryNodeRestClient, targetRound, - 10*catchpointCatchupProtocol.AgreementFilterTimeout) + _, err = primaryNodeRestClient.WaitForRound(targetRound, 10*catchpointCatchupProtocol.AgreementFilterTimeout) a.NoError(err) // let the 2nd client send a transaction @@ -674,8 +673,7 @@ func TestReadyEndpoint(t *testing.T) { // Wait for the network to start making progress again primaryNodeRestClient := fixture.GetAlgodClientForController(primaryNode) - err = fixture.ClientWaitForRound(primaryNodeRestClient, targetRound, - 10*catchpointCatchupProtocol.AgreementFilterTimeout) + _, err = primaryNodeRestClient.WaitForRound(targetRound, 10*catchpointCatchupProtocol.AgreementFilterTimeout) a.NoError(err) // The primary node has reached the target round, diff --git a/test/e2e-go/features/catchup/stateproofsCatchup_test.go b/test/e2e-go/features/catchup/stateproofsCatchup_test.go index 5dcbc11452..f9639abeb1 100644 --- a/test/e2e-go/features/catchup/stateproofsCatchup_test.go +++ b/test/e2e-go/features/catchup/stateproofsCatchup_test.go @@ -115,7 +115,7 @@ func TestStateProofInReplayCatchpoint(t *testing.T) { } // wait for fastcatchup to complete and the node is synced - err = fixture.ClientWaitForRoundWithTimeout(usingNodeRestClient, uint64(targetCatchpointRound+1)) + err = usingNodeRestClient.WaitForRoundWithTimeout(uint64(targetCatchpointRound + 1)) a.NoError(err) primaryLibGoal := fixture.GetLibGoalClientFromNodeController(primaryNode) @@ -174,7 +174,7 @@ func TestStateProofAfterCatchpoint(t *testing.T) { roundAfterSPGeneration := targetCatchpointRound.RoundUpToMultipleOf(basics.Round(consensusParams.StateProofInterval)) + basics.Round(consensusParams.StateProofInterval/2) - err = fixture.ClientWaitForRoundWithTimeout(usingNodeRestClient, uint64(roundAfterSPGeneration)) + err = usingNodeRestClient.WaitForRoundWithTimeout(uint64(roundAfterSPGeneration)) a.NoError(err) primaryLibGoal := fixture.GetLibGoalClientFromNodeController(primaryNode) @@ -234,14 +234,14 @@ func TestSendSigsAfterCatchpointCatchup(t *testing.T) { primaryNodeAddr, err := primaryNode.GetListeningAddress() a.NoError(err) - err = fixture.ClientWaitForRoundWithTimeout(primaryNodeRestClient, 3) + err = primaryNodeRestClient.WaitForRoundWithTimeout(3) a.NoError(err) normalNode, normalNodeRestClient, normalNodeEC := startCatchpointNormalNode(a, &fixture, "Node1", primaryNodeAddr) defer normalNodeEC.Print() defer normalNode.StopAlgod() - err = fixture.ClientWaitForRoundWithTimeout(normalNodeRestClient, 3) + err = normalNodeRestClient.WaitForRoundWithTimeout(3) a.NoError(err) // at this point PrimaryNode and Node1 would pass round 3. Before running Node2 we remove block 2 from Primary database. @@ -267,7 +267,7 @@ func TestSendSigsAfterCatchpointCatchup(t *testing.T) { _, err = usingNodeRestClient.Catchup(catchpointLabel, 0) a.NoError(err) - err = fixture.ClientWaitForRoundWithTimeout(usingNodeRestClient, uint64(targetCatchpointRound)+1) + err = usingNodeRestClient.WaitForRoundWithTimeout(uint64(targetCatchpointRound) + 1) a.NoError(err) lastNormalRound, err := fixture.GetLibGoalClientFromNodeController(normalNode).CurrentRound() @@ -280,7 +280,7 @@ func TestSendSigsAfterCatchpointCatchup(t *testing.T) { lastNormalNodeSignedRound := basics.Round(lastNormalRound).RoundDownToMultipleOf(basics.Round(consensusParams.StateProofInterval)) lastNormalNextStateProofRound := lastNormalNodeSignedRound + basics.Round(consensusParams.StateProofInterval) targetRound := lastNormalNextStateProofRound + basics.Round(consensusParams.StateProofInterval*2) - err = fixture.ClientWaitForRoundWithTimeout(usingNodeRestClient, uint64(targetRound)) + err = usingNodeRestClient.WaitForRoundWithTimeout(uint64(targetRound)) a.NoError(err) primaryClient := fixture.GetLibGoalClientFromNodeController(primaryNode) diff --git a/test/e2e-go/features/followernode/syncDeltas_test.go b/test/e2e-go/features/followernode/syncDeltas_test.go index af27c7dda7..d1458b7451 100644 --- a/test/e2e-go/features/followernode/syncDeltas_test.go +++ b/test/e2e-go/features/followernode/syncDeltas_test.go @@ -74,7 +74,7 @@ func TestBasicSyncMode(t *testing.T) { // Let the network make some progress waitForRound := uint64(5) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Get the follower client, and exercise the sync/ledger functionality @@ -88,7 +88,7 @@ func TestBasicSyncMode(t *testing.T) { a.NoError(err) a.Equal(round, rResp.Round) // make some progress to round - err = fixture.ClientWaitForRoundWithTimeout(followClient, round) + err = followClient.WaitForRoundWithTimeout(round) a.NoError(err) // retrieve state delta gResp, err := followClient.GetLedgerStateDelta(round) @@ -113,6 +113,6 @@ func TestBasicSyncMode(t *testing.T) { err = followClient.SetSyncRound(round + 1) a.NoError(err) } - err = fixture.LibGoalFixture.ClientWaitForRoundWithTimeout(fixture.LibGoalClient, waitForRound) + err = fixture.WaitForRoundWithTimeout(waitForRound) a.NoError(err) } diff --git a/test/e2e-go/features/followernode/syncRestart_test.go b/test/e2e-go/features/followernode/syncRestart_test.go index 589bb7b53c..1aa5b2560d 100644 --- a/test/e2e-go/features/followernode/syncRestart_test.go +++ b/test/e2e-go/features/followernode/syncRestart_test.go @@ -62,7 +62,7 @@ func TestSyncRestart(t *testing.T) { waitTill := func(node string, round uint64) { controller, err := fixture.GetNodeController(node) a.NoError(err) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(controller), round) + err = fixture.GetAlgodClientForController(controller).WaitForRoundWithTimeout(round) a.NoError(err) } diff --git a/test/e2e-go/features/incentives/challenge_test.go b/test/e2e-go/features/incentives/challenge_test.go new file mode 100644 index 0000000000..51586eab76 --- /dev/null +++ b/test/e2e-go/features/incentives/challenge_test.go @@ -0,0 +1,188 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package suspension + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/algorand/go-algorand/config" + "github.com/algorand/go-algorand/daemon/algod/api/server/v2/generated/model" + "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" + "github.com/algorand/go-algorand/libgoal" + "github.com/algorand/go-algorand/protocol" + "github.com/algorand/go-algorand/test/framework/fixtures" + "github.com/algorand/go-algorand/test/partitiontest" + "github.com/algorand/go-algorand/util" +) + +// TestChallenges ensures that accounts are knocked off if they don't respond to +// a challenge, and that algod responds for accounts it knows (keepign them online) +func TestChallenges(t *testing.T) { + partitiontest.PartitionTest(t) + defer fixtures.ShutdownSynchronizedTest(t) + + t.Parallel() + a := require.New(fixtures.SynchronizedTest(t)) + + // Overview of this test: + // Use a consensus protocol with challenge interval=50, grace period=10, bits=2. + // Start a three-node network. One relay, two nodes with 4 accounts each + // At round 50, ~2 nodes will be challenged. + + const lookback = 32 + const interval = 50 + const grace = 10 + const mask = 0x80 + + var fixture fixtures.RestClientFixture + // Speed up rounds, keep lookback > 2 * grace period + fixture.FasterConsensus(protocol.ConsensusFuture, time.Second, lookback) + fixture.AlterConsensus(protocol.ConsensusFuture, + func(cp config.ConsensusParams) config.ConsensusParams { + cp.Payouts.ChallengeInterval = 50 + cp.Payouts.ChallengeGracePeriod = 10 + cp.Payouts.ChallengeBits = 1 // half of nodes should get challenged + return cp + }) + fixture.Setup(t, filepath.Join("nettemplates", "Challenges.json")) + defer fixture.Shutdown() + + clientAndAccounts := func(name string) (libgoal.Client, []model.Account) { + c := fixture.GetLibGoalClientForNamedNode(name) + accounts, err := fixture.GetNodeWalletsSortedByBalance(c) + a.NoError(err) + a.Len(accounts, 4) + fmt.Printf("Client %s has %v\n", name, accounts) + return c, accounts + } + + c1, accounts1 := clientAndAccounts("Node1") + c2, accounts2 := clientAndAccounts("Node2") + + // By re-regging, we become eligible for suspension (normal + challenges) + // TODO: Confirm that rereg is required for challenge suspensions + + err := fixture.WaitForRoundWithTimeout(interval - lookback) // Make all LastHeartbeats > interval, < 2*interval + a.NoError(err) + + for _, account := range accounts1 { + rekeyreg(&fixture, a, c1, account.Address) + } + for _, account := range accounts2 { + rekeyreg(&fixture, a, c2, account.Address) + } + + // turn off node 1, so it can't heartbeat + a.NoError(c1.FullStop()) + + current, err := c2.CurrentRound() + a.NoError(err) + // Get them all done so that their inflated LastHeartbeat comes before the + // next challenge. + a.Less(current+lookback, 2*uint64(interval)) + + // We need to wait for the first challenge that happens after the keyreg + // LastHeartbeat has passed. Example: current is 40, so the lastPossible + // LastHeartbeat is 72. Interval is 50, so challengeRound is 100. + + // 100 = 40 + 32 + (50-22) = 72 + 28 + lastPossible := current + lookback + challengeRound := lastPossible + (interval - lastPossible%interval) + + // Advance to challenge round, check the blockseed + err = fixture.WaitForRoundWithTimeout(challengeRound) + a.NoError(err) + blk, err := c2.BookkeepingBlock(challengeRound) + a.NoError(err) + challenge := blk.BlockHeader.Seed[0] & mask // high bit + + challenged1 := util.MakeSet[basics.Address]() + for _, account := range accounts1 { + address, err := basics.UnmarshalChecksumAddress(account.Address) + a.NoError(err) + if address[0]&mask == challenge { + fmt.Printf("%v of node 1 was challenged %v by %v\n", address, address[0], challenge) + challenged1.Add(address) + } + } + require.NotEmpty(t, challenged1, "rerun the test") // TODO: remove. + + challenged2 := util.MakeSet[basics.Address]() + for _, account := range accounts2 { + address, err := basics.UnmarshalChecksumAddress(account.Address) + a.NoError(err) + if address[0]&mask == challenge { + fmt.Printf("%v of node 2 was challenged %v by %v\n", address, address[0], challenge) + challenged2.Add(address) + } + } + require.NotEmpty(t, challenged2, "rerun the test") // TODO: remove. + + allChallenged := util.Union(challenged1, challenged2) + + // All challenged nodes are still online + for address := range allChallenged { + data, err := c2.AccountData(address.String()) + a.NoError(err) + a.Equal(basics.Online, data.Status, "%v %d", address.String(), data.LastHeartbeat) + a.NotZero(data.VoteID) + a.True(data.IncentiveEligible) + } + + // In the second half of the grace period, Node 2 should heartbeat for its accounts + beated := util.MakeSet[basics.Address]() + fixture.WithEveryBlock(challengeRound+grace/2, challengeRound+grace, func(block bookkeeping.Block) { + for _, txn := range block.Payset { + hb := txn.Txn.HeartbeatTxnFields + fmt.Printf("Heartbeat txn %v\n", hb) + a.True(challenged2.Contains(hb.HbAddress)) // only Node 2 is alive + a.False(beated.Contains(hb.HbAddress)) // beat only once + beated.Add(hb.HbAddress) + } + a.Empty(block.AbsentParticipationAccounts) // nobody suspended during grace + }) + a.Equal(challenged2, beated) + + blk, err = fixture.WaitForBlockWithTimeout(challengeRound + grace + 1) + a.NoError(err) + a.Equal(challenged1, util.MakeSet(blk.AbsentParticipationAccounts...)) + + // node 1 challenged accounts are suspended because node 1 is off + for address := range challenged1 { + data, err := c2.AccountData(address.String()) + a.NoError(err) + a.Equal(basics.Offline, data.Status, address) + a.NotZero(data.VoteID, address) + a.False(data.IncentiveEligible, address) // suspension turns off flag + } + + // node 2 challenged accounts are not suspended (saved by heartbeat) + for address := range challenged2 { + data, err := c2.AccountData(address.String()) + a.NoError(err) + a.Equal(basics.Online, data.Status, address) + a.NotZero(data.VoteID, address) + a.True(data.IncentiveEligible, address) + } + +} diff --git a/test/e2e-go/features/incentives/whalejoin_test.go b/test/e2e-go/features/incentives/whalejoin_test.go index b3e8dac479..783a01ca33 100644 --- a/test/e2e-go/features/incentives/whalejoin_test.go +++ b/test/e2e-go/features/incentives/whalejoin_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/require" + v2 "github.com/algorand/go-algorand/daemon/algod/api/server/v2" "github.com/algorand/go-algorand/daemon/algod/api/server/v2/generated/model" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/transactions" @@ -36,8 +37,8 @@ import ( // TestWhaleJoin shows a "whale" with more stake than is currently online can go // online without immediate suspension. This tests for a bug we had where we // calcululated expected proposal interval using the _old_ totals, rather than -// the totals following the keyreg. So big joiner could be expected to propose -// in the same block they joined. +// the totals following the keyreg. So big joiner was being expected to propose +// in the same block it joined. func TestWhaleJoin(t *testing.T) { partitiontest.PartitionTest(t) defer fixtures.ShutdownSynchronizedTest(t) @@ -185,6 +186,65 @@ func TestBigJoin(t *testing.T) { // is looking for. } +// TestBigIncrease shows when an incentive eligible account receives a lot of +// algos, they are not immediately suspended. We also check the details of the +// mechanism - that LastHeartbeat is incremented when such an account doubles +// its balance in a single pay. +func TestBigIncrease(t *testing.T) { + partitiontest.PartitionTest(t) + defer fixtures.ShutdownSynchronizedTest(t) + + t.Parallel() + a := require.New(fixtures.SynchronizedTest(t)) + + var fixture fixtures.RestClientFixture + const lookback = 32 + fixture.FasterConsensus(protocol.ConsensusFuture, time.Second/2, lookback) + fixture.Setup(t, filepath.Join("nettemplates", "Payouts.json")) + defer fixture.Shutdown() + + // Overview of this test: + // 0. spend wallet01 down so it has a very small percent of stake + // 1. rereg wallet01 so it is suspendable + // 2. move almost all of wallet15's money to wallet01 + // 3. check that c1.LastHeart is set to 32 rounds later + // 4. wait 40 rounds ensure c1 stays online + + clientAndAccount := func(name string) (libgoal.Client, model.Account) { + c := fixture.GetLibGoalClientForNamedNode(name) + accounts, err := fixture.GetNodeWalletsSortedByBalance(c) + a.NoError(err) + a.Len(accounts, 1) + fmt.Printf("Client %s is %v\n", name, accounts[0].Address) + return c, accounts[0] + } + + c1, account01 := clientAndAccount("Node01") + c15, account15 := clientAndAccount("Node15") + + // We need to spend 01 down so that it has nearly no stake. That way, it + // certainly will not have proposed by pure luck just before the critical + // round. If we don't do that, 1/16 of stake is enough that it will probably + // have a fairly recent proposal, and not get knocked off. + pay(&fixture, a, c1, account01.Address, account15.Address, 99*account01.Amount/100) + + rekeyreg(&fixture, a, c1, account01.Address) + + // 2. Wait lookback rounds + wait(&fixture, a, lookback) + + tx := pay(&fixture, a, c15, account15.Address, account01.Address, 50*account15.Amount/100) + data, err := c15.AccountData(account01.Address) + a.NoError(err) + a.EqualValues(*tx.ConfirmedRound+lookback, data.LastHeartbeat) + + wait(&fixture, a, lookback+5) + data, err = c15.AccountData(account01.Address) + a.NoError(err) + a.Equal(basics.Online, data.Status) + a.True(data.IncentiveEligible) +} + func wait(f *fixtures.RestClientFixture, a *require.Assertions, count uint64) { res, err := f.AlgodClient.Status() a.NoError(err) @@ -192,12 +252,18 @@ func wait(f *fixtures.RestClientFixture, a *require.Assertions, count uint64) { a.NoError(f.WaitForRoundWithTimeout(round)) } -func zeroPay(f *fixtures.RestClientFixture, a *require.Assertions, - c libgoal.Client, address string) { - pay, err := c.SendPaymentFromUnencryptedWallet(address, address, 1000, 0, nil) +func pay(f *fixtures.RestClientFixture, a *require.Assertions, + c libgoal.Client, from string, to string, amount uint64) v2.PreEncodedTxInfo { + pay, err := c.SendPaymentFromUnencryptedWallet(from, to, 1000, amount, nil) a.NoError(err) - _, err = f.WaitForConfirmedTxn(uint64(pay.LastValid), pay.ID().String()) + tx, err := f.WaitForConfirmedTxn(uint64(pay.LastValid), pay.ID().String()) a.NoError(err) + return tx +} + +func zeroPay(f *fixtures.RestClientFixture, a *require.Assertions, + c libgoal.Client, address string) { + pay(f, a, c, address, address, 0) } // Go offline, but return the key material so it's easy to go back online diff --git a/test/e2e-go/features/participation/onlineOfflineParticipation_test.go b/test/e2e-go/features/participation/onlineOfflineParticipation_test.go index 0b38fe76ff..21a701139a 100644 --- a/test/e2e-go/features/participation/onlineOfflineParticipation_test.go +++ b/test/e2e-go/features/participation/onlineOfflineParticipation_test.go @@ -216,7 +216,7 @@ func TestNewAccountCanGoOnlineAndParticipate(t *testing.T) { // Need to wait for funding to take effect on selection, then we can see if we're participating // Stop before the account should become eligible for selection so we can ensure it wasn't - err = fixture.ClientWaitForRound(fixture.AlgodClient, uint64(accountProposesStarting-1), + err = fixture.WaitForRound(uint64(accountProposesStarting-1), time.Duration(uint64(globals.MaxTimePerRound)*uint64(accountProposesStarting-1))) a.NoError(err) @@ -226,7 +226,7 @@ func TestNewAccountCanGoOnlineAndParticipate(t *testing.T) { a.False(blockWasProposed, "account should not be selected until BalLookback (round %d) passes", int(accountProposesStarting-1)) // Now wait until the round where the funded account will be used. - err = fixture.ClientWaitForRound(fixture.AlgodClient, uint64(accountProposesStarting), 10*globals.MaxTimePerRound) + err = fixture.WaitForRound(uint64(accountProposesStarting), 10*globals.MaxTimePerRound) a.NoError(err) blockWasProposedByNewAccountRecently := fixture.VerifyBlockProposedRange(newAccount, int(accountProposesStarting), 1) diff --git a/test/e2e-go/features/partitionRecovery/partitionRecovery_test.go b/test/e2e-go/features/partitionRecovery/partitionRecovery_test.go index 21ce3bdf0d..e3429490c4 100644 --- a/test/e2e-go/features/partitionRecovery/partitionRecovery_test.go +++ b/test/e2e-go/features/partitionRecovery/partitionRecovery_test.go @@ -57,7 +57,7 @@ func TestBasicPartitionRecovery(t *testing.T) { // Let the network make some progress waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc), waitForRound) + err = fixture.GetAlgodClientForController(nc).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Now stop 2nd node @@ -133,7 +133,7 @@ func runTestWithStaggeredStopStart(t *testing.T, fixture *fixtures.RestClientFix // Let the network make some progress waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc1), waitForRound) + err = fixture.GetAlgodClientForController(nc1).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Stop Node1 @@ -196,7 +196,7 @@ func TestBasicPartitionRecoveryPartOffline(t *testing.T) { // Let the network make some progress waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc1), waitForRound) + err = fixture.GetAlgodClientForController(nc1).WaitForRoundWithTimeout(waitForRound) a.NoError(err) // Stop Node1 @@ -264,8 +264,7 @@ func TestPartitionHalfOffline(t *testing.T) { // Let the network make some progress client := fixture.LibGoalClient - waitForRound := uint64(3) - err = fixture.ClientWaitForRoundWithTimeout(fixture.GetAlgodClientForController(nc1), waitForRound) + err = fixture.GetAlgodClientForController(nc1).WaitForRoundWithTimeout(3) a.NoError(err) // Stop nodes with 50% of stake diff --git a/test/e2e-go/restAPI/other/misc_test.go b/test/e2e-go/restAPI/other/misc_test.go index eeaff9fcd1..23e805dc25 100644 --- a/test/e2e-go/restAPI/other/misc_test.go +++ b/test/e2e-go/restAPI/other/misc_test.go @@ -62,7 +62,7 @@ func TestDisabledAPIConfig(t *testing.T) { a.NoError(err) testClient := client.MakeRestClient(url, "") // empty token - _, err = testClient.WaitForBlock(1) + err = testClient.WaitForRoundWithTimeout(1) assert.NoError(t, err) _, err = testClient.Block(1) assert.NoError(t, err) diff --git a/test/e2e-go/restAPI/simulate/simulateRestAPI_test.go b/test/e2e-go/restAPI/simulate/simulateRestAPI_test.go index 66601c1737..b058b510e4 100644 --- a/test/e2e-go/restAPI/simulate/simulateRestAPI_test.go +++ b/test/e2e-go/restAPI/simulate/simulateRestAPI_test.go @@ -53,7 +53,7 @@ func TestSimulateTxnTracerDevMode(t *testing.T) { testClient := localFixture.LibGoalClient - _, err := testClient.WaitForRound(1) + _, err := testClient.Status() a.NoError(err) wh, err := testClient.GetUnencryptedWalletHandle() @@ -288,11 +288,11 @@ int 1` // Let the primary node make some progress primaryClient := fixture.GetAlgodClientForController(nc) - err = fixture.ClientWaitForRoundWithTimeout(primaryClient, followerSyncRound+uint64(cfg.MaxAcctLookback)) + err = primaryClient.WaitForRoundWithTimeout(followerSyncRound + uint64(cfg.MaxAcctLookback)) a.NoError(err) // Let follower node progress as far as it can - err = fixture.ClientWaitForRoundWithTimeout(followClient, followerSyncRound+uint64(cfg.MaxAcctLookback)-1) + err = followClient.WaitForRoundWithTimeout(followerSyncRound + uint64(cfg.MaxAcctLookback) - 1) a.NoError(err) simulateRequest := v2.PreEncodedSimulateRequest{ diff --git a/test/e2e-go/upgrades/application_support_test.go b/test/e2e-go/upgrades/application_support_test.go index 549a82c5ab..c41ad84166 100644 --- a/test/e2e-go/upgrades/application_support_test.go +++ b/test/e2e-go/upgrades/application_support_test.go @@ -180,7 +180,7 @@ int 1 curStatus, err = client.Status() a.NoError(err) - a.Less(int64(time.Now().Sub(startLoopTime)), int64(3*time.Minute)) + a.Less(int64(time.Since(startLoopTime)), int64(3*time.Minute)) time.Sleep(time.Duration(smallLambdaMs) * time.Millisecond) } @@ -438,7 +438,7 @@ int 1 curStatus, err = client.Status() a.NoError(err) - a.Less(int64(time.Now().Sub(startLoopTime)), int64(3*time.Minute)) + a.Less(int64(time.Since(startLoopTime)), int64(3*time.Minute)) time.Sleep(time.Duration(smallLambdaMs) * time.Millisecond) round = curStatus.LastRound } diff --git a/test/e2e-go/upgrades/rekey_support_test.go b/test/e2e-go/upgrades/rekey_support_test.go index 0dcec41545..cc3eca018c 100644 --- a/test/e2e-go/upgrades/rekey_support_test.go +++ b/test/e2e-go/upgrades/rekey_support_test.go @@ -150,7 +150,7 @@ func TestRekeyUpgrade(t *testing.T) { curStatus, err = client.Status() a.NoError(err) - a.Less(int64(time.Now().Sub(startLoopTime)), int64(3*time.Minute)) + a.Less(int64(time.Since(startLoopTime)), int64(3*time.Minute)) time.Sleep(time.Duration(smallLambdaMs) * time.Millisecond) round = curStatus.LastRound } diff --git a/test/framework/fixtures/libgoalFixture.go b/test/framework/fixtures/libgoalFixture.go index bd4f615ae7..c05a59ff1f 100644 --- a/test/framework/fixtures/libgoalFixture.go +++ b/test/framework/fixtures/libgoalFixture.go @@ -42,7 +42,6 @@ import ( "github.com/algorand/go-algorand/netdeploy" "github.com/algorand/go-algorand/nodecontrol" "github.com/algorand/go-algorand/protocol" - "github.com/algorand/go-algorand/test/e2e-go/globals" "github.com/algorand/go-algorand/util/db" ) @@ -67,26 +66,32 @@ func (f *RestClientFixture) SetConsensus(consensus config.ConsensusProtocols) { f.consensus = consensus } +// AlterConsensus allows the caller to modify the consensus settings for a given version. +func (f *RestClientFixture) AlterConsensus(ver protocol.ConsensusVersion, alter func(config.ConsensusParams) config.ConsensusParams) { + if f.consensus == nil { + f.consensus = make(config.ConsensusProtocols) + } + f.consensus[ver] = alter(f.ConsensusParamsFromVer(ver)) +} + // FasterConsensus speeds up the given consensus version in two ways. The seed // refresh lookback is set to 8 (instead of 80), so the 320 round balance // lookback becomes 32. And, if the architecture implies it can be handled, // round times are shortened by lowering vote timeouts. func (f *RestClientFixture) FasterConsensus(ver protocol.ConsensusVersion, timeout time.Duration, lookback basics.Round) { - if f.consensus == nil { - f.consensus = make(config.ConsensusProtocols) - } - fast := config.Consensus[ver] - // balanceRound is 4 * SeedRefreshInterval - if lookback%4 != 0 { - panic(fmt.Sprintf("lookback must be a multiple of 4, got %d", lookback)) - } - fast.SeedRefreshInterval = uint64(lookback) / 4 - // and speed up the rounds while we're at it - if runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" { - fast.AgreementFilterTimeoutPeriod0 = timeout - fast.AgreementFilterTimeout = timeout - } - f.consensus[ver] = fast + f.AlterConsensus(ver, func(fast config.ConsensusParams) config.ConsensusParams { + // balanceRound is 4 * SeedRefreshInterval + if lookback%4 != 0 { + panic(fmt.Sprintf("lookback must be a multiple of 4, got %d", lookback)) + } + fast.SeedRefreshInterval = uint64(lookback) / 4 + // and speed up the rounds while we're at it + if runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" { + fast.AgreementFilterTimeoutPeriod0 = timeout + fast.AgreementFilterTimeout = timeout + } + return fast + }) } // Setup is called to initialize the test fixture for the test(s) @@ -452,75 +457,6 @@ func (f *LibGoalFixture) GetParticipationOnlyAccounts(lg libgoal.Client) []accou return f.clientPartKeys[lg.DataDir()] } -// WaitForRoundWithTimeout waits for a given round to reach. The implementation also ensures to limit the wait time for each round to the -// globals.MaxTimePerRound so we can alert when we're getting "hung" before waiting for all the expected rounds to reach. -func (f *LibGoalFixture) WaitForRoundWithTimeout(roundToWaitFor uint64) error { - return f.ClientWaitForRoundWithTimeout(f.LibGoalClient, roundToWaitFor) -} - -// ClientWaitForRoundWithTimeout waits for a given round to be reached by the specific client/node. The implementation -// also ensures to limit the wait time for each round to the globals.MaxTimePerRound so we can alert when we're -// getting "hung" before waiting for all the expected rounds to reach. -func (f *LibGoalFixture) ClientWaitForRoundWithTimeout(client libgoal.Client, roundToWaitFor uint64) error { - status, err := client.Status() - require.NoError(f.t, err) - lastRound := status.LastRound - - // If node is already at or past target round, we're done - if lastRound >= roundToWaitFor { - return nil - } - - roundTime := globals.MaxTimePerRound * 10 // For first block, we wait much longer - roundComplete := make(chan error, 2) - - for nextRound := lastRound + 1; lastRound < roundToWaitFor; { - roundStarted := time.Now() - - go func(done chan error) { - err := f.ClientWaitForRound(client, nextRound, roundTime) - done <- err - }(roundComplete) - - select { - case lastError := <-roundComplete: - if lastError != nil { - close(roundComplete) - return lastError - } - case <-time.After(roundTime): - // we've timed out. - time := time.Now().Sub(roundStarted) - return fmt.Errorf("fixture.WaitForRound took %3.2f seconds between round %d and %d", time.Seconds(), lastRound, nextRound) - } - - roundTime = singleRoundMaxTime - lastRound++ - nextRound++ - } - return nil -} - -// ClientWaitForRound waits up to the specified amount of time for -// the network to reach or pass the specified round, on the specific client/node -func (f *LibGoalFixture) ClientWaitForRound(client libgoal.Client, round uint64, waitTime time.Duration) error { - timeout := time.NewTimer(waitTime) - for { - status, err := client.Status() - if err != nil { - return err - } - if status.LastRound >= round { - return nil - } - select { - case <-timeout.C: - return fmt.Errorf("timeout waiting for round %v", round) - case <-time.After(200 * time.Millisecond): - } - } -} - // CurrentConsensusParams returns the consensus parameters for the currently active protocol func (f *LibGoalFixture) CurrentConsensusParams() (consensus config.ConsensusParams, err error) { status, err := f.LibGoalClient.Status() @@ -532,20 +468,20 @@ func (f *LibGoalFixture) CurrentConsensusParams() (consensus config.ConsensusPar } // ConsensusParams returns the consensus parameters for the protocol from the specified round -func (f *LibGoalFixture) ConsensusParams(round uint64) (consensus config.ConsensusParams, err error) { +func (f *LibGoalFixture) ConsensusParams(round uint64) (config.ConsensusParams, error) { block, err := f.LibGoalClient.BookkeepingBlock(round) if err != nil { - return + return config.ConsensusParams{}, err } - version := protocol.ConsensusVersion(block.CurrentProtocol) - if f.consensus != nil { - consensus, has := f.consensus[version] - if has { - return consensus, nil - } + return f.ConsensusParamsFromVer(block.CurrentProtocol), nil +} + +// ConsensusParamsFromVer looks up a consensus version, allowing for override +func (f *LibGoalFixture) ConsensusParamsFromVer(cv protocol.ConsensusVersion) config.ConsensusParams { + if consensus, has := f.consensus[cv]; has { + return consensus } - consensus = config.Consensus[version] - return + return config.Consensus[cv] } // CurrentMinFeeAndBalance returns the MinTxnFee and MinBalance for the currently active protocol diff --git a/test/framework/fixtures/restClientFixture.go b/test/framework/fixtures/restClientFixture.go index 473df25d38..fb1a26d31b 100644 --- a/test/framework/fixtures/restClientFixture.go +++ b/test/framework/fixtures/restClientFixture.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/require" "github.com/algorand/go-algorand/data/basics" + "github.com/algorand/go-algorand/data/bookkeeping" "github.com/algorand/go-algorand/netdeploy" "github.com/algorand/go-algorand/protocol" @@ -34,7 +35,6 @@ import ( "github.com/algorand/go-algorand/libgoal" "github.com/algorand/go-algorand/nodecontrol" - "github.com/algorand/go-algorand/test/e2e-go/globals" "github.com/algorand/go-algorand/util/tokens" ) @@ -80,79 +80,37 @@ func (f *RestClientFixture) GetAlgodClientForController(nc nodecontrol.NodeContr // WaitForRound waits up to the specified amount of time for // the network to reach or pass the specified round func (f *RestClientFixture) WaitForRound(round uint64, waitTime time.Duration) error { - return f.ClientWaitForRound(f.AlgodClient, round, waitTime) + _, err := f.AlgodClient.WaitForRound(round, waitTime) + return err } -// ClientWaitForRound waits up to the specified amount of time for -// the network to reach or pass the specified round, on the specific client/node -func (f *RestClientFixture) ClientWaitForRound(client client.RestClient, round uint64, waitTime time.Duration) error { - timeout := time.NewTimer(waitTime) - for { - status, err := client.Status() - if err != nil { - return err - } - - if status.LastRound >= round { - return nil - } - select { - case <-timeout.C: - return fmt.Errorf("timeout waiting for round %v with last round = %v", round, status.LastRound) - case <-time.After(200 * time.Millisecond): - } +// WithEveryBlock calls the provided function for every block from first to last. +func (f *RestClientFixture) WithEveryBlock(first, last uint64, visit func(bookkeeping.Block)) { + for round := first; round <= last; round++ { + err := f.WaitForRoundWithTimeout(round) + require.NoError(f.t, err) + block, err := f.AlgodClient.Block(round) + require.NoError(f.t, err) + visit(block.Block) } } // WaitForRoundWithTimeout waits for a given round to reach. The implementation also ensures to limit the wait time for each round to the // globals.MaxTimePerRound so we can alert when we're getting "hung" before waiting for all the expected rounds to reach. func (f *RestClientFixture) WaitForRoundWithTimeout(roundToWaitFor uint64) error { - return f.ClientWaitForRoundWithTimeout(f.AlgodClient, roundToWaitFor) + return f.AlgodClient.WaitForRoundWithTimeout(roundToWaitFor) } -const singleRoundMaxTime = globals.MaxTimePerRound * 40 - -// ClientWaitForRoundWithTimeout waits for a given round to be reached by the specific client/node. The implementation -// also ensures to limit the wait time for each round to the globals.MaxTimePerRound so we can alert when we're -// getting "hung" before waiting for all the expected rounds to reach. -func (f *RestClientFixture) ClientWaitForRoundWithTimeout(client client.RestClient, roundToWaitFor uint64) error { - status, err := client.Status() - require.NoError(f.t, err) - lastRound := status.LastRound - - // If node is already at or past target round, we're done - if lastRound >= roundToWaitFor { - return nil +// WaitForBlockWithTimeout waits for a given round and returns its block. +func (f *RestClientFixture) WaitForBlockWithTimeout(roundToWaitFor uint64) (bookkeeping.Block, error) { + if err := f.AlgodClient.WaitForRoundWithTimeout(roundToWaitFor); err != nil { + return bookkeeping.Block{}, err } - - roundTime := globals.MaxTimePerRound * 10 // For first block, we wait much longer - roundComplete := make(chan error, 2) - - for nextRound := lastRound + 1; lastRound < roundToWaitFor; { - roundStarted := time.Now() - - go func(done chan error) { - err := f.ClientWaitForRound(client, nextRound, roundTime) - done <- err - }(roundComplete) - - select { - case lastError := <-roundComplete: - if lastError != nil { - close(roundComplete) - return lastError - } - case <-time.After(roundTime): - // we've timed out. - time := time.Now().Sub(roundStarted) - return fmt.Errorf("fixture.WaitForRound took %3.2f seconds between round %d and %d", time.Seconds(), lastRound, nextRound) - } - - roundTime = singleRoundMaxTime - lastRound++ - nextRound++ + both, err := f.AlgodClient.EncodedBlockCert(roundToWaitFor) + if err != nil { + return bookkeeping.Block{}, err } - return nil + return both.Block, nil } // GetFirstAccount returns the first account from listing local accounts @@ -367,17 +325,15 @@ func (f *RestClientFixture) SendMoneyAndWaitFromWallet(walletHandle, walletPassw // VerifyBlockProposedRange checks the rounds starting at fromRounds and moving backwards checking countDownNumRounds rounds if any // blocks were proposed by address -func (f *RestClientFixture) VerifyBlockProposedRange(account string, fromRound, countDownNumRounds int) (blockWasProposed bool) { - c := f.LibGoalClient +func (f *RestClientFixture) VerifyBlockProposedRange(account string, fromRound, countDownNumRounds int) bool { for i := 0; i < countDownNumRounds; i++ { - cert, err := c.EncodedBlockCert(uint64(fromRound - i)) + cert, err := f.AlgodClient.EncodedBlockCert(uint64(fromRound - i)) require.NoError(f.t, err, "client failed to get block %d", fromRound-i) if cert.Certificate.Proposal.OriginalProposer.GetUserAddress() == account { - blockWasProposed = true - break + return true } } - return + return false } // VerifyBlockProposed checks the last searchRange blocks to see if any blocks were proposed by address diff --git a/test/testdata/nettemplates/Challenges.json b/test/testdata/nettemplates/Challenges.json new file mode 100644 index 0000000000..6519033e9c --- /dev/null +++ b/test/testdata/nettemplates/Challenges.json @@ -0,0 +1,44 @@ +{ + "Genesis": { + "NetworkName": "tbd", + "ConsensusProtocol": "future", + "LastPartKeyRound": 500, + "Wallets": [ + { "Name": "Relay", "Stake": 92, "Online": true }, + { "Name": "Wallet0", "Stake": 1, "Online": true }, + { "Name": "Wallet1", "Stake": 1, "Online": true }, + { "Name": "Wallet2", "Stake": 1, "Online": true }, + { "Name": "Wallet3", "Stake": 1, "Online": true }, + { "Name": "Wallet4", "Stake": 1, "Online": true }, + { "Name": "Wallet5", "Stake": 1, "Online": true }, + { "Name": "Wallet6", "Stake": 1, "Online": true }, + { "Name": "Wallet7", "Stake": 1, "Online": true } + ], + "RewardsPoolBalance": 0 + }, + "Nodes": [ + { + "Name": "Relay", + "Wallets": [{ "Name": "Relay", "ParticipationOnly": false }], + "IsRelay": true + }, + { + "Name": "Node1", + "Wallets": [ + { "Name": "Wallet0", "ParticipationOnly": false }, + { "Name": "Wallet1", "ParticipationOnly": false }, + { "Name": "Wallet2", "ParticipationOnly": false }, + { "Name": "Wallet3", "ParticipationOnly": false } + ] + }, + { + "Name": "Node2", + "Wallets": [ + { "Name": "Wallet4", "ParticipationOnly": false }, + { "Name": "Wallet5", "ParticipationOnly": false }, + { "Name": "Wallet6", "ParticipationOnly": false }, + { "Name": "Wallet7", "ParticipationOnly": false } + ] + } + ] +} diff --git a/util/db/dbutil.go b/util/db/dbutil.go index a6e524464d..8a52862c54 100644 --- a/util/db/dbutil.go +++ b/util/db/dbutil.go @@ -311,7 +311,7 @@ func (db *Accessor) AtomicContext(ctx context.Context, fn idemFn, extras ...inte } if time.Now().After(atomicDeadline) { - db.getDecoratedLogger(fn, extras).Warnf("dbatomic: tx surpassed expected deadline by %v", time.Now().Sub(atomicDeadline)) + db.getDecoratedLogger(fn, extras).Warnf("dbatomic: tx surpassed expected deadline by %v", time.Since(atomicDeadline)) } return } diff --git a/util/set.go b/util/set.go index 6851299c46..54ee92a050 100644 --- a/util/set.go +++ b/util/set.go @@ -40,3 +40,37 @@ func (s Set[T]) Contains(elem T) (exists bool) { _, exists = s[elem] return } + +// Union constructs a new set, containing all elements from the given sets. nil +// is never returned +func Union[T comparable](sets ...Set[T]) Set[T] { + union := make(Set[T]) + for _, set := range sets { + for elem := range set { + union.Add(elem) + } + } + return union +} + +// Intersection constructs a new set, containing all elements that appear in all +// given sets. nil is never returned. +func Intersection[T comparable](sets ...Set[T]) Set[T] { + var intersection = make(Set[T]) + if len(sets) == 0 { + return intersection + } + for elem := range sets[0] { + inAll := true + for _, set := range sets[1:] { + if _, exists := set[elem]; !exists { + inAll = false + break + } + } + if inAll { + intersection.Add(elem) + } + } + return intersection +} diff --git a/util/set_test.go b/util/set_test.go new file mode 100644 index 0000000000..86df9c5464 --- /dev/null +++ b/util/set_test.go @@ -0,0 +1,75 @@ +// Copyright (C) 2019-2024 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package util + +import ( + "testing" + + "github.com/algorand/go-algorand/test/partitiontest" + "github.com/stretchr/testify/require" +) + +func TestMakeSet(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + s := MakeSet(1, 2, 3) + require.True(t, s.Contains(1)) + require.True(t, s.Contains(2)) + require.True(t, s.Contains(3)) + require.False(t, s.Contains(4)) + + s = MakeSet[int]() + require.NotNil(t, s) + require.False(t, s.Contains(1)) + require.False(t, s.Contains(4)) +} + +func TestSetAdd(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + s := MakeSet[int]() + s.Add(6) + require.False(t, s.Contains(1)) + require.True(t, s.Contains(6)) + s.Add(6) + require.False(t, s.Contains(1)) + require.True(t, s.Contains(6)) +} + +func TestSetOps(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + empty := MakeSet[string]() + abc := MakeSet("a", "b", "c") + cde := MakeSet("c", "d", "e") + + require.Equal(t, abc, Union(abc)) + require.Equal(t, abc, Union(empty, abc)) + require.Equal(t, abc, Union(abc, empty, abc)) + require.NotNil(t, Union(empty, empty, empty)) + require.Equal(t, empty, Union(empty, empty, empty)) + + require.Equal(t, abc, Intersection(abc, abc)) + require.NotNil(t, Intersection(abc, empty)) + require.Equal(t, empty, Intersection(abc, empty)) + require.Equal(t, empty, Intersection(empty, abc)) + require.Equal(t, MakeSet("c"), Intersection(abc, cde)) + require.Equal(t, MakeSet("c"), Intersection(cde, abc, cde)) +}