BotanaryBotanary
Smart account

ERC-7579 modules

The Kernel account and its modules - OwnableValidator, AgentGuard, and Smart Sessions - and how each signing lane works.

Every Botanary account is the owner's self-custodial ERC-7579 smart account - counterfactual until its first operation, deployed the moment that operation is signed and relayed. It's built on Kernel, and its behavior comes almost entirely from three ERC-7579 modules installed on it.

Kernel and the root validator

The account's root validator is an OwnableValidator - a Kernel module that turns the account into an M-of-N multisig over device keys, with the owner's own Privy embedded EOA as the first device. A gas-permit schema in the contract cites a Kernel version of 0.3.3 as an example, and a signing-code comment is specific about the version this targets: "Kernel v3.3's root ECDSA validator" - "nonce mode 00 routes to the root validator." That detail matters for how signatures get assembled; see Signing envelopes below.

Devices: the OwnableValidator set

  • GET /account/devices reads the live co-signer set and its M-of-N threshold directly on-chain. A counterfactual (never-deployed) account reads back as a dormant 1-of-1, whose sole device is the owner's own signer.
  • POST /account/devices builds an OwnableValidator.addOwner call.
  • POST /account/devices/threshold builds an OwnableValidator.setThreshold call.
  • POST /account/devices/{device}/remove builds an OwnableValidator.removeOwner call, with the linked-list predecessor it needs derived live on-chain rather than tracked separately.

AgentGuard: the on-chain policy hook

AgentGuard is the module that enforces the account's own rules - caps, allowlists, denylists, and the freeze switch - and its own docs put the enforcement boundary bluntly: the account "reverts anything outside the mandate on-chain." POST /account/freeze builds an AgentGuard.setFrozen(true) call: an account-wide kill switch that's risk-reducing and gasless, routed through a revoke-only paymaster. POST /account/unfreeze builds the corresponding setFrozen(false) call.

"Hook" here describes how AgentGuard behaves - it isn't a cited formal ERC-7579 module-type classification. Whether AgentGuard is formally registered as a hook module versus an executor module isn't confirmed in the source available to us.

Smart Sessions: delegations and the panic lane

Delegations - scoped, revocable session-key grants - are built on the SmartSession module. POST /delegations/build and POST /delegations build and relay a client-signed enableSessions call that installs a new session with its bounds. A hired agent's mandate goes further: MandateBuild describes its enableUserOp as "ONE op, and it is the WHOLE grant" - a single Kernel batched execute that writes every bound (venue, recipient, per-action cap) into the mandate executor and then calls enableSessions, atomically, so there's never a window where a session key is live before its bounds are in place.

The account's optional "panic" lane reuses the same module for a different purpose: installing it builds a scoped Smart Sessions session - a 1-of-N over the account's own device keys - bound to call nothing but AgentGuard.setFrozen(true). Once installed, any single device key can freeze the account by signing that one pre-scoped action, without waiting on the account's normal signing threshold.

MandateExecutor is a separate on-chain module from the session mechanism itself: it's where a mandate's real bounds (which venues, which recipients, which per-action caps) actually live, set through its own configuration calls rather than as ordinary session policy. A delegation record also carries whether an account-wide freeze stops it too (its freeze policy) - see Delegations for the full grant/revoke lifecycle from a product angle.

The frontend's v1 session-key model is a testnet demonstration seam, not production key management: a fresh session key is generated in the browser before a grant, and its private key is persisted in localStorage, keyed by its own address. There's no autonomous agent signing here yet - a human approves every session-key action.

Chain capability tiers

Not every chain an account can use supports every module. Chains are grouped into three tiers, and the same grouping drives both GET /chains and the authority check on every build path:

  • Botanary-tier - AgentGuard is deployed, so freezing, delegating, and rules all work.
  • Basic-tier - a bundler is available but AgentGuard isn't, so sends work but delegation doesn't.
  • Watch-tier - reads only.

Signing envelopes

Three different signers touch a Botanary account, and each one signs a slightly different envelope:

  • Owner-lane operations (deploy, grant, freeze, revoke) are signed by the Privy embedded EOA over the raw userOpHash, EIP-191-wrapped. For Kernel v3.3's root validator, the resulting userOp.signature is exactly that bare 65-byte signature - nonce mode 00 routes straight to the root validator, no extra prefix needed.

  • Session-lane operations are signed by the session key over the raw userOpHash directly, then wrapped in the Smart Sessions USE-mode envelope before being attached to the operation:

    export async function signSessionOp(
      sessionPrivateKey: Hex,
      userOpHash: Hex,
      permissionId: Hex,
    ): Promise<Hex> {
      const account = privateKeyToAccount(sessionPrivateKey);
      const raw = await account.sign({ hash: userOpHash });
      // SmartSessionMode.USE = 0x00 - encodePacked(bytes1, bytes32, bytes).
      return encodePacked(['bytes1', 'bytes32', 'bytes'], ['0x00', permissionId, raw]);
    }

    The account's Smart Sessions validator recovers that raw hash and re-checks the delegation's policy against it during validation.

  • The USDC gas-permit lane is the odd one out, and it's worth getting right. Paying gas in USDC goes through a Circle Paymaster, authorized by an EIP-2612 permit whose owner is the smart account itself - verified through Kernel's ERC-1271 wrapper rather than the permit's own EIP-712 hash. That wrapper recovers over the raw digest, with no EIP-191 wrap, because it's OwnableValidator acting as the root validator, not the legacy validator it replaced. Sign the permit digest raw, only then assemble the paymaster data, recompute the userOp hash from that, and sign that one EIP-191-wrapped - the order matters, and it's a two-signature flow, not one.

Sign order and wrapping matter here: sign the gas-permit digest raw, and the userOp hash EIP-191. Getting either one backwards makes the root validator's signature recovery fail during validation (the operation dies with an AA33 revert) - this has been directly observed on testnet, not just theorized.

On this page