Matador Docs
API Reference

DSL Syntax

The complete grammar and syntax reference for the Matador DSL.

The Matador DSL (Domain-Specific Language) is a human-readable language designed for defining secure, gas-optimized permission policies. It compiles down to compact bytecode executable by the on-chain interpreter.

File Structure

A standard .matador policy file consists of imports, a permission declaration, optional metadata/parameters, optional persistent state declarations, and one or more functions. fn main() -> bool is the write-capable lifecycle entry.

Imports

Import external ABI definitions to enable type-safe checking of contract calls and calldata.

import "abis/UniswapV3.json" as Uniswap;

Declaration

Define the policy name and semantic version. This helps with off-chain indexing and management.

permission SwapPolicy -> 1.0.0 {

Metadata & Parameters

Define off-chain metadata (author, description) and runtime parameters that the policy expects.

metadata: {
    author: "Steer Finance",
    description: "Limits swap amount"
}

parameters: {
    router: address,
    maxAmount: uint256
}

Functions

The core logic lives in fn main() -> bool and reusable helper functions. Public read functions use pub fn.

fn main() -> bool {
    if (context.target != parameters.router) {
        return false;
    }

    if (context.selector != Uniswap.exactInputSingle) {
        return false;
    }

    return context.value <= parameters.maxAmount;
}
} // End permission

Context Properties

The callable context object exposes the execution fields currently supported by callable bytecode.

PropertyTypeDescription
context.selectorbytes4The function selector in the execution calldata.
context.targetaddressThe contract being called.
context.calleraddressThe address initiating the execution.
context.valueuint256The ETH value (in wei) sent with the call.

Selector checks are not implied

Imported ABI function members such as Uniswap.exactInputSingle are bytes4 selector values. They must be compared explicitly with context.selector. They do not imply a target-address check, and a target-address check does not imply a selector check.

Operators

Matador supports standard comparison operators.

OperatorDescriptionLogic
==EqualStrict equality check.
!=Not EqualInequality check.
>Greater Thana > b
<Less Thana < b
>=Greater Than or Equala >= b
<=Less Than or Equala <= b

Arithmetic expressions

Arithmetic operators (+ - * / %) are supported for numeric expressions over literals, function-local lets, top-level pure derived lets, supported numeric context.* values, numeric parameters.* values, and numeric state.<var> reads.

Type casts

You can write casts like uint8(10) or uint64(parameters.value). Casts in arithmetic expressions behave as a no-op at runtime but can be used to narrow parameter/context references for opcode arguments.

Top-Level Derived Lets

Non-persist top-level let declarations are per-invocation derived values. They are recomputed before each callable function body that runs, stored in ordinary local registers, and never persisted between calls.

parameters: {
    max: uint256,
    fee: uint256
}

let maxWithFee = parameters.max + parameters.fee;

fn main() -> bool {
    return context.value <= maxWithFee;
}

The initial lowered subset is intentionally narrow. Top-level derived lets may use fixed-word literals, policy parameters, earlier top-level derived lets, arithmetic/comparison expressions, and safe casts. They cannot use context.*, ABI selector aliases, ABI calldata fields, external ABI calls, helper calls, runtime opcode calls, persisted state reads, strings, block expressions, or logical operators. Keep selector and target checks explicit inside functions.

External ABI Reads

Imported ABI view and pure calls are supported inside callable function bodies when the receiver is an ABI-typed policy parameter.

import "abis/ERC721.json" as NFT;

permission BalanceGate -> 1.0.0 {
    parameters: {
        collection: NFT
    }

    let minBalance = 10;

    fn main() -> bool {
        let ownerBalance = parameters.collection.balanceOf(context.caller);
        return ownerBalance > minBalance;
    }

    pub fn balanceOf(account: address) -> uint256 {
        return parameters.collection.balanceOf(account);
    }
}

