Seed Protocol
Contracts

Executor and router extension

Reference for SeedProtocolExecutor, the ERC-7579 executor module session keys publish through, and SeedExecutorRouterExtension, which lets a thirdweb ManagedAccount host it: install, functions, events, errors and supported accounts.

SeedProtocolExecutor is an ERC-7579 executor module: a separate contract that an account installs and then calls, and that can ask the account to make calls on its behalf. Seed uses it to give session keys a narrow way to publish. A session key may call the executor and nothing else, and the executor can only make the account call EAS attest and multiAttest. Because the account makes the EAS call, the account is still the attester.

thirdweb ManagedAccounts aren't ERC-7579 accounts, so SeedExecutorRouterExtension, the router extension, adds the part of ERC-7579 the executor needs to the account, through the factory's Router.

Deployments has both addresses on OP Sepolia. Older executors are under Not in use, including one that accounts installed.

How a session-key publish flows

The executor never holds data or funds of its own. All it keeps is, for each account, the EAS address it was installed with.

Install and enrollment

The executor has to be installed on an account before it can act for it, and only an account admin can install it.

  1. The account's admin calls installSeedExecutor() on the account. The router extension records the executor's address in the account's storage, then calls the executor's onInstall with the EAS address fixed in the router extension.
  2. The executor checks that the account reports it as installed, stores the EAS for that account, and emits ModuleInitialized(account, eas).
  3. The admin grants a session key with the executor as its only approved target (a thirdweb SignerPermissionRequest with a time window and a native-token limit).

