github-actions 2 жил өмнө
parent
commit
7f6c62a2b9

+ 8 - 0
CHANGELOG.md

@@ -1,5 +1,13 @@
 # Changelog
 
+## 4.8.2
+
+- `ERC721Consecutive`: Fixed a bug when `_mintConsecutive` is used for batches of size 1 that could lead to balance overflow. Refer to the breaking changes section in the changelog for a note on the behavior of `ERC721._beforeTokenTransfer`.
+
+### Breaking changes
+
+- `ERC721`: The internal function `_beforeTokenTransfer` no longer updates balances, which it previously did when `batchSize` was greater than 1. This change has no consequence unless a custom ERC721 extension is explicitly invoking `_beforeTokenTransfer`. Balance updates in extensions must now be done explicitly using `__unsafe_increaseBalance`, with a name that indicates that there is an invariant that has to be manually verified.
+
 ## 4.8.1 (2023-01-13)
 
  * `ERC4626`: Use staticcall instead of call when fetching underlying ERC-20 decimals. ([#3943](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3943))

+ 14 - 11
contracts/token/ERC721/ERC721.sol

@@ -467,18 +467,9 @@ contract ERC721 is Context, ERC165, IERC721, IERC721Metadata {
     function _beforeTokenTransfer(
         address from,
         address to,
-        uint256, /* firstTokenId */
+        uint256 firstTokenId,
         uint256 batchSize
-    ) internal virtual {
-        if (batchSize > 1) {
-            if (from != address(0)) {
-                _balances[from] -= batchSize;
-            }
-            if (to != address(0)) {
-                _balances[to] += batchSize;
-            }
-        }
-    }
+    ) internal virtual {}
 
     /**
      * @dev Hook that is called after any token transfer. This includes minting and burning. If {ERC721Consecutive} is
@@ -500,4 +491,16 @@ contract ERC721 is Context, ERC165, IERC721, IERC721Metadata {
         uint256 firstTokenId,
         uint256 batchSize
     ) internal virtual {}
+
+    /**
+     * @dev Unsafe write access to the balances, used by extensions that "mint" tokens using an {ownerOf} override.
+     *
+     * WARNING: Anyone calling this MUST ensure that the balances remain consistent with the ownership. The invariant
+     * being that for any address `a` the value returned by `balanceOf(a)` must be equal to the number of tokens such
+     * that `ownerOf(tokenId)` is `a`.
+     */
+    // solhint-disable-next-line func-name-mixedcase
+    function __unsafe_increaseBalance(address account, uint256 amount) internal {
+        _balances[account] += amount;
+    }
 }

+ 5 - 0
contracts/token/ERC721/extensions/ERC721Consecutive.sol

@@ -96,6 +96,11 @@ abstract contract ERC721Consecutive is IERC2309, ERC721 {
             // push an ownership checkpoint & emit event
             uint96 last = first + batchSize - 1;
             _sequentialOwnership.push(last, uint160(to));
+
+            // The invariant required by this function is preserved because the new sequentialOwnership checkpoint
+            // is attributing ownership of `batchSize` new tokens to account `to`.
+            __unsafe_increaseBalance(to, batchSize);
+
             emit ConsecutiveTransfer(first, last, address(0), to);
 
             // hook after

+ 36 - 7
docs/modules/api/pages/token/ERC721.adoc

@@ -90,6 +90,7 @@
 :xref-ERC721-_requireMinted-uint256-: xref:token/ERC721.adoc#ERC721-_requireMinted-uint256-
 :xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_beforeTokenTransfer-address-address-uint256-uint256-
 :xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_afterTokenTransfer-address-address-uint256-uint256-
+:xref-ERC721-__unsafe_increaseBalance-address-uint256-: xref:token/ERC721.adoc#ERC721-__unsafe_increaseBalance-address-uint256-
 :xref-IERC721-Transfer-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Transfer-address-address-uint256-
 :xref-IERC721-Approval-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Approval-address-address-uint256-
 :xref-IERC721-ApprovalForAll-address-address-bool-: xref:token/ERC721.adoc#IERC721-ApprovalForAll-address-address-bool-
@@ -144,6 +145,7 @@
 :xref-ERC721-_setApprovalForAll-address-address-bool-: xref:token/ERC721.adoc#ERC721-_setApprovalForAll-address-address-bool-
 :xref-ERC721-_requireMinted-uint256-: xref:token/ERC721.adoc#ERC721-_requireMinted-uint256-
 :xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_afterTokenTransfer-address-address-uint256-uint256-
+:xref-ERC721-__unsafe_increaseBalance-address-uint256-: xref:token/ERC721.adoc#ERC721-__unsafe_increaseBalance-address-uint256-
 :xref-IERC721-Transfer-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Transfer-address-address-uint256-
 :xref-IERC721-Approval-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Approval-address-address-uint256-
 :xref-IERC721-ApprovalForAll-address-address-bool-: xref:token/ERC721.adoc#IERC721-ApprovalForAll-address-address-bool-
@@ -192,6 +194,7 @@
 :xref-ERC721-_setApprovalForAll-address-address-bool-: xref:token/ERC721.adoc#ERC721-_setApprovalForAll-address-address-bool-
 :xref-ERC721-_requireMinted-uint256-: xref:token/ERC721.adoc#ERC721-_requireMinted-uint256-
 :xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_afterTokenTransfer-address-address-uint256-uint256-
+:xref-ERC721-__unsafe_increaseBalance-address-uint256-: xref:token/ERC721.adoc#ERC721-__unsafe_increaseBalance-address-uint256-
 :xref-Pausable-Paused-address-: xref:security.adoc#Pausable-Paused-address-
 :xref-Pausable-Unpaused-address-: xref:security.adoc#Pausable-Unpaused-address-
 :xref-IERC721-Transfer-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Transfer-address-address-uint256-
@@ -227,6 +230,7 @@
 :xref-ERC721-_requireMinted-uint256-: xref:token/ERC721.adoc#ERC721-_requireMinted-uint256-
 :xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_beforeTokenTransfer-address-address-uint256-uint256-
 :xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_afterTokenTransfer-address-address-uint256-uint256-
+:xref-ERC721-__unsafe_increaseBalance-address-uint256-: xref:token/ERC721.adoc#ERC721-__unsafe_increaseBalance-address-uint256-
 :xref-IERC721-Transfer-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Transfer-address-address-uint256-
 :xref-IERC721-Approval-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Approval-address-address-uint256-
 :xref-IERC721-ApprovalForAll-address-address-bool-: xref:token/ERC721.adoc#IERC721-ApprovalForAll-address-address-bool-
@@ -261,6 +265,7 @@
 :xref-ERC721-_setApprovalForAll-address-address-bool-: xref:token/ERC721.adoc#ERC721-_setApprovalForAll-address-address-bool-
 :xref-ERC721-_requireMinted-uint256-: xref:token/ERC721.adoc#ERC721-_requireMinted-uint256-
 :xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_beforeTokenTransfer-address-address-uint256-uint256-
+:xref-ERC721-__unsafe_increaseBalance-address-uint256-: xref:token/ERC721.adoc#ERC721-__unsafe_increaseBalance-address-uint256-
 :xref-IERC721-Transfer-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Transfer-address-address-uint256-
 :xref-IERC721-Approval-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Approval-address-address-uint256-
 :xref-IERC721-ApprovalForAll-address-address-bool-: xref:token/ERC721.adoc#IERC721-ApprovalForAll-address-address-bool-
@@ -299,6 +304,7 @@
 :xref-ERC721-_requireMinted-uint256-: xref:token/ERC721.adoc#ERC721-_requireMinted-uint256-
 :xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_beforeTokenTransfer-address-address-uint256-uint256-
 :xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_afterTokenTransfer-address-address-uint256-uint256-
+:xref-ERC721-__unsafe_increaseBalance-address-uint256-: xref:token/ERC721.adoc#ERC721-__unsafe_increaseBalance-address-uint256-
 :xref-IERC721-Transfer-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Transfer-address-address-uint256-
 :xref-IERC721-Approval-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Approval-address-address-uint256-
 :xref-IERC721-ApprovalForAll-address-address-bool-: xref:token/ERC721.adoc#IERC721-ApprovalForAll-address-address-bool-
@@ -348,6 +354,7 @@
 :xref-ERC721-_setApprovalForAll-address-address-bool-: xref:token/ERC721.adoc#ERC721-_setApprovalForAll-address-address-bool-
 :xref-ERC721-_requireMinted-uint256-: xref:token/ERC721.adoc#ERC721-_requireMinted-uint256-
 :xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_beforeTokenTransfer-address-address-uint256-uint256-
+:xref-ERC721-__unsafe_increaseBalance-address-uint256-: xref:token/ERC721.adoc#ERC721-__unsafe_increaseBalance-address-uint256-
 :xref-IERC721-Transfer-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Transfer-address-address-uint256-
 :xref-IERC721-Approval-address-address-uint256-: xref:token/ERC721.adoc#IERC721-Approval-address-address-uint256-
 :xref-IERC721-ApprovalForAll-address-address-bool-: xref:token/ERC721.adoc#IERC721-ApprovalForAll-address-address-bool-
@@ -385,6 +392,7 @@
 :xref-ERC721-_requireMinted-uint256-: xref:token/ERC721.adoc#ERC721-_requireMinted-uint256-
 :xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_beforeTokenTransfer-address-address-uint256-uint256-
 :xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_afterTokenTransfer-address-address-uint256-uint256-
+:xref-ERC721-__unsafe_increaseBalance-address-uint256-: xref:token/ERC721.adoc#ERC721-__unsafe_increaseBalance-address-uint256-
 :xref-ERC2981-royaltyInfo-uint256-uint256-: xref:token/common.adoc#ERC2981-royaltyInfo-uint256-uint256-
 :xref-ERC2981-_feeDenominator--: xref:token/common.adoc#ERC2981-_feeDenominator--
 :xref-ERC2981-_setDefaultRoyalty-address-uint96-: xref:token/common.adoc#ERC2981-_setDefaultRoyalty-address-uint96-
@@ -439,6 +447,7 @@
 :xref-ERC721-_setApprovalForAll-address-address-bool-: xref:token/ERC721.adoc#ERC721-_setApprovalForAll-address-address-bool-
 :xref-ERC721-_requireMinted-uint256-: xref:token/ERC721.adoc#ERC721-_requireMinted-uint256-
 :xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-: xref:token/ERC721.adoc#ERC721-_afterTokenTransfer-address-address-uint256-uint256-
+:xref-ERC721-__unsafe_increaseBalance-address-uint256-: xref:token/ERC721.adoc#ERC721-__unsafe_increaseBalance-address-uint256-
 :xref-AccessControlEnumerable-getRoleMember-bytes32-uint256-: xref:access.adoc#AccessControlEnumerable-getRoleMember-bytes32-uint256-
 :xref-AccessControlEnumerable-getRoleMemberCount-bytes32-: xref:access.adoc#AccessControlEnumerable-getRoleMemberCount-bytes32-
 :xref-AccessControlEnumerable-_grantRole-bytes32-address-: xref:access.adoc#AccessControlEnumerable-_grantRole-bytes32-address-
@@ -881,6 +890,7 @@ Use along with {totalSupply} to enumerate all tokens.
 :_checkOnERC721Received: pass:normal[xref:#ERC721-_checkOnERC721Received-address-address-uint256-bytes-[`++_checkOnERC721Received++`]]
 :_beforeTokenTransfer: pass:normal[xref:#ERC721-_beforeTokenTransfer-address-address-uint256-uint256-[`++_beforeTokenTransfer++`]]
 :_afterTokenTransfer: pass:normal[xref:#ERC721-_afterTokenTransfer-address-address-uint256-uint256-[`++_afterTokenTransfer++`]]
+:__unsafe_increaseBalance: pass:normal[xref:#ERC721-__unsafe_increaseBalance-address-uint256-[`++__unsafe_increaseBalance++`]]
 
 [.contract]
 [[ERC721]]
@@ -925,8 +935,9 @@ the Metadata extension, but not including the Enumerable extension, which is ava
 * {xref-ERC721-_approve-address-uint256-}[`++_approve(to, tokenId)++`]
 * {xref-ERC721-_setApprovalForAll-address-address-bool-}[`++_setApprovalForAll(owner, operator, approved)++`]
 * {xref-ERC721-_requireMinted-uint256-}[`++_requireMinted(tokenId)++`]
-* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, , batchSize)++`]
+* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, firstTokenId, batchSize)++`]
 * {xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-}[`++_afterTokenTransfer(from, to, firstTokenId, batchSize)++`]
+* {xref-ERC721-__unsafe_increaseBalance-address-uint256-}[`++__unsafe_increaseBalance(account, amount)++`]
 
 [.contract-subindex-inherited]
 .IERC721Metadata
@@ -1190,7 +1201,7 @@ Reverts if the `tokenId` has not been minted yet.
 
 [.contract-item]
 [[ERC721-_beforeTokenTransfer-address-address-uint256-uint256-]]
-==== `[.contract-item-name]#++_beforeTokenTransfer++#++(address from, address to, uint256, uint256 batchSize)++` [.item-kind]#internal#
+==== `[.contract-item-name]#++_beforeTokenTransfer++#++(address from, address to, uint256 firstTokenId, uint256 batchSize)++` [.item-kind]#internal#
 
 Hook that is called before any token transfer. This includes minting and burning. If {ERC721Consecutive} is
 used, the hook may be called as part of a consecutive (batch) mint, as indicated by `batchSize` greater than 1.
@@ -1222,6 +1233,16 @@ Calling conditions:
 
 To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks].
 
+[.contract-item]
+[[ERC721-__unsafe_increaseBalance-address-uint256-]]
+==== `[.contract-item-name]#++__unsafe_increaseBalance++#++(address account, uint256 amount)++` [.item-kind]#internal#
+
+Unsafe write access to the balances, used by extensions that "mint" tokens using an {ownerOf} override.
+
+WARNING: Anyone calling this MUST ensure that the balances remain consistent with the ownership. The invariant
+being that for any address `a` the value returned by `balanceOf(a)` must be equal to the number of tokens such
+that `ownerOf(tokenId)` is `a`.
+
 :_ownedTokens: pass:normal[xref:#ERC721Enumerable-_ownedTokens-mapping-address----mapping-uint256----uint256--[`++_ownedTokens++`]]
 :_ownedTokensIndex: pass:normal[xref:#ERC721Enumerable-_ownedTokensIndex-mapping-uint256----uint256-[`++_ownedTokensIndex++`]]
 :_allTokens: pass:normal[xref:#ERC721Enumerable-_allTokens-uint256--[`++_allTokens++`]]
@@ -1289,6 +1310,7 @@ account.
 * {xref-ERC721-_setApprovalForAll-address-address-bool-}[`++_setApprovalForAll(owner, operator, approved)++`]
 * {xref-ERC721-_requireMinted-uint256-}[`++_requireMinted(tokenId)++`]
 * {xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-}[`++_afterTokenTransfer(from, to, firstTokenId, batchSize)++`]
+* {xref-ERC721-__unsafe_increaseBalance-address-uint256-}[`++__unsafe_increaseBalance(account, amount)++`]
 
 [.contract-subindex-inherited]
 .IERC721Metadata
@@ -1461,6 +1483,7 @@ make the contract unpausable.
 * {xref-ERC721-_setApprovalForAll-address-address-bool-}[`++_setApprovalForAll(owner, operator, approved)++`]
 * {xref-ERC721-_requireMinted-uint256-}[`++_requireMinted(tokenId)++`]
 * {xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-}[`++_afterTokenTransfer(from, to, firstTokenId, batchSize)++`]
+* {xref-ERC721-__unsafe_increaseBalance-address-uint256-}[`++__unsafe_increaseBalance(account, amount)++`]
 
 [.contract-subindex-inherited]
 .IERC721Metadata
@@ -1561,8 +1584,9 @@ ERC721 Token that can be burned (destroyed).
 * {xref-ERC721-_approve-address-uint256-}[`++_approve(to, tokenId)++`]
 * {xref-ERC721-_setApprovalForAll-address-address-bool-}[`++_setApprovalForAll(owner, operator, approved)++`]
 * {xref-ERC721-_requireMinted-uint256-}[`++_requireMinted(tokenId)++`]
-* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, , batchSize)++`]
+* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, firstTokenId, batchSize)++`]
 * {xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-}[`++_afterTokenTransfer(from, to, firstTokenId, batchSize)++`]
+* {xref-ERC721-__unsafe_increaseBalance-address-uint256-}[`++__unsafe_increaseBalance(account, amount)++`]
 
 [.contract-subindex-inherited]
 .IERC721Metadata
@@ -1685,7 +1709,8 @@ _Available since v4.8._
 * {xref-ERC721-_approve-address-uint256-}[`++_approve(to, tokenId)++`]
 * {xref-ERC721-_setApprovalForAll-address-address-bool-}[`++_setApprovalForAll(owner, operator, approved)++`]
 * {xref-ERC721-_requireMinted-uint256-}[`++_requireMinted(tokenId)++`]
-* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, , batchSize)++`]
+* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, firstTokenId, batchSize)++`]
+* {xref-ERC721-__unsafe_increaseBalance-address-uint256-}[`++__unsafe_increaseBalance(account, amount)++`]
 
 [.contract-subindex-inherited]
 .IERC721Metadata
@@ -1833,8 +1858,9 @@ ERC721 token with storage based token URI management.
 * {xref-ERC721-_approve-address-uint256-}[`++_approve(to, tokenId)++`]
 * {xref-ERC721-_setApprovalForAll-address-address-bool-}[`++_setApprovalForAll(owner, operator, approved)++`]
 * {xref-ERC721-_requireMinted-uint256-}[`++_requireMinted(tokenId)++`]
-* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, , batchSize)++`]
+* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, firstTokenId, batchSize)++`]
 * {xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-}[`++_afterTokenTransfer(from, to, firstTokenId, batchSize)++`]
