Creator Token Standards: Building Guardrails For Your Tokens

Limit Break Dev
Limit Break
Published in
11 min readFeb 5, 2024

Creator Token Standards just got a major upgrade! Here’s what’s new in Version 2.0:

Not only has the feature-set gotten better, but so has the gas efficiency!

How Do Creator Tokens Work

All common token protocols (ERC20, ERC721, and ERC1155) include some form of transfer function. The typical base implementation of each standard includes a before transfer hook and an after transfer hook. Creator tokens share a base contract that ties into these hooks and adds guardrails using an external transfer validation registry to apply a creator-defined set of rules allowing or blocking transfers based upon the caller, from, and to addresses. This allows creators to dictate what protocols may interact with token transfers for their own projects.

Collection creators interact with the transfer validation registry to configure the ruleset for transfers, and they are free to change their ruleset at any time to escalate or de-escalate the security of their collections. The Creator Token Transfer Validator is now live on Ethereum, Polygon, Optimism, Arbitrum One, Base, Avalanche C-Chain, Binance Smart Chain (BSC), Polygon zkEVM, and Sepolia at 0x721C00182a990771244d7A71B9FA2ea789A3b433.

Creators apply two settings to their collections: (1) the transfer security level to apply to the collection and (2) the list id to apply to the collection. To deploy a collection without writing code, or to update settings visit developers.freenft.com.

Transfer Security Levels

With Version 2.0, the number of security levels has increased to nine. Here are the settings at each level.

The security levels range on a spectrum of least to most secure. At each level it becomes more difficult, but not necessarily impossible to evade royalties. There is a trade-off between security for the creator and friction for users, so creators should carefully assess the pros and cons of each level to make the best choice for their project needs. The matrix is shown below.

Bear in mind that the selection of a security level is not set in stone. If the needs of a project change in the future, the setting can be changed at any time.

Lists

Lists are used to store blacklisted and whitelisted accounts and code hashes. A code hash is a 32-byte hash of a smart contract’s bytecode. Whitelisting or blacklisting code hashes is useful when you want to whitelist or blacklist every contract that shares identical code. The handle to a list is known as a list id. Unless otherwise specified by the creator, the default list id, 0, is applied to all collections. Note that Limit Break manages the list with list id 0.

List ids may be shared across any number of collections, and the ability to create a new custom list id is open to anybody. This means that each creator may optionally curate their own list and apply it to all of their collections if they choose to do so. Alternatively, a consortium or DAO could create a list that a coalition of creators opts into. Note that when a new list id is created, the msg.sender that created the list becomes the list owner/admin. The following table describes the operations that may be performed on a list, and by whom.

Security For Holders

A chronic problem with the existing ERC721 and ERC1155 standards is security. NFTs are lost to wallet drainer attacks and the existing approval system puts the onus on holders to remember to clear approvals to protect themselves in case a protocol is exploited. High-value NFTs are lost nearly every day as a result.

At security levels 4, 7 and 8 (Whitelist + OTC Disabled) classic wallet draining attacks are not possible because the holder can’t directly sign away or approve the transfer of their NFT via an untrusted scam contract. The main avenue for an NFT to be lost would become a permit phishing attack on a whitelisted exchange contract where the seller approves the sale of their NFT below market value. This dramatically limits the risk of NFT asset theft.

Another benefit is that a creator that has a more permissive security level such as Level 1 could step in at the beginning of an exploit and quickly upgrade to security Level 8 to stop any further NFT loss while the exploit is mitigated. Without the security offered by creator token standards, creators can do nothing to help their communities in times of crisis.

A Note For Creators On Whitelisting Protocols

For creators interested in the royalty-enforcement mechanism offered by whitelisting, it is important to note that the selection of protocol to whitelist is critical. Royalty protection is only as good as the whitelisted protocol. As an example, say a creator whitelists Seaport because of promises to honor royalties. Legitimate orders will construct orders where royalties are paid as expected. But because the royalty assessment is not built directly into the Seaport contract and it is completely open other exchanges can simply use Seaport to fill zero-royalty orders as a workaround. Blur has previously demonstrated the capability to use this technique when creators used the OpenSea Operator Filter Registry. The safest exchange protocol to whitelist is Payment Processor because it can never opt out of paying royalties defined on-chain.

