Robodrop p2.1: autonomous smart airdrops – with reinforced rules.
In my last blog post we developed a simple autonomous airdrop smart contract that allows claimants to recieve an amount of TokenY if and only if:
- The claimant owns a certain NFT
- The claimant had an average of 200 TokenX between dateX and dateY
The first rule can be implemented onchain. The second rule however requires historical blockchain data and therefore we cant code the logic in a smart contract. We used a Merkle tree approach in the last post to store the hash of winning addresses, this however does not guarantee that the right logic has been used to produce the winning addresses. So we need a way to allow all parties that the right logic was applied.
Axiom – Zero Knowledge Coprocessors.
Axiom allows smart contracts to trustlessly compute over the entire history of Ethereum, including transactions and receipts. Developers can send on-chain queries into Axiom, which are trustlessly fulfilled with ZK-verified results sent in a callback to the developer’s smart contract. This allows developers to build on-chain applications which access more data at a lower cost without adding additional trust assumptions.
Axiom Docs
This looks like what we might need to perform our calcs on historical data. As if by a miracle, the Axiom template happen to implement rule 2 for us.
Axiom will allow us to prove computation trustlessly. The computation in our context/usecase is the fetching of historical data. We will be able to create the circuits (programs expressed in a way that they can be used in trustless computation) in TypeScript. The template circuit provided by Axiom already provides us with an average balance an account has between two periods (2 block numbers). The Axiom template provides us with a working example that gives the average balance of a given account, We’ll only modify the given template slightly.
import {
add,
sub,
mul,
div,
checkLessThan,
addToCallback,
CircuitValue,
CircuitValue256,
constant,
witness,
getAccount,
} from "@axiom-crypto/client";
// For type safety, define the input types to your circuit here.
// These should be the _variable_ inputs to your circuit. Constants can be hard-coded into the circuit itself.
export interface CircuitInputs {
blockNumber: CircuitValue;
address: CircuitValue;
}
// Default inputs to use for compiling the circuit. These values should be different than the inputs fed into
// the circuit at proving time.
export const defaultInputs = {
"blockNumber": 4000000,
"address": "0xEaa455e4291742eC362Bc21a8C46E5F2b5ed4701"
}
// The function name `circuit` is searched for by default by our Axiom CLI; if you decide to
// change the function name, you'll also need to ensure that you also pass the Axiom CLI flag
// `-f <circuitFunctionName>` for it to work
export const circuit = async (inputs: CircuitInputs) => {
// Number of samples to take. Note that this must be a constant value and NOT an input because the size of
// the circuit must be known at compile time.
const samples = 8;
// Number of blocks between each sample.
const spacing = 900;
// Validate that the block number is greater than the number of samples times the spacing
if (inputs.blockNumber.value() <= (samples * spacing)) {
throw new Error("Block number must be greater than the number of samples times the spacing");
}
// Perform the block number validation in the circuit as well
checkLessThan(mul(samples, spacing), inputs.blockNumber);
// Get account balance at the sample block numbers
let sampledAccounts = new Array(samples);
for (let i = 0; i < samples; i++) {
const sampleBlockNumber: CircuitValue = sub(inputs.blockNumber, mul(spacing, i));
const account = getAccount(sampleBlockNumber, inputs.address);
sampledAccounts[i] = account;
}
// Accumulate all of the balances to the `total` value
let total = constant(0);
for (const account of sampledAccounts) {
const balance: CircuitValue256 = await account.balance();
total = add(total, balance.lo());
}
// Divide the total amount by the number of samples to get the average value
const average: CircuitValue = div(total, samples);
// We call `addToCallback` on all values that we would like to be passed to our contract after the circuit has
// been proven in ZK. The values can then be handled by our contract once the prover calls the callback function.
addToCallback(inputs.blockNumber);
addToCallback(inputs.address);
addToCallback(average);
};
So Axiom allows us to do verifiable computation, this computation in our case is querying historical ethereum blockchain data.
As we can see, Axiom circuit SDK provides us with verifiable functions that we can use such as checkLessThan, sub, getAccount, account.balance.
Asynchronous: One major modification to the Robodrop contract is that we have to make qualify the user asynchronously. This means that the user now will not be interacting with our smart contract directly, rather the user will submit the circuit with the circuit inputs to the Axiom smart contracts that will in turn use thier infrastructure to prove and verify the circuits. This is what the Robodrop smart contract will look like now.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import { IERC721 } from "@openzeppelin-contracts/token/ERC721/IERC721.sol";
import { IERC20 } from "@openzeppelin-contracts/token/ERC20/IERC20.sol";
import { Ownable } from "@openzeppelin-contracts/access/Ownable.sol";
import { AxiomV2Client } from "@axiom-crypto/v2-periphery/client/AxiomV2Client.sol";
error ClaimedAlready();
error NotHodler();
error NotNFTOwner();
contract Robodrop is AxiomV2Client, Ownable {
event AirDropClaimed(address blockNumber, address addr, uint256 nftId);
bytes32 immutable QUERY_SCHEMA;
uint64 immutable SOURCE_CHAIN_ID;
IERC721 public nft;
uint public airdropAmount;
uint public requiredAverageBalance;
mapping(address => bool) public hasClaimed;
constructor(address _nftAddress, uint _airdropAmount, uint _averageBalance,
address _axiomV2QueryAddress, uint64 _callbackSourceChainId,
bytes32 _querySchema)
Ownable(msg.sender)
AxiomV2Client(_axiomV2QueryAddress)
{
QUERY_SCHEMA = _querySchema;
SOURCE_CHAIN_ID = _callbackSourceChainId;
nft = IERC721(_nftAddress);
airdropAmount = _airdropAmount;
requiredAverageBalance = _averageBalance;
}
/// @inheritdoc AxiomV2Client
function _validateAxiomV2Call(
AxiomCallbackType, // callbackType,
uint64 sourceChainId,
address, // caller,
bytes32 querySchema,
uint256, // queryId,
bytes calldata // extraData
) internal view override {
// Add your validation logic here for checking the callback responses
require(sourceChainId == SOURCE_CHAIN_ID, "Source chain ID does not match");
require(querySchema == QUERY_SCHEMA, "Invalid query schema");
}
/// @inheritdoc AxiomV2Client
function _axiomV2Callback(
uint64, // sourceChainId,
address, // caller,
bytes32, // querySchema,
uint256, // queryId,
bytes32[] calldata axiomResults,
bytes calldata // extraData
) internal override {
// The callback from the Axiom ZK circuit proof comes out here and we can handle the results from the
// `axiomResults` array. Values should be converted into their original types to be used properly.
address addr = address(uint160(uint256(axiomResults[1])));
uint256 userAverageBalance = uint256(axiomResults[2]);
uint256 nftId = uint256(axiomResults[3]);
address claimToken = address(uint160(uint256(axiomResults[4])));
if(userAverageBalance < requiredAverageBalance) revert NotHodler();
if(hasClaimed[addr]) revert ClaimedAlready();
if(addr != nft.ownerOf(nftId)) revert NotNFTOwner();
// airdrop to receiver
IERC20(claimToken).transfer(addr, airdropAmount);
hasClaimed[addr] = true;
emit AirDropClaimed(claimToken, addr, nftId);
}
}
We can see that most of the logic to qualify the user is in the callback function _axiomV2Callback. We can also see that axiomResults array that is returned from the circuit from addToCallback(inputs.blockNumber), addToCallback(inputs.address) and addToCallback(average). Anyway here is the modified tests
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import { Robodrop, ClaimedAlready, NotHodler, NotNFTOwner} from "../src/Robodrop.sol";
import "../src/UselessToken.sol";
import "@axiom-crypto/axiom-std/AxiomTest.sol";
contract RobodropTest is AxiomTest {
using Axiom for Query;
struct AxiomInput {
uint64 blockNumber;
address addr;
uint64 nftId;
address claimToken;
}
Robodrop public robodrop;
address public constant VEE_ADD = 0x7A3159290ba6672c3Cc5741F6BcDF5261266CD15;
address ownerOfToken = 0xC015f727B997c34767D275C306B0527E900Da2E9; // make this a DAO may be in the future.
uint64 tokenId = 36718;
address owner = 0x616D5b49738F6bc5290d5222115526339D9B6bB1;
address randomAccount = 0xF55ede940a8038aDAEd34e497fd4ac4Ce3D173c9;
uint requiredAverageBalance = 1;
UselessToken useless;
AxiomInput public input;
bytes32 public querySchema;
function setUp() public {
_createSelectForkAndSetupAxiom("sepolia", 5_518_964);
// create an account
vm.startPrank(ownerOfToken);
useless = new UselessToken();
vm.stopPrank();
querySchema = axiomVm.readCircuit("app/axiom/average.circuit.ts");
robodrop = new Robodrop(VEE_ADD, 10, requiredAverageBalance, axiomV2QueryAddress, uint64(block.chainid), querySchema);
vm.startPrank(ownerOfToken);
useless.transfer(address(robodrop), 20000);
vm.stopPrank();
}
/// @dev Simple demonstration of testing an Axiom client contract using Axiom cheatcodes
function test_airdrop_claimed() public {
input = AxiomInput({
blockNumber: 5_518_964,
addr: address(owner),
nftId: tokenId,
claimToken: address(useless)
});
Query memory q = query(querySchema, abi.encode(input), address(robodrop));
// send the query to Axiom
q.send();
// prank fulfillment of the query, returning the Axiom results
bytes32[] memory results = q.prankFulfill();
// parse Axiom results and verify length is as expected
assertEq(results.length, 5);
uint256 avg = uint256(results[2]);
assertLt(avg, 4 ether);
assertEq(robodrop.hasClaimed(owner), true);
assertEq(useless.balanceOf(owner), 10);
}
function test_claimed_already() public {
input = AxiomInput({
blockNumber: 5_518_964,
addr: address(owner),
nftId: tokenId,
claimToken: address(useless)
});
Query memory q = query(querySchema, abi.encode(input), address(robodrop));
q.send();
q.prankFulfill();
q.send();
vm.expectRevert(ClaimedAlready.selector);
q.prankFulfill();
}
function test_no_NFT() public {
input = AxiomInput({
blockNumber: 5_518_964,
addr: address(randomAccount),
nftId: tokenId,
claimToken: address(useless)
});
Query memory q = query(querySchema, abi.encode(input), address(robodrop));
q.send();
vm.expectRevert(NotNFTOwner.selector);
q.prankFulfill();
}
}
Et voila, we simulate the execution of the circuit and the callback with prankFulfill function. Couple of problems to mention about the tests is that the setup of the tests can be done better, i.e. not depend on hardcoded qualified address rather we need to create and set those accounts up dynamically for the tests and we’re testing the 2 out of the 3 reverts. Anyway that’ll do for now. The main thing is that we have now implemented the logic for our 2 airdrop rules.