Seed Protocol
Contracts

Local twin

Run a local copy of the Seed stack: a fork of OP Sepolia under its own chain ID with the current contracts, funded accounts, an ERC-4337 bundler and an EAS indexer seeded from easscan, and point an app at it.

The twin is a local stand-in for OP Sepolia, for developing apps and the SDK end to end without touching the real testnet. One command in the contracts repo starts a forked chain with the Seed contracts routed, funded test accounts, a bundler and an EAS indexer, and writes a file with every address and URL your app needs.

What it runs

LayerOP SepoliaTwin
ChainOP Sepolia, chain ID 11155420A hardhat node forking OP Sepolia at a pinned block (49,590,000 by default), under chain ID 31337, at http://127.0.0.1:8545
EAS, SchemaRegistry, account factory, EntryPointThe predeploys, factory 0x76F4…6d, EntryPoint v0.6The same contracts at the same addresses, with OP Sepolia's state at the fork block
Seed contractsDeployed and routedDeployed again and routed with the real rollout commands, at different addresses
Base schemasRegisteredInherited from the fork, and checked
UserOpsthirdweb's bundler and paymasterA local alto bundler on http://127.0.0.1:4337. No paymaster
EAS readseasscan GraphQLThe official EAS indexer in Docker, GraphQL at http://localhost:4000/graphql
Loginthirdweb in-app walletsUnchanged: still thirdweb's hosted login, so you need the internet
File uploadsAn upload APINot started by the twin

The separate chain ID is deliberate. Anything that misses your local config and falls back to OP Sepolia fails loudly instead of signing a transaction that's valid on the real testnet.

The Seed contracts get different addresses on the twin because the fork deploys them from a local account, not Seed's deployer. Read them from twin.json, not from Deployments.

Requirements

  • The seed-protocol contracts repo, installed with bun install, on Node 24 and the Bun version pinned in its package.json.
  • OPTIMISM_SEPOLIA_RPC_URL: an OP Sepolia RPC that can serve state at the fork block, in the environment or the repo's .env.
  • Docker, for the EAS indexer. Without it, pass --no-indexer.

Start it

bun run twin:up

It runs in the foreground, and Ctrl-C stops everything, including the indexer's containers and their data. In order, it:

  1. starts the forking node;
  2. deploys the Seed contracts and routes the factory to them, then runs seed:ensure-schemas and seed:verify-live;
  3. funds five test accounts (alice to erin) with 100 ETH each. Their keys are generated once per checkout and kept in .twin/keys.json, so they're the same on every start;
  4. starts the bundler;
  5. runs smoke tests: admin and session-key publishes, including one through the executor, then a UserOp through the bundler that deploys alice's ManagedAccount and publishes from it;
  6. starts the EAS indexer with a fresh database, seeded with OP Sepolia's schemas, schema names and attestations up to the fork block (see Reads);
  7. writes .twin/twin.json and prints a summary.

The first start for a fork block takes about 75 seconds, because it downloads the indexer seed; later starts take about 35.

OptionEffect
--fork-block <n> or --fork-block latestFork a different block. latest needs OPTIMISM_SEPOLIA_RPC_URL
--light-indexSeed the indexer with schemas and names only, not attestations
--no-indexerSkip the indexer, and Docker

twin.json

Everything your app needs is in .twin/twin.json in the contracts repo:

FieldValue
chainId31337
rpcUrlhttp://127.0.0.1:8545
bundlerUrlhttp://127.0.0.1:4337
easGraphqlUrlhttp://localhost:4000/graphql, or null with --no-indexer
forkedFrom{ chain, chainId, block }
entryPointEntryPoint v0.6
contractseas, schemaRegistry, managedAccountFactory, seedProtocolExtension, seedProtocolExecutor, seedExecutorRouterExtension
baseSchemasthe base schema UIDs, by key
accountsthe test accounts, with name, address and privateKey
aliceManagedAccountthe ManagedAccount the smoke test deployed for alice, holding 10 ETH

The keys are test keys for a local chain. Don't use them anywhere else.

Fund accounts

There's no paymaster, so every account that sends transactions needs ETH: an EOA for its own transactions, and a ManagedAccount for its own UserOps (without it you get AA21 didn't pay prefund). With the twin running:

bun run twin:fund 0xYourManagedAccount 0xYourAdminEoa --eth 10

The default is 10 ETH. It works on a ManagedAccount's address before the account is deployed.

Point an app at it

seedTwinConfig from @seedprotocol/publish turns twin.json into the publish package's settings and the SDK's EAS reads. Copy .twin/twin.json into your app, or load it at build time:

