Introducing ERC721-C: A New Standard for Enforceable On-Chain Programmable Royalties

Limit Break Dev
Limit Break
Published in
10 min readMay 9, 2023

In January Limit Break released Version 1.0 of our creator-friendly programmable royalty solution. In our first article on the topic, Introducing: Opt-In Programmable Royalties (and more) Through Staking, we covered a lot of ground, explaining the power and flexibility transfer whitelisting affords creators to innovate and push the NFT industry forward. We are thrilled to announce that we have released Version 1.1, which formalizes the creator token standard introduced in Version 1.0 into the ERC721-C. ERC721-C will finally eliminate workarounds and make on-chain royalties enforceable through the use of transfer security policies that allow creators to decide how permissive token transfers are for their own collections, opening the door to new forms of royalties that can reward both creators, communities, partners, and affiliates.

ERC721-C/ERC1155-C: Better Standards for NFTs

Because NFT royalties were not built into the core ERC721 and ERC1155 standards from the beginning, implementation of royalties across marketplaces has been inconsistent and messy. The EIP-2981 NFT Royalty Standard was released to address this gap and provide a consistent interface that exchanges could query. However, because EIP-2981 lacks a complementary enforcement mechanism, on-chain royalties appear to have been viewed as a soft request. In the long-run, this allowed for the incentivization of zero-fee, royalty-optional trading with airdrops, effectively turning tokens intended to be non-fungible into proxies for fungible tokens. Freed altogether from platform and royalty fees, traders were incentivized to farm tokens by wash-trading NFTs among their own wallets, which is bad for the NFT industry for a whole host of reasons we won’t go into here.

Built to extend and be fully backwards compatible with the existing standards, ERC721-C and ERC1155-C curb the problems of wash trading and puts the “Non-Fungible” back into NFTs by giving the creator the option to choose their distribution platforms and allow interactions from only those contracts and applications they deem safe and useful. Here’s how it works:

ERC721-C Transfer Validation
  1. The developer inherits ERC721-C contract, just like they would if they were using OpenZeppelin.
  2. The developer customizes or adds functionality as they would normally.
  3. The developer deploys their ERC721-C collection.
  4. In constructor, or after deployment, the developer calls the setToDefaultSecurityPolicy() function on their collection to point to Limit Break’s Creator Token Transfer Validator contract and apply the recommended default security policy (Security Level 1 with Limit Break’s curated operator whitelist. This will be explained later).
  5. That’s it — the collection will now apply the default collection transfer policy during all token transfers. The developer may change the transfer policy and/or whitelist at any time. Even the creator token transfer validator could be swapped out to another custom implementation for added flexibility.

Limit Break’s official Creator Token Transfer Validator contract is available on the Ethereum Mainnet, Polygon, Sepolia Testnet, and Mumbai Testnet networks.

The following code shows how easy it is to build your own ERC721-C collection.

import "../../contracts/erc721c/ERC721C.sol";

contract MyERC721CCollection is ERC721C {

constructor() ERC721C("Name of My Collection", "SYMBOL") {
setToDefaultSecurityPolicy();
}

// TODO: Implement/Override base uri
// TODO: Implement public minting function(s)
// TODO: Implement EIP-2981 Royalties
// TODO: Your other contract features, if any
}

By this point, you’re probably wondering what these transfer security policies are. They are the combination of three values:

  1. A predefined transfer security level
  2. An operator whitelist id that points to the desired whitelist
  3. An optional permitted contract receivers allowlist id that points to a list of contracts permitted to receive token transfers

There are seven predefined transfer security levels:

Transfer Security Levels

It has been suggested that the security measures put in place could be defeated or bypassed through the use of wrapper tokens. This is true for transfer security levels 0, 1, and 2. However, in case of abuse the creator has the ability to upgrade to one of the levels 3–6 to completely block this behavior should it become necessary.

Programmable Royalties

ERC721-C provides the enforcement mechanism and makes it possible to build novel programmable royalty contracts on top of EIP-2981 that can build stronger communities around NFTs and creates dynamic, equitable, rewarding ways that creators can share royalties with the communities, partners and affiliates. We will explore some simple examples of programmable royalty mix-ins that can be added to augment ERC721-C contracts. However, these examples are simply the tip of the iceberg and there are so many possible ways to build other compelling royalty sharing systems.

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 an ERC721-C collection with any programmable royalty implementation. Here is an example that combines ERC721-C with immutable minter royalties.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@limitbreak/creator-token-contracts/contracts
/erc721c/ERC721C.sol";
import "@limitbreak/creator-token-contracts/contracts
/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);
}
}

Ready To Get Started With Creator Token Contracts?

We firmly believe that Creator Token Contracts have the potential to revolutionize the NFT ecosystem by empowering artists, creators, and collectors while ensuring a fair, transparent, and secure marketplace. To harness the full potential of Creator Token Contracts, we encourage you to visit the repository, where you’ll find comprehensive guides, examples, and resources to help you get started.

The repository includes:

  • ERC721-C (based on OpenZeppelin)
  • ERC721-AC (based onERC721-A)
  • AdventureERC721-C (based on Limit Break’s AdventureERC721 standard)
  • ERC1155-C (based on OpenZeppelin)

Keep in mind that it’s essential to conduct thorough testing and auditing when integrating these contracts with your specific implementation to ensure the security, reliability, and proper functioning of your collections. Creator Token Contracts is actively maintained by Limit Break and released under the MIT License.

Want to use ERC721-C, but don’t want to code? No Problem!

FreeNFT proudly presents the Minting Press, a dApp that accelerates and streamlines the process of configuration, deployment and initialization of ERC721-C contracts on Ethereum and Polygon. Developers interested in using ERC721-C can save up to 90% of the typical deployment cost using the Minting Press, making this the most efficient deployment available on the market today. The Minting Press interface is available at developers.freenft.com and can be fully utilized — from configuration to deployment — without writing a single line of code. Experience the future of NFT development by unlocking the full potential of ERC721-C contracts with enforceable royalties and visit developers.freenft.com.

Just a few of the stand-out qualities include :

💡 Ease of use — configure contracts without code

💸 Cheap — save up to 90% of the typical deployment cost

🏆 Enforce creator royalties — the developer chooses which exchanges can execute trades of their NFTs

🎮 Powerful gaming mechanics with support for on-chain events

🪂 Merkle tree and airdrop distribution mechanisms

💾 Choice of 4 programmable royalty templates

We would love to hear your thoughts. Please reach out to blockchain@limitbreak.com with suggestions and feedback.

Disclaimer:

Both the Limit Break and DigiDaigaku teams are still planning how they might implement these solutions so don’t make any assumptions on how this will work for either team or their products yet.

--

--

Limit Break Dev
Limit Break

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