Matador Docs
Guides

Integration Guide

Architecture for integrating Matador into smart accounts and protocols.

Matador is designed to be the "permission engine" for modular smart accounts. This guide explains how to integrate the interpreter into your account implementation or use it with popular frameworks like Kernel and Safe.

Integration Lifecycle

The integration of Matador generally follows three stages:

  1. Deployment: Deploy the PermissionInterpreter and register modules. (One-time setup per network).
  2. Provisioning: The account owner grants a permission. The adapter preflights the compiled bytecode and allocates a fresh state generation.
  3. Enforcement: Before executing a user transaction, the account loads the active policy binding and delegatecalls interpreter.enforce() with its trusted subject and state namespace.

Base Implementation

The simplest integration is to inherit from BasePermissionAccount. This provides the storage layout and internal functions needed to manage permissions.

Each successful grantPermission, including replacement with identical bytecode, increments a retained generation for that permissionId and activates a fresh stateNamespaceId. revokePermission clears the active policy and namespace but does not reset the generation. Regranting cannot reconnect to old durable state. The grant path materializes any nonzero fixed @persist seeds in the new namespace before the binding becomes active.

Inherit Base Contract

MyAccount.sol
import { BasePermissionAccount } from "@matador/core/accounts/BasePermissionAccount.sol";

contract MyAccount is BasePermissionAccount {
    // ...
}

Implement Execution Logic

In your execution function, construct the ExecutionContext and call _enforcePermission.

function execute(
    bytes32 permissionId,
    address target,
    uint256 value,
    bytes calldata data
) external {
    // 1. Build Context
    IPermissionInterpreter.ExecutionContext memory exec = IPermissionInterpreter.ExecutionContext({
        account: address(this),
        authorizers: authenticatedAuthorizers,
        target: target,
        value: value,
        data: data,
        selector: data.length >= 4 ? bytes4(data) : bytes4(0),
        nonce: 0,
        phase: 0,
        operationId: bytes32(0)
    });

    // 2. Enforce (Reverts on failure)
    _enforcePermission(permissionId, exec);

    // 3. Execute
    (bool success, ) = target.call{value: value}(data);
    require(success, "Execution failed");
}

ExecutionContext carries only authenticated operation and lifecycle facts. Do not add block.timestamp, gas price, blob fee, or remaining gas to a custom builder. Ambient values are interpreter capabilities; BLOCK_TIMESTAMP reads the EVM value lazily when policy execution reaches that instruction.

Timestamp policy boundary

Raw callable bytecode may use BLOCK_TIMESTAMP (0xb5) in main(), Safe or Kernel pre/post execution, and public typed reads. The contract preflight rejects it from authorize() and every reachable helper, even in a dead branch. The source compiler does not expose context.timestamp yet, so do not advertise or depend on source-level syntax in this contract-only tranche. Timestamp checks can deny execution after validation has succeeded; fixed ERC-4337 validAfter/validUntil bounds are a separate design.

Modular Accounts (ERC-7579)

For modular accounts like Kernel or Safe, Matador acts as a Validation Module or Guard.

Kernel Integration

Matador is installed as both the ERC-7579 Validator and that validator's paired Hook.

  1. Set the validator's hook address to the same KernelMatador deployment.
  2. Use Kernel's ordinary non-forcing hook initialization flag (0x00) so the empty hook initialization runs before the validator's owner/policy payload is installed.
  3. Allow the inner Kernel.execute selector and route UserOperations through Kernel's executeUserOp envelope.
  4. Every nonempty installation payload receives a fresh per-account generation, including an owner-only empty-policy binding. Uninstall clears the binding but retains the generation counter.
  5. Keep compiled policy bytes at or below 24,575 bytes. KernelMatador stores them through an SSTORE2 pointer; the remaining byte is the pointer runtime's leading STOP under the EIP-170 limit.

validateUserOp checks the exact self-hook pairing, canonical UserOperation hash, policy metadata, and supported single-call execution shape. A normal ECDSA signature supplies an authenticated key; the explicit estimation stub supplies only the claimed key described below. Policy-gated paths run the mandatory authorize() entry against that singleton set. Validation performs no policy write, then mints a transient capability bound to the exact UserOperation hash, execution bytes, authorizer, nonce, retained generation, namespace, and code hash. Kernel calls preCheck and postCheck around the inner execution from the same account and module storage context. In canonical execution, pre gives the configured EntryPoint's nonzero current UserOperation hash absolute precedence, requires the validator config nonce to remain at or above Kernel's live validNonceFrom floor, and consumes the matching capability. Policy-gated capabilities run authorize() again at consumption so an earlier operation cannot make a later validation stale. Phase post revalidates the same active validator/self-hook pairing, so an inner attempt to remove or nonce-revoke that lifecycle configuration reverts atomically even when Kernel ignores a module's failed uninstall callback.

Non-phase policy runs exactly once in preCheck; its post hook is a no-op. Phase-aware policy uses a transient snapshot binding operation id, account, namespace, exact code hash, target, value, selector, and calldata hash. A target failure or post denial reverts pre state and protected execution. Multiple sequential operations for the same account may run in one EntryPoint bundle; distinct UserOperation hashes isolate their transient state, and a failed operation does not block a later one. Direct account calls and alternate validator/executor attempts cannot consume another operation's capability. An owner or installation change earlier in the same EntryPoint batch derives a different capability key, so an old authorizer cannot execute after rotation. Only the exact default ERC-7579 single-call mode is supported. Batch, delegatecall, static, try, nonzero mode-selector, and payload variants fail closed; try mode cannot be accepted because it suppresses target failure before post.

