diff --git a/daemon/algod/api/server/v2/account.go b/daemon/algod/api/server/v2/account.go index ddb0219372..7410b7515c 100644 --- a/daemon/algod/api/server/v2/account.go +++ b/daemon/algod/api/server/v2/account.go @@ -24,6 +24,7 @@ import ( "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/crypto/merklesignature" "github.com/algorand/go-algorand/daemon/algod/api/server/v2/generated/model" "github.com/algorand/go-algorand/data/basics" "golang.org/x/exp/slices" @@ -200,12 +201,16 @@ func AccountToAccountData(a *model.Account) (basics.AccountData, error) { var voteFirstValid basics.Round var voteLastValid basics.Round var voteKeyDilution uint64 + var stateProofID merklesignature.Commitment if a.Participation != nil { copy(voteID[:], a.Participation.VoteParticipationKey) copy(selID[:], a.Participation.SelectionParticipationKey) voteFirstValid = basics.Round(a.Participation.VoteFirstValid) voteLastValid = basics.Round(a.Participation.VoteLastValid) voteKeyDilution = a.Participation.VoteKeyDilution + if a.Participation.StateProofKey != nil { + copy(stateProofID[:], *a.Participation.StateProofKey) + } } var rewardsBase uint64 @@ -351,11 +356,13 @@ func AccountToAccountData(a *model.Account) (basics.AccountData, error) { MicroAlgos: basics.MicroAlgos{Raw: a.Amount}, RewardsBase: rewardsBase, RewardedMicroAlgos: basics.MicroAlgos{Raw: a.Rewards}, + IncentiveEligible: nilToZero(a.IncentiveEligible), VoteID: voteID, SelectionID: selID, VoteFirstValid: voteFirstValid, VoteLastValid: voteLastValid, VoteKeyDilution: voteKeyDilution, + StateProofID: stateProofID, Assets: assets, AppLocalStates: appLocalStates, AppParams: appParams, diff --git a/daemon/algod/api/server/v2/account_test.go b/daemon/algod/api/server/v2/account_test.go index ac1abd3b9d..421bfdc7ed 100644 --- a/daemon/algod/api/server/v2/account_test.go +++ b/daemon/algod/api/server/v2/account_test.go @@ -25,12 +25,15 @@ import ( "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" + ledgertesting "github.com/algorand/go-algorand/ledger/testing" "github.com/algorand/go-algorand/protocol" "github.com/algorand/go-algorand/test/partitiontest" ) func TestAccount(t *testing.T) { partitiontest.PartitionTest(t) + t.Parallel() + proto := config.Consensus[protocol.ConsensusFuture] appIdx1 := basics.AppIndex(1) appIdx2 := basics.AppIndex(2) @@ -203,3 +206,21 @@ func TestAccount(t *testing.T) { } }) } + +func TestAccountRandomRoundTrip(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + for _, simple := range []bool{true, false} { + accts := ledgertesting.RandomAccounts(20, simple) + for addr, acct := range accts { + round := basics.Round(2) + proto := config.Consensus[protocol.ConsensusFuture] + conv, err := AccountDataToAccount(addr.String(), &acct, round, &proto, acct.MicroAlgos) + require.NoError(t, err) + c, err := AccountToAccountData(&conv) + require.NoError(t, err) + require.Equal(t, acct, c) + } + } +} diff --git a/data/basics/units.go b/data/basics/units.go index c1b8f413b2..35a5ee5446 100644 --- a/data/basics/units.go +++ b/data/basics/units.go @@ -17,6 +17,8 @@ package basics import ( + "math" + "github.com/algorand/go-codec/codec" "github.com/algorand/msgp/msgp" @@ -43,6 +45,11 @@ func (a MicroAlgos) GreaterThan(b MicroAlgos) bool { return a.Raw > b.Raw } +// GTE implements arithmetic comparison for MicroAlgos +func (a MicroAlgos) GTE(b MicroAlgos) bool { + return a.Raw >= b.Raw +} + // IsZero implements arithmetic comparison for MicroAlgos func (a MicroAlgos) IsZero() bool { return a.Raw == 0 @@ -122,6 +129,17 @@ func MicroAlgosMaxSize() (s int) { return msgp.Uint64Size } +// Algos is a convenience function so that whole Algos can be written easily. It +// panics on overflow because it should only be used constants - things that are +// best human-readable in source code - not used on arbitrary values from, say, +// transactions. +func Algos(algos uint64) MicroAlgos { + if algos > math.MaxUint64/1_000_000 { + panic(algos) + } + return MicroAlgos{Raw: algos * 1_000_000} +} + // Round represents a protocol round index type Round uint64 diff --git a/data/basics/userBalance.go b/data/basics/userBalance.go index 2bca5c2b32..20d3388100 100644 --- a/data/basics/userBalance.go +++ b/data/basics/userBalance.go @@ -209,6 +209,9 @@ type AccountData struct { // This allows key rotation, changing the members in a multisig, etc. AuthAddr Address `codec:"spend"` + // IncentiveEligible indicates whether the account came online with the + // extra fee required to be eligible for block incentives. At proposal time, + // balance limits must also be met to receive incentives. IncentiveEligible bool `codec:"ie"` // AppLocalStates stores the local states associated with any applications diff --git a/data/txntest/txn.go b/data/txntest/txn.go index 5815aacc60..8ad6575d2d 100644 --- a/data/txntest/txn.go +++ b/data/txntest/txn.go @@ -23,6 +23,7 @@ import ( "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/crypto" + "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/stateproofmsg" @@ -55,6 +56,7 @@ type Txn struct { VoteLast basics.Round VoteKeyDilution uint64 Nonparticipation bool + StateProofPK merklesignature.Commitment Receiver basics.Address Amount uint64 @@ -228,6 +230,7 @@ func (tx Txn) Txn() transactions.Transaction { VoteLast: tx.VoteLast, VoteKeyDilution: tx.VoteKeyDilution, Nonparticipation: tx.Nonparticipation, + StateProofPK: tx.StateProofPK, }, PaymentTxnFields: transactions.PaymentTxnFields{ Receiver: tx.Receiver, diff --git a/ledger/apply/keyreg.go b/ledger/apply/keyreg.go index 206ff31bf3..2d4ea0c312 100644 --- a/ledger/apply/keyreg.go +++ b/ledger/apply/keyreg.go @@ -31,7 +31,7 @@ var errKeyregGoingOnlineFirstVotingInFuture = errors.New("transaction tries to m // Keyreg applies a KeyRegistration transaction using the Balances interface. func Keyreg(keyreg transactions.KeyregTxnFields, header transactions.Header, balances Balances, spec transactions.SpecialAddresses, ad *transactions.ApplyData, round basics.Round) error { if header.Sender == spec.FeeSink { - return fmt.Errorf("cannot register participation key for fee sink's address %v ", header.Sender) + return fmt.Errorf("cannot register participation key for fee sink's address %v", header.Sender) } // Get the user's balance entry @@ -67,6 +67,7 @@ func Keyreg(keyreg transactions.KeyregTxnFields, header transactions.Header, bal record.VoteFirstValid = 0 record.VoteLastValid = 0 record.VoteKeyDilution = 0 + record.IncentiveEligible = false } else { if params.EnableKeyregCoherencyCheck { if keyreg.VoteLast <= round { @@ -80,6 +81,9 @@ func Keyreg(keyreg transactions.KeyregTxnFields, header transactions.Header, bal record.VoteFirstValid = keyreg.VoteFirst record.VoteLastValid = keyreg.VoteLast record.VoteKeyDilution = keyreg.VoteKeyDilution + if header.Fee.GTE(incentiveFeeForEligibility) && params.EnableMining { + record.IncentiveEligible = true + } } // Write the updated entry @@ -90,3 +94,9 @@ func Keyreg(keyreg transactions.KeyregTxnFields, header transactions.Header, bal return nil } + +// incentiveFeeForEligibility imparts a small cost on moving from offline to +// online. This will impose a cost to running unreliable nodes that get +// suspended and then come back online. Becomes a consensus param if ever +// changed. +var incentiveFeeForEligibility = basics.Algos(2) diff --git a/ledger/apptxn_test.go b/ledger/apptxn_test.go index f6e826e1a3..dc2ce9fd18 100644 --- a/ledger/apptxn_test.go +++ b/ledger/apptxn_test.go @@ -26,6 +26,7 @@ import ( "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/crypto/merklesignature" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/data/transactions/logic" @@ -60,11 +61,19 @@ func TestPayAction(t *testing.T) { // We're going to test some mining effects here too, so that we have an inner transaction example. proposer := basics.Address{0x01, 0x02, 0x03} - dl.txn(&txntest.Txn{ + dl.txns(&txntest.Txn{ Type: "pay", Sender: addrs[7], Receiver: proposer, Amount: 1_000_000 * 1_000_000, // 1 million algos is surely an eligible amount + }, &txntest.Txn{ + Type: "keyreg", + Sender: proposer, + Fee: 3_000_000, + VotePK: crypto.OneTimeSignatureVerifier{0x01}, + SelectionPK: crypto.VRFVerifier{0x02}, + StateProofPK: merklesignature.Commitment{0x03}, + VoteFirst: 1, VoteLast: 1000, }) payout1 := txntest.Txn{ diff --git a/ledger/eval/eval.go b/ledger/eval/eval.go index 2d0da29e02..209b7725c1 100644 --- a/ledger/eval/eval.go +++ b/ledger/eval/eval.go @@ -799,7 +799,7 @@ func StartEvaluator(l LedgerForEvaluator, hdr bookkeeping.BlockHeader, evalOpts return eval, nil } -const ( +var ( // these would become ConsensusParameters if we ever wanted to change them // incentiveMinBalance is the minimum balance an account must have to be @@ -812,7 +812,7 @@ const ( // that assurance, it is difficult to model their behaviour - might many // participants join for the hope of easy financial rewards, but without // caring enough to run a high-quality node? - incentiveMinBalance = 100_000 * 1_000_000 // 100K algos + incentiveMinBalance = basics.Algos(100_000) // incentiveMaxBalance is the maximum balance an account might have to be // eligible for incentives. It encourages large accounts to split their @@ -820,7 +820,7 @@ const ( // nothing in protocol can prevent such accounts from running nodes that // share fate (same machine, same data center, etc), but this serves as a // gentle reminder. - incentiveMaxBalance = 100_000_000 * 1_000_000 // 100M algos + incentiveMaxBalance = basics.Algos(100_000_000) ) func (eval *BlockEvaluator) eligibleForIncentives(proposer basics.Address) bool { @@ -828,15 +828,13 @@ func (eval *BlockEvaluator) eligibleForIncentives(proposer basics.Address) bool if err != nil { return false } - if proposerState.MicroAlgos.Raw < incentiveMinBalance { + if proposerState.MicroAlgos.LessThan(incentiveMinBalance) { return false } - if proposerState.MicroAlgos.Raw > incentiveMaxBalance { + if proposerState.MicroAlgos.GreaterThan(incentiveMaxBalance) { return false } - // We'll also need a flag on the account, set to true if the account - // properly key-regged for incentives by including the "entry fee". - return true + return proposerState.IncentiveEligible } // hotfix for testnet stall 08/26/2019; move some algos from testnet bank to rewards pool to give it enough time until protocol upgrade occur. diff --git a/ledger/eval_simple_test.go b/ledger/eval_simple_test.go index 7ba3c2d159..7744dcecfe 100644 --- a/ledger/eval_simple_test.go +++ b/ledger/eval_simple_test.go @@ -29,6 +29,7 @@ import ( "github.com/algorand/go-algorand/agreement" "github.com/algorand/go-algorand/config" "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/crypto/merklesignature" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/bookkeeping" "github.com/algorand/go-algorand/data/transactions" @@ -229,14 +230,31 @@ func TestMiningFees(t *testing.T) { smallest := basics.Address{0x01, 0x033} biggest := basics.Address{0x01, 0x044} - dl.txns(&txntest.Txn{Type: "pay", Sender: addrs[1], Receiver: tooBig, Amount: 100_000_000*1_000_000 + 1}, - &txntest.Txn{Type: "pay", Sender: addrs[1], Receiver: tooSmall, Amount: 100_000*1_000_000 - 1}, - &txntest.Txn{Type: "pay", Sender: addrs[1], Receiver: smallest, Amount: 100_000 * 1_000_000}, - &txntest.Txn{Type: "pay", Sender: addrs[1], Receiver: biggest, Amount: 100_000_000 * 1_000_000}, + const eFee = 3_000_000 + dl.txns( + &txntest.Txn{Type: "pay", Sender: addrs[1], + Receiver: tooBig, Amount: eFee + 100_000_000*1_000_000 + 1}, + &txntest.Txn{Type: "pay", Sender: addrs[1], + Receiver: tooSmall, Amount: eFee + 100_000*1_000_000 - 1}, + &txntest.Txn{Type: "pay", Sender: addrs[1], + Receiver: smallest, Amount: eFee + 100_000*1_000_000}, + &txntest.Txn{Type: "pay", Sender: addrs[1], + Receiver: biggest, Amount: eFee + 100_000_000*1_000_000}, ) for _, proposer := range []basics.Address{tooBig, tooSmall, smallest, biggest} { t.Log(proposer) + + dl.txn(&txntest.Txn{ + Type: "keyreg", + Sender: proposer, + Fee: eFee, + VotePK: crypto.OneTimeSignatureVerifier{0x01}, + SelectionPK: crypto.VRFVerifier{0x02}, + StateProofPK: merklesignature.Commitment{0x03}, + VoteFirst: 1, VoteLast: 1000, + }) + dl.fullBlock() // start with an empty block, so no mining fees are paid at start of next one presink := micros(dl.t, dl.generator, genBalances.FeeSink) @@ -293,6 +311,68 @@ func TestMiningFees(t *testing.T) { }) } +// TestIncentiveEligible checks that keyreg with extra fee turns on the incentive eligible flag +func TestIncentiveEligible(t *testing.T) { + partitiontest.PartitionTest(t) + t.Parallel() + + genBalances, addrs, _ := ledgertesting.NewTestGenesis() + // Incentive-eligible appears in v39. Start checking in v38 to test that is unchanged. + ledgertesting.TestConsensusRange(t, 38, 0, func(t *testing.T, ver int, cv protocol.ConsensusVersion, cfg config.Local) { + dl := NewDoubleLedger(t, genBalances, cv, cfg) + defer dl.Close() + + tooSmall := basics.Address{0x01, 0x011} + smallest := basics.Address{0x01, 0x022} + + // They begin ineligible + for _, addr := range []basics.Address{tooSmall, smallest} { + acct, _, _, err := dl.generator.LookupLatest(addr) + require.NoError(t, err) + require.False(t, acct.IncentiveEligible) + } + + // Fund everyone + dl.txns(&txntest.Txn{Type: "pay", Sender: addrs[1], Receiver: tooSmall, Amount: 10_000_000}, + &txntest.Txn{Type: "pay", Sender: addrs[1], Receiver: smallest, Amount: 10_000_000}, + ) + + // Keyreg (but offline) with various fees. No effect on incentive eligible + dl.txns(&txntest.Txn{Type: "keyreg", Sender: tooSmall, Fee: 2_000_000 - 1}, + &txntest.Txn{Type: "keyreg", Sender: smallest, Fee: 2_000_000}, + ) + + for _, addr := range []basics.Address{tooSmall, smallest} { + acct, _, _, err := dl.generator.LookupLatest(addr) + require.NoError(t, err) + require.False(t, acct.IncentiveEligible) + } + + // Keyreg to get online with various fees. Sufficient fee gets `smallest` eligible + keyreg := txntest.Txn{ + Type: "keyreg", + VotePK: crypto.OneTimeSignatureVerifier{0x01}, + SelectionPK: crypto.VRFVerifier{0x02}, + StateProofPK: merklesignature.Commitment{0x03}, + VoteFirst: 1, VoteLast: 1000, + } + tooSmallKR := keyreg + tooSmallKR.Sender = tooSmall + tooSmallKR.Fee = 2_000_000 - 1 + + smallKR := keyreg + smallKR.Sender = smallest + smallKR.Fee = 2_000_000 + dl.txns(&tooSmallKR, &smallKR) + a, _, _, err := dl.generator.LookupLatest(tooSmall) + require.NoError(t, err) + require.False(t, a.IncentiveEligible) + a, _, _, err = dl.generator.LookupLatest(smallest) + require.NoError(t, err) + require.Equal(t, a.IncentiveEligible, ver > 38) + }) +} + // TestHoldingGet tests some of the corner cases for the asset_holding_get // opcode: the asset doesn't exist, the account doesn't exist, account not opted // in, vs it has none of the asset. This is tested here, even though it should diff --git a/ledger/testing/randomAccounts.go b/ledger/testing/randomAccounts.go index 1d14a630ea..438e379a7a 100644 --- a/ledger/testing/randomAccounts.go +++ b/ledger/testing/randomAccounts.go @@ -68,17 +68,17 @@ func RandomAccountData(rewardsBase uint64) basics.AccountData { switch crypto.RandUint64() % 3 { case 0: data.Status = basics.Online + data.VoteID = crypto.OneTimeSignatureVerifier{0x01} + data.IncentiveEligible = crypto.RandUint64()%5 == 0 + data.VoteFirstValid = 1 data.VoteLastValid = 10000 case 1: data.Status = basics.Offline - data.VoteLastValid = 0 default: data.Status = basics.NotParticipating } - data.VoteFirstValid = 0 data.RewardsBase = rewardsBase - data.IncentiveEligible = crypto.RandUint64()%5 == 0 return data } diff --git a/test/scripts/e2e_subs/eligible.py b/test/scripts/e2e_subs/eligible.py new file mode 100755 index 0000000000..ddac9a3515 --- /dev/null +++ b/test/scripts/e2e_subs/eligible.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python + +import base64 +import os +import sys +from goal import Goal +import algosdk.encoding as enc + +from datetime import datetime + +stamp = datetime.now().strftime("%Y%m%d_%H%M%S") +print(f"{os.path.basename(sys.argv[0])} start {stamp}") + +goal = Goal(sys.argv[1], autosend=True) + +joe = goal.new_account() + +txinfo, err = goal.pay(goal.account, joe, amt=10_000_000) +assert not err, err + +# Joe is a brand new account, it is not incentive eligible +joe_info = goal.algod.account_info(joe) +assert "incentive-eligible" not in joe_info, joe_info + +# Go online, but without paying enough to be incentive eligible +txinfo, err = goal.keyreg(joe, votekey=base64.b64encode(b'1'*32), + selkey=base64.b64encode(b'1'*32), + sprfkey=base64.b64encode(b'1'*64), + votekd=1, + votefst=1, votelst=2000) +assert not err, err + +# No extra fee paid, so not eligible +joe_info = goal.algod.account_info(joe) +assert "incentive-eligible" not in joe_info, joe_info + +# Pay the extra fee to become eligible +txinfo, err = goal.keyreg(joe, fee=3_000_000, + votekey=base64.b64encode(b'1'*32), + selkey=base64.b64encode(b'1'*32), + sprfkey=base64.b64encode(b'1'*64), + votekd=2, + votefst=1, votelst=2000) +assert not err, err +joe_info = goal.algod.account_info(joe) +assert joe_info.get("incentive-eligible", None) == True, joe_info + + + +stamp = datetime.now().strftime("%Y%m%d_%H%M%S") +print(f"{os.path.basename(sys.argv[0])} OK {stamp}") diff --git a/test/scripts/e2e_subs/goal/goal.py b/test/scripts/e2e_subs/goal/goal.py index 605f732eb7..62cd5788c8 100755 --- a/test/scripts/e2e_subs/goal/goal.py +++ b/test/scripts/e2e_subs/goal/goal.py @@ -227,21 +227,21 @@ def finish(self, tx, send): return tx def keyreg(self, sender, votekey=None, selkey=None, votefst=None, - votelst=None, votekd=None, + votelst=None, votekd=None, sprfkey=None, send=None, **kwargs): - params = self.params(kwargs.pop("lifetime", 1000)) + params = self.params(kwargs.pop("lifetime", 1000), kwargs.pop("fee", None)) tx = txn.KeyregTxn(sender, params, - votekey, selkey, votefst, votelst, votekd, + votekey, selkey, votefst, votelst, votekd, sprfkey=sprfkey, **kwargs) return self.finish(tx, send) def pay(self, sender, receiver, amt: int, send=None, **kwargs): - params = self.params(kwargs.pop("lifetime", 1000)) + params = self.params(kwargs.pop("lifetime", 1000), kwargs.pop("fee", None)) tx = txn.PaymentTxn(sender, params, receiver, amt, **kwargs) return self.finish(tx, send) def acfg(self, sender, send=None, **kwargs): - params = self.params(kwargs.pop("lifetime", 1000)) + params = self.params(kwargs.pop("lifetime", 1000), kwargs.pop("fee", None)) tx = txn.AssetConfigTxn( sender, params, **kwargs, strict_empty_address_check=False ) @@ -252,7 +252,7 @@ def asset_create(self, sender, **kwargs): return self.acfg(sender, **kwargs) def axfer(self, sender, receiver, amt: int, index: int, send=None, **kwargs): - params = self.params(kwargs.pop("lifetime", 1000)) + params = self.params(kwargs.pop("lifetime", 1000), kwargs.pop("fee", None)) tx = txn.AssetTransferTxn( sender, params, receiver, amt, index, **kwargs ) @@ -263,7 +263,7 @@ def asset_optin(self, sender, index: int, **kwargs): return self.axfer(sender, sender, 0, index, **kwargs) def afrz(self, sender, index: int, target, frozen, send=None, **kwargs): - params = self.params(kwargs.pop("lifetime", 1000)) + params = self.params(kwargs.pop("lifetime", 1000), kwargs.pop("fee", None)) tx = txn.AssetFreezeTxn(sender, params, index, target, frozen, **kwargs) return self.finish(tx, send) @@ -275,14 +275,17 @@ def coerce_schema(self, values): return txn.StateSchema(num_uints=values[0], num_byte_slices=values[1]) - def params(self, lifetime): + def params(self, lifetime, fee): params = self.algod.suggested_params() params.last = params.first + lifetime + if fee: + params.flat_fee = True + params.fee = fee return params def appl(self, sender, index: int, on_complete=txn.OnComplete.NoOpOC, send=None, **kwargs): - params = self.params(kwargs.pop("lifetime", 1000)) + params = self.params(kwargs.pop("lifetime", 1000), kwargs.pop("fee", None)) local_schema = self.coerce_schema(kwargs.pop("local_schema", None)) global_schema = self.coerce_schema(kwargs.pop("global_schema", None)) tx = txn.ApplicationCallTxn( diff --git a/test/scripts/e2e_subs/mining.py b/test/scripts/e2e_subs/mining.py index 04f10e4b7f..85273844db 100755 --- a/test/scripts/e2e_subs/mining.py +++ b/test/scripts/e2e_subs/mining.py @@ -82,4 +82,3 @@ stamp = datetime.now().strftime("%Y%m%d_%H%M%S") print(f"{os.path.basename(sys.argv[0])} OK {stamp}") -