Matador Docs
Guides

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 router address 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 reserve main() 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 @transient only when the adapter runs both phases in the same transaction with the same nonzero operationId.
  • Keep durable state.* writes in explicit context.phase == post branches 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 @transient policies 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/validUntil data 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.

  1. Source Control: Store .matador files in git.
  2. CI Verification: Run npx --package @steerprotocol/matador-cli matador-policy-cli compile in CI and compare the output hash with the on-chain bytecode.
  3. Human Review: Use the instructions field 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 opcode in 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 target address?
  • 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 by context.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?

On this page