In order to ensure royalties are not evaded, creators MUST block Seaport to disable:

  • OpenSea
  • Blur
  • X2Y2
  • All other zero-royalty exchanges

ERC20-C

The same protections game developers and creators can apply to NFTs are now available for fungible coins! This is especially useful for Web3 games, as it is now possible to keep the use of fungible on-chain game currency strictly within the game’s ecosystem. Using ERC20-C, a creator can sponsor an official liquidity pool on a decentralized exchange without exposing their in-game currencies to unexpected usage in DeFi protocols such as collateralized lending.

Upgrading Legacy Collections

There are thousands of old NFT collections that have immutable contract code deployed before the Creator Token Standards were available. Typically, these could not be upgraded but the Limit Break contracts include easy-to-use wrapper contracts that can be used to upgrade any old token to a creator token. The upgrade process is opt-in by the holders of the original token, ensuring that the creator must carefully align the incentives to encourage their community to opt-in, and ensuring a fair process for communities. If you are a creator interested in upgrading your collections, read the contracts on the Creator Token Standards Github (ERC20-CW, ERC721-CW, ERC1155-CW).

Programmable Royalties

Because royalties were not previously enforceable, and because exchanges never examined the royalty settings for individual tokens there was no ability to implement dynamic royalty systems. Creator Token Standards fixes this and unlocks a new frontier of possibilities for royalties that go beyond simple payments to creators. Thanks to this innovation, novel programmable royalty contracts can leverage EIP-2981 in order to build stronger communities around NFTs. Using programmable royalties, creators can share royalties with the communities, partners and affiliates that are dynamic rather than static.

Limit Break shared a set of example programmable royalty contracts when Version 1 released, but we will revisit the examples here as a refresher.

Basic Royalties

This is the most basic form of programmable royalties, where the contract owner can set the default collection and/or per-token royalty settings.

abstract contract BasicRoyalties is ERC2981 {

event DefaultRoyaltySet(address indexed receiver, uint96 feeNumerator);
event TokenRoyaltySet(uint256 indexed tokenId, address indexed receiver, uint96 feeNumerator);

constructor(address receiver, uint96 feeNumerator) {
_setDefaultRoyalty(receiver, feeNumerator);
}

function _setDefaultRoyalty(address receiver, uint96 feeNumerator) internal virtual override {
super._setDefaultRoyalty(receiver, feeNumerator);
emit DefaultRoyaltySet(receiver, feeNumerator);
}

function _setTokenRoyalty(uint256 tokenId, address receiver, uint96 feeNumerator) internal virtual override {
super._setTokenRoyalty(tokenId, receiver, feeNumerator);
emit TokenRoyaltySet(tokenId, receiver, feeNumerator);
}
}

Minter-Only Royalties

This version of programmable royalties grants 100% of royalties to the minter of each individual token. In this variation, the royalty amount is immutable. However, our Github also has a version that allows minters to set their own royalty rates.

abstract contract ImmutableMinterRoyalties is IERC2981, ERC165 {

error ImmutableMinterRoyalties__MinterCannotBeZeroAddress();
error ImmutableMinterRoyalties__MinterHasAlreadyBeenAssignedToTokenId();
error ImmutableMinterRoyalties__RoyaltyFeeWillExceedSalePrice();

uint256 public constant FEE_DENOMINATOR = 10_000;
uint256 public immutable royaltyFeeNumerator;

mapping (uint256 => address) private _minters;

constructor(uint256 royaltyFeeNumerator_) {
if(royaltyFeeNumerator_ > FEE_DENOMINATOR) {
revert ImmutableMinterRoyalties__RoyaltyFeeWillExceedSalePrice();
}

royaltyFeeNumerator = royaltyFeeNumerator_;
}

function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) {
return interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId);
}

function royaltyInfo(
uint256 tokenId,
uint256 salePrice
) external view override returns (address receiver, uint256 royaltyAmount) {
return (_minters[tokenId], (salePrice * royaltyFeeNumerator) / FEE_DENOMINATOR);
}

