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:
- Epoch Identifier Matches: the
epochIdentifiermust match the configuredparams.EpochIdentifier. If not, the hook returns immediately and does not advance any state. - Minting Has Started: the
epochNumbermust be>= params.MintingRewardsDistributionStartEpoch. Before that, the hook returns without recording anything. - 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:
- Reads
bank.GetSupply(adt)to capture the post-burn, pre-mint state of the epoch that just ended. - Computes this epoch's observed burn via supply diff against the persisted snapshot (zero on first run; clamped to zero on negative diff).
- Applies the geometric reward-decay schedule (reduction-period check, history write).
- Computes the replacement component
r · B_{n-1}from the prior epoch's recorded burn. - Caps
reward + replacementatMaxSupply - currentSupply. - Mints+distributes through the existing fee-collector / community-pool path.
- Writes the three new history collections (
BurnHistoryColl,ReplacementMintHistoryColl,LastEpochSupplyColl) and emits the augmentedmint_epoch_endevent.
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:
bank.GetSupplyis read before any mint in this hook.S_currentreflects the post-burn, pre-mint state of epochn— otherwise the replacement minted by this same hook would be (incorrectly) counted as a recovery of burns.- History writes (
BurnHistoryColl[n],ReplacementMintHistoryColl[n]) happen after the mint of epochn. On a mint or distribute failure the transaction reverts and no partial history is persisted. LastEpochSupplyCollis written last, storingcurrentSupply + cappedTotal. That value — the supply after this epoch's mint — becomes the reference point against which the next hook computesB_{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 occurredepoch_provisions: Current epoch provisions (may have been reduced)amount: Total minted this epoch (reward + replacement, capped at headroom)total_supply_after: Total supply after mintingmax_supply: Configured maximum supplylast_reduction_epoch: Epoch of last reductionburnt_last_epoch:B_{n-1}— adt observed burnt during the prior epoch (decimal stringmath.Int)replacement_amount:replacementRecorded— the replacement portion ofamount, after replacement-priority bookkeepingreward_amount:rewardRecorded— the reward portion (amount − replacement_amount)burn_replacement_ratio: current value ofparams.BurnReplacementRatio(decimal stringmath.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