Token factory
Module name and store key: tokenfactory. Consensus version 3.
x/tokenfactory is the base layer of CenterChain. It lets any account create its
own bank-native denom, administer it, and decide who may hold or receive it. It
also implements the two-phase swap authorization, which is the only way to move
one CenterChain denom into another outside a liquidity pool.
The denom model
A denom is created by the account that will administer it:
factory/{creator}/{subdenom}
creator is the signer's bech32 account address, and the creator becomes the
denom's admin. The full string is then validated by sdk.ValidateDenom, so
it behaves as an ordinary bank denom everywhere else.
| Limit | Value |
|---|---|
| Subdenom length | 44 bytes |
| Creator segment length | 75 bytes |
| Reserved namespace | factory/liquidity/pool/{pool_id} |
The liquidity namespace is reserved. A genesis file may only contain such a denom if the liquidity module account administers it, and it must match a registered pool id with a non-zero id. That is what stops an account from minting a counterfeit LP share.
Creating a denom writes three things: bank denom metadata, a
DenomAuthorityMetadata record, and a creator-to-denom index entry.
Per-denom administration
DenomAuthorityMetadata is the whole administrative surface of a denom.
| Field | Meaning |
|---|---|
admin | The account that may mint, burn, and change policy. The creator at creation. |
delegates | Accounts that bypass the restricted-denom rule for this denom. |
is_restricted | When set, the denom may only be sent to an address that already holds it. |
Change the admin with MsgChangeAdmin. It is a single step and emits an event;
the previous admin cannot block it.
Messages
| Message | Signer | Requires |
|---|---|---|
MsgCreateDenom | sender | Nothing; the creator becomes the admin. Pays the creation fee and gas. |
MsgMint | sender | Denom admin, or the module authority through the sudo-mint capability. |
MsgBurn | sender | The denom admin, and only its own balance. Burning from another address is rejected. |
MsgForceTransfer | sender | Nothing — the message is always rejected. It stays in the API surface so a caller gets a stable error instead of "unimplemented". |
MsgChangeAdmin | sender | Denom admin. |
MsgSetDenomMetadata | sender | Denom admin and enable_metadata; the module authority may also set LP denom metadata. |
MsgAddDelegate / MsgRemoveDelegate | sender | Denom admin. |
MsgAddBlacklist / MsgRemoveBlacklist | sender | Denom admin. An account cannot blacklist itself. |
MsgSetDenomRestriction | sender | Denom admin. |
MsgSetPoolable | sender | Denom admin. |
MsgSetPoolDenylist | authority | The module authority, which is the gov account by default. |
MsgUpdateParams | authority | The module authority. |
Capabilities are a module-wide allow-list, configured at wiring time. The shipped
chain enables enable_metadata, enable_force_transfer, enable_burn_from,
enable_admin_sudo_mint, and enable_community_pool_fee_funding. A capability
that is absent disables the message; the check is an exact string match.
Two of those switches no longer gate anything since v1.1:
enable_force_transfer and enable_burn_from. Holder asset protection rejects the
force-transfer and burn-from paths unconditionally, so the message fails whether
or not the capability is set. See
Holder asset protection.
Params
| Field | Default | Notes |
|---|---|---|
denom_creation_fee | 10000000ucen | Charged at denom creation, and burned unless community-pool funding is enabled. |
denom_creation_gas_consume | 2000000 | Extra gas consumed by the create-denom path, so creation costs more than a normal message. |
Only denom_creation_fee is validated.
Compliance: the send restriction
tokenfactory installs itself as a bank send restriction through
bankKeeper.AppendSendRestriction, and it separately owns the NFT transfer
policy. One function decides every send.
CanSendTokens(ctx, from, to, coins):
for each coin:
1. recipient blacklisted for this denom -> ErrRecipientLocked
2. sender blacklisted for this denom -> ErrSenderLocked
3. denom has authority metadata and is_restricted:
sender is the admin or a delegate -> allowed
the coin is a swap leg -> allowed
recipient already holds the denom -> allowed
otherwise -> ErrRestrictedSender
Denoms with no authority metadata pass through untouched, so a plain SDK denom is never affected by this logic. The blacklist is per denom: being blocked on one token does not block another.
The restricted-denom rule
is_restricted is a per-denom, on-chain allow-list expressed as a balance
check. Under RESTRICTED, an address can only receive the denom from an admin
or a delegate, or in a transfer from somebody who already holds it. This is the
mechanism that lets an issuer close circulation to new addresses without
freezing existing holders.
It is deliberately not a KYC list. The chain knows that a recipient holds a balance; it cannot know who that recipient is.
State limits
| Limit | Value | Enforced when |
|---|---|---|
| Factory denoms | 100,000 | Denom creation. |
| Pool policy records | 100,000 | First MsgSetPoolable and first MsgSetPoolDenylist. |
| Blacklisted addresses | 100,000 | Globally, across all denoms. |
The counters are seeded by a consensus migration from version 1 to 2, and
ExportGenesis fails if any collection exceeds its cap.
Pool eligibility
A denom can only enter a liquidity pool if it is poolable. Non-tokenfactory
denoms — the native coin, and assets arriving over IBC — are eligible unless the
governance denylist says otherwise. A tokenfactory denom must additionally have
bank metadata, a non-empty admin, and its poolable flag set.
The liquidity module sets poolable = true automatically for the LP denom it
creates, and clears it when a pool is retired.
Two-phase authorizations
The module lets a holder grant a limited, single-use, time-boxed permission and a named executor settle against it later. An authorization carries a kind that fixes which settlement message may spend it, so the purpose is never left implicit.
| Kind | Settlement (TX2, signed by executor) | Meaning |
|---|---|---|
SWAP | MsgExecuteSwap | Two legs: burn or transfer the input, mint the output. |
PULL | MsgExecutePull | One leg: move the input to the executor. |
BURN | MsgExecuteBurn | One leg: the executor burns the input. |
MsgCreateAuthorization is the single TX1 for every kind and is signed by the
holder. The settlement messages are signed by the executor and served by the
SwapMsg gRPC service, as is MsgRevokeAuthorization, which the holder signs to
cancel.
Configuration of the executor list is governance-only, through
MsgSetSwapConfig with a SwapConfig holding the technical issuer and the
executor addresses.
Authorization terms
MsgCreateAuthorization {
kind // SWAP, PULL, or BURN; an explicit purpose, never defaulted
holder // granter, and the only destination a SWAP output may go to
order_seq // per-holder monotonic sequence, never reused
chain_id // domain separation against cross-chain replay
executor // the account allowed to settle
from_denom // fungible input, empty for an NFT input
to_denom // fungible output, empty for an NFT output
to_amount // exact amount the holder receives
max_from // ceiling on the total input
gas_fee_max // ceiling on the gas reimbursement
from_nft // NFT input: class_id + token_id
to_nft // NFT output: class_id (token id is assigned at settlement)
}
ValidateBasic rejects the message unless the shape is exact:
- Both addresses must be canonical bech32, and
holder != executor. order_seq != 0, andchain_idis 1 to 128 characters.kindmust beSWAP,PULL, orBURN.UNSPECIFIEDis rejected, so the purpose is never left implicit.- A
PULLorBURNforbids every output leg (to_denom,to_amount,to_nft) and a positive gas fee; an NFT input pinsmax_fromto1. - At most one NFT scope. Fungible-to-fungible requires two distinct valid denoms.
- All amounts are canonical decimal strings:
v.String() == s. max_frommust be positive, andgas_fee_maxmust be strictly below it, so the gas ceiling can never eat the whole swap budget.- An NFT input pins
to_amountandmax_fromto1and forbids a positive gas fee. An NFT output pinsto_amountto1and allows a gas fee.
Creating an authorization
The keeper validates in a fixed order:
chain_idmust equal the current chain.order_seqmust be greater than the holder's high-water mark. Sequence numbers are never reused, so a delayed TX2 cannot consume a fresh authorization with a stale id.- The holder must not be a module account.
- Neither leg may be blacklisted.
- The holder must not be locked for the input.
- An NFT input requires that the holder owns the token and that it is not already locked; the token is locked under the order id.
- A fungible input reserves
max_from. The holder's spendable balance must cover every reservation, open orders included.
A PULL or BURN has no output leg, so only the input side is blacklist-checked
and only the input is reserved or locked.
The per-holder high-water mark is stored next to the authorization, and the
reservation is a counter per (holder, denom).
Executing a swap
MsgExecuteSwap carries executor, holder, order_seq, chain_id,
used_from, to_amount, and gas_fee. Validation:
- Canonical addresses, then the chain domain.
- The authorization must exist and not be consumed, revoked, or bound to a different executor.
- The budget:
used_from <= max_from,gas_fee <= gas_fee_max,gas_fee < used_from, andto_amountequal to the authorized amount. - The input leg is re-checked through
CanSendTokens, so a blacklist entry or a restriction change between the phases blocks settlement.
Settlement, in one atomic transaction:
- An NFT input transfers the escrowed token to the executor and mints the requested output.
- A fungible input releases the reservation, pays
gas_feein the input denom to the executor, sends the remainder to the module, and burns it if the executor is the input denom's admin. The output is then minted to the holder.
consumed, used_from, and gas_fee are written in the same transaction, so a
retried TX2 fails.
Settling a pull or a burn
MsgExecutePull and MsgExecuteBurn each carry executor, holder,
order_seq, chain_id, and amount. The keeper re-checks the chain domain, that
the authorization exists and is neither consumed nor revoked, that its named
executor matches, and that its kind is the one expected. A kind mismatch is
rejected with ErrUnauthorized (1103, "authorization unavailable"), so a
PULL authorization cannot be spent by MsgExecuteSwap or by
MsgExecuteBurn.
- A
PULLtransfers the input from the holder to the executor and burns nothing. The executor is the recipient, and settlement never touches the module account, so total supply is unchanged even when the executor is the denom admin. - A
BURNmoves the input to the module and burns it, so total supply shrinks. The executor must be the input denom's admin, or, for an NFT input, the class issuer.
Both enforce amount <= max_from, re-run the blacklist and restriction checks
the input needs, release the whole max_from reservation so any unspent budget
returns to the holder, and mark the authorization consumed. Settlement runs in a
cached context and rolls back whole on failure.
Revoking
MsgRevokeAuthorization takes holder, order_seq, and chain_id. It rejects
an authorization that is already consumed or revoked, releases the balance
reservation or unlocks the NFT, and sets the revoked flag. Consumption and
revocation are mutually exclusive: whichever lands first wins, and both are
evented.
Gas
The gas fee is paid in the input denom by the holder, not in ucen. The
reimbursement mode is fixed and reported by the query as
input_asset_reimbursement. The holder authorizes the ceiling with
gas_fee_max; the executor charges the actual amount. A PULL or BURN
forbids a positive gas fee, so a single-leg settlement always records
gas_fee = 0.
Queries
| Query | Returns |
|---|---|
Params | The module parameters. |
DenomAuthorityMetadata | The admin, delegates, and restriction flag for one denom. |
DenomsFromCreator | Every denom created by an address. |
DenomsFromAdmin | Every denom an address currently administers. |
DenomBlacklist | Paginated blacklisted addresses for a denom. |
Authorization | One authorization, its kind, its high-water mark, the reserved amount, and the gas mode. |
Authorizations | Paginated authorizations created by a holder, newest sequence last. Consumed and revoked entries are included. |
HighWaterMark | The last consumed order sequence for a holder. |
Every swap query is served over the gRPC gateway under
/centerchain/tokenfactory/v1beta1/swaps/{holder}/...:
authorizations/{order_seq} for one authorization, authorizations for the
paginated list, and high_water_mark for the high water mark.
Events
| Event | Attributes |
|---|---|
create_denom | creator, new_token_denom |
tf_mint | mint_to_address, amount |
tf_burn | burn_from_address, amount |
force_transfer | transfer_from_address, transfer_to_address, amount. Never emitted since v1.1: MsgForceTransfer is always rejected. |
change_admin | denom, new_admin |
set_denom_metadata | denom, denom_metadata |
set_denom_restriction | denom, is_restricted |
set_poolable | denom, poolable |
set_pool_denylist | denom, denylisted |
authorization_created | order_id, holder, executor, used_from, gas_fee, to_amount, gas_mode |
authorization_revoked | same shape |
swap_executed | same shape, plus the NFT scope fields when set |
pull_executed | order_id, holder, executor, gas_fee, and denom + amount, or from_nft_class + from_nft_token |
burn_executed | same shape |
Blacklist, delegate, and params changes emit no event. Watch chain state for those, not the event stream.
Errors
| Code | Identifier | Raised when |
|---|---|---|
| 1100 | ErrInvalidSigner | A governance message was not signed by the authority. |
| 1102 | ErrDenomExists | The denom already has bank metadata. |
| 1103 | ErrUnauthorized | The signer is not the denom admin, or the authorization is unavailable. |
| 1104 | ErrInvalidDenom | The denom does not deconstruct to factory/.... |
| 1105 | ErrInvalidCreator | The creator segment is empty or contains a separator. |
| 1107 | ErrInvalidGenesis | The genesis state is inconsistent. |
| 1108 | ErrSubdenomTooLong | The subdenom exceeds 44 bytes. |
| 1109 | ErrCreatorTooLong | The creator segment exceeds 75 bytes. |
| 1110 | ErrDenomDoesNotExist | Minting or burning an unknown denom. |
| 1111 | ErrCapabilityNotEnabled | The required capability is not configured. |
| 1112 | ErrRestrictedSender | A restricted denom was sent to a non-holder. |
| 1113 | ErrSenderLocked | The sender is blacklisted for the denom. |
| 1114 | ErrRecipientLocked | The recipient is blacklisted for the denom. |
| 1115 | ErrLiquidityModule | The liquidity module is not registered with the factory. |
| 1116 | ErrLiquidityDenom | An LP denom does not match its pool. |
| 1117 | ErrStateLimit | A state collection is at its cap. |
| 1118 | ErrHolderAssetProtection | A burn or force transfer targeted another holder's asset. Since v1.1 this is the whole MsgForceTransfer and the burn_from_address ≠ sender path. |
The swap subsystem additionally uses plain errors with stable text: wrong chain domain, order sequence already used, module holder not allowed,
insufficient unreserved funds, authorization unavailable, authorization budget exceeded, and authorization terminal.
CLI
centerchaind query tokenfactory params
centerchaind query tokenfactory denom-authority-metadata <denom>
centerchaind query tokenfactory denoms-from-creator <creator>
centerchaind query tokenfactory denoms-from-admin <admin>
centerchaind query tokenfactory denom-blacklist <denom>
centerchaind tx tokenfactory create-denom <subdenom>
centerchaind tx tokenfactory change-admin <denom> <new-admin>
centerchaind tx tokenfactory add-delegate <denom> <address>
centerchaind tx tokenfactory remove-delegate <denom> <address>
centerchaind tx tokenfactory add-blacklist <denom> <address>
centerchaind tx tokenfactory remove-blacklist <denom> <address>
centerchaind tx tokenfactory set-denom-restriction <denom> <bool>
centerchaind tx tokenfactory set-poolable <denom> <bool>
centerchaind tx tokenfactory set-pool-denylist <denom> <bool>
centerchaind tx tokenfactory mint <amount>
centerchaind tx tokenfactory mint-to <address> <amount>
centerchaind tx tokenfactory burn <amount>
centerchaind tx tokenfactory burn-from <address> <amount>
centerchaind tx tokenfactory force-transfer <amount> <from> <to>
centerchaind tx tokenfactory modify-metadata <denom> <symbol> <description> <exponent>
The swap service is reachable through autocli under a swap subcommand:
centerchaind query tokenfactory swap authorization [holder] [order-seq]
centerchaind query tokenfactory swap authorizations [holder]
centerchaind query tokenfactory swap high-water-mark [holder]
centerchaind tx tokenfactory swap create-authorization [order-seq] [chain-id] [executor] [from-denom] [to-denom] [to-amount] [max-from] [gas-fee-max]
centerchaind tx tokenfactory swap execute-swap [holder] [order-seq] [chain-id] [used-from] [to-amount] [gas-fee]
centerchaind tx tokenfactory swap execute-pull [holder] [order-seq] [chain-id] [amount]
centerchaind tx tokenfactory swap execute-burn [holder] [order-seq] [chain-id] [amount]
centerchaind tx tokenfactory swap revoke-authorization [order-seq] [chain-id]
centerchaind tx tokenfactory swap set-swap-config
The single-leg MsgExecutePull and MsgExecuteBurn are served by the same
SwapMsg service, so they are the execute-pull and execute-burn commands
above rather than separate top-level commands.
burn-from and force-transfer still appear in the CLI. force-transfer always
fails, and burn-from fails unless the address is the sender's own.