Skip to main content
Version: Next

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​

FieldDefaultRule
listing_deposit1000000ucenMust be denominated in ucen, and non-negative.
default_listing_duration_blocks100,000Positive, and at most the maximum.
max_listing_duration_blocks1,000,000Positive, 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​

MessageSignerRequirement
MsgUpdateClassMarketConfigsenderThe class technical issuer, or the module authority.
MsgListNFTsenderOwns 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.
MsgBuyNFTsenderThe listing exists, is not expired, and the buyer is not the seller.
MsgCancelListingsenderThe seller, or anyone once the listing has expired.
MsgUpdateParamsauthorityThe module authority, which is the gov account by default.

Queries​

QueryReturns
ParamsThe market parameters.
ListingOne listing by id.
ListingsPaginated listings, optionally filtered by class id or seller.
ClassMarketConfigThe 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​

EventAttributes
nft_listedlisting_id, class_id, token_id, seller, price, deposit, royalty, royalty_recipient, expiry_height
nft_soldlisting_id, class_id, token_id, seller, buyer, price, royalty, royalty_recipient, deposit
nft_listing_cancelledlisting_id, class_id, token_id, seller, pruner, deposit
nft_listing_prune_failedlisting_id, class_id, token_id, expiry_height, failure_reason
nft_listing_burn_cleanuplisting_id, class_id, token_id, seller, deposit, cleanup_reason
class_market_config_updatedclass_id, signer, secondary_enabled, royalty, royalty_recipient, accepted_denoms
params_updatedauthority, 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​

LimitValue
Active listings10,000
Listing record size2,048 bytes
Query page limit100
Prunes per block200
Accepted denoms per class32
Token id length512 bytes
Royalty1,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​

CodeIdentifierRaised when
1301ErrUnauthorizedCancelling someone else's listing before expiry.
1302ErrInvalidAuthorityA governance message was signed by the wrong account.
1306ErrListingNotFoundNo such listing.
1307ErrListingExpiredThe listing has passed its expiry height.
1308ErrPriceInvalidThe price is not strictly positive.
1309ErrCannotBuyOwnThe buyer is the seller.
1310ErrNFTNotOwnedThe seller does not own the token.
1311ErrNFTLockedThe token is locked, or already listed.
1312ErrInsufficientFundThe buyer cannot cover the price.
1313ErrDurationInvalidThe expiry height is invalid.
1314ErrDenomNotAcceptedThe price denom is not in accepted_denoms.
1315ErrRoyaltyInvalidThe royalty consumes the whole price, or exceeds 1,000,000 ppm.
1316ErrDepositInvalidThe module account cannot back the deposits.
1317ErrClassConfigNotFoundThe class has no market configuration.
1318ErrSecondaryDisabledSecondary trading is disabled for the class.
1319ErrListingLimitExceededThe 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.