This lifecycle is pinned to canonical EntryPoint v0.9 hashing and execution behavior, tested at canonical deployment 0x433709009b8330fda32311df1c2afa402ed8d009. Validation reproduces the v0.9 UserOperation hash locally and does not call EntryPoint. Normal execution requires getCurrentUserOpHash(). Alto's v0.9 call-gas binary search leaves that value zero, so the module may use the account/execution/policy-binding lookup populated during validation. If two different UserOperation hashes compete for that lookup, it is marked ambiguous and the zero-hash path fails closed. A nonzero EntryPoint hash never falls back to the lookup. Older, modified, or malicious EntryPoints are not implicitly supported.

ERC-7769 gas estimation

Use estimationStubSignature(claimedAuthorizer) to construct the explicit placeholder signature. It returns exactly 89 bytes: magic 0x52a99cb6, a nonzero 20-byte claimed authorizer, and 65 bytes of 0xff padding. The padding keeps calldata gas conservative relative to a normal 65-byte ECDSA signature.

The claim is unauthenticated. Structural validation and authorize() must admit it before an exact transient capability is minted, and validateUserOp still returns 1. Canonical v0.9 handleOps rejects that result before execution; only a simulation may consume the capability. Policy-gated simulation re-runs authorize() when it consumes the capability. If owner bypass is enabled and the stub claims the configured owner, it instead mirrors the real owner path and skips all policy lifecycle execution. No validationFailed bit is stored in the capability.

Kernel owner bypass is explicit per installation and defaults to disabled. With bypass disabled, owner and delegated keys both pass authorize() and lifecycle policy. With bypass enabled, only the configured owner skips authorize(), main, pre, and post; every non-owner key remains policy-gated. A timestamp policy is therefore universal only when owner bypass is disabled.

Lifecycle Context

Policies use one adapter-neutral authorization vocabulary:

  • context.account is the controlled Kernel account or Safe.
  • context.authorizerCount is the authenticated set size.
  • authorizedBy(address) tests membership in that set.

The adapter authenticates identities; the policy decides whether those identities may perform the requested operation. Kernel supplies the recovered UserOperation key. Safe supplies the threshold identities accepted by native Safe signature checking.

Phase-aware policies use context.phase == pre and context.phase == post inside fn main() -> bool. The integration, not the policy author, supplies the numeric runtime phase and the stable operationId.

  • Use phase: 1 for pre-execution enforcement.
  • Use phase: 2 for post-execution enforcement.
  • Use a nonzero operationId only when the same adapter can pass the identical id to both phases in the same transaction.
  • Leave operationId as bytes32(0) for ordinary single-phase validation policies that do not use @transient.

Transient state is EIP-1153-backed. If the chain or runtime profile does not support TSTORE and TLOAD, phase-aware bytecode fails during adapter preflight, before Safe writes a snapshot. Non-phase Safe policies do not use the snapshot path.

Single-phase adapters

Do not set phase: 1 unless the same adapter also runs a matching post-execution phase: 2 hook with the same nonzero operationId. Single-phase adapters should pass phase: 0 so phase-aware policies fail closed instead of silently skipping post conditions.

ERC-4337 Session Keys

Matador is the ideal engine for ERC-4337 Session Keys.

The Flow

  1. UserOp Creation: The user signs a UserOp with a temporary session key.
  2. Validation: The validateUserOp function checks that the session key is authorized and active.
  3. Execution: The execute function enforces the Matador policy to ensure the session key is only performing allowed actions (e.g., swapping specific tokens).

Storage Restrictions

During validateUserOp, access to external storage is restricted by the bundler. If your policy uses stateful checks (like RATE_LIMIT_CHECK), you must enforce them during the execution phase, not the validation phase.

function validateUserOp(PackedUserOperation calldata userOp, ...) returns (uint256) {
    // 1. Recover signer from userOp.signature
    // 2. Verify signer corresponds to a valid permissionId
    // 3. Return validation success (pay prefund)
}

function executeWithPermission(bytes32 permissionId, uint256 operationNonce, ...) external {
    // Validation requires operationNonce == userOp.nonce for this exact call.
    // BasePermissionAccount loads the installed policy and its trusted
    // stateNamespaceId, then delegatecalls the interpreter.
    execContext.nonce = operationNonce;
    _enforcePermission(permissionId, execContext);
    
    // 2. Perform Action
}

The included ERC-4337 example carries the exact operation nonce inside executeWithPermission calldata and rejects a UserOperation when that embedded value differs from userOp.nonce. Direct owner calls must use zero. A production account should bind every policy-visible context field to authenticated account execution data with equivalent rigor.

Custom adapter trust boundary

A custom stateful adapter must store a nonzero namespace with each installed policy and pass it outside ExecutionContext. Use delegatecall so policy state lives in the adapter. Under delegatecall, namespaceAuthority is the adapter/storage owner; under a direct interpreter call it is direct msg.sender, and state lives in the interpreter contract instead. Physical storage ownership is implicit in EVM call context, not another encoded key field. Never accept subject or stateNamespaceId from policy bytecode or an untrusted transaction field.

Best Practices

  • Fail Closed: Ensure that if a permissionId does not exist, the transaction reverts immediately.
  • Fresh Generations: Increment an adapter-retained generation for every successful install or replacement. Removal must clear the active namespace without resetting the counter.
  • Fresh State on Replacement: A replacement receives a new installation namespace and does not preserve state merely because a name, permission ID, or bytecode hash matches.
  • Gas Overhead: Complex policies add overhead. Profile your gas usage using the Matador benchmarks.
  • Context Integrity: Authenticate authorizers before constructing context, set account, target, and value from the protected operation, and keep trusted state namespace inputs outside policy-visible context.

On this page