Skip to content

Commit 12533bc

Browse files
authored
ERC721 bugfix + gas optimizations (#1549)
* Now only swapping when needed. * Removed _addTokenTo and _removeTokenFrom * Removed removeTokenFrom test. * Added tests for ERC721 _mint and _burn * _burn now uses the same swap and pop mechanism as _removeFromOwner * Gas optimization on burn
1 parent 2da19ee commit 12533bc

File tree

6 files changed

+170
-124
lines changed

6 files changed

+170
-124
lines changed

contracts/mocks/ERC721FullMock.sol

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import "../token/ERC721/ERC721Burnable.sol";
88
/**
99
* @title ERC721FullMock
1010
* This mock just provides public functions for setting metadata URI, getting all tokens of an owner,
11-
* checking token existence, removal of a token from an address
11+
* checking token existence, removal of a token from an address
1212
*/
1313
contract ERC721FullMock is ERC721Full, ERC721Mintable, ERC721MetadataMintable, ERC721Burnable {
1414
constructor (string name, string symbol) public ERC721Mintable() ERC721Full(name, symbol) {}
@@ -24,8 +24,4 @@ contract ERC721FullMock is ERC721Full, ERC721Mintable, ERC721MetadataMintable, E
2424
function setTokenURI(uint256 tokenId, string uri) public {
2525
_setTokenURI(tokenId, uri);
2626
}
27-
28-
function removeTokenFrom(address from, uint256 tokenId) public {
29-
_removeTokenFrom(from, tokenId);
30-
}
3127
}

contracts/mocks/ERC721Mock.sol

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ contract ERC721Mock is ERC721 {
1111
_mint(to, tokenId);
1212
}
1313

14+
function burn(address owner, uint256 tokenId) public {
15+
_burn(owner, tokenId);
16+
}
17+
1418
function burn(uint256 tokenId) public {
1519
_burn(tokenId);
1620
}

contracts/token/ERC721/ERC721.sol

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,11 @@ contract ERC721 is ERC165, IERC721 {
202202
*/
203203
function _mint(address to, uint256 tokenId) internal {
204204
require(to != address(0));
205-
_addTokenTo(to, tokenId);
205+
require(!_exists(tokenId));
206+
207+
_tokenOwner[tokenId] = to;
208+
_ownedTokensCount[to] = _ownedTokensCount[to].add(1);
209+
206210
emit Transfer(address(0), to, tokenId);
207211
}
208212

@@ -214,11 +218,16 @@ contract ERC721 is ERC165, IERC721 {
214218
* @param tokenId uint256 ID of the token being burned
215219
*/
216220
function _burn(address owner, uint256 tokenId) internal {
221+
require(ownerOf(tokenId) == owner);
222+
217223
_clearApproval(tokenId);
218-
_removeTokenFrom(owner, tokenId);
224+
225+
_ownedTokensCount[owner] = _ownedTokensCount[owner].sub(1);
226+
_tokenOwner[tokenId] = address(0);
227+
219228
emit Transfer(owner, address(0), tokenId);
220229
}
221-
230+
222231
/**
223232
* @dev Internal function to burn a specific token
224233
* Reverts if the token does not exist
@@ -228,33 +237,6 @@ contract ERC721 is ERC165, IERC721 {
228237
_burn(ownerOf(tokenId), tokenId);
229238
}
230239

231-
/**
232-
* @dev Internal function to add a token ID to the list of a given address
233-
* Note that this function is left internal to make ERC721Enumerable possible, but is not
234-
* intended to be called by custom derived contracts: in particular, it emits no Transfer event.
235-
* @param to address representing the new owner of the given token ID
236-
* @param tokenId uint256 ID of the token to be added to the tokens list of the given address
237-
*/
238-
function _addTokenTo(address to, uint256 tokenId) internal {
239-
require(_tokenOwner[tokenId] == address(0));
240-
_tokenOwner[tokenId] = to;
241-
_ownedTokensCount[to] = _ownedTokensCount[to].add(1);
242-
}
243-
244-
/**
245-
* @dev Internal function to remove a token ID from the list of a given address
246-
* Note that this function is left internal to make ERC721Enumerable possible, but is not
247-
* intended to be called by custom derived contracts: in particular, it emits no Transfer event,
248-
* and doesn't clear approvals.
249-
* @param from address representing the previous owner of the given token ID
250-
* @param tokenId uint256 ID of the token to be removed from the tokens list of the given address
251-
*/
252-
function _removeTokenFrom(address from, uint256 tokenId) internal {
253-
require(ownerOf(tokenId) == from);
254-
_ownedTokensCount[from] = _ownedTokensCount[from].sub(1);
255-
_tokenOwner[tokenId] = address(0);
256-
}
257-
258240
/**
259241
* @dev Internal function to transfer ownership of a given token ID to another address.
260242
* As opposed to transferFrom, this imposes no restrictions on msg.sender.

contracts/token/ERC721/ERC721Enumerable.sol

Lines changed: 50 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -67,37 +67,6 @@ contract ERC721Enumerable is ERC165, ERC721, IERC721Enumerable {
6767
return _allTokens[index];
6868
}
6969

70-
/**
71-
* @dev Internal function to add a token ID to the list of a given address
72-
* This function is internal due to language limitations, see the note in ERC721.sol.
73-
* It is not intended to be called by custom derived contracts: in particular, it emits no Transfer event.
74-
* @param to address representing the new owner of the given token ID
75-
* @param tokenId uint256 ID of the token to be added to the tokens list of the given address
76-
*/
77-
function _addTokenTo(address to, uint256 tokenId) internal {
78-
super._addTokenTo(to, tokenId);
79-
80-
_addTokenToOwnerEnumeration(to, tokenId);
81-
}
82-
83-
/**
84-
* @dev Internal function to remove a token ID from the list of a given address
85-
* This function is internal due to language limitations, see the note in ERC721.sol.
86-
* It is not intended to be called by custom derived contracts: in particular, it emits no Transfer event,
87-
* and doesn't clear approvals.
88-
* @param from address representing the previous owner of the given token ID
89-
* @param tokenId uint256 ID of the token to be removed from the tokens list of the given address
90-
*/
91-
function _removeTokenFrom(address from, uint256 tokenId) internal {
92-
super._removeTokenFrom(from, tokenId);
93-
94-
_removeTokenFromOwnerEnumeration(from, tokenId);
95-
96-
// Since the token is being destroyed, we also clear its index
97-
// TODO(nventuro): 0 is still a valid index, so arguably this isnt really helpful, remove?
98-
_ownedTokensIndex[tokenId] = 0;
99-
}
100-
10170
/**
10271
* @dev Internal function to transfer ownership of a given token ID to another address.
10372
* As opposed to transferFrom, this imposes no restrictions on msg.sender.
@@ -122,8 +91,9 @@ contract ERC721Enumerable is ERC165, ERC721, IERC721Enumerable {
12291
function _mint(address to, uint256 tokenId) internal {
12392
super._mint(to, tokenId);
12493

125-
_allTokensIndex[tokenId] = _allTokens.length;
126-
_allTokens.push(tokenId);
94+
_addTokenToOwnerEnumeration(to, tokenId);
95+
96+
_addTokenToAllTokensEnumeration(tokenId);
12797
}
12898

12999
/**
@@ -136,17 +106,11 @@ contract ERC721Enumerable is ERC165, ERC721, IERC721Enumerable {
136106
function _burn(address owner, uint256 tokenId) internal {
137107
super._burn(owner, tokenId);
138108

139-
// Reorg all tokens array
140-
uint256 tokenIndex = _allTokensIndex[tokenId];
141-
uint256 lastTokenIndex = _allTokens.length.sub(1);
142-
uint256 lastToken = _allTokens[lastTokenIndex];
143-
144-
_allTokens[tokenIndex] = lastToken;
145-
_allTokens[lastTokenIndex] = 0;
109+
_removeTokenFromOwnerEnumeration(owner, tokenId);
110+
// Since tokenId will be deleted, we can clear its slot in _ownedTokensIndex to trigger a gas refund
111+
_ownedTokensIndex[tokenId] = 0;
146112

147-
_allTokens.length--;
148-
_allTokensIndex[tokenId] = 0;
149-
_allTokensIndex[lastToken] = tokenIndex;
113+
_removeTokenFromAllTokensEnumeration(tokenId);
150114
}
151115

152116
/**
@@ -164,9 +128,17 @@ contract ERC721Enumerable is ERC165, ERC721, IERC721Enumerable {
164128
* @param tokenId uint256 ID of the token to be added to the tokens list of the given address
165129
*/
166130
function _addTokenToOwnerEnumeration(address to, uint256 tokenId) private {
167-
uint256 newOwnedTokensLength = _ownedTokens[to].push(tokenId);
168-
// No need to use SafeMath since the length after a push cannot be zero
169-
_ownedTokensIndex[tokenId] = newOwnedTokensLength - 1;
131+
_ownedTokensIndex[tokenId] = _ownedTokens[to].length;
132+
_ownedTokens[to].push(tokenId);
133+
}
134+
135+
/**
136+
* @dev Private function to add a token to this extension's token tracking data structures.
137+
* @param tokenId uint256 ID of the token to be added to the tokens list
138+
*/
139+
function _addTokenToAllTokensEnumeration(uint256 tokenId) private {
140+
_allTokensIndex[tokenId] = _allTokens.length;
141+
_allTokens.push(tokenId);
170142
}
171143

172144
/**
@@ -182,21 +154,45 @@ contract ERC721Enumerable is ERC165, ERC721, IERC721Enumerable {
182154
// then delete the last slot (swap and pop).
183155

184156
uint256 lastTokenIndex = _ownedTokens[from].length.sub(1);
185-
uint256 lastTokenId = _ownedTokens[from][lastTokenIndex];
186-
187157
uint256 tokenIndex = _ownedTokensIndex[tokenId];
188158

189-
_ownedTokens[from][tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token
190-
_ownedTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index
159+
// When the token to delete is the last token, the swap operation is unnecessary
160+
if (tokenIndex != lastTokenIndex) {
161+
uint256 lastTokenId = _ownedTokens[from][lastTokenIndex];
191162

192-
// Note that this will handle single-element arrays. In that case, both tokenIndex and lastTokenIndex are going
193-
// to be zero. The swap operation will therefore have no effect, but the token _will_ be deleted during the
194-
// 'pop' operation.
163+
_ownedTokens[from][tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token
164+
_ownedTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index
165+
}
195166

196167
// This also deletes the contents at the last position of the array
197168
_ownedTokens[from].length--;
198169

199170
// Note that _ownedTokensIndex[tokenId] hasn't been cleared: it still points to the old slot (now occcupied by
200-
// lasTokenId).
171+
// lasTokenId, or just over the end of the array if the token was the last one).
172+
}
173+
174+
/**
175+
* @dev Private function to remove a token from this extension's token tracking data structures.
176+
* This has O(1) time complexity, but alters the order of the _allTokens array.
177+
* @param tokenId uint256 ID of the token to be removed from the tokens list
178+
*/
179+
function _removeTokenFromAllTokensEnumeration(uint256 tokenId) private {
180+
// To prevent a gap in the tokens array, we store the last token in the index of the token to delete, and
181+
// then delete the last slot (swap and pop).
182+
183+
uint256 lastTokenIndex = _allTokens.length.sub(1);
184+
uint256 tokenIndex = _allTokensIndex[tokenId];
185+
186+
// When the token to delete is the last token, the swap operation is unnecessary. However, since this occurs so
187+
// rarely (when the last minted token is burnt) that we still do the swap here to avoid the gas cost of adding
188+
// an 'if' statement (like in _removeTokenFromOwnerEnumeration)
189+
uint256 lastTokenId = _allTokens[lastTokenIndex];
190+
191+
_allTokens[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token
192+
_allTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index
193+
194+
// This also deletes the contents at the last position of the array
195+
_allTokens.length--;
196+
_allTokensIndex[tokenId] = 0;
201197
}
202198
}

test/token/ERC721/ERC721.test.js

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,112 @@
1-
const { shouldBehaveLikeERC721 } = require('./ERC721.behavior');
1+
require('../../helpers/setup');
2+
const { ZERO_ADDRESS } = require('../../helpers/constants');
3+
const expectEvent = require('../../helpers/expectEvent');
4+
const send = require('../../helpers/send');
5+
const shouldFail = require('../../helpers/shouldFail');
26

7+
const { shouldBehaveLikeERC721 } = require('./ERC721.behavior');
38
const ERC721Mock = artifacts.require('ERC721Mock.sol');
49

5-
require('../../helpers/setup');
6-
7-
contract('ERC721', function ([_, creator, ...accounts]) {
10+
contract('ERC721', function ([_, creator, tokenOwner, anyone, ...accounts]) {
811
beforeEach(async function () {
912
this.token = await ERC721Mock.new({ from: creator });
1013
});
1114

1215
shouldBehaveLikeERC721(creator, creator, accounts);
16+
17+
describe('internal functions', function () {
18+
const tokenId = 5042;
19+
20+
describe('_mint(address, uint256)', function () {
21+
it('reverts with a null destination address', async function () {
22+
await shouldFail.reverting(this.token.mint(ZERO_ADDRESS, tokenId));
23+
});
24+
25+
context('with minted token', async function () {
26+
beforeEach(async function () {
27+
({ logs: this.logs } = await this.token.mint(tokenOwner, tokenId));
28+
});
29+
30+
it('emits a Transfer event', function () {
31+
expectEvent.inLogs(this.logs, 'Transfer', { from: ZERO_ADDRESS, to: tokenOwner, tokenId });
32+
});
33+
34+
it('creates the token', async function () {
35+
(await this.token.balanceOf(tokenOwner)).should.be.bignumber.equal(1);
36+
(await this.token.ownerOf(tokenId)).should.equal(tokenOwner);
37+
});
38+
39+
it('reverts when adding a token id that already exists', async function () {
40+
await shouldFail.reverting(this.token.mint(tokenOwner, tokenId));
41+
});
42+
});
43+
});
44+
45+
describe('_burn(address, uint256)', function () {
46+
it('reverts when burning a non-existent token id', async function () {
47+
await shouldFail.reverting(send.transaction(this.token, 'burn', 'address,uint256', [tokenOwner, tokenId]));
48+
});
49+
50+
context('with minted token', function () {
51+
beforeEach(async function () {
52+
await this.token.mint(tokenOwner, tokenId);
53+
});
54+
55+
it('reverts when the account is not the owner', async function () {
56+
await shouldFail.reverting(send.transaction(this.token, 'burn', 'address,uint256', [anyone, tokenId]));
57+
});
58+
59+
context('with burnt token', function () {
60+
beforeEach(async function () {
61+
({ logs: this.logs } =
62+
await send.transaction(this.token, 'burn', 'address,uint256', [tokenOwner, tokenId]));
63+
});
64+
65+
it('emits a Transfer event', function () {
66+
expectEvent.inLogs(this.logs, 'Transfer', { from: tokenOwner, to: ZERO_ADDRESS, tokenId });
67+
});
68+
69+
it('deletes the token', async function () {
70+
(await this.token.balanceOf(tokenOwner)).should.be.bignumber.equal(0);
71+
await shouldFail.reverting(this.token.ownerOf(tokenId));
72+
});
73+
74+
it('reverts when burning a token id that has been deleted', async function () {
75+
await shouldFail.reverting(send.transaction(this.token, 'burn', 'address,uint256', [tokenOwner, tokenId]));
76+
});
77+
});
78+
});
79+
});
80+
81+
describe('_burn(uint256)', function () {
82+
it('reverts when burning a non-existent token id', async function () {
83+
await shouldFail.reverting(send.transaction(this.token, 'burn', 'uint256', [tokenId]));
84+
});
85+
86+
context('with minted token', function () {
87+
beforeEach(async function () {
88+
await this.token.mint(tokenOwner, tokenId);
89+
});
90+
91+
context('with burnt token', function () {
92+
beforeEach(async function () {
93+
({ logs: this.logs } = await send.transaction(this.token, 'burn', 'uint256', [tokenId]));
94+
});
95+
96+
it('emits a Transfer event', function () {
97+
expectEvent.inLogs(this.logs, 'Transfer', { from: tokenOwner, to: ZERO_ADDRESS, tokenId });
98+
});
99+
100+
it('deletes the token', async function () {
101+
(await this.token.balanceOf(tokenOwner)).should.be.bignumber.equal(0);
102+
await shouldFail.reverting(this.token.ownerOf(tokenId));
103+
});
104+
105+
it('reverts when burning a token id that has been deleted', async function () {
106+
await shouldFail.reverting(send.transaction(this.token, 'burn', 'uint256', [tokenId]));
107+
});
108+
});
109+
});
110+
});
111+
});
13112
});

0 commit comments

Comments
 (0)