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_incomeset, or the message fails withErrIncomeDisabled. deltamust 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
| Message | Signer | Who may send it |
|---|---|---|
MsgSetIncome | authority | The module authority, a configured income admin, the tokenfactory admin of the denom, or the NFT class issuer. |
MsgDistribute | sender | An eligibility admin: the authority, an income admin, or the tokenfactory denom admin. |
MsgPushNFTClaimable | sender | The module authority, an income admin, or the class issuer. |
MsgClaimRewards | sender | Anyone, for their own rewards. |
MsgUpdateParams | authority | The 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
| Field | Default | Meaning |
|---|---|---|
max_nft_claims | 50 | NFT claimables per claim message. |
max_streams | 1,000 | Global 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
| Query | Returns |
|---|---|
Params | The module parameters. |
IncomeConfig | The configuration for one denom. |
AccountStream | A holder's checkpoint, settled against the live index without writing. |
Claimable | Everything an address can claim, aggregated, including tokens found by scanning the holder's tokens. |
AllStreams | Paginated streams. |
Stats | The 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
| Event | Attributes |
|---|---|
income_set | denom, yield_address, has_income |
income_distribute | eligibility_denom, reward_denom, amount, index, supply |
income_nft_push | class_id, token_id, reward_denom, amount, claimable |
income_claim | holder, reward_denom, amount, streams_count, nft_claims_paid — one per reward denom, sorted |
income_claim_nft | holder, class_id, token_id, reward_denom, amount |
income_nft_burn_refund | class_id, token_id, reward_denom, amount, holder |
income_invariant_violation | height, error |
Errors
| Code | Identifier | Raised when |
|---|---|---|
| 2 | ErrInvalidDenom | The denom or NFT reference is malformed. |
| 3 | ErrInvalidAmount | An amount is nil, zero, or negative. |
| 5 | ErrUnauthorized | The signer is not allowed to send this message. |
| 6 | ErrIncomeNotRegistered | The denom has no income configuration. |
| 7 | ErrIncomeDisabled | The configuration exists but has_income is false. |
| 9 | ErrInsufficientYield | A yield pool cannot cover a claim. |
| 10 | ErrInvalidNFTRef | The class or token does not exist. |
| 11 | ErrHolderRestricted | The claiming holder is blocklisted. |
| 13 | ErrYieldExhausted | The eligibility denom has no supply. |
| 14 | ErrStreamLimitExceeded | Creating this stream would exceed max_streams. |
| 18 | ErrClassNotAllowed | The class is not registered for income. |
| 19 | ErrYieldAddressLocked | The yield address cannot be rotated while claims exist. |
| 20 | ErrInvalidAccountingState | A 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/....