function _onMinted(address minter, uint256 tokenId) internal {
if (minter == address(0)) {
revert ImmutableMinterRoyalties__MinterCannotBeZeroAddress();
}

if (_minters[tokenId] != address(0)) {
revert ImmutableMinterRoyalties__MinterHasAlreadyBeenAssignedToTokenId();
}

_minters[tokenId] = minter;
}

function _onBurned(uint256 tokenId) internal {
delete _minters[tokenId];
}
}

Shared Royalties

This version of programmable royalties uses payment splitters to share royalties between the creator and token minters.

abstract contract MinterCreatorSharedRoyalties is IERC2981, ERC165 {
error MinterCreatorSharedRoyalties__RoyaltyFeeWillExceedSalePrice();
error MinterCreatorSharedRoyalties__MinterCannotBeZeroAddress();
error MinterCreatorSharedRoyalties__MinterHasAlreadyBeenAssignedToTokenId();
error MinterCreatorSharedRoyalties__PaymentSplitterDoesNotExistForSpecifiedTokenId();

enum ReleaseTo {
Minter,
Creator
}

uint256 public constant FEE_DENOMINATOR = 10_000;
uint256 public immutable royaltyFeeNumerator;
uint256 public immutable minterShares;
uint256 public immutable creatorShares;
address public immutable creator;

mapping (uint256 => address) private _minters;
mapping (uint256 => address) private _paymentSplitters;
mapping (address => address[]) private _minterPaymentSplitters;

constructor(uint256 royaltyFeeNumerator_, uint256 minterShares_, uint256 creatorShares_, address creator_) {
if(royaltyFeeNumerator_ > FEE_DENOMINATOR) {
revert MinterCreatorSharedRoyalties__RoyaltyFeeWillExceedSalePrice();
}

royaltyFeeNumerator = royaltyFeeNumerator_;
minterShares = minterShares_;
creatorShares = creatorShares_;
creator = creator_;
}

function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC165) returns (bool) {
return interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId);
}

function royaltyInfo(
uint256 tokenId,
uint256 salePrice
) external view override returns (address, uint256) {
return (_paymentSplitters[tokenId], (salePrice * royaltyFeeNumerator) / FEE_DENOMINATOR);
}

function minterOf(uint256 tokenId) external view returns (address) {
return _minters[tokenId];
}

function paymentSplitterOf(uint256 tokenId) external view returns (address) {
return _paymentSplitters[tokenId];
}

function paymentSplittersOfMinter(address minter) external view returns (address[] memory) {
return _minterPaymentSplitters[minter];
}

function releasableNativeFunds(uint256 tokenId, ReleaseTo releaseTo) external view returns (uint256) {
PaymentSplitter paymentSplitter = _getPaymentSplitterForTokenOrRevert(tokenId);

if (releaseTo == ReleaseTo.Minter) {
return paymentSplitter.releasable(payable(_minters[tokenId]));
} else {
return paymentSplitter.releasable(payable(creator));
}
}

function releasableERC20Funds(uint256 tokenId, address coin, ReleaseTo releaseTo) external view returns (uint256) {
PaymentSplitter paymentSplitter = _getPaymentSplitterForTokenOrRevert(tokenId);

if (releaseTo == ReleaseTo.Minter) {
return paymentSplitter.releasable(IERC20(coin), _minters[tokenId]);
} else {
return paymentSplitter.releasable(IERC20(coin), creator);
}
}

function releaseNativeFunds(uint256 tokenId, ReleaseTo releaseTo) external {
PaymentSplitter paymentSplitter = _getPaymentSplitterForTokenOrRevert(tokenId);

if (releaseTo == ReleaseTo.Minter) {
paymentSplitter.release(payable(_minters[tokenId]));
} else {
paymentSplitter.release(payable(creator));
}
}

