How to Build a Zero Knowledge Airdrop App with Mina Protocol: A Step-by-Step Guide

How to Build a Zero Knowledge Airdrop App with Mina Protocol: A Step-by-Step Guide

Zero-knowledge proofs (ZK-Proofs) have revolutionized the field of cryptography and secure computing, offering solutions to real-world problems. In essence, a zero-knowledge proof allows for the validation of a statement without disclosing the statement itself.

This might feel unreal but it is achieved by having the verifier ask the prover to perform actions that can only be accurately completed if the prover possesses the necessary information. Real-world examples of ZK-Proofs include StarkNet which enables developers to deploy smart contracts on an Ethereum-based ZK-Rollup, StarkEx is a layer-2 scalability solution built on Ethereum that uses STARK proofs to validate self-custodial transactions.


What is Mina Protocol

Mina Protocol is a revolutionary blockchain platform that distinguishes itself as the lightest blockchain in the world, with a mere size of 22kB. It is powered by participants and aims to establish a secure and democratic infrastructure for a future that prioritizes our collective well-being.

You might be wondering, how is it possible for Mina to achieve such remarkable lightness? The answer lies in its utilization of zero-knowledge proofs, specifically a technical innovation called zk-SNARKs, in conjunction with a consensus mechanism known as Proof-of-Stake (PoS).

Mina Protocol employs a concept called "Proof of Validity" to reduce file size and minimize the computing power required by network nodes to participate in reaching consensus. As the blockchain expands, Mina incorporates zk-SNARK "snapshots" or blockchain summaries. These snapshots contain proof of the blockchain's validity based on metadata, rather than storing the entire blockchain history. In simpler terms, they serve as evidence of the data's authenticity without necessitating access to the full blockchain history.


What are we building?

In this article, we will be building an Airdrop zkApp designed specifically for members of a Developer DAO. To achieve this, we will utilize oracles, a technology that retrieves data from external sources and verifies it off-chain. Throughout this article, we will explore the technical aspects of building this airdrop zkApp, using SnarkyJS and Mina Protocol.

You can find the entire code for the project in the GitHub Repository


Prerequisites

To install the Mina zkApp CLI:

npm install -g zkapp-cli

Setting Up the Development Environment

let's create a new directory called "zkDrop" which will serve as our root directory. Within this directory, we will initiate a new zkApp by running the following command in the terminal:

zk project contracts

This command will generate a new directory named "contracts" and install the necessary packages. During the setup process, you will be prompted to create an accompanying UI for the project. In this case, select "none" as we won't be creating a UI this time.

Now that we have our zkApp set up, navigate to the root of the project and create a new folder called "oracle". This folder will host an Express.js server responsible for handling REST API calls to verify whether a user is a member of the DAO.

After completing these steps, your project structure should resemble the following:

├── 📁 zkDrop
|  ├── oracle
|  ├── contracts

With this directory structure in, we are ready to proceed with the development of our airdrop zkApp.


Creating an Oracle

Navigate to the "oracle" directory and initialize an empty npm project by executing the following command in the terminal

cd oracle 
npm init -y

Next, we need to install the necessary packages to set up a basic Express.JS API server with TypeScript. Install the required packages by running the following command:

npm install --save express dotenv alchemy-sdk snarkyjs
npm install --save-dev typescript ts-node @types/node @types/express

This command will install Express.js and its corresponding TypeScript types. We will utilize SnarkyJS to create signatures that will be verified in our smart contract. Additionally, we will install Alchemy SDK to retrieve information such as the number of code tokens or NFTs owned by a specific address. Lastly, we require dotenv to handle environment variables within our application.

To use TypeScript we need to make a new file called tsconfig.json and set the required configuration.

// 👇 tsconfig.json
{
    "compilerOptions": {
        "target": "es5",
        "module": "commonjs",
        "outDir": "./dist",
        "rootDir": "./",
        "baseUrl": "./",
        "esModuleInterop": true
    }
}

Create a .gitignore file so that you don't push your node modules or environments to the tree.

// 👇 .gitignore

# node modules
node_modules

# env files
.env
.env.*

To run the express API server we need to configure some scripts in our package.json file.

