Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Plank is an EVM-native programming language for writing smart contracts, giving you direct access to every opcode as a builtin function, without inline assembly blocks, or separate syntax. At the same time, it provides modern language features like compile-time evaluation (comptime), generics, structs, modules, and first-class functions. By combining low-level control with high-level ergonomics, Plank lets you write expressive code that still compiles to efficient bytecode.

Under the hood, Plank compiles to EVM bytecode via Sensei IR (SIR), a language-agnostic intermediate representation designed for fast compilation and EVM-specific optimizations.

Why Plank

The EVM tooling stack has lagged behind. “Stack too deep” errors, slow compile times, and unreliable editor support shouldn’t exist in 2026. Plank aims to raise the bar on expressivity, compilation speed, and developer experience.

Comptime is Plank’s superpower that makes this design possible. Instead of relying on separate macro systems, template metaprogramming, or external code generation, Plank uses a single unified mechanism for generics, type introspection, constant computation, and metaprogramming. Inspired by Zig, this keeps the language simple while still enabling powerful abstractions, without adding runtime cost.

Status

Plank is still in its early stages: the compiler, standard library, and language features are actively evolving. If you run into bugs, have ideas for improvement, or want to get involved, open an issue on GitHub or reach out on X.

Getting Started

Installation

Install Plank using plankup:

curl -L https://install.plankevm.org | bash

This installs the plankup tool, which manages your Plank installation. It downloads the latest binary to ~/.plank/bin/, installs local documentation, and can optionally configure syntax highlighting for VS Code, Cursor, and VSCodium.

To update Plank to the latest version, run:

plankup

Other Editors

Neovim

For Neovim check out the plank.nvim extension.

Zed

For Zed check out the zed-plank extension.

Other Editors

For additional editor support, a tree-sitter grammar is available. You can find a description of Plank’s at grammar in the monorepo if you wish to add support for another editor.

Your First Contract

Create a file called magic_number.plk:

import std::constructor::return_runtime;

const MAGIC_NUMBER_SLOT = 0;
const GET_SELECTOR = 0x6d4ce63c;

init {
    let buf = @malloc_zeroed(32);
    @evm_codecopy(buf, @init_end_offset(), 32);
    @evm_sstore(MAGIC_NUMBER_SLOT, @mload32(buf));
    return_runtime();
}

run {
    let selector = @evm_calldataload(0) >> 224;
    if selector == GET_SELECTOR {
        let buf = @malloc_uninit(32);
        @mstore32(buf, @evm_sload(MAGIC_NUMBER_SLOT));
        @evm_return(buf, 32);
    } else {
        @evm_revert(@malloc_uninit(0), 0);
    }
}

Out of the box Plank contracts are very bare bones, giving you access to two entry points: init and run. init becomes your contract’s initcode and runs once at deployment, while run is the entrypoint to your runtime logic. Note nothing is implicit, by default you’ll need to use something like std::constructor::return_runtime in your init to ensure your contract’s runtime code is set to run.

In the above example, when the init block runs, it reads the initial magic number from the arguments, stores it, and returns the runtime bytecode. The run block executes on every call: it extracts the function selector from the first 4 bytes of calldata and executes the get() method if the selector matches GET_SELECTOR; otherwise, it reverts.

Compile it:

plank build magic_number.plk

Browsing the Documentation

Plank also installs the documentation locally. Open it in your browser with:

plank doc

To jump directly to a specific topic:

plank doc comptime

Alternatively feed it to your LLM by pointing it to the ~/.plank/share/doc/src/ folder which contains the docs in their original markdown form.

What Makes Plank Different

Plank gives smart contract developers more direct control over how their code is written and executed. It exposes low-level EVM behavior, removes hidden abstractions, and keeps most mechanisms explicit.

This section highlights the key differences you’ll encounter coming from Solidity, Vyper, or Huff, and how those differences affect how you write contracts.

