Skip to main content
Version: 1.2.0

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.

LimitValue
Subdenom length44 bytes
Creator segment length75 bytes
Reserved namespacefactory/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.

FieldMeaning
adminThe account that may mint, burn, and change policy. The creator at creation.
delegatesAccounts that bypass the restricted-denom rule for this denom.
is_restrictedWhen 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​

MessageSignerRequires
MsgCreateDenomsenderNothing; the creator becomes the admin. Pays the creation fee and gas.
MsgMintsenderDenom admin, or the module authority through the sudo-mint capability.
MsgBurnsenderThe denom admin, and only its own balance. Burning from another address is rejected.
MsgForceTransfersenderNothing — the message is always rejected. It stays in the API surface so a caller gets a stable error instead of "unimplemented".
MsgChangeAdminsenderDenom admin.
MsgSetDenomMetadatasenderDenom admin and enable_metadata; the module authority may also set LP denom metadata.
MsgAddDelegate / MsgRemoveDelegatesenderDenom admin.
MsgAddBlacklist / MsgRemoveBlacklistsenderDenom admin. An account cannot blacklist itself.
MsgSetDenomRestrictionsenderDenom admin.
MsgSetPoolablesenderDenom admin.
MsgSetPoolDenylistauthorityThe module authority, which is the gov account by default.
MsgUpdateParamsauthorityThe 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​

FieldDefaultNotes
denom_creation_fee10000000ucenCharged at denom creation, and burned unless community-pool funding is enabled.
denom_creation_gas_consume2000000Extra gas consumed by the create-denom path, so creation costs more than a normal message.
default_authorization_blocks300Lifetime applied when MsgCreateAuthorization sends valid_blocks = 0.
max_authorization_blocks10000Upper bound a holder may request.
auth_base_gas50000Flat TX1 gas charge.
auth_gas_per_block100Charge for each block beyond auth_free_blocks.
auth_free_blocks100Lifetime covered by auth_base_gas alone.
max_claim_expiry_blocks14400Maximum 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​

LimitValueEnforced when
Factory denoms100,000Denom creation.
Pool policy records100,000First MsgSetPoolable and first MsgSetPoolDenylist.
Blacklisted addresses100,000Globally, 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.

KindSettlement (TX2, signed by executor)Meaning
SWAPMsgExecuteSwapTwo legs: consume the input and deliver the output to the holder.
PULLMsgExecutePullOne leg: move the input to the executor.
BURNMsgExecuteBurnOne leg: the executor burns the input.
MINTMsgExecuteMintGas authorization only; the mint action is a separate signed permit.
SENDMsgExecuteSendGas 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 != executor except for a gas-only MINT or SEND authorization.
  • order_seq != 0, and chain_id is 1 to 128 characters.
  • kind must be SWAP, PULL, BURN, MINT, or SEND. UNSPECIFIED is rejected, so the purpose is never left implicit.
  • max_fee must be a positive canonical amount. It is denominated in ucen.
  • A MINT or SEND authorization must not carry from_denom, to_denom, to_amount, max_from, or either NFT scope.
  • A PULL or BURN forbids every output leg and requires either a fungible input denom with a positive budget or a single NFT input. An NFT input pins max_from to 1.
  • A SWAP requires exactly one input and one output. Fungible-to-fungible requires two distinct valid denoms. An NFT input pins to_amount and max_from to 1; an NFT output pins to_amount to 1.
  • All amounts are canonical decimal strings: v.String() == s.

Creating an authorization​

The keeper validates in a fixed order:

  1. chain_id must equal the current chain.
  2. order_seq must 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.
  3. valid_blocks is resolved: zero becomes default_authorization_blocks, and a value above max_authorization_blocks is rejected. The absolute expire_height is current height + valid_blocks.
  4. TX1 consumes duration-based gas: auth_base_gas plus auth_gas_per_block for every block beyond auth_free_blocks.
  5. The holder must not be a module account.
  6. The input side must pass the blacklist check.
  7. 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:

  1. Canonical addresses, then the chain domain.
  2. The authorization must exist, not be consumed or revoked, not be expired, and be bound to the same executor and chain.
  3. The kind must be SWAP.
  4. The authorization must not already be attempted.
  5. The budget: used_from <= max_from, and to_amount equal to the authorized amount.
  6. 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.
  • consumed and used_from 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, 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 PULL transfers the input from the holder to the executor and burns nothing. Total supply is unchanged even when the executor is the denom admin.
  • A BURN moves 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:

MessageTX1 kindAction signer (executor)Effect
MsgExecuteMintMINTThe denom adminMints amount of denom to mint_to.
MsgExecuteSendSENDThe balance holderSends 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_gas plus auth_gas_per_block for 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 ucen by a tokenfactory ante decorator, and it must not exceed the signed max_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.

