Skip to main content

Design

The main design principle for x/mint module is to provide a predictable, epoch-based token minting mechanism with a maximum supply cap, tailored for Dchain's requirements.

Design Principles

  • Epoch-based rather than block-based: Minting occurs at epoch boundaries (e.g., weekly) rather than every block, providing predictability and reducing computational overhead
  • Maximum supply enforcement: Hard cap on total token supply prevents unlimited inflation
  • Geometric reduction schedule: Predictable reduction in minting over time
  • Flexible distribution: Configurable allocation between staking rewards and community pool
  • Burn replacement: Each epoch's mint = scheduled reward + a governance-controlled replacement component that compensates the network for adt burnt during the prior epoch, while still preserving the MaxSupply cap exactly

Minting Flow

End-to-end sequence inside a single AfterEpochEnd(n) invocation, including burn replacement:

Ordering invariants the implementation preserves:

  1. bank.GetSupply is read before any mint in this hook — so S_current reflects post-burn, pre-mint state of epoch n.
  2. History writes (Burn[n], Repl[n]) happen after the mint of epoch n — on a mint or distribution failure, no partial history is persisted.
  3. LastEpochSupplyColl is written last, and stores S_current + cappedTotal — i.e., the supply after this epoch's mint, which becomes next epoch's reference point.

State Management

The mint module maintains several pieces of state:

Params

Configurable parameters that define the minting behavior:

  • mint_denom: The denomination to mint (e.g., "adt")
  • genesis_epoch_provisions: Initial minting amount per epoch
  • epoch_identifier: Which epoch to use (e.g., "week")
  • reduction_period_in_epochs: How often to reduce (e.g., 52 for yearly)
  • reduction_factor: Multiplier for reduction (e.g., 0.8 for 20% reduction)
  • distribution_proportions: How to split minted tokens
  • minting_rewards_distribution_start_epoch: When to start minting
  • max_supply: Maximum total supply cap
  • burn_replacement_ratio (r): Multiplier in [0, 1] applied to the prior epoch's observed burn to compute this epoch's replacement mint. 0 disables replacement (default at upgrade); 1 fully neutralises burns at steady state

Minter

Runtime state tracking current provisions:

  • epoch_provisions: Current minting amount per epoch (reduces over time)

Historical Tracking

