Security Best Practices
Guidelines for authoring secure policies and managing permissions.
Matador policies act as the "programmable firewall" for your smart accounts. While the interpreter is robust, the security of your system ultimately depends on the logic of the policies you write.
Core Design Principles
Principle of Least Privilege
Grant only the exact permissions needed for a specific task.
- Bad: Whitelisting an entire protocol router address.
- Good: Restricting access to a specific
routeraddress AND a specific function selector (e.g.,swap).
Fail Closed
Design your policies so that any ambiguity results in a revert. Matador enforces this by default: if an opcode is unknown or a check fails, the transaction is blocked.
Explicit Control Flow
Prefer clear if (...) { return false; } branches in fn main() so reviewers can see exactly which condition blocks execution.
Common Pitfalls
1. Calldata Manipulation
When inspecting calldata, a malicious actor might try to manipulate unchecked arguments.
Vulnerability Example
Checking amountOutMinimum but ignoring recipient in a swap could allow an attacker to swap your tokens but send the proceeds to their own wallet.
Mitigation: Always verify critical parameters like recipient, tokenIn, and tokenOut.
// Secure Swap Policy
fn main() -> bool {
if (context.target != parameters.router) {
return false;
}
if (context.selector == Uniswap.exactInputSingle) {
if (Uniswap.exactInputSingle.params.recipient != context.account) {
return false;
}
return Uniswap.exactInputSingle.params.tokenIn == parameters.allowedToken;
}
return false;
}2. Rate Limit Key Collision
The ratelimit opcode uses a 32-byte key to track state.
- Risk: Reusing the string
"daily-limit"across multiple policies will cause them to share the same counter. - Fix: Use unique, descriptive keys or hash the policy ID into the key.
// Unique key per policy/action
ratelimit(1 days, 1000 ether, "policy-123-daily-spend")3. Reentrancy & External Calls
Typed ABI external reads perform calls to external contracts and should be treated as part of the policy's trust boundary.
Mitigation:
- Treat external data as untrusted.
- Use typed read APIs such as
callBool(validate())for read-only queries, and reservemain()for enforcement paths that may persist state. - Only whitelist trusted oracles and validators.
4. Lifecycle State Boundaries
Phase-aware policies can compare context.phase with pre or post, and can
share operation-scoped values through declared @transient variables.
- Use
@transientonly when the adapter runs both phases in the same transaction with the same nonzerooperationId. - Keep durable
state.*writes in explicitcontext.phase == postbranches for phase-aware policies. - Treat uninitialized transient reads as policy failures; do not use zero as an implicit sentinel.
- Do not deploy phase-aware or
@transientpolicies on runtime profiles without EIP-1153 support.
Adapter support is part of policy safety
Safe supplies lifecycle callbacks through its guard. Kernel supplies them only
when KernelMatador is installed as both validator and its paired ERC-7579
hook and the UserOperation routes through executeUserOp. Kernel validation
fails closed if that exact hook binding is absent. Validation mints a
transient capability bound to userOpHash, execution bytes, authenticating
owner, retained generation, namespace, and exact code hash. During normal
execution, pre consumes it only when the configured EntryPoint exposes the
same nonzero current UserOperation hash and the validator config nonce remains
at or above Kernel's live validNonceFrom floor. The pinned v0.9 estimation
path may resolve a zero-hash lookup, but conflicting hashes for the same
account, execution bytes, and policy binding mark it ambiguous and fail it
closed. Phase post revalidates the same active self-hook
binding, so an inner self-uninstall or nonce-revocation attempt reverts
atomically. Owner and installation changes invalidate capabilities
minted earlier in the same bundle. This prevents direct account,
alternate-validator, and alternate-executor hook forgery, including theft of
a later operation's pending capability. EntryPoints outside the pinned
canonical v0.9 behavior are not a supported trust boundary. Only the exact
default single-call execution mode is accepted; batch, try, delegatecall,
static, and nonzero mode extensions are rejected before policy execution.
5. Kernel Estimation Stubs
Kernel gas estimation uses an explicit ERC-7769 placeholder rather than
treating every invalid ECDSA signature as a simulator capability. Generate it
with KernelMatador.estimationStubSignature(claimedAuthorizer). The exact
89-byte format is magic 0x52a99cb6, a nonzero 20-byte claimed authorizer, and
65 bytes of 0xff padding.
The address is a claim, not an authenticated signature. For a policy-gated
path, authorize() must admit that authorizer during validation and again when
the capability is consumed. A policy denial, malformed stub, or ambiguous
zero-hash lookup fails closed. Owner bypass is the deliberate exception: a stub
claiming the configured owner mirrors the real bypass path and skips
authorize(), main, pre, and post.
A simulation capability is not validation success
An admitted estimation stub still makes validateUserOp return signature
failure (1). Safety depends on the canonical EntryPoint v0.9 behavior that
rejects that value before handleOps execution, sets a nonzero current hash
during real execution, and unconditionally reverts delegateAndRevert call
trees. The tested canonical deployment is
0x433709009b8330fda32311df1c2afa402ed8d009. Do not use the integration with
an EntryPoint that can ignore the validation result or report a forged
current hash. The transient capability stores count, issued marker,
authorizer, nonce, and bypass status directly in account-associated module
slots; it intentionally stores no validationFailed provenance flag.
6. Timestamp Conditions
BLOCK_TIMESTAMP reads the EVM block timestamp only when policy execution
reaches the opcode. It is suitable for execution-time deadlines in main(),
pre/post hooks, and public reads. It is not caller-provided context and cannot
be spoofed by changing ExecutionContext calldata.
- Keep timestamp out of
authorize()and every helper it can reach. Contract preflight enforces this structurally, including dead branches. - Expect validation and execution to occur at different times. A UserOperation may validate successfully and then revert during execution at a deadline.
- Use explicit inclusive/exclusive boundary tests and allow for block-producer timestamp tolerance; the value is not a precise wall clock.
- Do not treat a timestamp opcode as ERC-4337
validAfter/validUntildata or as proof of general ERC-7562 compatibility. - For Kernel, disable owner bypass if the timestamp condition must apply to the configured owner as well as delegated keys.
Operational Security
Compiler Verification
Always verify that the bytecode you are signing matches your source code.
- Source Control: Store
.matadorfiles in git. - CI Verification: Run
npx --package @steerprotocol/matador-cli matador-policy-cli compilein CI and compare the output hash with the on-chain bytecode. - Human Review: Use the
instructionsfield in the compiler output to sanity-check the generated opcodes.
Monitoring
Set up alerts for PermissionViolation events.
- Spikes: A sudden spike in violations often indicates a broken bot or an active exploit attempt.
- Root Cause: Decode the
opcodein the error to identify exactly which check failed.
Audit Checklist
Before deploying a policy to mainnet, ensure you can answer "Yes" to these questions:
- Does the policy restrict the
targetaddress? - Does the policy restrict the function
selector? - Are all critical calldata arguments (recipient, amount) validated?
- Are numeric limits (allowance, slippage) set to safe bounds?
- Is the rate-limit key unique to this use case?
- If the policy uses
@transient, does the adapter provide same-transaction pre/post execution and a stable nonzero operation id? - Are durable
state.*writes in phase-aware policies guarded bycontext.phase == post? - Is the target chain/runtime profile configured for EIP-1153 before deploying phase-aware bytecode?
- If the policy uses
BLOCK_TIMESTAMP, are boundary behavior, delayed execution, owner bypass, and block-producer tolerance explicit? - Has the policy been tested against both valid and invalid transactions in Foundry?