Skip to main content

Epoch Hooks

The mint module implements epoch hooks from the epochs module to perform minting at epoch boundaries rather than in an endblocker. This design provides predictable minting schedules aligned with longer time periods (e.g., weekly) rather than block-by-block.

Epoch Hooks Interface

The mint module implements the EpochHooks interface:

type EpochHooks interface {
BeforeEpochStart(ctx context.Context, epochIdentifier string, epochNumber int64) error
AfterEpochEnd(ctx context.Context, epochIdentifier string, epochNumber int64) error
}

BeforeEpochStart

Called before an epoch starts. The mint module does not perform any action in this hook.

func (k Keeper) BeforeEpochStart(ctx context.Context, epochIdentifier string, epochNumber int64) error {
// no-op
return nil
}

AfterEpochEnd

Called after an epoch ends. This is where all minting logic occurs.

Execution Conditions

Each invocation runs through these guards in order:

  1. Epoch Identifier Matches: the epochIdentifier must match the configured params.EpochIdentifier. If not, the hook returns immediately and does not advance any state.
  2. Minting Has Started: the epochNumber must be >= params.MintingRewardsDistributionStartEpoch. Before that, the hook returns without recording anything.
  3. Max Supply: if currentSupply >= params.MaxSupply, the hook skips the mint+distribute step but still records the observed burn and updates the snapshot (see Burn Replacement below). This keeps the history collections dense.

Minting Flow

The full pseudocode lives below in Burn Replacement → Augmented Pseudocode, which is the canonical description of AfterEpochEnd. At a glance, the hook:

  1. Reads bank.GetSupply(adt) to capture the post-burn, pre-mint state of the epoch that just ended.
  2. Computes this epoch's observed burn via supply diff against the persisted snapshot (zero on first run; clamped to zero on negative diff).
  3. Applies the geometric reward-decay schedule (reduction-period check, history write).
  4. Computes the replacement component r · B_{n-1} from the prior epoch's recorded burn.
  5. Caps reward + replacement at MaxSupply - currentSupply.
  6. Mints+distributes through the existing fee-collector / community-pool path.
  7. Writes the three new history collections (BurnHistoryColl, ReplacementMintHistoryColl, LastEpochSupplyColl) and emits the augmented mint_epoch_end event.

The distribution step is unchanged from prior behaviour: the minted amount is split per params.DistributionProportions.Staking between the fee collector and the community pool.

Burn Replacement

In addition to the scheduled reward, AfterEpochEnd mints a replacement component that compensates the network for adt burnt during the previous epoch. The summary below augments the flow described above.

Augmented Pseudocode

func (k Keeper) AfterEpochEnd(ctx, epochIdentifier, n) error {
params := k.GetParams(ctx)
if epochIdentifier != params.EpochIdentifier { return nil }
if n < params.MintingRewardsDistributionStartEpoch { return nil }

// 1. Read supply BEFORE any mint in this hook.
currentSupply := k.bankKeeper.GetSupply(ctx, params.MintDenom).Amount

// 2. Existing reward-schedule logic (reduction check + EpochProvisions).
rewardScheduled := minter.EpochProvision(params).Amount

// 3. Compute B_n via supply diff against last epoch's snapshot.
lastSnapshot, err := k.LastEpochSupplyColl.Get(ctx)
firstRun := errors.Is(err, collections.ErrNotFound)
burnThisEpoch := math.ZeroInt()
if !firstRun {
burnThisEpoch = lastSnapshot.Sub(currentSupply)
if burnThisEpoch.IsNegative() {
// Defensive clamp: sole-minter invariant violated.
k.Logger(ctx).Error("supply increased unexpectedly between epochs")
burnThisEpoch = math.ZeroInt()
}
}

// 4. Replacement uses LAST epoch's burn (n-1), not this epoch's.
replacement := math.ZeroInt()
if !firstRun && !params.BurnReplacementRatio.IsZero() {
if lastBurn, err := k.BurnHistoryColl.Get(ctx, n-1); err == nil {
lastBurnInt, _ := math.NewIntFromString(lastBurn)
replacement = params.BurnReplacementRatio.MulInt(lastBurnInt).TruncateInt()
}
}

// 5. Cap at MaxSupply headroom; replacement-priority bookkeeping.
headroom := params.MaxSupply.TruncateInt().Sub(currentSupply)
if headroom.IsNegative() { headroom = math.ZeroInt() }
cappedTotal := rewardScheduled.Add(replacement)
if cappedTotal.GT(headroom) { cappedTotal = headroom }
replacementRecorded := replacement
if replacementRecorded.GT(cappedTotal) { replacementRecorded = cappedTotal }
rewardRecorded := cappedTotal.Sub(replacementRecorded)

// 6. Mint and distribute (skipped if cappedTotal is zero).
if cappedTotal.IsPositive() {
mintedCoin := sdk.NewCoin(params.MintDenom, cappedTotal)
if err := k.mintCoins(ctx, sdk.NewCoins(mintedCoin)); err != nil { return err }
if err := k.DistributeMintedCoin(ctx, mintedCoin); err != nil { return err }
}

// 7. Persist state — ALWAYS, even at-cap or zero-mint, so history is dense.
k.BurnHistoryColl.Set(ctx, n, burnThisEpoch.String())
k.ReplacementMintHistoryColl.Set(ctx, n, replacementRecorded.String())
k.LastEpochSupplyColl.Set(ctx, currentSupply.Add(cappedTotal))

// 8. Emit augmented event (see Events section).
return nil
}