function releaseERC20Funds(uint256 tokenId, address coin, ReleaseTo releaseTo) external {
PaymentSplitter paymentSplitter = _getPaymentSplitterForTokenOrRevert(tokenId);

if(releaseTo == ReleaseTo.Minter) {
paymentSplitter.release(IERC20(coin), _minters[tokenId]);
} else {
paymentSplitter.release(IERC20(coin), creator);
}
}

function _onMinted(address minter, uint256 tokenId) internal {
if (minter == address(0)) {
revert MinterCreatorSharedRoyalties__MinterCannotBeZeroAddress();
}

if (_minters[tokenId] != address(0)) {
revert MinterCreatorSharedRoyalties__MinterHasAlreadyBeenAssignedToTokenId();
}

address paymentSplitter = _createPaymentSplitter(minter);
_paymentSplitters[tokenId] = paymentSplitter;
_minterPaymentSplitters[minter].push(paymentSplitter);
_minters[tokenId] = minter;
}

function _onBurned(uint256 tokenId) internal {
delete _paymentSplitters[tokenId];
delete _minters[tokenId];
}

function _createPaymentSplitter(address minter) private returns (address) {
if (minter == creator) {
address[] memory payees = new address[](1);
payees[0] = creator;

uint256[] memory shares = new uint256[](1);
shares[0] = minterShares + creatorShares;

return address(new PaymentSplitter(payees, shares));
} else {
address[] memory payees = new address[](2);
payees[0] = minter;
payees[1] = creator;

uint256[] memory shares = new uint256[](2);
shares[0] = minterShares;
shares[1] = creatorShares;

return address(new PaymentSplitter(payees, shares));
}
}

function _getPaymentSplitterForTokenOrRevert(uint256 tokenId) private view returns (PaymentSplitter) {
address paymentSplitterForToken = _paymentSplitters[tokenId];
if(paymentSplitterForToken == address(0)) {
revert MinterCreatorSharedRoyalties__PaymentSplitterDoesNotExistForSpecifiedTokenId();
}

return PaymentSplitter(payable(paymentSplitterForToken));
}
}

Transferrable Royalties

This version of programmable royalties uses a secondary NFT to represent ownership of royalties. The minter receives the royalty rights NFT to start, but the rights can be transferred to another wallet.

interface ICloneableRoyaltyRightsERC721 is IERC721 {
function initializeAndBindToCollection() external;
function mint(address to, uint256 tokenId) external;
function burn(uint256 tokenId) external;
}

abstract contract MinterRoyaltiesReassignableRightsNFT is IERC2981, ERC165 {

error MinterRoyaltiesReassignableRightsNFT__MinterCannotBeZeroAddress();
error MinterRoyaltiesReassignableRightsNFT__RoyaltyFeeWillExceedSalePrice();

uint256 public constant FEE_DENOMINATOR = 10_000;
uint256 public immutable royaltyFeeNumerator;
ICloneableRoyaltyRightsERC721 public immutable royaltyRightsNFT;

constructor(uint256 royaltyFeeNumerator_, address royaltyRightsNFTReference_) {
if(royaltyFeeNumerator_ > FEE_DENOMINATOR) {
revert MinterRoyaltiesReassignableRightsNFT__RoyaltyFeeWillExceedSalePrice();
}

royaltyFeeNumerator = royaltyFeeNumerator_;


ICloneableRoyaltyRightsERC721 royaltyRightsNFT_ =
ICloneableRoyaltyRightsERC721(Clones.clone(royaltyRightsNFTReference_));
royaltyRightsNFT_.initializeAndBindToCollection();

royaltyRightsNFT = royaltyRightsNFT_;
}

function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC165) returns (bool) {
return interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId);
}

function royaltyInfo(
uint256 tokenId,
uint256 salePrice
) external view override returns (address receiver, uint256 royaltyAmount) {

address rightsHolder = address(0);

try royaltyRightsNFT.ownerOf(tokenId) returns (address rightsTokenOwner) {
rightsHolder = rightsTokenOwner;
} catch {}

return (rightsHolder, (salePrice * royaltyFeeNumerator) / FEE_DENOMINATOR);
}

function _onMinted(address minter, uint256 tokenId) internal {
if (minter == address(0)) {
revert MinterRoyaltiesReassignableRightsNFT__MinterCannotBeZeroAddress();
}

royaltyRightsNFT.mint(minter, tokenId);
}

