Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to set starting token id for ERC721Consecutive #4097

Merged
merged 20 commits into from
May 26, 2023
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spotty-hotels-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': patch
---

`ERC721Consecutive`: Add a `_firstConsecutiveId` internal function that can be overridden to change the id of the first token minted through `_mintConsecutive`.
9 changes: 9 additions & 0 deletions contracts/mocks/token/ERC721ConsecutiveMock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ import "../../token/ERC721/extensions/draft-ERC721Votes.sol";
* @title ERC721ConsecutiveMock
*/
contract ERC721ConsecutiveMock is ERC721Consecutive, ERC721Pausable, ERC721Votes {
uint96 private immutable _offset;

constructor(
string memory name,
string memory symbol,
uint96 offset,
address[] memory delegates,
address[] memory receivers,
uint96[] memory amounts
) ERC721(name, symbol) EIP712(name, "1") {
_offset = offset;

for (uint256 i = 0; i < delegates.length; ++i) {
_delegate(delegates[i], delegates[i]);
}
Expand All @@ -27,6 +32,10 @@ contract ERC721ConsecutiveMock is ERC721Consecutive, ERC721Pausable, ERC721Votes
}
}

function _firstConsecutiveId() internal view virtual override returns (uint96) {
return _offset;
}

function _ownerOf(uint256 tokenId) internal view virtual override(ERC721, ERC721Consecutive) returns (address) {
return super._ownerOf(tokenId);
}
Expand Down
43 changes: 28 additions & 15 deletions contracts/token/ERC721/extensions/ERC721Consecutive.sol
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import "../../../utils/structs/BitMaps.sol";
* regained after construction. During construction, only batch minting is allowed.
*
* IMPORTANT: This extension bypasses the hooks {_beforeTokenTransfer} and {_afterTokenTransfer} for tokens minted in
* batch. When using this extension, you should consider the {_beforeConsecutiveTokenTransfer} and
* {_afterConsecutiveTokenTransfer} hooks in addition to {_beforeTokenTransfer} and {_afterTokenTransfer}.
* batch. The hooks will be only called once per batch, so you should take `batchSize` parameter into consideration
* when relying on hooks.
*
* IMPORTANT: When overriding {_afterTokenTransfer}, be careful about call ordering. {ownerOf} may return invalid
* values during the {_afterTokenTransfer} execution if the super call is not called first. To be safe, execute the
Expand Down Expand Up @@ -56,7 +56,7 @@ abstract contract ERC721Consecutive is IERC2309, ERC721 {
address owner = super._ownerOf(tokenId);

// If token is owned by the core, or beyond consecutive range, return base value
if (owner != address(0) || tokenId > type(uint96).max) {
if (owner != address(0) || tokenId > type(uint96).max || tokenId < _firstConsecutiveId()) {
return owner;
}

Expand All @@ -82,7 +82,7 @@ abstract contract ERC721Consecutive is IERC2309, ERC721 {
* Emits a {IERC2309-ConsecutiveTransfer} event.
*/
function _mintConsecutive(address to, uint96 batchSize) internal virtual returns (uint96) {
uint96 first = _totalConsecutiveSupply();
uint96 next = _nextConsecutiveId();

// minting a batch of size 0 is a no-op
if (batchSize > 0) {
Expand All @@ -91,29 +91,29 @@ abstract contract ERC721Consecutive is IERC2309, ERC721 {
require(batchSize <= _maxBatchSize(), "ERC721Consecutive: batch too large");
kfishnchips marked this conversation as resolved.
Show resolved Hide resolved

// hook before
_beforeTokenTransfer(address(0), to, first, batchSize);
_beforeTokenTransfer(address(0), to, next, batchSize);

// push an ownership checkpoint & emit event
uint96 last = first + batchSize - 1;
uint96 last = next + 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);
emit ConsecutiveTransfer(next, last, address(0), to);

// hook after
_afterTokenTransfer(address(0), to, first, batchSize);
_afterTokenTransfer(address(0), to, next, batchSize);
}

return first;
return next;
}

/**
* @dev See {ERC721-_mint}. Override version that restricts normal minting to after construction.
*
* Warning: Using {ERC721Consecutive} prevents using {_mint} during construction in favor of {_mintConsecutive}.
* WARNING: Using {ERC721Consecutive} prevents using {_mint} during construction in favor of {_mintConsecutive}.
* After construction, {_mintConsecutive} is no longer available and {_mint} becomes available.
*/
function _mint(address to, uint256 tokenId) internal virtual override {
Expand All @@ -132,17 +132,30 @@ abstract contract ERC721Consecutive is IERC2309, ERC721 {
) internal virtual override {
if (
to == address(0) && // if we burn
firstTokenId < _totalConsecutiveSupply() && // and the tokenId was minted in a batch
!_sequentialBurn.get(firstTokenId) // and the token was never marked as burnt
) {
firstTokenId >= _firstConsecutiveId() &&
firstTokenId < _nextConsecutiveId() &&
!_sequentialBurn.get(firstTokenId)
) // and the token was never marked as burnt
{
require(batchSize == 1, "ERC721Consecutive: batch burn not supported");
_sequentialBurn.set(firstTokenId);
}
super._afterTokenTransfer(from, to, firstTokenId, batchSize);
}

function _totalConsecutiveSupply() private view returns (uint96) {
/**
* @dev Used to offset the first token id in {_nextConsecutiveId}
*/
function _firstConsecutiveId() internal view virtual returns (uint96) {
Amxx marked this conversation as resolved.
Show resolved Hide resolved
return 0;
}

/**
* @dev Returns the next tokenId to mint using {_mintConsecutive}. It will return {_firstConsecutiveId}
* if no consecutive tokenId has been minted before.
*/
function _nextConsecutiveId() private view returns (uint96) {
(bool exists, uint96 latestId, ) = _sequentialOwnership.latestCheckpoint();
return exists ? latestId + 1 : 0;
return exists ? latestId + 1 : _firstConsecutiveId();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think 0 to _firstConsecutiveId() should be accounted for the _totalConsecutiveSupply, it affects line 135, which will be excluding the after hook for real burned tokenIds

For example:

  1. Someone replaces _firstConsecutiveId() with a 5
  2. They consecutively mint tokenIds from 5 to 9 (5 mints)
  3. After that, someone else mints the token 2 via normal minting
  4. Then, they burn it, and the token will make it to the _sequentialBurn on the ownerOf(...) method, which is not what we want

I think this is what we should aim for:

Suggested change
return exists ? latestId + 1 : _firstConsecutiveId();
return exists ? latestId + 1 - _firstConsecutiveId() : 0;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make more sense for someone to consecutively mint token 10 instead of 2? I was thinking that there could be no minting below the first consecutive id.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would requiring that the tokenId be greater than the last batch minted one in the _mint function be a viable option? It might be too inefficient...

    function _mint(address to, uint256 tokenId) internal virtual override {
        require(Address.isContract(address(this)), "ERC721Consecutive: can't mint during construction");
        (, uint96 latestId, ) = _sequentialOwnership.latestCheckpoint();
        require(tokenId > latestId, "ERC721Consecutive: token id must be greater than latest id");
        super._mint(to, tokenId);
    }

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is something we don't want, because it adds 2 (cold) sload to the mint operation, which we want to keep as cheap as possible.

Also, if we do that, it would be impossible to _mint a token that was first minted as part of a batch then burnt.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that mint already check if the tokenId has an owner, and revert if that is the case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, I'm still wrapping my head around the internals of this but I think I got it.

}
}
90 changes: 74 additions & 16 deletions test/token/ERC721/extensions/ERC721Consecutive.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ function toSingleton(address account) pure returns (address[] memory) {
}

contract ERC721ConsecutiveTarget is StdUtils, ERC721Consecutive {
uint96 private immutable _offset;
uint256 public totalMinted = 0;

constructor(address[] memory receivers, uint256[] memory batches) ERC721("", "") {
constructor(address[] memory receivers, uint256[] memory batches, uint256 startingId) ERC721("", "") {
_offset = uint96(startingId);
for (uint256 i = 0; i < batches.length; i++) {
address receiver = receivers[i % receivers.length];
uint96 batchSize = uint96(bound(batches[i], 0, _maxBatchSize()));
Expand All @@ -28,43 +30,71 @@ contract ERC721ConsecutiveTarget is StdUtils, ERC721Consecutive {
function burn(uint256 tokenId) public {
_burn(tokenId);
}

function _firstConsecutiveId() internal view virtual override returns (uint96) {
return _offset;
}
}

contract ERC721ConsecutiveTest is Test {
function test_balance(address receiver, uint256[] calldata batches) public {
function test_balance(address receiver, uint256[] calldata batches, uint96 startingId) public {
vm.assume(receiver != address(0));

ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches);
uint256 startingTokenId = bound(startingId, 0, 5000);

ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches, startingTokenId);

assertEq(token.balanceOf(receiver), token.totalMinted());
}

function test_ownership(address receiver, uint256[] calldata batches, uint256[2] calldata unboundedTokenId) public {
function test_ownership(
address receiver,
uint256[] calldata batches,
uint256[2] calldata unboundedTokenId,
uint96 startingId
) public {
vm.assume(receiver != address(0));

ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches);
uint256 startingTokenId = bound(startingId, 0, 5000);

ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches, startingTokenId);

if (token.totalMinted() > 0) {
uint256 validTokenId = bound(unboundedTokenId[0], 0, token.totalMinted() - 1);
uint256 validTokenId = bound(
unboundedTokenId[0],
startingTokenId,
startingTokenId + token.totalMinted() - 1
);
assertEq(token.ownerOf(validTokenId), receiver);
}

uint256 invalidTokenId = bound(unboundedTokenId[1], token.totalMinted(), type(uint256).max);
uint256 invalidTokenId = bound(
unboundedTokenId[1],
startingTokenId + token.totalMinted(),
startingTokenId + token.totalMinted() + 1
);
vm.expectRevert();
token.ownerOf(invalidTokenId);
}

function test_burn(address receiver, uint256[] calldata batches, uint256 unboundedTokenId) public {
function test_burn(
address receiver,
uint256[] calldata batches,
uint256 unboundedTokenId,
uint96 startingId
) public {
vm.assume(receiver != address(0));

ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches);
uint256 startingTokenId = bound(startingId, 0, 5000);

ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches, startingTokenId);

// 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);
uint256 tokenId = bound(unboundedTokenId, startingTokenId, startingTokenId + supply - 1);
token.burn(tokenId);

// balance should have decreased
Expand All @@ -78,25 +108,28 @@ contract ERC721ConsecutiveTest is Test {
function test_transfer(
address[2] calldata accounts,
uint256[2] calldata unboundedBatches,
uint256[2] calldata unboundedTokenId
uint256[2] calldata unboundedTokenId,
uint96 startingId
) public {
vm.assume(accounts[0] != address(0));
vm.assume(accounts[1] != address(0));
vm.assume(accounts[0] != accounts[1]);

uint256 startingTokenId = bound(startingId, 1, 5000);

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);
batches[0] = bound(unboundedBatches[0], startingTokenId, 5000);
batches[1] = bound(unboundedBatches[1], startingTokenId, 5000);

ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(receivers, batches);
ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(receivers, batches, startingTokenId);

uint256 tokenId0 = bound(unboundedTokenId[0], 0, batches[0] - 1);
uint256 tokenId1 = bound(unboundedTokenId[1], 0, batches[1] - 1) + batches[0];
uint256 tokenId0 = bound(unboundedTokenId[0], startingTokenId, batches[0]);
uint256 tokenId1 = bound(unboundedTokenId[1], startingTokenId, batches[1]) + batches[0];

assertEq(token.ownerOf(tokenId0), accounts[0]);
assertEq(token.ownerOf(tokenId1), accounts[1]);
Expand All @@ -119,4 +152,29 @@ contract ERC721ConsecutiveTest is Test {
assertEq(token.balanceOf(accounts[0]), batches[0]);
assertEq(token.balanceOf(accounts[1]), batches[1]);
}

function test_start_consecutive_id(
address receiver,
uint256[2] calldata unboundedBatches,
uint256[2] calldata unboundedTokenId,
uint96 startingId
) public {
vm.assume(receiver != address(0));

uint256 startingTokenId = bound(startingId, 1, 5000);

// We assume _maxBatchSize is 5000 (the default). This test will break otherwise.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this breaking? If it's breaking because of the ownerOf range check, we definitely need to address that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment about the breaking test is from a previous commit which I still can't decipher

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test tries to mint a batch that is too large. too large means bigger than _maxBatchSize. Since the contract is not yet constructed, we assume the value is 5000. If you ever override _maxBatchSize in ERC721ConsecutiveTarget , you should make sure it is reflected here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, understood. Thanks for clarifying!

uint256[] memory batches = new uint256[](2);
batches[0] = bound(unboundedBatches[0], startingTokenId, 5000);
batches[1] = bound(unboundedBatches[1], startingTokenId, 5000);

ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches, startingTokenId);

uint256 tokenId0 = bound(unboundedTokenId[0], startingTokenId, batches[0]);
uint256 tokenId1 = bound(unboundedTokenId[1], startingTokenId, batches[1]);

assertEq(token.ownerOf(tokenId0), receiver);
assertEq(token.ownerOf(tokenId1), receiver);
assertEq(token.balanceOf(receiver), batches[0] + batches[1]);
}
}
Loading