Contract Interfaces
Technical reference for the Matador smart contract ecosystem.
This reference documents the core interfaces, data structures, and errors used by the on-chain components of Matador.
IPermissionInterpreter
The IPermissionInterpreter is the heart of the system. It executes the compiled bytecode to validate transactions.
interface IPermissionInterpreter {
function enforce(
bytes memory context,
address subject,
bytes32 stateNamespaceId,
ExecutionContext calldata exec
) external;
function preflightPolicy(bytes calldata context)
external
view
returns (
bytes32 policyCodeHash,
bool phaseAware,
bool hasTransient,
bool hasAuthorize
);
function callBool(
bytes memory context,
bytes4 selector,
bytes memory args,
address subject,
bytes32 stateNamespaceId,
ExecutionContext calldata exec
) external view returns (bool);
function callUint256(
bytes memory context,
bytes4 selector,
bytes memory args,
address subject,
bytes32 stateNamespaceId,
ExecutionContext calldata exec
) external view returns (uint256);
function callBytes32(
bytes memory context,
bytes4 selector,
bytes memory args,
address subject,
bytes32 stateNamespaceId,
ExecutionContext calldata exec
) external view returns (bytes32);
function callAddress(
bytes memory context,
bytes4 selector,
bytes memory args,
address subject,
bytes32 stateNamespaceId,
ExecutionContext calldata exec
) external view returns (address);
}ExecutionContext
The ExecutionContext struct contains authenticated operation and lifecycle
facts supplied by the adapter. Ambient chain values are interpreter-owned
capabilities and are read lazily only when their opcode executes; callers do
not populate them in this struct.
| Property | Type | Description |
|---|---|---|
account | address | The smart account or Safe whose authority and state are being exercised. |
authorizers | address[] | Identities authenticated by the adapter for this operation. Policies inspect membership through authorizedBy(address). |
target | address | The destination contract address. |
value | uint256 | The native value (Wei) sent with the call. |
data | bytes | The transaction calldata. |
selector | bytes4 | The function selector from data, or 0x00000000 when unavailable. |
nonce | uint256 | The nonce of the smart account. |
phase | uint8 | Lifecycle phase for enforce(): 1 for pre, 2 for post. Typed public reads may pass 0 because read entries cannot access context.phase. |
operationId | bytes32 | Stable id shared by matching pre/post calls. Required for transient state. |
Methods
enforce
Executes the reserved fn main() -> bool lifecycle entry in a mutable context. Successful execution requires main() to return canonical true; persistence writes are flushed only on that successful path.
- State namespace: Requires a nonzero authority-issued
stateNamespaceId. Production adapters load it from their installation record; a direct caller manages namespaces only within its ownmsg.senderpartition. The namespace is outsideExecutionContextand policy bytecode. - Reverts: If any condition in the policy fails.
- Gas: Costs vary based on the policy complexity and state access.
preflightPolicy
Fully validates callable bytecode without executing it and returns the exact
policyCodeHash, validated phase-aware capability, and transient-state
capability, plus the mandatory authorize() capability. Adapters call preflight
before activating a policy. The configured EIP-1153 support gate is part of
preflight.
Typed Read Callables
Executes a public read callable selected by its canonical function selector. These APIs are for query surfaces such as validate() or remainingDailySpend().
Read Callables
Public callable functions are read-only in the initial implementation. Use
enforce for the write-capable main() lifecycle path.
IPermissionInterpreter.ExecutionContext memory exec = IPermissionInterpreter.ExecutionContext({
account: address(this),
authorizers: authenticatedAuthorizers,
target: target,
value: value,
data: data,
selector: bytes4(0),
nonce: 0,
phase: 0,
operationId: bytes32(0)
});
// The account wrapper loads the active state namespace and delegatecalls the
// interpreter in the same storage context used by enforcement.
bool ok = account.callPermissionBool(
permissionId,
bytes4(keccak256("validate()")),
"",
exec
);
uint256 nav = account.callPermissionUint256(
permissionId,
bytes4(keccak256("nav(address)")),
abi.encode(vault),
exec
);State-aware reads require the adapter
A direct interpreter call uses the interpreter's physical storage and direct
msg.sender as namespaceAuthority; it cannot read state written under an
account or module's delegatecall storage. Use account wrappers for
persistence-aware reads. A custom adapter must load subject and
stateNamespaceId from its trusted installation record rather than accept
either from policy bytecode or ExecutionContext. Physical storage ownership
is implicit in EVM call context and is not a separate encoded key field. Do
not place the interpreter behind a generic proxy: every delegatecall context
is classified as a trusted adapter namespace.
Safe policy rotation
Safe GuardManager does not notify the guard when it is enabled or disabled.
Disabling/re-enabling therefore preserves the current policy generation;
setPolicy creates the new generation. For a phase-aware policy, disable the
guard in a policy-approved Safe transaction, rotate or clear policy while
unguarded, then re-enable it. The cached guard completes post on the disabling
transaction. clearPolicy reverts with SafeGuardStillEnabled while the guard
remains enabled.
If an exactly empty binding is enabled accidentally, the guard permits only a
zero-value ordinary Safe call with canonical ABI encoding to either
setPolicy(bytes) on the guard or setGuard(address(0)) on that same Safe.
This is the first-install/recovery path; it performs no policy enforcement or
transient writes. Delegatecall, value, arbitrary targets, a nonzero guard,
malformed/noncanonical calldata, and any partial policy metadata still fail
closed.
The Safe-scoped active marker intentionally remains until EIP-1153 clears it
at top-level transaction end. A given Safe can complete only one phase-aware
lifecycle in that transaction; relayer/multicall attempts to execute another
fail with SafeOperationAlreadyActive. This keeps early Safe-origin post calls
fail-closed because the guard cannot authenticate them as the official post
callback.
Callable Bytecode Notes
Callable bytecode starts with its header and a fixed-size
function table before the instruction section. The reserved main() lifecycle
entry has function id 0 and selector 0x00000000; public entries are sorted by
canonical selector and carry return type, argument count, argument type tags,
local count, stack bound, flags, and body offsets.
Every execution path runs preflight before dispatch. Preflight rejects malformed
headers, unsupported versions, duplicate public selectors, non-public selectors,
overlapping or gapped function bodies, invalid branch targets, invalid CALL_FN
targets, unsupported type tags, local/stack limit violations, and read entries
that can reach write-classified behavior.
Every nonempty policy also contains public authorize() -> bool. Adapters run
it after authenticating the authorizer set and before lifecycle enforcement.
Preflight rejects authorize call graphs that reach external reads, phase
access, or BLOCK_TIMESTAMP, including transitively reachable helpers and
instructions in branches that would be dead at runtime.
BLOCK_TIMESTAMP (0xb5) has no immediate bytes and pushes the current EVM
block.timestamp as one uint256. It is read only when that instruction is
reached. The opcode is valid in main(), pre/post lifecycle paths, and ordinary
public reads, but is forbidden throughout the authorize() call graph. It is a
contract-only capability in this release: the source compiler does not yet
expose context.timestamp syntax.
Execution time, not validation time
A timestamp condition can allow validation and later deny execution if the
block time changes before execution. It does not populate ERC-4337
validAfter or validUntil, and it does not make time-dependent
authorize() logic canonical-mempool safe. Block producers also have bounded
discretion over timestamps, so policies should include an appropriate time
tolerance rather than treating the value as a precise wall clock.
Callable bytecode includes declared-state metadata for durable
@persist and operation-scoped @transient variables. The compiler sets the
phase-aware header flag when bytecode uses context.phase, declares transient
state, or contains transient load/store opcodes. Runtime preflight rejects
header mismatches.
The compiler and preflight accept at most 64 combined persist/transient state
declarations. Preflight also rejects duplicate nameHash or declKey values
across all persist/transient kinds and types.
Every declared state load and store validates canonical runtime representation:
bool is 0/1, address fits uint160, and uint256/bytes32 accept a full word.
Already-corrupt state fails closed on load.
State keys use a namespaceAuthority: the adapter/storage owner under
delegatecall, or direct msg.sender for direct interpreter calls. Durable
state is keyed by its versioned domain separator, namespace authority, trusted
subject, adapter-issued state namespace, and declaration key. Transient state adds the
nonzero operationId and exact runtime policyCodeHash; a separate init key
distinguishes an initialized zero from unwritten state. EIP-1153 profiles
without TSTORE and TLOAD support reject phase-aware bytecode during
preflight.
Every successful policy install or replacement receives a fresh adapter-local
generation namespace, even when the bytecode is identical. Revocation,
clearPolicy, and uninstall remove the active binding but retain the generation
counter. Reinstall cannot recover state from an earlier installation namespace.
Adapter lifecycle requirements
Safe runs non-phase policy code exactly once in checkTransaction, with a
no-op post callback. Phase-aware Safe policies are capability-gated before
snapshot TSTORE, then use the Safe transaction hash and bind namespace plus
exact code hash across pre/post. Kernel requires KernelMatador to be paired
with itself as validator and ERC-7579 hook. Validation performs no policy
write; it mints a transient capability bound to the exact UserOperation,
authenticating owner, retained installation generation, and policy binding.
Every nonempty install payload advances that generation, including an
owner-only empty-policy binding. Kernel invokes hook pre/post around
executeUserOp in one account transaction and module storage context. During
normal execution, pre requires the configured EntryPoint's matching nonzero
getCurrentUserOpHash() result and a validator config nonce at or above
Kernel's live validNonceFrom floor. The pinned v0.9 call-gas simulation may
instead resolve an unambiguous zero-hash lookup created during validation;
two different hashes for the same account, execution bytes, and policy
binding make that lookup ambiguous and fail it closed. Phase post revalidates
the same active validator/self-hook pairing, so protected execution cannot
remove or nonce-revoke its own lifecycle configuration. Every nonempty install emits
PolicyBindingActivated; uninstall emits the matching PolicyBindingCleared
for policy-bearing and owner-only empty bindings. A transaction-scoped issued
marker also prevents the exact same Kernel authorization from being minted a
second time in one top-level transaction, even after its single-use
capability has been consumed.
KernelMatador
KernelMatador is the paired Kernel validator and ERC-7579 hook. Policy bytes
are stored in a Solady SSTORE2 pointer rather than a dynamic storage value. The
pointer's runtime code contains one leading STOP, so the maximum accepted
policy is 24,575 bytes; a larger install reverts with KernelPolicyTooLarge.
accountPolicies(account) reads and returns the exact bytes from the pointer,
while accountPolicyPointers(account) exposes the pointer address.
function accountPolicies(address account) external view returns (bytes memory);
function accountPolicyPointers(address account) external view returns (address);
function estimationStubSignature(address claimedAuthorizer)
external
pure
returns (bytes memory);ERC-7769 estimation signature
estimationStubSignature returns the only placeholder signature recognized by
the module. Its exact 89-byte layout is:
| Byte range | Length | Value |
|---|---|---|
0..3 | 4 | 0x52a99cb6, the first four bytes of keccak256("matador.kernel.estimation.stub.v1") |
4..23 | 20 | Nonzero claimed policy authorizer |
24..55 | 32 | All 0xff |
56..87 | 32 | All 0xff |
88 | 1 | 0xff |
The claimed authorizer is deliberately not authenticated. The module validates
the canonical UserOperation shape and hash and, unless owner bypass applies,
runs authorize() against that claim. A malformed stub or policy-denied claim
does not mint a capability. An admitted stub may mint the same exact-hash,
one-shot transient capability used by normal validation, but
validateUserOp always returns signature failure (1) for the stub.
Canonical EntryPoint handleOps therefore rejects it before execution; the
capability exists so an ERC-7769 simulation can measure the policy lifecycle.
At consumption, policy-gated capabilities run authorize() again against
current durable state. A configured owner-bypass stub follows the real owner
bypass path instead: it verifies that the claimed authorizer is still the
configured owner and skips authorize(), main, pre, and post.
The capability page is stored directly in KernelMatador transient storage at
an account-associated root: offsets 0..4 hold count, issued marker,
authorizer, authenticated nonce, and owner-bypass status. A separate
account/execution/policy-binding lookup root stores the simulation hash at
offset 0 and an ambiguity marker at offset 1. The exact-hash capability
does not store a validationFailed provenance flag; stub failure remains a
return-value property of validateUserOp, not a capability field.
IPermissionAccount
BasePermissionAccount implements the account-level installation and typed-read
wrappers. The active namespace is observable for integration/debugging, but only
the account chooses it for interpreter execution.
interface IPermissionAccount {
function grantPermission(bytes32 permissionId, bytes calldata context) external;
function revokePermission(bytes32 permissionId) external;
function hasPermission(bytes32 permissionId) external view returns (bool);
function getPermissionContext(bytes32 permissionId) external view returns (bytes memory);
function getPermissionStateNamespace(bytes32 permissionId) external view returns (bytes32);
function getPermissionGeneration(bytes32 permissionId) external view returns (uint256);
function callPermissionBool(
bytes32 permissionId,
bytes4 selector,
bytes calldata args,
IPermissionInterpreter.ExecutionContext calldata exec
) external returns (bool);
function callPermissionUint256(
bytes32 permissionId,
bytes4 selector,
bytes calldata args,
IPermissionInterpreter.ExecutionContext calldata exec
) external returns (uint256);
function callPermissionBytes32(
bytes32 permissionId,
bytes4 selector,
bytes calldata args,
IPermissionInterpreter.ExecutionContext calldata exec
) external returns (bytes32);
function callPermissionAddress(
bytes32 permissionId,
bytes4 selector,
bytes calldata args,
IPermissionInterpreter.ExecutionContext calldata exec
) external returns (address);
}grantPermission rejects empty/malformed policy bytes, preflights the policy,
increments the retained generation for that permissionId, and activates a
fresh namespace. revokePermission clears the policy and active namespace but
not the generation counter. Regranting the same ID and identical bytecode still
creates a new state generation.
IModuleRegistry
The registry maps 1-byte module IDs to their deployed contract addresses.
interface IModuleRegistry {
function getModule(uint8 moduleId) external view returns (address module);
}Errors
Matador uses custom errors for gas efficiency and clarity.
Permission Errors
Errors thrown when a specific policy condition fails.
| Error | Parameters | Description |
|---|---|---|
UnknownOpcode | uint8 opcode | The bytecode contained an undefined opcode. |
InvalidCondition | - | A logical condition (AND/OR) was malformed. |
RateLimitExceeded | count, limit, reset | A rate limit has been breached. |
BalanceCheckFailed | token, account, req, act | Token or native balance requirements were not met. |
CallerCheckFailed | required, actual | The caller is not allowed. |
PermissionExpired | expiry, current | The policy includes an expiry timestamp that has passed. |
System Errors
Errors related to the interpreter's internal execution or account management.
| Error | Parameters | Description |
|---|---|---|
Unauthorized | - | Caller is not authorized to grant/revoke permissions. |
InvalidPermissionContext | - | Permission installation supplied empty policy bytes. |
InvalidStateNamespace | - | An account adapter has no valid active namespace for the permission. |
MissingStateNamespace | - | The interpreter received a zero state namespace. |
SafeGuardStillEnabled | - | clearPolicy was called before disabling the guard on the Safe. |
SafeOperationAlreadyActive | - | A second phase-aware lifecycle was attempted for the Safe in one top-level transaction. |
KernelPolicyTooLarge | uint256 length | A Kernel policy exceeds the 24,575-byte SSTORE2 payload limit. |
KernelUserOperationRequired | - | Kernel execution has neither an authoritative current UserOperation hash nor an unambiguous simulation lookup. |
StackOverflow | - | Policy complexity exceeded the interpreter's stack limit (1024). |
StackUnderflow | - | An opcode attempted to pop from an empty stack. |
PermissionNotFound | bytes32 id | The requested permission ID does not exist on the account. |