Seed Protocol
Contracts

Deploying to a new chain

The procedure the contracts repo's scripts implement for putting Seed Protocol on another EVM chain: prerequisites, the Hardhat commands in order, rollback, and the SDK settings apps need afterwards.

This page is for protocol operators. It walks through deploying the Seed contracts to a chain where they don't exist yet and routing a thirdweb account factory to them, using the tasks in the seed-protocol contracts repo. App developers don't need it: apps use the deployment that exists.

This procedure has only been run on OP Sepolia and in local rehearsals. The commands below are the repo's own; nothing here has been tried on another chain. Deployments lists where Seed is deployed.

What gets deployed

The Ignition module ignition/modules/SeedProtocol.ts deploys three plain, non-upgradeable contracts:

ContractConstructor arguments
SeedProtocolExtensionthe chain's EAS address
SeedProtocolExecutornone
SeedExecutorRouterExtensionthe EAS address and the executor's address

Then seed:replace-extension routes the factory to them. Nothing else is deployed: EAS, the account factory, the EntryPoint and CreateX must already be on the chain.

Prerequisites

You needWhy
EAS and its SchemaRegistry on the chainThe contracts attest through EAS, and the base schemas are registered in its registry. On OP Stack chains these are the predeploys 0x4200…0021 and 0x4200…0020.
CreateX at 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5EdContracts are deployed through CreateX with --strategy create2. seed:predict-addresses fails if it isn't there.
A thirdweb ManagedAccountFactory, and its EntryPoint (v0.6)The extensions are routed through its Router. The repo has no tooling to deploy a factory on a new chain; it assumes one exists.
EXTENSION_ROLE on that factory, or access to the key that holds itOnly that role can add or replace extensions. Without it, seed:replace-extension prints the call for the holder to send.
The contracts repo, installed with bun install (not npm), on Node 24Bun applies a patch the build needs.
An RPC URL, a funded deployer key and, for --verify, an Etherscan API keyRead through Hardhat's configVariable, from the environment or a .env file.
An EAS indexer for the chain, and an ERC-4337 bundler and paymaster that serve itApps read attestations from an indexer and send UserOps through a bundler. See step 9.

Addresses across chains

Deploys go through CreateX with a salt guarded to the Seed deployer address and the label seed-v1. When that deployer sends the deploy, a contract's address depends only on the salt, its bytecode and its constructor arguments, so it's the same on every chain where those match:

  • SeedProtocolExecutor has no constructor arguments, so it lands at the same address everywhere the Seed deployer deploys it.
  • SeedProtocolExtension and SeedExecutorRouterExtension take the EAS address, so they only match across chains with the same EAS address. On OP Stack chains with the predeploys (OP Sepolia, Base Sepolia, and so on), they'd match OP Sepolia's. On a chain where EAS lives elsewhere, they won't.

Anyone else deploying with the same salt lands somewhere else: CreateX treats a salt guarded for another sender as random. Pass --deployer <address> to seed:predict-addresses to see where your key's deploy will go.

The build settings matter too. hardhat.config.ts pins solc 0.8.38, EVM cancun, optimizer 200 runs and isolated builds, identically for the default profile (tests, predictions) and the production profile (ignition deploy on live networks). Change any of them and every address moves. A second compiler, 0.8.27 with the same settings, builds only the EAS contracts, which pin that pragma and which only the tests and the twin deploy.

The procedure

All the seed:* tasks take --network, and read ignition/parameters/<network>.json unless you pass --parameters. Run them from the contracts repo.

1. Add the network

Add an entry to networks in hardhat.config.ts, alongside optimism_sepolia:

hardhat.config.ts (excerpt)
optimism_sepolia: {
  type: 'http',
  chainType: 'op',
  url: configVariable('OPTIMISM_SEPOLIA_RPC_URL'),
  chainId: 11155420,
  accounts: [configVariable('DEV_KEY')],
},

