My gas-optimized ERC721 had an auth bypass. The fixed one still beats solmate.
In 2022 I wrote keser-contracts, a Solidity library aimed at low gas. The README had a proud table: ERC721 transfers 61% cheaper than solmate and 70% cheaper than OpenZeppelin.
This week I went to write a post about it and read the code properly for the first time in years. The number was real. The reason for it was not something to be proud of.
What was actually wrong
Here is the heart of the old transfer:
function _transferFrom(address _from, address _to, uint256 _tokenId) internal {
_checkTransferInput(_to, _tokenId);
_authorize(_tokenId, msg.sender, _from); // result ignored
ownerOf[_tokenId] = _to;
delete getApproved[_tokenId];
emit Transfer(_from, _to, _tokenId);
}
_authorize returns whether the caller is allowed to move the token, and the result was thrown away. transferFrom wrapped it in a require, but both safeTransferFrom functions went straight here. Anyone could take anyone's NFT by calling safeTransferFrom.
Two more problems made the benchmark look better than it was:
- The receiver hook often never ran.
safeTransferFromis supposed to callonERC721Receivedon a contract recipient. The three-argumentsafeTransferFromand_safeMintpassed their arguments to my helper in the wrong order, so it checked the code size ofaddress(0)and returned early every time. The benchmarked "safe" transfer was the three-argument one, so it skipped the external call that solmate and OpenZeppelin make. - Balances weren't tracked.
balanceOflooped over every token ever minted. That saves two storage writes per transfer. It also costs more and more to call as the collection grows, and it counted a token twice if its id was burned and minted again.
Almost all of the 61% came from those missing checks. The README did say "not audited", which doesn't make it OK.
Testing against the libraries I was beating
The old tests checked almost nothing: no reverts, no balances, no events. And two other test files had a describe.only left in, so hardhat test had been quietly skipping the ERC721 tests.
So I wrote one ERC-721 spec suite and ran it against three implementations side by side: mine, solmate 6.2.0 and OpenZeppelin 4.5.0. If a test passes on both reference libraries, it's testing the standard, not my opinion of it. The old keser failed 20 of 54 cases. Solmate and OpenZeppelin passed all of them.
After fixing the bugs in plain Solidity, all three passed. The honest gas result was humbling: 0.1–0.9% worse than solmate on most operations, and still a few percent better than OpenZeppelin.
Beating solmate for real
Solmate is already lean, so the saving had to come from design, not from skipping work. It turned out to be in approvals.
Every ERC-721 transfer has to revoke the token's single approval. Solmate and OpenZeppelin do that with a storage write to the approval slot on every transfer and burn, even when there is no approval to revoke, which is almost always. That write touches a slot nothing else needs, and it costs about 2,200 gas.
The fix is an approval epoch. An address is 160 bits, so a 256-bit storage word has 96 spare bits:
owner word: owner | epoch << 160
approval word: approved | epoch << 160
An approval only counts while its epoch equals the owner word's epoch. A transfer has to rewrite the owner word anyway, so it bumps the epoch for free, and every outstanding approval stops counting without the approval slot ever being touched.
There's one sharp edge. When a token is burned, the owner word goes back to zero, and so does its epoch. An approval granted at epoch 0 would come back to life when the id is minted to someone new. So burn clears both words. That also keeps the gas refund solmate gets for zeroing the owner slot.
The rest is careful assembly on the hot paths, and custom errors instead of revert strings. One bug worth sharing: Yul evaluates function arguments right to left. I wrote
and(call(gas(), to, 0, ptr, size, 0x00, 0x20), eq(shr(224, mload(0x00)), MAGIC))
and every contract recipient was rejected, because mload read the return buffer before the call had run. The spec suite caught it on the first run.
The numbers
Same calls, in the same order, on each implementation, with gas read from every transaction receipt:
| Operation | keser | solmate | vs solmate | vs OZ |
|---|---|---|---|---|
| transferFrom (owner) | 39,655 | 42,198 | −6.0% | −12.4% |
| safeTransferFrom (to wallet) | 42,337 | 44,936 | −5.8% | −12.1% |
| safeTransferFrom (to contract) | 83,371 | 86,165 | −3.2% | −7.1% |
| transferFrom (operator) | 44,139 | 44,661 | −1.2% | −8.1% |
| transferFrom (approved) | 39,993 | 40,392 | −1.0% | −8.6% |
| burn | 31,017 | 31,344 | −1.0% | −8.0% |
| mint | 51,165 | 51,432 | −0.5% | −0.7% |
| approve | 48,246 | 48,345 | −0.2% | −1.1% |
| deploy | 871,520 | 979,998 | −11.1% | −32.7% |
It's cheaper than both on every operation I measured, while passing the same spec suite they pass. Averages over the whole test suite with hardhat-gas-reporter agree. Forget "61%": the real win is about 6% on the most common transfers and 11% on deployment, with every check in place.
How I know the epoch trick is safe
Clever storage tricks are exactly where bugs hide, so the test suite attacks it directly:
- Targeted cases. An approval must not survive burn and re-mint, must not come back when a token returns to its old owner, and must be cleared when an operator moves the token.
- An invariant test. 300 random mints, transfers, approvals, approved spends, unauthorized attempts and burns, checked after every step against a simple model: every owner, balance and approval has to match.
- Mutation testing. I broke the design on purpose twice: no epoch bump, and no approval clear on burn. The invariant test caught each one on its own, at steps 80 and 141.
That last step caught me too. The first version of my invariant test used a textbook linear congruential generator with seed % 6 to pick operations. Its low bits cycle, so only three of the six operations ever ran. The test never tried an owner transfer or an approval, and it passed anyway. I only found out because the mutations survived it. It now uses a better generator and fails if any operation runs fewer than three times.
What's left
It's still not audited, so don't ship hand-written assembly to mainnet on the strength of a blog post, mine included. The comparison is against the solmate and OpenZeppelin versions the repo pins. The fix, the tests and the benchmark are all in the pull request. Run npx hardhat test test/ERC721Bench.ts and check my numbers.
The lesson I keep relearning: when you're much faster than a well-reviewed library, first find out what you're not doing.