From fb226210dd26696288c705a63a31649f86e51138 Mon Sep 17 00:00:00 2001 From: Vectorized Date: Sun, 13 Feb 2022 00:38:58 +0800 Subject: [PATCH] Added burn functionality (#61) * Added burn functionality. * Changed _initOneIndexed * Moved burn function into ERC721ABurnable * Moved burn function into ERC721ABurnable * Remove redundant burn check in ownershipOf * Optimized ownershipOf * Removed aux from AddressData for future PR * Packed currentIndex and totalBurned for gas savings * Added gas optimizations * Added gas optimizations * Added requested changes * Edited comments. * Renamed totalBurned to burnedCounter * Renamed to burnCounter * Updated comments. * Mark transferFrom/safeTransferFrom virtual * Mark transferFrom/safeTransferFrom virtual * Updated comments. * Tidy up tests * Inlined _exists for _burn and _transfer. * Merged custom errors * Merged custom errors * Fixed missing change from #59 * Gas optimization * update specs for _beforeTokenTransfers and _afterTokenTransfers hooks * Added #84 * Added #87 * Added #85 * Added #89 * Added comments on packing _currentIndex and _burnCounter * Removed overflow check for updatedIndex * Added requested test changes * Removed unused variable in burn test * Removed unused variable in burn test Co-authored-by: Amirhossein Banavi --- README.md | 1 - contracts/ERC721A.sol | 189 +++++++++++++++--- contracts/extensions/ERC721ABurnable.sol | 33 +++ .../extensions/ERC721AOwnersExplicit.sol | 2 +- contracts/mocks/ERC721ABurnableMock.sol | 22 ++ .../ERC721ABurnableOwnersExplicitMock.sol | 27 +++ package-lock.json | 4 +- test/extensions/ERC721ABurnable.test.js | 157 +++++++++++++++ .../ERC721ABurnableOwnersExplicit.test.js | 45 +++++ 9 files changed, 444 insertions(+), 36 deletions(-) create mode 100644 contracts/extensions/ERC721ABurnable.sol create mode 100644 contracts/mocks/ERC721ABurnableMock.sol create mode 100644 contracts/mocks/ERC721ABurnableOwnersExplicitMock.sol create mode 100644 test/extensions/ERC721ABurnable.test.js create mode 100644 test/extensions/ERC721ABurnableOwnersExplicit.test.js diff --git a/README.md b/README.md index af077b522..eb404b9ab 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,6 @@ contract Azuki is ERC721A { ## Roadmap -- [] Add burn function - [] Add flexibility for the first token id to not start at 0 - [] Support ERC721 Upgradeable - [] Add more documentation on benefits of using ERC721A diff --git a/contracts/ERC721A.sol b/contracts/ERC721A.sol index c83daa5c9..371567e71 100644 --- a/contracts/ERC721A.sol +++ b/contracts/ERC721A.sol @@ -18,6 +18,7 @@ error ApproveToCaller(); error ApprovalToCurrentOwner(); error BalanceQueryForZeroAddress(); error MintedQueryForZeroAddress(); +error BurnedQueryForZeroAddress(); error MintToZeroAddress(); error MintZeroQuantity(); error OwnerIndexOutOfBounds(); @@ -27,7 +28,6 @@ error TransferCallerNotOwnerNorApproved(); error TransferFromIncorrectOwner(); error TransferToNonERC721ReceiverImplementer(); error TransferToZeroAddress(); -error UnableDetermineTokenOwner(); error URIQueryForNonexistentToken(); /** @@ -36,25 +36,42 @@ error URIQueryForNonexistentToken(); * * Assumes serials are sequentially minted starting at 0 (e.g. 0, 1, 2, 3..). * - * Does not support burning tokens to address(0). + * Assumes that an owner cannot have more than 2**64 - 1 (max value of uint64) of supply. * - * Assumes that an owner cannot have more than the 2**128 - 1 (max value of uint128) of supply + * Assumes that the maximum token id cannot exceed 2**128 - 1 (max value of uint128). */ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable { using Address for address; using Strings for uint256; + // Compiler will pack this into a single 256bit word. struct TokenOwnership { + // The address of the owner. address addr; + // Keeps track of the start time of ownership with minimal overhead for tokenomics. uint64 startTimestamp; + // Whether the token has been burned. + bool burned; } + // Compiler will pack this into a single 256bit word. struct AddressData { - uint128 balance; - uint128 numberMinted; + // Realistically, 2**64-1 is more than enough. + uint64 balance; + // Keeps track of mint count with minimal overhead for tokenomics. + uint64 numberMinted; + // Keeps track of burn count with minimal overhead for tokenomics. + uint64 numberBurned; } - uint256 internal _currentIndex; + // Compiler will pack the following + // _currentIndex and _burnCounter into a single 256bit word. + + // The tokenId of the next token to be minted. + uint128 internal _currentIndex; + + // The number of tokens burned. + uint128 internal _burnCounter; // Token name string private _name; @@ -84,15 +101,36 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable * @dev See {IERC721Enumerable-totalSupply}. */ function totalSupply() public view override returns (uint256) { - return _currentIndex; + // Counter underflow is impossible as _burnCounter cannot be incremented + // more than _currentIndex times + unchecked { + return _currentIndex - _burnCounter; + } } /** * @dev See {IERC721Enumerable-tokenByIndex}. + * This read function is O(totalSupply). If calling from a separate contract, be sure to test gas first. + * It may also degrade with extremely large collection sizes (e.g >> 10000), test for your use case. */ function tokenByIndex(uint256 index) public view override returns (uint256) { - if (index >= totalSupply()) revert TokenIndexOutOfBounds(); - return index; + uint256 numMintedSoFar = _currentIndex; + uint256 tokenIdsIdx; + + // Counter overflow is impossible as the loop breaks when + // uint256 i is equal to another uint256 numMintedSoFar. + unchecked { + for (uint256 i; i < numMintedSoFar; i++) { + TokenOwnership memory ownership = _ownerships[i]; + if (!ownership.burned) { + if (tokenIdsIdx == index) { + return i; + } + tokenIdsIdx++; + } + } + } + revert TokenIndexOutOfBounds(); } /** @@ -102,14 +140,18 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable */ function tokenOfOwnerByIndex(address owner, uint256 index) public view override returns (uint256) { if (index >= balanceOf(owner)) revert OwnerIndexOutOfBounds(); - uint256 numMintedSoFar = totalSupply(); + uint256 numMintedSoFar = _currentIndex; uint256 tokenIdsIdx; address currOwnershipAddr; - // Counter overflow is impossible as the loop breaks when uint256 i is equal to another uint256 numMintedSoFar. + // Counter overflow is impossible as the loop breaks when + // uint256 i is equal to another uint256 numMintedSoFar. unchecked { for (uint256 i; i < numMintedSoFar; i++) { TokenOwnership memory ownership = _ownerships[i]; + if (ownership.burned) { + continue; + } if (ownership.addr != address(0)) { currOwnershipAddr = ownership.addr; } @@ -123,7 +165,7 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable } // Execution should never reach this point. - assert(false); + revert(); } /** @@ -150,21 +192,40 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable return uint256(_addressData[owner].numberMinted); } + function _numberBurned(address owner) internal view returns (uint256) { + if (owner == address(0)) revert BurnedQueryForZeroAddress(); + return uint256(_addressData[owner].numberBurned); + } + /** * Gas spent here starts off proportional to the maximum mint batch size. * It gradually moves to O(1) as tokens get transferred around in the collection over time. */ function ownershipOf(uint256 tokenId) internal view returns (TokenOwnership memory) { - if (!_exists(tokenId)) revert OwnerQueryForNonexistentToken(); + uint256 curr = tokenId; unchecked { - for (uint256 curr = tokenId;; curr--) { + if (curr < _currentIndex) { TokenOwnership memory ownership = _ownerships[curr]; - if (ownership.addr != address(0)) { - return ownership; + if (!ownership.burned) { + if (ownership.addr != address(0)) { + return ownership; + } + // Invariant: + // There will always be an ownership that has an address and is not burned + // before an ownership that does not have an address and is not burned. + // Hence, curr will not underflow. + while (true) { + curr--; + ownership = _ownerships[curr]; + if (ownership.addr != address(0)) { + return ownership; + } + } } } } + revert OwnerQueryForNonexistentToken(); } /** @@ -214,7 +275,9 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable address owner = ERC721A.ownerOf(tokenId); if (to == owner) revert ApprovalToCurrentOwner(); - if (_msgSender() != owner && !isApprovedForAll(owner, _msgSender())) revert ApprovalCallerNotOwnerNorApproved(); + if (_msgSender() != owner && !isApprovedForAll(owner, _msgSender())) { + revert ApprovalCallerNotOwnerNorApproved(); + } _approve(to, tokenId, owner); } @@ -275,9 +338,11 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable address to, uint256 tokenId, bytes memory _data - ) public override { + ) public virtual override { _transfer(from, to, tokenId); - if (!_checkOnERC721Received(from, to, tokenId, _data)) revert TransferToNonERC721ReceiverImplementer(); + if (!_checkOnERC721Received(from, to, tokenId, _data)) { + revert TransferToNonERC721ReceiverImplementer(); + } } /** @@ -288,7 +353,7 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable * Tokens start existing when they are minted (`_mint`), */ function _exists(uint256 tokenId) internal view returns (bool) { - return tokenId < _currentIndex; + return tokenId < _currentIndex && !_ownerships[tokenId].burned; } function _safeMint(address to, uint256 quantity) internal { @@ -337,10 +402,10 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable // Overflows are incredibly unrealistic. // balance or numberMinted overflow if current value of either + quantity > 3.4e38 (2**128) - 1 - // updatedIndex overflows if _currentIndex + quantity > 1.56e77 (2**256) - 1 + // updatedIndex overflows if _currentIndex + quantity > 3.4e38 (2**128) - 1 unchecked { - _addressData[to].balance += uint128(quantity); - _addressData[to].numberMinted += uint128(quantity); + _addressData[to].balance += uint64(quantity); + _addressData[to].numberMinted += uint64(quantity); _ownerships[startTokenId].addr = to; _ownerships[startTokenId].startTimestamp = uint64(block.timestamp); @@ -352,13 +417,11 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable if (safe && !_checkOnERC721Received(address(0), to, updatedIndex, _data)) { revert TransferToNonERC721ReceiverImplementer(); } - updatedIndex++; } - _currentIndex = updatedIndex; + _currentIndex = uint128(updatedIndex); } - _afterTokenTransfers(address(0), to, startTokenId, quantity); } @@ -394,7 +457,7 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable // Underflow of the sender's balance is impossible because we check for // ownership above and the recipient's balance can't realistically overflow. - // Counter overflow is incredibly unrealistic as tokenId would have to be 2**256. + // Counter overflow is incredibly unrealistic as tokenId would have to be 2**128. unchecked { _addressData[from].balance -= 1; _addressData[to].balance += 1; @@ -406,7 +469,9 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable // Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls. uint256 nextTokenId = tokenId + 1; if (_ownerships[nextTokenId].addr == address(0)) { - if (_exists(nextTokenId)) { + // This will suffice for checking _exists(nextTokenId), + // as a burned slot cannot contain the zero address. + if (nextTokenId < _currentIndex) { _ownerships[nextTokenId].addr = prevOwnership.addr; _ownerships[nextTokenId].startTimestamp = prevOwnership.startTimestamp; } @@ -417,6 +482,58 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable _afterTokenTransfers(from, to, tokenId, 1); } + /** + * @dev Destroys `tokenId`. + * The approval is cleared when the token is burned. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits a {Transfer} event. + */ + function _burn(uint256 tokenId) internal virtual { + TokenOwnership memory prevOwnership = ownershipOf(tokenId); + + _beforeTokenTransfers(prevOwnership.addr, address(0), tokenId, 1); + + // Clear approvals from the previous owner + _approve(address(0), tokenId, prevOwnership.addr); + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as tokenId would have to be 2**128. + unchecked { + _addressData[prevOwnership.addr].balance -= 1; + _addressData[prevOwnership.addr].numberBurned += 1; + + // Keep track of who burned the token, and the timestamp of burning. + _ownerships[tokenId].addr = prevOwnership.addr; + _ownerships[tokenId].startTimestamp = uint64(block.timestamp); + _ownerships[tokenId].burned = true; + + // If the ownership slot of tokenId+1 is not explicitly set, that means the burn initiator owns it. + // Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls. + uint256 nextTokenId = tokenId + 1; + if (_ownerships[nextTokenId].addr == address(0)) { + // This will suffice for checking _exists(nextTokenId), + // as a burned slot cannot contain the zero address. + if (nextTokenId < _currentIndex) { + _ownerships[nextTokenId].addr = prevOwnership.addr; + _ownerships[nextTokenId].startTimestamp = prevOwnership.startTimestamp; + } + } + } + + emit Transfer(prevOwnership.addr, address(0), tokenId); + _afterTokenTransfers(prevOwnership.addr, address(0), tokenId, 1); + + // Overflow not possible, as _burnCounter cannot be exceed _currentIndex times. + unchecked { + _burnCounter++; + } + } + /** * @dev Approve `to` to operate on `tokenId` * @@ -451,8 +568,9 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, _data) returns (bytes4 retval) { return retval == IERC721Receiver(to).onERC721Received.selector; } catch (bytes memory reason) { - if (reason.length == 0) revert TransferToNonERC721ReceiverImplementer(); - else { + if (reason.length == 0) { + revert TransferToNonERC721ReceiverImplementer(); + } else { assembly { revert(add(32, reason), mload(reason)) } @@ -465,15 +583,18 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable /** * @dev Hook that is called before a set of serially-ordered token ids are about to be transferred. This includes minting. + * And also called before burning one token. * * startTokenId - the first token id to be transferred * quantity - the amount to be transferred * * Calling conditions: * - * - When `from` and `to` are both non-zero, ``from``'s `tokenId` will be + * - 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, `tokenId` will be burned by `from`. + * - `from` and `to` are never both zero. */ function _beforeTokenTransfers( address from, @@ -485,13 +606,17 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable /** * @dev Hook that is called after a set of serially-ordered token ids have been transferred. This includes * minting. + * And also called after one token has been burned. * * startTokenId - the first token id to be transferred * quantity - the amount to be transferred * * Calling conditions: * - * - when `from` and `to` are both non-zero. + * - When `from` and `to` are both non-zero, `from`'s `tokenId` has been + * transferred to `to`. + * - When `from` is zero, `tokenId` has been minted for `to`. + * - When `to` is zero, `tokenId` has been burned by `from`. * - `from` and `to` are never both zero. */ function _afterTokenTransfers( diff --git a/contracts/extensions/ERC721ABurnable.sol b/contracts/extensions/ERC721ABurnable.sol new file mode 100644 index 000000000..2d00a6e1e --- /dev/null +++ b/contracts/extensions/ERC721ABurnable.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import '../ERC721A.sol'; +import '@openzeppelin/contracts/utils/Context.sol'; + +/** + * @title ERC721A Burnable Token + * @dev ERC721A Token that can be irreversibly burned (destroyed). + */ +abstract contract ERC721ABurnable is Context, ERC721A { + + /** + * @dev Burns `tokenId`. See {ERC721A-_burn}. + * + * Requirements: + * + * - The caller must own `tokenId` or be an approved operator. + */ + function burn(uint256 tokenId) public virtual { + TokenOwnership memory prevOwnership = ownershipOf(tokenId); + + bool isApprovedOrOwner = (_msgSender() == prevOwnership.addr || + isApprovedForAll(prevOwnership.addr, _msgSender()) || + getApproved(tokenId) == _msgSender()); + + if (!isApprovedOrOwner) revert TransferCallerNotOwnerNorApproved(); + + _burn(tokenId); + } +} \ No newline at end of file diff --git a/contracts/extensions/ERC721AOwnersExplicit.sol b/contracts/extensions/ERC721AOwnersExplicit.sol index c8c631b68..af1865148 100644 --- a/contracts/extensions/ERC721AOwnersExplicit.sol +++ b/contracts/extensions/ERC721AOwnersExplicit.sol @@ -32,7 +32,7 @@ abstract contract ERC721AOwnersExplicit is ERC721A { } for (uint256 i = _nextOwnerToExplicitlySet; i <= endIndex; i++) { - if (_ownerships[i].addr == address(0)) { + if (_ownerships[i].addr == address(0) && !_ownerships[i].burned) { TokenOwnership memory ownership = ownershipOf(i); _ownerships[i].addr = ownership.addr; _ownerships[i].startTimestamp = ownership.startTimestamp; diff --git a/contracts/mocks/ERC721ABurnableMock.sol b/contracts/mocks/ERC721ABurnableMock.sol new file mode 100644 index 000000000..151f031f9 --- /dev/null +++ b/contracts/mocks/ERC721ABurnableMock.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// Creators: Chiru Labs + +pragma solidity ^0.8.4; + +import '../extensions/ERC721ABurnable.sol'; + +contract ERC721ABurnableMock is ERC721A, ERC721ABurnable { + constructor(string memory name_, string memory symbol_) ERC721A(name_, symbol_) {} + + function exists(uint256 tokenId) public view returns (bool) { + return _exists(tokenId); + } + + function safeMint(address to, uint256 quantity) public { + _safeMint(to, quantity); + } + + function getOwnershipAt(uint256 index) public view returns (TokenOwnership memory) { + return _ownerships[index]; + } +} \ No newline at end of file diff --git a/contracts/mocks/ERC721ABurnableOwnersExplicitMock.sol b/contracts/mocks/ERC721ABurnableOwnersExplicitMock.sol new file mode 100644 index 000000000..7ef195e16 --- /dev/null +++ b/contracts/mocks/ERC721ABurnableOwnersExplicitMock.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// Creators: Chiru Labs + +pragma solidity ^0.8.4; + +import '../extensions/ERC721ABurnable.sol'; +import '../extensions/ERC721AOwnersExplicit.sol'; + +contract ERC721ABurnableOwnersExplicitMock is ERC721A, ERC721ABurnable, ERC721AOwnersExplicit { + constructor(string memory name_, string memory symbol_) ERC721A(name_, symbol_) {} + + function exists(uint256 tokenId) public view returns (bool) { + return _exists(tokenId); + } + + function safeMint(address to, uint256 quantity) public { + _safeMint(to, quantity); + } + + function setOwnersExplicit(uint256 quantity) public { + _setOwnersExplicit(quantity); + } + + function getOwnershipAt(uint256 index) public view returns (TokenOwnership memory) { + return _ownerships[index]; + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 514dc83c2..7bf7c221f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "erc721a", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "erc721a", - "version": "1.0.0", + "version": "2.0.0", "license": "ISC", "dependencies": { "@openzeppelin/contracts": "^4.4.2" diff --git a/test/extensions/ERC721ABurnable.test.js b/test/extensions/ERC721ABurnable.test.js new file mode 100644 index 000000000..6bd82bcf5 --- /dev/null +++ b/test/extensions/ERC721ABurnable.test.js @@ -0,0 +1,157 @@ +const { expect } = require('chai'); + +describe('ERC721ABurnable', function () { + + beforeEach(async function () { + this.ERC721ABurnable = await ethers.getContractFactory('ERC721ABurnableMock'); + this.token = await this.ERC721ABurnable.deploy('Azuki', 'AZUKI'); + await this.token.deployed(); + }); + + beforeEach(async function () { + const [owner, addr1, addr2] = await ethers.getSigners(); + this.owner = owner; + this.addr1 = addr1; + this.addr2 = addr2; + this.numTestTokens = 10; + this.burnedTokenId = 5; + await this.token['safeMint(address,uint256)'](this.addr1.address, this.numTestTokens); + await this.token.connect(this.addr1).burn(this.burnedTokenId); + }); + + it('changes exists', async function () { + expect(await this.token.exists(this.burnedTokenId)).to.be.false; + }); + + it('cannot burn a non-existing token', async function () { + const query = this.token.connect(this.addr1).burn(this.numTestTokens); + await expect(query).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ); + }); + + it('cannot burn a burned token', async function () { + const query = this.token.connect(this.addr1).burn(this.burnedTokenId); + await expect(query).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ); + }) + + it('cannot transfer a burned token', async function () { + const query = this.token.connect(this.addr1) + .transferFrom(this.addr1.address, this.addr2.address, this.burnedTokenId); + await expect(query).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ); + }) + + it('reduces totalSupply', async function () { + const supplyBefore = await this.token.totalSupply(); + for (let i = 0; i < 2; ++i) { + await this.token.connect(this.addr1).burn(i); + expect(supplyBefore - (await this.token.totalSupply())).to.equal(i + 1); + } + }) + + it('adjusts owners tokens by index', async function () { + const n = await this.token.totalSupply(); + for (let i = 0; i < this.burnedTokenId; ++i) { + expect(await this.token.tokenByIndex(i)).to.be.equal(i); + } + for (let i = this.burnedTokenId; i < n; ++i) { + expect(await this.token.tokenByIndex(i)).to.be.equal(i + 1); + } + // tokenIds of addr1: [0,1,2,3,4,6,7,8,9] + expect(await this.token.tokenOfOwnerByIndex(this.addr1.address, 2)) + .to.be.equal(2); + await this.token.connect(this.addr1).burn(2); + // tokenIds of addr1: [0,1,3,4,6,7,8,9] + expect(await this.token.tokenOfOwnerByIndex(this.addr1.address, 2)) + .to.be.equal(3); + await this.token.connect(this.addr1).burn(0); + // tokenIds of addr1: [1,3,4,6,7,8,9] + expect(await this.token.tokenOfOwnerByIndex(this.addr1.address, 2)) + .to.be.equal(4); + await this.token.connect(this.addr1).burn(3); + // tokenIds of addr1: [1,4,6,7,8,9] + expect(await this.token.tokenOfOwnerByIndex(this.addr1.address, 2)) + .to.be.equal(6); + }) + + it('adjusts owners balances', async function () { + expect(await this.token.balanceOf(this.addr1.address)) + .to.be.equal(this.numTestTokens - 1); + }); + + it('adjusts token by index', async function () { + const n = await this.token.totalSupply(); + for (let i = 0; i < this.burnedTokenId; ++i) { + expect(await this.token.tokenByIndex(i)).to.be.equal(i); + } + for (let i = this.burnedTokenId; i < n; ++i) { + expect(await this.token.tokenByIndex(i)).to.be.equal(i + 1); + } + await expect(this.token.tokenByIndex(n)).to.be.revertedWith( + 'TokenIndexOutOfBounds' + ); + }); + + describe('ownerships correctly set', async function () { + it('with token before previously burnt token transfered and burned', async function () { + const tokenIdToBurn = this.burnedTokenId - 1; + await this.token.connect(this.addr1) + .transferFrom(this.addr1.address, this.addr2.address, tokenIdToBurn); + expect(await this.token.ownerOf(tokenIdToBurn)).to.be.equal(this.addr2.address); + await this.token.connect(this.addr2).burn(tokenIdToBurn); + for (let i = 0; i < this.numTestTokens; ++i) { + if (i == tokenIdToBurn || i == this.burnedTokenId) { + await expect(this.token.ownerOf(i)).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ); + } else { + expect(await this.token.ownerOf(i)).to.be.equal(this.addr1.address); + } + } + }); + + it('with token after previously burnt token transfered and burned', async function () { + const tokenIdToBurn = this.burnedTokenId + 1; + await this.token.connect(this.addr1) + .transferFrom(this.addr1.address, this.addr2.address, tokenIdToBurn); + expect(await this.token.ownerOf(tokenIdToBurn)).to.be.equal(this.addr2.address); + await this.token.connect(this.addr2).burn(tokenIdToBurn); + for (let i = 0; i < this.numTestTokens; ++i) { + if (i == tokenIdToBurn || i == this.burnedTokenId) { + await expect(this.token.ownerOf(i)).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ) + } else { + expect(await this.token.ownerOf(i)).to.be.equal(this.addr1.address); + } + } + }); + + it('with first token burned', async function () { + await this.token.connect(this.addr1).burn(0); + for (let i = 0; i < this.numTestTokens; ++i) { + if (i == 0 || i == this.burnedTokenId) { + await expect(this.token.ownerOf(i)).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ) + } else { + expect(await this.token.ownerOf(i)).to.be.equal(this.addr1.address); + } + } + }); + + it('with last token burned', async function () { + await expect(this.token.ownerOf(this.numTestTokens)).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ) + await this.token.connect(this.addr1).burn(this.numTestTokens - 1); + await expect(this.token.ownerOf(this.numTestTokens - 1)).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ) + }); + }); +}); diff --git a/test/extensions/ERC721ABurnableOwnersExplicit.test.js b/test/extensions/ERC721ABurnableOwnersExplicit.test.js new file mode 100644 index 000000000..317f2c38a --- /dev/null +++ b/test/extensions/ERC721ABurnableOwnersExplicit.test.js @@ -0,0 +1,45 @@ +const { expect } = require('chai'); +const { constants } = require('@openzeppelin/test-helpers'); +const { ZERO_ADDRESS } = constants; + +describe('ERC721ABurnableOwnersExplicit', function () { + beforeEach(async function () { + this.ERC721ABurnableOwnersExplicit = await ethers.getContractFactory('ERC721ABurnableOwnersExplicitMock'); + this.token = await this.ERC721ABurnableOwnersExplicit.deploy('Azuki', 'AZUKI'); + await this.token.deployed(); + }); + + beforeEach(async function () { + const [owner, addr1, addr2, addr3] = await ethers.getSigners(); + this.owner = owner; + this.addr1 = addr1; + this.addr2 = addr2; + this.addr3 = addr3; + await this.token['safeMint(address,uint256)'](addr1.address, 1); + await this.token['safeMint(address,uint256)'](addr2.address, 2); + await this.token['safeMint(address,uint256)'](addr3.address, 3); + await this.token.connect(this.addr1).burn(0); + await this.token.connect(this.addr3).burn(4); + await this.token.setOwnersExplicit(6); + }); + + it('ownerships correctly set', async function () { + for (let tokenId = 0; tokenId < 6; tokenId++) { + let owner = await this.token.getOwnershipAt(tokenId); + expect(owner[0]).to.not.equal(ZERO_ADDRESS); + if (tokenId == 0 || tokenId == 4) { + expect(owner[2]).to.equal(true); + await expect(this.token.ownerOf(tokenId)).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ) + } else { + expect(owner[2]).to.equal(false); + if (tokenId < 1+2) { + expect(await this.token.ownerOf(tokenId)).to.be.equal(this.addr2.address); + } else { + expect(await this.token.ownerOf(tokenId)).to.be.equal(this.addr3.address); + } + } + } + }); +});