The network's name is what you pass as --network, and it picks the parameters file. Use chainType: 'op' only for OP Stack chains.

2. Write the parameters file

Create ignition/parameters/<network>.json with the chain's EAS and the factory:

ignition/parameters/optimism_sepolia.json
{
  "SeedProtocol": {
    "eas": "0x4200000000000000000000000000000000000021"
  },
  "SeedRollout": {
    "factory": "0x76F47D88bfaf670F5208911181fCDC0E160cb16d"
  }
}

SeedProtocol.eas is the Ignition module's only parameter. SeedRollout.factory is read by the rollout tasks.

3. Rehearse locally

bun run rehearse:local

This starts a throwaway hardhat node, deploys a stand-in stack (EAS, an EntryPoint, a factory and one account), then runs every command below against it and publishes through the result. A green rehearsal means the commands, parameters and scripts work end to end.

bun run rehearse:op-sepolia does the same against a fork of OP Sepolia with its real factory. It's OP Sepolia only; there's no fork rehearsal for other chains. With --account <address> --admin <its admin>, that account also installs the routed executor and publishes through it. --keep-node leaves the node up until Ctrl-C, for testing a client against it.

4. Register the base schemas

npx hardhat seed:ensure-schemas --network <network>

Registers any of the protocol's base schemas that are missing from the chain's SchemaRegistry, from the first signer, and reports the rest as registered. Registering is permissionless and the UIDs are the same on every chain. --check-only only reports, and exits non-zero if any are missing. The task doesn't add name attestations, and a publish names only the schemas it registers itself, so the base schemas show without names on an explorer. A publish on a chain where the Version or "Name a Schema" schema is missing fails before it sends anything.

5. Predict the addresses

npx hardhat seed:predict-addresses --network <network>

Read-only. For each contract it computes the CREATE2 address and eth_calls CreateX's deployCreate2 from the deployer to confirm CreateX would create exactly that contract there. Every line should say free (or deployed, if it's already there). It exits non-zero otherwise.

6. Deploy

npx hardhat ignition deploy ignition/modules/SeedProtocol.ts --network <network> --strategy create2 --parameters ignition/parameters/<network>.json --verify

Ignition writes its record to ignition/deployments/chain-<chainId>/, the one place the repo keeps deployed addresses; commit it. Ignition resumes an interrupted deploy instead of starting over. --verify publishes the source on the chain's block explorers. In the OP Sepolia deploy, the deploy log records its Blockscout step failing with Cannot read properties of null (reading 'startsWith'), so read that step's output rather than assuming every explorer took it.

7. Route the factory to the new contracts

Dry-run first, and read the planned changes:

npx hardhat seed:replace-extension --network <network> --dry-run
npx hardhat seed:replace-extension --network <network>

The task:

  1. snapshots the factory's whole extension registry, and its EXTENSION_ROLE holders, to ignition/deployments/chain-<chainId>/routing-before.json, the rollback record. The snapshot belongs to the deployment folder and is never overwritten, so before a second rollout on a chain, move the old folder aside (OP Sepolia keeps its earlier records in op-sepolia-superseded-<date>/);
  2. replaces the registered Seed extension with the new SeedProtocolExtension, routing multiPublish and getEas, or adds it if the factory has none;
  3. adds SeedExecutorRouterExtension, or replaces the one already registered;
  4. sends steps 2 and 3 in one multicall, so they land together;
  5. reads every selector back and fails if any routes to the wrong place.

It refuses, and asks for a human, if more than one registered extension looks like the Seed extension, or if one of the new selectors is already routed to an unrelated extension. Run it again after a success and it reports nothing to change.

If your signer doesn't hold EXTENSION_ROLE, the task prints the multicall to send from the role holder's wallet. After it's sent, check the result:

npx hardhat seed:replace-extension --network <network> --check-only

8. Check a live account

npx hardhat seed:verify-live --network <network> --account <a deployed ManagedAccount from this factory>