Contract Structure: init and run

Plank contracts are split into two explicit blocks:

  • init - deployment logic
  • run - runtime logic

Unlike Solidity, there’s no implicit constructor or fallback. Deployment and execution are separated by design, so it’s always clear what runs when.

Comptime

Plank supports compile-time execution through comptime. It enables features like generics, ABI encoding, and type-driven code generation with zero runtime cost.

See Comptime for details.

Direct EVM Access

Every EVM opcode is exposed as a builtin function. There’s no separate assembly or inline assembly: opcodes are called directly in your code via builtins such as @evm_sload, @evm_sstore, @evm_caller, and @evm_keccak256. Low-level operations become composable building blocks, reusable and abstractable with the same flexibility as high-level code.

let caller = @evm_caller();
let amount = @evm_calldataload(4);
let slot = map_slot_hash(caller, BALANCE_SLOT);
@evm_sstore(slot, amount);

Operator Semantics

Arithmetic operators in Plank are checked by default: +, -, and * revert on overflow and underflow. When wrapping behavior is required, for example for modular arithmetic on storage slots, use the %-suffixed variants (+%, -%, *%):

let sum = a + b;            // reverts on overflow
let slot = base +% offset;  // wraps around

Division has two rounding modes, since division can be floored or ceiled:

7 -/ 2 == 3;   // floor division
7 +/ 2 == 4;   // ceiling division

By requiring an explicit choice, Plank makes the rounding direction immediately visible in the code, rather than relying on a single / operator whose rounding behavior is easy to forget or overlook.

Bitwise operators (|, ^, &, <<, >>, ~) and comparison operators (==, !=, <, >, <=, >=) follow standard conventions.

Logical operators are and and or, instead of && and ||.

Memory Management

Plank simplifies low-level memory management via the @malloc_uninit and @malloc_zeroed builtins, with the compiler controlling the final memory layout and allocation lifetimes.

This design enables optimizations such as redundant allocation removal, allocation merging and reordering, and promotion of dynamic allocations to static slots. With full visibility into memory usage, the compiler can produce memory layouts that minimize memory footprint, avoid unnecessary reads and writes, and safely spill variables from the EVM stack to memory when needed, thus eliminating the possibility of “stack too deep” errors.

While some of these optimizations are still a work in progress, the model is designed to support them.

const map_slot_hash = fn (key: u256, base_slot: u256) u256 {
    let buf = @malloc_uninit(64);
    @mstore32(buf, key);
    @mstore32(buf +% 32, base_slot);
    @evm_keccak256(buf, 64)
};

Modules Over Inheritance

Plank has no contract inheritance. Instead, code is organized through a module system with explicit imports.

import std::abi::{abi_encode, abi_decode};
import std::math::max;

There are no virtual functions, no inheritance linearization, and no diamond problem. Composition is done by importing and using functionality directly.

ABI Encode and Decode

Plank’s standard library provides ABI encoding and decoding utilities. With comptime, the compiler inspects struct fields and generates the correct encoding logic automatically - you only pass the type and data.

import std::abi::abi_encode;

const Transfer = struct { to: u256, amount: u256 };

let encoded = abi_encode(Transfer, transfer);
@evm_return(encoded.ptr, encoded.len);

No manual calldata slicing, no hardcoded offsets - just define your struct, and the standard library handles the rest.

First-Class Functions

Functions in Plank are values at comptime - you can store them in variables, pass them as arguments, and return them from other functions.

Patterns that require special syntax in Solidity, like access control modifiers, can be expressed as comptime functions in Plank:

const require_owner = fn(comptime action: function) void {
    if @evm_caller() != OWNER { revert_empty(); }
    action();
};

Comptime

Comptime lets you execute code during compilation rather than at runtime, using the same syntax you already know. The result is embedded directly into the final bytecode with no runtime cost.

You can use comptime in two ways:

  • A comptime block evaluates an expression at compile time
