Paradigm

Open Sourcing the Art Gobblers Smart Contracts

Sep 20, 2022 | Frankie, Transmissions11, Dave White, Justin Roiland

Contents

Introduction

Today, we’re excited to announce that the Art Gobblers smart contracts are open source and available 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.

Diving into the Contracts

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.

Custom ERC721 Implementations

GobblersERC721

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.

PagesERC721

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.

Integrating GOO

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.

Progressive Reveals

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).

Testing and Security

We’ve made significant efforts to try to ensure the correctness of these smart contracts, including various forms of testings and audits.

Testing

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.

Incentivized Play Testing

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
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.

Audits and C4 Contest

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.

Conclusion

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

Disclaimer: This post is for general information purposes only. It does not constitute investment advice or a recommendation or solicitation to buy or sell any investment and should not be used in the evaluation of the merits of making any investment decision. It should not be relied upon for accounting, legal or tax advice or investment recommendations. This post reflects the current opinions of the authors and is not made on behalf of Paradigm or its affiliates and does not necessarily reflect the opinions of Paradigm, its affiliates or individuals associated with Paradigm. The opinions reflected herein are subject to change without being updated.