diff --git a/README.md b/README.md index eb404b9ab..40440fcfd 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,6 @@ contract Azuki is ERC721A { ## Roadmap -- [] Add flexibility for the first token id to not start at 0 - [] Support ERC721 Upgradeable - [] Add more documentation on benefits of using ERC721A - [] Increase test coverage diff --git a/contracts/ERC721A.sol b/contracts/ERC721A.sol index 4cd738986..9c5d5e00c 100644 --- a/contracts/ERC721A.sol +++ b/contracts/ERC721A.sol @@ -35,7 +35,7 @@ error URIQueryForNonexistentToken(); * @dev Implementation of https://eips.ethereum.org/EIPS/eip-721[ERC721] Non-Fungible Token Standard, including * the Metadata extension. Built to optimize for lower gas during batch mints. * - * Assumes serials are sequentially minted starting at 0 (e.g. 0, 1, 2, 3..). + * Assumes serials are sequentially minted starting at _startTokenId() (defaults to 0, e.g. 0, 1, 2, 3..). * * Assumes that an owner cannot have more than 2**64 - 1 (max value of uint64) of supply. * @@ -64,7 +64,7 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata { // Keeps track of burn count with minimal overhead for tokenomics. uint64 numberBurned; // For miscellaneous variable(s) pertaining to the address - // (e.g. number of whitelist mint slots used). + // (e.g. number of whitelist mint slots used). // If there are multiple variables, please pack them into a uint64. uint64 aux; } @@ -97,16 +97,36 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata { constructor(string memory name_, string memory symbol_) { _name = name_; _symbol = symbol_; + _currentIndex = _startTokenId(); + } + + /** + * To change the starting tokenId, please override this function. + */ + function _startTokenId() internal view virtual returns (uint256) { + return 0; } /** * @dev See {IERC721Enumerable-totalSupply}. + * @dev Burned tokens are calculated here, use _totalMinted() if you want to count just minted tokens. */ function totalSupply() public view returns (uint256) { // Counter underflow is impossible as _burnCounter cannot be incremented - // more than _currentIndex times + // more than _currentIndex - _startTokenId() times unchecked { - return _currentIndex - _burnCounter; + return _currentIndex - _burnCounter - _startTokenId(); + } + } + + /** + * Returns the total amount of tokens minted in the contract. + */ + function _totalMinted() internal view returns (uint256) { + // Counter underflow is impossible as _currentIndex does not decrement, + // and it is initialized to _startTokenId() + unchecked { + return _currentIndex - _startTokenId(); } } @@ -169,14 +189,14 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata { uint256 curr = tokenId; unchecked { - if (curr < _currentIndex) { + if (_startTokenId() <= curr && curr < _currentIndex) { TokenOwnership memory ownership = _ownerships[curr]; 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 + // 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) { @@ -317,7 +337,8 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata { * Tokens start existing when they are minted (`_mint`), */ function _exists(uint256 tokenId) internal view returns (bool) { - return tokenId < _currentIndex && !_ownerships[tokenId].burned; + return _startTokenId() <= tokenId && tokenId < _currentIndex && + !_ownerships[tokenId].burned; } function _safeMint(address to, uint256 quantity) internal { @@ -493,7 +514,7 @@ contract ERC721A is Context, ERC165, IERC721, IERC721Metadata { _afterTokenTransfers(prevOwnership.addr, address(0), tokenId, 1); // Overflow not possible, as _burnCounter cannot be exceed _currentIndex times. - unchecked { + unchecked { _burnCounter++; } } diff --git a/contracts/extensions/ERC721AOwnersExplicit.sol b/contracts/extensions/ERC721AOwnersExplicit.sol index af1865148..6991c5941 100644 --- a/contracts/extensions/ERC721AOwnersExplicit.sol +++ b/contracts/extensions/ERC721AOwnersExplicit.sol @@ -17,8 +17,11 @@ abstract contract ERC721AOwnersExplicit is ERC721A { */ function _setOwnersExplicit(uint256 quantity) internal { if (quantity == 0) revert QuantityMustBeNonZero(); - if (_currentIndex == 0) revert NoTokensMintedYet(); + if (_currentIndex == _startTokenId()) revert NoTokensMintedYet(); uint256 _nextOwnerToExplicitlySet = nextOwnerToExplicitlySet; + if (_nextOwnerToExplicitlySet == 0) { + _nextOwnerToExplicitlySet = _startTokenId(); + } if (_nextOwnerToExplicitlySet >= _currentIndex) revert AllOwnershipsHaveBeenSet(); // Index underflow is impossible. diff --git a/contracts/mocks/ERC721ABurnableMock.sol b/contracts/mocks/ERC721ABurnableMock.sol index 151f031f9..18cf4d595 100644 --- a/contracts/mocks/ERC721ABurnableMock.sol +++ b/contracts/mocks/ERC721ABurnableMock.sol @@ -19,4 +19,8 @@ contract ERC721ABurnableMock is ERC721A, ERC721ABurnable { function getOwnershipAt(uint256 index) public view returns (TokenOwnership memory) { return _ownerships[index]; } + + function totalMinted() public view returns (uint256) { + return _totalMinted(); + } } \ No newline at end of file diff --git a/contracts/mocks/ERC721ABurnableStartTokenIdMock.sol b/contracts/mocks/ERC721ABurnableStartTokenIdMock.sol new file mode 100644 index 000000000..cd621943c --- /dev/null +++ b/contracts/mocks/ERC721ABurnableStartTokenIdMock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Creators: Chiru Labs + +pragma solidity ^0.8.4; + +import './ERC721ABurnableMock.sol'; +import './StartTokenIdHelper.sol'; + +contract ERC721ABurnableStartTokenIdMock is StartTokenIdHelper, ERC721ABurnableMock { + constructor( + string memory name_, + string memory symbol_, + uint256 startTokenId_ + ) StartTokenIdHelper(startTokenId_) ERC721ABurnableMock(name_, symbol_) {} + + function _startTokenId() internal view override returns (uint256) { + return startTokenId; + } +} diff --git a/contracts/mocks/ERC721AMock.sol b/contracts/mocks/ERC721AMock.sol index e37992017..815e356eb 100644 --- a/contracts/mocks/ERC721AMock.sol +++ b/contracts/mocks/ERC721AMock.sol @@ -12,6 +12,10 @@ contract ERC721AMock is ERC721A { return _numberMinted(owner); } + function totalMinted() public view returns (uint256) { + return _totalMinted(); + } + function getAux(address owner) public view returns (uint64) { return _getAux(owner); } diff --git a/contracts/mocks/ERC721AExplicitOwnershipMock.sol b/contracts/mocks/ERC721AOwnersExplicitMock.sol similarity index 79% rename from contracts/mocks/ERC721AExplicitOwnershipMock.sol rename to contracts/mocks/ERC721AOwnersExplicitMock.sol index a4164bac9..72f14dd80 100644 --- a/contracts/mocks/ERC721AExplicitOwnershipMock.sol +++ b/contracts/mocks/ERC721AOwnersExplicitMock.sol @@ -16,7 +16,7 @@ contract ERC721AOwnersExplicitMock is ERC721AOwnersExplicit { _setOwnersExplicit(quantity); } - function getOwnershipAt(uint256 index) public view returns (TokenOwnership memory) { - return _ownerships[index]; + function getOwnershipAt(uint256 tokenId) public view returns (TokenOwnership memory) { + return _ownerships[tokenId]; } } diff --git a/contracts/mocks/ERC721AOwnersExplicitStartTokenIdMock.sol b/contracts/mocks/ERC721AOwnersExplicitStartTokenIdMock.sol new file mode 100644 index 000000000..43b19f9d1 --- /dev/null +++ b/contracts/mocks/ERC721AOwnersExplicitStartTokenIdMock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Creators: Chiru Labs + +pragma solidity ^0.8.4; + +import './ERC721AOwnersExplicitMock.sol'; +import './StartTokenIdHelper.sol'; + +contract ERC721AOwnersExplicitStartTokenIdMock is StartTokenIdHelper, ERC721AOwnersExplicitMock { + constructor( + string memory name_, + string memory symbol_, + uint256 startTokenId_ + ) StartTokenIdHelper(startTokenId_) ERC721AOwnersExplicitMock(name_, symbol_) {} + + function _startTokenId() internal view override returns (uint256) { + return startTokenId; + } +} diff --git a/contracts/mocks/ERC721AStartTokenIdMock.sol b/contracts/mocks/ERC721AStartTokenIdMock.sol new file mode 100644 index 000000000..15a6e743f --- /dev/null +++ b/contracts/mocks/ERC721AStartTokenIdMock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Creators: Chiru Labs + +pragma solidity ^0.8.4; + +import './ERC721AMock.sol'; +import './StartTokenIdHelper.sol'; + +contract ERC721AStartTokenIdMock is StartTokenIdHelper, ERC721AMock { + constructor( + string memory name_, + string memory symbol_, + uint256 startTokenId_ + ) StartTokenIdHelper(startTokenId_) ERC721AMock(name_, symbol_) {} + + function _startTokenId() internal view override returns (uint256) { + return startTokenId; + } +} diff --git a/contracts/mocks/StartTokenIdHelper.sol b/contracts/mocks/StartTokenIdHelper.sol new file mode 100644 index 000000000..1779d13fd --- /dev/null +++ b/contracts/mocks/StartTokenIdHelper.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +// Creators: Chiru Labs + +pragma solidity ^0.8.4; + +/** + * This Helper is used to return a dynmamic value in the overriden _startTokenId() function. + * Extending this Helper before the ERC721A contract give us access to the herein set `startTokenId` + * to be returned by the overriden `_startTokenId()` function of ERC721A in the ERC721AStartTokenId mocks. + */ +contract StartTokenIdHelper { + uint256 public immutable startTokenId; + + constructor(uint256 startTokenId_) { + startTokenId = startTokenId_; + } +} \ No newline at end of file diff --git a/test/ERC721A.test.js b/test/ERC721A.test.js index be4778ab5..007dd0094 100644 --- a/test/ERC721A.test.js +++ b/test/ERC721A.test.js @@ -5,319 +5,356 @@ const { ZERO_ADDRESS } = constants; const RECEIVER_MAGIC_VALUE = '0x150b7a02'; const GAS_MAGIC_VALUE = 20000; -describe('ERC721A', function () { - beforeEach(async function () { - this.ERC721A = await ethers.getContractFactory('ERC721AMock'); - this.ERC721Receiver = await ethers.getContractFactory('ERC721ReceiverMock'); - this.erc721a = await this.ERC721A.deploy('Azuki', 'AZUKI'); - await this.erc721a.deployed(); - }); - - context('with no minted tokens', async function () { - it('has 0 totalSupply', async function () { - const supply = await this.erc721a.totalSupply(); - expect(supply).to.equal(0); - }); - }); - - context('with minted tokens', async function () { - 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.erc721a['safeMint(address,uint256)'](addr1.address, 1); - await this.erc721a['safeMint(address,uint256)'](addr2.address, 2); - await this.erc721a['safeMint(address,uint256)'](addr3.address, 3); - }); +const createTestSuite = ({ contract, constructorArgs }) => + function () { + context(`${contract}`, function () { + beforeEach(async function () { + this.ERC721A = await ethers.getContractFactory(contract); - describe('exists', async function () { - it('verifies valid tokens', async function () { - for (let tokenId = 0; tokenId < 6; tokenId++) { - const exists = await this.erc721a.exists(tokenId); - expect(exists).to.be.true; - } - }); + this.ERC721Receiver = await ethers.getContractFactory('ERC721ReceiverMock'); + this.erc721a = await this.ERC721A.deploy(...constructorArgs); - it('verifies invalid tokens', async function () { - const exists = await this.erc721a.exists(6); - expect(exists).to.be.false; - }); - }); + await this.erc721a.deployed(); - describe('balanceOf', async function () { - it('returns the amount for a given address', async function () { - expect(await this.erc721a.balanceOf(this.owner.address)).to.equal('0'); - expect(await this.erc721a.balanceOf(this.addr1.address)).to.equal('1'); - expect(await this.erc721a.balanceOf(this.addr2.address)).to.equal('2'); - expect(await this.erc721a.balanceOf(this.addr3.address)).to.equal('3'); + this.startTokenId = this.erc721a.startTokenId ? (await this.erc721a.startTokenId()).toNumber() : 0; }); - it('throws an exception for the 0 address', async function () { - await expect(this.erc721a.balanceOf(ZERO_ADDRESS)).to.be.revertedWith('BalanceQueryForZeroAddress'); - }); - }); + context('with no minted tokens', async function () { + it('has 0 totalSupply', async function () { + const supply = await this.erc721a.totalSupply(); + expect(supply).to.equal(0); + }); - describe('_numberMinted', async function () { - it('returns the amount for a given address', async function () { - expect(await this.erc721a.numberMinted(this.owner.address)).to.equal('0'); - expect(await this.erc721a.numberMinted(this.addr1.address)).to.equal('1'); - expect(await this.erc721a.numberMinted(this.addr2.address)).to.equal('2'); - expect(await this.erc721a.numberMinted(this.addr3.address)).to.equal('3'); + it('has 0 totalMinted', async function () { + const totalMinted = await this.erc721a.totalMinted(); + expect(totalMinted).to.equal(0); + }); }); - }); - describe('aux', async function () { - it('get and set works correctly', async function () { - const uint64Max = '18446744073709551615'; - expect(await this.erc721a.getAux(this.owner.address)).to.equal('0'); - await this.erc721a.setAux(this.owner.address, uint64Max); - expect(await this.erc721a.getAux(this.owner.address)).to.equal(uint64Max); - - expect(await this.erc721a.getAux(this.addr1.address)).to.equal('0'); - await this.erc721a.setAux(this.addr1.address, '1'); - expect(await this.erc721a.getAux(this.addr1.address)).to.equal('1'); - - await this.erc721a.setAux(this.addr3.address, '5'); - expect(await this.erc721a.getAux(this.addr3.address)).to.equal('5'); - - expect(await this.erc721a.getAux(this.addr1.address)).to.equal('1'); - }); - - it('get and set rejects the zero address', async function () { - await expect(this.erc721a.getAux(ZERO_ADDRESS)) - .to.be.revertedWith('AuxQueryForZeroAddress'); - await expect(this.erc721a.setAux(ZERO_ADDRESS, '1')) - .to.be.revertedWith('AuxQueryForZeroAddress'); - }); - }); + context('with minted tokens', async function () { + 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.erc721a['safeMint(address,uint256)'](addr1.address, 1); + await this.erc721a['safeMint(address,uint256)'](addr2.address, 2); + await this.erc721a['safeMint(address,uint256)'](addr3.address, 3); + }); - describe('ownerOf', async function () { - it('returns the right owner', async function () { - expect(await this.erc721a.ownerOf(0)).to.equal(this.addr1.address); - expect(await this.erc721a.ownerOf(1)).to.equal(this.addr2.address); - expect(await this.erc721a.ownerOf(5)).to.equal(this.addr3.address); - }); + describe('exists', async function () { + it('verifies valid tokens', async function () { + for (let tokenId = this.startTokenId; tokenId < 6 + this.startTokenId; tokenId++) { + const exists = await this.erc721a.exists(tokenId); + expect(exists).to.be.true; + } + }); - it('reverts for an invalid token', async function () { - await expect(this.erc721a.ownerOf(10)).to.be.revertedWith('OwnerQueryForNonexistentToken'); - }); - }); + it('verifies invalid tokens', async function () { + expect(await this.erc721a.exists(6 + this.startTokenId)).to.be.false; + }); + }); - describe('approve', async function () { - const tokenId = 0; - const tokenId2 = 1; + describe('balanceOf', async function () { + it('returns the amount for a given address', async function () { + expect(await this.erc721a.balanceOf(this.owner.address)).to.equal('0'); + expect(await this.erc721a.balanceOf(this.addr1.address)).to.equal('1'); + expect(await this.erc721a.balanceOf(this.addr2.address)).to.equal('2'); + expect(await this.erc721a.balanceOf(this.addr3.address)).to.equal('3'); + }); - it('sets approval for the target address', async function () { - await this.erc721a.connect(this.addr1).approve(this.addr2.address, tokenId); - const approval = await this.erc721a.getApproved(tokenId); - expect(approval).to.equal(this.addr2.address); - }); + it('throws an exception for the 0 address', async function () { + await expect(this.erc721a.balanceOf(ZERO_ADDRESS)).to.be.revertedWith('BalanceQueryForZeroAddress'); + }); + }); - it('rejects an invalid token owner', async function () { - await expect(this.erc721a.connect(this.addr1).approve(this.addr2.address, tokenId2)).to.be.revertedWith( - 'ApprovalToCurrentOwner' - ); - }); + describe('_numberMinted', async function () { + it('returns the amount for a given address', async function () { + expect(await this.erc721a.numberMinted(this.owner.address)).to.equal('0'); + expect(await this.erc721a.numberMinted(this.addr1.address)).to.equal('1'); + expect(await this.erc721a.numberMinted(this.addr2.address)).to.equal('2'); + expect(await this.erc721a.numberMinted(this.addr3.address)).to.equal('3'); + }); + }); - it('rejects an unapproved caller', async function () { - await expect(this.erc721a.approve(this.addr2.address, tokenId)).to.be.revertedWith( - 'ApprovalCallerNotOwnerNorApproved' - ); - }); + context('_totalMinted', async function () { + it('has 6 totalMinted', async function () { + const totalMinted = await this.erc721a.totalMinted(); + expect(totalMinted).to.equal('6'); + }); + }); - it('does not get approved for invalid tokens', async function () { - await expect(this.erc721a.getApproved(10)).to.be.revertedWith('ApprovalQueryForNonexistentToken'); - }); - }); + describe('aux', async function () { + it('get and set works correctly', async function () { + const uint64Max = '18446744073709551615'; + expect(await this.erc721a.getAux(this.owner.address)).to.equal('0'); + await this.erc721a.setAux(this.owner.address, uint64Max); + expect(await this.erc721a.getAux(this.owner.address)).to.equal(uint64Max); - describe('setApprovalForAll', async function () { - it('sets approval for all properly', async function () { - const approvalTx = await this.erc721a.setApprovalForAll(this.addr1.address, true); - await expect(approvalTx) - .to.emit(this.erc721a, 'ApprovalForAll') - .withArgs(this.owner.address, this.addr1.address, true); - expect(await this.erc721a.isApprovedForAll(this.owner.address, this.addr1.address)).to.be.true; - }); + expect(await this.erc721a.getAux(this.addr1.address)).to.equal('0'); + await this.erc721a.setAux(this.addr1.address, '1'); + expect(await this.erc721a.getAux(this.addr1.address)).to.equal('1'); - it('sets rejects approvals for non msg senders', async function () { - await expect(this.erc721a.connect(this.addr1).setApprovalForAll(this.addr1.address, true)).to.be.revertedWith( - 'ApproveToCaller' - ); - }); - }); + await this.erc721a.setAux(this.addr3.address, '5'); + expect(await this.erc721a.getAux(this.addr3.address)).to.equal('5'); - context('test transfer functionality', function () { - const testSuccessfulTransfer = function (transferFn) { - const tokenId = 1; - let from; - let to; + expect(await this.erc721a.getAux(this.addr1.address)).to.equal('1'); + }); - beforeEach(async function () { - const sender = this.addr2; - from = sender.address; - this.receiver = await this.ERC721Receiver.deploy(RECEIVER_MAGIC_VALUE); - to = this.receiver.address; - await this.erc721a.connect(sender).setApprovalForAll(to, true); - this.transferTx = await this.erc721a.connect(sender)[transferFn](from, to, tokenId); + it('get and set rejects the zero address', async function () { + await expect(this.erc721a.getAux(ZERO_ADDRESS)).to.be.revertedWith('AuxQueryForZeroAddress'); + await expect(this.erc721a.setAux(ZERO_ADDRESS, '1')).to.be.revertedWith('AuxQueryForZeroAddress'); + }); }); - it('transfers the ownership of the given token ID to the given address', async function () { - expect(await this.erc721a.ownerOf(tokenId)).to.be.equal(to); - }); + describe('ownerOf', async function () { + it('returns the right owner', async function () { + expect(await this.erc721a.ownerOf(0 + this.startTokenId)).to.equal(this.addr1.address); + expect(await this.erc721a.ownerOf(1 + this.startTokenId)).to.equal(this.addr2.address); + expect(await this.erc721a.ownerOf(5 + this.startTokenId)).to.equal(this.addr3.address); + }); - it('emits a Transfer event', async function () { - await expect(this.transferTx).to.emit(this.erc721a, 'Transfer').withArgs(from, to, tokenId); + it('reverts for an invalid token', async function () { + await expect(this.erc721a.ownerOf(10)).to.be.revertedWith('OwnerQueryForNonexistentToken'); + }); }); - it('clears the approval for the token ID', async function () { - expect(await this.erc721a.getApproved(tokenId)).to.be.equal(ZERO_ADDRESS); - }); + describe('approve', async function () { + beforeEach(function () { + this.tokenId = this.startTokenId; + this.tokenId2 = this.startTokenId + 1; + }); - it('emits an Approval event', async function () { - await expect(this.transferTx).to.emit(this.erc721a, 'Approval').withArgs(from, ZERO_ADDRESS, tokenId); - }); + it('sets approval for the target address', async function () { + await this.erc721a.connect(this.addr1).approve(this.addr2.address, this.tokenId); + const approval = await this.erc721a.getApproved(this.tokenId); + expect(approval).to.equal(this.addr2.address); + }); - it('adjusts owners balances', async function () { - expect(await this.erc721a.balanceOf(from)).to.be.equal(1); - }); - }; + it('rejects an invalid token owner', async function () { + await expect( + this.erc721a.connect(this.addr1).approve(this.addr2.address, this.tokenId2) + ).to.be.revertedWith('ApprovalToCurrentOwner'); + }); - const testUnsuccessfulTransfer = function (transferFn) { - const tokenId = 1; + it('rejects an unapproved caller', async function () { + await expect(this.erc721a.approve(this.addr2.address, this.tokenId)).to.be.revertedWith( + 'ApprovalCallerNotOwnerNorApproved' + ); + }); - it('rejects unapproved transfer', async function () { - await expect( - this.erc721a.connect(this.addr1)[transferFn](this.addr2.address, this.addr1.address, tokenId) - ).to.be.revertedWith('TransferCallerNotOwnerNorApproved'); + it('does not get approved for invalid tokens', async function () { + await expect(this.erc721a.getApproved(10)).to.be.revertedWith('ApprovalQueryForNonexistentToken'); + }); }); - it('rejects transfer from incorrect owner', async function () { - await this.erc721a.connect(this.addr2).setApprovalForAll(this.addr1.address, true); - await expect( - this.erc721a.connect(this.addr1)[transferFn](this.addr3.address, this.addr1.address, tokenId) - ).to.be.revertedWith('TransferFromIncorrectOwner'); - }); + describe('setApprovalForAll', async function () { + it('sets approval for all properly', async function () { + const approvalTx = await this.erc721a.setApprovalForAll(this.addr1.address, true); + await expect(approvalTx) + .to.emit(this.erc721a, 'ApprovalForAll') + .withArgs(this.owner.address, this.addr1.address, true); + expect(await this.erc721a.isApprovedForAll(this.owner.address, this.addr1.address)).to.be.true; + }); - it('rejects transfer to zero address', async function () { - await this.erc721a.connect(this.addr2).setApprovalForAll(this.addr1.address, true); - await expect( - this.erc721a.connect(this.addr1)[transferFn](this.addr2.address, ZERO_ADDRESS, tokenId) - ).to.be.revertedWith('TransferToZeroAddress'); + it('sets rejects approvals for non msg senders', async function () { + await expect( + this.erc721a.connect(this.addr1).setApprovalForAll(this.addr1.address, true) + ).to.be.revertedWith('ApproveToCaller'); + }); }); - }; - context('successful transfers', function () { - describe('transferFrom', function () { - testSuccessfulTransfer('transferFrom'); - }); + context('test transfer functionality', function () { + const testSuccessfulTransfer = function (transferFn) { + beforeEach(async function () { + this.tokenId = this.startTokenId + 1; + + const sender = this.addr2; + this.from = sender.address; + this.receiver = await this.ERC721Receiver.deploy(RECEIVER_MAGIC_VALUE); + this.to = this.receiver.address; + await this.erc721a.connect(sender).setApprovalForAll(this.to, true); + this.transferTx = await this.erc721a.connect(sender)[transferFn](this.from, this.to, this.tokenId); + }); + + it('transfers the ownership of the given token ID to the given address', async function () { + expect(await this.erc721a.ownerOf(this.tokenId)).to.be.equal(this.to); + }); + + it('emits a Transfer event', async function () { + await expect(this.transferTx) + .to.emit(this.erc721a, 'Transfer') + .withArgs(this.from, this.to, this.tokenId); + }); + + it('clears the approval for the token ID', async function () { + expect(await this.erc721a.getApproved(this.tokenId)).to.be.equal(ZERO_ADDRESS); + }); + + it('emits an Approval event', async function () { + await expect(this.transferTx) + .to.emit(this.erc721a, 'Approval') + .withArgs(this.from, ZERO_ADDRESS, this.tokenId); + }); + + it('adjusts owners balances', async function () { + expect(await this.erc721a.balanceOf(this.from)).to.be.equal(1); + }); + }; + + const testUnsuccessfulTransfer = function (transferFn) { + beforeEach(function () { + this.tokenId = this.startTokenId + 1; + }); + + it('rejects unapproved transfer', async function () { + await expect( + this.erc721a.connect(this.addr1)[transferFn](this.addr2.address, this.addr1.address, this.tokenId) + ).to.be.revertedWith('TransferCallerNotOwnerNorApproved'); + }); + + it('rejects transfer from incorrect owner', async function () { + await this.erc721a.connect(this.addr2).setApprovalForAll(this.addr1.address, true); + await expect( + this.erc721a.connect(this.addr1)[transferFn](this.addr3.address, this.addr1.address, this.tokenId) + ).to.be.revertedWith('TransferFromIncorrectOwner'); + }); + + it('rejects transfer to zero address', async function () { + await this.erc721a.connect(this.addr2).setApprovalForAll(this.addr1.address, true); + await expect( + this.erc721a.connect(this.addr1)[transferFn](this.addr2.address, ZERO_ADDRESS, this.tokenId) + ).to.be.revertedWith('TransferToZeroAddress'); + }); + }; + + context('successful transfers', function () { + describe('transferFrom', function () { + testSuccessfulTransfer('transferFrom'); + }); + + describe('safeTransferFrom', function () { + testSuccessfulTransfer('safeTransferFrom(address,address,uint256)'); + + it('validates ERC721Received', async function () { + await expect(this.transferTx) + .to.emit(this.receiver, 'Received') + .withArgs(this.addr2.address, this.addr2.address, 1 + this.startTokenId, '0x', GAS_MAGIC_VALUE); + }); + }); + }); - describe('safeTransferFrom', function () { - testSuccessfulTransfer('safeTransferFrom(address,address,uint256)'); + context('unsuccessful transfers', function () { + describe('transferFrom', function () { + testUnsuccessfulTransfer('transferFrom'); + }); - it('validates ERC721Received', async function () { - await expect(this.transferTx) - .to.emit(this.receiver, 'Received') - .withArgs(this.addr2.address, this.addr2.address, 1, '0x', GAS_MAGIC_VALUE); + describe('safeTransferFrom', function () { + testUnsuccessfulTransfer('safeTransferFrom(address,address,uint256)'); + }); }); }); }); - context('unsuccessful transfers', function () { - describe('transferFrom', function () { - testUnsuccessfulTransfer('transferFrom'); + context('mint', async function () { + beforeEach(async function () { + const [owner, addr1, addr2] = await ethers.getSigners(); + this.owner = owner; + this.addr1 = addr1; + this.addr2 = addr2; + this.receiver = await this.ERC721Receiver.deploy(RECEIVER_MAGIC_VALUE); }); - describe('safeTransferFrom', function () { - testUnsuccessfulTransfer('safeTransferFrom(address,address,uint256)'); - }); - }); - }); - }); - - context('mint', async function () { - beforeEach(async function () { - const [owner, addr1, addr2] = await ethers.getSigners(); - this.owner = owner; - this.addr1 = addr1; - this.addr2 = addr2; - this.receiver = await this.ERC721Receiver.deploy(RECEIVER_MAGIC_VALUE); - }); - - describe('safeMint', function () { - it('successfully mints a single token', async function () { - const mintTx = await this.erc721a['safeMint(address,uint256)'](this.receiver.address, 1); - await expect(mintTx).to.emit(this.erc721a, 'Transfer').withArgs(ZERO_ADDRESS, this.receiver.address, 0); - await expect(mintTx) - .to.emit(this.receiver, 'Received') - .withArgs(this.owner.address, ZERO_ADDRESS, 0, '0x', GAS_MAGIC_VALUE); - expect(await this.erc721a.ownerOf(0)).to.equal(this.receiver.address); - }); + describe('safeMint', function () { + it('successfully mints a single token', async function () { + const mintTx = await this.erc721a['safeMint(address,uint256)'](this.receiver.address, 1); + await expect(mintTx) + .to.emit(this.erc721a, 'Transfer') + .withArgs(ZERO_ADDRESS, this.receiver.address, this.startTokenId); + await expect(mintTx) + .to.emit(this.receiver, 'Received') + .withArgs(this.owner.address, ZERO_ADDRESS, this.startTokenId, '0x', GAS_MAGIC_VALUE); + expect(await this.erc721a.ownerOf(this.startTokenId)).to.equal(this.receiver.address); + }); - it('successfully mints multiple tokens', async function () { - const mintTx = await this.erc721a['safeMint(address,uint256)'](this.receiver.address, 5); - for (let tokenId = 0; tokenId < 5; tokenId++) { - await expect(mintTx).to.emit(this.erc721a, 'Transfer').withArgs(ZERO_ADDRESS, this.receiver.address, tokenId); - await expect(mintTx) - .to.emit(this.receiver, 'Received') - .withArgs(this.owner.address, ZERO_ADDRESS, 0, '0x', GAS_MAGIC_VALUE); - expect(await this.erc721a.ownerOf(tokenId)).to.equal(this.receiver.address); - } - }); + it('successfully mints multiple tokens', async function () { + const mintTx = await this.erc721a['safeMint(address,uint256)'](this.receiver.address, 5); + for (let tokenId = this.startTokenId; tokenId < 5 + this.startTokenId; tokenId++) { + await expect(mintTx) + .to.emit(this.erc721a, 'Transfer') + .withArgs(ZERO_ADDRESS, this.receiver.address, tokenId); + await expect(mintTx) + .to.emit(this.receiver, 'Received') + .withArgs(this.owner.address, ZERO_ADDRESS, tokenId, '0x', GAS_MAGIC_VALUE); + expect(await this.erc721a.ownerOf(tokenId)).to.equal(this.receiver.address); + } + }); - it('rejects mints to the zero address', async function () { - await expect(this.erc721a['safeMint(address,uint256)'](ZERO_ADDRESS, 1)).to.be.revertedWith( - 'MintToZeroAddress' - ); - }); + it('rejects mints to the zero address', async function () { + await expect(this.erc721a['safeMint(address,uint256)'](ZERO_ADDRESS, 1)).to.be.revertedWith( + 'MintToZeroAddress' + ); + }); - it('requires quantity to be greater than 0', async function () { - await expect(this.erc721a['safeMint(address,uint256)'](this.owner.address, 0)).to.be.revertedWith( - 'MintZeroQuantity' - ); - }); + it('requires quantity to be greater than 0', async function () { + await expect(this.erc721a['safeMint(address,uint256)'](this.owner.address, 0)).to.be.revertedWith( + 'MintZeroQuantity' + ); + }); - it('reverts for non-receivers', async function () { - const nonReceiver = this.erc721a; - await expect(this.erc721a['safeMint(address,uint256)'](nonReceiver.address, 1)).to.be.revertedWith( - 'TransferToNonERC721ReceiverImplementer' - ); - }); - }); + it('reverts for non-receivers', async function () { + const nonReceiver = this.erc721a; + await expect(this.erc721a['safeMint(address,uint256)'](nonReceiver.address, 1)).to.be.revertedWith( + 'TransferToNonERC721ReceiverImplementer' + ); + }); + }); - describe('mint', function () { - const data = '0x42'; + describe('mint', function () { + const data = '0x42'; - it('successfully mints a single token', async function () { - const mintTx = await this.erc721a.mint(this.receiver.address, 1, data, false); - await expect(mintTx).to.emit(this.erc721a, 'Transfer').withArgs(ZERO_ADDRESS, this.receiver.address, 0); - await expect(mintTx).to.not.emit(this.receiver, 'Received'); - expect(await this.erc721a.ownerOf(0)).to.equal(this.receiver.address); - }); + it('successfully mints a single token', async function () { + const mintTx = await this.erc721a.mint(this.receiver.address, 1, data, false); + await expect(mintTx) + .to.emit(this.erc721a, 'Transfer') + .withArgs(ZERO_ADDRESS, this.receiver.address, this.startTokenId); + await expect(mintTx).to.not.emit(this.receiver, 'Received'); + expect(await this.erc721a.ownerOf(this.startTokenId)).to.equal(this.receiver.address); + }); - it('successfully mints multiple tokens', async function () { - const mintTx = await this.erc721a.mint(this.receiver.address, 5, data, false); - for (let tokenId = 0; tokenId < 5; tokenId++) { - await expect(mintTx).to.emit(this.erc721a, 'Transfer').withArgs(ZERO_ADDRESS, this.receiver.address, tokenId); - await expect(mintTx).to.not.emit(this.receiver, 'Received'); - expect(await this.erc721a.ownerOf(tokenId)).to.equal(this.receiver.address); - } - }); + it('successfully mints multiple tokens', async function () { + const mintTx = await this.erc721a.mint(this.receiver.address, 5, data, false); + for (let tokenId = this.startTokenId; tokenId < 5 + this.startTokenId; tokenId++) { + await expect(mintTx) + .to.emit(this.erc721a, 'Transfer') + .withArgs(ZERO_ADDRESS, this.receiver.address, tokenId); + await expect(mintTx).to.not.emit(this.receiver, 'Received'); + expect(await this.erc721a.ownerOf(tokenId)).to.equal(this.receiver.address); + } + }); - it('does not revert for non-receivers', async function () { - const nonReceiver = this.erc721a; - await this.erc721a.mint(nonReceiver.address, 1, data, false); - expect(await this.erc721a.ownerOf(0)).to.equal(nonReceiver.address); - }); + it('does not revert for non-receivers', async function () { + const nonReceiver = this.erc721a; + await this.erc721a.mint(nonReceiver.address, 1, data, false); + expect(await this.erc721a.ownerOf(this.startTokenId)).to.equal(nonReceiver.address); + }); - it('rejects mints to the zero address', async function () { - await expect(this.erc721a.mint(ZERO_ADDRESS, 1, data, false)).to.be.revertedWith('MintToZeroAddress'); - }); + it('rejects mints to the zero address', async function () { + await expect(this.erc721a.mint(ZERO_ADDRESS, 1, data, false)).to.be.revertedWith('MintToZeroAddress'); + }); - it('requires quantity to be greater than 0', async function () { - await expect(this.erc721a.mint(this.owner.address, 0, data, false)).to.be.revertedWith('MintZeroQuantity'); + it('requires quantity to be greater than 0', async function () { + await expect(this.erc721a.mint(this.owner.address, 0, data, false)).to.be.revertedWith('MintZeroQuantity'); + }); + }); }); }); - }); -}); + }; + +describe('ERC721A', createTestSuite({ contract: 'ERC721AMock', constructorArgs: ['Azuki', 'AZUKI'] })); + +describe( + 'ERC721A override _startTokenId()', + createTestSuite({ contract: 'ERC721AStartTokenIdMock', constructorArgs: ['Azuki', 'AZUKI', 1] }) +); diff --git a/test/extensions/ERC721ABurnable.test.js b/test/extensions/ERC721ABurnable.test.js index 76343a1fd..cc52052d6 100644 --- a/test/extensions/ERC721ABurnable.test.js +++ b/test/extensions/ERC721ABurnable.test.js @@ -1,119 +1,142 @@ 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 balances', async function () { - expect(await this.token.balanceOf(this.addr1.address)) - .to.be.equal(this.numTestTokens - 1); - }); - - 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); - } - } - }); +const createTestSuite = ({ contract, constructorArgs }) => + function () { + context(`${contract}`, function () { + beforeEach(async function () { + this.ERC721ABurnable = await ethers.getContractFactory(contract); - 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); + this.erc721aBurnable = await this.ERC721ABurnable.deploy(...constructorArgs); + + await this.erc721aBurnable.deployed(); + + this.startTokenId = this.erc721aBurnable.startTokenId + ? (await this.erc721aBurnable.startTokenId()).toNumber() + : 0; + }); + + 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.erc721aBurnable['safeMint(address,uint256)'](this.addr1.address, this.numTestTokens); + await this.erc721aBurnable.connect(this.addr1).burn(this.burnedTokenId); + }); + + context('totalSupply()', function () { + it('has the expected value', async function () { + expect(await this.erc721aBurnable.totalSupply()).to.equal(9); + }); + + it('is reduced by burns', async function () { + const supplyBefore = await this.erc721aBurnable.totalSupply(); + + for (let i = 0; i < 2 + this.startTokenId; ++i) { + await this.erc721aBurnable.connect(this.addr1).burn(i + this.startTokenId); + + const supplyNow = await this.erc721aBurnable.totalSupply(); + expect(supplyNow).to.equal(supplyBefore - (i + 1)); + } + }); + }); + + it('changes exists', async function () { + expect(await this.erc721aBurnable.exists(this.burnedTokenId)).to.be.false; + }); + + it('cannot burn a non-existing token', async function () { + const query = this.erc721aBurnable.connect(this.addr1).burn(this.numTestTokens + this.startTokenId); + await expect(query).to.be.revertedWith('OwnerQueryForNonexistentToken'); + }); + + it('cannot burn a burned token', async function () { + const query = this.erc721aBurnable.connect(this.addr1).burn(this.burnedTokenId); + await expect(query).to.be.revertedWith('OwnerQueryForNonexistentToken'); + }); + + it('cannot transfer a burned token', async function () { + const query = this.erc721aBurnable + .connect(this.addr1) + .transferFrom(this.addr1.address, this.addr2.address, this.burnedTokenId); + await expect(query).to.be.revertedWith('OwnerQueryForNonexistentToken'); + }); + + it('does not affect _totalMinted', async function () { + const totalMintedBefore = await this.erc721aBurnable.totalMinted(); + expect(totalMintedBefore).to.equal(this.numTestTokens); + for (let i = 0; i < 2; ++i) { + await this.erc721aBurnable.connect(this.addr1).burn(i + this.startTokenId); } - } - }); + expect(await this.erc721aBurnable.totalMinted()).to.equal(totalMintedBefore); + }); + + it('adjusts owners balances', async function () { + expect(await this.erc721aBurnable.balanceOf(this.addr1.address)).to.be.equal(this.numTestTokens - 1); + }); + + describe('ownerships correctly set', async function () { + it('with token before previously burnt token transferred and burned', async function () { + const tokenIdToBurn = this.burnedTokenId - 1; + await this.erc721aBurnable + .connect(this.addr1) + .transferFrom(this.addr1.address, this.addr2.address, tokenIdToBurn); + expect(await this.erc721aBurnable.ownerOf(tokenIdToBurn)).to.be.equal(this.addr2.address); + await this.erc721aBurnable.connect(this.addr2).burn(tokenIdToBurn); + for (let i = this.startTokenId; i < this.numTestTokens + this.startTokenId; ++i) { + if (i == tokenIdToBurn || i == this.burnedTokenId) { + await expect(this.erc721aBurnable.ownerOf(i)).to.be.revertedWith('OwnerQueryForNonexistentToken'); + } else { + expect(await this.erc721aBurnable.ownerOf(i)).to.be.equal(this.addr1.address); + } + } + }); + + it('with token after previously burnt token transferred and burned', async function () { + const tokenIdToBurn = this.burnedTokenId + 1; + await this.erc721aBurnable + .connect(this.addr1) + .transferFrom(this.addr1.address, this.addr2.address, tokenIdToBurn); + expect(await this.erc721aBurnable.ownerOf(tokenIdToBurn)).to.be.equal(this.addr2.address); + await this.erc721aBurnable.connect(this.addr2).burn(tokenIdToBurn); + for (let i = this.startTokenId; i < this.numTestTokens + this.startTokenId; ++i) { + if (i == tokenIdToBurn || i == this.burnedTokenId) { + await expect(this.erc721aBurnable.ownerOf(i)).to.be.revertedWith('OwnerQueryForNonexistentToken'); + } else { + expect(await this.erc721aBurnable.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( + it('with first token burned', async function () { + await this.erc721aBurnable.connect(this.addr1).burn(this.startTokenId); + for (let i = this.startTokenId; i < this.numTestTokens + this.startTokenId; ++i) { + if (i == this.startTokenId || i == this.burnedTokenId) { + await expect(this.erc721aBurnable.ownerOf(i)).to.be.revertedWith('OwnerQueryForNonexistentToken'); + } else { + expect(await this.erc721aBurnable.ownerOf(i)).to.be.equal(this.addr1.address); + } + } + }); + + it('with last token burned', async function () { + await expect(this.erc721aBurnable.ownerOf(this.numTestTokens + this.startTokenId)).to.be.revertedWith( 'OwnerQueryForNonexistentToken' - ) - } else { - expect(await this.token.ownerOf(i)).to.be.equal(this.addr1.address); - } - } + ); + await this.erc721aBurnable.connect(this.addr1).burn(this.numTestTokens - 1 + this.startTokenId); + await expect(this.erc721aBurnable.ownerOf(this.numTestTokens - 1 + this.startTokenId)).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ); + }); + }); }); + }; - 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' - ) - }); - }); -}); +describe('ERC721ABurnable', createTestSuite({ contract: 'ERC721ABurnableMock', constructorArgs: ['Azuki', 'AZUKI'] })); + +describe( + 'ERC721ABurnable override _startTokenId()', + createTestSuite({ contract: 'ERC721ABurnableStartTokenIdMock', constructorArgs: ['Azuki', 'AZUKI', 1] }) +); diff --git a/test/extensions/ERC721AOwnersExplicit.test.js b/test/extensions/ERC721AOwnersExplicit.test.js index 23d98ebf2..cf4f50d48 100644 --- a/test/extensions/ERC721AOwnersExplicit.test.js +++ b/test/extensions/ERC721AOwnersExplicit.test.js @@ -2,84 +2,119 @@ const { expect } = require('chai'); const { constants } = require('@openzeppelin/test-helpers'); const { ZERO_ADDRESS } = constants; -describe('ERC721AOwnersExplicit', function () { - beforeEach(async function () { - this.ERC721AOwnersExplicit = await ethers.getContractFactory('ERC721AOwnersExplicitMock'); - this.token = await this.ERC721AOwnersExplicit.deploy('Azuki', 'AZUKI'); - await this.token.deployed(); - }); +const createTestSuite = ({ contract, constructorArgs }) => + function () { + context(`${contract}`, function () { + beforeEach(async function () { + this.ERC721AOwnersExplicit = await ethers.getContractFactory(contract); - context('with no minted tokens', async function () { - it('does not have enough tokens minted', async function () { - await expect(this.token.setOwnersExplicit(1)).to.be.revertedWith('NoTokensMintedYet'); - }); - }); + this.erc721aOwnersExplicit = await this.ERC721AOwnersExplicit.deploy(...constructorArgs); - context('with minted tokens', async function () { - beforeEach(async function () { - const [owner, addr1, addr2, addr3] = await ethers.getSigners(); - this.owner = owner; - this.addr1 = addr1; - this.addr2 = addr2; - this.addr3 = addr3; - // After the following mints, our ownership array will look like this: - // | 1 | 2 | Empty | 3 | Empty | Empty | - 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.erc721aOwnersExplicit.deployed(); - describe('setOwnersExplicit', async function () { - it('rejects 0 quantity', async function () { - await expect(this.token.setOwnersExplicit(0)).to.be.revertedWith('QuantityMustBeNonZero'); + this.startTokenId = this.erc721aOwnersExplicit.startTokenId + ? (await this.erc721aOwnersExplicit.startTokenId()).toNumber() + : 0; }); - it('handles single increment properly', async function () { - await this.token.setOwnersExplicit(1); - expect(await this.token.nextOwnerToExplicitlySet()).to.equal('1'); + context('with no minted tokens', async function () { + it('does not have enough tokens minted', async function () { + await expect(this.erc721aOwnersExplicit.setOwnersExplicit(1)).to.be.revertedWith('NoTokensMintedYet'); + }); }); - it('properly sets the ownership of index 2', async function () { - let ownerAtTwo = await this.token.getOwnershipAt(2); - expect(ownerAtTwo[0]).to.equal(ZERO_ADDRESS); - await this.token.setOwnersExplicit(3); - ownerAtTwo = await this.token.getOwnershipAt(2); - expect(ownerAtTwo[0]).to.equal(this.addr2.address); - expect(await this.token.nextOwnerToExplicitlySet()).to.equal('3'); - }); + context('with minted tokens', async function () { + beforeEach(async function () { + const [owner, addr1, addr2, addr3] = await ethers.getSigners(); + this.owner = owner; + this.addr1 = addr1; + this.addr2 = addr2; + this.addr3 = addr3; + // After the following mints, our ownership array will look like this: + // | 1 | 2 | Empty | 3 | Empty | Empty | + await this.erc721aOwnersExplicit['safeMint(address,uint256)'](addr1.address, 1); + await this.erc721aOwnersExplicit['safeMint(address,uint256)'](addr2.address, 2); + await this.erc721aOwnersExplicit['safeMint(address,uint256)'](addr3.address, 3); + }); - it('sets all ownerships in one go', async function () { - await this.token.setOwnersExplicit(6); - for (let tokenId = 0; tokenId < 6; tokenId++) { - let owner = await this.token.getOwnershipAt(tokenId); - expect(owner[0]).to.not.equal(ZERO_ADDRESS); - } - }); + describe('setOwnersExplicit', async function () { + it('rejects 0 quantity', async function () { + await expect(this.erc721aOwnersExplicit.setOwnersExplicit(0)).to.be.revertedWith('QuantityMustBeNonZero'); + }); - it('sets all ownerships with overflowing quantity', async function () { - await this.token.setOwnersExplicit(15); - for (let tokenId = 0; tokenId < 6; tokenId++) { - let owner = await this.token.getOwnershipAt(tokenId); - expect(owner[0]).to.not.equal(ZERO_ADDRESS); - } - }); + it('handles single increment properly', async function () { + await this.erc721aOwnersExplicit.setOwnersExplicit(1); + expect(await this.erc721aOwnersExplicit.nextOwnerToExplicitlySet()).to.equal( + (1 + this.startTokenId).toString() + ); + }); - it('sets all ownerships in multiple calls', async function () { - await this.token.setOwnersExplicit(2); - expect(await this.token.nextOwnerToExplicitlySet()).to.equal('2'); - await this.token.setOwnersExplicit(1); - expect(await this.token.nextOwnerToExplicitlySet()).to.equal('3'); - await this.token.setOwnersExplicit(3); - for (let tokenId = 0; tokenId < 6; tokenId++) { - let owner = await this.token.getOwnershipAt(tokenId); - expect(owner[0]).to.not.equal(ZERO_ADDRESS); - } - }); + it('properly sets the ownership of index 2', async function () { + let ownerAtTwo = await this.erc721aOwnersExplicit.getOwnershipAt(2 + this.startTokenId); + expect(ownerAtTwo[0]).to.equal(ZERO_ADDRESS); + await this.erc721aOwnersExplicit.setOwnersExplicit(3); + ownerAtTwo = await this.erc721aOwnersExplicit.getOwnershipAt(2); + expect(ownerAtTwo[0]).to.equal(this.addr2.address); + expect(await this.erc721aOwnersExplicit.nextOwnerToExplicitlySet()).to.equal( + (3 + this.startTokenId).toString() + ); + }); - it('rejects after all ownerships have been set', async function () { - await this.token.setOwnersExplicit(6); - await expect(this.token.setOwnersExplicit(1)).to.be.revertedWith('AllOwnershipsHaveBeenSet'); + it('sets all ownerships in one go', async function () { + await this.erc721aOwnersExplicit.setOwnersExplicit(6); + for (let tokenId = this.startTokenId; tokenId < 6 + this.startTokenId; tokenId++) { + let owner = await this.erc721aOwnersExplicit.getOwnershipAt(tokenId); + expect(owner[0]).to.not.equal(ZERO_ADDRESS); + } + }); + + it('sets all ownerships with overflowing quantity', async function () { + await this.erc721aOwnersExplicit.setOwnersExplicit(15); + for (let tokenId = this.startTokenId; tokenId < 6 + this.startTokenId; tokenId++) { + let owner = await this.erc721aOwnersExplicit.getOwnershipAt(tokenId); + expect(owner[0]).to.not.equal(ZERO_ADDRESS); + } + }); + + it('sets all ownerships in multiple calls', async function () { + await this.erc721aOwnersExplicit.setOwnersExplicit(2); + expect(await this.erc721aOwnersExplicit.nextOwnerToExplicitlySet()).to.equal( + (2 + this.startTokenId).toString() + ); + await this.erc721aOwnersExplicit.setOwnersExplicit(1); + expect(await this.erc721aOwnersExplicit.nextOwnerToExplicitlySet()).to.equal( + (3 + this.startTokenId).toString() + ); + await this.erc721aOwnersExplicit.setOwnersExplicit(3); + for (let tokenId = this.startTokenId; tokenId < 6 + this.startTokenId; tokenId++) { + let owner = await this.erc721aOwnersExplicit.getOwnershipAt(tokenId); + expect(owner[0]).to.not.equal(ZERO_ADDRESS); + } + }); + + it('rejects after all ownerships have been set', async function () { + await this.erc721aOwnersExplicit.setOwnersExplicit(6); + await expect(this.erc721aOwnersExplicit.setOwnersExplicit(1)).to.be.revertedWith( + 'AllOwnershipsHaveBeenSet' + ); + }); + }); }); }); - }); -}); + }; + +describe( + 'ERC721AOwnersExplicit', + createTestSuite({ + contract: 'ERC721AOwnersExplicitMock', + constructorArgs: ['Azuki', 'AZUKI'], + }) +); + +describe( + 'ERC721AOwnersExplicit override _startTokenId()', + createTestSuite({ + contract: 'ERC721AOwnersExplicitStartTokenIdMock', + constructorArgs: ['Azuki', 'AZUKI', 1], + }) +);