Better Airdrops (Part II-the Details)

Relic Protocol
ChainLight Blog & Research
8 min readNov 14, 2022

--

After reading through Part I, we should now know a bit better what the problems are with airdrops and why it would be nice to use historical state from a project such as Relic, let’s get into exactly how to implement this.

This example will go through how to build an airdrop to trustlessly distribute our new airdrop token as a 1:1 match for any tokens the user held in block 15,000,000. Our code will be split up into the on-chain component and the front end that users can easily interact with.

We won’t go over every line of the source, but we’ll at least cover the main aspects of how things are done so it should be easy to modify for your own needs. You can also see the finished example in action.

Smart Contract

While the code is available online, we’ll go into it step by step here.

In Solidity we’ll import a few things provided by Relic:

  • interfaces/IReliquary.sol provides the interface to the Reliquary, which stores the proven historical state data we want to access
  • lib/Storage.sol provides helpers for interfacing with contract storage slots
  • lib/FactSigs.sol provides the signatures we must pass to the Reliquary to indicate what data we’re trying to verify

And since we’re making an airdrop ERC-20 token, we’ll just import the ERC-20 template from OpenZeppelin.

/// SPDX-License-Identifier: MIT
pragma solidity >=0.8.12;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@relicprotocol/contracts/lib/FactSigs.sol";
import "@relicprotocol/contracts/lib/Storage.sol";
import "@relicprotocol/contracts/interfaces/IReliquary.sol";
contract Token is ERC20 {
}

Now the first thing we need to do is figure out the storage slot structure for our token. Assuming a pretty standard ERC-20 token, this is likely just a matter of determining the storage slot offset for something like a balance map of owners to their tokens. Notably, it doesn’t matter if this is public or private, either way it’s still on the blockchain!

For most token type contracts to figure out the offset, you can use this super simple tool. If the contract has a more complicated structure, you may need to manually inspect it to determine how to do this. This guide can help in that situation.

For USDT, we just enter the address of USDT and a current token holder, and we get an offset of 2.

Slot helper tool example: it’s very simple!

We can stick that in a simple helper function that is public in case users of our contract want to verify any of the internal calculations.

function slotForUSDTBalance(address who) public pure returns (bytes32) {
return Storage.mapElemSlot(
bytes32(uint(2)),
bytes32(uint256(uint160(who)))
);
}

With that in hand, we can start coding our smart contract. We can simply ask Relic for the value at that storage slot for the block we care about and interpret it as a uint256. Because we are querying a storage slot value, we use the storageSlotFactSig to create our query.

We will use then query the Reliquary with the verifyFactNoFee function.

(
bool exists, uint64 version, bytes memory data
) = reliquary.verifyFactNoFee(
USDT,
FactSigs.storageSlotFactSig(
slotForUSDTBalance(who),
blockNum
)
);

We care here about the exists (to ensure someone has proven this storage slot) and the data (to see what was present in the storage slot). The version field can be used if we care about which internal prover Relic used to prove the fact, but that doesn’t really matter here.

Assuming the value exists, we can parse the dataeasily using

require(exists, "storage proof missing");
uint priorUSDTBalance = Storage.parseUint256(data);

That’s basically all we need — just a few pieces of glue and sanity checks remain. We need to ensure once people claim their tokens, they can’t repeat multiple times. This just requires a simple map like mapping(address => bool) public claimed to easily track. We can stick everything together into a mint function and call the OpenZeppelin _mintfunction with the priorUSDTBalance to actually transfer the amount to our user.

function mint(address who) external {
require(claimed[who] == false, "already claimed");
    (bool exists, , bytes memory data) = reliquary.verifyFactNoFee(
USDT,
FactSigs.storageSlotFactSig(
slotForUSDTBalance(who),
blockNum
)
);

require(exists, "storage proof missing");
claimed[who] = true;
    uint priorUSDTBalance = Storage.parseUint256(data);
_mint(who, priorUSDTBalance);
}

Then a few other fields to set up and establish the address for USDT and the reliquary, as well as setting the block to use for our claim window snapshot. Since we’re using USDT, we should also set decimals in our contract to return 6, to match the native USDT value.

That’s pretty much it! You can check out the full code here.

We also need to deploy our contract, being sure to pass in addresses for the Reliquary and USDT, as well as setting which block we want to use for our checks.

Front End

Once again feel free to skip ahead to the finished source if you wish.

To make things easier for users, we also need a front end they can use to interact with this. The main necessary feature here is the ability for users to prove their USDT claims and mint their airdrop without needing to worry about the details.

Once again, Relic SDKs make this simple. We’ll use ethers as well to easily interact with Ethereum.

import * as ethers from "ethers";
import { RelicClient, utils } from "@relicprotocol/client";

We’ll need to reference our addresses for USDT and our newly deployed Airdrop contract. We’ll also want a multicall contract for a bit later on.

const addresses = {
ADUSDT: "0xc2Ed14521e009FDe80FC610375769E0C292FC12d",
USDT: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
MAKERDAOMULTICALL: "0xeefBa1e63905eF1D7ACbA5a8513c70307C1cE441",
};

Creating the actual proof is pretty simple:

