Optimising snapshots for off-chain systems
When we first came up with the Nanopass Black Box system, we brainstormed various ideas on how to achieve the goal of being able to airdrop users prizes for holding a Nanopass for over one week. We explored various on-chain and off-chain options, and eventually settled on the snapshot mechanism.
This turned out to be a relatively good choice as our community benefits from the gas-less and hassle free nature, while still maintaining inter-connectivity with the blockchain ecosystem, at the expense of some de-centralisation.
The basic requirements of the black box system are:
- A user holds a Nanopass for one week
- They receive a black box containing a prize or fragments
- If the Nanopass is traded away during that week, then the user does not qualify for a black box until they hold it for one week again
After exploring several options on how to accomplish this, we landed on the snapshot system. A snapshot is taken every week on Monday, these snapshots are then compared with the previous week’s snapshot, and if the address is equal for a particular user, that means they held it for one. This has the side effect of allowing users to trade away the Nanopass and buy it back later, which we felt was an acceptable trade-off.
Maintaining a Geth node, rolling back to a specific block, freezing it, and then taking a snapshot was outside of the scope of what we were trying to accomplish. Services like Infura or Alchemy exist to allow scalable off-chain interaction with the blockchain, so we decided to leverage those instead.
Because these snapshots happen live, speed is important. If a snapshot takes 10–15 minutes to complete, some addresses may be evaluated just before the deadline, and others after, potentially causing some confusion or inaccurate results. Our users typically avoided trading during the snapshot time, so any issues we encountered were extremely minimal.
The initial implementation was very bare-bones, using Infura, it called the standard
ownerOf for every token id, and walla, you have a snapshot:
This took nearly 20 minutes to complete, and of course we can already do much better. Since these are async calls, we can just fetch them at the same time. However, making 5555 simultaneous calls to Infura is not feasible, as the sockets quickly max out (depending on your underlying implementation, you may face other issues). So we fetch them in batches (Here
MAX_CURRENCY = 200 ):
This was “good enough” for the initial release, and everyone (from a functional perspective) was satisfied. So this section of code was left alone for a few months as we focused on other priorities.
There are several issues however with this implementation:
- Snapshots are still taking 3–5 minutes to complete, which is not “instant” enough for my liking
- We are making 5555 calls to Infura every time, which for this usecase is OK, but if we plan to expand snapshots to other usecases, we could run into cost and/or plan limit issues. It just feels wasteful too
Moving Code Back On-Chain
Since the number of Infura calls is the main blocker here, a simple solution is to move the majority of the calculation back on-chain, and only make the minimum amount of calls, reducing network traffic and overall latency.
Unfortunately, the Nanopass NFT contract was already deployed when these requirements were relatively unclear, and it lacked a function to easily obtain a snapshot of all holders. Originally when we foreseed this use-case, we did put in a
walletOfOwner function which returns all the tokens held by a specific address, but this doesn’t help us in this scenario (Since we’re trying to find out what address holds each token, the direct opposite).
Proxy calls to the rescue. Since smart contracts are allowed to call other smart contracts, we simply need to write one that calls
ownerOf for us “on-chain” (but actually running on Infura/Alchemy servers), and return an array of addresses, drastically reducing the latency and the amount of calls required. This does incur a one-time deployment fee, but it should pay off in the long run:
In order to make the deployment fee worth it, we go a step further and allow ANY IERC721 token address to be passed in to the contract. You can use this to perform a Kaiju holders snapshot, a CyberKongz holders snapshot, and so on, and so forth. A range is also taken in-case retrieving too many entries at once causes any issues. This can be called simply as
contract.takeSnapshot('0xContractAddress', 0, MAX_SUPPLY) if you would like. I however like to split it up like so (I’ve stuck with 200 again for portion size):
In the above,
portions = 200 , so for our collection size it’s split up across 28 requests, which all run simultaneously, reducing the snapshot time from a few minutes to 1 second! This is a stark contrast from earlier when we were performing 200 requests at time, for 5555 requests in total.
Since this contract can be used for any NFT project, I present the $100 or so gas that I spent to push it as sort of a “gift” to the NFT community, the contract with verified source code can be found on EtherScan here: https://etherscan.io/address/0xf3fB3F2Dab388Dc0d868be4A349aa1e8939D315D#code
You can even use the EtherScan read contract page to directly take a snapshot of any NFT project, here is me taking a snapshot of the entire KaijuKingz supply (I grabbed the total supply from their contract page first):
Well I hope this has been useful, or entertaining at least. Leave any comments down below or @ me on Twitter for a more in depth conversation.
—- NANO Kai