src/twin.ts
import { client } from '@seedprotocol/sdk'
import { initPublish, seedTwinConfig } from '@seedprotocol/publish'
import schema from '../seed.schema.json'
import twinJson from './twin.json'

const twin = seedTwinConfig(twinJson)

initPublish({
  ...twin.publish,
  thirdwebClientId: import.meta.env.VITE_THIRDWEB_CLIENT_ID,
  uploadApiBaseUrl: import.meta.env.VITE_UPLOAD_API_BASE_URL,
  useModularExecutor: true,
  autoDeployManagedAccount: true,
})

await client.init({
  config: { schema, eas: twin.eas, filesDir: `.seed-${twin.chain.id}` },
})

twin.publish sets chain (a viem chain for 31337), rpcUrl, the EAS and SchemaRegistry addresses, the factory, the executor as modularAccountModuleContract, and thirdweb. twin.eas is { chainId, indexerUrl }. seedTwinConfig throws seedTwinConfig: twin.json is missing <fields>. Re-run twin:up. when one of these is empty, which includes easGraphqlUrl after --no-indexer.

  • useModularExecutor and autoDeployManagedAccount deploy a user's ManagedAccount on their first publish. See autoDeployManagedAccount for what happens without it.
  • thirdweb.bundlerUrl sends the ManagedAccount's UserOps to the twin's bundler.
  • thirdweb.sponsorGas: false: there's no paymaster, so fund the accounts.
  • thirdweb.modularWalletMode: 'EOA': thirdweb's EIP-7702 mode depends on thirdweb's hosted services for the chain, which don't serve chain 31337, so the in-app wallet sends its own admin transactions as a plain EOA. Fund it too.
  • The factory and EAS are OP Sepolia's addresses on the twin; only the Seed contracts differ. The publish package looks up built-in addresses by chain ID, and 31337 has none, so seedTwinConfig passes them all.
  • filesDir keeps the twin's local database apart from the one your app uses on OP Sepolia.

Login works as in production: email, passkey, phone and Farcaster go through thirdweb's auth service, which doesn't depend on the chain. The user gets the same in-app wallet as on OP Sepolia and, because the factory is the same, the same ManagedAccount address. That account's pre-fork history is on the twin's chain and in its indexer; nothing you do on the twin reaches OP Sepolia. Connect-button extras that come from thirdweb's services, such as buying funds, token prices and chain icons, don't know chain 31337.

An external wallet such as MetaMask works too: add a network with chain ID 31337 and RPC http://127.0.0.1:8545, and fund the address.

Publish overview and Client and configuration cover these options.

Reads and the indexer

The twin runs the official EAS indexing service, the software behind easscan, so the SDK's queries work unchanged against easGraphqlUrl. Each twin:up seeds it from optimism-sepolia.easscan.org with everything up to the fork block, which the indexer needs before it accepts the twin's first publishes; EAS indexing covers the seeding and running the indexer on its own.

Things to know

  • Every twin:up resets the chain to the fork block. Accounts, attestations and nonces from the last session are gone. Clear what your app cached: on Node, the filesDir you gave the twin (a relative one is under <cwd>/.seed); in the browser, the storage for your app's origin (client.resetLocalData() deletes the client's database and files, but not localStorage). In MetaMask, clear the twin network's activity data, or it reuses old nonces.
  • Attestations revoked after the fork block show as not revoked in the seeded index, matching the forked chain.
  • Uploads need an upload API. The twin doesn't start one. Set uploadApiBaseUrl to an upload API you have access to, or use payment: { storage: 'user' } (User-paid storage).
  • Nonces lag on the fork. ethers' pending-nonce lookup is one block behind, so back-to-back sends from one wallet can fail with "nonce too low". Wrap ethers wallets in NonceManager.
  • The fork depends on your RPC. State is fetched lazily from it and cached on disk; a rate-limited RPC can slow the first run.
  • A UserOp that fails with no reason has usually run out of gas. From the contracts repo, npx hardhat seed:explain-userop --network localhost --tx <hash> decodes the revert, or replays the call and says how much gas it needed.
  • SDK contributors can run bun run twin:e2e [--sdk <path>] against a running twin. It drives the SDK's publish code from a local checkout (../seed-protocol-sdk by default) and imports its internals by path, so it follows the SDK source, not a published version, and needs a checkout from the 0.8.0 line. It also covers a publish with a cycle through a list relation.

Not in the twin

  • thirdweb's sponsored gas, both the paymaster and the EIP-7702 relayer. These can only be tested on OP Sepolia itself. A local paymaster is planned, not built.
  • A block explorer. An earlier plan for a Blockscout-based sandbox was dropped in favor of the twin.

On this page