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 two ways to move an asset with the holder's consent: the
two-phase authorization, and the single-transaction claim permit.
Consensus version 3 is unchanged in v1.2: the release changes the state schema but ships no migration, because every v1.2 network starts from a new genesis.
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. |
default_authorization_blocks | 300 | Lifetime applied when MsgCreateAuthorization sends valid_blocks = 0. |
max_authorization_blocks | 10000 | Upper bound a holder may request. |
auth_base_gas | 50000 | Flat TX1 gas charge. |
auth_gas_per_block | 100 | Charge for each block beyond auth_free_blocks. |
auth_free_blocks | 100 | Lifetime covered by auth_base_gas alone. |
max_claim_expiry_blocks | 14400 | Maximum number of blocks a claim permit may extend past the executing height. About one day at six-second blocks. |
All fields are validated. The authorization parameters must be positive,
max_authorization_blocks must be at least the default, auth_free_blocks must
not exceed the maximum, and max_claim_expiry_blocks must be at most
1,000,000.
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, expiring 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: consume the input and deliver the output to the holder. |
PULL | MsgExecutePull | One leg: move the input to the executor. |
BURN | MsgExecuteBurn | One leg: the executor burns the input. |
MINT | MsgExecuteMint | Gas authorization only; the mint action is a separate signed permit. |
SEND | MsgExecuteSend | Gas authorization only; the send action is a separate signed permit. |
MsgCreateAuthorization is the single TX1 for every kind and is signed by the
holder. The settlement messages are signed by the executor and are served by
the module's Msg service, as is MsgRevokeAuthorization, which the holder
signs to cancel.
MsgSetSwapConfig remains in the API, but SwapConfig has no fields. The
executor is chosen by the holder in each authorization; an allow-list, if one is
needed, is enforced off-chain.
Authorization terms
MsgCreateAuthorization {
kind // SWAP, PULL, BURN, MINT, or SEND; never defaulted
holder // granter; for SWAP, the only destination of the output
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
max_fee // ceiling on the TX2 network fee, in ucen
valid_blocks // relative lifetime; zero means the default
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 != executorexcept for a gas-onlyMINTorSENDauthorization. order_seq != 0, andchain_idis 1 to 128 characters.kindmust beSWAP,PULL,BURN,MINT, orSEND.UNSPECIFIEDis rejected, so the purpose is never left implicit.max_feemust be a positive canonical amount. It is denominated inucen.- A
MINTorSENDauthorization must not carryfrom_denom,to_denom,to_amount,max_from, or either NFT scope. - A
PULLorBURNforbids every output leg and requires either a fungible input denom with a positive budget or a single NFT input. An NFT input pinsmax_fromto1. - A
SWAPrequires exactly one input and one output. Fungible-to-fungible requires two distinct valid denoms. An NFT input pinsto_amountandmax_fromto1; an NFT output pinsto_amountto1. - All amounts are canonical decimal strings:
v.String() == s.
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.valid_blocksis resolved: zero becomesdefault_authorization_blocks, and a value abovemax_authorization_blocksis rejected. The absoluteexpire_heightiscurrent height + valid_blocks.- TX1 consumes duration-based gas:
auth_base_gasplusauth_gas_per_blockfor every block beyondauth_free_blocks. - The holder must not be a module account.
- The input side must pass the blacklist check.
- An NFT input must not be locked by the secondary market. This is a read-only check; the tokenfactory does not lock the token.
The authorization is then stored with its terms, expire_height, and an
attempted flag. No funds are reserved and no NFT is locked. The holder can
spend the same balance or transfer the same token while the authorization is
open; settlement fails closed if the asset is no longer there.
Expiry and pruning
An authorization is executable only while block height <= expire_height.
BeginBlock removes records whose expiry is below the current height, emits
authorization_expired, and leaves the high-water mark intact. Queries also
filter an expired record defensively.
Executing a swap
MsgExecuteSwap carries executor, holder, order_seq, chain_id,
used_from, and to_amount. Validation:
- Canonical addresses, then the chain domain.
- The authorization must exist, not be consumed or revoked, not be expired, and be bound to the same executor and chain.
- The kind must be
SWAP. - The authorization must not already be
attempted. - The budget:
used_from <= max_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 token to the executor and mints the requested output.
- A fungible input sends the input to the executor, or burns it through the module when the executor is the input denom's admin. The output is minted to the holder or delivered from the executor's inventory.
consumedandused_fromare 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, is not consumed, revoked, expired, or attempted, that
its named executor matches, and that its kind is the one expected. A kind
mismatch is rejected with ErrUnauthorized (1103, "authorization unavailable").
- A
PULLtransfers the input from the holder to the executor and burns nothing. 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,
and mark the authorization consumed. Because TX1 reserved nothing, both fail
closed if the holder no longer owns the input or an NFT input is locked.
Settlement runs in a cached context and rolls back whole on failure.
Executing a mint or a send
MINT and SEND authorizations carry no asset terms. They exist to pay the TX2
network fee for an action that is signed separately:
| Message | TX1 kind | Action signer (executor) | Effect |
|---|---|---|---|
MsgExecuteMint | MINT | The denom admin | Mints amount of denom to mint_to. |
MsgExecuteSend | SEND | The balance holder | Sends amount of denom from the executor to to. |
The TX2 message carries its own nonce, expiry_height, pubkey, and
signature over a domain-tagged payload. That action permit is checked
independently of the TX1 gas authorization, so TX1 cannot change the action's
denom, amount, or recipient. The holder named in TX1 pays the transaction fee;
the executor signs the transaction and the action permit.
The action permit uses the same replay model as a claim permit: a 16-byte nonce, the chain ID, an absolute expiry, a compressed secp256k1 public key bound to the signer address, and a domain tag.
Revoking
MsgRevokeAuthorization takes holder, order_seq, and chain_id. It rejects
an authorization that is already consumed or revoked and sets the revoked flag.
There is no reservation to release and no NFT lock to clear; the only cleanup is
a legacy tokenfactory lock that a pre-v1.2 authorization may have created.
Consumption and revocation are mutually exclusive: whichever lands first wins,
and both are evented.
Gas and fees
TX1 and TX2 are charged differently:
- TX1 pays duration-based gas. The handler consumes
auth_base_gasplusauth_gas_per_blockfor every block beyond the free tier. A longer-lived authorization costs more. - TX2 is submitted by the executor but the holder named in the
authorization pays the network fee. The fee is charged in
ucenby a tokenfactory ante decorator, and it must not exceed the signedmax_fee.
The settlement transaction must contain exactly one MsgExecute* message. An
authz.MsgExec may not wrap one, and feegrant is ignored. If the holder cannot
cover the fee, the transaction is rejected; the executor cannot pay in the
holder's place. A failed settlement still consumes the attempt: the
authorization is marked attempted, and retrying it fails with
ErrAuthorizationAttempted (1126). A new attempt requires a new authorization.
The old input-denom reimbursement (gas_fee, gas_fee_max, and the
gas_mode query field) is gone. The holder pays the chain's network fee in
ucen, and the asset legs carry no gas amount.
Single-transaction claim permits
A claim permit lets a signer authorize an action with a signature alone. An
executor submits the signed message in one transaction and pays the standard
transaction fee. The signer needs no ucen and never submits a transaction.
| Message | Signing authority | Effect |
|---|---|---|
MsgClaimMint | The denom admin | Mints to mint_to. |
MsgClaimSend | The balance holder (from) | Sends to to. |
MsgClaimMintNFT | The class issuer | Mints a certificate and returns its token id. |
MsgClaimSendNFT | The current certificate holder | Transfers the certificate. |
MsgRevokeClaim | The signer | Permanently invalidates an unused nonce. |
The permit signature is secp256k1 over SHA-256 of a domain-tagged,
length-prefixed payload. Every field except executor and signature is signed.
The signature is a 64-byte compact R‖S with lower-S, the public key is 33-byte
compressed, and the public key must hash to the claimed signer address.
Replay protection is fourfold:
| Axis | Mechanism |
|---|---|
| Single use | A 16-byte per-signer nonce; the used marker is written in the same transaction as the action. |
| Cross-chain | chain_id must equal the executing chain. |
| Expiry | expiry_height must be in the future and at most max_claim_expiry_blocks ahead. |
| Purpose | A domain tag per message type, so a mint signature cannot be used as a send. |
MsgRevokeClaim writes a permanent revocation tombstone for a nonce that has not
been spent. Used-nonce markers are pruned after the permit's expiry; revocation
tombstones are never pruned. Both are carried in genesis export/import, and a
new chain must use a new chain ID.
Claim recipients cannot be module accounts. An NFT claim send re-runs the lock, blacklist, and restricted-class checks. A mint claim requires the signer to be the denom admin; an NFT mint claim requires the class issuer.
The full signature contract and client checklist are in Claim permits.
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, expire_height, attempted, and its terms. |
Authorizations | Paginated authorizations created by a holder, newest sequence last. Consumed and revoked entries are included. |
HighWaterMark | The last used order sequence for a holder. |
The authorization queries are 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, to_amount, plus the NFT scope fields when set |
authorization_revoked | same shape |
swap_executed | same shape, with the settled used_from |
pull_executed | order_id, holder, executor, and denom + amount, or from_nft_class + from_nft_token |
burn_executed | same shape |
execute_mint | executor, holder, order_seq, mint_to_address, denom, amount |
execute_send | executor, holder, order_seq, to, denom, amount |
claim_mint | executor, owner, mint_to_address, denom, amount, nonce |
claim_send | executor, from, to, denom, amount, nonce |
claim_mint_nft | executor, issuer, class_id, token_id, to, nonce |
claim_send_nft | executor, from, to, class_id, token_id, nonce |
claim_revoked | signer, nonce |
authorization_expired | order_id, holder, expire_height |
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. |
| 1119 | ErrClaimSignature | A claim permit signature is invalid, or the public key does not resolve to the claimed signer. |
| 1120 | ErrClaimExpired | The claim permit has expired. |
| 1121 | ErrClaimNonceUsed | The claim nonce was already spent. |
| 1122 | ErrClaimChainId | The claim chain ID does not match the executing chain. |
| 1123 | ErrAuthorizationExpired | The authorization has passed its expiry height. |
| 1124 | ErrFeePayerUnfunded | The authorization holder cannot pay the TX2 fee. |
| 1125 | ErrFeeCapExceeded | The TX2 fee is above max_fee, or is not paid in ucen. |
| 1126 | ErrAuthorizationAttempted | The authorization was already attempted and cannot be retried. |
| 1127 | ErrExecuteTxShape | The transaction does not contain exactly one top-level MsgExecute*. |
| 1128 | ErrClaimRevoked | The claim nonce was revoked. |
| 1129 | ErrClaimExpiryTooFar | The permit expiry is more than max_claim_expiry_blocks ahead. |
| 1130 | ErrClaimModuleAccount | A claim recipient is a module account. |
The authorization subsystem additionally uses plain errors with stable text:
wrong chain domain, order sequence already used, module holder not allowed,
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 authorization commands are top-level. There is no swap subcommand:
centerchaind query tokenfactory authorization [holder] [order-seq]
centerchaind query tokenfactory authorizations [holder]
centerchaind query tokenfactory high-water-mark [holder]
centerchaind tx tokenfactory create-authorization [order-seq] [chain-id] [executor] [from-denom] [to-denom] [to-amount] [max-from] [max-fee]
centerchaind tx tokenfactory execute-swap [holder] [order-seq] [chain-id] [used-from] [to-amount]
centerchaind tx tokenfactory execute-pull [holder] [order-seq] [chain-id] [amount]
centerchaind tx tokenfactory execute-burn [holder] [order-seq] [chain-id] [amount]
centerchaind tx tokenfactory execute-mint [holder] [order-seq] [chain-id] [denom] [amount] [mint-to] [nonce] [expiry-height] [pubkey] [signature]
centerchaind tx tokenfactory execute-send [holder] [order-seq] [chain-id] [denom] [amount] [to] [nonce] [expiry-height] [pubkey] [signature]
centerchaind tx tokenfactory revoke-authorization [order-seq] [chain-id]
Claim permits are top-level commands too:
centerchaind tx tokenfactory claim-mint [owner] [denom] [amount] [mint-to] [nonce] [chain-id] [expiry-height] [pubkey] [signature]
centerchaind tx tokenfactory claim-send [from] [denom] [amount] [to] [nonce] [chain-id] [expiry-height] [pubkey] [signature]
centerchaind tx tokenfactory claim-mint-nft [issuer] [class-id] [to] [uri] [uri-hash] [nonce] [chain-id] [expiry-height] [pubkey] [signature]
centerchaind tx tokenfactory claim-send-nft [from] [class-id] [token-id] [to] [nonce] [chain-id] [expiry-height] [pubkey] [signature]
centerchaind tx tokenfactory revoke-claim [nonce]
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.