Interpreter Architecture
How the interpreter evaluates policies, isolates state, and enforces lifecycle rules.
The Matador Permission Interpreter executes compiled policies on-chain. It validates bytecode before dispatch, evaluates policy functions, and isolates state by installation.
Execution Model
Lifecycle enforcement enters through the reserved main() function (function ID 0).
Persistent writes are staged during evaluation and committed only after it returns true.
Preflight checks the policy structure; execution checks initialization and lifecycle
context before evaluating main(). State loads use the installation namespace.
Persistent stores accumulate in a write set, which is flushed only on success.
A failed check or false result reverts the call.
Function Execution
Each function has an operand stack and 16 registers for arguments and locals. The interpreter advances a program counter through the function body, applying arithmetic, branches, state access, and function calls as instructions require.
| Resource | Limit |
|---|---|
| Operand stack | 16 words per function |
| Arguments | 8 per function |
| Locals | 16 per function |
| Function-call depth | 16 |
Preflight validates instruction boundaries, branch targets, function calls, and stack/local limits before execution. Public typed reads select a public read entry and check its declared return type before dispatch.
Bytecode Layout
The current callable format places metadata before the instruction section.
| Section | Size | Purpose |
|---|---|---|
| Header | 48 bytes | Format version, section offsets, counts, and phase-aware flag |
| Function table | 32 bytes per entry | Selectors, types, limits, flags, and body offsets |
| State declarations | 104 bytes per entry | Declaration identity, type, storage kind, and fixed seed |
| Instructions | Variable | Function bodies |
Preflight rejects gaps or overlaps in function bodies and requires the instruction section to end at the bytecode boundary. The current format does not accept a separate data section.
See Callable Bytecode Notes for entry metadata and format validation, and the Opcode Reference for instruction encodings.
Authorization and Phases
Every nonempty policy declares public authorize() -> bool. The adapter authenticates
the authorizer set, invokes authorize(), then runs lifecycle execution. Signature
formats remain the adapter's responsibility.
| Instruction | Value |
|---|---|
EXEC_ACCOUNT | Controlled smart account or Safe |
EXEC_AUTHORIZER_COUNT | Number of authenticated authorizers |
EXEC_AUTHORIZED_BY | Whether an address belongs to the authenticated set |
EXEC_PHASE | 1 for pre, 2 for post; unknown values revert |
The authorize() call graph is read-only. Preflight rejects external reads, phase
access, and BLOCK_TIMESTAMP throughout that graph, including reachable helpers
and branches that would be dead at runtime. The reserved main() entry can read
context.phase through EXEC_PHASE.
Block Time
BLOCK_TIMESTAMP (0xb5) reads the current block.timestamp when execution
reaches it and pushes a uint256. It is allowed in main(), pre/post execution,
and public typed reads. Timestamp use alone does not make a policy phase-aware
or require transient storage.
It is forbidden throughout authorize() because Kernel runs authorization during
ERC-4337 validation, where canonical-mempool rules prohibit TIMESTAMP. A timestamp
condition governs execution time; it does not set ERC-4337 validAfter or validUntil.
See Callable Bytecode Notes
for opcode compatibility and timing considerations.
Declared State
State belongs to a policy installation. Replacing or reinstalling identical bytecode receives a fresh namespace rather than recovering the previous installation's values.
| State kind | Load / store instructions | Scope |
|---|---|---|
| Durable | PERSIST_LOAD_DECL / PERSIST_STORE_DECL | Installation namespace |
| Operation-scoped | TRANSIENT_LOAD_DECL / TRANSIENT_STORE_DECL | Installation, operation ID, and exact policy bytes |
Storage Ownership
| Call mode | Physical storage owner | Namespace authority |
|---|---|---|
delegatecall | Adapter | Adapter |
| Direct call | Interpreter | Direct msg.sender |
A direct caller can manage only its own partition and cannot read adapter storage.
The namespace authority supplies a nonzero stateNamespaceId. Production adapters
issue a fresh value on every successful install or replacement and retain their
generation counter after removal.
The keys below use immutable hash-domain identifiers. The v2 suffixes do not
identify supported policy generations.
persist = H("matador.persist.decl.v2", namespaceAuthority, subject,
stateNamespaceId, declKey)
transient = H("matador.transient.value.decl.v2", namespaceAuthority, subject,
operationId, stateNamespaceId, policyCodeHash, declKey)policyCodeHash binds transient pre/post state to the exact enforced bytes;
durable state is independent of that hash. Physical storage ownership comes from
the EVM call context and is not another encoded key field.
Initialization
Each state entry includes a 32-byte fixed seed. Transient seeds must be zero.
Allocate a Namespace
The adapter assigns a fresh stateNamespaceId for the installation.
Initialize Seeds
Before activating the binding, the trusted adapter delegatecalls
initializePolicyState. It writes nonzero persistent seeds and sets a
separate initialization marker. Zero-only policies need no materialization write.
Enforce Initialization
Enforcement and typed reads require the marker for policies with nonzero seeds. A seeded value later set to zero remains initialized.
Declaration Validation
The language validator, semantic compiler pass, final encoder, and runtime preflight
each enforce a 64-entry limit across persistent and transient state. Preflight
checks the limit before allocating the declaration array and rejects duplicate
nameHash or declKey values across all kinds and types.
Every state load and store also validates the runtime word:
| Type | Accepted value |
|---|---|
bool | 0 or 1 |
address | Fits in uint160 |
uint256, bytes32 | Full 256-bit word |
Corrupt stored values revert on load; the interpreter never truncates or reinterprets them.
Adapter Requirements
Adapters supply trusted subject and stateNamespaceId arguments from their own
installation records. These arguments remain outside policy-visible ExecutionContext.
Deployment boundary
Deploy the interpreter implementation directly, without a generic proxy. Every delegatecall context is treated as a trusted adapter namespace. Custom adapters must load the subject and namespace from their installation record; accepting caller-selected values breaks state isolation.
Transient Storage Support
Preflight derives phase/transient capability from bytecode and rejects a mismatch with the phase-aware header flag.
EIP-1153 required
Transient state uses TSTORE and TLOAD. Deployments with transient storage
disabled reject phase-aware bytecode before execution.
Safe Lifecycle Limit
A Safe can run only one phase-aware lifecycle per top-level transaction. Its active-operation marker remains until EIP-1153 clears transient storage at transaction end. A second relayed or multicall execution reverts, preventing an early Safe-origin post callback from suppressing the official post check.
See Contract Interfaces for adapter APIs and lifecycle requirements.
Security Invariants
Alongside the format and state checks above, execution enforces these rules:
- Canonical authorizers:
EXEC_AUTHORIZED_BYrejects words aboveuint160before address conversion, so dirty high bits cannot alias an authenticated address. - Checked arithmetic:
ADD,SUB, andMULrevert on overflow or underflow;DIVandMODrevert on division by zero using interpreter custom errors. - Lifecycle integrity: Transient reads require initialized data for the same operation ID. Durable writes in phase-aware bytecode require post-guarded paths.
- Invalid instructions: Unknown opcodes and malformed encodings revert.