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/UniswapRouter.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,
    operator: address,
    maxAmount: uint256
}

Functions

Every nonempty policy declares public authorize() -> bool. The adapter authenticates identities and authorizedBy(address) tests membership in that set. The operation logic lives in fn main() -> bool; other public read functions use pub fn.

pub fn authorize() -> bool {
    return authorizedBy(parameters.operator);
}

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.accountaddressThe controlled smart account or Safe.
context.authorizerCountuint256Number of identities authenticated by the adapter.
context.valueuint256The ETH value (in wei) sent with the call.
context.phasepre or postThe integration-supplied lifecycle phase for main().

Use authorizedBy(address) to test an authenticated identity. Kernel provides the recovered UserOperation key; Safe provides the threshold identities accepted by native Safe signature checking. Policies do not parse adapter signatures and must not treat an executor, bundler, or tx.origin as an authorizer.

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.

Lifecycle phase is integration supplied

context.phase is valid only in lexical fn main() -> bool. Public read callables and helpers reachable from public read callables cannot use it because typed read APIs accept client-supplied execution context.

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.

Declared State

Durable declarations require a fixed-word literal initializer. The supported seed types are bool, uint256, bytes32, and address.

@persist let remaining: uint256 = 100;
@persist let enabled: bool = true;
@transient let balanceBefore: uint256;

The compiler encodes the persistent seed into the callable declaration table. The account adapter materializes every nonzero seed exactly once before activating a fresh installation namespace. Replacing or reinstalling a policy allocates a new namespace and therefore receives a fresh seed; it cannot recover state from an older generation. Assigning a seeded slot to zero does not cause the seed to be applied again.

Transient declarations never accept an initializer. They are operation-scoped, must be written before they are read, and clear at the end of the top-level transaction through EIP-1153 semantics.

Use declared state for accumulation

accumulate() is not supported. Use a declared @persist value and an explicit assignment in main().

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,
        operator: address
    }

    let minBalance = 10;

    pub fn authorize() -> bool {
        return authorizedBy(parameters.operator);
    }

    fn main() -> bool {
        let ownerBalance = parameters.collection.balanceOf(context.account);
        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. Multi-output read functions are supported when the source selects one named fixed-word output, such as parameters.pool.getUserAccountData(account).healthFactor. Dynamic arguments, dynamic returns, mutable ABI functions, unresolved overloads, and top-level external read let declarations fail before bytecode emission.

Named outputs are lowered to their physical ABI return-word offset, not merely their position in the JSON output list. A preceding static tuple or fixed array contributes its complete recursive word width; a preceding dynamic value contributes its single ABI head word. The selected fixed word must resolve to an offset from 0 through 31, otherwise compilation fails.

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 implicit stateful control-flow helpers are not part of the 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;
}

Use explicit control flow

Write policy logic with 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.to == 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.account) 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 split precondition/commit model.

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

permission Subscription -> 1.0.0 {
    parameters: {
        authorizer: address
    }

  @persist let chargeCount: uint256 = 0;

  pub fn authorize() -> bool {
      return authorizedBy(parameters.authorizer);
  }

  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.

In phase-aware policies, durable state.* writes are post-phase only. The compiler accepts them only inside an explicit positive if (context.phase == post) or else if (context.phase == post) branch. This prevents durable pre-state from surviving an execution that later fails.

Operation-Scoped Transient State

Use @transient let for values that must be written in pre and read in post for the same operation. Transient variables are addressed by bare name, not through state.<name>.

permission ValueDoesNotDecrease -> 1.0.0 {
    parameters: {
        authorizer: address
    }

  @transient let valueBefore: uint256;
  @persist let accepted: uint256 = 0;

  pub fn authorize() -> bool {
      return authorizedBy(parameters.authorizer);
  }

  fn main() -> bool {
    if (context.phase == pre) {
      valueBefore = context.value;
      return true;
    }

    if (context.phase == post) {
      if (context.value >= valueBefore) {
        state.accepted = state.accepted + 1;
        return true;
      }

      return false;
    }

    return false;
  }
}

Transient reads fail closed if the value was not initialized for the current operation id. Zero is valid initialized data, so Matador tracks initialization separately. Operation-scoped transient storage uses EIP-1153 and is rejected on runtime profiles that do not support TSTORE and TLOAD.

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