function _onBurned(uint256 tokenId) internal {
royaltyRightsNFT.burn(tokenId);
}
}

contract RoyaltyRightsNFT is ERC721, ICloneableRoyaltyRightsERC721 {

error RoyaltyRightsNFT__CollectionAlreadyInitialized();
error RoyaltyRightsNFT__OnlyBurnableFromCollection();
error RoyaltyRightsNFT__OnlyMintableFromCollection();

IERC721Metadata public collection;

constructor() ERC721("", "") {}

function initializeAndBindToCollection() external override {
if (address(collection) != address(0)) {
revert RoyaltyRightsNFT__CollectionAlreadyInitialized();
}

collection = IERC721Metadata(_msgSender());
}

function mint(address to, uint256 tokenId) external override {
if (_msgSender() != address(collection)) {
revert RoyaltyRightsNFT__OnlyMintableFromCollection();
}

_mint(to, tokenId);
}

function burn(uint256 tokenId) external override {
if (_msgSender() != address(collection)) {
revert RoyaltyRightsNFT__OnlyBurnableFromCollection();
}

_burn(tokenId);
}

function name() public view virtual override returns (string memory) {
return string(abi.encodePacked(collection.name(), " Royalty Rights"));
}

function symbol() public view virtual override returns (string memory) {
return string(abi.encodePacked(collection.symbol(), "RR"));
}

function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
return collection.tokenURI(tokenId);
}
}

Putting It All Together

By separating the logic of programmable royalties into mix-ins, it becomes easy to make a collection with any programmable royalty implementation. Here is an example that combines ERC721-C with immutable minter royalties.

pragma solidity ^0.8.4;

import "@limitbreak/creator-token-standards/erc721c/v2/ERC721C.sol";
import "@limitbreak/creator-token-standards/programmable-royalties/ImmutableMinterRoyalties.sol";

contract ERC721CWithImmutableMinterRoyalties is ERC721C, ImmutableMinterRoyalties {

constructor(
uint256 royaltyFeeNumerator_,
string memory name_,
string memory symbol_)
ERC721C(name_, symbol_)
ImmutableMinterRoyalties(royaltyFeeNumerator_) {
setToDefaultSecurityPolicy();
}

function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721C, ImmutableMinterRoyalties) returns (bool) {
return super.supportsInterface(interfaceId);
}

function mint(address to, uint256 tokenId) external {
_mint(to, tokenId);
}

function safeMint(address to, uint256 tokenId) external {
_safeMint(to, tokenId);
}

function burn(uint256 tokenId) external {
_burn(tokenId);
}

function _mint(address to, uint256 tokenId) internal virtual override {
_onMinted(to, tokenId);
super._mint(to, tokenId);
}

function _burn(uint256 tokenId) internal virtual override {
super._burn(tokenId);
_onBurned(tokenId);
}
}

Further Reading

Check out Limit Break’s Github or erc721c.com for developer-level documentation on the standards. To continue reading about Payment Processor, the most creator-friendly trading protocol for ERC721 and ERC1155 tokens, continue to the next article in the series.

Or, to read more about ERC721-C, here is a list of prior blog posts on this topic to help you get started.

Limit Break’s Creator Advanced Protection Suite (Creator Token Standards, Payment Processor (V1 & V2), Trusted Forwarder) and related services and protocols (collectively “Tools”) are made available on an as-is basis and Limit Break disclaims all representations and warranties, express or implied, in connection with use of these Tools. Users bear all responsibility for ensuring the proper and legal use of these Tools and should exercise best judgement and caution where appropriate when deploying them. Limit Break does not warrant, endorse, guarantee, or assume responsibility for any product or service advertised or offered by a third party using the Tools, and will not be a party to or in any way be responsible for monitoring any transaction between users and any third-party providers of products or services deploying the Tools. Use of the Tools is subject to the licenses under which such Tools are made available in all respects.

--

--

Limit Break Dev
Limit Break

Limit Break devs live by a code: be the best and ship, ship, ship!