From 03835b3ed78cc2b001d742bfb2917e92f1a4a9e9 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 10 Aug 2022 16:48:16 +0200 Subject: [PATCH] Rebase ERC721Consecutive work on Checkpoints --- contracts/mocks/ERC721ConsecutiveMock.sol | 118 ++++++++++++++++++ contracts/token/ERC721/ERC721.sol | 35 +++++- .../ERC721/extensions/ERC721Consecutive.sol | 98 +++++++++++++++ .../ERC721/extensions/ERC721Enumerable.sol | 42 +++++++ .../ERC721/extensions/ERC721Pausable.sol | 11 ++ .../ERC721/extensions/draft-ERC721Votes.sol | 15 +++ .../ERC721PresetMinterPauserAutoId.sol | 9 ++ .../extensions/ERC721Consecutive.test.js | 109 ++++++++++++++++ 8 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 contracts/mocks/ERC721ConsecutiveMock.sol create mode 100644 contracts/token/ERC721/extensions/ERC721Consecutive.sol create mode 100644 test/token/ERC721/extensions/ERC721Consecutive.test.js diff --git a/contracts/mocks/ERC721ConsecutiveMock.sol b/contracts/mocks/ERC721ConsecutiveMock.sol new file mode 100644 index 00000000000..c4845e22bbc --- /dev/null +++ b/contracts/mocks/ERC721ConsecutiveMock.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../token/ERC721/extensions/ERC721Burnable.sol"; +import "../token/ERC721/extensions/ERC721Consecutive.sol"; +import "../token/ERC721/extensions/ERC721Enumerable.sol"; +import "../token/ERC721/extensions/ERC721Pausable.sol"; +import "../token/ERC721/extensions/draft-ERC721Votes.sol"; + +/* solhint-disable-next-line contract-name-camelcase */ +abstract contract __VotesDelegationInConstructor is Votes { + constructor(address[] memory accounts) { + for (uint256 i; i < accounts.length; ++i) { + _delegate(accounts[i], accounts[i]); + } + } +} + +/** + * @title ERC721ConsecutiveMock + */ +contract ERC721ConsecutiveMock is + __VotesDelegationInConstructor, + ERC721Burnable, + ERC721Consecutive, + ERC721Enumerable, + ERC721Pausable, + ERC721Votes +{ + constructor( + string memory name, + string memory symbol, + address[] memory receivers, + uint96[] memory amounts + ) + __VotesDelegationInConstructor(receivers) + ERC721(name, symbol) + ERC721Consecutive(receivers, amounts) + EIP712(name, "1") + {} + + function pause() external { + _pause(); + } + + function unpause() external { + _unpause(); + } + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC721, ERC721Enumerable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + function exists(uint256 tokenId) public view returns (bool) { + return _exists(tokenId); + } + + function mint(address to, uint256 tokenId) public { + _mint(to, tokenId); + } + + function safeMint(address to, uint256 tokenId) public { + _safeMint(to, tokenId); + } + + function _ownerOf(uint256 tokenId) internal view virtual override(ERC721, ERC721Consecutive) returns (address) { + return super._ownerOf(tokenId); + } + + function _burn(uint256 tokenId) internal virtual override(ERC721, ERC721Consecutive) { + super._burn(tokenId); + } + + function _mint(address to, uint256 tokenId) internal virtual override(ERC721, ERC721Consecutive) { + super._mint(to, tokenId); + } + + function _beforeTokenTransfer( + address from, + address to, + uint256 tokenId + ) internal virtual override(ERC721, ERC721Enumerable, ERC721Pausable) { + super._beforeTokenTransfer(from, to, tokenId); + } + + function _afterTokenTransfer( + address from, + address to, + uint256 tokenId + ) internal virtual override(ERC721, ERC721Votes) { + super._afterTokenTransfer(from, to, tokenId); + } + + function _beforeConsecutiveTokenTransfer( + address from, + address to, + uint256 first, + uint256 last + ) internal virtual override(ERC721, ERC721Enumerable, ERC721Pausable) { + super._beforeConsecutiveTokenTransfer(from, to, first, last); + } + + function _afterConsecutiveTokenTransfer( + address from, + address to, + uint256 first, + uint256 last + ) internal virtual override(ERC721, ERC721Votes) { + super._afterConsecutiveTokenTransfer(from, to, first, last); + } +} diff --git a/contracts/token/ERC721/ERC721.sol b/contracts/token/ERC721/ERC721.sol index e33d2c79459..77601ce8e05 100644 --- a/contracts/token/ERC721/ERC721.sol +++ b/contracts/token/ERC721/ERC721.sol @@ -68,7 +68,7 @@ contract ERC721 is Context, ERC165, IERC721, IERC721Metadata { * @dev See {IERC721-ownerOf}. */ function ownerOf(uint256 tokenId) public view virtual override returns (address) { - address owner = _owners[tokenId]; + address owner = _ownerOf(tokenId); require(owner != address(0), "ERC721: invalid token ID"); return owner; } @@ -210,6 +210,13 @@ contract ERC721 is Context, ERC165, IERC721, IERC721Metadata { require(_checkOnERC721Received(from, to, tokenId, data), "ERC721: transfer to non ERC721Receiver implementer"); } + /** + * @dev Returns the owner of the `tokenId`. Does NOT revert if token doesn't exist + */ + function _ownerOf(uint256 tokenId) internal view virtual returns (address) { + return _owners[tokenId]; + } + /** * @dev Returns whether `tokenId` exists. * @@ -219,7 +226,7 @@ contract ERC721 is Context, ERC165, IERC721, IERC721Metadata { * and stop existing when they are burned (`_burn`). */ function _exists(uint256 tokenId) internal view virtual returns (bool) { - return _owners[tokenId] != address(0); + return _ownerOf(tokenId) != address(0); } /** @@ -452,4 +459,28 @@ contract ERC721 is Context, ERC165, IERC721, IERC721Metadata { address to, uint256 tokenId ) internal virtual {} + + /** + * TODO + */ + function _beforeConsecutiveTokenTransfer( + address from, + address to, + uint256 first, + uint256 last + ) internal virtual { + if (from != address(0)) { + _balances[from] -= last - first + 1; + } + if (to != address(0)) { + _balances[to] += last - first + 1; + } + } + + function _afterConsecutiveTokenTransfer( + address from, + address to, + uint256 first, + uint256 last + ) internal virtual {} } diff --git a/contracts/token/ERC721/extensions/ERC721Consecutive.sol b/contracts/token/ERC721/extensions/ERC721Consecutive.sol new file mode 100644 index 00000000000..8894692cfd5 --- /dev/null +++ b/contracts/token/ERC721/extensions/ERC721Consecutive.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (token/ERC721/extensions/ERC721Burnable.sol) + +pragma solidity ^0.8.0; + +import "../ERC721.sol"; +import "../../../utils/Checkpoints.sol"; +import "../../../utils/math/SafeCast.sol"; +import "../../../utils/structs/BitMaps.sol"; + +/** + * @title ERC721 Cheap sequential minting + */ +abstract contract ERC721Consecutive is ERC721 { + using BitMaps for BitMaps.BitMap; + using Checkpoints for Checkpoints.Checkpoint160[]; + + Checkpoints.Checkpoint160[] private _sequentialOwnership; + BitMaps.BitMap private _sequentialBurn; + + event ConsecutiveTransfer( + uint256 indexed fromTokenId, + uint256 toTokenId, + address indexed fromAddress, + address indexed toAddress + ); + + constructor(address[] memory receivers, uint96[] memory amounts) { + // Check input length + uint256 length = receivers.length; + require(length == amounts.length); + + // For each batch of token + for (uint256 i = 0; i < length; ++i) { + _mintConsecutive(receivers[i], amounts[i]); + } + } + + function _ownerOf(uint256 tokenId) internal view virtual override returns (address) { + address owner = super._ownerOf(tokenId); + + // If token is owned by the core, or beyound consecutive range, return base value + if (owner != address(0) || tokenId > type(uint96).max) { + return owner; + } + + // Otherwize, check the token was not burned, and fetch ownership from the anchors + // Note: no need for safe cast, we know that tokenId <= type(uint96).max + return + _sequentialBurn.get(tokenId) + ? address(0) + : address(_sequentialOwnership.lowerLookup(uint96(tokenId))); + } + + function _mintConsecutive(address to, uint96 batchSize) internal virtual { + require(!Address.isContract(address(this)), "ERC721Consecutive: batch minting restricted to constructor"); + + require(to != address(0), "ERC721Consecutive: mint to the zero address"); + require(batchSize > 0, "ERC721Consecutive: empty batch"); + require(batchSize < 5000, "ERC721Consecutive: batches too large for indexing"); + + uint96 first = _totalConsecutiveSupply(); + uint96 last = first + batchSize - 1; + + // hook before + _beforeConsecutiveTokenTransfer(address(0), to, first, last); + + // push an ownership checkpoint & emit event + _sequentialOwnership.push(SafeCast.toUint96(last), uint160(to)); + emit ConsecutiveTransfer(first, last, address(0), to); + + // hook after + _afterConsecutiveTokenTransfer(address(0), to, first, last); + } + + function _mint(address to, uint256 tokenId) internal virtual override { + // During construction, minting should only be performed using the batch mechanism. + // This is necessary because interleaving mint and batchmint would cause issues. + require(Address.isContract(address(this)), "ERC721Consecutive: cant mint durring construction"); + + super._mint(to, tokenId); + if (_sequentialBurn.get(tokenId)) { + _sequentialBurn.unset(tokenId); + } + } + + function _burn(uint256 tokenId) internal virtual override { + super._burn(tokenId); + if (tokenId <= _totalConsecutiveSupply()) { + _sequentialBurn.set(tokenId); + } + } + + function _totalConsecutiveSupply() private view returns (uint96) { + uint256 length = _sequentialOwnership.length; + return length == 0 ? 0 : _sequentialOwnership[length - 1]._key + 1; + } +} diff --git a/contracts/token/ERC721/extensions/ERC721Enumerable.sol b/contracts/token/ERC721/extensions/ERC721Enumerable.sol index 46afd5d0b0c..338d39edf7b 100644 --- a/contracts/token/ERC721/extensions/ERC721Enumerable.sol +++ b/contracts/token/ERC721/extensions/ERC721Enumerable.sol @@ -88,6 +88,48 @@ abstract contract ERC721Enumerable is ERC721, IERC721Enumerable { } } + /** + * @dev Hook that is called before any batch token transfer. For now this is limited + * to batch minting by the {ERC721Consecutive} extension. + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, ``from``'s `tokenId` will be + * transferred to `to`. + * - When `from` is zero, `tokenId` will be minted for `to`. + * - When `to` is zero, ``from``'s `tokenId` will be burned. + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * + * To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks]. + */ + function _beforeConsecutiveTokenTransfer( + address from, + address to, + uint256 first, + uint256 last + ) internal virtual override { + require(from == address(0) && to != address(0), "ERC721Enumerable: only batch minting is supported"); + + // Before balance is updated (that is part of the super call) + uint256 length = ERC721.balanceOf(to); + + // Do the super call + super._beforeConsecutiveTokenTransfer(from, to, first, last); + + // Add to enumerability + for (uint256 tokenId = first; tokenId <= last; ++tokenId) { + // Add to all tokens + _addTokenToAllTokensEnumeration(tokenId); + + // Add to owner tokens + _ownedTokens[to][length] = tokenId; + _ownedTokensIndex[tokenId] = length; + + ++length; + } + } + /** * @dev Private function to add a token to this extension's ownership-tracking data structures. * @param to address representing the new owner of the given token ID diff --git a/contracts/token/ERC721/extensions/ERC721Pausable.sol b/contracts/token/ERC721/extensions/ERC721Pausable.sol index fbf8b638236..eb8bf2f611a 100644 --- a/contracts/token/ERC721/extensions/ERC721Pausable.sol +++ b/contracts/token/ERC721/extensions/ERC721Pausable.sol @@ -30,4 +30,15 @@ abstract contract ERC721Pausable is ERC721, Pausable { require(!paused(), "ERC721Pausable: token transfer while paused"); } + + function _beforeConsecutiveTokenTransfer( + address from, + address to, + uint256 first, + uint256 last + ) internal virtual override { + super._beforeConsecutiveTokenTransfer(from, to, first, last); + + require(!paused(), "ERC721Pausable: token transfer while paused"); + } } diff --git a/contracts/token/ERC721/extensions/draft-ERC721Votes.sol b/contracts/token/ERC721/extensions/draft-ERC721Votes.sol index b4ec91eab5e..5af0f958f04 100644 --- a/contracts/token/ERC721/extensions/draft-ERC721Votes.sol +++ b/contracts/token/ERC721/extensions/draft-ERC721Votes.sol @@ -31,6 +31,21 @@ abstract contract ERC721Votes is ERC721, Votes { super._afterTokenTransfer(from, to, tokenId); } + /** + * @dev Adjusts votes when a batch of tokens is transferred. + * + * Emits a {Votes-DelegateVotesChanged} event. + */ + function _afterConsecutiveTokenTransfer( + address from, + address to, + uint256 first, + uint256 last + ) internal virtual override { + _transferVotingUnits(from, to, last - first + 1); + super._afterConsecutiveTokenTransfer(from, to, first, last); + } + /** * @dev Returns the balance of `account`. */ diff --git a/contracts/token/ERC721/presets/ERC721PresetMinterPauserAutoId.sol b/contracts/token/ERC721/presets/ERC721PresetMinterPauserAutoId.sol index 11b9787800d..11b97564e7c 100644 --- a/contracts/token/ERC721/presets/ERC721PresetMinterPauserAutoId.sol +++ b/contracts/token/ERC721/presets/ERC721PresetMinterPauserAutoId.sol @@ -124,6 +124,15 @@ contract ERC721PresetMinterPauserAutoId is super._beforeTokenTransfer(from, to, tokenId); } + function _beforeConsecutiveTokenTransfer( + address from, + address to, + uint256 first, + uint256 last + ) internal virtual override(ERC721, ERC721Enumerable, ERC721Pausable) { + super._beforeConsecutiveTokenTransfer(from, to, first, last); + } + /** * @dev See {IERC165-supportsInterface}. */ diff --git a/test/token/ERC721/extensions/ERC721Consecutive.test.js b/test/token/ERC721/extensions/ERC721Consecutive.test.js new file mode 100644 index 00000000000..6d9736f13a2 --- /dev/null +++ b/test/token/ERC721/extensions/ERC721Consecutive.test.js @@ -0,0 +1,109 @@ +const { constants, expectEvent, expectRevert } = require('@openzeppelin/test-helpers'); +const { expect } = require('chai'); + +const ERC721ConsecutiveMock = artifacts.require('ERC721ConsecutiveMock'); + +contract('ERC721Consecutive', function (accounts) { + const [ user1, user2, receiver ] = accounts; + + const name = 'Non Fungible Token'; + const symbol = 'NFT'; + const batches = [ + { receiver: user1, amount: 3 }, + { receiver: user2, amount: 5 }, + { receiver: user1, amount: 7 }, + ]; + + beforeEach(async function () { + this.token = await ERC721ConsecutiveMock.new( + name, + symbol, + batches.map(({ receiver }) => receiver), + batches.map(({ amount }) => amount), + ); + }); + + describe('batch are minted', function () { + it('events are emitted at construction', async function () { + let first = 0; + + for (const batch of batches) { + await expectEvent.inTransaction(this.token.transactionHash, this.token, 'ConsecutiveTransfer', { + fromTokenId: web3.utils.toBN(first), + toTokenId: web3.utils.toBN(first + batch.amount - 1), + fromAddress: constants.ZERO_ADDRESS, + toAddress: batch.receiver, + }); + + first += batch.amount; + } + }); + + it('ownership is set', async function () { + const owners = batches.flatMap(({ receiver, amount }) => Array(amount).fill(receiver)); + + for (const tokenId in owners) { + expect(await this.token.ownerOf(tokenId)) + .to.be.equal(owners[tokenId]); + } + }); + + it('balance & voting poser are set', async function () { + for (const account of accounts) { + const balance = batches + .filter(({ receiver }) => receiver === account) + .map(({ amount }) => amount) + .reduce((a, b) => a + b, 0); + + expect(await this.token.balanceOf(account)) + .to.be.bignumber.equal(web3.utils.toBN(balance)); + + expect(await this.token.getVotes(account)) + .to.be.bignumber.equal(web3.utils.toBN(balance)); + } + }); + + it('enumerability correctly set', async function () { + const owners = batches.flatMap(({ receiver, amount }) => Array(amount).fill(receiver)); + + expect(await this.token.totalSupply()) + .to.be.bignumber.equal(web3.utils.toBN(owners.length)); + + for (const tokenId in owners) { + expect(await this.token.tokenByIndex(tokenId)) + .to.be.bignumber.equal(web3.utils.toBN(tokenId)); + } + + for (const account of accounts) { + const owned = Object.entries(owners) + .filter(([ _, owner ]) => owner === account) + .map(([ tokenId, _ ]) => tokenId); + + for (const i in owned) { + expect(await this.token.tokenOfOwnerByIndex(account, i).then(x => x.toString())) + .to.be.bignumber.equal(web3.utils.toBN(owned[i])); + } + } + }); + }); + + describe('ERC721 behavior', function () { + it('core takes over ownership on transfer', async function () { + await this.token.transferFrom(user1, receiver, 1, { from: user1 }); + + expect(await this.token.ownerOf(1)).to.be.equal(receiver); + }); + + it('tokens can be burned and re-minted', async function () { + const receipt1 = await this.token.burn(1, { from: user1 }); + expectEvent(receipt1, 'Transfer', { from: user1, to: constants.ZERO_ADDRESS, tokenId: '1' }); + + await expectRevert(this.token.ownerOf(1), 'ERC721: invalid token ID'); + + const receipt2 = await this.token.mint(user2, 1); + expectEvent(receipt2, 'Transfer', { from: constants.ZERO_ADDRESS, to: user2, tokenId: '1' }); + + expect(await this.token.ownerOf(1)).to.be.equal(user2); + }); + }); +});