In the publish package, with modularAccountModuleContract set, ConnectButton installs the executor if it's missing, and so does publish prep when useModularExecutor is on, with a transaction from the account's admin (the user's in-app wallet). Both are best-effort: a failed install doesn't stop connecting or publishing, since only a session key needs the executor. enrollPublishAutomation does steps 1 and 3, refuses to add the session key unless the executor is installed and set up with your configured EAS, and records the grant as a seedprotocol.publishAuthorization attestation. See Publish unattended with a session key.

Before it installs anything, the publish package checks that modularAccountModuleContract is the executor the account's router extension trusts (from getSeedExecutor()), and fails with EXECUTOR_MODULE_NOT_INSTALLED if it isn't. readFactorySeedExecutor() reads the same value from the factory, so you can check your config at startup.

After the factory moves to a new executor

The install record holds the executor's address, and installSeedExecutor, uninstallSeedExecutor, isModuleInstalled and executeFromExecutor all compare it with the executor the router extension pins. When the factory routes a new router extension with a new executor, an account that installed the old one reads as not installed. Its admin calls installSeedExecutor() again, with no uninstall step; uninstallSeedExecutor() reverts with SeedExecutorNotInstalled on such an account. A true left by the 2026-10-03 router extension's flag reads as address 1, so those accounts behave the same way.

Session keys are granted again, because a key's approved target is the executor's address.

With modularAccountModuleContract set to the new address, an account whose admin connects or publishes interactively installs it, best-effort, and enrollPublishAutomation installs it and replaces the key's targets. Until then:

SituationWhat a publish does
A session-key publish on an account that hasn't installed the new executorFails with AUTOMATION_UNSUPPORTED_ACCOUNT. A key can't install.
A key approved only for the old executor, on an account that has installed the new oneFails with MODULAR_SIGNER_ACTIVATION_FAILED, and the message says to run enrollPublishAutomation again
modularAccountModuleContract still names the old executorAn install fails with EXECUTOR_MODULE_NOT_INSTALLED, naming both addresses. A session-key publish fails with MULTI_PUBLISH_NOT_ROUTED.

What a session key can reach

A session key whose only target is the executor can only attest: the executor has no revoke function, executeFromExecutor lets it call only EAS attest and multiAttest, and the install-order checks below stop it re-pointing the executor. What a leaked session key can and can't do has the full list.

SeedProtocolExecutor

multiPublish, publish, createSeed and createVersion act for msg.sender, which must be an account that installed the executor. A call from any other address reverts with NotInitialized(caller). The lifecycle functions and views have their own rules (below).

multiPublish

The same batch publish as the extension's multiPublish, with the same PublishRequestData struct (selector 0xa1263421 on both), the same three phases and cross-reference rules from SeedPublishLib, the same exact msg.value check, and the same forced revocability.

function multiPublish(PublishRequestData[] memory requests)
    external payable returns (bytes32[] memory)

Differences from the extension:

  • Each Seed, Version and property batch is a separate executeFromExecutor call back into the account.
  • The caller is checked first (NotInitialized), then msg.value. Each request's property batch goes back to the account with that request's share, so the executor never keeps ETH. Session keys are granted no native value, so the publish package refuses a session-key publish with a non-zero total (AUTOMATION_PREFLIGHT_FAILED) before sending.
  • SeedPublished(seedUid, versionUid) is emitted by the executor, only for requests that had property attestations.

The publish package sends this call when the signer is an active automation session key and useModularExecutor is on. It sends multiPublish to the executor from the session key's smart wallet, which thirdweb wraps in a UserOp calling execute(executor, 0, multiPublish(...)) on the user's account. It simulates the call from the account first and refuses to send (AUTOMATION_PREFLIGHT_FAILED) if it would revert or can't be simulated.

publish, createSeed, createVersion

Smaller entry points that only create the Seed and Version attestations, with no properties.

function publish(PublishRequestData memory request) external returns (bytes32 seedUid, bytes32 versionUid)
function createSeed(bytes32 schemaUid) external returns (bytes32 seedUid)
function createVersion(bytes32 seedUid, bytes32 versionSchemaUid) external returns (bytes32 versionUid)

publish creates the Seed and/or Version when their UIDs are zero, ignores listOfAttestations and propertiesToUpdate, and returns both UIDs. The publish package doesn't use these.

Lifecycle and views

FunctionDescription
onInstall(bytes data)Called by the account during install. data is abi.encode(address eas). Reverts unless the account already reports the executor installed (NotInstalledOnAccount), if it's already initialized (AlreadyInitialized), or if EAS is zero (InvalidEASAddress).
onUninstall(bytes)Called by the account during uninstall. Clears the account's EAS. Reverts with StillInstalledOnAccount while the account still reports it installed.
isModuleType(uint256 moduleTypeId) viewtrue for 2 (executor) only
isInitialized(address account) viewWhether the module is set up for account
getEAS(address account) viewThe EAS address stored for account, or zero

The install-order checks are what stop a session key, which can reach the executor through the account, from wiping or re-pointing the account's configuration by calling onInstall or onUninstall itself.

Events

Emitted by the executor's address. Any contract can call the executor as an account, with its own EAS, so these events can carry arbitrary UIDs. Read Attested from a known EAS address to see what was attested.

EventWhen
CreatedAttestation(CreatedAttestationResult result)For each Seed and each Version created. result is (schemaUid, attestationUid).
SeedPublished(bytes32 seedUid, bytes32 versionUid)After a request's properties are attested, in multiPublish only
ModuleInitialized(address indexed account, address indexed eas)On install
ModuleUninitialized(address indexed account)On uninstall

The extension's SeedPublished takes bytes instead, so the two events have different signatures.

Errors

ErrorCause
NotInitialized(address account)The caller hasn't installed the executor
AlreadyInitialized(address account)onInstall on an account that's already set up
InvalidEASAddress()onInstall with a zero EAS address
NotInstalledOnAccount(address account)onInstall called outside a real install
StillInstalledOnAccount(address account)onUninstall called outside a real uninstall
AttestationFailed()EAS returned a zero UID for a Seed or Version. An account whose executeFromExecutor returns an empty array panics (0x32) before this check.
MultiAttestFailed()The account's executeFromExecutor returned no result

SeedPublishLib's PublishValueMismatch and cross-reference errors apply too; see SeedProtocolExtension. Errors from the router extension and from EAS bubble up through the account.

SeedExecutorRouterExtension

A Router extension, like SeedProtocolExtension: its code runs inside the account by delegatecall, and its events come from the account's address. It trusts exactly one executor and one EAS, both immutable constructor arguments chosen by whoever registers it on the factory. Account admins can only opt in or out; they can't be talked into installing a different module.

The factory routes five selectors to it:

FunctionWho may callBehavior
installSeedExecutor()An admin, or the EntryPoint (an admin-signed UserOp). Not the account itself.Records the executor's address, then calls executor.onInstall(abi.encode(eas)). Emits SeedExecutorInstalled(executor).
uninstallSeedExecutor()SameClears the record, then calls executor.onUninstall(""). Emits SeedExecutorUninstalled(executor).
isModuleInstalled(uint256 moduleTypeId, address module, bytes) viewAnyonetrue only for type 2 and the pinned executor, while the record holds that address
getSeedExecutor() view returns (address executor, address eas)AnyoneThe pinned executor and EAS
executeFromExecutor(bytes32 mode, bytes executionCalldata) payable returns (bytes[])The installed executor onlyMakes one call to EAS for the executor (below)

Its events come from the account's address:

EventWhen
SeedExecutorInstalled(address indexed executor)installSeedExecutor() succeeded
SeedExecutorUninstalled(address indexed executor)uninstallSeedExecutor() succeeded

Installing rejects self-calls (Unauthorized) because a session key whose targets include the account can make self-calls. To install through a UserOp, its call data has to be installSeedExecutor() itself, not execute(account, …). The publish package sends it as a transaction from the admin itself (the user's in-app wallet), not as a UserOp.

executeFromExecutor

The ERC-7579 callback the executor uses to make the account act. It's deliberately narrow. It reverts unless:

  • the caller is the pinned executor and it's installed (NotSeedExecutor);
  • mode equals ERC-7579's simple single mode exactly: single call type, default exec type, and zero selector and payload (UnsupportedExecutionMode);
  • the target in executionCalldata (abi.encodePacked(target, value, callData)) is the pinned EAS (TargetNotAllowed);
  • the selector is EAS attest or multiAttest (SelectorNotAllowed), so no revoke;
  • the encoded value equals msg.value (ValueMismatch), so the account never pays for the executor from its own balance.

It then calls EAS from the account and returns EAS's return data as the single entry of the array. If EAS reverts, the revert is passed through unchanged.

Errors

ErrorCause
Unauthorized(address caller)Install or uninstall from someone other than an admin or the EntryPoint
SeedExecutorAlreadyInstalled()The record already holds the pinned executor
SeedExecutorNotInstalled()Uninstalling when the record doesn't hold the pinned executor, including on an account that installed an earlier one
NotSeedExecutor(address caller)executeFromExecutor from anyone but the installed executor
UnsupportedExecutionMode(bytes32 mode)Batch or non-default mode
TargetNotAllowed(address target)Target isn't the pinned EAS
SelectorNotAllowed(bytes4 selector)Not attest or multiAttest, or calldata too short to hold a selector
ValueMismatch(uint256 encodedValue, uint256 sentValue)Encoded value and msg.value differ
InvalidAddress(address addr)Constructor only: EAS or the executor has no code

The install record lives in ERC-7201 namespaced account storage (extensions.seedprotocol.executor-router.storage), so it doesn't collide with thirdweb's own storage.

Which accounts are supported

The executor works with any account that calls onInstall and onUninstall normally (so msg.sender is the account) and marks the module installed before onInstall and uninstalled before onUninstall, as the ERC-7579 reference implementation does. That covers:

  • thirdweb ManagedAccounts whose factory routes SeedExecutorRouterExtension. This is the only setup the publish package installs on and enrolls, and the one deployed on OP Sepolia.
  • ERC-7579 accounts, such as thirdweb's ERC-7579 accounts, Safe with the Safe7579 adapter, Kernel and Nexus. You install the executor with the account's own installModule(2, executor, abi.encode(eas)). The contracts repo tests this with a mock ERC-7579 account only. On these accounts the narrow executeFromExecutor checks above don't apply; the executor's own code still only ever calls EAS attest and multiAttest, but limiting what a session key can reach is up to the account's own permission setup.

It does not work with thirdweb's modular-contracts Core accounts (the SDK calls these "ModularCore"). Core.installModule calls getModuleConfig(), which the executor doesn't implement, and Core runs onInstall by delegatecall, so the configuration would land in the account's storage and isInitialized(account) would stay false.

On this page