const client = await RelicClient.fromProvider(provider.value);
const prover = await client.storageSlotProver();
const storageSlot = utils.mapElemSlot(
2, // The slot we found earlier
userAddress, // The customer's wallet address
);
const proveTx = await prover.prove(
BLOCK,
addresses.USDT,
storageSlot,
);

This will use Relic’s Web2 API to generate a proof of the specified storage slot in the USDT contract at the specified block. Note that while this uses a Web2 endpoint controlled by Relic for convenience, the actual proof is verified on-chain — so there is no room for manipulation by a centralized authority. It is also possible to construct the same proof yourself with an Ethereum archive node, it’s just more tedious.

Before we issue this proof on chain, we can also aggregate it with our actual mint transaction. This way users don’t have to approve (and pay for) two separate transactions.

const ADUSDT = new ethers.Contract(addresses.ADUSDT, [
"function mint(address who) public"
]);
const Multicall = new ethers.Contract(addresses.MAKERDAOMULTICALL, [
"function aggregate(tuple(address target, bytes callData)[] memory calls) public returns (uint256 blockNumber, bytes[] memory returnData)"
]);
const mintTx = await adusdt.populateTransaction.mint(userAddress);
const multicall = Multicall.connect(signer.value);
proveAndMintTx = [
{ target: proveTx.to, callData: proveTx.data },
{ target: mintTx.to, callData: mintTx.data },
]
const res = await multicall.aggregate(proveAndMintTx);
await res.wait();

Keep in mind here the mint function on chain only needs to know the address of the user. The actual value of the tokens distributed to them is entirely dependent on what they proved in the previous transaction.

There’s quite a bit still needed to turn this into a usable web front end, but this handles the basics for issuing a proof to the Reliquary.

A few things that would help user friendliness are:

  • stopping the user if they have already claimed their tokens (the example code handles this)
  • showing what the results of a proof and mint will be before doing it on chain (the example code handles this)
  • only minting and not proving if the user has already proven the slot

The full source adds more overhead to use Vue and interact with web wallets cleanly. However the main body of the code is straightforward.

Modifications

While this example was straight-forward, one can easily add slight changes to better suit their needs.

Block Range

While our airdrop only handled the use case of token holders from a single block, you may wish to extend this to a wider range of blocks. Luckily, this is quite simple! Users will be incentivized to pick the block for themselves with the largest balance, so we just need to update our code to check the low and high end of the range, and then modify the mint function to also accept the block number for which a user is claiming their airdrop.

Payout Structure

This example uses a 1:1 payout format, meaning each USDT held gives one airdrop token. However, this could trivially be modified. For example, we may wish to scale the rewards to benefit smaller holders, so we can take the square root of the old balance before minting. Or we can reward all holders equally, so we simply allow a user to mint one token regardless of their prior holdings.

import "@openzeppelin/contracts/utils/math/Math.sol";
...
_mint(who, Math.sqrt(priorUSDTBalance));  // sqrt of balance

Any deterministic payout structure can easily be added with just a simple modification to our mint function, such as this square root modification. And importantly, as the payout structure must be committed to the blockchain, it is completely transparent and decentralized. If the airdrop creator attempted to pay out more to their friends, the logic for that exception would need to be committed.

Gas Optimizations

There are two main gas optimizations that the Relic team plans to implement that should decrease the cost of these proofs. The first is to allow projects to commit intermediate values of the proof for later use. For example, the account state Merkle Root for the USDT account at block 15,000,000 could be committed and re-used by all users of this example airdrop, cutting down on gas significantly.

Fortunately, this change will not require modifications to the code. This change can be done transparently by Relic and update the API code when completed to make the proveTx use the most efficient method possible.

The second main optimization is to not require the proven storage slot value be stored on-chain to access it. Because we are doing our prove and mint operations in the same transaction, we may be able to optimize some of those costs. However, the airdrop contract will need to be modified to take advantage of this optimization.

Eligibility Criteria

The criteria for this airdrop were very simple, just based on owning a single token. However, there are a number of other criteria that one may wish to use.

Any other type of activity that can be viewed from a storage slot can easily be worked into this same format — just change around which storage slot is being proven and you’re good to go.

Other types of activity are currently in-the-works for Relic, such as transaction receipts and log events. (Follow us on Twitter or Discord to stay up-to-date for when these are available in the SDK). With these criteria such as “all users that interacted with a certain contract” will be as simple to use as the storage slot check.

For combining multiple eligibility criteria, things are a bit more complicated. The main difficulty is simply creating a simple way for users to select the criteria that apply to them. Chaining together multiple facts from Relic is not difficult to do in a smart contract, but doing so may not be gas efficient.

Wrap Up

Overall, the code required to make this example work is quite small! Most of the complexity simply arises from making a web front end that is easy to use. Using Relic to verify facts on chain is only a couple lines of code, and generating the proofs in Javascript is similar.

Although this is already a very powerful primitive for on chain applications, Relic is under heavy development to add more features. If you want to stay in touch for updates, suggest new features, ask questions, or just hang out, join our Discord or follow us on Twitter.

And as a reminder, you can view the source code for this (and other!) examples on our Github. Check back regularly as more projects are added to give an idea of what can be built with Relic.

--

--