const SECONDS_PER_YEAR = comptime { 365 * 24 * 3600 };
  • A comptime parameter tells the compiler to specialize a function for each distinct value it is called with:
const DOUBLE = fn(comptime x: u256) u256 {
    x * 2
};

In many cases, compile-time evaluation happens implicitly. When all values in an expression are known at compile time, the compiler evaluates it automatically. For example, 1 + 2 becomes 3 in the bytecode with no runtime cost.

What You Get From Comptime

Reduced On-Chain Cost

The EVM charges for computation, not compilation. Anything you move from runtime to compile time is effectively free. The SECONDS_PER_YEAR example above evaluates entirely at comptime and the result is directly embedded in the bytecode without executing the multiplication on chain.

Readability and Auditability

If you see 7919 in a codebase, do you know it’s the 1000th prime? With comptime, you don’t need to guess, you can write const P_1000 = comptime { nth_prime(1000) }; instead. Both produce the same bytecode, but only one makes it clear what the value represents.

Zero-Cost Generics

comptime parameters allow the compiler to specialize functions per type. No runtime type checks, no unused code paths. One definition, used with any type - the compiler generates optimized code for each.

const max = fn(comptime T: type, a: T, b: T) T {
    if a > b { a } else { b }
};

Compile-Time Introspection

At comptime, everything is a value, including struct types. This lets you define and use functions that perform introspection on them, such as inspecting the number and types of fields.

This enables code generation at compile time, eliminating boilerplate and allowing functions to adapt to the exact structure they operate on.

For example, this is how ABI encoding is handled through the standard library:

const Transfer = struct { to: u256, amount: u256 };
let encoded = abi_encode(Transfer, transfer);

As long as your struct is defined, this call never changes. Add a new field to Transfer, and the encoding updates automatically - no manual fixes, no risk of missing fields.

The same pattern can be extended to things like ERC712 type hashes, storage layout, and event encoding: define the type once, and let the compiler keep everything in sync.

Examples

The following examples demonstrate real contract patterns you can write in Plank today, broken down by their key components.

Note: Plank is still evolving, and future releases will make these patterns simpler and more powerful. Check the roadmap for planned improvements.

ERC20 Token

A standard ERC20 implementation covering the core patterns used in Plank:

  • Contract structure: init (deployment) and run (runtime)
  • Direct EVM access via opcode builtins (@evm_sload, @evm_sstore, @evm_log3, etc.)
  • Explicit memory management with @malloc_uninit and @mstore32
import std::abi::{abi_encode};
import std::membytes::{membytes, membytes_from_ptr};
import std::storage::map_slot_hash;
import std::constructor::return_runtime;

const TOTAL_SUPPLY = 1000000;

const TOTAL_SUPPLY_SLOT = 0;
const BALANCE_SLOT_BASE = 1;
const ALLOWANCE_SLOT_BASE = 2;

const BALANCE_OF_SELECTOR = 0x70a08231;
const TOTAL_SUPPLY_SELECTOR = 0x18160ddd;
const ALLOWANCE_SELECTOR = 0xdd62ed3e;
const TRANSFER_SELECTOR = 0xa9059cbb;
const APPROVE_SELECTOR = 0x095ea7b3;
const TRANSFER_FROM_SELECTOR = 0x23b872dd;
const DECIMALS_SELECTOR = 0x313ce567;
const NAME_SELECTOR = 0x06fdde03;
const SYMBOL_SELECTOR = 0x95d89b41;

const TRANSFER_TOPIC = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef;
const APPROVAL_TOPIC = 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925;

init {
    @evm_sstore(TOTAL_SUPPLY_SLOT, TOTAL_SUPPLY);
    let caller = @evm_caller();
    let slot = map_slot_hash(caller, BALANCE_SLOT_BASE);
    let buf = @malloc_uninit(32);
    @evm_sstore(slot, TOTAL_SUPPLY);

    @mstore32(buf, TOTAL_SUPPLY);
    @evm_log3(buf, 32, TRANSFER_TOPIC, 0, caller);

    return_runtime();
}