+* {xref-ERC721-__unsafe_increaseBalance-address-uint256-}[`++__unsafe_increaseBalance(account, amount)++`]
 
 [.contract-subindex-inherited]
 .IERC721Metadata
@@ -1973,7 +1999,8 @@ _Available since v4.5._
 * {xref-ERC721-_approve-address-uint256-}[`++_approve(to, tokenId)++`]
 * {xref-ERC721-_setApprovalForAll-address-address-bool-}[`++_setApprovalForAll(owner, operator, approved)++`]
 * {xref-ERC721-_requireMinted-uint256-}[`++_requireMinted(tokenId)++`]
-* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, , batchSize)++`]
+* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, firstTokenId, batchSize)++`]
+* {xref-ERC721-__unsafe_increaseBalance-address-uint256-}[`++__unsafe_increaseBalance(account, amount)++`]
 
 [.contract-subindex-inherited]
 .IERC721Metadata
@@ -2097,8 +2124,9 @@ _Available since v4.5._
 * {xref-ERC721-_approve-address-uint256-}[`++_approve(to, tokenId)++`]
 * {xref-ERC721-_setApprovalForAll-address-address-bool-}[`++_setApprovalForAll(owner, operator, approved)++`]
 * {xref-ERC721-_requireMinted-uint256-}[`++_requireMinted(tokenId)++`]
-* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, , batchSize)++`]
+* {xref-ERC721-_beforeTokenTransfer-address-address-uint256-uint256-}[`++_beforeTokenTransfer(from, to, firstTokenId, batchSize)++`]
 * {xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-}[`++_afterTokenTransfer(from, to, firstTokenId, batchSize)++`]
+* {xref-ERC721-__unsafe_increaseBalance-address-uint256-}[`++__unsafe_increaseBalance(account, amount)++`]
 
 [.contract-subindex-inherited]
 .IERC721Metadata
@@ -2271,6 +2299,7 @@ _Deprecated in favor of https://wizard.openzeppelin.com/[Contracts Wizard]._
 * {xref-ERC721-_setApprovalForAll-address-address-bool-}[`++_setApprovalForAll(owner, operator, approved)++`]
 * {xref-ERC721-_requireMinted-uint256-}[`++_requireMinted(tokenId)++`]
 * {xref-ERC721-_afterTokenTransfer-address-address-uint256-uint256-}[`++_afterTokenTransfer(from, to, firstTokenId, batchSize)++`]
+* {xref-ERC721-__unsafe_increaseBalance-address-uint256-}[`++__unsafe_increaseBalance(account, amount)++`]
 
 [.contract-subindex-inherited]
 .IERC721Metadata

+ 120 - 0
test/token/ERC721/extensions/ERC721Consecutive.t.sol

@@ -0,0 +1,120 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+import "../../../../contracts/token/ERC721/extensions/ERC721Consecutive.sol";
+import "forge-std/Test.sol";
+
+function toSingleton(address account) pure returns (address[] memory) {
+    address[] memory accounts = new address[](1);
+    accounts[0] = account;
+    return accounts;
+}
+
+contract ERC721ConsecutiveTarget is StdUtils, ERC721Consecutive {
+    uint256 public totalMinted = 0;
+
+    constructor(address[] memory receivers, uint256[] memory batches) ERC721("", "") {
+        for (uint256 i = 0; i < batches.length; i++) {
+            address receiver = receivers[i % receivers.length];
+            uint96 batchSize = uint96(bound(batches[i], 0, _maxBatchSize()));
+            _mintConsecutive(receiver, batchSize);
+            totalMinted += batchSize;
+        }
+    }
+
+    function burn(uint256 tokenId) public {
+        _burn(tokenId);
+    }
+}
+
+contract ERC721ConsecutiveTest is Test {
+    function test_balance(address receiver, uint256[] calldata batches) public {
+        vm.assume(receiver != address(0));
+
+        ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches);
+
+        assertEq(token.balanceOf(receiver), token.totalMinted());
+    }
+
+    function test_ownership(address receiver, uint256[] calldata batches, uint256[2] calldata unboundedTokenId) public {
+        vm.assume(receiver != address(0));
+
+        ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches);
+
+        if (token.totalMinted() > 0) {
+            uint256 validTokenId = bound(unboundedTokenId[0], 0, token.totalMinted() - 1);
+            assertEq(token.ownerOf(validTokenId), receiver);
+        }
+
+        uint256 invalidTokenId = bound(unboundedTokenId[1], token.totalMinted(), type(uint256).max);
+        vm.expectRevert();
+        token.ownerOf(invalidTokenId);
+    }
+
+    function test_burn(address receiver, uint256[] calldata batches, uint256 unboundedTokenId) public {
+        vm.assume(receiver != address(0));
+
+        ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches);
+
+        // only test if we minted at least one token
+        uint256 supply = token.totalMinted();
+        vm.assume(supply > 0);
+
+        // burn a token in [0; supply[
+        uint256 tokenId = bound(unboundedTokenId, 0, supply - 1);
+        token.burn(tokenId);
+
+        // balance should have decreased
+        assertEq(token.balanceOf(receiver), supply - 1);
+
+        // token should be burnt
+        vm.expectRevert();
+        token.ownerOf(tokenId);
+    }
+
+    function test_transfer(
+        address[2] calldata accounts,
+        uint256[2] calldata unboundedBatches,
+        uint256[2] calldata unboundedTokenId
+    ) public {
+        vm.assume(accounts[0] != address(0));
+        vm.assume(accounts[1] != address(0));
+        vm.assume(accounts[0] != accounts[1]);
+
+        address[] memory receivers = new address[](2);
+        receivers[0] = accounts[0];
+        receivers[1] = accounts[1];
+
+        // We assume _maxBatchSize is 5000 (the default). This test will break otherwise.
+        uint256[] memory batches = new uint256[](2);
+        batches[0] = bound(unboundedBatches[0], 1, 5000);
+        batches[1] = bound(unboundedBatches[1], 1, 5000);
+
+        ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(receivers, batches);
+
+        uint256 tokenId0 = bound(unboundedTokenId[0], 0, batches[0] - 1);
+        uint256 tokenId1 = bound(unboundedTokenId[1], 0, batches[1] - 1) + batches[0];
+
+        assertEq(token.ownerOf(tokenId0), accounts[0]);
+        assertEq(token.ownerOf(tokenId1), accounts[1]);
+        assertEq(token.balanceOf(accounts[0]), batches[0]);
+        assertEq(token.balanceOf(accounts[1]), batches[1]);
+
+        vm.prank(accounts[0]);
+        token.transferFrom(accounts[0], accounts[1], tokenId0);
+
+        assertEq(token.ownerOf(tokenId0), accounts[1]);
+        assertEq(token.ownerOf(tokenId1), accounts[1]);
+        assertEq(token.balanceOf(accounts[0]), batches[0] - 1);
+        assertEq(token.balanceOf(accounts[1]), batches[1] + 1);
+
+        vm.prank(accounts[1]);
+        token.transferFrom(accounts[1], accounts[0], tokenId1);
+
+        assertEq(token.ownerOf(tokenId0), accounts[1]);
+        assertEq(token.ownerOf(tokenId1), accounts[0]);
+        assertEq(token.balanceOf(accounts[0]), batches[0]);
+        assertEq(token.balanceOf(accounts[1]), batches[1]);
+    }
+}

+ 2 - 1
test/token/ERC721/extensions/ERC721Consecutive.test.js

@@ -12,7 +12,8 @@ contract('ERC721Consecutive', function (accounts) {
   const symbol = 'NFT';
   const batches = [
     { receiver: user1, amount: 0 },
-    { receiver: user1, amount: 3 },
+    { receiver: user1, amount: 1 },
+    { receiver: user1, amount: 2 },
     { receiver: user2, amount: 5 },
     { receiver: user3, amount: 0 },
     { receiver: user1, amount: 7 },