ERC-4337 smart account system with session key support and an EVM event indexer, sharing a single SvelteKit frontend.
Built with Foundry (Solidity), Go (net/http), and SvelteKit 2 (Svelte 5, viem, permissionless.js).
┌──────────────┐ UserOps ┌─────────────┐ handleOps ┌────────────┐
│ Frontend │ ───────────────► │ Bundler │ ────────────────► │ EntryPoint │
│ (SvelteKit) │ │ (Pimlico) │ │ (v0.7) │
└──────┬───────┘ └─────────────┘ └─────┬──────┘
│ │
│ REST / WebSocket validateUserOp + execute
│ │
┌──────▼───────┐ ┌───────▼──────┐
│ Indexer │ eth_getLogs / eth_subscribe │ SmartAccount │
│ (Go) │ ◄────────────────────────────────────────────── │ (Solidity) │
└──────────────┘ └──────────────┘
Part 1 — Smart Contracts: ERC-4337 compliant SmartAccount with ECDSA owner validation and session key support, deployed via CREATE2 factory. Demo contracts (Counter, FaucetToken) for interaction.
Part 2 — EVM Indexer: Go backend that indexes UserOperationEvent from EntryPoint v0.7, with PostgreSQL persistence, REST API, and WebSocket live feed.
Shared Frontend: SvelteKit app for wallet connection, smart account deployment, owner and session key interactions, and indexer dashboard.
Get from clone to all services running against Sepolia in under 10 minutes. First install the tools in Prerequisites (Foundry, Node + pnpm, Go, Docker or a local Postgres, and jq).
git submodule update --init --recursive # forge-std, OZ, account-abstraction, solady
cd frontend && pnpm install && cd ..Skip this step only if the submodules are already populated and frontend/node_modules/ exists.
cp .env.example .envFill in:
SEPOLIA_RPC_URL— Alchemy/Infura/QuickNode Sepolia HTTPS endpointETHERSCAN_API_KEY— only required if you plan to redeploy withmake forge-deployDEPLOYER_PRIVATE_KEY— only required if you plan to redeployDATABASE_URL— leave as-is if using the bundledmake db-upRPC_URL— same Sepolia endpoint asSEPOLIA_RPC_URLINDEXER_START_BLOCK— any recent Sepolia block before the first deployedUserOperationEventyou want indexed
cp frontend/.env.example frontend/.envFill in:
PUBLIC_PIMLICO_API_KEY— create one at pimlico.io; the free tier covers the demoPUBLIC_INDEXER_URL— leave ashttp://localhost:3001PUBLIC_FACTORY_ADDRESS,PUBLIC_COUNTER_ADDRESS,PUBLIC_FAUCET_TOKEN_ADDRESS— optional. The frontend falls back to the committed Sepolia addresses infrontend/src/lib/contracts/addresses.ts, so you only need to set these to override (e.g. after your own redeploy). After a redeploy,make export-addressesregeneratesaddresses.tsfromcontracts/deployments/11155111.json.
make db-up # Postgres via docker compose
make forge-build # Compile contracts
make export-abis # Bridge ABIs into frontend/src/lib/contracts/
make dev # Starts indexer + frontend (contracts already compiled)- Frontend: http://localhost:5173
- Indexer API: http://localhost:3001
Running a fresh deployment is not required — the Sepolia addresses in the status table below are live and verified. If you do want to redeploy, make forge-deploy handles build, broadcast, and Etherscan verification in one step (requires ETHERSCAN_API_KEY and DEPLOYER_PRIVATE_KEY).
bastion/
├── contracts/ # Foundry — Solidity sources, tests, deploy scripts
│ ├── src/ # Contract source files
│ ├── test/ # Foundry tests (*.t.sol)
│ ├── script/ # Deployment scripts
│ └── lib/ # Git submodule deps (forge-std, OZ, account-abstraction, solady)
├── indexer/ # Go module — EVM event indexer
│ ├── cmd/indexer/ # Entry point (main.go)
│ └── internal/ # Internal packages (api/, indexer/, db/)
├── frontend/ # SvelteKit 2 — shared between both pillars
│ └── src/lib/ # Utilities, stores, contract ABIs
├── scripts/ # Build/tooling scripts (export-abis.sh)
└── Makefile # Orchestrates all three components
- Foundry (forge, cast, anvil)
- Node.js v20+ and pnpm v9+
- Go 1.25+
- Docker + Compose v2 — required for
make db-up; alternatively, use a host-level PostgreSQL 15+ and setDATABASE_URLyourself
Optional for tooling scripts:
jq— used bymake export-abisandmake export-addressesto parse Forge JSON. Install withbrew install jq(macOS) orapt install jq(Debian/Ubuntu). The scripts fail fast with an install hint if it is missing.
Per-component details. See Quick Start for the condensed zero-to-running flow.
cd contracts
forge build # Compile
forge test -vvv # Run testscd frontend
pnpm install
pnpm dev # Dev server on http://localhost:5173cd indexer
go build ./cmd/indexer
go run ./cmd/indexer # Starts on http://localhost:3001Required env vars:
DATABASE_URL— PostgreSQL DSNRPC_URL— chain JSON-RPC endpointINDEXER_START_BLOCK— required on first run (no cursor) to define historical backfill start block
Optional indexer env vars:
WS_RPC_URL— WebSocket RPC endpoint foreth_subscribenew-head triggers (falls back to poll-only when unset)ENTRYPOINT— override EntryPoint address (default: canonical v0.7)INDEXER_BATCH_SIZE— max block span pereth_getLogsbatch (default500)INDEXER_CONFIRMATIONS— confirmation lag before indexing (default3)INDEXER_REORG_WINDOW— rewind window from cursor each loop (default = confirmations)INDEXER_POLL_INTERVAL— polling interval (default4s)INDEXER_REQUEST_TIMEOUT— per-RPC request timeout (default15s)INDEXER_RPC_CONCURRENCY— max concurrent RPC calls for tx/block enrichment (default8)INDEXER_RPC_RESPONSE_MAX_BYTES— max RPC response size before adaptive range splitting (default8388608)INDEXER_RPC_MAX_RETRIES— total attempts per RPC call including the initial request (default5, max20)INDEXER_RPC_RETRY_BASE_DELAY— initial backoff delay between retries (default500ms)INDEXER_RPC_RETRY_MAX_DELAY— maximum backoff delay cap (default30s)INDEXER_ENABLE_TX_ENRICHMENT— toggle tx input decoding fortarget/calldataenrichment (defaulttrue)INDEXER_ALLOW_CURSOR_TRIM— allow destructive trim when cursor is ahead of safe head (defaultfalse)
make build # Build contracts + frontend + indexer
make test # Run all test suites| Contract | Description | Status |
|---|---|---|
SmartAccount |
ERC-4337 account — ECDSA owner validation, session keys, execute/executeBatch, proxy-compatible via Initializable |
Implemented |
SmartAccountFactory |
CREATE2 deployment of SmartAccount proxies; createAccount(owner, salt) and getAddress(owner, salt) |
Implemented |
Counter |
Demo target — per-account counters, increment() and getCount(address) |
Implemented |
FaucetToken |
ERC-20 (symbol BFT) with claim() faucet mint |
Implemented |
EntryPoint v0.7 (external, canonical): 0x0000000071727De22E5E9d8BAf0edAc6f37da032
All four project contracts are deployed and verified on Sepolia. Addresses are committed in contracts/deployments/11155111.json and mirrored to frontend/src/lib/contracts/addresses.ts, which the frontend imports as its default — no PUBLIC_*_ADDRESS env vars required unless you want to override. After a redeploy, run make export-addresses to regenerate addresses.ts from the deployments JSON.
| Contract | Address | Etherscan |
|---|---|---|
SmartAccountFactory |
0x903794183FB881FC78dCA8c9CEB63EC7F10BD5Fd |
View |
SmartAccount (implementation) |
0x436365cBED02eFBf3F7adb3Da35FbA8098A94a52 |
View |
Counter |
0x1bFe2EE14a1AFac835bB4C3Dc61d8f3520335e94 |
View |
FaucetToken (BFT) |
0x7EFb41d61f894e787405c5D7E114dB86542adafF |
View |
The
SmartAccountentry is the implementation contract behind every proxy the factory deploys; per-user accounts are deterministic CREATE2 addresses derived from(owner, salt)and are counterfactual until first UserOp.
End-to-end flow an evaluator can reproduce in ~5 minutes after completing Quick Start. Every UserOp below is sponsored by the Pimlico paymaster — no Sepolia ETH required in the connecting wallet.
- Browser wallet (MetaMask, Rabby, …) with any Sepolia-capable account. No funding required.
- A Pimlico API key with paymaster sponsorship enabled (free tier is sufficient).
- Indexer, frontend, and Postgres running locally (Quick Start covers this).
-
Open
http://localhost:5173/and connect your wallet. The app issueswallet_switchEthereumChainto Sepolia automatically — approve if prompted. -
Observe the counterfactual SmartAccount address. Derived via
SmartAccountFactory.getAddress(owner, 0). Aneth_getCodecall returns0x, confirming the account has not been deployed yet — this is an important property of ERC-4337: the address is usable before deployment. -
Click Deploy Account. The frontend submits a sponsored no-op UserOp with
initCodeset to the factory +createAccountcalldata. The EntryPoint deploys the proxy in the same transaction that runsvalidateUserOp. Post-deploy,getCodereturns non-empty bytecode. The Jiffyscan link in the success toast shows the UserOp lifecycle (validation → deploy → execute). -
Open the Counter card, click Increment. Encoded as
SmartAccount.execute(counter, 0, abi.encodeCall(Counter.increment, ())), signed by the owner, sponsored by Pimlico.Counter.getCount(smartAccount)reflects the new value. TheUserOperationEventemitted by the EntryPoint appears in the indexer feed (step 11) within one poll cycle. -
Open the Faucet card, click Claim Tokens. Same pattern, targeting
FaucetToken.claim(). The card refreshes the connecting wallet'sBFTbalance after the UserOp lands. Useful as a second sponsored owner-flow data point before moving to session keys. -
Open Session Keys, click Generate. The browser creates a fresh secp256k1 keypair entirely in memory — the private key never leaves the page and is not persisted. Pick:
- Target:
CounterorFaucetToken - Selector: one of
increment(),claim(), ortransfer(address,uint256) - Valid window:
validAfter/validUntil(UNIX seconds)
- Target:
-
Click Register. The frontend submits an owner-signed UserOp whose inner
executecall targets the SmartAccount itself and invokesregisterSessionKey(publicKey, target, selector, validAfter, validUntil). EmitsSessionKeyAdded. Copy the session-key private key now — it only exists in browser memory; a page reload loses it forever. -
Open
http://localhost:5173/sessionin a new tab. Paste the SmartAccount address and the session-key private key, click Load. The page readssessionKeys(publicKey)on-chain to verify scope (target, selector, window) and cross-checks that the caller's public key matches. -
In the Permissions card, click Execute. The session-key-flow helper signs a UserOp locally with the session-key private key (never prompting the wallet), submits it through Pimlico's bundler, and the paymaster sponsors gas. The SmartAccount's
validateUserOpwalks into_validateSessionKey, which checks signer ∈ active session keys, inner call target matchestarget, 4-byte selector matchesselector, andblock.timestamp ∈ [validAfter, validUntil]. Jiffyscan + Etherscan links appear on success. -
Return to the first tab, click Revoke on the session key. Owner-signed UserOp calling
revokeSessionKey(publicKey). EmitsSessionKeyRevoked. Re-running step 9 now fails atvalidateUserOp— the EntryPoint surfacesSIG_VALIDATION_FAILEDand the bundler rejects the op. -
Open
http://localhost:5173/indexer. The WebSocket feed streams everyUserOperationEventfrom the demo in order. The stats panel shows total ops, success rate, sponsored-% (should be 100% — paymaster covered all ops), and unique senders. Etherscan links on each row let the evaluator cross-check the on-chain transaction. -
(Optional) Observe reorg resilience. Kill the indexer mid-demo (
Ctrl-Conmake indexer-dev/make dev) and restart it. On boot it resumes from the persisted cursor and rewindsINDEXER_REORG_WINDOWblocks to re-index anything that might have reorged in that window. The feed catches back up without double-counting — atomicReplaceOperationsAndSetCursordeletes the rewound range and re-inserts in one transaction.
One bullet per decision, in the order an evaluator is likely to ask about them.
- Pimlico as bundler and paymaster. Single endpoint for ERC-4337 v0.7 bundling and Sepolia gas sponsorship;
permissionless.jshas first-class client support. Avoids standing up our own bundler just for the demo. - Sepolia as the target chain. Only stable public testnet with the canonical v0.7 EntryPoint deployed, active paymaster support, reliable Alchemy/Infura endpoints, and free Etherscan verification.
- CREATE2 factory for SmartAccount deployment. Counterfactual addresses let the UI display and interact with an account before it exists on-chain.
initCodeon the first UserOp deploys the proxy atomically via the EntryPoint — deployment and the first action share a single user signature. - Session-key scope enforced at the account, not the key. Scope checks (target address, 4-byte selector,
validAfter/validUntil) live inside_validateSessionKeyonSmartAccount. A session key holds no authority outside the account that registered it — compromising one doesn't grant access to any other account that happens to authorize the same public key. - Session keys are single-call only.
validateUserOprequires the outer call to beexecute(notexecuteBatch). Scoping a batch would mean iterating every inner call insidevalidateUserOp, inflating verification gas and making the scope check non-atomic under partial-revert semantics. Single-call keeps the security model simple. - In-memory session-key list in the frontend. Deliberate. Making the mapping enumerable on-chain would add storage and gas on every register/revoke. The
SessionKeyAdded/SessionKeyRevokedevents are the auditable source of truth; a production UI would index them (which is exactly what Part 2 demonstrates at the EntryPoint level). - Go +
net/httpstdlib for the indexer. No web framework, no ORM. The scope is small enough that the stdlib's ergonomics are fine, and it keeps the binary + dependency surface minimal. - PostgreSQL, not SQLite. We need atomic reorg handling in a single transaction (
ReplaceOperationsAndSetCursordeletes all rows abovefromBlock, inserts the re-scanned range, and updates the cursor as one unit). PG also gives real concurrency so the API and the indexer loop can share the DB without locking contention. - Polling + WebSocket hybrid for event ingestion.
eth_getLogspolling is the authoritative range scanner and the only path that handles reorgs.eth_subscribeoverWS_RPC_URLis a trigger — anewHeadsevent causes the loop to wake immediately instead of sleeping forINDEXER_POLL_INTERVAL. If the WS drops, polling continues unchanged. WS is optimization, not a dependency. - REST for history, WebSocket for live. REST is cacheable, paginated, and easy to test; the frontend hydrates with REST then overlays the WS stream for deltas. Falls back to REST polling if WS is unavailable.
- Reorg strategy: confirmation lag + rewind window. Only index blocks at
safeHead = latest − INDEXER_CONFIRMATIONS. Each loop rewindsINDEXER_REORG_WINDOWblocks from the cursor before the nextgetLogscall, so any range that reorged within the window is re-indexed. Replacement is atomic (delete-above + insert + cursor update in one transaction), so readers never observe a partial state.
- Session keys are single-call only. The account validates that the outer call is
execute, notexecuteBatch. Lifting this would require scoping every inner call individually duringvalidateUserOp. - Session-key list is not enumerable on-chain. Registered keys are stored in a non-iterable mapping; the UI tracks the set in memory and relies on
SessionKeyAdded/SessionKeyRevokedevents as the source of truth. A production UI would index these. - Single EntryPoint on a single chain. The indexer is configured for one
ENTRYPOINTon oneRPC_URL. Scaling to multiple contracts or chains would need a worker-per-chain model sharing the Postgres instance — orthogonal to the rest of the design, but not implemented. - No frontend test suite. Deliberate scope choice — frontend behavior is covered by the manual Demo Walkthrough. Contracts have Foundry tests, indexer has Go tests (
make test).
make test # forge test -vvv + go test ./...
make forge-test # contracts only
make indexer-test # indexer onlyThe frontend has no automated test suite — see Limitations & Trade-offs. Verification there is manual via the Demo Walkthrough.