Ordering Invariants

The implementation must preserve three invariants:

  1. bank.GetSupply is read before any mint in this hook. S_current reflects the post-burn, pre-mint state of epoch n — otherwise the replacement minted by this same hook would be (incorrectly) counted as a recovery of burns.
  2. History writes (BurnHistoryColl[n], ReplacementMintHistoryColl[n]) happen after the mint of epoch n. On a mint or distribute failure the transaction reverts and no partial history is persisted.
  3. LastEpochSupplyColl is written last, storing currentSupply + cappedTotal. That value — the supply after this epoch's mint — becomes the reference point against which the next hook computes B_{n+1}.

Sequence Diagram

Example Execution Timeline

Genesis State (Epoch 0-26)

Params.MintingRewardsDistributionStartEpoch = 27
Current epoch: 0-26
Action: Hook returns early, no minting

First Minting (Epoch 27)

Current epoch: 27
EpochProvisions: 76923076923075000000000000
Action:
- Initialize last_reduction_epoch = 27
- Mint 76923076923075000000000000 adt
- Distribute to staking (100%)
- Emit event

Regular Minting (Epochs 28-51)

Current epoch: 28-51
EpochProvisions: 76923076923075000000000000 (unchanged)
Action:
- Mint 76923076923075000000000000 adt each epoch
- Distribute according to proportions
- Emit events

First Reduction (Epoch 79)

Current epoch: 79 (= 27 + 52)
Last reduction: 27
ReductionPeriod: 52
Action:
- Store old provisions: epoch_provisions_history[78] = 76923076923075000000000000
- Apply reduction: 76923076923075000000000000 * 0.8 = 61538461538460000000000000
- Store reduction: reduction_epochs[79] = 0.8
- Update last_reduction_epoch = 79
- Mint 61538461538460000000000000 adt
- Distribute and emit event

Approaching Max Supply

Current epoch: X
Current supply: 99,950,000,000,000,000,000,000,000,000
Max supply: 100,000,000,000,000,000,000,000,000,000
Epoch provisions: 100,000,000,000,000,000,000,000,000
Action:
- Calculate: would mint 100T (in 18-decimal adt), but only 50T remaining
- Adjust: mint only 50,000,000,000,000,000,000,000,000
- Distribute and emit event

At Max Supply

Current epoch: X+1
Current supply: 100,000,000,000,000,000,000,000,000,000 (max reached)
Action:
- Check: supply >= max_supply
- Skip mint/distribute
- Still write BurnHistory[n] (observed burn), ReplacementMintHistory[n]=0, and LastEpochSupplyColl=currentSupply so
history stays dense
- Log: "Max supply reached, stopping minting"

Events

TypeMintEpochEnd

Emitted after each successful minting operation. (Not emitted on the at-max-supply branch.)

Attributes:

  • epoch_number: The epoch number when minting occurred
  • epoch_provisions: Current epoch provisions (may have been reduced)
  • amount: Total minted this epoch (reward + replacement, capped at headroom)
  • total_supply_after: Total supply after minting
  • max_supply: Configured maximum supply
  • last_reduction_epoch: Epoch of last reduction
  • burnt_last_epoch: B_{n-1} — adt observed burnt during the prior epoch (decimal string math.Int)
  • replacement_amount: replacementRecorded — the replacement portion of amount, after replacement-priority bookkeeping
  • reward_amount: rewardRecorded — the reward portion (amount − replacement_amount)
  • burn_replacement_ratio: current value of params.BurnReplacementRatio (decimal string math.LegacyDec)

Error Handling

The hook is designed to be fault-tolerant:

  • If any operation fails (e.g., minting coins, sending coins), the error is returned and the entire state change is reverted
  • This ensures atomicity - either all minting operations succeed or none do
  • Errors are logged for debugging

Performance Considerations

  • Epoch-based execution: Minting once per epoch (e.g., weekly) is far more efficient than per-block minting
  • Lazy history storage: History is only stored when reductions occur or minting happens
  • Early returns: Multiple guard clauses ensure minimal computation when minting conditions aren't met
  • Predictable gas costs: Fixed operations per epoch make gas costs predictable