The first supported read-call subset is fixed-word only. Arguments may be bool, uint256, bytes32, bytes4, or address; returns may be bool, uint256, bytes32, or address. Dynamic arguments, dynamic returns, multiple returns, mutable ABI functions, unresolved overloads, and top-level external read let declarations fail before bytecode emission.

Callable Function Limits

The initial callable function surface is fixed-word only. Function return types and public function arguments support bool, uint256, bytes32, and address. Public callable argument payloads contain exactly one 32-byte ABI word per declared argument.

LimitValue
Maximum public/internal function arguments8
Maximum function-local slots16
Maximum function stack height16
Maximum callable frame depth16 total frames, including the entry frame
Maximum dirty persistent writes per main()4 distinct persisted variables

Initial non-goals

Dynamic returns, dynamic arguments, arbitrary bytes, strings, arrays, recursion, source-level overloads, write-capable pub fn entries, read-only policies without fn main() -> bool, and legacy when / commit bytecode are not part of the initial callable implementation.

Control Flow

Callable functions use ordinary control flow with parenthesized conditions and explicit returns.

fn main() -> bool {
    if (context.target != parameters.allowedTarget) {
        return false;
    }

    if (context.value == 0) {
        return false;
    }

    return true;
}

Deferred block expressions

Legacy block expressions such as all {}, any {}, cached {}, once tx {}, and block not {} are not part of the initial callable lowering path. Use explicit if (...) / else if (...) / else and return statements.

Contract Calls

You can check calldata against specific function signatures defined in your imported ABIs.

fn main() -> bool {
    if (context.target != parameters.token) {
        return false;
    }

    if (context.selector == Token.transfer) {
        return Token.transfer.recipient == parameters.allowedRecipient;
    }

    return false;
}

Deep Nested Access

Callable lowering currently supports fixed-width ABI fields that resolve to one 32-byte word and are guarded by an explicit same-function selector check. Dynamic fields, arrays, strings, arbitrary bytes, and unguarded ABI field reads fail closed before bytecode emission.

Target, selector, and reads are separate

context.target == parameters.token checks the contract address. context.selector == Token.transfer checks the function selector. External read calls such as parameters.token.balanceOf(context.caller) read state and do not imply either transaction guard.

Persistent State

Matador supports per-account persistent state. The callable model uses transactional main() execution instead of a separate when: / commit: split.

Use @persist let to declare a persisted variable and state.<name> to read/write it.

permission Subscription -> v1 {
  @persist let chargeCount: uint64 = 0;

  fn main() -> bool {
    if (state.chargeCount >= 10) {
      return false;
    }

    state.chargeCount = state.chargeCount + 1;
    return true;
  }
}

Persistent writes are dirty until main() returns canonical true. If main() returns false, returns a non-canonical bool, or reverts, pending writes are discarded.

Merkle Allowlist (Recipient)

Merkle roots allow you to enforce a large allowlist without storing an address[] onchain. Store a bytes32 Merkle root under a persistent rootKey, then prove membership per transaction.

Canonical Leaf Encoding

For a recipient allowlist, use this canonical leaf:

leaf = keccak256(abi.encode(
  keccak256("matador.merkle.allowlist.recipient.v1"),
  policyNamespace,
  rootKey,
  recipient
));

Proofs from Execution Context

The lower-level interpreter has Merkle membership opcodes for proof checks. The callable source helper for this pattern is still being finalized, so treat this section as opcode-level guidance rather than a ready-to-copy callable source snippet.

  • rootKey: the persistent key that stores the Merkle root.
  • leaf: the leaf hash you computed offchain.
  • proofOffset: a byte offset into ExecutionContext.data pointing at the length word of an ABI-encoded bytes value.

The proof payload is parsed as:

  • depth: uint256 (must be <= 32)
  • directionBits: uint256 (bit i indicates whether the sibling is left/right)
  • siblings: bytes32[depth]

Who supplies the proof?

The proof bytes are part of the transaction payload. In ERC-4337 flows, the user’s signature covers callData, so a bundler/solver can fetch a proof but cannot change it after the user signs.

On this page