run {
    let selector = @evm_calldataload(0) >> 224;
    if selector == BALANCE_OF_SELECTOR {
        balance_of(@evm_calldataload(4));
    } else if selector == TOTAL_SUPPLY_SELECTOR {
        total_supply();
    } else if selector == TRANSFER_SELECTOR {
        transfer();
    } else if selector == ALLOWANCE_SELECTOR {
        allowance();
    } else if selector == APPROVE_SELECTOR {
        approve();
    } else if selector == TRANSFER_FROM_SELECTOR {
        transfer_from();
    } else if selector == DECIMALS_SELECTOR {
        decimals();
    } else if selector == NAME_SELECTOR {
        name();
    } else if selector == SYMBOL_SELECTOR {
        symbol();
    } else {
        @evm_revert(@malloc_uninit(0), 0);
    } 
}

const balance_of = fn (address: u256) never {
    let slot = map_slot_hash(address, BALANCE_SLOT_BASE);
    let buf = @malloc_uninit(32);
    let balance = @evm_sload(slot);
    @mstore32(buf, balance);
    @evm_return(buf, 32);
};

const total_supply = fn () never {
    let buf = @malloc_uninit(32);
    let supply = @evm_sload(TOTAL_SUPPLY_SLOT);
    @mstore32(buf, supply);
    @evm_return(buf, 32);
};

const transfer = fn () never {
    let to = @evm_calldataload(4);
    let amount = @evm_calldataload(36);
    transfer_balance(@evm_caller(), to, amount);
    let buf = @malloc_uninit(32);
    @mstore32(buf, 1);
    @evm_return(buf, 32);
};

const allowance = fn () never {
    let owner = @evm_calldataload(4);
    let spender = @evm_calldataload(36);
    let slot = map_slot_hash(spender, map_slot_hash(owner, ALLOWANCE_SLOT_BASE));
    let allowance = @evm_sload(slot);
    let buf = @malloc_uninit(32);
    @mstore32(buf, allowance);
    @evm_return(buf, 32);
};

const approve = fn () never {
    let spender = @evm_calldataload(4);
    let amount = @evm_calldataload(36);
    let caller = @evm_caller();
    let slot = map_slot_hash(spender, map_slot_hash(caller, ALLOWANCE_SLOT_BASE));
    @evm_sstore(slot, amount);
    let buf = @malloc_uninit(32);
    @mstore32(buf, amount);
    @evm_log3(buf, 32, APPROVAL_TOPIC, caller, spender);
    @mstore32(buf, 1);
    @evm_return(buf, 32);
};

const transfer_from = fn () never {
    let from = @evm_calldataload(4);
    let to = @evm_calldataload(36);
    let amount = @evm_calldataload(68);

    let allowance_slot = map_slot_hash(@evm_caller(), map_slot_hash(from, ALLOWANCE_SLOT_BASE));
    let allowance_val = @evm_sload(allowance_slot);
    if allowance_val < amount {
        @evm_revert(@malloc_uninit(0), 0);
    }
    @evm_sstore(allowance_slot, allowance_val - amount);

    transfer_balance(from, to, amount);
    let buf = @malloc_uninit(32);
    @mstore32(buf, 1);
    @evm_return(buf, 32);
};

const decimals = fn () never {
    let buf = @malloc_uninit(32);
    @mstore32(buf, 18);
    @evm_return(buf, 32);
};

const name = fn () never {
    let ptr = @malloc_uninit(10);
    @mstore10(ptr, 0x506c616e6b546f6b656e);
    let encoded = abi_encode(membytes, membytes_from_ptr(ptr, 10));
    @evm_return(encoded.ptr, encoded.len);
};

