Skip to main content
Version: Next

Income

Module name and store key: income. Consensus version 1.

x/income distributes yield to the holders of a denom, and pays out the claimable amounts attached to individual NFTs. It stores distribution state, never a schedule: the chain does not know a coupon calendar, so an administrator funds a stream and the chain takes care of who is owed what.

Accounting is lazy. There is no per-block sweep, and no reward is lost because a holder did not claim.

Two mechanisms​

Fungible balances use a reward-per-share accumulator. Funds are sent to a yield address, and a single global index for the (eligibility, reward) pair moves. A holder's right is then purely arithmetic against their balance.

NFTs use an earmarked claimable. An administrator pushes a per-token amount, and the claim is paid from the same yield address on the holder's instruction.

State​

IncomeConfig { denom, yield_address, has_income, distributed_total }
Stream { eligibility_denom, reward_denom, index, total_distributed, last_update }
AccountStream { address, eligibility_denom, reward_denom, reward_index, pending, last_update }
NFTClaimable { class_id, token_id, reward_denom, amount, status }

A stream is uniquely identified by the pair. AccountStream is a holder's checkpoint in that stream. index and reward_index are scaled by 1e18; pending is in whole reward units.

The yield address defaults to the income module account when a configuration leaves it empty.

The accumulator​

Distributing​

MsgDistribute moves the reward from the sender to the yield address and then advances the index, atomically:

delta = floor(amount * 1e18 / eligibility_supply)
index += delta

The eligibility supply is the total bank supply of the eligibility denom. There is no time weighting and no per-block accrual: the index moves only when a distribution happens.

Two rejections happen before any transfer:

  • The denom must be registered with has_income set, or the message fails with ErrIncomeDisabled.
  • delta must be greater than zero. A distribution too small to move the 1e18 index is rejected, so the funds are never stranded in the yield address for nothing.

Accruing​

A holder's pending amount is the difference between the stream index and their checkpoint, applied to their balance:

pending += floor(balance * (index - reward_index) / 1e18)
reward_index = index

A zero balance or a zero index delta leaves the holder untouched, so an untouched holder never accumulates a record.

Settling on transfer​

x/income installs itself as a bank send restriction with PrependSendRestriction, which places it before the tokenfactory blacklist check. On every send, for every non-zero coin, it settles the sender and the recipient for that eligibility denom using their pre-transfer balances.

This is what makes an unpushed, unclaimed holder correct: the moment a balance moves, the entitlement for the old balance is frozen into pending.

Two addresses never checkpoint: the income module account and the configuration's own yield address. The reserve that pays the claims can therefore never accrue a liability it cannot settle.

Messages​

MessageSignerWho may send it
MsgSetIncomeauthorityThe module authority, a configured income admin, the tokenfactory admin of the denom, or the NFT class issuer.
MsgDistributesenderAn eligibility admin: the authority, an income admin, or the tokenfactory denom admin.
MsgPushNFTClaimablesenderThe module authority, an income admin, or the class issuer.
MsgClaimRewardssenderAnyone, for their own rewards.
MsgUpdateParamsauthorityThe module authority.

The yield address is locked once it is in use​

Setting a yield address on an existing configuration never silently overwrites a custom pool. Rotating to a different address is only allowed while the denom has no possible claims: no distributed total, no advanced stream, no pending balance, and no active NFT claimable. Otherwise the message fails with ErrYieldAddressLocked.

Claiming​

MsgClaimRewards settles every stream the sender has a checkpoint in, and every other stream where they hold a positive balance, then pays out. Before any transfer it verifies that every yield pool involved is solvent; if one is short, the whole claim is rejected rather than partially paid.

The NFT portion of a claim is bounded by max_nft_claims, default 50. The message can lower that cap but never raise it, and it may only claim tokens the sender currently owns.

After a payout the fungible checkpoints are zeroed and the NFT claimables are set to amount = 0 with status = "claimed".

NFT claimables​

A claimable is a flat earmark keyed by (class_id, token_id, reward_denom). Amounts are additive: several pushes accumulate, and a push to a previously claimed record reactivates it.

