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
| Layer | OP Sepolia | Twin |
|---|---|---|
| Chain | OP Sepolia, chain ID 11155420 | A 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, EntryPoint | The predeploys, factory 0x76F4…6d, EntryPoint v0.6 | The same contracts at the same addresses, with OP Sepolia's state at the fork block |
| Seed contracts | Deployed and routed | Deployed again and routed with the real rollout commands, at different addresses |
| Base schemas | Registered | Inherited from the fork, and checked |
| UserOps | thirdweb's bundler and paymaster | A local alto bundler on http://127.0.0.1:4337. No paymaster |
| EAS reads | easscan GraphQL | The official EAS indexer in Docker, GraphQL at http://localhost:4000/graphql |
| Login | thirdweb in-app wallets | Unchanged: still thirdweb's hosted login, so you need the internet |
| File uploads | An upload API | Not 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-protocolcontracts repo, installed withbun install, on Node 24 and the Bun version pinned in itspackage.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:upIt runs in the foreground, and Ctrl-C stops everything, including the indexer's containers and their data. In order, it:
- starts the forking node;
- deploys the Seed contracts and routes the factory to them, then runs
seed:ensure-schemasandseed:verify-live; - funds five test accounts (
alicetoerin) 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; - starts the bundler;
- 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;
- 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);
- writes
.twin/twin.jsonand 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.
| Option | Effect |
|---|---|
--fork-block <n> or --fork-block latest | Fork a different block. latest needs OPTIMISM_SEPOLIA_RPC_URL |
--light-index | Seed the indexer with schemas and names only, not attestations |
--no-indexer | Skip the indexer, and Docker |
twin.json
Everything your app needs is in .twin/twin.json in the contracts repo:
| Field | Value |
|---|---|
chainId | 31337 |
rpcUrl | http://127.0.0.1:8545 |
bundlerUrl | http://127.0.0.1:4337 |
easGraphqlUrl | http://localhost:4000/graphql, or null with --no-indexer |
forkedFrom | { chain, chainId, block } |
entryPoint | EntryPoint v0.6 |
contracts | eas, schemaRegistry, managedAccountFactory, seedProtocolExtension, seedProtocolExecutor, seedExecutorRouterExtension |
baseSchemas | the base schema UIDs, by key |
accounts | the test accounts, with name, address and privateKey |
aliceManagedAccount | the 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 10The 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:
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.
useModularExecutorandautoDeployManagedAccountdeploy a user's ManagedAccount on their first publish. SeeautoDeployManagedAccountfor what happens without it.thirdweb.bundlerUrlsends 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
seedTwinConfigpasses them all. filesDirkeeps 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:upresets the chain to the fork block. Accounts, attestations and nonces from the last session are gone. Clear what your app cached: on Node, thefilesDiryou 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
uploadApiBaseUrlto an upload API you have access to, or usepayment: { 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-sdkby 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.
Related
- Deploying to a new chain: the rollout commands the twin runs, and
rehearse:localfor rehearsing them - Deployments: the real OP Sepolia addresses
- EAS indexing
- Bundler and paymaster
- Using Seed in Node and Electron
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.
Arweave and gateways
How Seed Protocol stores bytes on Arweave and reads them back through gateways: the SDK's read path and fallbacks, AR.IO, how published Html links are written and re-hosted, transaction IDs versus content hashes, the tags Seed writes, and how to verify what a gateway returns.