diff --git a/contracts/ERC721A.sol b/contracts/ERC721A.sol index 089462087..1a2cd4556 100644 --- a/contracts/ERC721A.sol +++ b/contracts/ERC721A.sol @@ -61,6 +61,12 @@ contract ERC721A is IERC721A { // The mask of the lower 160 bits for addresses. uint256 private constant BITMASK_ADDRESS = (1 << 160) - 1; + + // The maximum `quantity` that can be minted with `_mintERC2309`. + // This limit is to prevent overflows on the address data entries. + // For a limit of 5000, a total of 3.689e15 calls to `_mintERC2309` + // is required to cause an overflow, which is unrealistic. + uint256 private constant MAX_MINT_ERC2309_QUANTITY_LIMIT = 5000; // The tokenId of the next token to be minted. uint256 private _currentIndex; @@ -443,6 +449,8 @@ contract ERC721A is IERC721A { * {IERC721Receiver-onERC721Received}, which is called for each safe transfer. * - `quantity` must be greater than 0. * + * See {_mint}. + * * Emits a {Transfer} event for each mint. */ function _safeMint( @@ -516,6 +524,61 @@ contract ERC721A is IERC721A { _afterTokenTransfers(address(0), to, startTokenId, quantity); } + /** + * @dev Mints `quantity` tokens and transfers them to `to`. + * + * This function is intended for efficient minting only during contract creation. + * + * It emits only one {ConsecutiveTransfer} as defined in + * [ERC2309](https://eips.ethereum.org/EIPS/eip-2309), + * instead of a sequence of {Transfer} event(s). + * + * Calling this function outside of contract creation WILL make your contract + * non-compliant with the ERC721 standard. + * For full ERC721 compliance, substituting ERC721 {Transfer} event(s) with the ERC2309 + * {ConsecutiveTransfer} event is only permissible during contract creation. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `quantity` must be greater than 0. + * + * Emits a {ConsecutiveTransfer} event. + */ + function _mintERC2309(address to, uint256 quantity) internal { + uint256 startTokenId = _currentIndex; + if (to == address(0)) revert MintToZeroAddress(); + if (quantity == 0) revert MintZeroQuantity(); + if (quantity > MAX_MINT_ERC2309_QUANTITY_LIMIT) revert MintERC2309QuantityExceedsLimit(); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are unrealistic due to the above check for `quantity` to be below the limit. + unchecked { + // Updates: + // - `balance += quantity`. + // - `numberMinted += quantity`. + // + // We can directly add to the balance and number minted. + _packedAddressData[to] += quantity * ((1 << BITPOS_NUMBER_MINTED) | 1); + + // Updates: + // - `address` to the owner. + // - `startTimestamp` to the timestamp of minting. + // - `burned` to `false`. + // - `nextInitialized` to `quantity == 1`. + _packedOwnerships[startTokenId] = _packOwnershipData( + to, + (_boolToUint256(quantity == 1) << BITPOS_NEXT_INITIALIZED) | _nextExtraData(address(0), to, 0) + ); + + emit ConsecutiveTransfer(startTokenId, startTokenId + quantity - 1, address(0), to); + + _currentIndex = startTokenId + quantity; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + /** * @dev Transfers `tokenId` from `from` to `to`. * diff --git a/contracts/IERC721A.sol b/contracts/IERC721A.sol index 5f59d90b3..098984d73 100644 --- a/contracts/IERC721A.sol +++ b/contracts/IERC721A.sol @@ -68,6 +68,11 @@ interface IERC721A { */ error URIQueryForNonexistentToken(); + /** + * The `quantity` minted with ERC2309 exceeds the safety limit. + */ + error MintERC2309QuantityExceedsLimit(); + struct TokenOwnership { // The address of the owner. address addr; @@ -254,4 +259,14 @@ interface IERC721A { * @dev Returns the Uniform Resource Identifier (URI) for `tokenId` token. */ function tokenURI(uint256 tokenId) external view returns (string memory); + + // ============================== + // IERC2309 + // ============================== + + /** + * @dev Emitted when tokens in `fromTokenId` to `toTokenId` (inclusive) is transferred from `from` to `to`, + * as defined in the ERC2309 standard. See `_mintERC2309` for more details. + */ + event ConsecutiveTransfer(uint256 indexed fromTokenId, uint256 toTokenId, address indexed from, address indexed to); } diff --git a/contracts/mocks/ERC721AWithERC2309Mock.sol b/contracts/mocks/ERC721AWithERC2309Mock.sol new file mode 100644 index 000000000..802fd9915 --- /dev/null +++ b/contracts/mocks/ERC721AWithERC2309Mock.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.0.0 +// Creators: Chiru Labs + +pragma solidity ^0.8.4; + +import '../ERC721A.sol'; + +contract ERC721AWithERC2309Mock is ERC721A { + constructor( + string memory name_, + string memory symbol_, + address to, + uint256 quantity, + bool mintInConstructor + ) ERC721A(name_, symbol_) { + if (mintInConstructor) { + _mintERC2309(to, quantity); + } + } + + /** + * @dev This function is only for gas comparison purposes. + * Calling `_mintERC3201` outside of contract creation is non-compliant + * with the ERC721 standard. + */ + function mintOneERC2309(address to) public { + _mintERC2309(to, 1); + } + + /** + * @dev This function is only for gas comparison purposes. + * Calling `_mintERC3201` outside of contract creation is non-compliant + * with the ERC721 standard. + */ + function mintTenERC2309(address to) public { + _mintERC2309(to, 10); + } +} diff --git a/test/ERC721A.test.js b/test/ERC721A.test.js index ae36ab435..303c6bc6a 100644 --- a/test/ERC721A.test.js +++ b/test/ERC721A.test.js @@ -680,6 +680,51 @@ describe( createTestSuite({ contract: 'ERC721AStartTokenIdMock', constructorArgs: ['Azuki', 'AZUKI', 1] }) ); +describe('ERC721A with ERC2309', async function () { + beforeEach(async function () { + const [owner, addr1] = await ethers.getSigners(); + this.owner = owner; + this.addr1 = addr1; + + let args; + args = ['Azuki', 'AZUKI', this.owner.address, 1, true]; + this.erc721aMint1 = await deployContract('ERC721AWithERC2309Mock', args); + args = ['Azuki', 'AZUKI', this.owner.address, 10, true]; + this.erc721aMint10 = await deployContract('ERC721AWithERC2309Mock', args); + }); + + it('emits a ConsecutiveTransfer event for single mint', async function () { + expect(this.erc721aMint1.deployTransaction) + .to.emit(this.erc721aMint1, 'ConsecutiveTransfer') + .withArgs(0, 0, ZERO_ADDRESS, this.owner.address); + }); + + it('emits a ConsecutiveTransfer event for a batch mint', async function () { + expect(this.erc721aMint10.deployTransaction) + .to.emit(this.erc721aMint10, 'ConsecutiveTransfer') + .withArgs(0, 9, ZERO_ADDRESS, this.owner.address); + }); + + it('requires quantity to be below mint limit', async function () { + let args; + const mintLimit = 5000; + args = ['Azuki', 'AZUKI', this.owner.address, mintLimit, true]; + await deployContract('ERC721AWithERC2309Mock', args); + args = ['Azuki', 'AZUKI', this.owner.address, mintLimit + 1, true]; + await expect(deployContract('ERC721AWithERC2309Mock', args)).to.be.revertedWith('MintERC2309QuantityExceedsLimit'); + }) + + it('rejects mints to the zero address', async function () { + let args = ['Azuki', 'AZUKI', ZERO_ADDRESS, 1, true]; + await expect(deployContract('ERC721AWithERC2309Mock', args)).to.be.revertedWith('MintToZeroAddress'); + }); + + it('requires quantity to be greater than 0', async function () { + let args = ['Azuki', 'AZUKI', this.owner.address, 0, true]; + await expect(deployContract('ERC721AWithERC2309Mock', args)).to.be.revertedWith('MintZeroQuantity'); + }); +}); + describe( 'ERC721A override _extraData()', createTestSuite({ contract: 'ERC721ATransferCounterMock', constructorArgs: ['Azuki', 'AZUKI'] }) diff --git a/test/GasUsage.test.js b/test/GasUsage.test.js index 50eeda415..380ff9583 100644 --- a/test/GasUsage.test.js +++ b/test/GasUsage.test.js @@ -68,4 +68,26 @@ describe('ERC721A Gas Usage', function () { await this.erc721a.connect(this.owner).transferTenAvg(this.addr1.address); }); }); + + it('mintOneERC2309', async function () { + // The following call `_mintERC3201` outside of contract creation. + // This is non-compliant with the ERC721 standard, + // and is only meant for gas comparisons. + let args = ['Azuki', 'AZUKI', this.owner.address, 0, false]; + let contract = await deployContract('ERC721AWithERC2309Mock', args); + await contract.mintOneERC2309(this.owner.address); + await contract.mintOneERC2309(this.owner.address); + await contract.mintOneERC2309(this.addr1.address); + }); + + it('mintTenERC2309', async function () { + // The following call `_mintERC3201` outside of contract creation. + // This is non-compliant with the ERC721 standard, + // and is only meant for gas comparisons. + let args = ['Azuki', 'AZUKI', this.owner.address, 0, false]; + let contract = await deployContract('ERC721AWithERC2309Mock', args); + await contract.mintTenERC2309(this.owner.address); + await contract.mintTenERC2309(this.owner.address); + await contract.mintTenERC2309(this.addr1.address); + }); });