From 201dc78b43274f04dfdc282c3228a859f9bfa0bb Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 1 Jun 2026 11:17:06 +0800 Subject: [PATCH 1/4] implement inflation --- action/protocol/rewarding/inflation.go | 131 +++++ action/protocol/rewarding/inflation_state.go | 414 +++++++++++++ action/protocol/rewarding/inflation_test.go | 510 ++++++++++++++++ action/protocol/rewarding/protocol.go | 33 +- action/protocol/rewarding/reward.go | 85 ++- action/protocol/rewarding/reward_test.go | 15 +- .../rewarding/rewardingpb/rewarding.pb.go | 554 +++++++++--------- .../rewarding/rewardingpb/rewarding.proto | 31 + blockchain/genesis/genesis.go | 73 ++- go.mod | 2 +- go.sum | 2 + 11 files changed, 1542 insertions(+), 308 deletions(-) create mode 100644 action/protocol/rewarding/inflation.go create mode 100644 action/protocol/rewarding/inflation_state.go create mode 100644 action/protocol/rewarding/inflation_test.go diff --git a/action/protocol/rewarding/inflation.go b/action/protocol/rewarding/inflation.go new file mode 100644 index 0000000000..bb1041bf62 --- /dev/null +++ b/action/protocol/rewarding/inflation.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package rewarding + +import "math/big" + +// Pure inflation math for IIP-62 "Productive Inflation". All functions in this file +// are side-effect-free and operate on math/big.Int — the high-year compounded ratios +// (e.g. 8000^11 ≈ 8.6e42) overflow 64-bit. The state-mutating wrapper lives in +// inflation_state.go. + +const bpsDenom = 10000 + +// YearIndex returns the 1-indexed Year that contains height under the IIP-62 curve. +// Year 1 covers [activation, activation+blocksPerYear); Year 2 covers +// [activation+blocksPerYear, activation+2*blocksPerYear); and so on. Returns 0 for +// heights strictly before activation (i.e. pre-activation; mint should not run). +func YearIndex(activation, blocksPerYear, height uint64) uint64 { + if height < activation || blocksPerYear == 0 { + return 0 + } + return (height-activation)/blocksPerYear + 1 +} + +// IsYearBoundary reports whether height is the first block of a new Year ≥ 2. +// The activation block itself is the start of Year 1 and returns false here — its +// initialization is the activation hook, not a year transition. +func IsYearBoundary(activation, blocksPerYear, height uint64) bool { + if height <= activation || blocksPerYear == 0 { + return false + } + return (height-activation)%blocksPerYear == 0 +} + +// IsYearFinalBlock reports whether height is the last block of the current Year. +// Used to flush yearMintRemainder per IIP-62 §4.1. +func IsYearFinalBlock(activation, blocksPerYear, height uint64) bool { + if height < activation || blocksPerYear == 0 { + return false + } + return (height-activation+1)%blocksPerYear == 0 +} + +// ComputeInflationBps returns the per-year inflation rate in basis points under the +// IIP-62 curve, with round-half-up bps rounding and a permanent lower-bound clamp. +// +// rate(year) = max(floorBps, round_half_up(y1Bps · num^(year-1) / denom^(year-1))) +// +// Year is 1-indexed (year=1 returns y1Bps without exponentiation). +func ComputeInflationBps(year, y1Bps, num, denom, floorBps uint64) uint64 { + if year == 0 { + return 0 + } + if year == 1 { + if y1Bps < floorBps { + return floorBps + } + return y1Bps + } + exp := year - 1 + numPow := new(big.Int).Exp(new(big.Int).SetUint64(num), new(big.Int).SetUint64(exp), nil) + denPow := new(big.Int).Exp(new(big.Int).SetUint64(denom), new(big.Int).SetUint64(exp), nil) + numerator := new(big.Int).Mul(new(big.Int).SetUint64(y1Bps), numPow) + // round half up: (numerator + denominator/2) / denominator + halfDen := new(big.Int).Rsh(denPow, 1) + numerator.Add(numerator, halfDen) + res := new(big.Int).Quo(numerator, denPow).Uint64() + if res < floorBps { + return floorBps + } + return res +} + +// AnnualMint returns supplyAtYearStart · inflationBps / bpsDenom in Rau (integer). +// This is the §1.3 column "Annual Mint" — the exact total minted in the Year. +func AnnualMint(supplyAtYearStart *big.Int, inflationBps uint64) *big.Int { + annual := new(big.Int).Mul(supplyAtYearStart, new(big.Int).SetUint64(inflationBps)) + annual.Quo(annual, big.NewInt(bpsDenom)) + return annual +} + +// PerBlockMint returns the constant per-block mint amount and the year-end remainder +// (in Rau) under the §1.2 rule: per_block = annualMint / blocksPerYear; remainder = +// annualMint − per_block · blocksPerYear. The remainder is flushed on the Year's +// final block so the realized annual mint exactly equals AnnualMint(...). +func PerBlockMint(supplyAtYearStart *big.Int, inflationBps, blocksPerYear uint64) (perBlock, yearEndRemainder *big.Int) { + annual := AnnualMint(supplyAtYearStart, inflationBps) + if blocksPerYear == 0 { + return new(big.Int), new(big.Int).Set(annual) + } + bpy := new(big.Int).SetUint64(blocksPerYear) + perBlock = new(big.Int).Quo(annual, bpy) + consumed := new(big.Int).Mul(perBlock, bpy) + yearEndRemainder = new(big.Int).Sub(annual, consumed) + return perBlock, yearEndRemainder +} + +// SplitMint distributes mTotal between the staker pool and the Machina DAO using +// basis-point shares, carrying sub-bpsDenom dust between blocks so that over time +// the realized split is exact. dustStakerIn / dustMachinaIn are the per-share dust +// accumulators from the previous block (in "Rau·bps" units); the returned dust +// values must be persisted for the next call. +// +// stakerBps + machinaBps must equal bpsDenom; the caller is expected to enforce this +// at genesis-validation time so the assertion does not run hot per block. +func SplitMint( + mTotal *big.Int, + stakerBps, machinaBps uint64, + dustStakerIn, dustMachinaIn *big.Int, +) (mStaker, mMachina, dustStakerOut, dustMachinaOut *big.Int) { + bpsDenomBig := big.NewInt(bpsDenom) + + stakerNum := new(big.Int).Mul(mTotal, new(big.Int).SetUint64(stakerBps)) + if dustStakerIn != nil { + stakerNum.Add(stakerNum, dustStakerIn) + } + mStaker = new(big.Int).Quo(stakerNum, bpsDenomBig) + dustStakerOut = new(big.Int).Mod(stakerNum, bpsDenomBig) + + machinaNum := new(big.Int).Mul(mTotal, new(big.Int).SetUint64(machinaBps)) + if dustMachinaIn != nil { + machinaNum.Add(machinaNum, dustMachinaIn) + } + mMachina = new(big.Int).Quo(machinaNum, bpsDenomBig) + dustMachinaOut = new(big.Int).Mod(machinaNum, bpsDenomBig) + + return mStaker, mMachina, dustStakerOut, dustMachinaOut +} diff --git a/action/protocol/rewarding/inflation_state.go b/action/protocol/rewarding/inflation_state.go new file mode 100644 index 0000000000..1c278feefc --- /dev/null +++ b/action/protocol/rewarding/inflation_state.go @@ -0,0 +1,414 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package rewarding + +import ( + "context" + "math/big" + + "github.com/pkg/errors" + "google.golang.org/protobuf/proto" + + "github.com/iotexproject/iotex-address/address" + "github.com/iotexproject/iotex-proto/golang/iotextypes" + + "github.com/iotexproject/iotex-core/v2/action" + "github.com/iotexproject/iotex-core/v2/action/protocol" + accountutil "github.com/iotexproject/iotex-core/v2/action/protocol/account/util" + "github.com/iotexproject/iotex-core/v2/action/protocol/rewarding/rewardingpb" + "github.com/iotexproject/iotex-core/v2/blockchain/genesis" + "github.com/iotexproject/iotex-core/v2/state" + "github.com/iotexproject/iotex-core/v2/systemcontracts" +) + +// _inflKey is the rewarding-namespace key for the IIP-62 InflationState. Mirrors +// the style of the existing single-record keys in protocol.go. +var _inflKey = []byte("inf") + +// inflationState is the in-memory mirror of rewardingpb.InflationState. Mirrors +// the fund / admin / rewardAccount serialization pattern in this package. +type inflationState struct { + outstandingSupply *big.Int + outstandingSupplyAtYearStart *big.Int + postActivationMinted *big.Int + currentInflationBps uint64 + currentYearIndex uint64 + dustStaker *big.Int + dustMachina *big.Int + yearMintRemainder *big.Int + epochRemainderAccumulator *big.Int +} + +func newInflationState() *inflationState { + return &inflationState{ + outstandingSupply: new(big.Int), + outstandingSupplyAtYearStart: new(big.Int), + postActivationMinted: new(big.Int), + dustStaker: new(big.Int), + dustMachina: new(big.Int), + yearMintRemainder: new(big.Int), + epochRemainderAccumulator: new(big.Int), + } +} + +// Serialize encodes the inflation state into bytes. +func (s inflationState) Serialize() ([]byte, error) { + return proto.Marshal(s.toProto()) +} + +// Deserialize decodes bytes into the inflation state. +func (s *inflationState) Deserialize(data []byte) error { + gen := rewardingpb.InflationState{} + if err := proto.Unmarshal(data, &gen); err != nil { + return err + } + return s.fromProto(&gen) +} + +// Encode satisfies the systemcontracts.GenericValueContainer interface so the +// state can ride the erigon storage path used by Fund / Admin. +func (s *inflationState) Encode() (systemcontracts.GenericValue, error) { + d, err := proto.Marshal(s.toProto()) + if err != nil { + return systemcontracts.GenericValue{}, err + } + return systemcontracts.GenericValue{PrimaryData: d}, nil +} + +// Decode is the inverse of Encode. +func (s *inflationState) Decode(v systemcontracts.GenericValue) error { + gen := rewardingpb.InflationState{} + if err := proto.Unmarshal(v.PrimaryData, &gen); err != nil { + return err + } + return s.fromProto(&gen) +} + +func (s *inflationState) toProto() *rewardingpb.InflationState { + return &rewardingpb.InflationState{ + OutstandingSupply: bigToStr(s.outstandingSupply), + OutstandingSupplyAtYearStart: bigToStr(s.outstandingSupplyAtYearStart), + PostActivationMinted: bigToStr(s.postActivationMinted), + CurrentInflationBps: s.currentInflationBps, + CurrentYearIndex: s.currentYearIndex, + DustStaker: bigToStr(s.dustStaker), + DustMachina: bigToStr(s.dustMachina), + YearMintRemainder: bigToStr(s.yearMintRemainder), + EpochRemainderAccumulator: bigToStr(s.epochRemainderAccumulator), + } +} + +func (s *inflationState) fromProto(gen *rewardingpb.InflationState) error { + var err error + if s.outstandingSupply, err = strToBig(gen.OutstandingSupply, "outstandingSupply"); err != nil { + return err + } + if s.outstandingSupplyAtYearStart, err = strToBig(gen.OutstandingSupplyAtYearStart, "outstandingSupplyAtYearStart"); err != nil { + return err + } + if s.postActivationMinted, err = strToBig(gen.PostActivationMinted, "postActivationMinted"); err != nil { + return err + } + if s.dustStaker, err = strToBig(gen.DustStaker, "dustStaker"); err != nil { + return err + } + if s.dustMachina, err = strToBig(gen.DustMachina, "dustMachina"); err != nil { + return err + } + if s.yearMintRemainder, err = strToBig(gen.YearMintRemainder, "yearMintRemainder"); err != nil { + return err + } + if s.epochRemainderAccumulator, err = strToBig(gen.EpochRemainderAccumulator, "epochRemainderAccumulator"); err != nil { + return err + } + s.currentInflationBps = gen.CurrentInflationBps + s.currentYearIndex = gen.CurrentYearIndex + return nil +} + +// initInflationState seeds the IIP-62 InflationState at activation height. Called +// from CreatePreStates exactly once. Validates genesis inflation params and the +// Machina DAO address; panics on misconfiguration since this is a deployment-time +// invariant, not a per-block condition. +func (p *Protocol) initInflationState(ctx context.Context, sm protocol.StateManager) error { + g := genesis.MustExtractGenesisContext(ctx) + cfg := g.Rewarding + + if err := validateInflationConfig(&cfg); err != nil { + return errors.Wrap(err, "invalid IIP-62 inflation configuration") + } + + supply := cfg.OutstandingSupplyAtActivationBig() + if supply.Sign() <= 0 { + return errors.Errorf( + "OutstandingSupplyAtActivation must be positive, got %s", supply.String()) + } + + s := newInflationState() + s.outstandingSupply.Set(supply) + s.outstandingSupplyAtYearStart.Set(supply) + s.currentInflationBps = cfg.InflationRateY1Bps + s.currentYearIndex = 1 + + return p.putState(ctx, sm, _inflKey, s) +} + +// validateInflationConfig enforces IIP-62 shape constraints on the Rewarding +// genesis fields. Run at activation time; misconfiguration here is unrecoverable. +func validateInflationConfig(cfg *genesis.Rewarding) error { + if cfg.StakerShareBps+cfg.MachinaShareBps != bpsDenom { + return errors.Errorf( + "share splits must sum to %d, got %d+%d", + bpsDenom, cfg.StakerShareBps, cfg.MachinaShareBps) + } + if cfg.InflationFloorBps > cfg.InflationRateY1Bps { + return errors.Errorf( + "InflationFloorBps (%d) must not exceed InflationRateY1Bps (%d)", + cfg.InflationFloorBps, cfg.InflationRateY1Bps) + } + if cfg.InflationDecayDenominator == 0 { + return errors.New("InflationDecayDenominator must be non-zero") + } + if cfg.InflationDecayNumerator > cfg.InflationDecayDenominator { + return errors.Errorf( + "decay must be ≤ 1 (numerator %d > denominator %d)", + cfg.InflationDecayNumerator, cfg.InflationDecayDenominator) + } + if cfg.BlocksPerYear == 0 { + return errors.New("BlocksPerYear must be non-zero") + } + if cfg.MachinaDaoAddress == "" { + return errors.New("MachinaDaoAddress must be set in genesis before activation") + } + if _, err := address.FromString(cfg.MachinaDaoAddress); err != nil { + return errors.Wrapf(err, "MachinaDaoAddress %q does not parse", cfg.MachinaDaoAddress) + } + if cfg.OutstandingSupplyAtActivation == "" { + return errors.New("OutstandingSupplyAtActivation must be set in genesis before activation") + } + return nil +} + +// mintAndAllocate runs the IIP-62 per-block productive-inflation step. Called from +// the top of GrantBlockReward (after the assertNoRewardYet guard). On any block +// before the activation height it is a no-op and returns a zero mStaker so the +// step-F clamp degenerates gracefully. +// +// Pipeline at activation and beyond: +// 1. Load InflationState (must have been seeded by initInflationState). +// 2. If we crossed into a new Year: snapshot OutstandingSupplyAtYearStart from the +// current OutstandingSupply, recompute CurrentInflationBps from the curve, and +// reset YearMintRemainder to (annualMint mod blocksPerYear) for the new Year. +// 3. Compute the constant per-block mint for the current Year. On the Year's final +// block, add YearMintRemainder so the realized annual mint exactly equals the +// §1.3 table. +// 4. Split via SplitMint, carrying sub-bpsDenom dust between blocks. +// 5. Credit the staker share to the Fund (mirrors Deposit() arithmetic but without +// a caller-subtraction — this is protocol mint, not user deposit). +// 6. Credit the Machina share to the externally-managed MachinaDaoAddress account. +// 7. Bump OutstandingSupply / PostActivationMinted; bank the staker-vs-block-reward +// excess into EpochRemainderAccumulator (consumed by step G in GrantEpochReward). +// 8. Persist. +// +// Returns mStaker (this block's staker-share mint) and the per-block transaction +// logs attributing the staker / Machina credits. The staker log uses Sender="" to +// signal "protocol mint, no source" (mirroring the convention that Recipient="" +// indicates burn). Caller should treat a zero mStaker / nil logs as "no productive +// inflation in effect". +// +// Log types: INFLATION_MINT_STAKER / INFLATION_MINT_MACHINA, added to iotex-proto +// alongside IIP-62. Sender="" signals "protocol mint, no source account". +func (p *Protocol) mintAndAllocate(ctx context.Context, sm protocol.StateManager) (*big.Int, []*action.TransactionLog, error) { + g := genesis.MustExtractGenesisContext(ctx) + blkCtx := protocol.MustGetBlockCtx(ctx) + if !g.IsToBeEnabled(blkCtx.BlockHeight) { + return new(big.Int), nil, nil + } + cfg := g.Rewarding + activation := g.ToBeEnabledBlockHeight + + s := newInflationState() + if _, err := p.state(ctx, sm, _inflKey, s); err != nil { + return nil, nil, errors.Wrap(err, "inflation state not seeded; initInflationState must run at activation") + } + + year := YearIndex(activation, cfg.BlocksPerYear, blkCtx.BlockHeight) + if year == 0 { + return new(big.Int), nil, nil + } + // Year boundary crossing: refresh snapshot and curve rate. This branch also fires + // after a reorg that crosses the boundary because CurrentYearIndex is persisted. + if year != s.currentYearIndex { + s.outstandingSupplyAtYearStart.Set(s.outstandingSupply) + s.currentInflationBps = ComputeInflationBps( + year, + cfg.InflationRateY1Bps, + cfg.InflationDecayNumerator, + cfg.InflationDecayDenominator, + cfg.InflationFloorBps, + ) + s.currentYearIndex = year + // Pre-stage the year-end remainder so the year's final block can flush it. + _, rem := PerBlockMint(s.outstandingSupplyAtYearStart, s.currentInflationBps, cfg.BlocksPerYear) + s.yearMintRemainder.Set(rem) + } + + perBlock, _ := PerBlockMint(s.outstandingSupplyAtYearStart, s.currentInflationBps, cfg.BlocksPerYear) + mTotal := new(big.Int).Set(perBlock) + // Flush yearMintRemainder on the Year's final block so realized annual mint is exact. + if IsYearFinalBlock(activation, cfg.BlocksPerYear, blkCtx.BlockHeight) { + mTotal.Add(mTotal, s.yearMintRemainder) + s.yearMintRemainder.SetUint64(0) + } + if mTotal.Sign() == 0 { + return new(big.Int), nil, nil + } + + mStaker, mMachina, dStaker, dMachina := SplitMint( + mTotal, + cfg.StakerShareBps, cfg.MachinaShareBps, + s.dustStaker, s.dustMachina, + ) + s.dustStaker.Set(dStaker) + s.dustMachina.Set(dMachina) + + var tLogs []*action.TransactionLog + + // Credit staker share: mirror Fund.Deposit() arithmetic without caller subtraction. + // This is protocol mint, not a user deposit. + f := fund{} + if _, err := p.state(ctx, sm, _fundKey, &f); err != nil { + return nil, nil, errors.Wrap(err, "failed to load rewarding fund") + } + if mStaker.Sign() > 0 { + f.totalBalance = new(big.Int).Add(f.totalBalance, mStaker) + f.unclaimedBalance = new(big.Int).Add(f.unclaimedBalance, mStaker) + if err := p.putState(ctx, sm, _fundKey, &f); err != nil { + return nil, nil, errors.Wrap(err, "failed to credit staker share to fund") + } + tLogs = append(tLogs, &action.TransactionLog{ + Type: iotextypes.TransactionLogType_INFLATION_MINT_STAKER, + Sender: "", // empty Sender = protocol mint, no source account + Recipient: address.RewardingPoolAddr, + Amount: new(big.Int).Set(mStaker), + }) + } + + // Credit Machina share to the externally-managed recipient account. LoadOrCreate + // only allocates a state record on first credit; the multisig owning this + // address is created and governed out of band. + if mMachina.Sign() > 0 { + machinaAddr := cfg.MachinaDaoAddr() + accountCreationOpts := []state.AccountCreationOption{} + if protocol.MustGetFeatureCtx(ctx).CreateLegacyNonceAccount { + accountCreationOpts = append(accountCreationOpts, state.LegacyNonceAccountTypeOption()) + } + acc, err := accountutil.LoadOrCreateAccount(sm, machinaAddr, accountCreationOpts...) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to load Machina DAO account") + } + if err := acc.AddBalance(mMachina); err != nil { + return nil, nil, errors.Wrap(err, "failed to add Machina DAO balance") + } + if err := accountutil.StoreAccount(sm, machinaAddr, acc); err != nil { + return nil, nil, errors.Wrap(err, "failed to store Machina DAO account") + } + tLogs = append(tLogs, &action.TransactionLog{ + Type: iotextypes.TransactionLogType_INFLATION_MINT_MACHINA, + Sender: "", // empty Sender = protocol mint, no source account + Recipient: machinaAddr.String(), + Amount: new(big.Int).Set(mMachina), + }) + } + + s.outstandingSupply.Add(s.outstandingSupply, mTotal) + s.postActivationMinted.Add(s.postActivationMinted, mTotal) + // Bank the staker-share-minus-block-reward excess into the epoch accumulator. + // effective_block_reward = min(a.blockReward, mStaker); excess = mStaker − that. + // Must read a.blockReward (admin state) — the SAME source calculateTotalRewardAndTip + // uses for the actual grant — so the epoch accumulator stays exactly consistent + // with what GrantBlockReward pays out. Post-Wake, a.blockReward == WakeBlockReward. + a := admin{} + if _, err := p.state(ctx, sm, _adminKey, &a); err != nil { + return nil, nil, errors.Wrap(err, "failed to load rewarding admin for block-reward clamp") + } + effectiveBlock := new(big.Int).Set(a.blockReward) + if mStaker.Cmp(effectiveBlock) < 0 { + effectiveBlock.Set(mStaker) + } + excess := new(big.Int).Sub(mStaker, effectiveBlock) + if excess.Sign() > 0 { + s.epochRemainderAccumulator.Add(s.epochRemainderAccumulator, excess) + } + + if err := p.putState(ctx, sm, _inflKey, s); err != nil { + return nil, nil, errors.Wrap(err, "failed to persist inflation state") + } + return mStaker, tLogs, nil +} + +// OutstandingSupply returns the current outstanding native-token supply tracked +// by the IIP-62 inflation state. Returns state.ErrStateNotExist before activation. +func (p *Protocol) OutstandingSupply(ctx context.Context, sm protocol.StateReader) (*big.Int, uint64, error) { + s := newInflationState() + height, err := p.state(ctx, sm, _inflKey, s) + if err != nil { + return nil, height, err + } + return s.outstandingSupply, height, nil +} + +// PostActivationMinted returns the cumulative amount minted by productive +// inflation since activation. Returns state.ErrStateNotExist before activation. +func (p *Protocol) PostActivationMinted(ctx context.Context, sm protocol.StateReader) (*big.Int, uint64, error) { + s := newInflationState() + height, err := p.state(ctx, sm, _inflKey, s) + if err != nil { + return nil, height, err + } + return s.postActivationMinted, height, nil +} + +// CurrentInflationBps returns the inflation rate (in basis points of outstanding +// supply per year) currently in effect. Returns state.ErrStateNotExist before +// activation. +func (p *Protocol) CurrentInflationBps(ctx context.Context, sm protocol.StateReader) (uint64, uint64, error) { + s := newInflationState() + height, err := p.state(ctx, sm, _inflKey, s) + if err != nil { + return 0, height, err + } + return s.currentInflationBps, height, nil +} + +// decrementOutstandingSupply subtracts amount from outstandingSupply. Reserved for a +// future burn-side IIP (e.g. wiring EIP-1559 base-fee burns into the counter) — not +// used by IIP-62 itself. Kept as a helper so external callers don't reach into the +// struct. +func (s *inflationState) decrementOutstandingSupply(amount *big.Int) { + if amount == nil || amount.Sign() <= 0 { + return + } + s.outstandingSupply.Sub(s.outstandingSupply, amount) +} + +func bigToStr(v *big.Int) string { + if v == nil { + return "0" + } + return v.String() +} + +func strToBig(s, field string) (*big.Int, error) { + if s == "" { + return new(big.Int), nil + } + v, ok := new(big.Int).SetString(s, 10) + if !ok { + return nil, errors.Errorf("failed to parse %s as big int: %q", field, s) + } + return v, nil +} diff --git a/action/protocol/rewarding/inflation_test.go b/action/protocol/rewarding/inflation_test.go new file mode 100644 index 0000000000..1b5e9db3ec --- /dev/null +++ b/action/protocol/rewarding/inflation_test.go @@ -0,0 +1,510 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package rewarding + +import ( + "context" + "math/big" + "testing" + + "github.com/agiledragon/gomonkey/v2" + "github.com/iotexproject/iotex-address/address" + "github.com/iotexproject/iotex-proto/golang/iotextypes" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/iotexproject/iotex-core/v2/action/protocol" + accountutil "github.com/iotexproject/iotex-core/v2/action/protocol/account/util" + "github.com/iotexproject/iotex-core/v2/action/protocol/rewarding/rewardingpb" + "github.com/iotexproject/iotex-core/v2/action/protocol/staking" + "github.com/iotexproject/iotex-core/v2/blockchain/genesis" + "github.com/iotexproject/iotex-core/v2/test/identityset" +) + +// IIP-62 §1.1 / §1.3 reference values. The spec only pins a handful of years; the +// rest are derived from the formula and asserted exactly so future refactors of the +// big.Int math cannot drift. +func TestComputeInflationBps_SpecValues(t *testing.T) { + const ( + y1Bps uint64 = 500 + num uint64 = 8000 + den uint64 = 10000 + floorBps uint64 = 50 + ) + cases := []struct { + year uint64 + want uint64 + }{ + {1, 500}, // §1.3: Y1 5.00% + {2, 400}, // 500 * 0.8 = 400 + {3, 320}, // 500 * 0.64 = 320 + {4, 256}, // 500 * 0.512 = 256 + {5, 205}, // §1.1: 204.80 → 205 (round-half-up) + {6, 164}, // 500 * 0.32768 = 163.84 → 164 + {7, 131}, // 130.97152 → 131 (round-half-up of .97) + {8, 105}, // 104.78 → 105 + {9, 84}, // 83.886 → 84 + {10, 67}, // §1.1: 67.11 → 67 + {11, 54}, // §1.1: 53.69 → 54 + {12, 50}, // §1.1: 42.95 → floor clamps to 50 + {13, 50}, // formula keeps going below 50; floor holds + {17, 50}, // floor still holds + {50, 50}, // arbitrary far-future year — still at floor + {100, 50}, // ensure no overflow at large exponents + } + for _, c := range cases { + got := ComputeInflationBps(c.year, y1Bps, num, den, floorBps) + require.Equalf(t, c.want, got, "year %d: want %d, got %d", c.year, c.want, got) + } +} + +func TestComputeInflationBps_EdgeCases(t *testing.T) { + // Year 0 is a sentinel for "pre-activation"; result must be 0. + require.Equal(t, uint64(0), ComputeInflationBps(0, 500, 8000, 10000, 50)) + // Y1Bps below floor is clamped up. + require.Equal(t, uint64(50), ComputeInflationBps(1, 10, 8000, 10000, 50)) +} + +func TestYearIndex(t *testing.T) { + const activation, bpy uint64 = 1000, 100 + require.Equal(t, uint64(0), YearIndex(activation, bpy, 999)) // pre-activation + require.Equal(t, uint64(1), YearIndex(activation, bpy, 1000)) // activation block = Y1 start + require.Equal(t, uint64(1), YearIndex(activation, bpy, 1099)) // last block of Y1 + require.Equal(t, uint64(2), YearIndex(activation, bpy, 1100)) // first block of Y2 + require.Equal(t, uint64(12), YearIndex(activation, bpy, 2199)) // mid Y12 + require.Equal(t, uint64(0), YearIndex(activation, 0, 9999)) // blocksPerYear=0 is degenerate +} + +func TestIsYearBoundary(t *testing.T) { + const activation, bpy uint64 = 1000, 100 + require.False(t, IsYearBoundary(activation, bpy, 1000)) // activation is Y1 start, not a transition + require.False(t, IsYearBoundary(activation, bpy, 1050)) + require.True(t, IsYearBoundary(activation, bpy, 1100)) + require.True(t, IsYearBoundary(activation, bpy, 1200)) + require.False(t, IsYearBoundary(activation, bpy, 1101)) +} + +func TestIsYearFinalBlock(t *testing.T) { + const activation, bpy uint64 = 1000, 100 + require.False(t, IsYearFinalBlock(activation, bpy, 1000)) + require.True(t, IsYearFinalBlock(activation, bpy, 1099)) // last block of Y1 + require.False(t, IsYearFinalBlock(activation, bpy, 1100)) + require.True(t, IsYearFinalBlock(activation, bpy, 1199)) // last block of Y2 +} + +// 20-year IIP §1.3 reproduction, with annual mint and end-of-year supply checked +// against the table. The table's headline numbers are independently rounded (the +// spec calls that out), so we assert against the §1.2 formula output exactly and +// only sanity-check the §1.3 rounded values to within ±1M IOTX. +func TestIIP62_SpecTable_20Year(t *testing.T) { + const ( + y1Bps uint64 = 500 + num uint64 = 8000 + den uint64 = 10000 + floorBps uint64 = 50 + ) + // 9.44B IOTX baseline (§1.3 assumption). + supply := new(big.Int).Mul(big.NewInt(9_440_000_000), iotxRau()) + + // (year, inflationBps, approxAnnualMintMIOTX) — last column is the §1.3 rounded + // annual mint in millions of IOTX, used as a tolerance check only. + cases := []struct { + year uint64 + wantBps uint64 + approxAnnualMintMIO int64 + }{ + {1, 500, 472}, + {2, 400, 396}, + {3, 320, 330}, + {4, 256, 272}, + {5, 205, 224}, + {6, 164, 183}, + {7, 131, 148}, + {8, 105, 120}, + {9, 84, 97}, + {10, 67, 78}, + {11, 54, 64}, + {12, 50, 59}, + {13, 50, 59}, + {14, 50, 60}, + {15, 50, 60}, + {16, 50, 60}, + {17, 50, 61}, + {18, 50, 61}, + {19, 50, 61}, + {20, 50, 62}, + } + cumulative := new(big.Int) + for _, c := range cases { + bps := ComputeInflationBps(c.year, y1Bps, num, den, floorBps) + require.Equalf(t, c.wantBps, bps, "year %d bps", c.year) + + annual := AnnualMint(supply, bps) + + // §1.3 rounded check: annual mint is within 1M IOTX of the spec's headline. + approxRau := new(big.Int).Mul(big.NewInt(c.approxAnnualMintMIO*1_000_000), iotxRau()) + diff := new(big.Int).Sub(annual, approxRau) + diff.Abs(diff) + oneMIotx := new(big.Int).Mul(big.NewInt(1_000_000), iotxRau()) + require.Truef(t, diff.Cmp(oneMIotx) < 0, + "year %d: annual mint %s diverges from §1.3 by ≥1M IOTX (table approx %s)", + c.year, annual.String(), approxRau.String()) + + cumulative.Add(cumulative, annual) + supply.Add(supply, annual) + } + + // §1.3 cumulative: ~2.93B IOTX over 20 years. + cumulativeMIOTX := new(big.Int).Quo(cumulative, iotxRau()) + require.Truef(t, + cumulativeMIOTX.Cmp(big.NewInt(2_900_000_000)) > 0 && + cumulativeMIOTX.Cmp(big.NewInt(2_950_000_000)) < 0, + "20-year cumulative mint %s IOTX is outside §1.3 expected ~2.93B band", + cumulativeMIOTX.String()) +} + +// PerBlockMint: integer-div consistency — perBlock * blocksPerYear + remainder = annual. +func TestPerBlockMint_RemainderClosesYear(t *testing.T) { + supply := new(big.Int).Mul(big.NewInt(9_440_000_000), iotxRau()) + const ( + bps uint64 = 500 + bpy uint64 = 12_614_400 + ) + perBlock, rem := PerBlockMint(supply, bps, bpy) + annual := AnnualMint(supply, bps) + + got := new(big.Int).Mul(perBlock, new(big.Int).SetUint64(bpy)) + got.Add(got, rem) + require.Equalf(t, 0, annual.Cmp(got), + "perBlock·blocksPerYear + remainder must equal annual mint; annual=%s got=%s", + annual.String(), got.String()) + require.Truef(t, rem.Cmp(new(big.Int).SetUint64(bpy)) < 0, + "year-end remainder %s must be < blocksPerYear %d", rem.String(), bpy) +} + +// SplitMint: dust accumulation closes a 10000-block window exactly. +func TestSplitMint_DustClosesWindow(t *testing.T) { + const ( + stakerBps uint64 = 8000 + machinaBps uint64 = 2000 + nBlocks = 10000 + ) + // Pick an mTotal that is intentionally indivisible by bpsDenom so dust must accumulate. + mTotal := big.NewInt(123_456_789) + + dStaker, dMachina := new(big.Int), new(big.Int) + totalStaker, totalMachina := new(big.Int), new(big.Int) + for i := 0; i < nBlocks; i++ { + var s, m *big.Int + s, m, dStaker, dMachina = SplitMint(mTotal, stakerBps, machinaBps, dStaker, dMachina) + totalStaker.Add(totalStaker, s) + totalMachina.Add(totalMachina, m) + } + // Over an integer-multiple-of-bpsDenom block window the staker share equals + // exactly nBlocks * mTotal * stakerBps / bpsDenom with zero dust remaining. + wantStaker := new(big.Int).Mul(mTotal, big.NewInt(int64(nBlocks)*int64(stakerBps)/bpsDenom)) + wantMachina := new(big.Int).Mul(mTotal, big.NewInt(int64(nBlocks)*int64(machinaBps)/bpsDenom)) + require.Equal(t, 0, totalStaker.Cmp(wantStaker), "staker total mismatch: got %s want %s", totalStaker, wantStaker) + require.Equal(t, 0, totalMachina.Cmp(wantMachina), "machina total mismatch: got %s want %s", totalMachina, wantMachina) + require.Equal(t, 0, dStaker.Sign(), "staker dust should drain to 0 at window close, got %s", dStaker) + require.Equal(t, 0, dMachina.Sign(), "machina dust should drain to 0 at window close, got %s", dMachina) +} + +// Conservation: mStaker + mMachina + (dust_delta / bpsDenom) equals mTotal each block. +// In Rau·bps space: stakerNum + machinaNum = mTotal * bpsDenom (since shares sum to bpsDenom). +func TestSplitMint_PerBlockConservation(t *testing.T) { + mTotal := big.NewInt(1_000_000_007) // a prime, to force nontrivial dust + dStakerIn := big.NewInt(1234) + dMachinaIn := big.NewInt(4321) + mStaker, mMachina, dStakerOut, dMachinaOut := SplitMint( + mTotal, 8000, 2000, dStakerIn, dMachinaIn, + ) + // (mStaker * bpsDenom + dStakerOut) + (mMachina * bpsDenom + dMachinaOut) + // = mTotal * 10000 + dStakerIn + dMachinaIn + lhs := new(big.Int).Add( + new(big.Int).Add(new(big.Int).Mul(mStaker, big.NewInt(bpsDenom)), dStakerOut), + new(big.Int).Add(new(big.Int).Mul(mMachina, big.NewInt(bpsDenom)), dMachinaOut), + ) + rhs := new(big.Int).Add( + new(big.Int).Mul(mTotal, big.NewInt(bpsDenom)), + new(big.Int).Add(dStakerIn, dMachinaIn), + ) + require.Equal(t, 0, lhs.Cmp(rhs), "per-block conservation: lhs=%s rhs=%s", lhs, rhs) +} + +func TestInflationState_RoundTrip(t *testing.T) { + s := newInflationState() + s.outstandingSupply.SetString("9440000000000000000000000000", 10) + s.outstandingSupplyAtYearStart.SetString("9440000000000000000000000000", 10) + s.postActivationMinted.SetString("123456789012345", 10) + s.currentInflationBps = 500 + s.currentYearIndex = 1 + s.dustStaker.SetUint64(7777) + s.dustMachina.SetUint64(3333) + s.yearMintRemainder.SetUint64(99) + s.epochRemainderAccumulator.SetString("999999999999", 10) + + data, err := s.Serialize() + require.NoError(t, err) + + out := newInflationState() + require.NoError(t, out.Deserialize(data)) + + require.Equal(t, 0, s.outstandingSupply.Cmp(out.outstandingSupply)) + require.Equal(t, 0, s.outstandingSupplyAtYearStart.Cmp(out.outstandingSupplyAtYearStart)) + require.Equal(t, 0, s.postActivationMinted.Cmp(out.postActivationMinted)) + require.Equal(t, s.currentInflationBps, out.currentInflationBps) + require.Equal(t, s.currentYearIndex, out.currentYearIndex) + require.Equal(t, 0, s.dustStaker.Cmp(out.dustStaker)) + require.Equal(t, 0, s.dustMachina.Cmp(out.dustMachina)) + require.Equal(t, 0, s.yearMintRemainder.Cmp(out.yearMintRemainder)) + require.Equal(t, 0, s.epochRemainderAccumulator.Cmp(out.epochRemainderAccumulator)) +} + +func TestInflationState_DecrementOutstandingSupply(t *testing.T) { + s := newInflationState() + s.outstandingSupply.SetUint64(1000) + s.decrementOutstandingSupply(big.NewInt(250)) + require.Equal(t, uint64(750), s.outstandingSupply.Uint64()) + // nil and non-positive are no-ops + s.decrementOutstandingSupply(nil) + require.Equal(t, uint64(750), s.outstandingSupply.Uint64()) + s.decrementOutstandingSupply(big.NewInt(-100)) + require.Equal(t, uint64(750), s.outstandingSupply.Uint64()) + s.decrementOutstandingSupply(big.NewInt(0)) + require.Equal(t, uint64(750), s.outstandingSupply.Uint64()) +} + +func TestValidateInflationConfig(t *testing.T) { + valid := &genesis.Rewarding{ + InflationRateY1Bps: 500, + InflationDecayNumerator: 8000, + InflationDecayDenominator: 10000, + InflationFloorBps: 50, + BlocksPerYear: 12_614_400, + StakerShareBps: 8000, + MachinaShareBps: 2000, + MachinaDaoAddress: "io1ar5l5s268rtgzshltnqv88mua06ucm58dx678y", + OutstandingSupplyAtActivation: "9440000000000000000000000000", + } + require.NoError(t, validateInflationConfig(valid)) + + cases := []struct { + name string + mutate func(*genesis.Rewarding) + wantSub string + }{ + {"shares don't sum", func(c *genesis.Rewarding) { c.StakerShareBps = 7000 }, "share splits"}, + {"floor above Y1", func(c *genesis.Rewarding) { c.InflationFloorBps = 600 }, "InflationFloorBps"}, + {"zero decay denom", func(c *genesis.Rewarding) { c.InflationDecayDenominator = 0 }, "Denominator"}, + {"decay > 1", func(c *genesis.Rewarding) { c.InflationDecayNumerator = 11000 }, "decay must be ≤ 1"}, + {"zero blocksPerYear", func(c *genesis.Rewarding) { c.BlocksPerYear = 0 }, "BlocksPerYear"}, + {"empty machina addr", func(c *genesis.Rewarding) { c.MachinaDaoAddress = "" }, "MachinaDaoAddress must"}, + {"malformed machina addr", func(c *genesis.Rewarding) { c.MachinaDaoAddress = "not-an-address" }, "does not parse"}, + {"empty supply", func(c *genesis.Rewarding) { c.OutstandingSupplyAtActivation = "" }, "OutstandingSupplyAtActivation"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg := *valid + c.mutate(&cfg) + err := validateInflationConfig(&cfg) + require.Error(t, err) + require.Contains(t, err.Error(), c.wantSub) + }) + } +} + +// End-to-end IIP-62 mintAndAllocate: configure the Rewarding genesis with a small +// supply, fire CreatePreStates at the activation block to seed InflationState, then +// run GrantBlockReward at the next height. Asserts the staker → Fund credit, the +// Machina → account credit, and the two TransactionLogs emitted. +func TestMintAndAllocate_EmitsTransactionLogs(t *testing.T) { + machinaAddr := identityset.Address(33).String() + // Tiny supply so the per-block mint is human-readable in failure messages. + const supplyRau = "100000000000000000000" // 100 IOTX + const blocksPerYear = 100 // short year for fast tests + + testProtocol(t, func(t *testing.T, ctx context.Context, sm protocol.StateManager, p *Protocol) { + req := require.New(t) + g := genesis.MustExtractGenesisContext(ctx) + // Splice in IIP-62 params. testProtocol used the package default Y1=500, etc. + g.Rewarding.InflationRateY1Bps = 10000 // 100% — pick a chunky rate so per-block mint > 0 + g.Rewarding.InflationDecayNumerator = 8000 + g.Rewarding.InflationDecayDenominator = 10000 + g.Rewarding.InflationFloorBps = 50 + g.Rewarding.BlocksPerYear = blocksPerYear + g.Rewarding.StakerShareBps = 8000 + g.Rewarding.MachinaShareBps = 2000 + g.Rewarding.MachinaDaoAddress = machinaAddr + g.Rewarding.OutstandingSupplyAtActivation = supplyRau + + // Activate IIP-62 at the current test block height. + blkCtx := protocol.MustGetBlockCtx(ctx) + activation := blkCtx.BlockHeight + g.ToBeEnabledBlockHeight = activation + ctx = genesis.WithGenesisContext(ctx, g) + ctx = protocol.WithFeatureCtx(ctx) + + // Seed InflationState via CreatePreStates at the activation block. + req.NoError(p.CreatePreStates(ctx, sm)) + + // Step to the next block: still in Y1, but past activation so mintAndAllocate fires. + blkCtx.BlockHeight = activation + 1 + ctx = protocol.WithBlockCtx(ctx, blkCtx) + ctx = genesis.WithGenesisContext(ctx, g) + ctx = protocol.WithFeatureCtx(ctx) + + // Snapshot pre-state. + fundBefore, _, err := p.AvailableBalance(ctx, sm) + req.NoError(err) + machinaAccBefore, err := accountutil.LoadAccount(sm, mustAddr(machinaAddr)) + req.NoError(err) + machinaBalBefore := new(big.Int).Set(machinaAccBefore.Balance) + + // Compute expected per-block mint via the same pure math the protocol uses. + supply, _ := new(big.Int).SetString(supplyRau, 10) + annual := AnnualMint(supply, 10000) // 100% of 100 IOTX = 100 IOTX/year + expectPerBlock := new(big.Int).Quo(annual, big.NewInt(blocksPerYear)) + expectStaker := new(big.Int).Quo(new(big.Int).Mul(expectPerBlock, big.NewInt(8000)), big.NewInt(10000)) + expectMachina := new(big.Int).Quo(new(big.Int).Mul(expectPerBlock, big.NewInt(2000)), big.NewInt(10000)) + + _, mintTLogs, err := p.GrantBlockReward(ctx, sm) + req.NoError(err) + req.Len(mintTLogs, 2, "expected staker + machina mint tLogs") + + // Staker log: INFLATION_MINT_STAKER, Sender="" (protocol mint), Recipient=RewardingPoolAddr. + req.Equal(iotextypes.TransactionLogType_INFLATION_MINT_STAKER, mintTLogs[0].Type) + req.Equal("", mintTLogs[0].Sender, "Sender must be empty to mark protocol mint") + req.Equal(address.RewardingPoolAddr, mintTLogs[0].Recipient) + req.Equalf(0, mintTLogs[0].Amount.Cmp(expectStaker), + "staker mint amount mismatch: got %s want %s", mintTLogs[0].Amount, expectStaker) + + // Machina log: INFLATION_MINT_MACHINA, Sender="", Recipient=Machina. + req.Equal(iotextypes.TransactionLogType_INFLATION_MINT_MACHINA, mintTLogs[1].Type) + req.Equal("", mintTLogs[1].Sender, "Sender must be empty to mark protocol mint") + req.Equal(machinaAddr, mintTLogs[1].Recipient) + req.Equalf(0, mintTLogs[1].Amount.Cmp(expectMachina), + "machina mint amount mismatch: got %s want %s", mintTLogs[1].Amount, expectMachina) + + // State side-effects: the fund is credited mStaker by the mint, then debited + // effective_block_reward = min(a.blockReward, mStaker) by the producer grant. + // testProtocol seeds admin blockReward = 10 and mStaker ≫ 10, so the clamp is 10 + // and the net fund gain is mStaker − 10. + effectiveBlock := big.NewInt(10) + expectFundGain := new(big.Int).Sub(expectStaker, effectiveBlock) + fundAfter, _, err := p.AvailableBalance(ctx, sm) + req.NoError(err) + gain := new(big.Int).Sub(fundAfter, fundBefore) + req.Equalf(0, gain.Cmp(expectFundGain), + "fund delta mismatch: got %s want %s", gain, expectFundGain) + + machinaAccAfter, err := accountutil.LoadAccount(sm, mustAddr(machinaAddr)) + req.NoError(err) + machinaGain := new(big.Int).Sub(machinaAccAfter.Balance, machinaBalBefore) + req.Equalf(0, machinaGain.Cmp(expectMachina), + "machina balance delta mismatch: got %s want %s", machinaGain, expectMachina) + }, nil, false, 0) +} + +// IIP-62 step G: post-activation, GrantEpochReward funds the split from +// EpochRemainderAccumulator (banked per-block by mintAndAllocate) instead of +// admin.epochReward. The accumulator must be drained back to zero after the +// grant so the next epoch starts fresh. +func TestGrantEpochReward_UsesAccumulator(t *testing.T) { + machinaAddr := identityset.Address(33).String() + testProtocol(t, func(t *testing.T, ctx context.Context, sm protocol.StateManager, p *Protocol) { + req := require.New(t) + + // Activate IIP-62 at the harness's current height (also the last block of + // epoch 1, so assertLastBlockInEpoch passes inside GrantEpochReward). + g := genesis.MustExtractGenesisContext(ctx) + blkCtx := protocol.MustGetBlockCtx(ctx) + g.Rewarding.InflationRateY1Bps = 500 + g.Rewarding.InflationDecayNumerator = 8000 + g.Rewarding.InflationDecayDenominator = 10000 + g.Rewarding.InflationFloorBps = 50 + g.Rewarding.BlocksPerYear = 1000 + g.Rewarding.StakerShareBps = 8000 + g.Rewarding.MachinaShareBps = 2000 + g.Rewarding.MachinaDaoAddress = machinaAddr + g.Rewarding.OutstandingSupplyAtActivation = "100000000000000000000" + g.ToBeEnabledBlockHeight = blkCtx.BlockHeight + ctx = genesis.WithGenesisContext(ctx, g) + ctx = protocol.WithFeatureCtx(ctx) + + // Seed InflationState via the activation pre-state hook. + req.NoError(p.CreatePreStates(ctx, sm)) + + // Override the accumulator with a distinctive value. testProtocol seeds + // a.epochReward = 100, so accumulator = 300 makes Address(27)'s slice + // (votes 4M of 10M total) settle at 300·4M/10M = 120 instead of the + // legacy 100·4M/10M = 40 — disambiguating the two funding paths. + const accumulator = int64(300) + inf := newInflationState() + _, err := p.state(ctx, sm, _inflKey, inf) + req.NoError(err) + inf.epochRemainderAccumulator.SetInt64(accumulator) + req.NoError(p.putState(ctx, sm, _inflKey, inf)) + + // Fund the rewarding pool so the grant + foundation bonus succeed. Caller + // (Address(28)) is seeded with 1000 by the harness. + _, err = p.Deposit(ctx, sm, big.NewInt(500), iotextypes.TransactionLogType_DEPOSIT_TO_REWARDING_FUND) + req.NoError(err) + + // Staking mock (mirrors TestProtocol_GrantEpochReward). + registry := protocol.MustGetRegistry(ctx) + sp := &staking.Protocol{} + req.NoError(sp.Register(registry)) + patches := gomonkey.NewPatches() + patches.ApplyMethodReturn(sp, "SlashCandidateByOperator", nil) + patches.ApplyMethodReturn(sp, "SlashCandidateByID", nil) + defer patches.Reset() + + ctx = protocol.WithFeatureWithHeightCtx(ctx) + _, rewardLogs, err := p.GrantEpochReward(ctx, sm) + req.NoError(err) + + // Address(27)'s votes (4M of 10M total) → reward routed to Address(0). + // Accumulator-funded slice = 300·4M/10M = 120. Legacy a.epochReward=100 + // slice would be 40. Pinning to 120 proves the new path fired. + var address0Reward *big.Int + for _, l := range rewardLogs { + var rl rewardingpb.RewardLog + req.NoError(proto.Unmarshal(l.Data, &rl)) + if rl.Type == rewardingpb.RewardLog_EPOCH_REWARD && rl.Addr == identityset.Address(0).String() { + amt, ok := new(big.Int).SetString(rl.Amount, 10) + req.True(ok) + address0Reward = amt + break + } + } + req.NotNilf(address0Reward, "no EPOCH_REWARD log for Address(0); got %d logs", len(rewardLogs)) + req.Equalf(0, address0Reward.Cmp(big.NewInt(120)), + "Address(0) reward = %s; expected 120 (accumulator path) — 40 means legacy path fired", + address0Reward) + + // Accumulator must be drained back to zero. + inf2 := newInflationState() + _, err = p.state(ctx, sm, _inflKey, inf2) + req.NoError(err) + req.Equalf(0, inf2.epochRemainderAccumulator.Sign(), + "accumulator must be zero after grant; got %s", inf2.epochRemainderAccumulator) + }, nil, false, 0) +} + +func mustAddr(s string) address.Address { + a, err := address.FromString(s) + if err != nil { + panic(err) + } + return a +} + +// iotxRau returns 10^18 (1 IOTX in Rau) as a big.Int. +func iotxRau() *big.Int { + r, _ := new(big.Int).SetString("1000000000000000000", 10) + return r +} diff --git a/action/protocol/rewarding/protocol.go b/action/protocol/rewarding/protocol.go index 62ee8df661..4f33934d8c 100644 --- a/action/protocol/rewarding/protocol.go +++ b/action/protocol/rewarding/protocol.go @@ -8,6 +8,7 @@ package rewarding import ( "context" "math/big" + "strconv" "github.com/pkg/errors" "go.uber.org/zap" @@ -121,6 +122,11 @@ func (p *Protocol) CreatePreStates(ctx context.Context, sm protocol.StateManager return p.setFoundationBonusExtension(ctx, sm) case g.WakeBlockHeight: return p.SetReward(ctx, sm, g.WakeBlockReward(), true) + case g.ToBeEnabledBlockHeight: + // IIP-62 activation: seed the InflationState used by per-block productive + // inflation. Reuses ToBeEnabledBlockHeight as the gate until the hardfork + // is named. + return p.initInflationState(ctx, sm) } return nil } @@ -243,15 +249,16 @@ func (p *Protocol) Handle( case *action.GrantReward: switch act.RewardType() { case action.BlockReward: - rewardLog, err := p.GrantBlockReward(ctx, sm) + rewardLog, mintTLogs, err := p.GrantBlockReward(ctx, sm) if err != nil { log.L().Debug("Error when handling rewarding action", zap.Error(err)) return p.settleSystemAction(ctx, sm, elp, uint64(iotextypes.ReceiptStatus_Failure), si, nil) } - if rewardLog == nil { - return p.settleSystemAction(ctx, sm, elp, uint64(iotextypes.ReceiptStatus_Success), si, nil) + var logs []*action.Log + if rewardLog != nil { + logs = []*action.Log{rewardLog} } - return p.settleSystemAction(ctx, sm, elp, uint64(iotextypes.ReceiptStatus_Success), si, []*action.Log{rewardLog}) + return p.settleSystemAction(ctx, sm, elp, uint64(iotextypes.ReceiptStatus_Success), si, logs, mintTLogs...) case action.EpochReward: transactionLogs, rewardLogs, err := p.GrantEpochReward(ctx, sm) if err != nil { @@ -297,6 +304,24 @@ func (p *Protocol) ReadState( return nil, uint64(0), err } return []byte(balance.String()), height, nil + case "OutstandingSupply": + v, height, err := p.OutstandingSupply(ctx, sr) + if err != nil { + return nil, uint64(0), err + } + return []byte(v.String()), height, nil + case "PostActivationMinted": + v, height, err := p.PostActivationMinted(ctx, sr) + if err != nil { + return nil, uint64(0), err + } + return []byte(v.String()), height, nil + case "CurrentInflationBps": + bps, height, err := p.CurrentInflationBps(ctx, sr) + if err != nil { + return nil, uint64(0), err + } + return []byte(strconv.FormatUint(bps, 10)), height, nil default: return nil, uint64(0), errors.New("corresponding method isn't found") } diff --git a/action/protocol/rewarding/reward.go b/action/protocol/rewarding/reward.go index 25c4f566d7..1e28e327f8 100644 --- a/action/protocol/rewarding/reward.go +++ b/action/protocol/rewarding/reward.go @@ -24,6 +24,7 @@ import ( "github.com/iotexproject/iotex-core/v2/action/protocol/rewarding/rewardingpb" "github.com/iotexproject/iotex-core/v2/action/protocol/rolldpos" "github.com/iotexproject/iotex-core/v2/action/protocol/staking" + "github.com/iotexproject/iotex-core/v2/blockchain/genesis" "github.com/iotexproject/iotex-core/v2/pkg/enc" "github.com/iotexproject/iotex-core/v2/pkg/log" "github.com/iotexproject/iotex-core/v2/state" @@ -110,11 +111,14 @@ func (a *rewardAccount) Decode(v systemcontracts.GenericValue) error { return a.Deserialize(v.AuxiliaryData) } -// GrantBlockReward grants the block reward (token) to the block producer +// GrantBlockReward grants the block reward (token) to the block producer. +// Also runs the IIP-62 per-block productive-inflation mint at the head of the +// pipeline (post-activation only) and returns its transaction logs alongside the +// block-reward event log. func (p *Protocol) GrantBlockReward( ctx context.Context, sm protocol.StateManager, -) (*action.Log, error) { +) (*action.Log, []*action.TransactionLog, error) { actionCtx := protocol.MustGetActionCtx(ctx) blkCtx := protocol.MustGetBlockCtx(ctx) fCtx := protocol.MustGetFeatureCtx(ctx) @@ -126,7 +130,7 @@ func (p *Protocol) GrantBlockReward( key := append(_blockRewardHistoryKeyPrefix, indexBytes[:]...) err := p.deleteStateV1(sm, key, &rewardHistory{}, protocol.ErigonStoreOnlyOption()) if err != nil && !errors.Is(err, state.ErrErigonStoreNotSupported) { - return nil, err + return nil, nil, err } // revert the changese for erigon storage optimazation defer func() { @@ -138,7 +142,17 @@ func (p *Protocol) GrantBlockReward( } if err := p.assertNoRewardYet(ctx, sm, _blockRewardHistoryKeyPrefix, blkCtx.BlockHeight); err != nil { - return nil, err + return nil, nil, err + } + + // IIP-62: per-block productive-inflation mint. Runs once per block from activation + // onward, before the block-reward grant debits the fund. Pre-activation this is a + // no-op and returns zero mStakerBlock + nil logs; the clamp in calculateTotalRewardAndTip + // degenerates accordingly. Mint tLogs are returned even if the producer has no + // reward address (the credit to Fund and Machina has already happened). + mStakerBlock, mintLogs, err := p.mintAndAllocate(ctx, sm) + if err != nil { + return nil, nil, err } producerAddrStr := blkCtx.Producer.String() @@ -147,7 +161,7 @@ func (p *Protocol) GrantBlockReward( if pp != nil { candidates, err := pp.Candidates(ctx, sm) if err != nil { - return nil, err + return nil, nil, err } for _, candidate := range candidates { if candidate.Address == producerAddrStr { @@ -156,27 +170,28 @@ func (p *Protocol) GrantBlockReward( } } } - // If reward address doesn't exist, do nothing + // If reward address doesn't exist, do nothing for the block-reward grant; still + // surface the mint tLogs so the inflation credit is attributable. if rewardAddrStr == "" { log.S().Debugf("Producer %s doesn't have a reward address", producerAddrStr) - return nil, nil + return nil, mintLogs, nil } rewardAddr, err := address.FromString(rewardAddrStr) if err != nil { - return nil, err + return nil, nil, err } - totalReward, blockReward, effectiveTip, err := p.calculateTotalRewardAndTip(ctx, sm) + totalReward, blockReward, effectiveTip, err := p.calculateTotalRewardAndTip(ctx, sm, mStakerBlock) if err != nil { - return nil, err + return nil, nil, err } if err := p.updateAvailableBalance(ctx, sm, totalReward); err != nil { - return nil, err + return nil, nil, err } if err := p.grantToAccount(ctx, sm, rewardAddr, totalReward); err != nil { - return nil, err + return nil, nil, err } if err := p.updateRewardHistory(ctx, sm, _blockRewardHistoryKeyPrefix, blkCtx.BlockHeight); err != nil { - return nil, err + return nil, nil, err } var ( featureCtx = protocol.MustGetFeatureCtx(ctx) @@ -201,7 +216,7 @@ func (p *Protocol) GrantBlockReward( } data, err := proto.Marshal(msg) if err != nil { - return nil, err + return nil, nil, err } return &action.Log{ Address: p.addr.String(), @@ -209,7 +224,7 @@ func (p *Protocol) GrantBlockReward( Data: data, BlockHeight: blkCtx.BlockHeight, ActionHash: actionCtx.ActionHash, - }, nil + }, mintLogs, nil } // GrantEpochReward grants the epoch reward (token) to all beneficiaries of a epoch @@ -282,7 +297,22 @@ func (p *Protocol) GrantEpochReward( if featureWithHeightCtx.GetUnproductiveDelegates(epochStartHeight) { epochRewardSplitUqdMap = uqdMap } - addrs, amounts, err := p.splitEpochReward(candidates, a.epochReward, a.numDelegatesForEpochReward, exemptAddrs, epochRewardSplitUqdMap) + // IIP-62 step G: post-activation, the epoch grant is funded by the per-block + // inflation surplus banked into EpochRemainderAccumulator (mStaker minus the + // clamped block reward, summed per block by mintAndAllocate), not by the legacy + // admin.epochReward constant. The accumulator is reset to zero after the grant + // loop below. + g := genesis.MustExtractGenesisContext(ctx) + inf := newInflationState() + postActivation := g.IsToBeEnabled(blkCtx.BlockHeight) + epochAmount := a.epochReward + if postActivation { + if _, err := p.state(ctx, sm, _inflKey, inf); err != nil { + return nil, nil, errors.Wrap(err, "epoch grant: inflation state not seeded") + } + epochAmount = new(big.Int).Set(inf.epochRemainderAccumulator) + } + addrs, amounts, err := p.splitEpochReward(candidates, epochAmount, a.numDelegatesForEpochReward, exemptAddrs, epochRewardSplitUqdMap) if err != nil { return nil, nil, err } @@ -354,6 +384,18 @@ func (p *Protocol) GrantEpochReward( if err := p.updateAvailableBalance(ctx, sm, actualTotalReward); err != nil { return nil, nil, err } + // IIP-62 step G: drain the per-block inflation surplus that funded this epoch + // grant. Reset unconditionally — even if splitEpochReward returned no addrs (all + // candidates exempt / zero votes), the next epoch must start with a fresh + // accumulator so we don't double-count last epoch's mint. Reset BEFORE the + // history sentinel so any retry at the same height re-loads a zero accumulator + // rather than re-paying the same surplus. + if postActivation { + inf.epochRemainderAccumulator.SetUint64(0) + if err := p.putState(ctx, sm, _inflKey, inf); err != nil { + return nil, nil, errors.Wrap(err, "epoch grant: reset accumulator") + } + } if err := p.updateRewardHistory(ctx, sm, _epochRewardHistoryKeyPrefix, epochNum); err != nil { return nil, nil, err } @@ -503,7 +545,7 @@ func (p *Protocol) claimFromAccount(ctx context.Context, sm protocol.StateManage return accountutil.StoreAccount(sm, addr, primAcc) } -func (p *Protocol) calculateTotalRewardAndTip(ctx context.Context, sm protocol.StateManager) (*big.Int, *big.Int, *big.Int, error) { +func (p *Protocol) calculateTotalRewardAndTip(ctx context.Context, sm protocol.StateManager, mStakerBlock *big.Int) (*big.Int, *big.Int, *big.Int, error) { a := admin{} if _, err := p.state(ctx, sm, _adminKey, &a); err != nil { return nil, nil, nil, err @@ -515,6 +557,15 @@ func (p *Protocol) calculateTotalRewardAndTip(ctx context.Context, sm protocol.S blockReward = (&big.Int{}).Set(a.blockReward) effectiveTip = &big.Int{} ) + // IIP-62 step F: post-activation, clamp the constant block reward down to the + // per-block staker-share mint when the latter is smaller (e.g. at the Y12+ floor + // where annual mint dips below WakeBlockReward × blocksPerYear). The excess + // (mStakerBlock − effective_block_reward) is banked into EpochRemainderAccumulator + // by mintAndAllocate and surfaced via step G as the epoch grant. Pre-activation + // mStakerBlock is zero, so this branch is a no-op. + if mStakerBlock != nil && mStakerBlock.Sign() > 0 && mStakerBlock.Cmp(blockReward) < 0 { + blockReward.Set(mStakerBlock) + } if featureCtx.EnableDynamicFeeTx { if blkCtx.AccumulatedTips.Sign() > 0 { effectiveTip.Set(&blkCtx.AccumulatedTips) diff --git a/action/protocol/rewarding/reward_test.go b/action/protocol/rewarding/reward_test.go index 496c068c57..ece81662c9 100644 --- a/action/protocol/rewarding/reward_test.go +++ b/action/protocol/rewarding/reward_test.go @@ -60,14 +60,14 @@ func TestProtocol_GrantBlockReward(t *testing.T) { req.NoError(err) req.Equal(tv.blockReward, br) // Grant block reward will fail because of no available balance - _, err = p.GrantBlockReward(ctx, sm) + _, _, err = p.GrantBlockReward(ctx, sm) req.Error(err) _, err = p.Deposit(ctx, sm, tv.deposit, iotextypes.TransactionLogType_DEPOSIT_TO_REWARDING_FUND) req.NoError(err) // Grant block reward - rewardLog, err := p.GrantBlockReward(ctx, sm) + rewardLog, _, err := p.GrantBlockReward(ctx, sm) req.NoError(err) req.Equal(p.addr.String(), rewardLog.Address) var rl rewardingpb.RewardLog @@ -89,7 +89,7 @@ func TestProtocol_GrantBlockReward(t *testing.T) { req.Equal(tv.blockReward, unclaimedBalance) // Grant the same block reward again will fail - _, err = p.GrantBlockReward(ctx, sm) + _, _, err = p.GrantBlockReward(ctx, sm) req.Error(err) // Grant with priority fee after VanuatuBlockHeight @@ -100,7 +100,7 @@ func TestProtocol_GrantBlockReward(t *testing.T) { req.NoError(err) req.Equal(tLog[0].Type, iotextypes.TransactionLogType_PRIORITY_FEE) req.Equal(&blkCtx.AccumulatedTips, tLog[0].Amount) - rewardLog, err = p.GrantBlockReward(ctx, sm) + rewardLog, _, err = p.GrantBlockReward(ctx, sm) req.NoError(err) rls, err := UnmarshalRewardLog(rewardLog.Data) req.NoError(err) @@ -289,7 +289,7 @@ func TestProtocol_ClaimReward(t *testing.T) { require.NoError(t, err) // Grant block reward - rewardLog, err := p.GrantBlockReward(ctx, sm) + rewardLog, _, err := p.GrantBlockReward(ctx, sm) require.NoError(t, err) require.Equal(t, p.addr.String(), rewardLog.Address) var rl rewardingpb.RewardLog @@ -520,7 +520,7 @@ func TestProtocol_NoRewardAddr(t *testing.T) { ctx = protocol.WithFeatureWithHeightCtx(ctx) // Grant block reward - _, err = p.GrantBlockReward(ctx, sm) + _, _, err = p.GrantBlockReward(ctx, sm) require.NoError(t, err) availableBalance, _, err := p.AvailableBalance(ctx, sm) @@ -629,7 +629,8 @@ func TestProtocol_CalculateReward(t *testing.T) { ctx = protocol.WithFeatureCtx(genesis.WithGenesisContext(protocol.WithBlockCtx(ctx, blkCtx), g)) } // verify block reward, total reward, and tip - total, br, tip, err := p.calculateTotalRewardAndTip(ctx, sm) + // Pre-activation IIP-62: mStakerBlock is zero, so the clamp degenerates. + total, br, tip, err := p.calculateTotalRewardAndTip(ctx, sm, nil) req.NoError(err) req.Zero(tv.blockReward.Cmp(br)) req.Zero(tv.totalReward.Cmp(total)) diff --git a/action/protocol/rewarding/rewardingpb/rewarding.pb.go b/action/protocol/rewarding/rewardingpb/rewarding.pb.go index 3084b8abfc..b7fcd1b631 100644 --- a/action/protocol/rewarding/rewardingpb/rewarding.pb.go +++ b/action/protocol/rewarding/rewardingpb/rewarding.pb.go @@ -8,9 +8,9 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.26.0 -// protoc v4.23.3 -// source: action/protocol/rewarding/rewardingpb/rewarding.proto +// protoc-gen-go v1.36.11 +// protoc v7.34.1 +// source: rewarding.proto package rewardingpb @@ -19,6 +19,7 @@ import ( protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" + unsafe "unsafe" ) const ( @@ -67,11 +68,11 @@ func (x RewardLog_RewardType) String() string { } func (RewardLog_RewardType) Descriptor() protoreflect.EnumDescriptor { - return file_action_protocol_rewarding_rewardingpb_rewarding_proto_enumTypes[0].Descriptor() + return file_rewarding_proto_enumTypes[0].Descriptor() } func (RewardLog_RewardType) Type() protoreflect.EnumType { - return &file_action_protocol_rewarding_rewardingpb_rewarding_proto_enumTypes[0] + return &file_rewarding_proto_enumTypes[0] } func (x RewardLog_RewardType) Number() protoreflect.EnumNumber { @@ -80,30 +81,27 @@ func (x RewardLog_RewardType) Number() protoreflect.EnumNumber { // Deprecated: Use RewardLog_RewardType.Descriptor instead. func (RewardLog_RewardType) EnumDescriptor() ([]byte, []int) { - return file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescGZIP(), []int{5, 0} + return file_rewarding_proto_rawDescGZIP(), []int{5, 0} } type Admin struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - BlockReward string `protobuf:"bytes,1,opt,name=blockReward,proto3" json:"blockReward,omitempty"` - EpochReward string `protobuf:"bytes,2,opt,name=epochReward,proto3" json:"epochReward,omitempty"` - NumDelegatesForEpochReward uint64 `protobuf:"varint,3,opt,name=numDelegatesForEpochReward,proto3" json:"numDelegatesForEpochReward,omitempty"` - FoundationBonus string `protobuf:"bytes,4,opt,name=foundationBonus,proto3" json:"foundationBonus,omitempty"` - NumDelegatesForFoundationBonus uint64 `protobuf:"varint,5,opt,name=numDelegatesForFoundationBonus,proto3" json:"numDelegatesForFoundationBonus,omitempty"` - FoundationBonusLastEpoch uint64 `protobuf:"varint,6,opt,name=foundationBonusLastEpoch,proto3" json:"foundationBonusLastEpoch,omitempty"` - ProductivityThreshold uint64 `protobuf:"varint,7,opt,name=productivityThreshold,proto3" json:"productivityThreshold,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + BlockReward string `protobuf:"bytes,1,opt,name=blockReward,proto3" json:"blockReward,omitempty"` + EpochReward string `protobuf:"bytes,2,opt,name=epochReward,proto3" json:"epochReward,omitempty"` + NumDelegatesForEpochReward uint64 `protobuf:"varint,3,opt,name=numDelegatesForEpochReward,proto3" json:"numDelegatesForEpochReward,omitempty"` + FoundationBonus string `protobuf:"bytes,4,opt,name=foundationBonus,proto3" json:"foundationBonus,omitempty"` + NumDelegatesForFoundationBonus uint64 `protobuf:"varint,5,opt,name=numDelegatesForFoundationBonus,proto3" json:"numDelegatesForFoundationBonus,omitempty"` + FoundationBonusLastEpoch uint64 `protobuf:"varint,6,opt,name=foundationBonusLastEpoch,proto3" json:"foundationBonusLastEpoch,omitempty"` + ProductivityThreshold uint64 `protobuf:"varint,7,opt,name=productivityThreshold,proto3" json:"productivityThreshold,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Admin) Reset() { *x = Admin{} - if protoimpl.UnsafeEnabled { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_rewarding_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Admin) String() string { @@ -113,8 +111,8 @@ func (x *Admin) String() string { func (*Admin) ProtoMessage() {} func (x *Admin) ProtoReflect() protoreflect.Message { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_rewarding_proto_msgTypes[0] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -126,7 +124,7 @@ func (x *Admin) ProtoReflect() protoreflect.Message { // Deprecated: Use Admin.ProtoReflect.Descriptor instead. func (*Admin) Descriptor() ([]byte, []int) { - return file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescGZIP(), []int{0} + return file_rewarding_proto_rawDescGZIP(), []int{0} } func (x *Admin) GetBlockReward() string { @@ -179,21 +177,18 @@ func (x *Admin) GetProductivityThreshold() uint64 { } type Fund struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TotalBalance string `protobuf:"bytes,1,opt,name=totalBalance,proto3" json:"totalBalance,omitempty"` - UnclaimedBalance string `protobuf:"bytes,2,opt,name=unclaimedBalance,proto3" json:"unclaimedBalance,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + TotalBalance string `protobuf:"bytes,1,opt,name=totalBalance,proto3" json:"totalBalance,omitempty"` + UnclaimedBalance string `protobuf:"bytes,2,opt,name=unclaimedBalance,proto3" json:"unclaimedBalance,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Fund) Reset() { *x = Fund{} - if protoimpl.UnsafeEnabled { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_rewarding_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Fund) String() string { @@ -203,8 +198,8 @@ func (x *Fund) String() string { func (*Fund) ProtoMessage() {} func (x *Fund) ProtoReflect() protoreflect.Message { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_rewarding_proto_msgTypes[1] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -216,7 +211,7 @@ func (x *Fund) ProtoReflect() protoreflect.Message { // Deprecated: Use Fund.ProtoReflect.Descriptor instead. func (*Fund) Descriptor() ([]byte, []int) { - return file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescGZIP(), []int{1} + return file_rewarding_proto_rawDescGZIP(), []int{1} } func (x *Fund) GetTotalBalance() string { @@ -234,20 +229,17 @@ func (x *Fund) GetUnclaimedBalance() string { } type RewardHistory struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` unknownFields protoimpl.UnknownFields - - Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` + sizeCache protoimpl.SizeCache } func (x *RewardHistory) Reset() { *x = RewardHistory{} - if protoimpl.UnsafeEnabled { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_rewarding_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RewardHistory) String() string { @@ -257,8 +249,8 @@ func (x *RewardHistory) String() string { func (*RewardHistory) ProtoMessage() {} func (x *RewardHistory) ProtoReflect() protoreflect.Message { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_rewarding_proto_msgTypes[2] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -270,7 +262,7 @@ func (x *RewardHistory) ProtoReflect() protoreflect.Message { // Deprecated: Use RewardHistory.ProtoReflect.Descriptor instead. func (*RewardHistory) Descriptor() ([]byte, []int) { - return file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescGZIP(), []int{2} + return file_rewarding_proto_rawDescGZIP(), []int{2} } func (x *RewardHistory) GetHeight() uint64 { @@ -281,20 +273,17 @@ func (x *RewardHistory) GetHeight() uint64 { } type Account struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Balance string `protobuf:"bytes,1,opt,name=balance,proto3" json:"balance,omitempty"` unknownFields protoimpl.UnknownFields - - Balance string `protobuf:"bytes,1,opt,name=balance,proto3" json:"balance,omitempty"` + sizeCache protoimpl.SizeCache } func (x *Account) Reset() { *x = Account{} - if protoimpl.UnsafeEnabled { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_rewarding_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Account) String() string { @@ -304,8 +293,8 @@ func (x *Account) String() string { func (*Account) ProtoMessage() {} func (x *Account) ProtoReflect() protoreflect.Message { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_rewarding_proto_msgTypes[3] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -317,7 +306,7 @@ func (x *Account) ProtoReflect() protoreflect.Message { // Deprecated: Use Account.ProtoReflect.Descriptor instead. func (*Account) Descriptor() ([]byte, []int) { - return file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescGZIP(), []int{3} + return file_rewarding_proto_rawDescGZIP(), []int{3} } func (x *Account) GetBalance() string { @@ -328,20 +317,17 @@ func (x *Account) GetBalance() string { } type Exempt struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Addrs [][]byte `protobuf:"bytes,1,rep,name=addrs,proto3" json:"addrs,omitempty"` unknownFields protoimpl.UnknownFields - - Addrs [][]byte `protobuf:"bytes,1,rep,name=addrs,proto3" json:"addrs,omitempty"` + sizeCache protoimpl.SizeCache } func (x *Exempt) Reset() { *x = Exempt{} - if protoimpl.UnsafeEnabled { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_rewarding_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Exempt) String() string { @@ -351,8 +337,8 @@ func (x *Exempt) String() string { func (*Exempt) ProtoMessage() {} func (x *Exempt) ProtoReflect() protoreflect.Message { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_rewarding_proto_msgTypes[4] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -364,7 +350,7 @@ func (x *Exempt) ProtoReflect() protoreflect.Message { // Deprecated: Use Exempt.ProtoReflect.Descriptor instead. func (*Exempt) Descriptor() ([]byte, []int) { - return file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescGZIP(), []int{4} + return file_rewarding_proto_rawDescGZIP(), []int{4} } func (x *Exempt) GetAddrs() [][]byte { @@ -375,22 +361,19 @@ func (x *Exempt) GetAddrs() [][]byte { } type RewardLog struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Type RewardLog_RewardType `protobuf:"varint,1,opt,name=type,proto3,enum=rewardingpb.RewardLog_RewardType" json:"type,omitempty"` + Addr string `protobuf:"bytes,2,opt,name=addr,proto3" json:"addr,omitempty"` + Amount string `protobuf:"bytes,3,opt,name=amount,proto3" json:"amount,omitempty"` unknownFields protoimpl.UnknownFields - - Type RewardLog_RewardType `protobuf:"varint,1,opt,name=type,proto3,enum=rewardingpb.RewardLog_RewardType" json:"type,omitempty"` - Addr string `protobuf:"bytes,2,opt,name=addr,proto3" json:"addr,omitempty"` - Amount string `protobuf:"bytes,3,opt,name=amount,proto3" json:"amount,omitempty"` + sizeCache protoimpl.SizeCache } func (x *RewardLog) Reset() { *x = RewardLog{} - if protoimpl.UnsafeEnabled { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_rewarding_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RewardLog) String() string { @@ -400,8 +383,8 @@ func (x *RewardLog) String() string { func (*RewardLog) ProtoMessage() {} func (x *RewardLog) ProtoReflect() protoreflect.Message { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_rewarding_proto_msgTypes[5] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -413,7 +396,7 @@ func (x *RewardLog) ProtoReflect() protoreflect.Message { // Deprecated: Use RewardLog.ProtoReflect.Descriptor instead. func (*RewardLog) Descriptor() ([]byte, []int) { - return file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescGZIP(), []int{5} + return file_rewarding_proto_rawDescGZIP(), []int{5} } func (x *RewardLog) GetType() RewardLog_RewardType { @@ -438,20 +421,17 @@ func (x *RewardLog) GetAmount() string { } type RewardLogs struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Logs []*RewardLog `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` unknownFields protoimpl.UnknownFields - - Logs []*RewardLog `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` + sizeCache protoimpl.SizeCache } func (x *RewardLogs) Reset() { *x = RewardLogs{} - if protoimpl.UnsafeEnabled { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_rewarding_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RewardLogs) String() string { @@ -461,8 +441,8 @@ func (x *RewardLogs) String() string { func (*RewardLogs) ProtoMessage() {} func (x *RewardLogs) ProtoReflect() protoreflect.Message { - mi := &file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_rewarding_proto_msgTypes[6] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -474,7 +454,7 @@ func (x *RewardLogs) ProtoReflect() protoreflect.Message { // Deprecated: Use RewardLogs.ProtoReflect.Descriptor instead. func (*RewardLogs) Descriptor() ([]byte, []int) { - return file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescGZIP(), []int{6} + return file_rewarding_proto_rawDescGZIP(), []int{6} } func (x *RewardLogs) GetLogs() []*RewardLog { @@ -484,89 +464,197 @@ func (x *RewardLogs) GetLogs() []*RewardLog { return nil } -var File_action_protocol_rewarding_rewardingpb_rewarding_proto protoreflect.FileDescriptor - -var file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDesc = []byte{ - 0x0a, 0x35, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x2f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x2f, 0x72, 0x65, 0x77, 0x61, - 0x72, 0x64, 0x69, 0x6e, 0x67, 0x70, 0x62, 0x2f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, - 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x69, - 0x6e, 0x67, 0x70, 0x62, 0x22, 0xef, 0x02, 0x0a, 0x05, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x12, 0x20, - 0x0a, 0x0b, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0b, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, - 0x12, 0x20, 0x0a, 0x0b, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x52, 0x65, 0x77, 0x61, - 0x72, 0x64, 0x12, 0x3e, 0x0a, 0x1a, 0x6e, 0x75, 0x6d, 0x44, 0x65, 0x6c, 0x65, 0x67, 0x61, 0x74, - 0x65, 0x73, 0x46, 0x6f, 0x72, 0x45, 0x70, 0x6f, 0x63, 0x68, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x1a, 0x6e, 0x75, 0x6d, 0x44, 0x65, 0x6c, 0x65, 0x67, - 0x61, 0x74, 0x65, 0x73, 0x46, 0x6f, 0x72, 0x45, 0x70, 0x6f, 0x63, 0x68, 0x52, 0x65, 0x77, 0x61, - 0x72, 0x64, 0x12, 0x28, 0x0a, 0x0f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x42, 0x6f, 0x6e, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x66, 0x6f, 0x75, - 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x6f, 0x6e, 0x75, 0x73, 0x12, 0x46, 0x0a, 0x1e, - 0x6e, 0x75, 0x6d, 0x44, 0x65, 0x6c, 0x65, 0x67, 0x61, 0x74, 0x65, 0x73, 0x46, 0x6f, 0x72, 0x46, - 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x6f, 0x6e, 0x75, 0x73, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x04, 0x52, 0x1e, 0x6e, 0x75, 0x6d, 0x44, 0x65, 0x6c, 0x65, 0x67, 0x61, 0x74, - 0x65, 0x73, 0x46, 0x6f, 0x72, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, - 0x6f, 0x6e, 0x75, 0x73, 0x12, 0x3a, 0x0a, 0x18, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x42, 0x6f, 0x6e, 0x75, 0x73, 0x4c, 0x61, 0x73, 0x74, 0x45, 0x70, 0x6f, 0x63, 0x68, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x18, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x42, 0x6f, 0x6e, 0x75, 0x73, 0x4c, 0x61, 0x73, 0x74, 0x45, 0x70, 0x6f, 0x63, 0x68, - 0x12, 0x34, 0x0a, 0x15, 0x70, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, - 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x04, 0x52, - 0x15, 0x70, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x54, 0x68, 0x72, - 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, 0x56, 0x0a, 0x04, 0x46, 0x75, 0x6e, 0x64, 0x12, 0x22, - 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, - 0x63, 0x65, 0x12, 0x2a, 0x0a, 0x10, 0x75, 0x6e, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x64, 0x42, - 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x75, 0x6e, - 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x64, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x22, 0x27, - 0x0a, 0x0d, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, - 0x16, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, - 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x22, 0x23, 0x0a, 0x07, 0x41, 0x63, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x07, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x22, 0x1e, 0x0a, 0x06, - 0x45, 0x78, 0x65, 0x6d, 0x70, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x22, 0xe2, 0x01, 0x0a, - 0x09, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x12, 0x35, 0x0a, 0x04, 0x74, 0x79, - 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x21, 0x2e, 0x72, 0x65, 0x77, 0x61, 0x72, - 0x64, 0x69, 0x6e, 0x67, 0x70, 0x62, 0x2e, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x4c, 0x6f, 0x67, - 0x2e, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, - 0x65, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x64, 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x61, 0x64, 0x64, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x72, 0x0a, - 0x0a, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x54, 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x0c, 0x42, - 0x4c, 0x4f, 0x43, 0x4b, 0x5f, 0x52, 0x45, 0x57, 0x41, 0x52, 0x44, 0x10, 0x00, 0x12, 0x10, 0x0a, - 0x0c, 0x45, 0x50, 0x4f, 0x43, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x41, 0x52, 0x44, 0x10, 0x01, 0x12, - 0x14, 0x0a, 0x10, 0x46, 0x4f, 0x55, 0x4e, 0x44, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x42, 0x4f, - 0x4e, 0x55, 0x53, 0x10, 0x02, 0x12, 0x12, 0x0a, 0x0e, 0x50, 0x52, 0x49, 0x4f, 0x52, 0x49, 0x54, - 0x59, 0x5f, 0x42, 0x4f, 0x4e, 0x55, 0x53, 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x55, 0x4e, 0x50, - 0x52, 0x4f, 0x44, 0x55, 0x43, 0x54, 0x49, 0x56, 0x45, 0x5f, 0x53, 0x4c, 0x41, 0x53, 0x48, 0x10, - 0x04, 0x22, 0x38, 0x0a, 0x0a, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x73, 0x12, - 0x2a, 0x0a, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, - 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x70, 0x62, 0x2e, 0x52, 0x65, 0x77, 0x61, - 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x52, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x42, 0x4a, 0x5a, 0x48, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x69, 0x6f, 0x74, 0x65, 0x78, 0x70, - 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x69, 0x6f, 0x74, 0x65, 0x78, 0x2d, 0x63, 0x6f, 0x72, - 0x65, 0x2f, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x2f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x2f, 0x72, 0x65, 0x77, 0x61, - 0x72, 0x64, 0x69, 0x6e, 0x67, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +// InflationState is the IIP-62 productive-inflation consensus state, persisted in +// state.RewardingNamespace under key "inf". All big-int fields are decimal-string +// serialized to stay consistent with Fund / Account / Admin. +type InflationState struct { + state protoimpl.MessageState `protogen:"open.v1"` + // OutstandingSupply is the running mint base for the disinflation curve. + // Seeded at activation from genesis.OutstandingSupplyAtActivation; grows by + // mTotal each block; may decrement via decrementOutstandingSupply (e.g. a + // future burn IIP wiring a base-fee burn site into the counter). + OutstandingSupply string `protobuf:"bytes,1,opt,name=outstandingSupply,proto3" json:"outstandingSupply,omitempty"` + // OutstandingSupplyAtYearStart is the §1.2 snapshot used for the entire year. + OutstandingSupplyAtYearStart string `protobuf:"bytes,2,opt,name=outstandingSupplyAtYearStart,proto3" json:"outstandingSupplyAtYearStart,omitempty"` + // PostActivationMinted is the cumulative mint since activation (analytics). + PostActivationMinted string `protobuf:"bytes,3,opt,name=postActivationMinted,proto3" json:"postActivationMinted,omitempty"` + // CurrentInflationBps is the effective annual rate, refreshed at year boundaries. + CurrentInflationBps uint64 `protobuf:"varint,4,opt,name=currentInflationBps,proto3" json:"currentInflationBps,omitempty"` + // CurrentYearIndex is the 1-indexed year matching YearIndex(). Stored so a + // reorg crossing a year boundary can detect the crossing and re-derive. + CurrentYearIndex uint64 `protobuf:"varint,5,opt,name=currentYearIndex,proto3" json:"currentYearIndex,omitempty"` + // DustStaker / DustMachina are the per-share Rau·bps dust accumulators used + // by SplitMint so per-block truncation does not silently bias the split. + DustStaker string `protobuf:"bytes,6,opt,name=dustStaker,proto3" json:"dustStaker,omitempty"` + DustMachina string `protobuf:"bytes,7,opt,name=dustMachina,proto3" json:"dustMachina,omitempty"` + // YearMintRemainder is the per-year leftover (annualMint mod blocksPerYear) + // flushed on the year's final block per §4.1. + YearMintRemainder string `protobuf:"bytes,8,opt,name=yearMintRemainder,proto3" json:"yearMintRemainder,omitempty"` + // EpochRemainderAccumulator carries (mStaker − effective_block_reward) across + // the blocks of an epoch; consumed and reset at the epoch's last block by + // GrantEpochReward (replaces the old constant Admin.epochReward). + EpochRemainderAccumulator string `protobuf:"bytes,9,opt,name=epochRemainderAccumulator,proto3" json:"epochRemainderAccumulator,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InflationState) Reset() { + *x = InflationState{} + mi := &file_rewarding_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InflationState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InflationState) ProtoMessage() {} + +func (x *InflationState) ProtoReflect() protoreflect.Message { + mi := &file_rewarding_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InflationState.ProtoReflect.Descriptor instead. +func (*InflationState) Descriptor() ([]byte, []int) { + return file_rewarding_proto_rawDescGZIP(), []int{7} +} + +func (x *InflationState) GetOutstandingSupply() string { + if x != nil { + return x.OutstandingSupply + } + return "" +} + +func (x *InflationState) GetOutstandingSupplyAtYearStart() string { + if x != nil { + return x.OutstandingSupplyAtYearStart + } + return "" +} + +func (x *InflationState) GetPostActivationMinted() string { + if x != nil { + return x.PostActivationMinted + } + return "" +} + +func (x *InflationState) GetCurrentInflationBps() uint64 { + if x != nil { + return x.CurrentInflationBps + } + return 0 +} + +func (x *InflationState) GetCurrentYearIndex() uint64 { + if x != nil { + return x.CurrentYearIndex + } + return 0 } +func (x *InflationState) GetDustStaker() string { + if x != nil { + return x.DustStaker + } + return "" +} + +func (x *InflationState) GetDustMachina() string { + if x != nil { + return x.DustMachina + } + return "" +} + +func (x *InflationState) GetYearMintRemainder() string { + if x != nil { + return x.YearMintRemainder + } + return "" +} + +func (x *InflationState) GetEpochRemainderAccumulator() string { + if x != nil { + return x.EpochRemainderAccumulator + } + return "" +} + +var File_rewarding_proto protoreflect.FileDescriptor + +const file_rewarding_proto_rawDesc = "" + + "\n" + + "\x0frewarding.proto\x12\vrewardingpb\"\xef\x02\n" + + "\x05Admin\x12 \n" + + "\vblockReward\x18\x01 \x01(\tR\vblockReward\x12 \n" + + "\vepochReward\x18\x02 \x01(\tR\vepochReward\x12>\n" + + "\x1anumDelegatesForEpochReward\x18\x03 \x01(\x04R\x1anumDelegatesForEpochReward\x12(\n" + + "\x0ffoundationBonus\x18\x04 \x01(\tR\x0ffoundationBonus\x12F\n" + + "\x1enumDelegatesForFoundationBonus\x18\x05 \x01(\x04R\x1enumDelegatesForFoundationBonus\x12:\n" + + "\x18foundationBonusLastEpoch\x18\x06 \x01(\x04R\x18foundationBonusLastEpoch\x124\n" + + "\x15productivityThreshold\x18\a \x01(\x04R\x15productivityThreshold\"V\n" + + "\x04Fund\x12\"\n" + + "\ftotalBalance\x18\x01 \x01(\tR\ftotalBalance\x12*\n" + + "\x10unclaimedBalance\x18\x02 \x01(\tR\x10unclaimedBalance\"'\n" + + "\rRewardHistory\x12\x16\n" + + "\x06height\x18\x01 \x01(\x04R\x06height\"#\n" + + "\aAccount\x12\x18\n" + + "\abalance\x18\x01 \x01(\tR\abalance\"\x1e\n" + + "\x06Exempt\x12\x14\n" + + "\x05addrs\x18\x01 \x03(\fR\x05addrs\"\xe2\x01\n" + + "\tRewardLog\x125\n" + + "\x04type\x18\x01 \x01(\x0e2!.rewardingpb.RewardLog.RewardTypeR\x04type\x12\x12\n" + + "\x04addr\x18\x02 \x01(\tR\x04addr\x12\x16\n" + + "\x06amount\x18\x03 \x01(\tR\x06amount\"r\n" + + "\n" + + "RewardType\x12\x10\n" + + "\fBLOCK_REWARD\x10\x00\x12\x10\n" + + "\fEPOCH_REWARD\x10\x01\x12\x14\n" + + "\x10FOUNDATION_BONUS\x10\x02\x12\x12\n" + + "\x0ePRIORITY_BONUS\x10\x03\x12\x16\n" + + "\x12UNPRODUCTIVE_SLASH\x10\x04\"8\n" + + "\n" + + "RewardLogs\x12*\n" + + "\x04logs\x18\x01 \x03(\v2\x16.rewardingpb.RewardLogR\x04logs\"\xc2\x03\n" + + "\x0eInflationState\x12,\n" + + "\x11outstandingSupply\x18\x01 \x01(\tR\x11outstandingSupply\x12B\n" + + "\x1coutstandingSupplyAtYearStart\x18\x02 \x01(\tR\x1coutstandingSupplyAtYearStart\x122\n" + + "\x14postActivationMinted\x18\x03 \x01(\tR\x14postActivationMinted\x120\n" + + "\x13currentInflationBps\x18\x04 \x01(\x04R\x13currentInflationBps\x12*\n" + + "\x10currentYearIndex\x18\x05 \x01(\x04R\x10currentYearIndex\x12\x1e\n" + + "\n" + + "dustStaker\x18\x06 \x01(\tR\n" + + "dustStaker\x12 \n" + + "\vdustMachina\x18\a \x01(\tR\vdustMachina\x12,\n" + + "\x11yearMintRemainder\x18\b \x01(\tR\x11yearMintRemainder\x12<\n" + + "\x19epochRemainderAccumulator\x18\t \x01(\tR\x19epochRemainderAccumulatorBJZHgithub.com/iotexproject/iotex-core/action/protocol/rewarding/rewardingpbb\x06proto3" + var ( - file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescOnce sync.Once - file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescData = file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDesc + file_rewarding_proto_rawDescOnce sync.Once + file_rewarding_proto_rawDescData []byte ) -func file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescGZIP() []byte { - file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescOnce.Do(func() { - file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescData = protoimpl.X.CompressGZIP(file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescData) +func file_rewarding_proto_rawDescGZIP() []byte { + file_rewarding_proto_rawDescOnce.Do(func() { + file_rewarding_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_rewarding_proto_rawDesc), len(file_rewarding_proto_rawDesc))) }) - return file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDescData + return file_rewarding_proto_rawDescData } -var file_action_protocol_rewarding_rewardingpb_rewarding_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes = make([]protoimpl.MessageInfo, 7) -var file_action_protocol_rewarding_rewardingpb_rewarding_proto_goTypes = []interface{}{ +var file_rewarding_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_rewarding_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_rewarding_proto_goTypes = []any{ (RewardLog_RewardType)(0), // 0: rewardingpb.RewardLog.RewardType (*Admin)(nil), // 1: rewardingpb.Admin (*Fund)(nil), // 2: rewardingpb.Fund @@ -575,8 +663,9 @@ var file_action_protocol_rewarding_rewardingpb_rewarding_proto_goTypes = []inter (*Exempt)(nil), // 5: rewardingpb.Exempt (*RewardLog)(nil), // 6: rewardingpb.RewardLog (*RewardLogs)(nil), // 7: rewardingpb.RewardLogs + (*InflationState)(nil), // 8: rewardingpb.InflationState } -var file_action_protocol_rewarding_rewardingpb_rewarding_proto_depIdxs = []int32{ +var file_rewarding_proto_depIdxs = []int32{ 0, // 0: rewardingpb.RewardLog.type:type_name -> rewardingpb.RewardLog.RewardType 6, // 1: rewardingpb.RewardLogs.logs:type_name -> rewardingpb.RewardLog 2, // [2:2] is the sub-list for method output_type @@ -586,114 +675,27 @@ var file_action_protocol_rewarding_rewardingpb_rewarding_proto_depIdxs = []int32 0, // [0:2] is the sub-list for field type_name } -func init() { file_action_protocol_rewarding_rewardingpb_rewarding_proto_init() } -func file_action_protocol_rewarding_rewardingpb_rewarding_proto_init() { - if File_action_protocol_rewarding_rewardingpb_rewarding_proto != nil { +func init() { file_rewarding_proto_init() } +func file_rewarding_proto_init() { + if File_rewarding_proto != nil { return } - if !protoimpl.UnsafeEnabled { - file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Admin); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Fund); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RewardHistory); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Account); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Exempt); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RewardLog); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RewardLogs); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDesc, + RawDescriptor: unsafe.Slice(unsafe.StringData(file_rewarding_proto_rawDesc), len(file_rewarding_proto_rawDesc)), NumEnums: 1, - NumMessages: 7, + NumMessages: 8, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_action_protocol_rewarding_rewardingpb_rewarding_proto_goTypes, - DependencyIndexes: file_action_protocol_rewarding_rewardingpb_rewarding_proto_depIdxs, - EnumInfos: file_action_protocol_rewarding_rewardingpb_rewarding_proto_enumTypes, - MessageInfos: file_action_protocol_rewarding_rewardingpb_rewarding_proto_msgTypes, + GoTypes: file_rewarding_proto_goTypes, + DependencyIndexes: file_rewarding_proto_depIdxs, + EnumInfos: file_rewarding_proto_enumTypes, + MessageInfos: file_rewarding_proto_msgTypes, }.Build() - File_action_protocol_rewarding_rewardingpb_rewarding_proto = out.File - file_action_protocol_rewarding_rewardingpb_rewarding_proto_rawDesc = nil - file_action_protocol_rewarding_rewardingpb_rewarding_proto_goTypes = nil - file_action_protocol_rewarding_rewardingpb_rewarding_proto_depIdxs = nil + File_rewarding_proto = out.File + file_rewarding_proto_goTypes = nil + file_rewarding_proto_depIdxs = nil } diff --git a/action/protocol/rewarding/rewardingpb/rewarding.proto b/action/protocol/rewarding/rewardingpb/rewarding.proto index 744d004696..2a8e54c692 100644 --- a/action/protocol/rewarding/rewardingpb/rewarding.proto +++ b/action/protocol/rewarding/rewardingpb/rewarding.proto @@ -52,3 +52,34 @@ message RewardLog { message RewardLogs { repeated RewardLog logs = 1; } + +// InflationState is the IIP-62 productive-inflation consensus state, persisted in +// state.RewardingNamespace under key "inf". All big-int fields are decimal-string +// serialized to stay consistent with Fund / Account / Admin. +message InflationState { + // OutstandingSupply is the running mint base for the disinflation curve. + // Seeded at activation from genesis.OutstandingSupplyAtActivation; grows by + // mTotal each block; may decrement via decrementOutstandingSupply (e.g. a + // future burn IIP wiring a base-fee burn site into the counter). + string outstandingSupply = 1; + // OutstandingSupplyAtYearStart is the §1.2 snapshot used for the entire year. + string outstandingSupplyAtYearStart = 2; + // PostActivationMinted is the cumulative mint since activation (analytics). + string postActivationMinted = 3; + // CurrentInflationBps is the effective annual rate, refreshed at year boundaries. + uint64 currentInflationBps = 4; + // CurrentYearIndex is the 1-indexed year matching YearIndex(). Stored so a + // reorg crossing a year boundary can detect the crossing and re-derive. + uint64 currentYearIndex = 5; + // DustStaker / DustMachina are the per-share Rau·bps dust accumulators used + // by SplitMint so per-block truncation does not silently bias the split. + string dustStaker = 6; + string dustMachina = 7; + // YearMintRemainder is the per-year leftover (annualMint mod blocksPerYear) + // flushed on the year's final block per §4.1. + string yearMintRemainder = 8; + // EpochRemainderAccumulator carries (mStaker − effective_block_reward) across + // the blocks of an epoch; consumed and reset at the epoch's last block by + // GrantEpochReward (replaces the old constant Admin.epochReward). + string epochRemainderAccumulator = 9; +} diff --git a/blockchain/genesis/genesis.go b/blockchain/genesis/genesis.go index b2d486a751..1d588a7d79 100644 --- a/blockchain/genesis/genesis.go +++ b/blockchain/genesis/genesis.go @@ -163,6 +163,16 @@ func defaultConfig() Genesis { FoundationBonusP2EndEpoch: 18458, ProductivityThreshold: 85, WakeBlockRewardStr: "4000000000000000000", + // IIP-62 productive inflation defaults (5-20-Half curve, fixed 80/20 split). + // MachinaDaoAddress and OutstandingSupplyAtActivation must be set per-network + // in the YAML override; the protocol panics on activation if they are empty. + InflationRateY1Bps: 500, + InflationDecayNumerator: 8000, + InflationDecayDenominator: 10000, + InflationFloorBps: 50, + BlocksPerYear: 12614400, + StakerShareBps: 8000, + MachinaShareBps: 2000, }, Staking: Staking{ VoteWeightCalConsts: VoteWeightCalConsts{ @@ -390,8 +400,11 @@ type ( YapBlockHeight uint64 `yaml:"yapHeight"` // YapBetaBlockHeight is the start height to enable slashing candidate by identity YapBetaBlockHeight uint64 `yaml:"yapBetaHeight"` - // ToBeEnabledBlockHeight is a fake height that acts as a gating factor for WIP features - // upon next release, change IsToBeEnabled() to IsNextHeight() for features to be released + // ToBeEnabledBlockHeight is a fake height that acts as a gating factor for WIP features. + // IIP-62 productive inflation (per-block protocol mint on the 5-20-Half disinflation + // curve, 80/20 split between stakers and Machina DAO) currently uses this gate; when the + // hardfork is named, point IIP-62 at the named height instead. + // Upon next release, change IsToBeEnabled() to IsNextHeight() for features to be released ToBeEnabledBlockHeight uint64 `yaml:"toBeEnabledHeight"` } // Account contains the configs for account protocol @@ -493,6 +506,38 @@ type ( ProductivityThreshold uint64 `yaml:"productivityThreshold"` // WakeBlockReward is the block reward amount starts from wake height in decimal string format WakeBlockRewardStr string `yaml:"wakeBlockRewardStr"` + + // --- IIP-62 productive inflation parameters (activated at ProductiveInflationBlockHeight) --- + + // InflationRateY1Bps is the Year-1 inflation rate in basis points (1 bps = 0.01%). + // Spec default: 500 (5.00%). + InflationRateY1Bps uint64 `yaml:"inflationRateY1Bps"` + // InflationDecayNumerator and InflationDecayDenominator together form the rational + // per-year decay factor applied to the inflation rate (Y_n rate = Y1 * (num/denom)^(n-1)). + // Spec default: 8000 / 10000 (i.e. -20%/yr compounded). + InflationDecayNumerator uint64 `yaml:"inflationDecayNumerator"` + InflationDecayDenominator uint64 `yaml:"inflationDecayDenominator"` + // InflationFloorBps is the permanent lower bound on the per-year inflation rate. + // Spec default: 50 (0.50%). + InflationFloorBps uint64 `yaml:"inflationFloorBps"` + // BlocksPerYear is the consensus constant used to convert annual inflation into + // per-block mint. Spec default: 12_614_400 (2.5s blocks × 365 days). + BlocksPerYear uint64 `yaml:"blocksPerYear"` + // StakerShareBps is the per-block share of the inflation mint routed to the staker + // reward pool. Spec default: 8000 (80%). Must sum to 10000 with MachinaShareBps. + StakerShareBps uint64 `yaml:"stakerShareBps"` + // MachinaShareBps is the per-block share of the inflation mint routed to the + // Machina DAO recipient address. Spec default: 2000 (20%). + MachinaShareBps uint64 `yaml:"machinaShareBps"` + // MachinaDaoAddress is the externally-managed recipient (multisig or otherwise) + // of the Machina DAO share. This codebase only credits balance to it; the + // address itself is created and governed out of band. + MachinaDaoAddress string `yaml:"machinaDaoAddress"` + // OutstandingSupplyAtActivation is the snapshot of the on-chain non-zero-address + // balance sum at the parent of ProductiveInflationBlockHeight, in Rau as a decimal + // string. Produced by the snapshotexporter tool. This seeds the OutstandingSupply + // counter used as the inflation-curve mint base. + OutstandingSupplyAtActivation string `yaml:"outstandingSupplyAtActivation"` } // Staking contains the configs for staking protocol Staking struct { @@ -792,7 +837,9 @@ func (g *Blockchain) IsYapBeta(height uint64) bool { return g.isPost(g.YapBetaBlockHeight, height) } -// IsToBeEnabled checks whether height is equal to or larger than toBeEnabled height +// IsToBeEnabled checks whether height is equal to or larger than toBeEnabled height. +// IIP-62 productive inflation currently gates on this height; rename to a dedicated +// IsProductiveInflation when the hardfork is named. func (g *Blockchain) IsToBeEnabled(height uint64) bool { return g.isPost(g.ToBeEnabledBlockHeight, height) } @@ -939,6 +986,26 @@ func (r *Rewarding) WakeBlockReward() *big.Int { return val } +// MachinaDaoAddr returns the IIP-62 Machina DAO recipient address. +// Panics if the configured value is malformed; genesis validation should reject it earlier. +func (r *Rewarding) MachinaDaoAddr() address.Address { + addr, err := address.FromString(r.MachinaDaoAddress) + if err != nil { + log.L().Panic("Error when decoding the Machina DAO address from string.", zap.Error(err)) + } + return addr +} + +// OutstandingSupplyAtActivationBig returns the IIP-62 OutstandingSupplyAtActivation +// value parsed as a big integer (in Rau). +func (r *Rewarding) OutstandingSupplyAtActivationBig() *big.Int { + val, ok := new(big.Int).SetString(r.OutstandingSupplyAtActivation, 10) + if !ok { + log.S().Panicf("Error when casting outstanding supply at activation string %s into big int", r.OutstandingSupplyAtActivation) + } + return val +} + // ExemptAddrsFromEpochReward returns the list of addresses that exempt from epoch reward func (r *Rewarding) ExemptAddrsFromEpochReward() []address.Address { addrs := make([]address.Address, 0) diff --git a/go.mod b/go.mod index da789b389c..3d7b02ce22 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/iotexproject/iotex-address v0.2.9-0.20251203033311-6e8aa4fd43ef github.com/iotexproject/iotex-antenna-go/v2 v2.6.4 github.com/iotexproject/iotex-election v0.3.8-0.20251015031218-8df952babca1 - github.com/iotexproject/iotex-proto v0.6.6-0.20260211020747-f26bd969ed16 + github.com/iotexproject/iotex-proto v0.6.6-0.20260529071547-ac415d5ce67a github.com/ipfs/go-ipfs-api v0.7.0 github.com/libp2p/go-libp2p v0.46.0 github.com/libp2p/go-libp2p-pubsub v0.13.0 diff --git a/go.sum b/go.sum index 67e41bec79..a386bee51c 100644 --- a/go.sum +++ b/go.sum @@ -600,6 +600,8 @@ github.com/iotexproject/iotex-election v0.3.8-0.20251015031218-8df952babca1 h1:j github.com/iotexproject/iotex-election v0.3.8-0.20251015031218-8df952babca1/go.mod h1:w9HriT1coMRbuknaSD2xqiOqDTnowBDzvFZv8tg1j2M= github.com/iotexproject/iotex-proto v0.6.6-0.20260211020747-f26bd969ed16 h1:iaFjQ8QJ3ekZnwPlUX3XJHZ/5uf+FbUltH6o24d4NYA= github.com/iotexproject/iotex-proto v0.6.6-0.20260211020747-f26bd969ed16/go.mod h1:OOXZIG6Q9tInog8Y5zzEJQsDv9IaG/xxpDtl4KzdWZs= +github.com/iotexproject/iotex-proto v0.6.6-0.20260529071547-ac415d5ce67a h1:/mK70lqN1C/OS+MxXjbBOTsUrkiSw7ttTp+CAKzPLws= +github.com/iotexproject/iotex-proto v0.6.6-0.20260529071547-ac415d5ce67a/go.mod h1:OOXZIG6Q9tInog8Y5zzEJQsDv9IaG/xxpDtl4KzdWZs= github.com/ipfs/boxo v0.27.2 h1:sGo4KdwBaMjdBjH08lqPJyt27Z4CO6sugne3ryX513s= github.com/ipfs/boxo v0.27.2/go.mod h1:qEIRrGNr0bitDedTCzyzBHxzNWqYmyuHgK8LG9Q83EM= github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= From 997d6494da0c40f05962c4f9c2e799dc0594dfd4 Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 5 Jun 2026 00:47:06 +0800 Subject: [PATCH 2/4] fix(rewarding): IIP-62 spec consistency pass - Pre-stage Y1 yearMintRemainder in initInflationState so the final-block flush is non-zero (the boundary branch only fires from Y2+). - Cache parsed MachinaDaoAddress on *Protocol; mintAndAllocate no longer reparses the bech32 string each block. - Document Machina credit semantics: pure AddBalance, no receive() / fallback() runs - DAO contract must tolerate passive balance accrual. - Add TestMintAndAllocate_FundInvariant covering high-mint and floor regimes; default MachinaDaoAddress in testProtocol harness. - Drop unused decrementOutstandingSupply / MachinaDaoAddr helper; rewrite base-fee comments to reflect IoTeX's deposit-to-pool reality (not burn). Co-Authored-By: Claude Opus 4.7 --- action/protocol/protocol.go | 8 +- action/protocol/rewarding/inflation_state.go | 26 +++-- action/protocol/rewarding/inflation_test.go | 110 +++++++++++++++--- action/protocol/rewarding/protocol.go | 15 ++- action/protocol/rewarding/protocol_test.go | 3 + .../rewarding/rewardingpb/rewarding.pb.go | 5 +- .../rewarding/rewardingpb/rewarding.proto | 5 +- blockchain/genesis/genesis.go | 16 +-- 8 files changed, 143 insertions(+), 45 deletions(-) diff --git a/action/protocol/protocol.go b/action/protocol/protocol.go index a3df97f38a..6c177a5e2d 100644 --- a/action/protocol/protocol.go +++ b/action/protocol/protocol.go @@ -109,7 +109,8 @@ func BlobGasFeeOption(blobGasFee *big.Int) DepositOption { } type ( - // DepositGas deposits gas to rewarding pool and burns baseFee + // DepositGas deposits gas (base fee + priority fee) into the rewarding pool. + // IoTeX does not burn base fee; it is credited to the pool like priority fee. DepositGas func(context.Context, StateManager, *big.Int, ...DepositOption) ([]*action.TransactionLog, error) View interface { @@ -225,9 +226,8 @@ func SplitGas(ctx context.Context, tx action.TxDynamicGas, usedGas uint64) (*big if err != nil { return nil, nil, err } - // after enabling EIP-1559, fee is split into 2 parts - // priority fee goes to the rewarding pool (or block producer) as before - // base fee will be burnt + // after enabling EIP-1559, fee is split into 2 parts; both go to the + // rewarding pool (IoTeX does not burn base fee). base := new(big.Int).Set(baseFee) return priority.Mul(priority, gas), base.Mul(base, gas), nil } diff --git a/action/protocol/rewarding/inflation_state.go b/action/protocol/rewarding/inflation_state.go index 1c278feefc..40d0222631 100644 --- a/action/protocol/rewarding/inflation_state.go +++ b/action/protocol/rewarding/inflation_state.go @@ -152,6 +152,12 @@ func (p *Protocol) initInflationState(ctx context.Context, sm protocol.StateMana s.outstandingSupplyAtYearStart.Set(supply) s.currentInflationBps = cfg.InflationRateY1Bps s.currentYearIndex = 1 + // Pre-stage Y1's year-end remainder here — mintAndAllocate's boundary branch + // only fires when year != currentYearIndex (Y2+), so without this seed the Y1 + // final-block flush would add zero and Y1 mint would fall short by up to + // blocksPerYear-1 Rau. + _, rem := PerBlockMint(s.outstandingSupplyAtYearStart, s.currentInflationBps, cfg.BlocksPerYear) + s.yearMintRemainder.Set(rem) return p.putState(ctx, sm, _inflKey, s) } @@ -300,8 +306,15 @@ func (p *Protocol) mintAndAllocate(ctx context.Context, sm protocol.StateManager // Credit Machina share to the externally-managed recipient account. LoadOrCreate // only allocates a state record on first credit; the multisig owning this // address is created and governed out of band. + // + // This is a pure balance credit — no EVM call is made into the recipient. If the + // Machina DAO is implemented as a smart contract, its `receive()`/`fallback()` + // will NOT be invoked; the contract must therefore be designed to tolerate + // passive balance accrual (a multisig / Gnosis-Safe-style account does this + // natively; a custom DAO contract that relies on receive() to update internal + // accounting would not see this credit). if mMachina.Sign() > 0 { - machinaAddr := cfg.MachinaDaoAddr() + machinaAddr := p.machinaAddr accountCreationOpts := []state.AccountCreationOption{} if protocol.MustGetFeatureCtx(ctx).CreateLegacyNonceAccount { accountCreationOpts = append(accountCreationOpts, state.LegacyNonceAccountTypeOption()) @@ -384,17 +397,6 @@ func (p *Protocol) CurrentInflationBps(ctx context.Context, sm protocol.StateRea return s.currentInflationBps, height, nil } -// decrementOutstandingSupply subtracts amount from outstandingSupply. Reserved for a -// future burn-side IIP (e.g. wiring EIP-1559 base-fee burns into the counter) — not -// used by IIP-62 itself. Kept as a helper so external callers don't reach into the -// struct. -func (s *inflationState) decrementOutstandingSupply(amount *big.Int) { - if amount == nil || amount.Sign() <= 0 { - return - } - s.outstandingSupply.Sub(s.outstandingSupply, amount) -} - func bigToStr(v *big.Int) string { if v == nil { return "0" diff --git a/action/protocol/rewarding/inflation_test.go b/action/protocol/rewarding/inflation_test.go index 1b5e9db3ec..27affd023d 100644 --- a/action/protocol/rewarding/inflation_test.go +++ b/action/protocol/rewarding/inflation_test.go @@ -264,20 +264,6 @@ func TestInflationState_RoundTrip(t *testing.T) { require.Equal(t, 0, s.epochRemainderAccumulator.Cmp(out.epochRemainderAccumulator)) } -func TestInflationState_DecrementOutstandingSupply(t *testing.T) { - s := newInflationState() - s.outstandingSupply.SetUint64(1000) - s.decrementOutstandingSupply(big.NewInt(250)) - require.Equal(t, uint64(750), s.outstandingSupply.Uint64()) - // nil and non-positive are no-ops - s.decrementOutstandingSupply(nil) - require.Equal(t, uint64(750), s.outstandingSupply.Uint64()) - s.decrementOutstandingSupply(big.NewInt(-100)) - require.Equal(t, uint64(750), s.outstandingSupply.Uint64()) - s.decrementOutstandingSupply(big.NewInt(0)) - require.Equal(t, uint64(750), s.outstandingSupply.Uint64()) -} - func TestValidateInflationConfig(t *testing.T) { valid := &genesis.Rewarding{ InflationRateY1Bps: 500, @@ -495,6 +481,102 @@ func TestGrantEpochReward_UsesAccumulator(t *testing.T) { }, nil, false, 0) } +// IIP-62 §4.1 invariant: the rewarding Fund must never underflow across the +// per-block mint + block-reward debit cycle. This test walks many post-activation +// blocks and, after each GrantBlockReward, asserts: +// - totalBalance >= unclaimedBalance (Claim has not run, so this must always hold) +// - unclaimedBalance >= 0 (the floor-regime clamp prevents the debit from exceeding +// the mint credit; without the clamp this would underflow once mStaker < blockReward) +// - postActivationMinted == sum of per-block mTotal credited (no double-count, no drop) +// +// Two regimes are covered as sub-tests: +// +// high-mint: supply=100 IOTX × Y1=100%/year → mStaker ≫ a.blockReward (10 rau); +// the producer grant is bounded by blockReward and the rest accrues to fund. +// floor: supply=1000 rau × Y1=100%/year → mStaker (≈8 rau) < blockReward (10 rau); +// the step-F clamp kicks in and the producer is paid mStaker (not blockReward), +// so unclaimedBalance stays at zero block-over-block instead of underflowing. +func TestMintAndAllocate_FundInvariant(t *testing.T) { + machinaAddr := identityset.Address(33).String() + const blocksPerYear = 100 + const numBlocks = 25 + + cases := []struct { + name string + supply string // OutstandingSupplyAtActivation, in rau + regimeIs string // "high" or "floor" — annotates failure messages + }{ + {"high_mint_regime", "100000000000000000000", "high"}, // 100 IOTX + {"floor_regime", "1000", "floor"}, // 1000 rau → mStaker < blockReward(10) + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testProtocol(t, func(t *testing.T, ctx context.Context, sm protocol.StateManager, p *Protocol) { + req := require.New(t) + g := genesis.MustExtractGenesisContext(ctx) + g.Rewarding.InflationRateY1Bps = 10000 + g.Rewarding.InflationDecayNumerator = 8000 + g.Rewarding.InflationDecayDenominator = 10000 + g.Rewarding.InflationFloorBps = 50 + g.Rewarding.BlocksPerYear = blocksPerYear + g.Rewarding.StakerShareBps = 8000 + g.Rewarding.MachinaShareBps = 2000 + g.Rewarding.MachinaDaoAddress = machinaAddr + g.Rewarding.OutstandingSupplyAtActivation = tc.supply + + blkCtx := protocol.MustGetBlockCtx(ctx) + activation := blkCtx.BlockHeight + g.ToBeEnabledBlockHeight = activation + ctx = genesis.WithGenesisContext(ctx, g) + ctx = protocol.WithFeatureCtx(ctx) + + req.NoError(p.CreatePreStates(ctx, sm)) + + expectedMinted := new(big.Int) + for i := 1; i <= numBlocks; i++ { + blkCtx.BlockHeight = activation + uint64(i) + ctx = protocol.WithBlockCtx(ctx, blkCtx) + ctx = genesis.WithGenesisContext(ctx, g) + ctx = protocol.WithFeatureCtx(ctx) + + // Snapshot inflation state pre-grant so we can compute the expected + // per-block mint credit independently of the protocol's accounting. + infPre := newInflationState() + _, err := p.state(ctx, sm, _inflKey, infPre) + req.NoError(err) + perBlock, _ := PerBlockMint(infPre.outstandingSupplyAtYearStart, infPre.currentInflationBps, blocksPerYear) + mTotal := new(big.Int).Set(perBlock) + if IsYearFinalBlock(activation, blocksPerYear, blkCtx.BlockHeight) { + mTotal.Add(mTotal, infPre.yearMintRemainder) + } + expectedMinted.Add(expectedMinted, mTotal) + + _, _, err = p.GrantBlockReward(ctx, sm) + req.NoErrorf(err, "%s: GrantBlockReward failed at block %d", tc.regimeIs, blkCtx.BlockHeight) + + // Core invariant: total >= unclaimed >= 0 after every block. + total, _, err := p.TotalBalance(ctx, sm) + req.NoError(err) + unclaimed, _, err := p.AvailableBalance(ctx, sm) + req.NoError(err) + req.Truef(total.Sign() >= 0, "%s: totalBalance went negative at block %d: %s", tc.regimeIs, blkCtx.BlockHeight, total) + req.Truef(unclaimed.Sign() >= 0, "%s: unclaimedBalance underflowed at block %d: %s", tc.regimeIs, blkCtx.BlockHeight, unclaimed) + req.Truef(total.Cmp(unclaimed) >= 0, "%s: totalBalance < unclaimedBalance at block %d (t=%s u=%s)", tc.regimeIs, blkCtx.BlockHeight, total, unclaimed) + + // postActivationMinted must equal our independently summed mTotal. + infPost := newInflationState() + _, err = p.state(ctx, sm, _inflKey, infPost) + req.NoError(err) + req.Equalf(0, infPost.postActivationMinted.Cmp(expectedMinted), + "%s: postActivationMinted drift at block %d (got %s want %s)", + tc.regimeIs, blkCtx.BlockHeight, infPost.postActivationMinted, expectedMinted) + } + }, nil, false, 0) + }) + } +} + func mustAddr(s string) address.Address { a, err := address.FromString(s) if err != nil { diff --git a/action/protocol/rewarding/protocol.go b/action/protocol/rewarding/protocol.go index 4f33934d8c..f974f9bc99 100644 --- a/action/protocol/rewarding/protocol.go +++ b/action/protocol/rewarding/protocol.go @@ -50,6 +50,11 @@ type Protocol struct { keyPrefix []byte addr address.Address cfg genesis.Rewarding + // machinaAddr is the IIP-62 Machina DAO recipient pre-parsed once at construction + // so the per-block mintAndAllocate hot path does not reparse the bech32 string. + // nil when MachinaDaoAddress is empty in genesis (pre-activation networks); the + // activation-time validateInflationConfig check rejects an empty address. + machinaAddr address.Address } // NewProtocol instantiates a rewarding protocol instance. @@ -62,11 +67,19 @@ func NewProtocol(cfg genesis.Rewarding) *Protocol { if err = validateFoundationBonusExtension(cfg); err != nil { log.L().Panic("failed to validate foundation bonus extension", zap.Error(err)) } - return &Protocol{ + p := &Protocol{ keyPrefix: state.RewardingKeyPrefix[:], addr: addr, cfg: cfg, } + if cfg.MachinaDaoAddress != "" { + machinaAddr, err := address.FromString(cfg.MachinaDaoAddress) + if err != nil { + log.L().Panic("Error parsing MachinaDaoAddress", zap.Error(err)) + } + p.machinaAddr = machinaAddr + } + return p } // ProtocolAddr returns the address generated from protocol id diff --git a/action/protocol/rewarding/protocol_test.go b/action/protocol/rewarding/protocol_test.go index b919fab419..f4298d0ccb 100644 --- a/action/protocol/rewarding/protocol_test.go +++ b/action/protocol/rewarding/protocol_test.go @@ -73,6 +73,9 @@ func testProtocol(t *testing.T, test func(*testing.T, context.Context, protocol. g.Rewarding.NumDelegatesForFoundationBonus = 5 g.Rewarding.FoundationBonusLastEpoch = 365 g.Rewarding.ProductivityThreshold = 50 + // Default MachinaDaoAddress so NewProtocol can pre-parse it. IIP-62 tests that + // activate the inflation gate override this address and the rest of the cfg. + g.Rewarding.MachinaDaoAddress = identityset.Address(33).String() g.XinguBlockHeight = slashHeight // Initialize the protocol if withExempt { diff --git a/action/protocol/rewarding/rewardingpb/rewarding.pb.go b/action/protocol/rewarding/rewardingpb/rewarding.pb.go index b7fcd1b631..1fd2026756 100644 --- a/action/protocol/rewarding/rewardingpb/rewarding.pb.go +++ b/action/protocol/rewarding/rewardingpb/rewarding.pb.go @@ -471,8 +471,9 @@ type InflationState struct { state protoimpl.MessageState `protogen:"open.v1"` // OutstandingSupply is the running mint base for the disinflation curve. // Seeded at activation from genesis.OutstandingSupplyAtActivation; grows by - // mTotal each block; may decrement via decrementOutstandingSupply (e.g. a - // future burn IIP wiring a base-fee burn site into the counter). + // mTotal each block. IIP-62 only adds to this counter; IoTeX's EIP-1559 + // base fee is deposited to the rewarding pool, not burned, so there is no + // burn-side debit in this proposal. OutstandingSupply string `protobuf:"bytes,1,opt,name=outstandingSupply,proto3" json:"outstandingSupply,omitempty"` // OutstandingSupplyAtYearStart is the §1.2 snapshot used for the entire year. OutstandingSupplyAtYearStart string `protobuf:"bytes,2,opt,name=outstandingSupplyAtYearStart,proto3" json:"outstandingSupplyAtYearStart,omitempty"` diff --git a/action/protocol/rewarding/rewardingpb/rewarding.proto b/action/protocol/rewarding/rewardingpb/rewarding.proto index 2a8e54c692..57bc6ad2cd 100644 --- a/action/protocol/rewarding/rewardingpb/rewarding.proto +++ b/action/protocol/rewarding/rewardingpb/rewarding.proto @@ -59,8 +59,9 @@ message RewardLogs { message InflationState { // OutstandingSupply is the running mint base for the disinflation curve. // Seeded at activation from genesis.OutstandingSupplyAtActivation; grows by - // mTotal each block; may decrement via decrementOutstandingSupply (e.g. a - // future burn IIP wiring a base-fee burn site into the counter). + // mTotal each block. IIP-62 only adds to this counter; IoTeX's EIP-1559 + // base fee is deposited to the rewarding pool, not burned, so there is no + // burn-side debit in this proposal. string outstandingSupply = 1; // OutstandingSupplyAtYearStart is the §1.2 snapshot used for the entire year. string outstandingSupplyAtYearStart = 2; diff --git a/blockchain/genesis/genesis.go b/blockchain/genesis/genesis.go index 1d588a7d79..2e6cb7828c 100644 --- a/blockchain/genesis/genesis.go +++ b/blockchain/genesis/genesis.go @@ -532,6 +532,12 @@ type ( // MachinaDaoAddress is the externally-managed recipient (multisig or otherwise) // of the Machina DAO share. This codebase only credits balance to it; the // address itself is created and governed out of band. + // + // Credit semantics: balance-only. mintAndAllocate calls AddBalance on the + // account; it does NOT invoke any contract code at this address. If the + // recipient is a smart contract, its receive()/fallback() will NOT run. + // Any DAO contract placed here must tolerate passive balance accrual (a + // multisig / Gnosis-Safe-style account does this natively). MachinaDaoAddress string `yaml:"machinaDaoAddress"` // OutstandingSupplyAtActivation is the snapshot of the on-chain non-zero-address // balance sum at the parent of ProductiveInflationBlockHeight, in Rau as a decimal @@ -986,16 +992,6 @@ func (r *Rewarding) WakeBlockReward() *big.Int { return val } -// MachinaDaoAddr returns the IIP-62 Machina DAO recipient address. -// Panics if the configured value is malformed; genesis validation should reject it earlier. -func (r *Rewarding) MachinaDaoAddr() address.Address { - addr, err := address.FromString(r.MachinaDaoAddress) - if err != nil { - log.L().Panic("Error when decoding the Machina DAO address from string.", zap.Error(err)) - } - return addr -} - // OutstandingSupplyAtActivationBig returns the IIP-62 OutstandingSupplyAtActivation // value parsed as a big integer (in Rau). func (r *Rewarding) OutstandingSupplyAtActivationBig() *big.Int { From f4b35cf74edb880436ff8f51a9f0054cd5847bb5 Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 5 Jun 2026 21:52:30 +0800 Subject: [PATCH 3/4] test(rewarding): IIP-62 reorg-safety at year boundary Add TestMintAndAllocate_ReorgSafe_YearBoundary: walk Y1 to its last block, snapshot (inflation, fund, machina) state, execute the Y2-first block, then restore pre-boundary state and re-execute. Assert post-states are byte- identical. Covers the validator-orphan case where the boundary block is re-applied; relies on persisted currentYearIndex to make the boundary branch re-fire deterministically. Co-Authored-By: Claude Opus 4.7 --- action/protocol/rewarding/inflation_test.go | 128 ++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/action/protocol/rewarding/inflation_test.go b/action/protocol/rewarding/inflation_test.go index 27affd023d..c720318549 100644 --- a/action/protocol/rewarding/inflation_test.go +++ b/action/protocol/rewarding/inflation_test.go @@ -577,6 +577,134 @@ func TestMintAndAllocate_FundInvariant(t *testing.T) { } } +// IIP-62 reorg-safety: a validator that re-executes a year-boundary block (e.g. +// after an orphan) must produce byte-identical InflationState. The crossing +// branch in mintAndAllocate fires when YearIndex(height) != currentYearIndex — +// persisting currentYearIndex is what makes the branch re-fire deterministically +// on re-execution. +// +// Strategy: drive state to "last block of Y1," capture the (inflation, fund, +// machinaBalance) tuple, then run mintAndAllocate at the Y2-first block twice +// with a full state restore between runs. Assert the two post-states are equal. +func TestMintAndAllocate_ReorgSafe_YearBoundary(t *testing.T) { + machinaAddr := identityset.Address(33).String() + const ( + blocksPerYear = 100 + // Pick a supply chunky enough that the Y2 boundary actually moves the snapshot + // and the per-block mint is non-trivial (so equality is a meaningful assertion). + supplyRau = "100000000000000000000" // 100 IOTX + ) + + testProtocol(t, func(t *testing.T, ctx context.Context, sm protocol.StateManager, p *Protocol) { + req := require.New(t) + g := genesis.MustExtractGenesisContext(ctx) + g.Rewarding.InflationRateY1Bps = 10000 + g.Rewarding.InflationDecayNumerator = 8000 + g.Rewarding.InflationDecayDenominator = 10000 + g.Rewarding.InflationFloorBps = 50 + g.Rewarding.BlocksPerYear = blocksPerYear + g.Rewarding.StakerShareBps = 8000 + g.Rewarding.MachinaShareBps = 2000 + g.Rewarding.MachinaDaoAddress = machinaAddr + g.Rewarding.OutstandingSupplyAtActivation = supplyRau + + blkCtx := protocol.MustGetBlockCtx(ctx) + activation := blkCtx.BlockHeight + g.ToBeEnabledBlockHeight = activation + ctx = genesis.WithGenesisContext(ctx, g) + ctx = protocol.WithFeatureCtx(ctx) + req.NoError(p.CreatePreStates(ctx, sm)) + + // Walk Y1 to its final block via mintAndAllocate. Activation height itself is + // Y1's first block, so Y1's last block is activation + blocksPerYear - 1; the + // loop covers activation+1 .. activation+blocksPerYear-1. Skip the producer- + // grant debit since we only need to compare the inflation/fund/machina shape + // that drives the boundary branch. + for i := uint64(1); i < blocksPerYear; i++ { + blkCtx.BlockHeight = activation + i + ctx = protocol.WithBlockCtx(ctx, blkCtx) + ctx = genesis.WithGenesisContext(ctx, g) + ctx = protocol.WithFeatureCtx(ctx) + _, _, err := p.mintAndAllocate(ctx, sm) + req.NoErrorf(err, "Y1 walk at block %d", blkCtx.BlockHeight) + } + + // Sanity: end of Y1 — currentYearIndex must still be 1 (boundary not yet crossed). + preInf := newInflationState() + _, err := p.state(ctx, sm, _inflKey, preInf) + req.NoError(err) + req.Equalf(uint64(1), preInf.currentYearIndex, "expected end-of-Y1 currentYearIndex=1, got %d", preInf.currentYearIndex) + + // Snapshot full pre-boundary state. + preInfBytes, err := preInf.Serialize() + req.NoError(err) + preFund := fund{} + _, err = p.state(ctx, sm, _fundKey, &preFund) + req.NoError(err) + preFundTotal := new(big.Int).Set(preFund.totalBalance) + preFundUnclaimed := new(big.Int).Set(preFund.unclaimedBalance) + preMachina, err := accountutil.LoadAccount(sm, mustAddr(machinaAddr)) + req.NoError(err) + preMachinaBal := new(big.Int).Set(preMachina.Balance) + + // Step to Y2-first block. + boundary := activation + blocksPerYear + blkCtx.BlockHeight = boundary + ctx = protocol.WithBlockCtx(ctx, blkCtx) + ctx = genesis.WithGenesisContext(ctx, g) + ctx = protocol.WithFeatureCtx(ctx) + + // Run A: original execution of the boundary block. + _, _, err = p.mintAndAllocate(ctx, sm) + req.NoError(err) + postA := newInflationState() + _, err = p.state(ctx, sm, _inflKey, postA) + req.NoError(err) + req.Equalf(uint64(2), postA.currentYearIndex, "boundary branch did not fire; currentYearIndex=%d", postA.currentYearIndex) + postAInfBytes, err := postA.Serialize() + req.NoError(err) + postAFund := fund{} + _, err = p.state(ctx, sm, _fundKey, &postAFund) + req.NoError(err) + postAMachina, err := accountutil.LoadAccount(sm, mustAddr(machinaAddr)) + req.NoError(err) + + // Restore pre-boundary state — simulate the orphan being rolled back. + restored := newInflationState() + req.NoError(restored.Deserialize(preInfBytes)) + req.NoError(p.putState(ctx, sm, _inflKey, restored)) + preFund.totalBalance = preFundTotal + preFund.unclaimedBalance = preFundUnclaimed + req.NoError(p.putState(ctx, sm, _fundKey, &preFund)) + preMachina.Balance = preMachinaBal + req.NoError(accountutil.StoreAccount(sm, mustAddr(machinaAddr), preMachina)) + + // Run B: re-execution of the same boundary block on restored state. + _, _, err = p.mintAndAllocate(ctx, sm) + req.NoError(err) + postB := newInflationState() + _, err = p.state(ctx, sm, _inflKey, postB) + req.NoError(err) + postBInfBytes, err := postB.Serialize() + req.NoError(err) + postBFund := fund{} + _, err = p.state(ctx, sm, _fundKey, &postBFund) + req.NoError(err) + postBMachina, err := accountutil.LoadAccount(sm, mustAddr(machinaAddr)) + req.NoError(err) + + // Byte-identical inflation state across reorg. + req.Equal(postAInfBytes, postBInfBytes, "InflationState diverged across reorg of year-boundary block") + // Spot-check the derived balances too. + req.Equalf(0, postAFund.totalBalance.Cmp(postBFund.totalBalance), + "fund.totalBalance diverged: A=%s B=%s", postAFund.totalBalance, postBFund.totalBalance) + req.Equalf(0, postAFund.unclaimedBalance.Cmp(postBFund.unclaimedBalance), + "fund.unclaimedBalance diverged: A=%s B=%s", postAFund.unclaimedBalance, postBFund.unclaimedBalance) + req.Equalf(0, postAMachina.Balance.Cmp(postBMachina.Balance), + "machina balance diverged: A=%s B=%s", postAMachina.Balance, postBMachina.Balance) + }, nil, false, 0) +} + func mustAddr(s string) address.Address { a, err := address.FromString(s) if err != nil { From 6beaf0c96db23cf0315f06daf13f1fd9f2da9762 Mon Sep 17 00:00:00 2001 From: zhi Date: Thu, 18 Jun 2026 08:53:51 +0800 Subject: [PATCH 4/4] refactor(rewarding): derive per-block IIP-62 inflation state, slim to year-boundary writes InflationState was putState'd every block because several fields mutated per-block. They are all deterministic, so store only the year-boundary fields and derive the rest, dropping the per-block _inflKey write (archive growth for this key goes from per-block to once/year). - SplitMint is now stateless: staker = floor(mTotal*bps/denom), Machina = mTotal - staker. Conserves mTotal exactly; drops the dustStaker/dustMachina accumulators (sub-Rau per-block bias, negligible over the chain's lifetime). - outstandingSupply, postActivationMinted, epochRemainderAccumulator, and yearMintRemainder are no longer stored. OutstandingSupply/PostActivationMinted are derived via CumulativeMinted (snapshot + query height from p.state); the year-end remainder is recomputed on the final block; the epoch surplus is derived in closed form via EpochInflationSurplus over the epoch's block range. - mintAndAllocate persists InflationState only on year-boundary blocks; the year-start snapshot is recomputed via the genesis recurrence (ComputeYearStartSupply), which is reorg-deterministic. - proto: reserve fields 1,3,6,7,8,9; InflationState keeps only outstandingSupplyAtYearStart, currentInflationBps, currentYearIndex. Note: EpochInflationSurplus assumes a constant block reward across an epoch (holds post-Wake, where IIP-62 activates). Tests: brute-force cross-checks for CumulativeMinted and EpochInflationSurplus (single-year, year-final, straddle, pre-activation); end-to-end getter coverage via a fixed-height StateReader; fund-invariant and reorg tests updated. Co-Authored-By: Claude Opus 4.8 --- action/protocol/rewarding/inflation.go | 150 +++++++- action/protocol/rewarding/inflation_state.go | 183 +++++---- action/protocol/rewarding/inflation_test.go | 351 +++++++++++++----- action/protocol/rewarding/reward.go | 47 +-- .../rewarding/rewardingpb/rewarding.pb.go | 90 +---- .../rewarding/rewardingpb/rewarding.proto | 28 +- 6 files changed, 533 insertions(+), 316 deletions(-) diff --git a/action/protocol/rewarding/inflation.go b/action/protocol/rewarding/inflation.go index bb1041bf62..4e1a02aa33 100644 --- a/action/protocol/rewarding/inflation.go +++ b/action/protocol/rewarding/inflation.go @@ -36,7 +36,8 @@ func IsYearBoundary(activation, blocksPerYear, height uint64) bool { } // IsYearFinalBlock reports whether height is the last block of the current Year. -// Used to flush yearMintRemainder per IIP-62 §4.1. +// On this block the per-year remainder (annualMint mod blocksPerYear) is added to the +// mint so the realized annual mint exactly matches the §1.3 table (IIP-62 §4.1). func IsYearFinalBlock(activation, blocksPerYear, height uint64) bool { if height < activation || blocksPerYear == 0 { return false @@ -99,33 +100,146 @@ func PerBlockMint(supplyAtYearStart *big.Int, inflationBps, blocksPerYear uint64 } // SplitMint distributes mTotal between the staker pool and the Machina DAO using -// basis-point shares, carrying sub-bpsDenom dust between blocks so that over time -// the realized split is exact. dustStakerIn / dustMachinaIn are the per-share dust -// accumulators from the previous block (in "Rau·bps" units); the returned dust -// values must be persisted for the next call. +// basis-point shares. It is a pure, stateless function: the staker share is the +// truncated mTotal·stakerBps/bpsDenom and the Machina share is the complement, +// mMachina = mTotal − mStaker. This conserves mTotal exactly every block +// (mStaker + mMachina == mTotal). +// +// No sub-bpsDenom dust is carried across blocks. The per-block truncation biases +// at most (bpsDenom−1)/bpsDenom < 1 Rau toward Machina; with constant per-block +// mint that totals well under a micro-IOTX over the chain's lifetime, so exact +// per-share carry is not worth the persisted-state and reorg surface it costs. // // stakerBps + machinaBps must equal bpsDenom; the caller is expected to enforce this -// at genesis-validation time so the assertion does not run hot per block. +// at genesis-validation time so the assertion does not run hot per block. machinaBps +// is therefore implied by stakerBps and is not read here. func SplitMint( mTotal *big.Int, stakerBps, machinaBps uint64, - dustStakerIn, dustMachinaIn *big.Int, -) (mStaker, mMachina, dustStakerOut, dustMachinaOut *big.Int) { +) (mStaker, mMachina *big.Int) { bpsDenomBig := big.NewInt(bpsDenom) stakerNum := new(big.Int).Mul(mTotal, new(big.Int).SetUint64(stakerBps)) - if dustStakerIn != nil { - stakerNum.Add(stakerNum, dustStakerIn) - } mStaker = new(big.Int).Quo(stakerNum, bpsDenomBig) - dustStakerOut = new(big.Int).Mod(stakerNum, bpsDenomBig) + mMachina = new(big.Int).Sub(mTotal, mStaker) + + return mStaker, mMachina +} + +// stakerShare returns floor(mTotal · stakerBps / bpsDenom), the staker-pool mint for a +// block whose total mint is mTotal. Mirrors SplitMint's staker leg; factored out so the +// derived cumulative / epoch-surplus helpers compute the same value without allocating +// the Machina complement. +func stakerShare(mTotal *big.Int, stakerBps uint64) *big.Int { + n := new(big.Int).Mul(mTotal, new(big.Int).SetUint64(stakerBps)) + return n.Quo(n, big.NewInt(bpsDenom)) +} + +// ComputeYearStartSupply replays the IIP-62 disinflation recurrence from genesis to +// return OutstandingSupplyAtYearStart for the given (1-indexed) year: +// +// S(1) = activationSupply +// S(k+1) = S(k) + AnnualMint(S(k), ComputeInflationBps(k, …)) +// +// This is exact because the per-year remainder flush makes the realized mint of every +// completed year equal AnnualMint(...) exactly. It is self-contained from genesis params +// (no stored state), O(year), and called at most once per year boundary / twice per +// epoch — never per block. Returns activationSupply for year ≤ 1. +func ComputeYearStartSupply( + activationSupply *big.Int, + year, y1Bps, num, denom, floorBps, blocksPerYear uint64, +) *big.Int { + s := new(big.Int).Set(activationSupply) + for k := uint64(1); k < year; k++ { + bps := ComputeInflationBps(k, y1Bps, num, denom, floorBps) + s.Add(s, AnnualMint(s, bps)) + } + return s +} + +// CumulativeMinted returns the total productive inflation minted from activation through +// height (inclusive), i.e. the value the old per-block PostActivationMinted counter held. +// yearStart / bps are OutstandingSupplyAtYearStart and CurrentInflationBps as of height's +// year (the persisted snapshot, valid at the read height). It splits into: +// +// completed years : yearStart − activationSupply (= Σ AnnualMint of years < current) +// current year : blocksMinted·perBlock (+ remainder on the year's final block) +// +// Returns 0 for heights before activation. OutstandingSupply = activationSupply + this. +func CumulativeMinted( + activationSupply, yearStart *big.Int, + bps, activation, blocksPerYear, height uint64, +) *big.Int { + year := YearIndex(activation, blocksPerYear, height) + if year == 0 { + return new(big.Int) + } + completed := new(big.Int).Sub(yearStart, activationSupply) + perBlock, rem := PerBlockMint(yearStart, bps, blocksPerYear) + yearFirst := activation + (year-1)*blocksPerYear + blocksMinted := new(big.Int).SetUint64(height - yearFirst + 1) + partial := new(big.Int).Mul(perBlock, blocksMinted) + if IsYearFinalBlock(activation, blocksPerYear, height) { + partial.Add(partial, rem) + } + return completed.Add(completed, partial) +} - machinaNum := new(big.Int).Mul(mTotal, new(big.Int).SetUint64(machinaBps)) - if dustMachinaIn != nil { - machinaNum.Add(machinaNum, dustMachinaIn) +// EpochInflationSurplus returns Σ over blocks b in [epochStart, epochEnd] of +// max(0, mStaker(b) − blockReward) — the per-block staker-share excess over the (clamped) +// block reward that the old EpochRemainderAccumulator banked and GrantEpochReward paid as +// the epoch reward. It is computed in closed form per year-segment: an epoch lies in a +// single year (1 segment) unless it straddles a year boundary (2 segments, only possible +// if activation is not epoch-aligned). Within a segment mStaker is constant except on the +// year's final block, which carries the remainder-boosted mint. Pre-activation blocks +// (year 0) contribute nothing. blockReward is the unclamped admin block reward — the same +// threshold calculateTotalRewardAndTip uses for the step-F clamp. +func EpochInflationSurplus( + activationSupply *big.Int, + activation, blocksPerYear, epochStart, epochEnd uint64, + y1Bps, num, denom, floorBps, stakerBps uint64, + blockReward *big.Int, +) *big.Int { + sum := new(big.Int) + if blocksPerYear == 0 { + return sum } - mMachina = new(big.Int).Quo(machinaNum, bpsDenomBig) - dustMachinaOut = new(big.Int).Mod(machinaNum, bpsDenomBig) + yHi := YearIndex(activation, blocksPerYear, epochEnd) + for year := uint64(1); year <= yHi; year++ { + yearFirst := activation + (year-1)*blocksPerYear + yearFinal := activation + year*blocksPerYear - 1 + lo := epochStart + if yearFirst > lo { + lo = yearFirst + } + hi := epochEnd + if yearFinal < hi { + hi = yearFinal + } + if lo > hi { + continue + } + bps := ComputeInflationBps(year, y1Bps, num, denom, floorBps) + ys := ComputeYearStartSupply(activationSupply, year, y1Bps, num, denom, floorBps, blocksPerYear) + perBlock, rem := PerBlockMint(ys, bps, blocksPerYear) + excess := blockExcess(perBlock, stakerBps, blockReward) + n := new(big.Int).SetUint64(hi - lo + 1) + sum.Add(sum, n.Mul(n, excess)) + // The year's final block (if inside this segment) mints perBlock+rem, not perBlock. + if rem.Sign() > 0 && lo <= yearFinal && yearFinal <= hi { + boosted := new(big.Int).Add(perBlock, rem) + sum.Add(sum, blockExcess(boosted, stakerBps, blockReward)) + sum.Sub(sum, excess) // swap out the one normal block we over-counted + } + } + return sum +} - return mStaker, mMachina, dustStakerOut, dustMachinaOut +// blockExcess returns max(0, stakerShare(mTotal) − blockReward). +func blockExcess(mTotal *big.Int, stakerBps uint64, blockReward *big.Int) *big.Int { + mStaker := stakerShare(mTotal, stakerBps) + if mStaker.Cmp(blockReward) <= 0 { + return new(big.Int) + } + return mStaker.Sub(mStaker, blockReward) } diff --git a/action/protocol/rewarding/inflation_state.go b/action/protocol/rewarding/inflation_state.go index 40d0222631..f04c1c2cea 100644 --- a/action/protocol/rewarding/inflation_state.go +++ b/action/protocol/rewarding/inflation_state.go @@ -30,27 +30,20 @@ var _inflKey = []byte("inf") // inflationState is the in-memory mirror of rewardingpb.InflationState. Mirrors // the fund / admin / rewardAccount serialization pattern in this package. +// inflationState holds only the IIP-62 fields that change at year boundaries. +// PostActivationMinted, EpochRemainderAccumulator, and YearMintRemainder used to live +// here but were all deterministic per-block values; they are now derived on demand +// (CumulativeMinted / EpochInflationSurplus / PerBlockMint) so this record is written +// once per year instead of every block. type inflationState struct { - outstandingSupply *big.Int outstandingSupplyAtYearStart *big.Int - postActivationMinted *big.Int currentInflationBps uint64 currentYearIndex uint64 - dustStaker *big.Int - dustMachina *big.Int - yearMintRemainder *big.Int - epochRemainderAccumulator *big.Int } func newInflationState() *inflationState { return &inflationState{ - outstandingSupply: new(big.Int), outstandingSupplyAtYearStart: new(big.Int), - postActivationMinted: new(big.Int), - dustStaker: new(big.Int), - dustMachina: new(big.Int), - yearMintRemainder: new(big.Int), - epochRemainderAccumulator: new(big.Int), } } @@ -89,41 +82,17 @@ func (s *inflationState) Decode(v systemcontracts.GenericValue) error { func (s *inflationState) toProto() *rewardingpb.InflationState { return &rewardingpb.InflationState{ - OutstandingSupply: bigToStr(s.outstandingSupply), OutstandingSupplyAtYearStart: bigToStr(s.outstandingSupplyAtYearStart), - PostActivationMinted: bigToStr(s.postActivationMinted), CurrentInflationBps: s.currentInflationBps, CurrentYearIndex: s.currentYearIndex, - DustStaker: bigToStr(s.dustStaker), - DustMachina: bigToStr(s.dustMachina), - YearMintRemainder: bigToStr(s.yearMintRemainder), - EpochRemainderAccumulator: bigToStr(s.epochRemainderAccumulator), } } func (s *inflationState) fromProto(gen *rewardingpb.InflationState) error { var err error - if s.outstandingSupply, err = strToBig(gen.OutstandingSupply, "outstandingSupply"); err != nil { - return err - } if s.outstandingSupplyAtYearStart, err = strToBig(gen.OutstandingSupplyAtYearStart, "outstandingSupplyAtYearStart"); err != nil { return err } - if s.postActivationMinted, err = strToBig(gen.PostActivationMinted, "postActivationMinted"); err != nil { - return err - } - if s.dustStaker, err = strToBig(gen.DustStaker, "dustStaker"); err != nil { - return err - } - if s.dustMachina, err = strToBig(gen.DustMachina, "dustMachina"); err != nil { - return err - } - if s.yearMintRemainder, err = strToBig(gen.YearMintRemainder, "yearMintRemainder"); err != nil { - return err - } - if s.epochRemainderAccumulator, err = strToBig(gen.EpochRemainderAccumulator, "epochRemainderAccumulator"); err != nil { - return err - } s.currentInflationBps = gen.CurrentInflationBps s.currentYearIndex = gen.CurrentYearIndex return nil @@ -148,16 +117,13 @@ func (p *Protocol) initInflationState(ctx context.Context, sm protocol.StateMana } s := newInflationState() - s.outstandingSupply.Set(supply) + // Only the year-start snapshot, the curve rate, and the year index are persisted — + // all change only at year boundaries. The live supply, cumulative mint, per-year + // remainder, and epoch surplus are all derived on demand (see inflation.go) rather + // than stored, so this record is written once per year instead of every block. s.outstandingSupplyAtYearStart.Set(supply) s.currentInflationBps = cfg.InflationRateY1Bps s.currentYearIndex = 1 - // Pre-stage Y1's year-end remainder here — mintAndAllocate's boundary branch - // only fires when year != currentYearIndex (Y2+), so without this seed the Y1 - // final-block flush would add zero and Y1 mint would fall short by up to - // blocksPerYear-1 Rau. - _, rem := PerBlockMint(s.outstandingSupplyAtYearStart, s.currentInflationBps, cfg.BlocksPerYear) - s.yearMintRemainder.Set(rem) return p.putState(ctx, sm, _inflKey, s) } @@ -205,19 +171,21 @@ func validateInflationConfig(cfg *genesis.Rewarding) error { // // Pipeline at activation and beyond: // 1. Load InflationState (must have been seeded by initInflationState). -// 2. If we crossed into a new Year: snapshot OutstandingSupplyAtYearStart from the -// current OutstandingSupply, recompute CurrentInflationBps from the curve, and -// reset YearMintRemainder to (annualMint mod blocksPerYear) for the new Year. +// 2. If we crossed into a new Year: recompute OutstandingSupplyAtYearStart via the +// genesis recurrence (ComputeYearStartSupply), refresh CurrentInflationBps from the +// curve, advance CurrentYearIndex, and persist — this is the ONLY block on which +// InflationState changes, so it is the only block that writes _inflKey. // 3. Compute the constant per-block mint for the current Year. On the Year's final -// block, add YearMintRemainder so the realized annual mint exactly equals the -// §1.3 table. -// 4. Split via SplitMint, carrying sub-bpsDenom dust between blocks. +// block, add the recomputed year-end remainder so the realized annual mint exactly +// equals the §1.3 table (the remainder is derived, not stored). +// 4. Split via SplitMint (staker = mTotal·bps/denom truncated; Machina = mTotal − staker). // 5. Credit the staker share to the Fund (mirrors Deposit() arithmetic but without // a caller-subtraction — this is protocol mint, not user deposit). // 6. Credit the Machina share to the externally-managed MachinaDaoAddress account. -// 7. Bump OutstandingSupply / PostActivationMinted; bank the staker-vs-block-reward -// excess into EpochRemainderAccumulator (consumed by step G in GrantEpochReward). -// 8. Persist. +// +// PostActivationMinted and the epoch surplus are no longer accumulated here — they are +// derived on read (CumulativeMinted / EpochInflationSurplus), so non-boundary blocks +// leave InflationState untouched and skip the per-block write entirely. // // Returns mStaker (this block's staker-share mint) and the per-block transaction // logs attributing the staker / Machina credits. The staker log uses Sender="" to @@ -245,10 +213,24 @@ func (p *Protocol) mintAndAllocate(ctx context.Context, sm protocol.StateManager if year == 0 { return new(big.Int), nil, nil } - // Year boundary crossing: refresh snapshot and curve rate. This branch also fires - // after a reorg that crosses the boundary because CurrentYearIndex is persisted. - if year != s.currentYearIndex { - s.outstandingSupplyAtYearStart.Set(s.outstandingSupply) + // Year boundary crossing: refresh snapshot and curve rate, and persist. This is the + // only path that mutates InflationState; non-boundary blocks leave it unchanged and + // skip the write. The branch also fires after a reorg that crosses the boundary + // because CurrentYearIndex is persisted, so re-execution is deterministic. + boundary := year != s.currentYearIndex + if boundary { + // Recompute the year-start supply via the genesis recurrence. Equivalent to + // "previous snapshot + that year's AnnualMint" (exact, thanks to the final-block + // remainder flush), but robust without depending on the prior-year stored rate. + s.outstandingSupplyAtYearStart = ComputeYearStartSupply( + cfg.OutstandingSupplyAtActivationBig(), + year, + cfg.InflationRateY1Bps, + cfg.InflationDecayNumerator, + cfg.InflationDecayDenominator, + cfg.InflationFloorBps, + cfg.BlocksPerYear, + ) s.currentInflationBps = ComputeInflationBps( year, cfg.InflationRateY1Bps, @@ -257,29 +239,23 @@ func (p *Protocol) mintAndAllocate(ctx context.Context, sm protocol.StateManager cfg.InflationFloorBps, ) s.currentYearIndex = year - // Pre-stage the year-end remainder so the year's final block can flush it. - _, rem := PerBlockMint(s.outstandingSupplyAtYearStart, s.currentInflationBps, cfg.BlocksPerYear) - s.yearMintRemainder.Set(rem) } - perBlock, _ := PerBlockMint(s.outstandingSupplyAtYearStart, s.currentInflationBps, cfg.BlocksPerYear) + perBlock, rem := PerBlockMint(s.outstandingSupplyAtYearStart, s.currentInflationBps, cfg.BlocksPerYear) mTotal := new(big.Int).Set(perBlock) - // Flush yearMintRemainder on the Year's final block so realized annual mint is exact. + // Add the year-end remainder on the Year's final block so the realized annual mint is + // exact. The remainder is recomputed here, not stored/flushed. if IsYearFinalBlock(activation, cfg.BlocksPerYear, blkCtx.BlockHeight) { - mTotal.Add(mTotal, s.yearMintRemainder) - s.yearMintRemainder.SetUint64(0) + mTotal.Add(mTotal, rem) } if mTotal.Sign() == 0 { return new(big.Int), nil, nil } - mStaker, mMachina, dStaker, dMachina := SplitMint( + mStaker, mMachina := SplitMint( mTotal, cfg.StakerShareBps, cfg.MachinaShareBps, - s.dustStaker, s.dustMachina, ) - s.dustStaker.Set(dStaker) - s.dustMachina.Set(dMachina) var tLogs []*action.TransactionLog @@ -337,52 +313,63 @@ func (p *Protocol) mintAndAllocate(ctx context.Context, sm protocol.StateManager }) } - s.outstandingSupply.Add(s.outstandingSupply, mTotal) - s.postActivationMinted.Add(s.postActivationMinted, mTotal) - // Bank the staker-share-minus-block-reward excess into the epoch accumulator. - // effective_block_reward = min(a.blockReward, mStaker); excess = mStaker − that. - // Must read a.blockReward (admin state) — the SAME source calculateTotalRewardAndTip - // uses for the actual grant — so the epoch accumulator stays exactly consistent - // with what GrantBlockReward pays out. Post-Wake, a.blockReward == WakeBlockReward. - a := admin{} - if _, err := p.state(ctx, sm, _adminKey, &a); err != nil { - return nil, nil, errors.Wrap(err, "failed to load rewarding admin for block-reward clamp") - } - effectiveBlock := new(big.Int).Set(a.blockReward) - if mStaker.Cmp(effectiveBlock) < 0 { - effectiveBlock.Set(mStaker) - } - excess := new(big.Int).Sub(mStaker, effectiveBlock) - if excess.Sign() > 0 { - s.epochRemainderAccumulator.Add(s.epochRemainderAccumulator, excess) - } - - if err := p.putState(ctx, sm, _inflKey, s); err != nil { - return nil, nil, errors.Wrap(err, "failed to persist inflation state") + // PostActivationMinted is no longer accumulated, and the staker-vs-block-reward + // excess is no longer banked: both are derived on read (CumulativeMinted / + // EpochInflationSurplus). Persist only when the year boundary actually changed the + // state, so non-boundary blocks add no _inflKey write to the (archive) history. + if boundary { + if err := p.putState(ctx, sm, _inflKey, s); err != nil { + return nil, nil, errors.Wrap(err, "failed to persist inflation state") + } } return mStaker, tLogs, nil } -// OutstandingSupply returns the current outstanding native-token supply tracked -// by the IIP-62 inflation state. Returns state.ErrStateNotExist before activation. -func (p *Protocol) OutstandingSupply(ctx context.Context, sm protocol.StateReader) (*big.Int, uint64, error) { +// cumulativeMintedAt loads the (boundary-only) InflationState and derives the cumulative +// productive mint from activation through the read height. The height returned by p.state +// is the state reader's height — the query height for both latest and historical (archive) +// reads — so the partial-current-year term is reconstructed correctly. Returns the state +// error (e.g. state.ErrStateNotExist before activation) on failure. +func (p *Protocol) cumulativeMintedAt(ctx context.Context, sm protocol.StateReader) (*big.Int, *genesis.Rewarding, uint64, error) { s := newInflationState() height, err := p.state(ctx, sm, _inflKey, s) + if err != nil { + return nil, nil, height, err + } + g := genesis.MustExtractGenesisContext(ctx) + cfg := g.Rewarding + minted := CumulativeMinted( + cfg.OutstandingSupplyAtActivationBig(), + s.outstandingSupplyAtYearStart, + s.currentInflationBps, + g.ToBeEnabledBlockHeight, + cfg.BlocksPerYear, + height, + ) + return minted, &cfg, height, nil +} + +// OutstandingSupply returns the current outstanding native-token supply tracked by the +// IIP-62 inflation state. It is not stored: by construction it equals +// OutstandingSupplyAtActivation + the cumulative mint through the read height, derived via +// CumulativeMinted. Returns state.ErrStateNotExist before activation. +func (p *Protocol) OutstandingSupply(ctx context.Context, sm protocol.StateReader) (*big.Int, uint64, error) { + minted, cfg, height, err := p.cumulativeMintedAt(ctx, sm) if err != nil { return nil, height, err } - return s.outstandingSupply, height, nil + return minted.Add(minted, cfg.OutstandingSupplyAtActivationBig()), height, nil } -// PostActivationMinted returns the cumulative amount minted by productive -// inflation since activation. Returns state.ErrStateNotExist before activation. +// PostActivationMinted returns the cumulative amount minted by productive inflation since +// activation, derived (not stored) via CumulativeMinted. Returns state.ErrStateNotExist +// before activation. func (p *Protocol) PostActivationMinted(ctx context.Context, sm protocol.StateReader) (*big.Int, uint64, error) { - s := newInflationState() - height, err := p.state(ctx, sm, _inflKey, s) + minted, _, height, err := p.cumulativeMintedAt(ctx, sm) if err != nil { return nil, height, err } - return s.postActivationMinted, height, nil + return minted, height, nil } // CurrentInflationBps returns the inflation rate (in basis points of outstanding diff --git a/action/protocol/rewarding/inflation_test.go b/action/protocol/rewarding/inflation_test.go index c720318549..4daf9cefa5 100644 --- a/action/protocol/rewarding/inflation_test.go +++ b/action/protocol/rewarding/inflation_test.go @@ -185,67 +185,253 @@ func TestPerBlockMint_RemainderClosesYear(t *testing.T) { "year-end remainder %s must be < blocksPerYear %d", rem.String(), bpy) } -// SplitMint: dust accumulation closes a 10000-block window exactly. -func TestSplitMint_DustClosesWindow(t *testing.T) { +// SplitMint: staker is the truncated bps share, Machina is the complement, and the +// per-block truncation bias toward Machina stays strictly below 1 Rau. +func TestSplitMint_StakerTruncatedMachinaComplement(t *testing.T) { const ( stakerBps uint64 = 8000 machinaBps uint64 = 2000 nBlocks = 10000 ) - // Pick an mTotal that is intentionally indivisible by bpsDenom so dust must accumulate. + // Pick an mTotal that is intentionally indivisible by bpsDenom so truncation bites. mTotal := big.NewInt(123_456_789) - dStaker, dMachina := new(big.Int), new(big.Int) totalStaker, totalMachina := new(big.Int), new(big.Int) for i := 0; i < nBlocks; i++ { - var s, m *big.Int - s, m, dStaker, dMachina = SplitMint(mTotal, stakerBps, machinaBps, dStaker, dMachina) + s, m := SplitMint(mTotal, stakerBps, machinaBps) + // Staker is floor(mTotal*stakerBps/bpsDenom) every block (no carry). + wantS := new(big.Int).Quo(new(big.Int).Mul(mTotal, big.NewInt(int64(stakerBps))), big.NewInt(bpsDenom)) + require.Equal(t, 0, s.Cmp(wantS), "staker per-block mismatch: got %s want %s", s, wantS) + // Machina is exactly the complement, so the split conserves mTotal. + require.Equal(t, 0, new(big.Int).Add(s, m).Cmp(mTotal), "conservation broken: %s+%s != %s", s, m, mTotal) totalStaker.Add(totalStaker, s) totalMachina.Add(totalMachina, m) } - // Over an integer-multiple-of-bpsDenom block window the staker share equals - // exactly nBlocks * mTotal * stakerBps / bpsDenom with zero dust remaining. - wantStaker := new(big.Int).Mul(mTotal, big.NewInt(int64(nBlocks)*int64(stakerBps)/bpsDenom)) - wantMachina := new(big.Int).Mul(mTotal, big.NewInt(int64(nBlocks)*int64(machinaBps)/bpsDenom)) - require.Equal(t, 0, totalStaker.Cmp(wantStaker), "staker total mismatch: got %s want %s", totalStaker, wantStaker) - require.Equal(t, 0, totalMachina.Cmp(wantMachina), "machina total mismatch: got %s want %s", totalMachina, wantMachina) - require.Equal(t, 0, dStaker.Sign(), "staker dust should drain to 0 at window close, got %s", dStaker) - require.Equal(t, 0, dMachina.Sign(), "machina dust should drain to 0 at window close, got %s", dMachina) + // Bias: staker is shorted vs the exact fair share by < 1 Rau/block, so over + // nBlocks the shortfall (which all accrues to Machina) is strictly < nBlocks Rau. + fairStaker := new(big.Int).Quo( + new(big.Int).Mul(mTotal, big.NewInt(int64(nBlocks)*int64(stakerBps))), + big.NewInt(bpsDenom), + ) + shortfall := new(big.Int).Sub(fairStaker, totalStaker) + require.Equal(t, -1, shortfall.Cmp(big.NewInt(nBlocks)), "staker shortfall %s must be < %d Rau", shortfall, nBlocks) + require.True(t, shortfall.Sign() >= 0, "staker should never be over-paid, got shortfall %s", shortfall) } -// Conservation: mStaker + mMachina + (dust_delta / bpsDenom) equals mTotal each block. -// In Rau·bps space: stakerNum + machinaNum = mTotal * bpsDenom (since shares sum to bpsDenom). +// Conservation: with the Machina share derived as mTotal − mStaker, the split is +// exact every block — mStaker + mMachina == mTotal — for arbitrary inputs. func TestSplitMint_PerBlockConservation(t *testing.T) { - mTotal := big.NewInt(1_000_000_007) // a prime, to force nontrivial dust - dStakerIn := big.NewInt(1234) - dMachinaIn := big.NewInt(4321) - mStaker, mMachina, dStakerOut, dMachinaOut := SplitMint( - mTotal, 8000, 2000, dStakerIn, dMachinaIn, - ) - // (mStaker * bpsDenom + dStakerOut) + (mMachina * bpsDenom + dMachinaOut) - // = mTotal * 10000 + dStakerIn + dMachinaIn - lhs := new(big.Int).Add( - new(big.Int).Add(new(big.Int).Mul(mStaker, big.NewInt(bpsDenom)), dStakerOut), - new(big.Int).Add(new(big.Int).Mul(mMachina, big.NewInt(bpsDenom)), dMachinaOut), + mTotal := big.NewInt(1_000_000_007) // a prime, to force a nonzero truncation + mStaker, mMachina := SplitMint(mTotal, 8000, 2000) + + require.Equal(t, 0, new(big.Int).Add(mStaker, mMachina).Cmp(mTotal), + "per-block conservation: mStaker=%s mMachina=%s mTotal=%s", mStaker, mMachina, mTotal) + + // Staker is exactly the truncated bps share. + wantStaker := new(big.Int).Quo(new(big.Int).Mul(mTotal, big.NewInt(8000)), big.NewInt(bpsDenom)) + require.Equal(t, 0, mStaker.Cmp(wantStaker), "staker share mismatch: got %s want %s", mStaker, wantStaker) +} + +// CumulativeMinted must match a brute-force per-block sum of mTotal across years, +// including the year-end remainder flush on each year's final block. Cross-checks the +// closed-form derivation that replaces the old stored postActivationMinted counter. +func TestCumulativeMinted_MatchesBruteForce(t *testing.T) { + const ( + activation = uint64(360) + blocksPerYear = uint64(50) + y1Bps = uint64(10000) // 100%/yr so per-block mint is chunky + num = uint64(8000) + denom = uint64(10000) + floorBps = uint64(50) ) - rhs := new(big.Int).Add( - new(big.Int).Mul(mTotal, big.NewInt(bpsDenom)), - new(big.Int).Add(dStakerIn, dMachinaIn), + activationSupply, _ := new(big.Int).SetString("100000000000000000000", 10) // 100 IOTX + + // Brute-force running sum of mTotal, recomputing the year-start snapshot via the + // same recurrence mintAndAllocate uses at each boundary. + want := new(big.Int) + curYear := uint64(0) + var yearStart *big.Int + var bps uint64 + // Walk 3 years + a few blocks so multiple boundaries + final-block flushes are hit. + for h := activation; h <= activation+3*blocksPerYear+7; h++ { + year := YearIndex(activation, blocksPerYear, h) + if year != curYear { + yearStart = ComputeYearStartSupply(activationSupply, year, y1Bps, num, denom, floorBps, blocksPerYear) + bps = ComputeInflationBps(year, y1Bps, num, denom, floorBps) + curYear = year + } + perBlock, rem := PerBlockMint(yearStart, bps, blocksPerYear) + mTotal := new(big.Int).Set(perBlock) + if IsYearFinalBlock(activation, blocksPerYear, h) { + mTotal.Add(mTotal, rem) + } + want.Add(want, mTotal) + + got := CumulativeMinted(activationSupply, yearStart, bps, activation, blocksPerYear, h) + require.Equalf(t, 0, got.Cmp(want), "CumulativeMinted(%d) = %s, brute-force = %s", h, got, want) + } + // Before activation it is zero. + require.Equal(t, 0, CumulativeMinted(activationSupply, activationSupply, y1Bps, activation, blocksPerYear, activation-1).Sign()) +} + +// EpochInflationSurplus must equal a brute-force per-block Σ max(0, mStaker − blockReward), +// across single-year, year-final-block, and year-straddling epoch ranges, and must ignore +// pre-activation blocks. Cross-checks the closed-form that replaces the old stored +// epochRemainderAccumulator. +func TestEpochInflationSurplus_MatchesBruteForce(t *testing.T) { + const ( + activation = uint64(360) + blocksPerYear = uint64(50) + y1Bps = uint64(10000) + num = uint64(8000) + denom = uint64(10000) + floorBps = uint64(50) + stakerBps = uint64(8000) ) - require.Equal(t, 0, lhs.Cmp(rhs), "per-block conservation: lhs=%s rhs=%s", lhs, rhs) + activationSupply, _ := new(big.Int).SetString("100000000000000000000", 10) + blockReward := big.NewInt(1_000_000_000) // small vs per-block mint so excess is usually >0 + + bruteForce := func(epochStart, epochEnd uint64) *big.Int { + sum := new(big.Int) + for h := epochStart; h <= epochEnd; h++ { + year := YearIndex(activation, blocksPerYear, h) + if year == 0 { + continue + } + ys := ComputeYearStartSupply(activationSupply, year, y1Bps, num, denom, floorBps, blocksPerYear) + bps := ComputeInflationBps(year, y1Bps, num, denom, floorBps) + perBlock, rem := PerBlockMint(ys, bps, blocksPerYear) + mTotal := new(big.Int).Set(perBlock) + if IsYearFinalBlock(activation, blocksPerYear, h) { + mTotal.Add(mTotal, rem) + } + mStaker := new(big.Int).Quo(new(big.Int).Mul(mTotal, big.NewInt(int64(stakerBps))), big.NewInt(bpsDenom)) + if mStaker.Cmp(blockReward) > 0 { + sum.Add(sum, new(big.Int).Sub(mStaker, blockReward)) + } + } + return sum + } + + cases := []struct { + name string + epochStart, epochE uint64 + }{ + {"pre-activation only", activation - 20, activation - 1}, + {"straddles activation", activation - 5, activation + 4}, + {"single mid-year window", activation + 10, activation + 19}, + {"includes year-final block", activation + blocksPerYear - 3, activation + blocksPerYear + 2}, + {"straddles year boundary deep", activation + 2*blocksPerYear - 4, activation + 2*blocksPerYear + 5}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EpochInflationSurplus( + activationSupply, activation, blocksPerYear, tc.epochStart, tc.epochE, + y1Bps, num, denom, floorBps, stakerBps, blockReward, + ) + require.Equal(t, 0, got.Cmp(bruteForce(tc.epochStart, tc.epochE)), + "surplus mismatch: got %s want %s", got, bruteForce(tc.epochStart, tc.epochE)) + }) + } +} + +// fixedHeightSR reports a chosen height from State() while delegating the actual read +// to the wrapped StateManager. testProtocol's mock StateManager always reports height 0, +// which would defeat the height-dependent derivation in the OutstandingSupply / +// PostActivationMinted getters; production StateReaders return the query height (verified: +// workingSet.State returns ws.height). +type fixedHeightSR struct { + protocol.StateManager + height uint64 +} + +func (s fixedHeightSR) State(v interface{}, opts ...protocol.StateOption) (uint64, error) { + _, err := s.StateManager.State(v, opts...) + return s.height, err +} + +// End-to-end coverage of the derived OutstandingSupply / PostActivationMinted getters: +// they must reconstruct supply from the boundary-only snapshot plus the query height +// returned by p.state, for partial-year, activation-block, pre-activation, and post-year- +// boundary reads. +func TestOutstandingSupplyGetters_Derived(t *testing.T) { + machinaAddr := identityset.Address(33).String() + const blocksPerYear = uint64(100) + testProtocol(t, func(t *testing.T, ctx context.Context, sm protocol.StateManager, p *Protocol) { + req := require.New(t) + g := genesis.MustExtractGenesisContext(ctx) + blkCtx := protocol.MustGetBlockCtx(ctx) + g.Rewarding.InflationRateY1Bps = 500 + g.Rewarding.InflationDecayNumerator = 8000 + g.Rewarding.InflationDecayDenominator = 10000 + g.Rewarding.InflationFloorBps = 50 + g.Rewarding.BlocksPerYear = blocksPerYear + g.Rewarding.StakerShareBps = 8000 + g.Rewarding.MachinaShareBps = 2000 + g.Rewarding.MachinaDaoAddress = machinaAddr + g.Rewarding.OutstandingSupplyAtActivation = "100000000000000000000" // 100 IOTX + activation := blkCtx.BlockHeight + g.ToBeEnabledBlockHeight = activation + ctx = genesis.WithGenesisContext(ctx, g) + ctx = protocol.WithFeatureCtx(ctx) + req.NoError(p.CreatePreStates(ctx, sm)) + + supply := g.Rewarding.OutstandingSupplyAtActivationBig() + denomB := big.NewInt(10000) + bpyB := new(big.Int).SetUint64(blocksPerYear) + // Year 1 per-block mint, computed with plain arithmetic (independent of the + // production helpers): annual = supply·500/10000, perBlock = annual/blocksPerYear. + annual1 := new(big.Int).Quo(new(big.Int).Mul(supply, big.NewInt(500)), denomB) + perBlock1 := new(big.Int).Quo(annual1, bpyB) + // Year 2: ComputeInflationBps(2)=400 (see TestComputeInflationBps_SpecValues). + yearStart2 := new(big.Int).Add(supply, annual1) + annual2 := new(big.Int).Quo(new(big.Int).Mul(yearStart2, big.NewInt(400)), denomB) + perBlock2 := new(big.Int).Quo(annual2, bpyB) + + // assertAt queries the getters at height h (via the fixed-height wrapper) and + // checks both against wantMinted (= PostActivationMinted; OutstandingSupply is + // supply + that). Also confirms the getter echoes the query height. + assertAt := func(h uint64, wantMinted *big.Int) { + sr := fixedHeightSR{StateManager: sm, height: h} + os, hgt, err := p.OutstandingSupply(ctx, sr) + req.NoError(err) + req.Equalf(h, hgt, "OutstandingSupply height echo at %d", h) + req.Equalf(0, os.Cmp(new(big.Int).Add(supply, wantMinted)), + "OutstandingSupply at %d: got %s want %s", h, os, new(big.Int).Add(supply, wantMinted)) + pam, _, err := p.PostActivationMinted(ctx, sr) + req.NoError(err) + req.Equalf(0, pam.Cmp(wantMinted), "PostActivationMinted at %d: got %s want %s", h, pam, wantMinted) + } + + mulN := func(a *big.Int, n int64) *big.Int { return new(big.Int).Mul(a, big.NewInt(n)) } + + // At/before activation: nothing minted yet (state exists, but height precedes Y1). + assertAt(activation-1, new(big.Int)) + // Activation block itself mints exactly one perBlock. + assertAt(activation, perBlock1) + // Mid-year-1 partial: blocks [activation, activation+50] = 51 blocks. + assertAt(activation+50, mulN(perBlock1, 51)) + + // Cross into year 2: run mintAndAllocate at the boundary block so the stored + // snapshot advances to the Y2 base (ComputeYearStartSupply(2)). + blkCtx.BlockHeight = activation + blocksPerYear + ctx = protocol.WithBlockCtx(ctx, blkCtx) + ctx = genesis.WithGenesisContext(ctx, g) + ctx = protocol.WithFeatureCtx(ctx) + _, _, err := p.mintAndAllocate(ctx, sm) + req.NoError(err) + + // Year-2 read: completed year 1 (annual1) + 11 blocks of year 2. + wantY2 := new(big.Int).Add(annual1, mulN(perBlock2, 11)) + assertAt(activation+blocksPerYear+10, wantY2) + }, nil, false, 0) } func TestInflationState_RoundTrip(t *testing.T) { s := newInflationState() - s.outstandingSupply.SetString("9440000000000000000000000000", 10) s.outstandingSupplyAtYearStart.SetString("9440000000000000000000000000", 10) - s.postActivationMinted.SetString("123456789012345", 10) s.currentInflationBps = 500 s.currentYearIndex = 1 - s.dustStaker.SetUint64(7777) - s.dustMachina.SetUint64(3333) - s.yearMintRemainder.SetUint64(99) - s.epochRemainderAccumulator.SetString("999999999999", 10) data, err := s.Serialize() require.NoError(t, err) @@ -253,15 +439,9 @@ func TestInflationState_RoundTrip(t *testing.T) { out := newInflationState() require.NoError(t, out.Deserialize(data)) - require.Equal(t, 0, s.outstandingSupply.Cmp(out.outstandingSupply)) require.Equal(t, 0, s.outstandingSupplyAtYearStart.Cmp(out.outstandingSupplyAtYearStart)) - require.Equal(t, 0, s.postActivationMinted.Cmp(out.postActivationMinted)) require.Equal(t, s.currentInflationBps, out.currentInflationBps) require.Equal(t, s.currentYearIndex, out.currentYearIndex) - require.Equal(t, 0, s.dustStaker.Cmp(out.dustStaker)) - require.Equal(t, 0, s.dustMachina.Cmp(out.dustMachina)) - require.Equal(t, 0, s.yearMintRemainder.Cmp(out.yearMintRemainder)) - require.Equal(t, 0, s.epochRemainderAccumulator.Cmp(out.epochRemainderAccumulator)) } func TestValidateInflationConfig(t *testing.T) { @@ -395,17 +575,27 @@ func TestMintAndAllocate_EmitsTransactionLogs(t *testing.T) { }, nil, false, 0) } -// IIP-62 step G: post-activation, GrantEpochReward funds the split from -// EpochRemainderAccumulator (banked per-block by mintAndAllocate) instead of -// admin.epochReward. The accumulator must be drained back to zero after the -// grant so the next epoch starts fresh. -func TestGrantEpochReward_UsesAccumulator(t *testing.T) { +// IIP-62 step G: post-activation, GrantEpochReward funds the split from the derived +// per-block inflation surplus (EpochInflationSurplus over the epoch's block range) +// instead of admin.epochReward. No stored accumulator is read or reset. +func TestGrantEpochReward_UsesDerivedSurplus(t *testing.T) { machinaAddr := identityset.Address(33).String() testProtocol(t, func(t *testing.T, ctx context.Context, sm protocol.StateManager, p *Protocol) { req := require.New(t) // Activate IIP-62 at the harness's current height (also the last block of - // epoch 1, so assertLastBlockInEpoch passes inside GrantEpochReward). + // epoch 1, so assertLastBlockInEpoch passes inside GrantEpochReward). Only the + // activation block itself is post-activation in this epoch, so the derived + // surplus is exactly one block's excess. + // + // Params chosen so the single-block surplus is small and fundable from the + // harness's tiny accounts: + // annual = supply·bps/denom = 4_000_000·500/10000 = 200_000 + // perBlock = annual/blocksPerYear = 200_000/1000 = 200 + // mStaker = perBlock·stakerBps/denom = 200·8000/10000 = 160 + // excess = mStaker − a.blockReward (=10) = 150 → epoch surplus + // Address(27)'s slice (votes 4M of top-4 total 10M) routes to Address(0): + // 150·4M/10M = 60 (the legacy admin.epochReward=100 path would give 40). g := genesis.MustExtractGenesisContext(ctx) blkCtx := protocol.MustGetBlockCtx(ctx) g.Rewarding.InflationRateY1Bps = 500 @@ -416,7 +606,7 @@ func TestGrantEpochReward_UsesAccumulator(t *testing.T) { g.Rewarding.StakerShareBps = 8000 g.Rewarding.MachinaShareBps = 2000 g.Rewarding.MachinaDaoAddress = machinaAddr - g.Rewarding.OutstandingSupplyAtActivation = "100000000000000000000" + g.Rewarding.OutstandingSupplyAtActivation = "4000000" g.ToBeEnabledBlockHeight = blkCtx.BlockHeight ctx = genesis.WithGenesisContext(ctx, g) ctx = protocol.WithFeatureCtx(ctx) @@ -424,20 +614,9 @@ func TestGrantEpochReward_UsesAccumulator(t *testing.T) { // Seed InflationState via the activation pre-state hook. req.NoError(p.CreatePreStates(ctx, sm)) - // Override the accumulator with a distinctive value. testProtocol seeds - // a.epochReward = 100, so accumulator = 300 makes Address(27)'s slice - // (votes 4M of 10M total) settle at 300·4M/10M = 120 instead of the - // legacy 100·4M/10M = 40 — disambiguating the two funding paths. - const accumulator = int64(300) - inf := newInflationState() - _, err := p.state(ctx, sm, _inflKey, inf) - req.NoError(err) - inf.epochRemainderAccumulator.SetInt64(accumulator) - req.NoError(p.putState(ctx, sm, _inflKey, inf)) - // Fund the rewarding pool so the grant + foundation bonus succeed. Caller // (Address(28)) is seeded with 1000 by the harness. - _, err = p.Deposit(ctx, sm, big.NewInt(500), iotextypes.TransactionLogType_DEPOSIT_TO_REWARDING_FUND) + _, err := p.Deposit(ctx, sm, big.NewInt(500), iotextypes.TransactionLogType_DEPOSIT_TO_REWARDING_FUND) req.NoError(err) // Staking mock (mirrors TestProtocol_GrantEpochReward). @@ -454,8 +633,7 @@ func TestGrantEpochReward_UsesAccumulator(t *testing.T) { req.NoError(err) // Address(27)'s votes (4M of 10M total) → reward routed to Address(0). - // Accumulator-funded slice = 300·4M/10M = 120. Legacy a.epochReward=100 - // slice would be 40. Pinning to 120 proves the new path fired. + // Derived-surplus slice = 150·4M/10M = 60; legacy a.epochReward=100 slice = 40. var address0Reward *big.Int for _, l := range rewardLogs { var rl rewardingpb.RewardLog @@ -468,16 +646,9 @@ func TestGrantEpochReward_UsesAccumulator(t *testing.T) { } } req.NotNilf(address0Reward, "no EPOCH_REWARD log for Address(0); got %d logs", len(rewardLogs)) - req.Equalf(0, address0Reward.Cmp(big.NewInt(120)), - "Address(0) reward = %s; expected 120 (accumulator path) — 40 means legacy path fired", + req.Equalf(0, address0Reward.Cmp(big.NewInt(60)), + "Address(0) reward = %s; expected 60 (derived-surplus path) — 40 means legacy path fired", address0Reward) - - // Accumulator must be drained back to zero. - inf2 := newInflationState() - _, err = p.state(ctx, sm, _inflKey, inf2) - req.NoError(err) - req.Equalf(0, inf2.epochRemainderAccumulator.Sign(), - "accumulator must be zero after grant; got %s", inf2.epochRemainderAccumulator) }, nil, false, 0) } @@ -487,7 +658,8 @@ func TestGrantEpochReward_UsesAccumulator(t *testing.T) { // - totalBalance >= unclaimedBalance (Claim has not run, so this must always hold) // - unclaimedBalance >= 0 (the floor-regime clamp prevents the debit from exceeding // the mint credit; without the clamp this would underflow once mStaker < blockReward) -// - postActivationMinted == sum of per-block mTotal credited (no double-count, no drop) +// - derived PostActivationMinted == sum of per-block mTotal credited (no double-count, +// no drop) — exercises the CumulativeMinted derivation against an independent sum // // Two regimes are covered as sub-tests: // @@ -507,7 +679,7 @@ func TestMintAndAllocate_FundInvariant(t *testing.T) { regimeIs string // "high" or "floor" — annotates failure messages }{ {"high_mint_regime", "100000000000000000000", "high"}, // 100 IOTX - {"floor_regime", "1000", "floor"}, // 1000 rau → mStaker < blockReward(10) + {"floor_regime", "1000", "floor"}, // 1000 rau → mStaker < blockReward(10) } for _, tc := range cases { @@ -534,8 +706,11 @@ func TestMintAndAllocate_FundInvariant(t *testing.T) { req.NoError(p.CreatePreStates(ctx, sm)) expectedMinted := new(big.Int) - for i := 1; i <= numBlocks; i++ { - blkCtx.BlockHeight = activation + uint64(i) + // Mint from the activation block itself (production grants there), so the + // derived CumulativeMinted — which counts every block from activation — + // matches our independent running sum. + for i := uint64(0); i < numBlocks; i++ { + blkCtx.BlockHeight = activation + i ctx = protocol.WithBlockCtx(ctx, blkCtx) ctx = genesis.WithGenesisContext(ctx, g) ctx = protocol.WithFeatureCtx(ctx) @@ -545,10 +720,10 @@ func TestMintAndAllocate_FundInvariant(t *testing.T) { infPre := newInflationState() _, err := p.state(ctx, sm, _inflKey, infPre) req.NoError(err) - perBlock, _ := PerBlockMint(infPre.outstandingSupplyAtYearStart, infPre.currentInflationBps, blocksPerYear) + perBlock, rem := PerBlockMint(infPre.outstandingSupplyAtYearStart, infPre.currentInflationBps, blocksPerYear) mTotal := new(big.Int).Set(perBlock) if IsYearFinalBlock(activation, blocksPerYear, blkCtx.BlockHeight) { - mTotal.Add(mTotal, infPre.yearMintRemainder) + mTotal.Add(mTotal, rem) } expectedMinted.Add(expectedMinted, mTotal) @@ -564,13 +739,23 @@ func TestMintAndAllocate_FundInvariant(t *testing.T) { req.Truef(unclaimed.Sign() >= 0, "%s: unclaimedBalance underflowed at block %d: %s", tc.regimeIs, blkCtx.BlockHeight, unclaimed) req.Truef(total.Cmp(unclaimed) >= 0, "%s: totalBalance < unclaimedBalance at block %d (t=%s u=%s)", tc.regimeIs, blkCtx.BlockHeight, total, unclaimed) - // postActivationMinted must equal our independently summed mTotal. + // Derived cumulative mint must equal our independently summed mTotal. + // (The mock StateManager reports height 0, so exercise the pure + // CumulativeMinted derivation directly with the real block height; the + // PostActivationMinted getter wires this same helper to the working-set + // height in production.) infPost := newInflationState() _, err = p.state(ctx, sm, _inflKey, infPost) req.NoError(err) - req.Equalf(0, infPost.postActivationMinted.Cmp(expectedMinted), - "%s: postActivationMinted drift at block %d (got %s want %s)", - tc.regimeIs, blkCtx.BlockHeight, infPost.postActivationMinted, expectedMinted) + minted := CumulativeMinted( + g.Rewarding.OutstandingSupplyAtActivationBig(), + infPost.outstandingSupplyAtYearStart, + infPost.currentInflationBps, + activation, blocksPerYear, blkCtx.BlockHeight, + ) + req.Equalf(0, minted.Cmp(expectedMinted), + "%s: CumulativeMinted drift at block %d (got %s want %s)", + tc.regimeIs, blkCtx.BlockHeight, minted, expectedMinted) } }, nil, false, 0) }) diff --git a/action/protocol/rewarding/reward.go b/action/protocol/rewarding/reward.go index 1e28e327f8..f1826f996a 100644 --- a/action/protocol/rewarding/reward.go +++ b/action/protocol/rewarding/reward.go @@ -298,19 +298,28 @@ func (p *Protocol) GrantEpochReward( epochRewardSplitUqdMap = uqdMap } // IIP-62 step G: post-activation, the epoch grant is funded by the per-block - // inflation surplus banked into EpochRemainderAccumulator (mStaker minus the - // clamped block reward, summed per block by mintAndAllocate), not by the legacy - // admin.epochReward constant. The accumulator is reset to zero after the grant - // loop below. + // inflation surplus (mStaker minus the clamped block reward), not by the legacy + // admin.epochReward constant. The surplus is no longer accumulated in state — it is + // derived in closed form over the epoch's block range via EpochInflationSurplus, + // using a.blockReward (the same clamp threshold calculateTotalRewardAndTip applies). g := genesis.MustExtractGenesisContext(ctx) - inf := newInflationState() + cfg := g.Rewarding postActivation := g.IsToBeEnabled(blkCtx.BlockHeight) epochAmount := a.epochReward if postActivation { - if _, err := p.state(ctx, sm, _inflKey, inf); err != nil { - return nil, nil, errors.Wrap(err, "epoch grant: inflation state not seeded") - } - epochAmount = new(big.Int).Set(inf.epochRemainderAccumulator) + epochAmount = EpochInflationSurplus( + cfg.OutstandingSupplyAtActivationBig(), + g.ToBeEnabledBlockHeight, + cfg.BlocksPerYear, + epochStartHeight, + blkCtx.BlockHeight, + cfg.InflationRateY1Bps, + cfg.InflationDecayNumerator, + cfg.InflationDecayDenominator, + cfg.InflationFloorBps, + cfg.StakerShareBps, + a.blockReward, + ) } addrs, amounts, err := p.splitEpochReward(candidates, epochAmount, a.numDelegatesForEpochReward, exemptAddrs, epochRewardSplitUqdMap) if err != nil { @@ -384,18 +393,10 @@ func (p *Protocol) GrantEpochReward( if err := p.updateAvailableBalance(ctx, sm, actualTotalReward); err != nil { return nil, nil, err } - // IIP-62 step G: drain the per-block inflation surplus that funded this epoch - // grant. Reset unconditionally — even if splitEpochReward returned no addrs (all - // candidates exempt / zero votes), the next epoch must start with a fresh - // accumulator so we don't double-count last epoch's mint. Reset BEFORE the - // history sentinel so any retry at the same height re-loads a zero accumulator - // rather than re-paying the same surplus. - if postActivation { - inf.epochRemainderAccumulator.SetUint64(0) - if err := p.putState(ctx, sm, _inflKey, inf); err != nil { - return nil, nil, errors.Wrap(err, "epoch grant: reset accumulator") - } - } + // IIP-62 step G: the epoch surplus is derived (EpochInflationSurplus over this epoch's + // block range), not drained from stored state, so there is no accumulator to reset + // here. The epoch-reward history sentinel (below) is what makes a retry at the same + // height idempotent. if err := p.updateRewardHistory(ctx, sm, _epochRewardHistoryKeyPrefix, epochNum); err != nil { return nil, nil, err } @@ -560,8 +561,8 @@ func (p *Protocol) calculateTotalRewardAndTip(ctx context.Context, sm protocol.S // IIP-62 step F: post-activation, clamp the constant block reward down to the // per-block staker-share mint when the latter is smaller (e.g. at the Y12+ floor // where annual mint dips below WakeBlockReward × blocksPerYear). The excess - // (mStakerBlock − effective_block_reward) is banked into EpochRemainderAccumulator - // by mintAndAllocate and surfaced via step G as the epoch grant. Pre-activation + // (mStakerBlock − effective_block_reward) is the per-block inflation surplus that + // EpochInflationSurplus re-derives over the epoch and step G grants. Pre-activation // mStakerBlock is zero, so this branch is a no-op. if mStakerBlock != nil && mStakerBlock.Sign() > 0 && mStakerBlock.Cmp(blockReward) < 0 { blockReward.Set(mStakerBlock) diff --git a/action/protocol/rewarding/rewardingpb/rewarding.pb.go b/action/protocol/rewarding/rewardingpb/rewarding.pb.go index 1fd2026756..7cc865c6fb 100644 --- a/action/protocol/rewarding/rewardingpb/rewarding.pb.go +++ b/action/protocol/rewarding/rewardingpb/rewarding.pb.go @@ -467,36 +467,25 @@ func (x *RewardLogs) GetLogs() []*RewardLog { // InflationState is the IIP-62 productive-inflation consensus state, persisted in // state.RewardingNamespace under key "inf". All big-int fields are decimal-string // serialized to stay consistent with Fund / Account / Admin. +// InflationState holds only the IIP-62 fields that change at year boundaries. All other +// per-block values — the running supply (field 1, outstandingSupply), cumulative mint +// (field 3, postActivationMinted), per-year remainder (field 8, yearMintRemainder), epoch +// surplus (field 9, epochRemainderAccumulator), and the dust accumulators (fields 6,7) — +// were deterministic and are now derived on read (CumulativeMinted / EpochInflationSurplus +// / PerBlockMint in the rewarding package), so this record is written once per year +// instead of every block. IIP-62 is mint-only (the EIP-1559 base fee is deposited to the +// rewarding pool, not burned), so there is no burn-side debit. type InflationState struct { state protoimpl.MessageState `protogen:"open.v1"` - // OutstandingSupply is the running mint base for the disinflation curve. - // Seeded at activation from genesis.OutstandingSupplyAtActivation; grows by - // mTotal each block. IIP-62 only adds to this counter; IoTeX's EIP-1559 - // base fee is deposited to the rewarding pool, not burned, so there is no - // burn-side debit in this proposal. - OutstandingSupply string `protobuf:"bytes,1,opt,name=outstandingSupply,proto3" json:"outstandingSupply,omitempty"` // OutstandingSupplyAtYearStart is the §1.2 snapshot used for the entire year. OutstandingSupplyAtYearStart string `protobuf:"bytes,2,opt,name=outstandingSupplyAtYearStart,proto3" json:"outstandingSupplyAtYearStart,omitempty"` - // PostActivationMinted is the cumulative mint since activation (analytics). - PostActivationMinted string `protobuf:"bytes,3,opt,name=postActivationMinted,proto3" json:"postActivationMinted,omitempty"` // CurrentInflationBps is the effective annual rate, refreshed at year boundaries. CurrentInflationBps uint64 `protobuf:"varint,4,opt,name=currentInflationBps,proto3" json:"currentInflationBps,omitempty"` // CurrentYearIndex is the 1-indexed year matching YearIndex(). Stored so a // reorg crossing a year boundary can detect the crossing and re-derive. CurrentYearIndex uint64 `protobuf:"varint,5,opt,name=currentYearIndex,proto3" json:"currentYearIndex,omitempty"` - // DustStaker / DustMachina are the per-share Rau·bps dust accumulators used - // by SplitMint so per-block truncation does not silently bias the split. - DustStaker string `protobuf:"bytes,6,opt,name=dustStaker,proto3" json:"dustStaker,omitempty"` - DustMachina string `protobuf:"bytes,7,opt,name=dustMachina,proto3" json:"dustMachina,omitempty"` - // YearMintRemainder is the per-year leftover (annualMint mod blocksPerYear) - // flushed on the year's final block per §4.1. - YearMintRemainder string `protobuf:"bytes,8,opt,name=yearMintRemainder,proto3" json:"yearMintRemainder,omitempty"` - // EpochRemainderAccumulator carries (mStaker − effective_block_reward) across - // the blocks of an epoch; consumed and reset at the epoch's last block by - // GrantEpochReward (replaces the old constant Admin.epochReward). - EpochRemainderAccumulator string `protobuf:"bytes,9,opt,name=epochRemainderAccumulator,proto3" json:"epochRemainderAccumulator,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *InflationState) Reset() { @@ -529,13 +518,6 @@ func (*InflationState) Descriptor() ([]byte, []int) { return file_rewarding_proto_rawDescGZIP(), []int{7} } -func (x *InflationState) GetOutstandingSupply() string { - if x != nil { - return x.OutstandingSupply - } - return "" -} - func (x *InflationState) GetOutstandingSupplyAtYearStart() string { if x != nil { return x.OutstandingSupplyAtYearStart @@ -543,13 +525,6 @@ func (x *InflationState) GetOutstandingSupplyAtYearStart() string { return "" } -func (x *InflationState) GetPostActivationMinted() string { - if x != nil { - return x.PostActivationMinted - } - return "" -} - func (x *InflationState) GetCurrentInflationBps() uint64 { if x != nil { return x.CurrentInflationBps @@ -564,34 +539,6 @@ func (x *InflationState) GetCurrentYearIndex() uint64 { return 0 } -func (x *InflationState) GetDustStaker() string { - if x != nil { - return x.DustStaker - } - return "" -} - -func (x *InflationState) GetDustMachina() string { - if x != nil { - return x.DustMachina - } - return "" -} - -func (x *InflationState) GetYearMintRemainder() string { - if x != nil { - return x.YearMintRemainder - } - return "" -} - -func (x *InflationState) GetEpochRemainderAccumulator() string { - if x != nil { - return x.EpochRemainderAccumulator - } - return "" -} - var File_rewarding_proto protoreflect.FileDescriptor const file_rewarding_proto_rawDesc = "" + @@ -627,19 +574,12 @@ const file_rewarding_proto_rawDesc = "" + "\x12UNPRODUCTIVE_SLASH\x10\x04\"8\n" + "\n" + "RewardLogs\x12*\n" + - "\x04logs\x18\x01 \x03(\v2\x16.rewardingpb.RewardLogR\x04logs\"\xc2\x03\n" + - "\x0eInflationState\x12,\n" + - "\x11outstandingSupply\x18\x01 \x01(\tR\x11outstandingSupply\x12B\n" + - "\x1coutstandingSupplyAtYearStart\x18\x02 \x01(\tR\x1coutstandingSupplyAtYearStart\x122\n" + - "\x14postActivationMinted\x18\x03 \x01(\tR\x14postActivationMinted\x120\n" + + "\x04logs\x18\x01 \x03(\v2\x16.rewardingpb.RewardLogR\x04logs\"\xd6\x01\n" + + "\x0eInflationState\x12B\n" + + "\x1coutstandingSupplyAtYearStart\x18\x02 \x01(\tR\x1coutstandingSupplyAtYearStart\x120\n" + "\x13currentInflationBps\x18\x04 \x01(\x04R\x13currentInflationBps\x12*\n" + - "\x10currentYearIndex\x18\x05 \x01(\x04R\x10currentYearIndex\x12\x1e\n" + - "\n" + - "dustStaker\x18\x06 \x01(\tR\n" + - "dustStaker\x12 \n" + - "\vdustMachina\x18\a \x01(\tR\vdustMachina\x12,\n" + - "\x11yearMintRemainder\x18\b \x01(\tR\x11yearMintRemainder\x12<\n" + - "\x19epochRemainderAccumulator\x18\t \x01(\tR\x19epochRemainderAccumulatorBJZHgithub.com/iotexproject/iotex-core/action/protocol/rewarding/rewardingpbb\x06proto3" + "\x10currentYearIndex\x18\x05 \x01(\x04R\x10currentYearIndexJ\x04\b\x01\x10\x02J\x04\b\x03\x10\x04J\x04\b\x06\x10\aJ\x04\b\a\x10\bJ\x04\b\b\x10\tJ\x04\b\t\x10\n" + + "BJZHgithub.com/iotexproject/iotex-core/action/protocol/rewarding/rewardingpbb\x06proto3" var ( file_rewarding_proto_rawDescOnce sync.Once diff --git a/action/protocol/rewarding/rewardingpb/rewarding.proto b/action/protocol/rewarding/rewardingpb/rewarding.proto index 57bc6ad2cd..1c808849e2 100644 --- a/action/protocol/rewarding/rewardingpb/rewarding.proto +++ b/action/protocol/rewarding/rewardingpb/rewarding.proto @@ -56,31 +56,21 @@ message RewardLogs { // InflationState is the IIP-62 productive-inflation consensus state, persisted in // state.RewardingNamespace under key "inf". All big-int fields are decimal-string // serialized to stay consistent with Fund / Account / Admin. +// InflationState holds only the IIP-62 fields that change at year boundaries. All other +// per-block values — the running supply (field 1, outstandingSupply), cumulative mint +// (field 3, postActivationMinted), per-year remainder (field 8, yearMintRemainder), epoch +// surplus (field 9, epochRemainderAccumulator), and the dust accumulators (fields 6,7) — +// were deterministic and are now derived on read (CumulativeMinted / EpochInflationSurplus +// / PerBlockMint in the rewarding package), so this record is written once per year +// instead of every block. IIP-62 is mint-only (the EIP-1559 base fee is deposited to the +// rewarding pool, not burned), so there is no burn-side debit. message InflationState { - // OutstandingSupply is the running mint base for the disinflation curve. - // Seeded at activation from genesis.OutstandingSupplyAtActivation; grows by - // mTotal each block. IIP-62 only adds to this counter; IoTeX's EIP-1559 - // base fee is deposited to the rewarding pool, not burned, so there is no - // burn-side debit in this proposal. - string outstandingSupply = 1; + reserved 1, 3, 6, 7, 8, 9; // OutstandingSupplyAtYearStart is the §1.2 snapshot used for the entire year. string outstandingSupplyAtYearStart = 2; - // PostActivationMinted is the cumulative mint since activation (analytics). - string postActivationMinted = 3; // CurrentInflationBps is the effective annual rate, refreshed at year boundaries. uint64 currentInflationBps = 4; // CurrentYearIndex is the 1-indexed year matching YearIndex(). Stored so a // reorg crossing a year boundary can detect the crossing and re-derive. uint64 currentYearIndex = 5; - // DustStaker / DustMachina are the per-share Rau·bps dust accumulators used - // by SplitMint so per-block truncation does not silently bias the split. - string dustStaker = 6; - string dustMachina = 7; - // YearMintRemainder is the per-year leftover (annualMint mod blocksPerYear) - // flushed on the year's final block per §4.1. - string yearMintRemainder = 8; - // EpochRemainderAccumulator carries (mStaker − effective_block_reward) across - // the blocks of an epoch; consumed and reset at the epoch's last block by - // GrantEpochReward (replaces the old constant Admin.epochReward). - string epochRemainderAccumulator = 9; }