diff --git a/.prettierrc b/.prettierrc index 7ec948a6..3dff7f42 100644 --- a/.prettierrc +++ b/.prettierrc @@ -10,4 +10,4 @@ } } ] -} \ No newline at end of file +} diff --git a/contracts/ERC721A.sol b/contracts/ERC721A.sol index c4bcfa9d..f4341b9e 100644 --- a/contracts/ERC721A.sol +++ b/contracts/ERC721A.sol @@ -712,6 +712,208 @@ contract ERC721A is IERC721A { } } + /** + * @dev Equivalent to `_batchTransferFrom(from, to, tokenIds, false)`. + */ + function _batchTransferFrom( + address from, + address to, + uint256[] memory tokenIds + ) internal virtual { + _batchTransferFrom(from, to, tokenIds, false); + } + + /** + * @dev Transfers `tokenIds` in batch from `from` to `to`. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenIds` tokens must be owned by `from`. + * - `tokenIds` must be strictly ascending. + * - If the caller is not `from`, it must be approved to move these tokens + * by either {approve} or {setApprovalForAll}. + * + * Emits a {Transfer} event for each transfer. + */ + function _batchTransferFrom( + address from, + address to, + uint256[] memory tokenIds, + bool approvalCheck + ) internal virtual { + // We can use unchecked as the length of `tokenIds` is bounded + // to a small number by the max block gas limit. + unchecked { + // Mask `from` and `to` to the lower 160 bits, in case the upper bits somehow aren't clean. + from = address(uint160(uint256(uint160(from)) & _BITMASK_ADDRESS)); + if (uint256(uint160(to)) & _BITMASK_ADDRESS == 0) revert TransferToZeroAddress(); + + // Disable `approvalCheck` if sender is either the owner or an approved operator for all tokens + approvalCheck = from != _msgSenderERC721A() && !isApprovedForAll(from, _msgSenderERC721A()); + + uint256 n = tokenIds.length; + + // Increment and decrement the balances. + _packedAddressData[from] -= n; + _packedAddressData[to] += n; + + // The next `tokenId` to be minted (i.e. `_nextTokenId()`). + uint256 stop = _currentIndex; + + // For checking if the `tokenIds` are strictly ascending. + uint256 prevTokenId; + + uint256 tokenId; + uint256 currTokenId; + uint256 prevOwnershipPacked; + uint256 lastOwnershipPacked; + for (uint256 i; i != n; ) { + tokenId = tokenIds[i]; + + // Revert `tokenId` is out of bounds. + if (_or(tokenId < _startTokenId(), stop <= tokenId)) revert OwnerQueryForNonexistentToken(); + + // Revert if `tokenIds` is not strictly ascending. + if (i != 0) + if (tokenId <= prevTokenId) revert TokenIdsNotStrictlyAscending(); + + // Scan backwards for an initialized packed ownership slot. + // ERC721A's invariant guarantees that there will always be an initialized slot as long as + // the start of the backwards scan falls within `[_startTokenId() .. _nextTokenId())`. + for (uint256 j = tokenId; (prevOwnershipPacked = _packedOwnerships[j]) == 0; ) --j; + + // If the initialized slot is burned, revert. + if (prevOwnershipPacked & _BITMASK_BURNED != 0) revert OwnerQueryForNonexistentToken(); + + // Check ownership of `tokenId`. + if (address(uint160(prevOwnershipPacked)) != from) revert TransferFromIncorrectOwner(); + + currTokenId = tokenId; + uint256 offset; + do { + address approvedAddress = _tokenApprovals[currTokenId].value; + + // Revert if the sender is not authorized to transfer the token. + if (approvalCheck) { + if (_msgSenderERC721A() != approvedAddress) revert TransferCallerNotOwnerNorApproved(); + } + + // Call the hook. + _beforeTokenTransfers(from, to, currTokenId, 1); + + if (approvedAddress != address(0)) delete _tokenApprovals[currTokenId]; + + // Emit the `Transfer` event. + emit Transfer(from, to, currTokenId); + // Call the hook. + _afterTokenTransfers(from, to, currTokenId, 1); + // Increment `offset` and update `currTokenId`. + currTokenId = tokenId + (++offset); + } while ( + // Neither out of bounds, nor at the end of `tokenIds`. + !_or(currTokenId == stop, i + offset == n) && + // Token ID is sequential. + tokenIds[i + offset] == currTokenId && + // The packed ownership slot is not initialized. + (lastOwnershipPacked = _packedOwnerships[currTokenId]) == 0 + ); + + // Updates tokenId: + // - `address` to the next owner. + // - `startTimestamp` to the timestamp of transfering. + // - `burned` to `false`. + // - `nextInitialized` to `false`. + _packedOwnerships[tokenId] = _packOwnershipData(to, _nextExtraData(from, to, prevOwnershipPacked)); + + // If the slot after the mini batch is neither out of bounds, nor initialized. + // If `lastOwnershipPacked == 0` we didn't break the loop due to an initialized slot. + if (currTokenId != stop) + if (lastOwnershipPacked == 0) + if (_packedOwnerships[currTokenId] == 0) _packedOwnerships[currTokenId] = prevOwnershipPacked; + + // Advance `i` by `offset`, the number of tokens transferred in the mini batch. + i += offset; + + // Set the `prevTokenId` for checking that the `tokenIds` is strictly ascending. + prevTokenId = currTokenId - 1; + } + } + } + + /** + * @dev Equivalent to `_safeBatchTransferFrom(from, to, tokenIds, false)`. + */ + function _safeBatchTransferFrom( + address from, + address to, + uint256[] memory tokenIds + ) internal virtual { + _safeBatchTransferFrom(from, to, tokenIds, false); + } + + /** + * @dev Equivalent to `_safeBatchTransferFrom(from, to, tokenIds, '', approvalCheck)`. + */ + function _safeBatchTransferFrom( + address from, + address to, + uint256[] memory tokenIds, + bool approvalCheck + ) internal virtual { + _safeBatchTransferFrom(from, to, tokenIds, '', approvalCheck); + } + + /** + * @dev Equivalent to `_safeBatchTransferFrom(from, to, tokenIds, _data, false)`. + */ + function _safeBatchTransferFrom( + address from, + address to, + uint256[] memory tokenIds, + bytes memory _data + ) internal virtual { + _safeBatchTransferFrom(from, to, tokenIds, _data, false); + } + + /** + * @dev Safely transfers `tokenIds` in batch from `from` to `to`. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenIds` tokens must be owned by `from`. + * - If the caller is not `from`, it must be approved to move these tokens + * by either {approve} or {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement + * {IERC721Receiver-onERC721Received}, which is called for each transferred token. + * + * Emits a {Transfer} event for each transfer. + */ + function _safeBatchTransferFrom( + address from, + address to, + uint256[] memory tokenIds, + bytes memory _data, + bool approvalCheck + ) internal virtual { + _batchTransferFrom(from, to, tokenIds, approvalCheck); + + uint256 tokenId; + uint256 n = tokenIds.length; + unchecked { + for (uint256 i; i < n; ++i) { + tokenId = tokenIds[i]; + if (to.code.length != 0) + if (!_checkContractOnERC721Received(from, to, tokenId, _data)) { + _revert(TransferToNonERC721ReceiverImplementer.selector); + } + } + } + } + /** * @dev Hook that is called before a set of serially-ordered token IDs * are about to be transferred. This includes minting. @@ -1311,4 +1513,13 @@ contract ERC721A is IERC721A { revert(0x00, 0x04) } } + + /** + * @dev Branchless or. + */ + function _or(bool a, bool b) private pure returns (bool c) { + assembly { + c := or(a, b) + } + } } diff --git a/contracts/IERC721A.sol b/contracts/IERC721A.sol index ebde3074..e7815e1f 100644 --- a/contracts/IERC721A.sol +++ b/contracts/IERC721A.sol @@ -74,6 +74,11 @@ interface IERC721A { */ error OwnershipNotInitializedForExtraData(); + /** + * The `tokenIds` must be strictly ascending. + */ + error TokenIdsNotStrictlyAscending(); + /** * `_sequentialUpTo()` must be greater than `_startTokenId()`. */ diff --git a/contracts/extensions/ERC721ABatchTransferable.sol b/contracts/extensions/ERC721ABatchTransferable.sol new file mode 100644 index 00000000..d4caa4a0 --- /dev/null +++ b/contracts/extensions/ERC721ABatchTransferable.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.2.3 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import '../ERC721A.sol'; +import './IERC721ABatchTransferable.sol'; + +/** + * @title ERC721ABatchTransferable. + * + * @dev ERC721A token optimized for batch transfers. + */ +abstract contract ERC721ABatchTransferable is ERC721A, IERC721ABatchTransferable { + function batchTransferFrom( + address from, + address to, + uint256[] memory tokenIds + ) public payable virtual override { + _batchTransferFrom(from, to, tokenIds, true); + } + + function safeBatchTransferFrom( + address from, + address to, + uint256[] memory tokenIds + ) public payable virtual override { + _safeBatchTransferFrom(from, to, tokenIds, true); + } + + function safeBatchTransferFrom( + address from, + address to, + uint256[] memory tokenIds, + bytes memory _data + ) public payable virtual override { + _safeBatchTransferFrom(from, to, tokenIds, _data, true); + } +} diff --git a/contracts/extensions/IERC721ABatchTransferable.sol b/contracts/extensions/IERC721ABatchTransferable.sol new file mode 100644 index 00000000..0b984e16 --- /dev/null +++ b/contracts/extensions/IERC721ABatchTransferable.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.2.3 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import '../IERC721A.sol'; + +/** + * @dev Interface of ERC721ABatchTransferable. + */ +interface IERC721ABatchTransferable is IERC721A { + /** + * @dev Transfers `tokenIds` in batch from `from` to `to`. See {ERC721A-_batchTransferFrom}. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenIds` tokens must be owned by `from`. + * - If the caller is not `from`, it must be approved to move these tokens + * by either {approve} or {setApprovalForAll}. + * + * Emits a {Transfer} event for each transfer. + */ + function batchTransferFrom( + address from, + address to, + uint256[] memory tokenIds + ) external payable; + + /** + * @dev Equivalent to `safeBatchTransferFrom(from, to, tokenIds, '')`. + */ + function safeBatchTransferFrom( + address from, + address to, + uint256[] memory tokenIds + ) external payable; + + /** + * @dev Safely transfers `tokenIds` in batch from `from` to `to`. See {ERC721A-_safeBatchTransferFrom}. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenIds` tokens must be owned by `from`. + * - If the caller is not `from`, it must be approved to move these tokens + * by either {approve} or {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement + * {IERC721Receiver-onERC721Received}, which is called for each transferred token. + * + * Emits a {Transfer} event for each transfer. + */ + function safeBatchTransferFrom( + address from, + address to, + uint256[] memory tokenIds, + bytes memory _data + ) external payable; +} diff --git a/contracts/interfaces/IERC721ABatchTransferable.sol b/contracts/interfaces/IERC721ABatchTransferable.sol new file mode 100644 index 00000000..bde71cca --- /dev/null +++ b/contracts/interfaces/IERC721ABatchTransferable.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.2.3 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import '../extensions/IERC721ABatchTransferable.sol'; diff --git a/contracts/mocks/ERC721ABatchTransferableMock.sol b/contracts/mocks/ERC721ABatchTransferableMock.sol new file mode 100644 index 00000000..d0aaea81 --- /dev/null +++ b/contracts/mocks/ERC721ABatchTransferableMock.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.2.3 +// Creators: Chiru Labs + +pragma solidity ^0.8.4; + +import '../extensions/ERC721ABatchTransferable.sol'; + +contract ERC721ABatchTransferableMock is ERC721ABatchTransferable { + 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 _ownershipAt(index); + } + + function totalMinted() public view returns (uint256) { + return _totalMinted(); + } + + function totalBurned() public view returns (uint256) { + return _totalBurned(); + } + + function numberBurned(address owner) public view returns (uint256) { + return _numberBurned(owner); + } + + function burn(uint256 tokenId) public { + _burn(tokenId, true); + } + + function initializeOwnershipAt(uint256 index) public { + _initializeOwnershipAt(index); + } + + function _extraData( + address, + address, + uint24 previousExtraData + ) internal view virtual override returns (uint24) { + return previousExtraData; + } + + function setExtraDataAt(uint256 index, uint24 extraData) public { + _setExtraDataAt(index, extraData); + } + + function batchTransferFromUnoptimized( + address from, + address to, + uint256[] memory tokenIds + ) public { + unchecked { + uint256 tokenId; + for (uint256 i; i < tokenIds.length; ++i) { + tokenId = tokenIds[i]; + transferFrom(from, to, tokenId); + } + } + } +} diff --git a/test/extensions/ERC721ABatchTransferable.test.js b/test/extensions/ERC721ABatchTransferable.test.js new file mode 100644 index 00000000..20382f7f --- /dev/null +++ b/test/extensions/ERC721ABatchTransferable.test.js @@ -0,0 +1,529 @@ +const { deployContract, getBlockTimestamp, mineBlockTimestamp, offsettedIndex } = require('../helpers.js'); +const { expect } = require('chai'); +const { constants } = require('@openzeppelin/test-helpers'); +const { ZERO_ADDRESS } = constants; + +const RECEIVER_MAGIC_VALUE = '0x150b7a02'; + +const createTestSuite = ({ contract, constructorArgs }) => + function () { + let offsetted; + + context(`${contract}`, function () { + beforeEach(async function () { + this.erc721aBatchTransferable = await deployContract(contract, constructorArgs); + this.receiver = await deployContract('ERC721ReceiverMock', [ + RECEIVER_MAGIC_VALUE, + this.erc721aBatchTransferable.address, + ]); + this.startTokenId = this.erc721aBatchTransferable.startTokenId + ? (await this.erc721aBatchTransferable.startTokenId()).toNumber() + : 0; + + offsetted = (...arr) => offsettedIndex(this.startTokenId, arr); + offsetted(0); + }); + + beforeEach(async function () { + const [owner, addr1, addr2, addr3, addr4, addr5] = await ethers.getSigners(); + this.owner = owner; + this.addr1 = addr1; + this.addr2 = addr2; + this.addr3 = addr3; + this.addr4 = addr4; + this.addr5 = addr5; + + this.addr1.expected = { + mintCount: 3, + tokens: offsetted(2, 4, 5), + }; + + this.addr2.expected = { + mintCount: 20, + tokens: offsetted(0, 1, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22), + }; + + this.addr3.expected = { + mintCount: 7, + tokens: offsetted(23, 24, 25, 26, 27, 28, 29), + }; + + this.numTotalTokens = + this.addr1.expected.mintCount + this.addr2.expected.mintCount + this.addr3.expected.mintCount; + + await this.erc721aBatchTransferable['safeMint(address,uint256)'](this.addr2.address, 2); + await this.erc721aBatchTransferable['safeMint(address,uint256)'](this.addr1.address, 1); + await this.erc721aBatchTransferable['safeMint(address,uint256)'](this.addr2.address, 1); + await this.erc721aBatchTransferable['safeMint(address,uint256)'](this.addr1.address, 2); + await this.erc721aBatchTransferable['safeMint(address,uint256)'](this.addr2.address, 17); + await this.erc721aBatchTransferable['safeMint(address,uint256)'](this.addr3.address, 7); + }); + + context('test batch transfer functionality', function () { + const testSuccessfulBatchTransfer = function (transferFn, transferToContract = true) { + describe('successful transfers', async function () { + beforeEach(async function () { + const sender = this.addr2; + this.tokenIds = this.addr2.expected.tokens; + this.from = sender.address; + this.to = transferToContract ? this.receiver : this.addr4; + this.approvedIds = [this.tokenIds[2], this.tokenIds[3]]; + this.initializedToken = 8; + this.uninitializedToken = 10; + + this.approvedIds.forEach(async (tokenId) => { + await this.erc721aBatchTransferable.connect(sender).approve(this.to.address, tokenId); + }); + + // Manually initialize `this.initializedToken` + await this.erc721aBatchTransferable.initializeOwnershipAt(this.initializedToken); + + const ownershipBefore = await this.erc721aBatchTransferable.getOwnershipAt(3); + this.timestampBefore = parseInt(ownershipBefore.startTimestamp); + this.timestampToMine = (await getBlockTimestamp()) + 12345; + await mineBlockTimestamp(this.timestampToMine); + this.timestampMined = await getBlockTimestamp(); + + // prettier-ignore + this.transferTx = await this.erc721aBatchTransferable + .connect(sender)[transferFn](this.from, this.to.address, this.tokenIds); + + const ownershipAfter = await this.erc721aBatchTransferable.getOwnershipAt(3); + this.timestampAfter = parseInt(ownershipAfter.startTimestamp); + + // Transfer part of uninitialized tokens + this.tokensToTransferAlt = [25, 26, 27]; + // prettier-ignore + this.transferTxAlt = await this.erc721aBatchTransferable.connect(this.addr3)[transferFn]( + this.addr3.address, this.addr5.address, this.tokensToTransferAlt + ); + }); + + it('emits Transfers event', async function () { + for (let i = 0; i < this.tokenIds.length; i++) { + const tokenId = this.tokenIds[i]; + await expect(this.transferTx) + .to.emit(this.erc721aBatchTransferable, 'Transfer') + .withArgs(this.from, this.to.address, tokenId); + } + }); + + it('adjusts owners balances', async function () { + expect(await this.erc721aBatchTransferable.balanceOf(this.from)).to.be.equal(0); + expect(await this.erc721aBatchTransferable.balanceOf(this.to.address)).to.be.equal( + this.addr2.expected.mintCount + ); + expect(await this.erc721aBatchTransferable.balanceOf(this.addr3.address)).to.be.equal( + this.addr3.expected.tokens.length - this.tokensToTransferAlt.length + ); + expect(await this.erc721aBatchTransferable.balanceOf(this.addr5.address)).to.be.equal( + this.tokensToTransferAlt.length + ); + }); + + it('clears the approval for the token IDs', async function () { + this.approvedIds.forEach(async (tokenId) => { + expect(await this.erc721aBatchTransferable.getApproved(tokenId)).to.be.equal(ZERO_ADDRESS); + }); + }); + + it('startTimestamp updated correctly', async function () { + expect(this.timestampBefore).to.be.lt(this.timestampToMine); + expect(this.timestampAfter).to.be.gte(this.timestampToMine); + expect(this.timestampAfter).to.be.lt(this.timestampToMine + 10); + expect(this.timestampToMine).to.be.eq(this.timestampMined); + }); + + it('with transfer of the given token IDs to the given address', async function () { + for (let i = 0; i < this.tokenIds.length; i++) { + const tokenId = this.tokenIds[i]; + expect(await this.erc721aBatchTransferable.ownerOf(tokenId)).to.be.equal(this.to.address); + } + + // Initialized tokens were updated + expect((await this.erc721aBatchTransferable.getOwnershipAt(3))[0]).to.be.equal(this.to.address); + + // // Initialized tokens in a consecutive transfer are cleared + // expect((await this.erc721aBatchTransferable.getOwnershipAt(8))[0]).to.be.equal( + // transferFn !== 'batchTransferFromUnoptimized' ? ZERO_ADDRESS : this.to.address + // ); + + // Uninitialized tokens are left uninitialized + expect((await this.erc721aBatchTransferable.getOwnershipAt(7))[0]).to.be.equal( + transferFn !== 'batchTransferFromUnoptimized' ? ZERO_ADDRESS : this.to.address + ); + + // Other tokens in between are left unchanged + for (let i = 0; i < this.addr1.expected.tokens.length; i++) { + const tokenId = this.addr1.expected.tokens[i]; + expect(await this.erc721aBatchTransferable.ownerOf(tokenId)).to.be.equal(this.addr1.address); + } + }); + + it('with transfers of uninitialized token IDs to the given address', async function () { + const allTokensInitiallyOwned = this.addr3.expected.tokens; + allTokensInitiallyOwned.splice(2, this.tokensToTransferAlt.length); + + for (let i = 0; i < this.tokensToTransferAlt.length; i++) { + const tokenId = this.tokensToTransferAlt[i]; + expect(await this.erc721aBatchTransferable.ownerOf(tokenId)).to.be.equal(this.addr5.address); + } + + for (let i = 0; i < allTokensInitiallyOwned.length; i++) { + const tokenId = allTokensInitiallyOwned[i]; + expect(await this.erc721aBatchTransferable.ownerOf(tokenId)).to.be.equal(this.addr3.address); + } + + // Ownership of tokens was updated + expect((await this.erc721aBatchTransferable.getOwnershipAt(this.tokensToTransferAlt[0]))[0]).to.be.equal( + this.addr5.address + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(allTokensInitiallyOwned[2]))[0]).to.be.equal( + this.addr3.address + ); + + // Uninitialized tokens are left uninitialized + expect( + (await this.erc721aBatchTransferable.getOwnershipAt(this.tokensToTransferAlt[0] - 1))[0] + ).to.be.equal(ZERO_ADDRESS); + expect((await this.erc721aBatchTransferable.getOwnershipAt(allTokensInitiallyOwned[3]))[0]).to.be.equal( + ZERO_ADDRESS + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(this.tokensToTransferAlt[1]))[0]).to.be.equal( + transferFn !== 'batchTransferFromUnoptimized' ? ZERO_ADDRESS : this.addr5.address + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(this.tokensToTransferAlt[2]))[0]).to.be.equal( + transferFn !== 'batchTransferFromUnoptimized' ? ZERO_ADDRESS : this.addr5.address + ); + }); + }); + + describe('ownership correctly set', async function () { + beforeEach(async function () { + const sender = this.addr2; + this.from = sender.address; + this.to = transferToContract ? this.receiver : this.addr4; + this.initializedToken = 8; + this.uninitializedToken = 10; + + // Manually initialize some tokens of addr2 + await this.erc721aBatchTransferable.initializeOwnershipAt(this.initializedToken); + }); + + it('with tokens transferred and cleared', async function () { + const initializedToken = 15; + + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken - 1))[0]).to.be.equal( + ZERO_ADDRESS + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken))[0]).to.be.equal( + ZERO_ADDRESS + ); + + // Initialize token + await this.erc721aBatchTransferable.initializeOwnershipAt(initializedToken); + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken - 1))[0]).to.be.equal( + ZERO_ADDRESS + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken))[0]).to.be.equal( + this.addr2.address + ); + + // Transfer tokens + // prettier-ignore + await this.erc721aBatchTransferable + .connect(this.addr2)[transferFn]( + this.from, this.to.address, [initializedToken - 1, initializedToken] + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken - 1))[0]).to.be.equal( + this.to.address + ); + + // Initialized tokens in a consecutive transfer are cleared + // expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken))[0]).to.be.equal( + // transferFn !== 'batchTransferFromUnoptimized' ? ZERO_ADDRESS : this.to.address + // ); + }); + + it('with tokens transferred and updated', async function () { + const initializedToken = 15; + const extraData = 123; + + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken - 1))[0]).to.be.equal( + ZERO_ADDRESS + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken))[0]).to.be.equal( + ZERO_ADDRESS + ); + + // Initialize token + await this.erc721aBatchTransferable.initializeOwnershipAt(initializedToken); + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken - 1))[0]).to.be.equal( + ZERO_ADDRESS + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken))[0]).to.be.equal( + this.addr2.address + ); + + // Set extra data + await this.erc721aBatchTransferable.setExtraDataAt(initializedToken, extraData); + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken))[3]).to.be.equal(extraData); + + // Transfer tokens + // prettier-ignore + await this.erc721aBatchTransferable + .connect(this.addr2)[transferFn]( + this.from, this.to.address, [initializedToken - 1, initializedToken] + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken - 1))[0]).to.be.equal( + this.to.address + ); + + // Initialized tokens in a consecutive transfer are updated when nextData is not 0 + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken))[0]).to.be.equal( + this.to.address + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(initializedToken))[3]).to.be.equal(extraData); + }); + + it('with first token transferred', async function () { + expect(await this.erc721aBatchTransferable.ownerOf(0)).to.be.equal(this.from); + expect(await this.erc721aBatchTransferable.ownerOf(1)).to.be.equal(this.from); + expect((await this.erc721aBatchTransferable.getOwnershipAt(0))[0]).to.be.equal(this.from); + expect((await this.erc721aBatchTransferable.getOwnershipAt(1))[0]).to.be.equal(ZERO_ADDRESS); + + // prettier-ignore + await this.erc721aBatchTransferable + .connect(this.addr2)[transferFn](this.from, this.to.address, [0]); + + expect(await this.erc721aBatchTransferable.ownerOf(0)).to.be.equal(this.to.address); + expect(await this.erc721aBatchTransferable.ownerOf(1)).to.be.equal(this.from); + expect((await this.erc721aBatchTransferable.getOwnershipAt(0))[0]).to.be.equal(this.to.address); + expect((await this.erc721aBatchTransferable.getOwnershipAt(1))[0]).to.be.equal(this.from); + }); + + it('with last token transferred', async function () { + await expect(this.erc721aBatchTransferable.ownerOf(this.numTotalTokens)).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ); + + // prettier-ignore + await this.erc721aBatchTransferable + .connect(this.addr3)[transferFn]( + this.addr3.address, this.to.address, [offsetted(this.numTotalTokens - 1 + )]); + + expect(await this.erc721aBatchTransferable.ownerOf(offsetted(this.numTotalTokens - 1))).to.be.equal( + this.to.address + ); + await expect(this.erc721aBatchTransferable.ownerOf(this.numTotalTokens)).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ); + }); + + it('with initialized token transferred', async function () { + expect(await this.erc721aBatchTransferable.ownerOf(this.initializedToken)).to.be.equal(this.from); + expect(await this.erc721aBatchTransferable.ownerOf(this.initializedToken + 1)).to.be.equal(this.from); + expect((await this.erc721aBatchTransferable.getOwnershipAt(this.initializedToken))[0]).to.be.equal( + this.from + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(this.initializedToken + 1))[0]).to.be.equal( + ZERO_ADDRESS + ); + + // prettier-ignore + await this.erc721aBatchTransferable + .connect(this.addr2)[transferFn](this.from, this.to.address, [this.initializedToken]); + + expect(await this.erc721aBatchTransferable.ownerOf(this.initializedToken)).to.be.equal(this.to.address); + expect(await this.erc721aBatchTransferable.ownerOf(this.initializedToken + 1)).to.be.equal(this.from); + expect((await this.erc721aBatchTransferable.getOwnershipAt(this.initializedToken))[0]).to.be.equal( + this.to.address + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(this.initializedToken + 1))[0]).to.be.equal( + this.from + ); + }); + + it('with uninitialized token transferred', async function () { + expect(await this.erc721aBatchTransferable.ownerOf(this.uninitializedToken)).to.be.equal(this.from); + expect(await this.erc721aBatchTransferable.ownerOf(this.uninitializedToken + 1)).to.be.equal(this.from); + expect((await this.erc721aBatchTransferable.getOwnershipAt(this.uninitializedToken))[0]).to.be.equal( + ZERO_ADDRESS + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(this.uninitializedToken + 1))[0]).to.be.equal( + ZERO_ADDRESS + ); + + // prettier-ignore + await this.erc721aBatchTransferable + .connect(this.addr2)[transferFn](this.from, this.to.address, [this.uninitializedToken]); + + expect(await this.erc721aBatchTransferable.ownerOf(this.uninitializedToken)).to.be.equal(this.to.address); + expect(await this.erc721aBatchTransferable.ownerOf(this.uninitializedToken + 1)).to.be.equal(this.from); + expect((await this.erc721aBatchTransferable.getOwnershipAt(this.uninitializedToken))[0]).to.be.equal( + this.to.address + ); + expect((await this.erc721aBatchTransferable.getOwnershipAt(this.uninitializedToken + 1))[0]).to.be.equal( + this.from + ); + }); + }); + }; + + const testUnsuccessfulBatchTransfer = function (transferFn) { + describe('unsuccessful transfers', function () { + beforeEach(function () { + this.tokenIds = this.addr2.expected.tokens.slice(0, 2); + this.sender = this.addr1; + }); + + it('rejects unapproved transfer', async function () { + // prettier-ignore + await expect( + this.erc721aBatchTransferable + .connect(this.sender)[transferFn]( + this.addr2.address, this.sender.address, this.tokenIds + ) + ).to.be.revertedWith('TransferCallerNotOwnerNorApproved'); + }); + + it('rejects transfer from incorrect owner', async function () { + await this.erc721aBatchTransferable.connect(this.addr2).setApprovalForAll(this.sender.address, true); + // prettier-ignore + await expect( + this.erc721aBatchTransferable + .connect(this.sender)[transferFn]( + this.addr3.address, this.sender.address, this.tokenIds + ) + ).to.be.revertedWith('TransferFromIncorrectOwner'); + }); + + it('rejects transfer to zero address', async function () { + await this.erc721aBatchTransferable.connect(this.addr2).setApprovalForAll(this.sender.address, true); + // prettier-ignore + await expect( + this.erc721aBatchTransferable + .connect(this.sender)[transferFn]( + this.addr2.address, ZERO_ADDRESS, this.tokenIds + ) + ).to.be.revertedWith('TransferToZeroAddress'); + }); + }); + }; + + const testApproveBatchTransfer = function (transferFn) { + describe('approvals correctly set', async function () { + beforeEach(function () { + this.tokenIds = this.addr1.expected.tokens.slice(0, 2); + }); + + it('approval allows batch transfers', async function () { + // prettier-ignore + await expect( + this.erc721aBatchTransferable + .connect(this.addr3)[transferFn]( + this.addr1.address, this.addr3.address, this.tokenIds + ) + ).to.be.revertedWith('TransferCallerNotOwnerNorApproved'); + + for (let i = 0; i < this.tokenIds.length; i++) { + const tokenId = this.tokenIds[i]; + await this.erc721aBatchTransferable.connect(this.addr1).approve(this.addr3.address, tokenId); + } + + // prettier-ignore + await this.erc721aBatchTransferable + .connect(this.addr3)[transferFn]( + this.addr1.address, this.addr3.address, this.tokenIds + ); + // prettier-ignore + await expect( + this.erc721aBatchTransferable + .connect(this.addr1)[transferFn]( + this.addr3.address, this.addr1.address, this.tokenIds + ) + ).to.be.revertedWith('TransferCallerNotOwnerNorApproved'); + }); + + it('self-approval is cleared on batch transfers', async function () { + for (let i = 0; i < this.tokenIds.length; i++) { + const tokenId = this.tokenIds[i]; + await this.erc721aBatchTransferable.connect(this.addr1).approve(this.addr1.address, tokenId); + expect(await this.erc721aBatchTransferable.getApproved(tokenId)).to.equal(this.addr1.address); + } + + // prettier-ignore + await this.erc721aBatchTransferable + .connect(this.addr1)[transferFn]( + this.addr1.address, this.addr2.address, this.tokenIds + ); + for (let i = 0; i < this.tokenIds.length; i++) { + const tokenId = this.tokenIds[i]; + expect(await this.erc721aBatchTransferable.getApproved(tokenId)).to.not.equal(this.addr1.address); + } + }); + + it('approval for all allows batch transfers', async function () { + await this.erc721aBatchTransferable.connect(this.addr1).setApprovalForAll(this.addr3.address, true); + + // prettier-ignore + await this.erc721aBatchTransferable + .connect(this.addr3)[transferFn]( + this.addr1.address, this.addr3.address, this.tokenIds + ); + }); + }); + }; + + context('successful transfers', function () { + context('batchTransferFrom', function (fn = 'batchTransferFrom') { + describe('to contract', function () { + testSuccessfulBatchTransfer(fn); + testUnsuccessfulBatchTransfer(fn); + testApproveBatchTransfer(fn); + }); + + describe('to EOA', function () { + testSuccessfulBatchTransfer(fn, false); + testUnsuccessfulBatchTransfer(fn, false); + testApproveBatchTransfer(fn, false); + }); + }); + context('safeBatchTransferFrom', function (fn = 'safeBatchTransferFrom(address,address,uint256[])') { + describe('to contract', function () { + testSuccessfulBatchTransfer(fn); + testUnsuccessfulBatchTransfer(fn); + testApproveBatchTransfer(fn); + }); + + describe('to EOA', function () { + testSuccessfulBatchTransfer(fn, false); + testUnsuccessfulBatchTransfer(fn, false); + testApproveBatchTransfer(fn, false); + }); + }); + + // Use to compare gas usage and verify expected behaviour with respect to normal transfers + context('batchTransferFromUnoptimized', function (fn = 'batchTransferFromUnoptimized') { + describe('to contract', function () { + testSuccessfulBatchTransfer(fn); + testUnsuccessfulBatchTransfer(fn); + testApproveBatchTransfer(fn); + }); + + describe('to EOA', function () { + testSuccessfulBatchTransfer(fn, false); + testUnsuccessfulBatchTransfer(fn, false); + testApproveBatchTransfer(fn, false); + }); + }); + }); + }); + }); + }; + +describe( + 'ERC721ABatchTransferable', + createTestSuite({ contract: 'ERC721ABatchTransferableMock', constructorArgs: ['Azuki', 'AZUKI'] }) +);