const symbol = fn () never {
    let ptr = @malloc_uninit(3);
    @mstore3(ptr, 0x504c4b);
    let encoded = abi_encode(membytes, membytes_from_ptr(ptr, 3));
    @evm_return(encoded.ptr, encoded.len);
};

const transfer_balance = fn (from: u256, to: u256, amount: u256) void {
    let slot = map_slot_hash(from, BALANCE_SLOT_BASE);
    let balance_from = @evm_sload(slot);
    if balance_from < amount {
        @evm_revert(@malloc_uninit(0), 0);
    }
    @evm_sstore(slot, balance_from - amount);

    let slot = map_slot_hash(to, BALANCE_SLOT_BASE);
    let balance_to = @evm_sload(slot);
    @evm_sstore(slot, balance_to + amount);

    let buf = @malloc_uninit(32);
    @mstore32(buf, amount);
    @evm_log3(buf, 32, TRANSFER_TOPIC, from, to);
};

Imports

The contract uses a few standard library utilities:

  • std::storage::map_slot_hash - computes mapping storage slots using keccak256(key, base_slot)
  • std::constructor::return_runtime - returns runtime bytecode from init
  • std::abi::abi_encode - encodes values into ABI format
  • std::membytes::{membytes, membytes_from_ptr} - utilities for working with raw memory slices

Constants

Storage slots and function selectors are defined as constants at the top of the file. There is no compiler-managed storage layout yet, so storage slots are defined explicitly and mapping slots are derived using map_slot_hash.

Function selectors and event topics follow standard EVM conventions.

The init Block

The init block runs once at deployment. It sets the total supply in slot 0, credits the deployer’s balance, emits a Transfer event from address zero to the deployer, and returns the runtime bytecode via return_runtime().

The run Block and Dispatch

Every call to the deployed contract enters the run block. It extracts the 4-byte selector from the first calldata word and matches it against known selectors using an if / else if chain. Unrecognized selectors revert.

Reading and Writing Storage

Reading a balance is a single @evm_sload call with a slot derived from map_slot_hash(address, BALANCE_SLOT_BASE). Writing the balance is a @evm_sstore with the same slot.

Events

Events are emitted using @evm_log3 (three indexed topics plus data):

@evm_log3(buf, 32, TRANSFER_TOPIC, from, to);

ABI Encoding

let ptr = @malloc_uninit(10);
@mstore10(ptr, 0x506c616e6b546f6b656e);
let encoded = abi_encode(membytes, membytes_from_ptr(ptr, 10));
@evm_return(encoded.ptr, encoded.len);

This pattern writes raw data into memory, wraps it as membytes, and passes it to abi_encode to produce the output returned to the caller.

Minimal Proxy (ERC1167)

A factory contract that deploys cheap clones of an existing contract. Each clone delegates all calls to the same implementation, significantly reducing deployment cost.

Beyond the ERC20 example, this demonstrates:

  • Raw bytecode construction: building ERC1167 proxy bytecode in memory
  • Contract deployment using create and create2
  • Comptime generics: a single deploy_clone function that works with or without a salt
import std::constructor::return_runtime;

const CLONE_SELECTOR = 0x8124b78e;
const CLONE_DETERMINISTIC_SELECTOR = 0xb86b2ceb;

const CLONE_CREATED_TOPIC = 0xbe2f3d28fdeb5839123d65fd47ec2f5915c715d2b527b9e229123706fdecfc85;

init {
    return_runtime();
}

run {
    let selector = @evm_calldataload(0) >> 224;
    if selector == CLONE_SELECTOR {
        clone();
    } else if selector == CLONE_DETERMINISTIC_SELECTOR {
        clone_deterministic();
    } else {
        @evm_revert(@malloc_uninit(0), 0);
    }
}

const clone = fn () never {
    deploy_clone(void, {});
};

