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 permissionContext Properties
The callable context object exposes the execution fields currently supported
by callable bytecode.
| Property | Type | Description |
|---|---|---|
context.selector | bytes4 | The function selector in the execution calldata. |
context.target | address | The contract being called. |
context.caller | address | The address initiating the execution. |
context.value | uint256 | The 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.
| Operator | Description | Logic |
|---|---|---|
== | Equal | Strict equality check. |
!= | Not Equal | Inequality check. |
> | Greater Than | a > b |
< | Less Than | a < b |
>= | Greater Than or Equal | a >= b |
<= | Less Than or Equal | a <= 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.
| Limit | Value |
|---|---|
| Maximum public/internal function arguments | 8 |
| Maximum function-local slots | 16 |
| Maximum function stack height | 16 |
| Maximum callable frame depth | 16 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 intoExecutionContext.datapointing at the length word of an ABI-encodedbytesvalue.
The proof payload is parsed as:
depth: uint256(must be<= 32)directionBits: uint256(bitiindicates 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.