MessageSigning authorityEffect
MsgClaimMintThe denom adminMints to mint_to.
MsgClaimSendThe balance holder (from)Sends to to.
MsgClaimMintNFTThe class issuerMints a certificate and returns its token id.
MsgClaimSendNFTThe current certificate holderTransfers the certificate.
MsgRevokeClaimThe signerPermanently 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:

AxisMechanism
Single useA 16-byte per-signer nonce; the used marker is written in the same transaction as the action.
Cross-chainchain_id must equal the executing chain.
Expiryexpiry_height must be in the future and at most max_claim_expiry_blocks ahead.
PurposeA 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​

QueryReturns
ParamsThe module parameters.
DenomAuthorityMetadataThe admin, delegates, and restriction flag for one denom.
DenomsFromCreatorEvery denom created by an address.
DenomsFromAdminEvery denom an address currently administers.
DenomBlacklistPaginated blacklisted addresses for a denom.
AuthorizationOne authorization, its kind, expire_height, attempted, and its terms.
AuthorizationsPaginated authorizations created by a holder, newest sequence last. Consumed and revoked entries are included.
HighWaterMarkThe 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​

EventAttributes
create_denomcreator, new_token_denom
tf_mintmint_to_address, amount
tf_burnburn_from_address, amount
force_transfertransfer_from_address, transfer_to_address, amount. Never emitted since v1.1: MsgForceTransfer is always rejected.
change_admindenom, new_admin
set_denom_metadatadenom, denom_metadata
set_denom_restrictiondenom, is_restricted
set_poolabledenom, poolable
set_pool_denylistdenom, denylisted
authorization_createdorder_id, holder, executor, used_from, to_amount, plus the NFT scope fields when set
authorization_revokedsame shape
swap_executedsame shape, with the settled used_from
pull_executedorder_id, holder, executor, and denom + amount, or from_nft_class + from_nft_token
burn_executedsame shape
execute_mintexecutor, holder, order_seq, mint_to_address, denom, amount
execute_sendexecutor, holder, order_seq, to, denom, amount
claim_mintexecutor, owner, mint_to_address, denom, amount, nonce
claim_sendexecutor, from, to, denom, amount, nonce
claim_mint_nftexecutor, issuer, class_id, token_id, to, nonce
claim_send_nftexecutor, from, to, class_id, token_id, nonce
claim_revokedsigner, nonce
authorization_expiredorder_id, holder, expire_height

Blacklist, delegate, and params changes emit no event. Watch chain state for those, not the event stream.

Errors​

CodeIdentifierRaised when
1100ErrInvalidSignerA governance message was not signed by the authority.
1102ErrDenomExistsThe denom already has bank metadata.
1103ErrUnauthorizedThe signer is not the denom admin, or the authorization is unavailable.
1104ErrInvalidDenomThe denom does not deconstruct to factory/....
1105ErrInvalidCreatorThe creator segment is empty or contains a separator.
1107ErrInvalidGenesisThe genesis state is inconsistent.
1108ErrSubdenomTooLongThe subdenom exceeds 44 bytes.
1109ErrCreatorTooLongThe creator segment exceeds 75 bytes.
1110ErrDenomDoesNotExistMinting or burning an unknown denom.
1111ErrCapabilityNotEnabledThe required capability is not configured.
1112ErrRestrictedSenderA restricted denom was sent to a non-holder.
1113ErrSenderLockedThe sender is blacklisted for the denom.
1114ErrRecipientLockedThe recipient is blacklisted for the denom.
1115ErrLiquidityModuleThe liquidity module is not registered with the factory.
1116ErrLiquidityDenomAn LP denom does not match its pool.
1117ErrStateLimitA state collection is at its cap.
1118ErrHolderAssetProtectionA burn or force transfer targeted another holder's asset. Since v1.1 this is the whole MsgForceTransfer and the burn_from_address ≠ sender path.
1119ErrClaimSignatureA claim permit signature is invalid, or the public key does not resolve to the claimed signer.
1120ErrClaimExpiredThe claim permit has expired.
1121ErrClaimNonceUsedThe claim nonce was already spent.
1122ErrClaimChainIdThe claim chain ID does not match the executing chain.
1123ErrAuthorizationExpiredThe authorization has passed its expiry height.
1124ErrFeePayerUnfundedThe authorization holder cannot pay the TX2 fee.
1125ErrFeeCapExceededThe TX2 fee is above max_fee, or is not paid in ucen.
1126ErrAuthorizationAttemptedThe authorization was already attempted and cannot be retried.
1127ErrExecuteTxShapeThe transaction does not contain exactly one top-level MsgExecute*.
1128ErrClaimRevokedThe claim nonce was revoked.
1129ErrClaimExpiryTooFarThe permit expiry is more than max_claim_expiry_blocks ahead.
1130ErrClaimModuleAccountA 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.