const clone_deterministic = fn () never {
    let salt = @evm_calldataload(36);
    deploy_clone(u256, salt);
};

const deploy_clone = fn (comptime SaltT: type, salt: SaltT) never {
    let address = @evm_calldataload(4);
    let len = 32 + 20 + 15;
    let buf = @malloc_uninit(len);
    @mstore32(buf +% (len - 32), 0x5af43d82803e903d91602b57fd5bf3);
    @mstore32(buf +% (len - 32 - 15), address);
    @mstore32(buf +% (len - 32 - 15 - 20), 0x3d602d80600a3d3981f3363d3d373d3d3d363d73);
    let address = if SaltT == void {
        @evm_create(0, buf +% (32 - 20), 55)
    } else if SaltT == u256 {
        @evm_create2(0, buf +% (32 - 20), 55, salt)
    } else {
        // Jank compile-time error.
        let _unsupported_clone_type_error: u256 = true;
    };
    @mstore32(buf, address);
    @evm_log1(buf, 32, CLONE_CREATED_TOPIC);
    @evm_return(buf, 32);
};

Dispatch

The contract exposes two entry points for proxy deployment:

  • clone - deploys a proxy at a non-deterministic address using create
  • clone_deterministic - deploys a proxy at a deterministic address using create2

Both read the implementation address from calldata and delegate to deploy_clone.

Building the Proxy Bytecode

The ERC1167 minimal proxy is a fixed 55-byte contract assembled directly in memory:

let len = 32 + 20 + 15;
let buf = @malloc_uninit(len);
@mstore32(buf +% (len - 32), 0x5af43d82803e903d91602b57fd5bf3);
@mstore32(buf +% (len - 32 - 15), address);
@mstore32(buf +% (len - 32 - 15 - 20), 0x3d602d80600a3d3981f3363d3d373d3d3d363d73);

The bytecode consists of three parts: a fixed prefix (20 bytes), the implementation address (20 bytes), and a fixed suffix (15 bytes). We write the components in backwards order as this allows us to use more efficient @mstore32 calls instead of @mstore20 / @mstore15 that aren’t native in the EVM and therefore cost more.

Such optimizations will be handled automatically by the standard library in the future.

Comptime Generic Dispatch

Both clone and clone_deterministic share the same deploy_clone helper. The difference is the comptime type argument passed to it: clone uses void, meaning no salt specialization is required, while clone_deterministic uses u256 for the salt type.

const clone = fn () never {
    deploy_clone(void, {});
};

const clone_deterministic = fn () never {
    let salt = @evm_calldataload(36);
    deploy_clone(u256, salt);
};

The helper takes a comptime type SaltT (void or u256) and a value of that type. Because SaltT is comptime, the compiler specializes each branch and removes runtime checks, allowing shared logic to be written without any runtime branching cost.

In theory, deploy_clone could be called with any type, e.g., deploy_clone(bool, some_value_of_type_bool). However, this does not make sense in the context of the proxy. To restrict usages, any type other than void and u256 triggers a compile-time error via the else branch:

const deploy_clone = fn (comptime SaltT: type, salt: SaltT) never {
    let address = if SaltT == void {
        @evm_create(0, buf, 55)
    } else if SaltT == u256 {
        @evm_create2(0, buf, 55, salt)
    } else {
        // compile-time error.
        let _unsupported_clone_type_error: u256 = true;
    };
};

Merkle Airdrop

A token airdrop contract where eligible recipients are encoded in a Merkle tree. Users claim their allocation by submitting a Merkle proof, which the contract verifies on-chain before marking the claim. Token distribution is not handled.

This example demonstrates:

  • Iterating over calldata using a while loop to process proof elements
  • Handling variable-length calldata for runtime-sized proofs
import std::storage::map_slot_hash;
import std::constructor::return_runtime;

const CLAIM_SELECTOR = 0x3d13f874;
const MERKLE_ROOT_SELECTOR = 0x2eb4a7ab;
const HAS_CLAIMED_SELECTOR = 0x73b2e80e;

