NFT market
Module name and store key: nftmarket. Consensus version 1.
x/nftmarket is a fixed-price secondary market for the tokens created by
x/nft. A seller escrows a token through the NFT lock and pays a
refundable deposit; a buyer pays the asking price, the class's royalty is split
out, and the token is transferred. The market charges no protocol fee.
Class configuration comes first
Nothing can be listed until the class issuer, or governance, has enabled the class:
ClassMarketConfig {
class_id // must be an existing x/nft class
secondary_enabled // master switch for new listings
royalty_ppm // parts per million of the price
royalty_recipient // required whenever royalty_ppm > 0
accepted_denoms // the denoms a listing may be priced in, at most 32
}
A listing is rejected unless the configuration exists, secondary_enabled is
true, the price denom is in accepted_denoms, the royalty is at most 1,000,000
ppm, and the price denom is not restricted by tokenfactory.
Turning secondary_enabled off stops new listings. It does not block
cancelling or pruning the listings that already exist.
Listings
Listing {
listing_id, class_id, token_id, seller
price Coin
deposit Coin
royalty_ppm, royalty_recipient
created_height, expiry_height
}
Listing ids are a monotonic counter. The same token can have at most one active listing, and the token must not be locked by anyone else.
Escrow
The seller keeps ownership while the listing is live. The token is locked through
x/nft under the order id nftmarket/{listing_id}, which is what stops the
seller from transferring or burning it out from under the listing. The deposit
moves to the nftmarket module account and is backed by an invariant: the module
balance must always equal the sum of the active listing deposits.
Since v1.2 this is the only NFT lock in the chain. A tokenfactory authorization cannot promise a listed token: TX1 rejects a token that is already market-locked, and settlement fails closed if the lock appears later.
Expiry
Every listing has an expiry. Leaving expiry_height at zero substitutes
default_listing_duration_blocks, which is 100,000. A listing cannot be created
in the past or further ahead than max_listing_duration_blocks, which is
1,000,000. BeginBlock prunes expired listings in ascending expiry order, at
most 200 per block.
A prune that fails does not retry: the failure is logged, the counter
expired_listing_release_failure is incremented, and
nft_listing_prune_failed is emitted before the index entry is dropped. The
BeginBlocker itself never returns an error, because an error there would halt
finalization.
Buying
MsgBuyNFT takes a buyer address and a listing id. The buyer must not be the
seller, and the listing must exist and not be expired.
Settlement re-validates everything that could have changed since the listing was created — the restricted-denom rule, that the seller is still the owner, that the lock still exists and still belongs to this listing, and that the buyer's spendable balance covers the price — and then, in one transaction:
royalty = floor(price * royalty_ppm / 1_000_000)
sellerAmount = price - royalty
pay royalty -> royalty_recipient (when positive)
pay remainder -> seller (when positive)
refund deposit -> seller
unlock the token
transfer the token -> buyer
A purchase never burns. If the royalty consumes the whole price, that is
ErrRoyaltyInvalid rather than a zero payment.
Cancelling and burning
The seller may cancel at any time. A third party may cancel only after the listing has expired, which is the recovery path when a seller disappears. The deposit is refunded and the token is unlocked in both cases.
If a token is burned while it is listed — only its owner can burn it, since
v1.1 removed the class issuer's and governance's burn
paths — the burn hook refunds the seller's deposit, deletes the listing and its
expiry entry, and emits nft_listing_burn_cleanup with the reason
nft_burned. It does not unlock the token, because the token and its lock are
already gone.
Params
| Field | Default | Rule |
|---|---|---|
listing_deposit | 1000000ucen | Must be denominated in ucen, and non-negative. |
default_listing_duration_blocks | 100,000 | Positive, and at most the maximum. |
max_listing_duration_blocks | 1,000,000 | Positive, and at most 1,000,000. |
Field 1 of the params message was a market_fee_ppm and is reserved. The market
takes no protocol fee.
Messages
| Message | Signer | Requirement |
|---|---|---|
MsgUpdateClassMarketConfig | sender | The class technical issuer, or the module authority. |
MsgListNFT | sender | Owns the token, the token is unlocked, the class is configured and enabled, the price denom is accepted and unrestricted, and the active listing count is below the cap. |
MsgBuyNFT | sender | The listing exists, is not expired, and the buyer is not the seller. |
MsgCancelListing | sender | The seller, or anyone once the listing has expired. |
MsgUpdateParams | authority | The module authority, which is the gov account by default. |
Queries
| Query | Returns |
|---|---|
Params | The market parameters. |
Listing | One listing by id. |
Listings | Paginated listings, optionally filtered by class id or seller. |
ClassMarketConfig | The configuration for one class. |
All four are also served over REST under /centerchain/nftmarket/v1/.... The
Listings page limit is clamped to 100 rather than rejected, so a larger
request silently returns the first hundred rows.
Events
| Event | Attributes |
|---|---|
nft_listed | listing_id, class_id, token_id, seller, price, deposit, royalty, royalty_recipient, expiry_height |
nft_sold | listing_id, class_id, token_id, seller, buyer, price, royalty, royalty_recipient, deposit |
nft_listing_cancelled | listing_id, class_id, token_id, seller, pruner, deposit |
nft_listing_prune_failed | listing_id, class_id, token_id, expiry_height, failure_reason |
nft_listing_burn_cleanup | listing_id, class_id, token_id, seller, deposit, cleanup_reason |
class_market_config_updated | class_id, signer, secondary_enabled, royalty, royalty_recipient, accepted_denoms |
params_updated | authority, deposit, default_duration_blocks, max_duration_blocks |
The royalty attribute is the raw parts-per-million value on nft_listed and a
coin string on nft_sold. Do not parse it as a coin.
Limits
| Limit | Value |
|---|---|
| Active listings | 10,000 |
| Listing record size | 2,048 bytes |
| Query page limit | 100 |
| Prunes per block | 200 |
| Accepted denoms per class | 32 |
| Token id length | 512 bytes |
| Royalty | 1,000,000 ppm |
The active listing count is maintained by a store counter, not by counting rows, and decrements saturate so it can never underflow.
Errors
| Code | Identifier | Raised when |
|---|---|---|
| 1301 | ErrUnauthorized | Cancelling someone else's listing before expiry. |
| 1302 | ErrInvalidAuthority | A governance message was signed by the wrong account. |
| 1306 | ErrListingNotFound | No such listing. |
| 1307 | ErrListingExpired | The listing has passed its expiry height. |
| 1308 | ErrPriceInvalid | The price is not strictly positive. |
| 1309 | ErrCannotBuyOwn | The buyer is the seller. |
| 1310 | ErrNFTNotOwned | The seller does not own the token. |
| 1311 | ErrNFTLocked | The token is locked, or already listed. |
| 1312 | ErrInsufficientFund | The buyer cannot cover the price. |
| 1313 | ErrDurationInvalid | The expiry height is invalid. |
| 1314 | ErrDenomNotAccepted | The price denom is not in accepted_denoms. |
| 1315 | ErrRoyaltyInvalid | The royalty consumes the whole price, or exceeds 1,000,000 ppm. |
| 1316 | ErrDepositInvalid | The module account cannot back the deposits. |
| 1317 | ErrClassConfigNotFound | The class has no market configuration. |
| 1318 | ErrSecondaryDisabled | Secondary trading is disabled for the class. |
| 1319 | ErrListingLimitExceeded | The active listing cap is reached. |
CLI
centerchaind query nftmarket params
centerchaind query nftmarket listing <listing-id>
centerchaind query nftmarket listings --class-id <class-id> --seller <address>
centerchaind query nftmarket class-config <class-id>
centerchaind tx nftmarket set-class-config --config <json>
centerchaind tx nftmarket list <class-id> <token-id> <price> <expiry-height>
centerchaind tx nftmarket buy <listing-id>
centerchaind tx nftmarket cancel <listing-id>
centerchaind tx nftmarket update-params --params <json>
The price is written as an amount and denom, for example 1000000uusdc.