For transparency and auditability:

  • last_reduction_epoch: Last epoch when reduction was applied
  • reduction_epochs: Map of epoch number → reduction factor applied
  • last_epoch_supply: Single value — adt supply snapshot taken at the end of the previous AfterEpochEnd (after that epoch's mint). Used as the reference point for computing the next epoch's burn diff
  • burn_history: Map of epoch number → adt burnt during that epoch (math.Int as decimal string)
  • replacement_mint_history: Map of epoch number → replacement portion of that epoch's mint (math.Int as decimal string)
  • reward_mint_history: Map of epoch number → reward portion of that epoch's mint (math.Int as decimal string). Supersedes the legacy epoch_provisions_history (which stored scheduled LegacyDec provisions); the v1 → v2 migration backfills this collection from the legacy one via TruncateInt and then clears the legacy data

burn_history, replacement_mint_history, and reward_mint_history grow one entry per epoch indefinitely. No pruning is built in; if pruning is added later it should apply uniformly across all three collections.

Reduction Logic

The reduction mechanism ensures decreasing token emission:

if currentEpoch >= lastReductionEpoch + reductionPeriodInEpochs:
oldProvisions = minter.EpochProvisions
newProvisions = oldProvisions * reductionFactor

// Store reduction history
storeReductionEpoch(currentEpoch, reductionFactor)

// Update state
minter.EpochProvisions = newProvisions
lastReductionEpoch = currentEpoch

Per-epoch reward history is recorded separately in reward_mint_history every epoch (see Hook Logic § Writes), not only at reduction boundaries.

Max Supply Protection

The module ensures the maximum supply is never exceeded:

currentSupply = bank.GetSupply(mintDenom)
if currentSupply >= maxSupply:
// Stop minting
return

provisionedAmount = minter.EpochProvision(params)
if currentSupply + provisionedAmount > maxSupply:
// Mint only up to max supply
provisionedAmount = maxSupply - currentSupply

// Mint the adjusted amount
mintCoins(provisionedAmount)

When burn replacement is active (r > 0), the cap check is applied to the combined reward + replacement total — see Burn Replacement § MaxSupply Interaction.

Distribution Mechanism

Minted tokens are distributed proportionally:

stakingAmount = mintedCoin * distributionProportions.Staking
communityAmount = mintedCoin * distributionProportions.CommunityPool

// Note: stakingAmount + communityAmount must equal mintedCoin
// This is enforced by params validation (proportions must sum to 1.0)

sendToFeeCollector(stakingAmount) // For validator/delegator rewards
fundCommunityPool(communityAmount) // For community initiatives

Governance Integration

The mint parameters can be updated through governance via MsgUpdateParams. Only the governance module (or configured authority) can modify parameters, ensuring community control over the minting schedule. The handler at x/mint/keeper/msg_server.go gates execution on ms.authority == req.Authority and returns govtypes.ErrInvalidSigner for non-authority callers — this same gate covers burn_replacement_ratio updates with no additional code.

burn_replacement_ratio validation: must be ≥ 0 and ≤ 1. Default value at upgrade is "0".


Burn Replacement

Each epoch's mint is the sum of the scheduled reward and a replacement component that compensates the network for adt burnt during the prior epoch:

Mn=Rn+rBn1M_n = R_n + r \cdot B_{n-1}

where R_n is the scheduled reward, B_{n-1} is the adt burn observed during the previous epoch, and r ∈ [0, 1] is the governance-controlled BurnReplacementRatio param (default 0, replacement disabled).

Goal

Extend x/mint's per-epoch emission so each epoch's mint = reward + replacement, where:

  • Reward continues to follow the geometric-decay schedule (EpochProvisions × ReductionFactor every ReductionPeriodInEpochs).
  • Replacement is an emission component that compensates the network for adt burnt during the prior epoch, scaled by a governance-controlled ratio r ∈ [0, 1].

The 100 bn MaxSupply cap remains sacred: replacement only refills under the cap, never above it.

Motivation

x/notary burns adt as a notarisation fee. Without replacement, the mint module is unaware of those burns — it mints purely on schedule until MaxSupply is reached, so cumulative emission slowly drifts below cap as burns accumulate. The replacement mechanism allows the network to neutralise (or partially neutralise) burns via governance, without breaking the MaxSupply contract. Future modules that burn adt are supported automatically — the mint module observes total supply, not module-specific calls.

Assumed Invariant — Sole Minter

x/mint is the only module that calls bank.MintCoins for the adt denom.

This is the load-bearing assumption that makes supply-diff burn observation work. The invariant is enforced by:

  • Code comment in x/mint/keeper/keeper.go adjacent to the burn-calculation logic.
  • This documentation.
  • A defensive runtime clamp: if observed supply diff is negative, burn is treated as zero and an error is logged. This keeps the chain live in the event of an invariant violation but produces a clearly grep-able warning.

If a future requirement introduces a second adt minter, this design must be revisited (likely replaced with active per-burn reporting hooks).

Burn Observation — Supply Diff

At each epoch boundary, the mint keeper computes:

B_n = max(0, S_prev_snapshot - S_current)

where S_prev_snapshot is the adt supply persisted at the end of the previous AfterEpochEnd call (after that epoch's mint) and S_current is bank.GetSupply(adt) at the start of the current call.

Since x/mint is the only minter, the only way supply can decrease between snapshots is via burns from any module. The mechanism is transparent to the burner — no coordination required, no per-module integration.

Formula

Indexing convention: Subscript n denotes "the epoch that just ended" at the moment AfterEpochEnd(n) fires. The mint computed in that hook is M_n. The burn observed in that hook (via supply diff against the snapshot persisted at the end of AfterEpochEnd(n-1)) is B_n — burns that occurred during epoch n. Replacement at end of epoch n is therefore a function of last epoch's (n−1) data, matching the informal "use last epoch's burn" framing.

The mint per epoch is:

Mn=Rn+rBn1M_n = R_n + r \cdot B_{n-1}

That is, this epoch's replacement is last epoch's total burn, scaled by the ratio. Nothing is subtracted from the burn; the replacement is a direct, linear function of B_{n-1}. Since B_{n-1} ≥ 0 by construction (negative supply diffs are clamped at observation time) and r ∈ [0, 1], replacement ≥ 0 always — no max(0, …) clamp is needed.

This is a feed-forward (open-loop) rule: M_n depends only on the external burn signal from the prior epoch and the deterministic reward schedule, never on its own output. It converges in one epoch when the burn rate stabilises.

Steady-state Behaviour

Assume burns and rewards are constant at B and R:

QuantityValue
Steady-state mint MR + r·B
Per-epoch supply change ΔS = M − BR − (1 − r)·B
Convergence to steady state1 epoch
Behaviour at r = 0replacement disabled; M = R, ΔS = R − B
Behaviour at r = 1full burn replacement; M = R + B, ΔS = R

At r = 1 with B > R, total mint exceeds reward and supply continues to grow at the scheduled rate. The MaxSupply cap bounds cumulative growth in all regimes. The rule is well-defined for any r ∈ [0, 1].

MaxSupply Interaction

The cap is preserved exactly. The new constraint:

capped_total = min(reward_scheduled + replacement_computed, MaxSupply − S_current)

Bookkeeping Rule — Replacement Priority

When reward_scheduled + replacement_computed exceeds available headroom, the recorded split prioritises replacement:

replacement_recorded = min(replacement_computed, capped_total)
reward_recorded = capped_total − replacement_recorded

Edge case: if replacement_computed ≥ capped_total, then reward_recorded = 0 and the entire epoch's mint is classified as replacement.

This choice is purely a recording convention. All minted coins flow through DistributeMintedCoin to the same destinations (fee collector + community pool) regardless of internal labelling. The rationale: replacement reflects observed economic activity (burns actually happened); the reward schedule yields to it at the cap.

Hook Logic

Writes per AfterEpochEnd(n) call

  • LastEpochSupplyColl (singleton) ← currentSupply + cappedTotal. Supply after this epoch's mint; used as the snapshot for next epoch's burn diff.
  • BurnHistoryColl[n]B_n (burns observed during epoch n). Written every epoch, even on at-cap early return. Zero if no burns or first-run.
  • ReplacementMintHistoryColl[n]replacement_recorded. Written every epoch. Zero on first-run or when replacement is zero.
  • RewardMintHistoryColl[n]reward_recorded. Written every epoch. Zero at cap if replacement absorbs the full headroom.

The legacy EpochProvisionsHistoryColl is no longer writtenRewardMintHistoryColl supersedes it. The v1 → v2 migration backfills RewardMintHistoryColl from the legacy data (TruncateInt) and clears the legacy collection.

Reads from history: BurnHistoryColl[n-1] (for B_{n-1}). That is the only history read.

First-run trace: On the first post-upgrade call, LastEpochSupplyColl is empty → firstRun = trueburnThisEpoch = 0, replacement = 0. The hook proceeds to mint the scheduled reward, then writes BurnHistoryColl[n] = 0, ReplacementMintHistoryColl[n] = 0, RewardMintHistoryColl[n] = rewardScheduled, and LastEpochSupplyColl = currentSupply + rewardScheduled. From the next call onwards, all history reads succeed.

At-cap trace: When currentSupply ≥ MaxSupply, headroom = 0, cappedTotal = 0. No mint. But all new history collections are still written (burn = B_n, replacement = 0, reward = 0, snapshot = currentSupply). Queries over historical epochs remain dense.

See the Minting Flow diagram at the top of this document for the full call sequence inside a single AfterEpochEnd(n) invocation.

Events

The TypeMintEpochEnd event carries four burn-replacement attributes alongside the existing ones (adding attributes to an existing event is backward-compatible for downstream indexers, which read attribute by key):

AttributeValue
burnt_last_epochB_{n-1} (math.Int as decimal string)
replacement_amountreplacement_recorded
reward_amountreward_recorded
burn_replacement_ratior as decimal string

Existing attributes (epoch_number, epoch_provisions, amount, total_supply_after, max_supply, last_reduction_epoch) are unchanged. amount continues to be the total minted (now reward + replacement).

A separate log line at Error level fires on negative supply diff. No event is emitted for the warning — it is purely an operator-facing signal.

Edge Cases

  • r = 0: replacement disabled; behaviour identical to the pre-burn-replacement schedule.
  • B_{n-1} = 0: no burns; replacement = r · 0 = 0.
  • First post-upgrade epoch: end of first epoch: snapshot persisted (after the epoch's reward mint), BurnHistoryColl[n]=0, ReplacementMintHistoryColl[n]=0. Replacement becomes possible from the second post-upgrade epoch.
  • Epoch identifier mismatch: existing early-return; nothing recorded. The snapshot also does not advance; the next valid-identifier epoch's burn diff will capture burns across the elapsed interval.
  • Before MintingRewardsDistributionStartEpoch: existing early-return; burns in this period are not replaced.
  • Negative supply diff: clamped to 0; error logged.
  • At MaxSupply boundary: total clamped to headroom = MaxSupply − currentSupply. Replacement gets priority in bookkeeping (recorded up to cappedTotal; reward absorbs the shortfall, possibly reaching zero). History still written.

Worked Example (3 epochs)

Parameters: R = 50 (constant, no reduction), r = 0.5, MaxSupply far above current supply. Starting supply (just before epoch 1's hook fires): 1000. Burns of 70 adt occur during epoch 2 and during epoch 3 (assume zero burns during epoch 1 for simplicity).

Epoch 1 (first run) — snapshot in: absent · S_curr = 1000 · B_n = 0 (first-run skips diff) · B_{n-1} = — · replacement = 0 · mint = 50 · supply after = 1050 · writes: Burn[1]=0, Repl[1]=0.

Epoch 2 — snapshot in: 1050 · S_curr = 980 (= 1050 − 70 burnt) · B_n = 70 · B_{n-1} = 0 (from Burn[1]) · replacement = 0.5 · 0 = 0 · mint = 50 · supply after = 1030 · writes: Burn[2]=70, Repl[2]=0.

Epoch 3 — snapshot in: 1030 · S_curr = 960 (= 1030 − 70 burnt) · B_n = 70 · B_{n-1} = 70 (from Burn[2]) · replacement = 0.5 · 70 = 35 · mint = 85 · supply after = 1045 · writes: Burn[3]=70, Repl[3]=35.

Replacement at end of epoch n always uses B_{n-1} read from the history collection, not from the current epoch's freshly-observed burn. The current epoch's burn observation is stored for use one epoch later.

At epoch 3 the chain mints 85 (50 reward + 35 replacement) on top of supply that dropped to 960 — net supply moves to 1045, recovering some of the burn-driven loss. With sustained constant burns of 70 and r = 0.5, the system converges in one further epoch to a steady-state mint of 50 + 0.5·70 = 85 per epoch, with net per-epoch supply change 85 − 70 = 15 (matching R − (1 − r)·B = 50 − 0.5·70 = 15).

Migration

Consensus version bumps from 1 → 2 when burn replacement is introduced. The handler in x/mint/migrations/v2/migrate.go does two things:

  1. Sets burn_replacement_ratio to 0 (the safe default). The mechanism is inert until governance later raises r, so chain emission is byte-identical to v1 immediately after upgrade.
  2. Backfills RewardMintHistoryColl from the legacy EpochProvisionsHistoryColl, converting each LegacyDec scheduled-provision entry to math.Int via TruncateInt, then clears the legacy collection. At v1 there is no replacement, so scheduled provisions equal actually-minted reward modulo truncation — the copy is lossless under v1 semantics and preserves the historical reward series under the new key.

The migration intentionally does not seed LastEpochSupplyColl. The first post-upgrade AfterEpochEnd detects the missing snapshot, records bank.GetSupply(adt) after minting reward, and skips replacement for that epoch. Replacement becomes operational from the second post-upgrade epoch onwards, but only when governance later sets r > 0.

This intentionally drops any burns that occur between the upgrade height and the first post-upgrade epoch end. The trade-off was accepted because the alternative (seeding the snapshot at migration time) adds complexity for negligible benefit when r = 0 at activation anyway.

Genesis

GenesisState includes the burn-replacement collections so chain export/import is round-trip symmetric:

  • last_epoch_supply — optional; if absent, the first AfterEpochEnd after import seeds it.
  • burn_history — repeated entries of (epoch_number, amount).
  • replacement_mint_history — repeated entries of (epoch_number, amount).
  • reward_mint_history — repeated entries of (epoch_number, amount).

InitGenesis writes each field if present. ExportGenesis exports all four.

Queries

History collections are exposed via a unified range-query surface — see x/mint/docs/06_client.md for the live CLI, gRPC, and REST contract. BurnHistory, ReplacementMintHistory, RewardMintHistory, and ReductionFactor share a common request shape (epoch_number + cosmos.base.query.v1beta1.PageRequest); LastEpochSupply is parameterless.