// 👇 package.json
{
    "name": "oracle",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
        "start": "ts-node ./api/server.ts",
        "start:prod": "npm run build && node ./dist/api/server.js",
        "build": "npx tsc",
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "dependencies": {
        "alchemy-sdk": "^2.9.1",
        "dotenv": "^16.3.1",
        "express": "^4.18.2",
        "snarkyjs": "^0.11.3"
    },
    "devDependencies": {
        "@types/express": "^4.17.17",
        "@types/node": "^20.3.2",
        "ts-node": "^10.9.1",
        "typescript": "^5.1.5"
    }
}

Lastly, we need to set some environment variables in .env.local file.

// 👇 .env.local
ALCHEMY_API_KEY='alchemy_api_key'
PRIVATE_KEY='mina_wallet_private_key'
PORT=3001

To obtain your Alchemy API key, you can visit the Alchemy Dashboard.

Now that we have completed the initial setup, we can proceed with constructing the routes.

First, let's import the necessary packages:

// 👇 api/server.ts
import express from 'express';
import { Request, Response } from 'express';
import { Network, Alchemy } from 'alchemy-sdk';
import { PrivateKey, Field, Signature } from 'snarkyjs';

const dotenv = require('dotenv');
dotenv.config({ path: './.env.local' });

Next, we will initialize an instance of Alchemy SDK:

// 👇 api/server.ts
...

const settings = {
    apiKey: process.env.ALCHEMY_API_KEY,
    network: Network.ETH_MAINNET,
};

const alchemy = new Alchemy(settings);
const codeAddress = '0xb24cd494faE4C180A89975F1328Eab2a7D5d8f11'; // CODE Token Address
const DevDaoContract = '0x25ed58c027921E14D86380eA2646E3a1B5C55A8b'; // Devs for Revolution NFT Contract

The codeAddress and DevDaoContract variables represent the ERC-20 and ERC-721 tokens required to join the DAO. We will be checking these conditions in our API.

Now, let's create a new instance of Express, listen on port 3001 (specified in the environment variables), and set up a POST method.

// 👇 api/server.ts
...

const app = express();
app.use(express.json());

app.post('/', async (req: Request, res: Response) => {
    // TODO
});

app.listen(process.env.PORT, () => {
    console.log(`Application started on port ${process.env.PORT}!`);
});

Now, within the post method, we will retrieve the Ethereum account address from the request body and obtain the corresponding private and public keys for the Mina wallet.

// 👇 api/server.ts

app.post('/', async (req: Request, res: Response) => {
    const address: string = req.body.address;
    if (!address) res.status(400).send({ error: 'Address is required' });

    // Oracle Public and Private Key
    const privateKey = PrivateKey.fromBase58(process.env.PRIVATE_KEY);
    const publicKey = privateKey.toPublicKey();

    // TODO: Verify conditions
});

This private key will be utilized to create verifiable signatures. Next, we will fetch the CODE token balance and the total number of NFTs owned by the address using the Alchemy SDK's getTokenBalances and getNftsForOwner methods.

// 👇 api/server.ts

app.post('/', async (req: Request, res: Response) => {
    // ...

    // Get CODE BALANCE
    const codeBalance = await alchemy.core
        .getTokenBalances(address, [codeAddress])
        .then((result) => Number(result?.tokenBalances?.at(0)?.tokenBalance));

    // Get NFTs
    const totalNFTs = await alchemy.nft
        .getNftsForOwner(address, {
            contractAddresses: [DevDaoContract],
        })
        .then((result) => result?.totalCount);
    // TODO: Create and send Signatures
});

This will provide us with the CODE token balance and the total number of NFTs owned by the address. The condition joining the DAO is a balance of 400 tokens or at least 1 NFT. We will check these conditions in our smart contract. Next, we will create a signature that will be passed into the to check that the data came from a trusted oracle.

// 👇 api/server.ts

app.post('/', async (req: Request, res: Response) => {
    // ...

    const signature = Signature.create(privateKey, [
        Field(codeBalance),
        Field(totalNFTs),
    ]);
});

The data sent by the API will be in this format, data will be an object.

{
    data,
    signature,
    publicKey
}
// 👇 api/server.ts

