Browse Source

Implement Non Fungible Token Royalty (EIP2981) (#3012)

Co-authored-by: Francisco Giordano <frangio.1@gmail.com>
Co-authored-by: Hadrien Croubois <hadrien.croubois@gmail.com>
JulissaDantes 3 years ago
parent
commit
a65c03bc0d

+ 1 - 0
CHANGELOG.md

@@ -2,6 +2,7 @@
 
 
 ## Unreleased
 ## Unreleased
 
 
+ * `ERC2891`: add implementation of the royalty standard, and the respective extensions for `ERC721` and `ERC1155`. ([#3012](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3012))
  * `GovernorTimelockControl`: improve the `state()` function to have it reflect cases where a proposal has been canceled directly on the timelock. ([#2977](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2977))
  * `GovernorTimelockControl`: improve the `state()` function to have it reflect cases where a proposal has been canceled directly on the timelock. ([#2977](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2977))
  * `Math`: add a `abs(int256)` method that returns the unsigned absolute value of a signed value. ([#2984](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2984))
  * `Math`: add a `abs(int256)` method that returns the unsigned absolute value of a signed value. ([#2984](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2984))
  * Preset contracts are now deprecated in favor of [Contracts Wizard](https://wizard.openzeppelin.com). ([#2986](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2986))
  * Preset contracts are now deprecated in favor of [Contracts Wizard](https://wizard.openzeppelin.com). ([#2986](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2986))

+ 8 - 6
contracts/interfaces/IERC2981.sol

@@ -6,15 +6,17 @@ pragma solidity ^0.8.0;
 import "./IERC165.sol";
 import "./IERC165.sol";
 
 
 /**
 /**
- * @dev Interface for the NFT Royalty Standard
+ * @dev Interface for the NFT Royalty Standard.
+ *
+ * A standardized way to retrieve royalty payment information for non-fungible tokens (NFTs) to enable universal
+ * support for royalty payments across all NFT marketplaces and ecosystem participants.
+ *
+ * _Available since v4.5._
  */
  */
 interface IERC2981 is IERC165 {
 interface IERC2981 is IERC165 {
     /**
     /**
-     * @dev Called with the sale price to determine how much royalty is owed and to whom.
-     * @param tokenId - the NFT asset queried for royalty information
-     * @param salePrice - the sale price of the NFT asset specified by `tokenId`
-     * @return receiver - address of who should be sent the royalty payment
-     * @return royaltyAmount - the royalty payment amount for `salePrice`
+     * @dev Returns how much royalty is owed and to whom, based on a sale price that may be denominated in any unit of
+     * exchange. The royalty amount is denominated and should be payed in that same unit of exchange.
      */
      */
     function royaltyInfo(uint256 tokenId, uint256 salePrice)
     function royaltyInfo(uint256 tokenId, uint256 salePrice)
         external
         external

+ 33 - 0
contracts/mocks/ERC721RoyaltyMock.sol

@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+import "../token/ERC721/extensions/ERC721Royalty.sol";
+
+contract ERC721RoyaltyMock is ERC721Royalty {
+    constructor(string memory name, string memory symbol) ERC721(name, symbol) {}
+
+    function setTokenRoyalty(
+        uint256 tokenId,
+        address recipient,
+        uint96 fraction
+    ) public {
+        _setTokenRoyalty(tokenId, recipient, fraction);
+    }
+
+    function setDefaultRoyalty(address recipient, uint96 fraction) public {
+        _setDefaultRoyalty(recipient, fraction);
+    }
+
+    function mint(address to, uint256 tokenId) public {
+        _mint(to, tokenId);
+    }
+
+    function burn(uint256 tokenId) public {
+        _burn(tokenId);
+    }
+
+    function deleteDefaultRoyalty() public {
+        _deleteDefaultRoyalty();
+    }
+}

+ 3 - 0
contracts/token/ERC721/README.adoc

@@ -24,6 +24,7 @@ Additionally there are a few of other extensions:
 
 
 * {ERC721URIStorage}: A more flexible but more expensive way of storing metadata.
 * {ERC721URIStorage}: A more flexible but more expensive way of storing metadata.
 * {ERC721Votes}: Support for voting and vote delegation.
 * {ERC721Votes}: Support for voting and vote delegation.
+* {ERC721Royalty}: A way to signal royalty information following ERC2981.
 * {ERC721Pausable}: A primitive to pause contract operation.
 * {ERC721Pausable}: A primitive to pause contract operation.
 * {ERC721Burnable}: A way for token holders to burn their own tokens.
 * {ERC721Burnable}: A way for token holders to burn their own tokens.
 
 
@@ -53,6 +54,8 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel
 
 
 {{ERC721Votes}}
 {{ERC721Votes}}
 
 
+{{ERC721Royalty}}
+
 == Presets
 == Presets
 
 
 These contracts are preconfigured combinations of the above features. They can be used through inheritance or as models to copy and paste their source code.
 These contracts are preconfigured combinations of the above features. They can be used through inheritance or as models to copy and paste their source code.

+ 38 - 0
contracts/token/ERC721/extensions/ERC721Royalty.sol

@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: MIT
+// OpenZeppelin Contracts v4.4.0 (token/ERC721/extensions/ERC721Royalty.sol)
+
+pragma solidity ^0.8.0;
+
+import "../ERC721.sol";
+import "../../common/ERC2981.sol";
+import "../../../utils/introspection/ERC165.sol";
+
+/**
+ * @dev Extension of ERC721 with the ERC2981 NFT Royalty Standard, a standardized way to retrieve royalty payment
+ * information.
+ *
+ * Royalty information can be specified globally for all token ids via {_setDefaultRoyalty}, and/or individually for
+ * specific token ids via {_setTokenRoyalty}. The latter takes precedence over the first.
+ *
+ * IMPORTANT: ERC-2981 only specifies a way to signal royalty information and does not enforce its payment. See
+ * https://eips.ethereum.org/EIPS/eip-2981#optional-royalty-payments[Rationale] in the EIP. Marketplaces are expected to
+ * voluntarily pay royalties together with sales, but note that this standard is not yet widely supported.
+ *
+ * _Available since v4.5._
+ */
+abstract contract ERC721Royalty is ERC2981, ERC721 {
+    /**
+     * @dev See {IERC165-supportsInterface}.
+     */
+    function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721, ERC2981) returns (bool) {
+        return super.supportsInterface(interfaceId);
+    }
+
+    /**
+     * @dev See {ERC721-_burn}. This override additionally clears the royalty information for the token.
+     */
+    function _burn(uint256 tokenId) internal virtual override {
+        super._burn(tokenId);
+        _resetTokenRoyalty(tokenId);
+    }
+}

+ 112 - 0
contracts/token/common/ERC2981.sol

@@ -0,0 +1,112 @@
+// SPDX-License-Identifier: MIT
+// OpenZeppelin Contracts v4.4.0 (token/common/ERC2981.sol)
+
+pragma solidity ^0.8.0;
+
+import "../../interfaces/IERC2981.sol";
+import "../../utils/introspection/ERC165.sol";
+
+/**
+ * @dev Implementation of the NFT Royalty Standard, a standardized way to retrieve royalty payment information.
+ *
+ * Royalty information can be specified globally for all token ids via {_setDefaultRoyalty}, and/or individually for
+ * specific token ids via {_setTokenRoyalty}. The latter takes precedence over the first.
+ *
+ * Royalty is specified as a fraction of sale price. {_feeDenominator} is overridable but defaults to 10000, meaning the
+ * fee is specified in basis points by default.
+ *
+ * IMPORTANT: ERC-2981 only specifies a way to signal royalty information and does not enforce its payment. See
+ * https://eips.ethereum.org/EIPS/eip-2981#optional-royalty-payments[Rationale] in the EIP. Marketplaces are expected to
+ * voluntarily pay royalties together with sales, but note that this standard is not yet widely supported.
+ *
+ * _Available since v4.5._
+ */
+abstract contract ERC2981 is IERC2981, ERC165 {
+    struct RoyaltyInfo {
+        address receiver;
+        uint96 royaltyFraction;
+    }
+
+    RoyaltyInfo private _defaultRoyaltyInfo;
+    mapping(uint256 => RoyaltyInfo) private _tokenRoyaltyInfo;
+
+    /**
+     * @dev See {IERC165-supportsInterface}.
+     */
+    function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC165) returns (bool) {
+        return interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId);
+    }
+
+    /**
+     * @inheritdoc IERC2981
+     */
+    function royaltyInfo(uint256 _tokenId, uint256 _salePrice) external view override returns (address, uint256) {
+        RoyaltyInfo memory royalty = _tokenRoyaltyInfo[_tokenId];
+
+        if (royalty.receiver == address(0)) {
+            royalty = _defaultRoyaltyInfo;
+        }
+
+        uint256 royaltyAmount = (_salePrice * royalty.royaltyFraction) / _feeDenominator();
+
+        return (royalty.receiver, royaltyAmount);
+    }
+
+    /**
+     * @dev The denominator with which to interpret the fee set in {_setTokenRoyalty} and {_setDefaultRoyalty} as a
+     * fraction of the sale price. Defaults to 10000 so fees are expressed in basis points, but may be customized by an
+     * override.
+     */
+    function _feeDenominator() internal pure virtual returns (uint96) {
+        return 10000;
+    }
+
+    /**
+     * @dev Sets the royalty information that all ids in this contract will default to.
+     *
+     * Requirements:
+     *
+     * - `receiver` cannot be the zero address.
+     * - `feeNumerator` cannot be greater than the fee denominator.
+     */
+    function _setDefaultRoyalty(address receiver, uint96 feeNumerator) internal virtual {
+        require(feeNumerator <= _feeDenominator(), "ERC2981: royalty fee will exceed salePrice");
+        require(receiver != address(0), "ERC2981: invalid receiver");
+
+        _defaultRoyaltyInfo = RoyaltyInfo(receiver, feeNumerator);
+    }
+
+    /**
+     * @dev Removes default royalty information.
+     */
+    function _deleteDefaultRoyalty() internal virtual {
+        delete _defaultRoyaltyInfo;
+    }
+
+    /**
+     * @dev Sets the royalty information for a specific token id, overriding the global default.
+     *
+     * Requirements:
+     *
+     * - `tokenId` must be already minted.
+     * - `receiver` cannot be the zero address.
+     * - `feeNumerator` cannot be greater than the fee denominator.
+     */
+    function _setTokenRoyalty(
+        uint256 tokenId,
+        address receiver,
+        uint96 feeNumerator
+    ) internal virtual {
+        require(feeNumerator <= _feeDenominator(), "ERC2981: royalty fee will exceed salePrice");
+        require(receiver != address(0), "ERC2981: Invalid parameters");
+
+        _tokenRoyaltyInfo[tokenId] = RoyaltyInfo(receiver, feeNumerator);
+    }
+
+    /**
+     * @dev Resets royalty information for the token id back to the global default.
+     */
+    function _resetTokenRoyalty(uint256 tokenId) internal virtual {
+        delete _tokenRoyaltyInfo[tokenId];
+    }
+}

+ 10 - 0
contracts/token/common/README.adoc

@@ -0,0 +1,10 @@
+= Common (Tokens)
+
+Functionality that is common to multiple token standards.
+
+* {ERC2981}: NFT Royalties compatible with both ERC721 and ERC1155.
+** For ERC721 consider {ERC721Royalty} which clears the royalty information from storage on burn.
+
+== Contracts
+
+{{ERC2981}}

+ 5 - 2
scripts/gen-nav.js

@@ -13,9 +13,12 @@ const files = proc.execFileSync(
 console.log('.API');
 console.log('.API');
 
 
 function getPageTitle (directory) {
 function getPageTitle (directory) {
-  if (directory === 'metatx') {
+  switch (directory) {
+  case 'metatx':
     return 'Meta Transactions';
     return 'Meta Transactions';
-  } else {
+  case 'common':
+    return 'Common (Tokens)';
+  default:
     return startCase(directory);
     return startCase(directory);
   }
   }
 }
 }

+ 40 - 0
test/token/ERC721/extensions/ERC721Royalty.test.js

@@ -0,0 +1,40 @@
+const { BN, constants } = require('@openzeppelin/test-helpers');
+const ERC721RoyaltyMock = artifacts.require('ERC721RoyaltyMock');
+const { ZERO_ADDRESS } = constants;
+
+const { shouldBehaveLikeERC2981 } = require('../../common/ERC2981.behavior');
+
+contract('ERC721Royalty', function (accounts) {
+  const [ account1, account2 ] = accounts;
+  const tokenId1 = new BN('1');
+  const tokenId2 = new BN('2');
+  const royalty = new BN('200');
+  const salePrice = new BN('1000');
+
+  beforeEach(async function () {
+    this.token = await ERC721RoyaltyMock.new('My Token', 'TKN');
+
+    await this.token.mint(account1, tokenId1);
+    await this.token.mint(account1, tokenId2);
+    this.account1 = account1;
+    this.account2 = account2;
+    this.tokenId1 = tokenId1;
+    this.tokenId2 = tokenId2;
+    this.salePrice = salePrice;
+  });
+
+  describe('token specific functions', function () {
+    beforeEach(async function () {
+      await this.token.setTokenRoyalty(tokenId1, account1, royalty);
+    });
+
+    it('removes royalty information after burn', async function () {
+      await this.token.burn(tokenId1);
+      const tokenInfo = await this.token.royaltyInfo(tokenId1, salePrice);
+
+      expect(tokenInfo[0]).to.be.equal(ZERO_ADDRESS);
+      expect(tokenInfo[1]).to.be.bignumber.equal(new BN('0'));
+    });
+  });
+  shouldBehaveLikeERC2981();
+});

+ 160 - 0
test/token/common/ERC2981.behavior.js

@@ -0,0 +1,160 @@
+const { BN, constants, expectRevert } = require('@openzeppelin/test-helpers');
+const { expect } = require('chai');
+const { ZERO_ADDRESS } = constants;
+
+const { shouldSupportInterfaces } = require('../../utils/introspection/SupportsInterface.behavior');
+
+function shouldBehaveLikeERC2981 () {
+  const royaltyFraction = new BN('10');
+
+  shouldSupportInterfaces(['ERC2981']);
+
+  describe('default royalty', function () {
+    beforeEach(async function () {
+      await this.token.setDefaultRoyalty(this.account1, royaltyFraction);
+    });
+
+    it('checks royalty is set', async function () {
+      const royalty = new BN((this.salePrice * royaltyFraction) / 10000);
+
+      const initInfo = await this.token.royaltyInfo(this.tokenId1, this.salePrice);
+
+      expect(initInfo[0]).to.be.equal(this.account1);
+      expect(initInfo[1]).to.be.bignumber.equal(royalty);
+    });
+
+    it('updates royalty amount', async function () {
+      const newPercentage = new BN('25');
+
+      // Updated royalty check
+      await this.token.setDefaultRoyalty(this.account1, newPercentage);
+      const royalty = new BN((this.salePrice * newPercentage) / 10000);
+      const newInfo = await this.token.royaltyInfo(this.tokenId1, this.salePrice);
+
+      expect(newInfo[0]).to.be.equal(this.account1);
+      expect(newInfo[1]).to.be.bignumber.equal(royalty);
+    });
+
+    it('holds same royalty value for different tokens', async function () {
+      const newPercentage = new BN('20');
+      await this.token.setDefaultRoyalty(this.account1, newPercentage);
+
+      const token1Info = await this.token.royaltyInfo(this.tokenId1, this.salePrice);
+      const token2Info = await this.token.royaltyInfo(this.tokenId2, this.salePrice);
+
+      expect(token1Info[1]).to.be.bignumber.equal(token2Info[1]);
+    });
+
+    it('Remove royalty information', async function () {
+      const newValue = new BN('0');
+      await this.token.deleteDefaultRoyalty();
+
+      const token1Info = await this.token.royaltyInfo(this.tokenId1, this.salePrice);
+      const token2Info = await this.token.royaltyInfo(this.tokenId2, this.salePrice);
+      // Test royalty info is still persistent across all tokens
+      expect(token1Info[0]).to.be.bignumber.equal(token2Info[0]);
+      expect(token1Info[1]).to.be.bignumber.equal(token2Info[1]);
+      // Test information was deleted
+      expect(token1Info[0]).to.be.equal(ZERO_ADDRESS);
+      expect(token1Info[1]).to.be.bignumber.equal(newValue);
+    });
+
+    it('reverts if invalid parameters', async function () {
+      await expectRevert(
+        this.token.setDefaultRoyalty(ZERO_ADDRESS, royaltyFraction),
+        'ERC2981: invalid receiver',
+      );
+
+      await expectRevert(
+        this.token.setTokenRoyalty(this.tokenId1, this.account1, new BN('11000')),
+        'ERC2981: royalty fee will exceed salePrice',
+      );
+    });
+  });
+
+  describe('token based royalty', function () {
+    beforeEach(async function () {
+      await this.token.setTokenRoyalty(this.tokenId1, this.account1, royaltyFraction);
+    });
+
+    it('updates royalty amount', async function () {
+      const newPercentage = new BN('25');
+      let royalty = new BN((this.salePrice * royaltyFraction) / 10000);
+      // Initial royalty check
+      const initInfo = await this.token.royaltyInfo(this.tokenId1, this.salePrice);
+
+      expect(initInfo[0]).to.be.equal(this.account1);
+      expect(initInfo[1]).to.be.bignumber.equal(royalty);
+
+      // Updated royalty check
+      await this.token.setTokenRoyalty(this.tokenId1, this.account1, newPercentage);
+      royalty = new BN((this.salePrice * newPercentage) / 10000);
+      const newInfo = await this.token.royaltyInfo(this.tokenId1, this.salePrice);
+
+      expect(newInfo[0]).to.be.equal(this.account1);
+      expect(newInfo[1]).to.be.bignumber.equal(royalty);
+    });
+
+    it('holds different values for different tokens', async function () {
+      const newPercentage = new BN('20');
+      await this.token.setTokenRoyalty(this.tokenId2, this.account1, newPercentage);
+
+      const token1Info = await this.token.royaltyInfo(this.tokenId1, this.salePrice);
+      const token2Info = await this.token.royaltyInfo(this.tokenId2, this.salePrice);
+
+      // must be different even at the same this.salePrice
+      expect(token1Info[1]).to.not.be.equal(token2Info.royaltyFraction);
+    });
+
+    it('reverts if invalid parameters', async function () {
+      await expectRevert(
+        this.token.setTokenRoyalty(this.tokenId1, ZERO_ADDRESS, royaltyFraction),
+        'ERC2981: Invalid parameters',
+      );
+
+      await expectRevert(
+        this.token.setTokenRoyalty(this.tokenId1, this.account1, new BN('11000')),
+        'ERC2981: royalty fee will exceed salePrice',
+      );
+    });
+
+    it('can reset token after setting royalty', async function () {
+      const newPercentage = new BN('30');
+      const royalty = new BN((this.salePrice * newPercentage) / 10000);
+      await this.token.setTokenRoyalty(this.tokenId1, this.account2, newPercentage);
+
+      const tokenInfo = await this.token.royaltyInfo(this.tokenId1, this.salePrice);
+
+      // Tokens must have own information
+      expect(tokenInfo[1]).to.be.bignumber.equal(royalty);
+      expect(tokenInfo[0]).to.be.equal(this.account2);
+
+      await this.token.setTokenRoyalty(this.tokenId2, this.account1, new BN('0'));
+      const result = await this.token.royaltyInfo(this.tokenId2, this.salePrice);
+      // Token must not share default information
+      expect(result[0]).to.be.equal(this.account1);
+      expect(result[1]).to.be.bignumber.equal(new BN('0'));
+    });
+
+    it('can hold default and token royalty information', async function () {
+      const newPercentage = new BN('30');
+      const royalty = new BN((this.salePrice * newPercentage) / 10000);
+
+      await this.token.setTokenRoyalty(this.tokenId2, this.account2, newPercentage);
+
+      const token1Info = await this.token.royaltyInfo(this.tokenId1, this.salePrice);
+      const token2Info = await this.token.royaltyInfo(this.tokenId2, this.salePrice);
+      // Tokens must not have same values
+      expect(token1Info[1]).to.not.be.bignumber.equal(token2Info[1]);
+      expect(token1Info[0]).to.not.be.equal(token2Info[0]);
+
+      // Updated token must have new values
+      expect(token2Info[0]).to.be.equal(this.account2);
+      expect(token2Info[1]).to.be.bignumber.equal(royalty);
+    });
+  });
+}
+
+module.exports = {
+  shouldBehaveLikeERC2981,
+};

+ 3 - 0
test/utils/introspection/SupportsInterface.behavior.js

@@ -74,6 +74,9 @@ const INTERFACES = {
     'proposalEta(uint256)',
     'proposalEta(uint256)',
     'queue(address[],uint256[],bytes[],bytes32)',
     'queue(address[],uint256[],bytes[],bytes32)',
   ],
   ],
+  ERC2981: [
+    'royaltyInfo(uint256,uint256)',
+  ],
 };
 };
 
 
 const INTERFACE_IDS = {};
 const INTERFACE_IDS = {};