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 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.account | address | The controlled smart account or Safe. |
context.authorizerCount | uint256 | Number of identities authenticated by the adapter. |
context.value | uint256 | The ETH value (in wei) sent with the call. |
context.phase | pre or post | The 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.
| 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.
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.
| 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 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 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.