app.post('/', async (req: Request, res: Response) => {
    // ...

    res.send({
        data: { codeBalance: codeBalance, totalNFTs: totalNFTs },
        signature: signature,
        publicKey: publicKey,
    });
});

With the Oracle setup complete, you can test it by running the following command:

npm start

Then, execute following fetch request:

const res = await fetch('http://localhost:3001', {
    method: 'POST',
    body: JSON.stringify({
        address: '0xBF4979305B43B0eB5Bb6a5C67ffB89408803d3e1',
    }),
    headers: {
        'Content-Type': 'application/json',
    },
});

const data = await res.json();
console.log(data);

This will produce an output similar to the following:

{
  data: { codeBalance: 400050000000000000000, totalNFTs: 0 },
  signature: {
    r: '24982705480689229193171307812239670561942981419312568882671222745251068162391',
    s: '1362588844350633482079245233871679629039784347837483236383505108809622211330'
  },
  publicKey: 'B62qkyP8a2RfB5dZbdoRSWQqmbsdkTNWA8aDWNWt8ocndZhL7qqtgFD'
}

Oracle setup complete, we can now proceed to the development of our smart


Creating Smart Contract

To begin, let's remove the template contracts and create new ones. Navigate "contracts" directory and execute the following commands:

rm ./src/Add.ts ./src/Add.test.ts
zk file Airdrop

These commands will remove the existing "Add.ts" and "Add.test.ts" files and create two new files, "Airdrop.ts" and "Airdrop.test.ts", in the "src" directory. Now, we can proceed with writing our contract.

First, let's import the necessary fields from SnarkyJS.

// 👇 src/Airdrop.ts

import {
    Field,
    UInt64,
    SmartContract,
    state,
    State,
    method,
    Permissions,
    PublicKey,
    Signature,
    Provable,
    Bool,
    MerkleMap,
    MerkleMapWitness,
    Mina,
    PrivateKey,
    AccountUpdate,
    Scalar,
} from 'snarkyjs';

Now, let's store our Oracle public key. This is the public key that is obtained by the private key for the oracle.

const ORACLE_PUBLIC_KEY =
    'B62qkyP8a2RfB5dZbdoRSWQqmbsdkTNWA8aDWNWt8ocndZhL7qqtgFD';

For our airdrop logic, we will create a Merkle map. This map essentially associates one value with another, similar to dictionaries in Python. In our case, we will map the public address of an account to a field, which will be either 0 or 1 depending on whether the account has claimed the airdrop or not.

Next, we will create a class called "Airdrop" that extends the "SmartContract" class. We will also define a state variable to store the root of the Merkle map and the Oracle public key.

// 👇 src/Airdrop.ts

// ...

export class Airdrop extends SmartContract {
    @state(PublicKey) oraclePublicKey = State<PublicKey>();
    @state(Field) mapRoot = State<Field>();

    // TODO: ADD LOGIC
}

State variables are stored on-chain and can be accessed by anyone. Now, we need to initialize our smart contract, similar to using a constructor in Solidity. To do this, we will use the init function.

// 👇 src/Airdrop.ts

export class Airdrop extends SmartContract {
    // ...
    init() {
        super.init();
        this.account.permissions.set({
            ...Permissions.default(),
            editState: Permissions.proofOrSignature(),
        });
        this.oraclePublicKey.set(PublicKey.fromBase58(ORACLE_PUBLIC_KEY));
        this.mapRoot.set(initialRoot);
        this.requireSignature();
    }
}

In the above function, we are overriding the editState permission so that users need to provide valid proof or signature to edit the state of the contract. We then initialize our state variables. Additionally, in the last line, specify that the deployment of the contract requires a signature. Note that we have used a new variable called initialRoot, which we will define in our testing stage.

To deposit funds into the contract, we will create a new method (similar to functions in JavaScript) to send funds to the contract.

// 👇 src/Airdrop.ts

export class Airdrop extends SmartContract {
    // ...
    @method deposit(amount: UInt64) {
        let senderUpdate = AccountUpdate.createSigned(this.sender);
        senderUpdate.send({ to: this, amount });
    }
}

This method will simply send Mina tokens from the caller to the contract.