const CLAIMED_TOPIC = 0xd8138f8a3f377c5259ca548e70e4c2de94f129f5a11036a15b69513cba2b426a;

const MERKLE_ROOT_SLOT = 0;
const HAS_CLAIMED_SLOT_BASE = 1;

init {
    let args_buf = @malloc_zeroed(32);
    @evm_codecopy(args_buf, @init_end_offset(), 32);
    let merkle_root = @mload32(args_buf);
    @evm_sstore(MERKLE_ROOT_SLOT, merkle_root);

    return_runtime();
}

run {
    let selector = @evm_calldataload(0) >> 224;
    if selector == CLAIM_SELECTOR {
        claim();
    } else if selector == MERKLE_ROOT_SELECTOR {
        merkle_root();
    } else if selector == HAS_CLAIMED_SELECTOR {
        has_claimed();
    } else {
        @evm_revert(@malloc_uninit(0), 0);
    }
}

const claim = fn () never {
    let buf = @malloc_uninit(64);
    let slot = get_merkle_slot();
    let claimed = @evm_sload(slot);
    if claimed != 0 {
        @evm_revert(@malloc_uninit(0), 0);
    }

    let address = @evm_calldataload(4);
    let amount = @evm_calldataload(36);
    let offset = 4 + @evm_calldataload(68);
    let proof_length = @evm_calldataload(offset);

    @mstore32(buf, address);
    @mstore32(buf +% 32, amount);
    let mut node = @evm_keccak256(buf +% 12, 52);

    let mut i = 0;
    while i < proof_length {
        let proof_element = @evm_calldataload(offset + 32 + i * 32);
        if node <= proof_element {
            @mstore32(buf, node);
            @mstore32(buf +% 32, proof_element);
        } else {
            @mstore32(buf, proof_element);
            @mstore32(buf +% 32, node);
        }
        node = @evm_keccak256(buf, 64);
        i = i + 1;
    }

    let root = @evm_sload(MERKLE_ROOT_SLOT);
    if node != root {
        @evm_revert(@malloc_uninit(0), 0);
    }

    @evm_sstore(slot, 1);
    @mstore32(buf, amount);
    @evm_log2(buf, 32, CLAIMED_TOPIC, address);
    @evm_return(@malloc_uninit(0), 0);
};

const merkle_root = fn () never {
    let buf = @malloc_uninit(32);
    @mstore32(buf, @evm_sload(MERKLE_ROOT_SLOT));
    @evm_return(buf, 32);
};

const has_claimed = fn () never {
    let buf = @malloc_uninit(64);
    let slot = get_merkle_slot();
    let claimed = @evm_sload(slot);
    @mstore32(buf, claimed);
    @evm_return(buf, 32);
};

const get_merkle_slot = fn () u256 {
    let address = @evm_calldataload(4);
    map_slot_hash(address, HAS_CLAIMED_SLOT_BASE)
};

Claiming

The claim function performs the core verification logic:

  1. Checks if the caller has already claimed by reading their storage slot
  2. Reads the address, amount, and Merkle proof from calldata
  3. Computes the leaf hash from the address and amount
  4. Iterates over the proof, sorting each node pair before hashing to compute the next layer
  5. Compares the final computed root with the stored root; if valid, marks the claim and emits an event

The proof is processed in a while loop, reading each element from calldata by its offset:

let mut i = 0;
while i < proof_length {
    let proof_element = @evm_calldataload(offset + 32 + i * 32);
    ...
    i = i + 1;
}

Leaf Computation

The leaf is computed by packing the address (20 bytes) and amount (32 bytes) into a manually allocated buffer:

let buf = @malloc_uninit(64);
@mstore32(buf, address);
@mstore32(buf +% 32, amount);
let mut node = @evm_keccak256(buf +% 12, 52);