Static calls only, so it's safe on any network. It checks that the account belongs to the factory, that a stranger's multiPublish reverts with Unauthorized, that setEas, the old string-localId multiPublish and the publishIndex multiPublish without list elements aren't routed, that getEas() is the expected EAS, that the base schemas are registered, that getSeedExecutor() returns the new executor and EAS, and that every selector routes to the expected implementation. It exits non-zero on any failure. An account with no code yet is reported in one line instead.

Then publish once as an admin and once with a session key from a real app, on an @seedprotocol/publish release built for these contracts, against a test account before telling anyone the chain is ready. A contract publish on a release that doesn't match fails with MULTI_PUBLISH_NOT_ROUTED.

9. Configure apps

The publish package has no built-in Seed contract address that it sends to, and the only factory it knows is OP Sepolia's. On another chain an app passes everything in its config:

src/publish.ts
import { initPublish } from '@seedprotocol/publish'
import { baseSepolia } from 'viem/chains'

initPublish({
  chain: baseSepolia,
  rpcUrl: import.meta.env.VITE_RPC_URL,
  thirdwebClientId: import.meta.env.VITE_THIRDWEB_CLIENT_ID,
  uploadApiBaseUrl: import.meta.env.VITE_UPLOAD_API_BASE_URL,
  managedAccountFactoryAddress: import.meta.env.VITE_MANAGED_ACCOUNT_FACTORY,
  modularAccountModuleContract: import.meta.env.VITE_SEED_EXECUTOR,
})
  • chain is a viem chain. If it isn't in @seedprotocol/eas's list of known EAS deployments, also pass easContractAddress and schemaRegistryAddress. See @seedprotocol/eas.
  • managedAccountFactoryAddress is the factory you routed. Only OP Sepolia's is built in.
  • modularAccountModuleContract is the new SeedProtocolExecutor, needed for session keys.
  • The SDK's EAS reads must match: set eas: { chainId, indexerUrl } in the SDK config, with indexerUrl pointing at an EAS indexer for the chain if @seedprotocol/eas doesn't know one. See Client and configuration.
  • Bundler and paymaster: by default thirdweb's hosted ones for the chain are used; whether they serve your chain is up to thirdweb. thirdweb.bundlerUrl points the ManagedAccount at another bundler. See Bundler and paymaster.

Model and property schemas get registered on the new chain as apps publish, the same as on OP Sepolia.

A replacement on a chain that already has users goes further than this page. The switch needs a matching SDK release, every account's admin installs the new executor again, and session keys are granted again.

Rollback

Roll back from the EXTENSION_ROLE holder with replaceExtension to the implementations recorded in routing-before.json. A factory that had no router extension before the rollout gets removeExtension("SeedExecutorRouterExtension") instead; one that had a router extension gets replaceExtension back. Rolling back puts the older code back in every account, and an SDK release built for the newer contracts stops publishing through them, so do it only for a breaking failure and only until a fix is deployed.

After moving back to an older executor, installSeedExecutor reverts with AlreadyInitialized on accounts that installed it, because it kept their configuration. The account's admin clears it with execute(oldExecutor, 0, onUninstall("")), which passes because the account no longer reports that executor installed, and then installs.

Troubleshooting

TaskUse it when
npx hardhat seed:extension-payloadYou want to see the Router Extension structs for a deployment without sending anything
npx hardhat seed:debug-publish --account <a> --payload <file> [--from <admin>]A multiPublish reverts: it simulates the call through the account, from the first signer unless --from names another caller, and decodes the revert
npx hardhat seed:explain-userop --tx <hash>A UserOp in a handleOps transaction failed: it decodes the revert, or shows that it ran out of gas and how much it needed
npx hardhat seed:publish-smokeOn a simulated network only: admin and session-key publishes through the live routing, including one through the routed executor with a session key scoped to it. With --account and an impersonated admin it does the same on that account, and fails if the account already reads as installed

On this page