Now, let's move on to our claimAirdrop method. This method takes several arguments:

  1. codeBalance: The amount of CODE tokens.

  2. nftsOwned: The number of NFTs owned.

  3. signature: The signed data from the oracle, which we will obtain by calling the oracle.

  4. keyWitness: The Merkle proof or path to follow to reach the root.

  5. keyToChange: The public address to be changed.

  6. valueBefore: The claimed state of the Airdrop.

// 👇 src/Airdrop.ts

export class Airdrop extends SmartContract {
    // ...
    @method claimAirdrop(
        codeBalance: Field,
        nftsOwned: Field,
        signature: Signature,
        keyWitness: MerkleMapWitness,
        keyToChange: Field,
        valueBefore: Field
    ) {}

}

First, we will retrieve the Oracle public key from the contract state. Then, we will verify the provided signature to ensure that the source is indeed our oracle.

// 👇 src/Airdrop.ts

export class Airdrop extends SmartContract {
    // ...
    @method claimAirdrop(...) {
        // Get Oracle Public Key
        const oraclePublicKey = this.oraclePublicKey.get();
        this.oraclePublicKey.assertEquals(oraclePublicKey);

        // Verify Signature
        const validSignature = signature.verify(oraclePublicKey, [
            codeBalance,
            nftsOwned,
        ]);
        validSignature.assertTrue();

        // TODO: Verify Eligibility
    }
}

Next, we will create two boolean variables called hasEnoughCodeBalance and hasEnoughNFTs to check if the code balance is greater than or equal to 400*10**18 (as ERC-20 has 18 decimals) and if nftsOwned is greater than or equal to one. These variables work similarly to ternary in JavaScript, returning true or false.

// 👇 src/Airdrop.ts

export class Airdrop extends SmartContract {
    // ...

    @method claimAirdrop(...) {
        // ...

        // Check CODE Tokens
        const hasEnoughCodeBalance = Provable.if(
            codeBalance.greaterThanOrEqual(Field(400 * 10 * 18)),
            Bool(true),
            Bool(false)
        );

        // Check NFTs
        const hasEnoughNFTs = Provable.if(
            nftsOwned.greaterThanOrEqual(Field(1)),
            Bool(true),
            Bool(false)
        );
    }
}

Now, we perform a simple or operation on these two booleans to check if the user has either enough CODE tokens or NFTs.

// 👇 src/Airdrop.ts

export class Airdrop extends SmartContract {
    // ...
    @method claimAirdrop(...) {
        // ...

        // Check if the user has either enough CODE Tokens or NFTs
        const isEligible = hasEnoughCodeBalance.or(hasEnoughNFTs);
        isEligible.assertEquals(Bool(true), 'Not Eligible for Airdrop');
    }
}

Now that we know the eligibility for the airdrop, we need to update the Merkle map before sending the airdrop. First, we will retrieve the initial root from the contract state and check if the valueBefore is equal to 0.

To verify the Merkle proof, we will use the keyWitness along with valueBefore calculating the Merkle root and verify if it matches the root stored in the state. Additionally, we will check that valueBefore is equal to the key.

Next, we calculate the Merkle with a new value, which will be equal to Field(1).

Finally, we set the new root.

// 👇 src/Airdrop.ts

export class Airdrop extends SmartContract {
    // ...
    @method claimAirdrop(...) {
        // ..

        // Get the Initial Root and and Check valueBefore
        const initialRoot = this.mapRoot.get();
        this.mapRoot.assertEquals(initialRoot);

        valueBefore.assertEquals(Field(0));

        // Now Update the Root before sending MINA to prevent reentrancy attacks
        const [rootBefore, key] = keyWitness.computeRootAndKey(valueBefore);
        rootBefore.assertEquals(initialRoot, 'Airdrop Already Claimed');

        key.assertEquals(keyToChange);

        const [rootAfter, _] = keyWitness.computeRootAndKey(Field(1));
        this.mapRoot.set(rootAfter);
    }
}

Lastly, we will send MINA tokens, which is 1e10 or 10 MINA, to the user.

// 👇 src/Airdrop.ts

export class Airdrop extends SmartContract {
    // ...
    @method claimAirdrop(...) {
        // ...

        // SEND MINA
        let amount = UInt64.from(1e10);
        this.send({ to: this.sender, amount });

    }
}

