Bladeren bron

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 jaren geleden
bovenliggende
commit
a65c03bc0d

+ 1 - 0
CHANGELOG.md

@@ -2,6 +2,7 @@
 
 ## 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))
  * `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))

+ 8 - 6
contracts/interfaces/IERC2981.sol

@@ -6,15 +6,17 @@ pragma solidity ^0.8.0;
 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 {
     /**
-     * @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)
         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.
 * {ERC721Votes}: Support for voting and vote delegation.
+* {ERC721Royalty}: A way to signal royalty information following ERC2981.
 * {ERC721Pausable}: A primitive to pause contract operation.
 * {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}}
 
+{{ERC721Royalty}}
+
 == 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.

+ 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');
 
 function getPageTitle (directory) {
-  if (directory === 'metatx') {
+  switch (directory) {
+  case 'metatx':
     return 'Meta Transactions';
-  } else {
+  case 'common':
+    return 'Common (Tokens)';
+  default:
     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)',
     'queue(address[],uint256[],bytes[],bytes32)',
   ],
+  ERC2981: [
+    'royaltyInfo(uint256,uint256)',
+  ],
 };
 
 const INTERFACE_IDS = {};