Home Blog Games About
Dec 11, 2021

Lab Notes 1: The Hairy Token Transfer

by: Jon Borichevskiy

an experiment at draft-style lab notes chronicling our building adventures. This specific episode took place Dec 8 - 9 during the Gorblin deployment.


A few weeks into gameplay on Exquisite Land, commotion settled as the majority of in-progress tiles stayed in the drawing state and few neighbors were being played forward. There were still people who wanted to draw -- and tiles that needed to be drawn.

Figuring this might be a recurring situation (ie, a gameplay pattern and not a game incident) -- we introduced Gorblin, a character spawned from the canvas itself who would do the honors of re-claiming squatted tiles and give them out to the landless in a randomized lottery.

We have three contracts live that make Exquisite Land work:

  1. TerraMasu.sol -- the core of Canvas #1 (Terra Masu) holding tile data, colors, handling neighbor calculations, map size, and data validation.
  2. LandGranter.sol - our tile bank, maintaining the bridge between undrawn tiles and invitational coins which can be exchanged for a place to draw pixels.
  3. GenericRenderer.sol - a magical box transforming an array of pixel color definitions into space-efficient SVGs completely on the chain

For Gorblin to reclaim these tiles we need to call a recirculateTile(x, y) function in on our TerraMasu.sol contract, which takes back the tile from the address that owns it. To keep things transparent and fair, we put in a 12-hour cooldown timer for this function and weighed the sorting algorithm to the largest whales who have drawn the least first.

getting the tile stuck

We were just about ready to give away the first coin, but first needed to test the generated coin we'd be giving away. I called the recirculateTile(x, y) function and minted it with my address as the only allowed claimer. Some wrestling with code and several cups of coffee later; I had a slimy gorblin coin png generated. I put it into the arcade machine slot -- and voila! it was valid.

Seeing as redeeming it into my wallet was a success, it was time to get this tile back into the default state -- to be re-assigned to our winner. I called recirculateTile(x, y) for the second time that day and was met with a rather unhelpful error message: error={"reason":"cannot estimate gas; transaction may fail or may require manual gas limit","code":"UNPREDICTABLE_GAS_LIMIT"}.

Double checking our wallet had enough MATIC and scratching my head a while, I realized I'm hitting a require statement (effectively, a line of code that says do not proceed unless true): require(block.timestamp > _lastTransferred[tokenId] + 12 hours); -- this was the same 12-hour cooldown timer I mentioned earlier. And since I had transferred it less than 12 hours ago -- this function would not proceed.

As a workaround (wanting to get it out ASAP) I manually transferred the tile from my wallet back into the inventory of the LandGranter.sol contract. This is a standard operation called from the sending wallet and can only be done for items it holds. I paid the fee and was in the clear - the coin was back in the LandGranter.sol inventory. Things proceeded nicely: we picked a winner, assigned their address to the coin, and dropped it into chat.

The next morning we woke to the winner having gotten an error state on redemption -- meaning the tile was not transferred from LandGranter.sol to their wallet like it was to mine. Upon examination, the coin redemption flow was blocked on a different require statement this time:

      _coinCreator[tokenId] == address(0),
      'Coin Creator already exists for this token'

The require statement only proceeds if this the coinCreator field is equal to address(0) (null, or blank). But in this case, the address was filled. It had been filled earlier during my test redemption; saving the gorblin address as the creator. The only way the field is cleared is when resetTile(x, y) is called... something I bypassed when transferring the tile directly into the custody of LandGranter.sol. In other words, the contract thought the coin is already out in the wild when in fact it was holding it.

To solve this I figured I could simply call resetTile(x, y) and have it go through the regular housekeeping process. I did so and was met with: execution reverted: Coin Creator already exists for this token. Well, shit.

I assembled the team as CJ joined me from a coffee shop in NYC and Kristen and Dave from Atlanta as we tried to sort out what was going and how we could stop it from going. The problem was simple: resetTile(x, y) would only proceed if two conditions were met:

We were in an interesting situation. LandGranter.sol had the tile in its possession, but its knowledge about the state of this tile was out of sync from the tile itself. It had a coinCreator field assigned to this tile while it needed to be blank. And the only possible way to reset this field was for someone else to own this coin -- but it was impossible to redeem and transfer out of its inventory.

This was Not Good.

getting the tile un-stuck

Working with the blockchain is different from traditional server/client paradigms for the simple reason there is nobody with sudo access who can fix a bad record in a database, however grumpy they might be for being woken at 4 AM. A smart contract is just that: a contract. It's human-readable, machine-parseable, and simple to reason about. But deviating outside the pre-established rules is impossible as you'd be arguing with a planet-sized computer. There is no choice but to work within or around its structure.

What we did have is the original private key of the deployment wallet of the contacts. This gave us access to a limited set of admin functionality -- strictly pre-defined and the scope unchangeable. We left the following two admin functions for ourselves: setLandGranter and setRenderer; each allowing us to re-define a portion of the code -- but only with another module that fits the expected shape exactly.

After exploring several other simpler paths to freeing the tile, we were left with only one option: temporarily swap out the LandGranter.sol contract that TerraMasu.sol had on file to a new, blank LandGranterB.sol contract to satisfy the second condition (that the LandGranter.sol on file was not the owner of the stuck tile). Neither of us liked it -- but it was the only path forward we could see.

side note: the ability to upgrade the renderer contract is particularly interesting for us: since the data-to-SVG logic lives outside the contract and only the raw data is kept in the original, we can improve the rendering as best practices evolve and CJ figures out how to squeeze still more performance out of the EVM while retaining the integrity of the token data on TerraMasu.sol.

We triple-checked the addresses and proceeded. So resetTile(x, y) was called (moving the tile from LandGranter.sol to LandGranterB.sol), the coinCreator field reset, and the original LandGratner.sol was re-linked to TerraMasu.sol contract. Finally we called resetTile(x, y) once more - this time moving it from LandGranterB.sol back to to LandGranter.sol.

The recovery approach worked because in the period of time LandGranterB.sol was in place -- resetTile(x, y) saw that the tile owner was not LandGranter.sol but in fact LandGranterB.sol, allowing the transaction to proceed. If we had not left this door open for ourselves, I'm not sure what Plan C would've been. It's a good thing we did.


Some of my share of live-in-prod deployments were planned, calm, and followed a checklist. Others were at 4am and might better be described as exorcisms. But the feeling of modifying an immutable database -- using code which also happens to be the only code that can write to that database -- is distinctly more electrifying.

Once a contract exists on the chain, you hope it's coded it defensively enough to keep others out but open enough to leave yourself wiggle room out of trouble. It's a fine balance, and one I'm just starting to grasp the implications and patterns of writing in such an architecture.

Some other thoughts:

gorblin comin'