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:
- Deployment: Deploy the
PermissionInterpreterand register modules. (One-time setup per network). - Provisioning: The account owner grants a permission. The adapter preflights the compiled bytecode and allocates a fresh state generation.
- 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
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.
- Set the validator's hook address to the same
KernelMatadordeployment. - 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. - Allow the inner
Kernel.executeselector and route UserOperations through Kernel'sexecuteUserOpenvelope. - 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.
- Keep compiled policy bytes at or below 24,575 bytes.
KernelMatadorstores them through an SSTORE2 pointer; the remaining byte is the pointer runtime's leadingSTOPunder 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.
Safe Guard
Matador integrates as a Transaction Guard.
- Deploy a
SafeMatadorguard contract. - From the Safe, call
setPolicy(compiledBytecode). Every successful call, including same-byte replacement, creates a fresh policy-state generation. - Enable the guard on the Safe via
setGuard(). - Every transaction executed by the Safe is passed to
checkTransaction()before execution andcheckAfterExecution()after execution. - Non-phase policy code runs once in
checkTransaction; its post callback is a true no-op, including failed target execution. Phase-aware policy code uses the Safe transaction hash as operation id, snapshots bounded pre-context, and reverts target/post failures so the full transaction unwinds.
The guard first preserves Safe's native signature/threshold validation, then
exposes the exact accepted threshold identities as policy authorizers.
Ordinary ECDSA, eth_sign, approved-hash, and contract-signature owners are
supported. The transaction executor is never treated as an authorizer.
authorize() runs against that set before main/pre, and phase-aware post
verifies the same ordered set from the transient snapshot.
Safe GuardManager has no policy install/uninstall callback. Disabling and
re-enabling the guard alone pauses/resumes the same active policy generation.
setPolicy() is what creates or rotates an installation.
If the guard is enabled before any policy is installed, it does not allow
ordinary Safe transactions. The exact empty binding permits only canonical,
zero-value calls to setPolicy(bytes) on the guard or
setGuard(address(0)) on the same Safe. This narrow bootstrap/recovery path
rejects delegatecall, value, nonzero guards, arbitrary targets, malformed
calldata, and partial policy metadata without running policy code or writing
transient state.
To rotate a phase-aware policy, first disable the guard in a policy-approved
Safe transaction. The cached guard still performs post for that transaction.
Then, while unguarded, call clearPolicy() or setPolicy(), and re-enable
the guard. clearPolicy() retains the generation counter, so a later
setPolicy() starts fresh. While the guard remains enabled, clearPolicy()
reverts with SafeGuardStillEnabled.
Operationally, install the replacement policy before re-enabling the guard.
Use clearPolicy() only while unguarded; if the resulting empty guard is
enabled accidentally, use the recovery calls above to install a policy or
disable it again.
A Safe-scoped transient active marker remains set until the top-level EVM
transaction ends. Only one phase-aware lifecycle for a given Safe can
complete in that transaction; a relayer or multicall attempt to execute a
second one fails with SafeOperationAlreadyActive. This prevents an early
Safe-origin checkAfterExecution call from bypassing the official post
callback.
Lifecycle Context
Policies use one adapter-neutral authorization vocabulary:
context.accountis the controlled Kernel account or Safe.context.authorizerCountis 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: 1for pre-execution enforcement. - Use
phase: 2for post-execution enforcement. - Use a nonzero
operationIdonly when the same adapter can pass the identical id to both phases in the same transaction. - Leave
operationIdasbytes32(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
- UserOp Creation: The user signs a UserOp with a temporary session key.
- Validation: The
validateUserOpfunction checks that the session key is authorized and active. - Execution: The
executefunction 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
permissionIddoes 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
authorizersbefore constructing context, setaccount,target, andvaluefrom the protected operation, and keep trusted state namespace inputs outside policy-visible context.