09.20.2022|Frankietransmissions11Dave WhiteJustin Roiland
Today, we’re excited to announce that the Art Gobblers smart contracts are open source and on our Github repo. We hope the systems we’ve built will capture your imagination, and we can’t wait to see what you’ll build on top of them.
In this post, we want to highlight a few of the interesting parts of the Art Gobblers codebase, like our custom ERC721 implementations, GOO integration, our progressive reveal system, and our approach to testing.
Art Gobblers is significantly more complex than the average NFT project. Gobblers are sold using a VRGDA, and are paid for with a custom utility token (Goo). On top of this, each Gobbler is assigned an “emission multiple”, which determines the rate at which it continuously generates Goo tokens, using GOO issuance.
Nevertheless, we wanted the gas costs of minting & transferring Gobbler NFTs to be one of the lowest in the industry.
To achieve this, we built a custom ERC721 implementation that makes heavy use of struct packing to remain efficient. We are able to fit all of the state associated with each gobbler and user in the mappings typically used for owner and balance information.
For Gobbler state, we pack each Gobbler’s 160 bit owner address with 2 other variables: idx
and emissionMultiple
:
/// @notice Struct holding gobbler data. struct GobblerData { // The current owner of the gobbler. address owner; // Index of token after shuffle. uint64 idx; // Multiple on goo issuance. uint32 emissionMultiple; }
For owner state, we pack a 32 bit count of the gobblers owned by that user with 3 other variables related to GOO issuance: emissionMultiple
, lastBalance
and lastTimestamp
:
struct UserData { // The total number of gobblers currently owned by the user. uint32 gobblersOwned; // The sum of the multiples of all gobblers the user holds. uint32 emissionMultiple; // User's goo balance at time of last checkpointing. uint128 lastBalance; // Timestamp of the last goo balance checkpoint. uint64 lastTimestamp; }
While under the hood all this data is packed together in one 256 bit slot, GobblersERC721 is still 100% compliant with the ERC721 interface, thanks to helper functions which expose the specific storage sections needed for ownerOf
and balanceOf
:
function ownerOf(uint256 id) external view returns (address owner) { require((owner = getGobblerData[id].owner) != address(0), "NOT_MINTED"); } function balanceOf(address owner) external view returns (uint256) { require(owner != address(0), "ZERO_ADDRESS"); return getUserData[owner].gobblersOwned; }
Additionally, GobblersERC721 also features optimizations that leverage fundamental invariants of the Art Gobblers system (no tokens will be minted to address(0)
, the supply cap of 10,000
, etc) to remove redundant assertions.
Pages, while significantly less complex than Gobblers, also use a modified ERC721 implementation to improve improve gas costs and UX around feeding pages to gobblers.
Primarily, Pages automatically skip approval checks when being transferred by the Gobblers contract to make gobbling a single shot process with no extra hassle.
function isApprovedForAll(address owner, address operator) public view returns (bool isApproved) { if (operator == address(artGobblers)) return true; // Skip approvals for the ArtGobblers contract. return _isApprovedForAll[owner][operator]; }
And, like Gobblers, Pages utilize known invariants of the Art Gobblers system to remove redundant assertions in their minting logic.
Gobblers generate Goo at a specified emission rate, which we discuss in GOO paper. Because Goo is generated continuously over time, goo balances need to be computed lazily. That is, a virtual balance is tracked which can be exchanged for a regular ERC20 balance at the user's convenience.
Because this virtual balance is a function of the user’s total emission multiple, one has to make sure that it remains correct when transfers happen. For example, when a user transfers a gobbler away, their total emission multiple should go down (and so should their future emissions), but their current Goo balance should not change.
In order to get around this, we overrode Gobbler’s transfer function to ensure that proper snapshots are taken of the user's state.
function transferFrom( address from, address to, uint256 id ) public override { ... snip ... unchecked { // Caching saves gas. uint32 emissionMultiple = getGobblerData[id].emissionMultiple; // Update the sending address's user data. getUserData[from].lastBalance = uint128(gooBalance(from)); getUserData[from].lastTimestamp = uint64(block.timestamp); getUserData[from].emissionMultiple -= emissionMultiple; getUserData[from].gobblersOwned -= 1; // Update the receiving address's user data. getUserData[to].lastBalance = uint128(gooBalance(to)); getUserData[to].lastTimestamp = uint64(block.timestamp); getUserData[to].emissionMultiple += emissionMultiple; getUserData[to].gobblersOwned += 1; } emit Transfer(from, to, id); }
Since virtual balances are used to keep track of Goo, we wanted to avoid purchases being a multi-transaction process. Normally, users would have to submit one transaction to turn their virtual balance into a regular ERC20 balance, one transaction to approve Gobblers as a Goo spender, and another transaction to purchase the items.
To streamline this process, we modified the purchase functions in both Gobblers and Pages so that users are able to spend directly from their virtual balances, and set up permissions between contracts so that no additional approval transactions are required.
The Art Gobblers mint and reveal process has some unique constraints. Our goal was for the process to be provably fair while remaining gas efficient. But since Gobblers will be mintable over a period of 10 years, waiting until the mint was over to do a full-collection reveal wasn't an option.
In order to achieve this, we’ve implemented a highly optimized batch reveal process using a Fisher-Yates Shuffle. This allow reveals to happen once per day, using Chainlink as our randomness provider.
During the reveal process, this randomness is used to assign metadata to every unrevealed gobbler. This includes both a token ID as well as an emission multiplier sampled from a predefined distribution.
function revealGobblers(uint256 numGobblers) external { uint256 randomSeed = gobblerRevealsData.randomSeed; ... snip ... unchecked { for (uint256 i = 0; i < numGobblers; ++i) { ... snip ... // Randomly pick distance for swap. uint256 distance = randomSeed % remainingIds; // Select swap id, adding distance to next reveal id. uint256 swapId = currentId + distance; // Get the index of the swap id. uint64 swapIndex = getGobblerData[swapId].idx == 0 ? uint64(swapId) // Hasn't been shuffled before. : getGobblerData[swapId].idx; // Shuffled before. // Get the index of the current id. uint64 currentIndex = getGobblerData[currentId].idx == 0 ? uint64(currentId) // Hasn't been shuffled before. : getGobblerData[currentId].idx; // Shuffled before. // Swap indices getGobblerData[currentId].idx = swapIndex; getGobblerData[swapId].idx = currentIndex; ... snip ... } }
Thanks to some clever struct packing and various additional optimization, this process can be run in a gas efficient manner.
We’ve also made our randomness provider upgradable (writing a thin provider interface and adaptors). This was necessary because we were unable to find a VRF provider that guaranteed service for the duration of the mint (approximately 10 years).
We’ve made significant efforts to try to ensure the correctness of these smart contracts, including various forms of testings and audits.
We’ve written multiple unit tests and fuzz tests, leveraging Foundry as a testing framework. We’ve also made heavy use of differential fuzzing to test some of the more mathematically complex behavior. Implementations of VRGDAs and Goo have been written in python, and these implementations have been fuzzed against Solidity to ensure the outputs are equivalent:
function testFFICorrectness(uint256 timeSinceStart, uint256 numSold) public { ... snip ... // Calculate actual price from VRGDA. actualPrice = gobblers.getVRGDAPrice(toDaysWadUnsafe(timeSinceStart), numSold); // Calculate expected price from python script. uint256 expectedPrice = calculatePrice(timeSinceStart, numSold); // Equal within 1 percent. assertRelApproxEq(actualPrice, expectedPrice, 0.01e18); } function calculatePrice( uint256 timeSinceStart, uint256 numSold ) private returns (uint256) { string[] memory inputs = new string[](7); inputs[0] = "python3"; inputs[1] = "analysis/python/compute_price.py"; inputs[2] = "gobblers"; inputs[3] = "--time_since_start"; inputs[4] = timeSinceStart.toString(); inputs[5] = "--num_sold"; inputs[6] = numSold.toString(); return abi.decode(vm.ffi(inputs), (uint256)); }
We’ve also made use of use of advanced security tooling, like Slither for static analysis, and Z3 for automated theorem proving, in order to verify some of our assumptions about the mechanism.
It was also important for us to verify that Gobblers gameplay is balanced. We wanted to test whether there are any strategies that would allow players to end up with a disproportionate amount of the game’s resources (whether it’s Gobblers, Pages or Goo).
In order to do this, we ran an incentivized play test, organized with some help from Grug. We tuned the mechanism’s parameters to speed up gameplay by 30x, and invited a group of searchers and developers to play. We tasked them with trying to obtain as many Gobblers and as much Goo as they could, with Gobblers being given as prizes.
Final standings of our incentivized play test
This was an interesting experiment, and we were able to observe some fun strategies emerge. We encourage developers building onchain games to include play testing as part of their development process.
For audits, we underwent an internal review process by samczsun and Riley Holterhus. We also engaged Spearbit to conduct an audit of the protocol near the completion of its current development, which did not uncover any major vulnerabilities.
We are also excited to announce that today, a one-week audit contest is being kicked off with code4rena, with a $100,000 prize pool. We invite you all to participate and see what you can find.
We are excited to share the Art Gobblers contracts with you, and are looking forward to see what you’ll build.
If you have any projects in mind, we’d love to hear from you. You can reach us on twitter at @FrankieIsLost, @transmissions11, @_Dave__White_ and @JustinRoiland.
Acknowledgments: samczsun, Riley Holterhus, Grug, Otto Suwen, misaka, Snoopy Mev, CuriousRabbit, Will Price, Snarks, Taarush, Ben Leimberger, QTpie Pluto
Graphics By: Achal Srinivasan
Copyright © 2024 Paradigm Operations LP All rights reserved. “Paradigm” is a trademark, and the triangular mobius symbol is a registered trademark of Paradigm Operations LP