-
Notifications
You must be signed in to change notification settings - Fork 11.8k
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
Add EnumerableMap, refactor ERC721 #2160
Conversation
Note also that this refactor could also include the approval-related mappings if we extended EnumerableMap/Set to generic structs as opposed to limiting it to 32-byte data chunks. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very nice!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some thoughts on documentation. Should be applied to the other overloads.
I think this is good to go. The only thing pending is deciding whether we want |
Any news with respect to |
It can be done using inline assembly, but it triggers tons of shadowing warnings due to assembly keywords that clash with function names like |
Ugh, why are you merging everything into ERC721.sol? I have contracts that intentionally use different (e.g. lighter-gas-cost variants) of ERC721Enumerable and ERC721Metadata and with this refactoring I cannot base them on OpenZeppelin's ERC721 implementation any more but need to do a full copy of ERC721.sol with that changes only implementations specific to the enumerable and metadata inferfaces :( |
FWIW, the previous variant of what I'm doing (and would like to do again with OpenZeppelin 3.0) is at https://etherscan.io/address/0x7e789E2dd1340971De0A9bca35b14AC0939Aa330#code in the ERC721EnumerableSimple contract (starting line 534) and the tokenURI() function in line 921 and following, for which I meanwhile created my own variant of the ERC721Metadata contract in newer (as yet unpublished) work. When you create a large number of NFTs (e.g. 150k as we did for Crypto stamp Edition 1), you want minting cost to be as small as possible... |
That's very interesting @KaiRo-at, thanks a lot for your feedback! We intend to support that use case for If I understand your code correctly, what you're basically doing is:
These two together mean that there is no need for a tokenIndex -> tokenId mapping, since the ids and indexes match. You do still need the id -> owner mapping ( Now, there's multiple reasons why we decided to merge all ERC721 flavors: to reduce library complexity, make the code easier to read (e.g. by using I'm not sure what the best way to proceed regarding this is. On one hand, the requirements for this optimization are rather high (the ability to mint arbitrary |
Interesting to hear about #1745 - that would be great (esp. as the current solution with the _baseURI pattern results in rather ugly URLs and what OpenSea and us have done looks nicer in terms of URLs). For the rest, you understood correctly, we save 2 stores on empty slots on minting as those are a series of collectibles that have sequential IDs anyhow (and cannot be burned by design, which becomes a requirement with this optimization). Even those 2 stores sum up quite a bit if you mint 150k NFTs up-front like we did for Crypto Stamp Edition 1 (and we will do a similar thing again). That said, arbitrary token IDs of course need to be possible for other tokens, we have use cases ourselves where we need that (and have been using the default enumerable implementation). In Solidity 0.5 days, I would have just gone and overridden the affected functions and be done (see the Unfortunately, the merging of contracts for simplifying the OpenZeppelin library (as nice and clean it looks by itself) exacerbates that problem as it requires me to copy and replace the whole ERC-721 implementation instead of only parts of it. The only thing that could potentially save things somewhat is to mark almost everything virtual so people can override whatever they want, but of course that also makes it much easier for people to shoot themselves in the foot by forgetting to override something that is involved as well. |
Note that we have indeed decided to mark all non-view functions virtual so it will be possible to override them. See the forum post. I'm not sure if we should include burn by default though, given that in ERC20 the public burn function is a separate extension. |
@frangio, it's enough to have On the gas-saving stuff, I see that with the |
Sadly no, libraries don't work like that :/ That's an interesting idea though - I'd bring this up with the Solidity team and see what they think about it, they've been looking at their mechanisms for code reuse recently. |
So, I just ran our code through a test, and comparing numbers for a version with my |
And with just changing --- openzeppelin-contracts/utils/EnumerableMap.sol 1985-10-26 09:15:00.000000000 +0100
+++ contracts/EnumerableMapSimple.sol 2020-04-14 18:07:43.454981171 +0200
@@ -1,6 +1,6 @@
pragma solidity ^0.6.0;
-library EnumerableMap {
+library EnumerableMapSimple {
// To implement this library for multiple types with as little code
// repetition as possible, we write it in terms of a generic Map type with
// bytes32 keys and values.
@@ -10,18 +10,9 @@
// This means that we can only create new EnumerableMaps for types that fit
// in bytes32.
- struct MapEntry {
- bytes32 _key;
- bytes32 _value;
- }
-
struct Map {
// Storage of map keys and values
- MapEntry[] _entries;
-
- // Position of the entry defined by a key in the `entries` array, plus 1
- // because index 0 means a key is not in the map.
- mapping (bytes32 => uint256) _indexes;
+ bytes32[] _entries;
}
/**
@@ -32,17 +23,14 @@
* already present.
*/
function _set(Map storage map, bytes32 key, bytes32 value) private returns (bool) {
- // We read and store the key's index to prevent multiple reads from the same storage slot
- uint256 keyIndex = map._indexes[key];
+ uint256 uintKey = uint256(key);
+ require(uintKey <= map._entries.length, "Cannot add entry that is not connected to existing IDs");
- if (keyIndex == 0) { // Equivalent to !contains(map, key)
- map._entries.push(MapEntry({ _key: key, _value: value }));
- // The entry is stored at length-1, but we add 1 to all indexes
- // and use 0 as a sentinel value
- map._indexes[key] = map._entries.length;
+ if (uintKey == map._entries.length) { // add new entry
+ map._entries.push(value);
return true;
} else {
- map._entries[keyIndex - 1]._value = value;
+ map._entries[uintKey] = value;
return false;
}
}
@@ -52,45 +40,15 @@
*
* Returns true if the key was removed from the map, that is if it was present.
*/
- function _remove(Map storage map, bytes32 key) private returns (bool) {
- // We read and store the key's index to prevent multiple reads from the same storage slot
- uint256 keyIndex = map._indexes[key];
-
- if (keyIndex != 0) { // Equivalent to contains(map, key)
- // To delete a key-value pair from the _entries array in O(1), we swap the entry to delete with the last one
- // in the array, and then remove the last entry (sometimes called as 'swap and pop').
- // This modifies the order of the array, as noted in {at}.
-
- uint256 toDeleteIndex = keyIndex - 1;
- uint256 lastIndex = map._entries.length - 1;
-
- // When the entry to delete is the last one, the swap operation is unnecessary. However, since this occurs
- // so rarely, we still do the swap anyway to avoid the gas cost of adding an 'if' statement.
-
- MapEntry storage lastEntry = map._entries[lastIndex];
-
- // Move the last entry to the index where the entry to delete is
- map._entries[toDeleteIndex] = lastEntry;
- // Update the index for the moved entry
- map._indexes[lastEntry._key] = toDeleteIndex + 1; // All indexes are 1-based
-
- // Delete the slot where the moved entry was stored
- map._entries.pop();
-
- // Delete the index for the deleted slot
- delete map._indexes[key];
-
- return true;
- } else {
- return false;
- }
+ function _remove(Map storage /*map*/, bytes32 /*key*/) private pure returns (bool) {
+ revert("No removal supported");
}
/**
* @dev Returns true if the key is in the map. O(1).
*/
function _contains(Map storage map, bytes32 key) private view returns (bool) {
- return map._indexes[key] != 0;
+ return uint256(key) < map._entries.length;
}
/**
@@ -112,9 +70,7 @@
*/
function _at(Map storage map, uint256 index) private view returns (bytes32, bytes32) {
require(map._entries.length > index, "EnumerableMap: index out of bounds");
-
- MapEntry storage entry = map._entries[index];
- return (entry._key, entry._value);
+ return (bytes32(index), map._entries[index]);
}
/**
@@ -132,9 +88,9 @@
* @dev Same as {_get}, with a custom error message when `key` is not in the map.
*/
function _get(Map storage map, bytes32 key, string memory errorMessage) private view returns (bytes32) {
- uint256 keyIndex = map._indexes[key];
- require(keyIndex != 0, errorMessage); // Equivalent to contains(map, key)
- return map._entries[keyIndex - 1]._value; // All indexes are 1-based
+ uint256 uintKey = uint256(key);
+ require(map._entries.length > uintKey, errorMessage); // Equivalent to contains(map, key)
+ return map._entries[uintKey];
}
// UintToAddressMap
@@ -159,7 +115,7 @@
*
* Returns true if the key was removed from the map, that is if it was present.
*/
- function remove(UintToAddressMap storage map, uint256 key) internal returns (bool) {
+ function remove(UintToAddressMap storage map, uint256 key) internal view returns (bool) {
return _remove(map._inner, bytes32(key));
} |
@KaiRo-at Can you please create a new issue describing the problems in full? I'm starting to lose track of the context here. |
Yes, sorry, not the best style to start a discussion on a closed PR. I created #2187 for this issue, summarizing what this all is about (and putting that diff into another comment on there). |
Closes #2072.
This PR does a number of things (split across separate commits):
EnumerableSet
, to make adding new sets easierEnumerableMap
, following the same approach for generic setsA couple things didn't go very well:
EnumerableSet.enumerate
. I plan on bringing this back once I get feedback from the Solidity team on how to best do itERC721._tokensOfOwner()
Finally, I added some small new tests to
ERC721
: during the migration I made a small mistake that was not caught by the suite, so I created a new test.