That's it! have completed the contract implementation. Now, we can proceed to some tests for our contract.


Testing and Deployment

Now, let's dive into the technical details of testing our zkApp. We'll break it down into four steps: setting up the local chain, deploying the contract, sending tokens to the zkApp, and claiming the airdrop.

Deploying the zkApp

To begin, we'll deploy the zkApp to the local Mina blockchain. First, we'll create an empty Merkle map and set the initial root variable to the root of the map. This initialization step is crucial for the contract.

const map = new MerkleMap();
let initialRoot = map.getRoot();

For Mina contracts that utilize proofs, we need to compile the contract first In this case, we'll disable proofs and create a local chain instance.

let useProof = false;
if (useProof) await Airdrop.compile();

let Local = Mina.LocalBlockchain({ proofsEnabled: useProof });
Mina.setActiveInstance(Local);

Next, we'll set the deployer account from the local chain. Additionally, we'll generate random accounts for testing purposes and store their public and private keys.

const { privateKey: deployerKey, publicKey: deployerAccount } =
    Local.testAccounts[0];

// Test Accounts
let account1Key = PrivateKey.random();
let account1Address = account1Key.toPublicKey();

let account2Key = PrivateKey.random();
let account2Address = account2Key.toPublicKey();

Deploying the zkApp

To deploy the zkApp, we'll generate a random public and private key for the contract. Then, we'll initialize a new instance of the zkApp from the Airdrop class.

const zkAppPrivateKey = PrivateKey.random();
const zkAppAddress = zkAppPrivateKey.toPublicKey();

let zkapp = new Airdrop(zkAppAddress);

Now, we'll create a transaction from the deployerAccount. In this transaction, we'll fund three accounts. To create a new account on Mina, a minimum of 1 MINA is required. We'll use the feePayerUpdate to fund the accounts and deposit 100 MINA to Account 1, which we'll later transfer to the zkApp.

let tx;

tx = await Mina.transaction(deployerAccount, () => {
    const feePayerUpdate = AccountUpdate.fundNewAccount(deployerAccount, 3);
    feePayerUpdate.send({ to: account1Address, amount: 1e11 });
    feePayerUpdate.send({ to: account2Address, amount: 0 });
    zkapp.deploy();
});

Finally, we'll send the transaction and log all the details.

await tx.sign([deployerKey, zkAppPrivateKey]).send();

console.log(`\nDeployed zkApp at ${zkAppAddress.toBase58()}`);
console.log(
    'Balance of Account 1: ',
    Number(Mina.getBalance(account1Address).toString()) / 10 ** 9
);

This process ensures the successful deployment of the zkApp and sets the stage for further testing and interaction with the application.


Funding the zkApp

Now, let's create a transaction to call the deposit method from the zkApp and send all 100 MINA tokens from account 1 to the zkApp. We'll also log the balances.

console.log('\n---------- Sending Funds to zkApp  ----------');
tx = await Mina.transaction(account1Address, () => {
    zkapp.deposit(UInt64.from(1e11));
});
await tx.prove();
await tx.sign([account1Key]).send();

console.log(
    '\nBalance of zkApp: ',
    Number(Mina.getBalance(zkAppAddress).toString()) / 10 ** 9
);
console.log(
    'Balance of Account 1: ',
    Number(Mina.getBalance(account1Address).toString()) / 10 ** 9
);

Claiming Airdrop

Now, we'll write a function called claimAirdrop, which takes in the public key, private key, and Ethereum address.

console.log('\n---------- Claiming Airdrop  ----------');

async function claimAirdrop(
    pb: PublicKey,
    pk: PrivateKey,
    ethAdddress: string
) {}

First, we'll fetch our API with the Ethereum address to get the data and signature.

async function claimAirdrop(...)) {
    const res = await fetch('http://localhost:3001', {
        method: 'POST',
        body: JSON.stringify({
            address: ethAdddress,
        }),
        headers: {
            'Content-Type': 'application/json',
        },
    });

    const { data, signature } = await res.json();
}

Then, we'll convert the public key to the Field type for our contract and calculate the Merkle proof from this field.