:::note There is no period model The current code has no period, epoch, or schedule field. "Claimable" is simply a pushed amount, and pushing twice for two scheduled coupons is expressed as two messages. :::

A claimable must satisfy active => amount > 0 and claimed => amount == 0. Claimables that cannot be claimed can never dangle, because the validation would reject them.

If the token is burned before it is claimed, the burn hook releases the earmark back into the shared yield pool, so the funds return to the pool rather than staying reserved for a token that no longer exists.

Compliance interaction​

MsgClaimRewards rejects a blocklisted holder, and it re-checks every settled checkpoint inside the same transaction before zeroing anything. A blocklist entry therefore stops a claim outright.

The account-level freeze enforced by tokenfactory does not block claiming. That asymmetry is deliberate: a freeze is a temporary transfer control, and it should not confiscate income the holder has already accrued, whereas a sanctions block stops the payout entirely.

Params​

FieldDefaultMeaning
max_nft_claims50NFT claimables per claim message.
max_streams1,000Global cap on stream records, checked when a new one would be created.

The module configuration also lists income_admins, the addresses allowed to register, distribute, and push besides the authority. On the default build the list is empty, so only the gov module address and the relevant denom or class admin can act.

Queries​

QueryReturns
ParamsThe module parameters.
IncomeConfigThe configuration for one denom.
AccountStreamA holder's checkpoint, settled against the live index without writing.
ClaimableEverything an address can claim, aggregated, including tokens found by scanning the holder's tokens.
AllStreamsPaginated streams.
StatsThe supply, total distributed, and stream count for a denom.

Claimable is the query to use for a holder-facing balance view: it includes streams the holder has a balance in but no checkpoint for yet, and it scans the holder's tokens in pages of 100.

Invariants​

Every 10,000 blocks, EndBlock checks that every yield pool holds at least the total unpaid liability for its reward denom. The figure includes holders who have never been checkpointed, derived as supply - yield pool balance - checkpointed balances, plus the active NFT earmarks.

A violation emits income_invariant_violation and returns no error, so block finalization is never interrupted. The same check runs in InitGenesis, where it does fail the genesis.

Events​

EventAttributes
income_setdenom, yield_address, has_income
income_distributeeligibility_denom, reward_denom, amount, index, supply
income_nft_pushclass_id, token_id, reward_denom, amount, claimable
income_claimholder, reward_denom, amount, streams_count, nft_claims_paid — one per reward denom, sorted
income_claim_nftholder, class_id, token_id, reward_denom, amount
income_nft_burn_refundclass_id, token_id, reward_denom, amount, holder
income_invariant_violationheight, error

Errors​

CodeIdentifierRaised when
2ErrInvalidDenomThe denom or NFT reference is malformed.
3ErrInvalidAmountAn amount is nil, zero, or negative.
5ErrUnauthorizedThe signer is not allowed to send this message.
6ErrIncomeNotRegisteredThe denom has no income configuration.
7ErrIncomeDisabledThe configuration exists but has_income is false.
9ErrInsufficientYieldA yield pool cannot cover a claim.
10ErrInvalidNFTRefThe class or token does not exist.
11ErrHolderRestrictedThe claiming holder is blocklisted.
13ErrYieldExhaustedThe eligibility denom has no supply.
14ErrStreamLimitExceededCreating this stream would exceed max_streams.
18ErrClassNotAllowedThe class is not registered for income.
19ErrYieldAddressLockedThe yield address cannot be rotated while claims exist.
20ErrInvalidAccountingStateA checkpoint index is ahead of the stream index.

CLI​

centerchaind query income params
centerchaind query income config <denom>
centerchaind query income stream <address> <eligibility-denom> <reward-denom>
centerchaind query income claimable <address>
centerchaind query income streams
centerchaind query income stats <denom>

centerchaind tx income set-income <denom> <yield-address> <bool>
centerchaind tx income distribute <eligibility-denom> <reward-denom> <amount>
centerchaind tx income claim <eligibility-denom> <reward-denom>
centerchaind tx income push-nft <class-id> <token-id> <reward-denom> <amount>
centerchaind tx income update-params

All six queries are also served over REST under /centerchain/income/v1/....