async function claimAirdrop(...)) {
    // ...

    const pbToField = Field.fromFields(pb.toFields());
    const witness = map.getWitness(pbToField);
    const valueBefore = Field(0);

    console.log(
        '\nBalance of Account before Airdrop: ',
        Number(Mina.getBalance(pb).toString()) / 10 ** 9
    );
}

Next, we'll create a transaction from the public key to call the claimAirdrop function from the zkApp and pass the necessary arguments. We'll prove the transaction and send it.

async function claimAirdrop(...)) {
    // ... 

    tx = await Mina.transaction(pb, () => {
        zkapp.claimAirdrop(
            Field(data?.codeBalance),
            Field(data?.isNFTHolder),
            new Signature(Field(signature?.r), Scalar.from(signature?.s)),
            witness,
            pbToField,
            valueBefore
        );
    });
    await tx.prove();
    await tx.sign([pk]).send();
}

Finally, we'll log the balances.

async function claimAirdrop(...)) {
    // ...

    console.log(
        'Balance of Account after Airdrop: ',
        Number(Mina.getBalance(pb).toString()) / 10 ** 9
    );
    console.log(
        'Balance of zkApp after Airdrop: ',
        Number(Mina.getBalance(zkAppAddress).toString()) / 10 ** 9
    );
}

For testing purposes, we'll call this function twice from the same address 0xBF4979305BB0eB5Bb6a5C67ffB08803d3e1, which contains 400 CODE. The expected behavior is that the first time should be successful, and the second time should throw an error.

console.log('\n---------- Test 1 ----------');
await claimAirdrop(
    account1Address,
    account1Key,
    '0xBF4979305B43B0eB5Bb6a5C67ffB89408803d3e1'
);

console.log('\n---------- Test 2 ----------');

await claimAirdrop(
    account1Address,
    account1Key,
    '0xBF4979305B43B0eB5Bb6a5C67ffB89408803d3e1'
);

Furthermore, if we call the airdrop from an address that does not have CODE tokens or NFTs, it should throw an error.

console.log('\n---------- Test 3 ----------');

await claimAirdrop(
    account2Address,
    account2Key,
    '0xe269688F24e1C7487f649fC3dCD99A4Bf15bDaA1'
);

To run the code, use the following command

npm run build && node build/src/index.js

The output should resemble the following:

---------- Deploying zkApp  ----------

Deployed zkApp at B62qpGZ84EdiQKaYSHjGqiPQcxMTmefL64R1qynf8Z6GnPRsP9cp5u1
Balance of Account 1:  100

---------- Sending Funds to zkApp  ----------

Balance of zkApp:  100
Balance of Account 1:  0

---------- Claiming Airdrop  ----------

---------- Test 1 ----------

Balance of Account before Airdrop:  0
Balance of Account after Airdrop:  10
Balance of zkApp after Airdrop:  90

---------- Test 2 ----------

Balance of Account before Airdrop:  10
C:\Projects\zkDrop\contracts\node_modules\snarkyjs\dist\node\bindings\compiled\_node_bindings\snarky_js_node.bc.cjs:7470
         throw err;
         ^

Error: Airdrop Already Claimed

for Test 3 you should get the following:

---------- Claiming Airdrop  ----------

---------- Test 3 ----------

Balance of Account before Airdrop:  0
C:\Projects\zkDrop\contracts\node_modules\snarkyjs\dist\node\bindings\compiled\_node_bindings\snarky_js_node.bc.cjs:7470
         throw err;
         ^

Error: Not Eligible for Airdrop

Congratulations! You have successfully created an Airdrop zkApp using the Mina Protocol.


Next Steps

In the example, we used Developer Dao as a reference, but you can take it a step further by implementing more complex and creative restrictions on how users can claim the airdrop. This will add an extra layer of customization and uniqueness to your application.

Additionally, you may have noticed that we directly passed the address to our API without verifying if the user possesses the account. To enhance the security of your application, you can consider adding signature verification in the API call. This will ensure that only authorized users can access and claim the airdrop, adding an extra layer of trust and protection.

Thank you for taking the time to read this article. If you enjoyed this type of content, make sure to follow us for more informative and engaging articles. Stay tuned for more technical guides!

WAGMI ❤️

Did you find this article valuable?

Support EnvoyOS | Blog by becoming a sponsor. Any amount is appreciated!