Minimal
Template:
templates/minimal
minimal is the reference starting point. It has one chain (Hardhat in dev), one grammar
action, one state transition, one table, one API endpoint and a vanilla-JS page — and
nothing else. There is no batcher, no second chain, no ZK circuit and no framework in the
frontend, so every line between "a wallet sent a transaction" and "a row exists in
Postgres" is visible in a file you can read in a minute.
Read this template first. Every other template in this repository is minimal plus
something: another chain, a batcher, scheduled inputs, a game loop. If you understand the
six hops below, the rest are variations.
What this template shows
The complete path from an on-chain event to a database row, with nothing else in the way. Six hops, each in exactly one file:
- Submit. A wallet calls
effectstreamSubmitGameInput(bytes)onMyEffectstreamL2(packages/contracts-evm/src/contracts/MyEffectstreamL2.sol). Thebytesare the hex-encoded JSON of a command tuple —["my_action_name", "hello"]. The contract emitsEffectstreamGameInteraction(address userAddress, bytes data, uint256 value)and does nothing else. The browser side of this issendTransactionEffectstreamL2inpackages/frontend/index.js. - Sync.
packages/node/config.dev.tsregisters anEVM_RPC_PARALLELsync protocol pointed at the Hardhat RPC, so the node polls that chain for blocks. - Decode and validate. The same file attaches a
PrimitiveTypeEVMEffectstreamL2primitive, pinned to the deployed contract address. The primitive listens forEffectstreamGameInteraction, parsesdataas a command tuple and checks it againstpackages/node/grammar.ts. Anything that is notmy_action_namewith a stringinputof at most 256 characters never reaches your code. - Transition.
packages/node/state-machine.tsreceives the parsed input together with the signer address and the block height, and queues a singleINSERT. - Persist. The queued query is
insertInput, generated by pgTyped frompackages/database/sql/queries.sql, writing into theinputs_logtable created bypackages/database/migrations/000-init.sql. - Read back.
packages/node/api.tsservesGET /api/inputs, which the page fetches after each submit — and on the Refresh button — to show the row that just appeared.
Two things about this flow are worth understanding before you copy it.
Inputs are self-sequenced. There is no batcher package here, so the user signs and pays for a real transaction on the target chain. That is the simplest possible submission path and the one to start from; adding a batcher later changes the frontend and adds a service, but hops 3–6 stay exactly the same.
The clock is not the EVM chain. config.dev.ts declares two sync protocols: an
NTP_MAIN protocol named mainNtp with a 1000 ms block time, and the EVM RPC protocol
added in parallel. The NTP protocol is the main one — it defines the Effectstream block
cadence — and the EVM chain is folded into it. This is why the blockHeight your state
transition receives is an Effectstream block height, not an Ethereum block number, and why
config.mainnet.ts goes to some trouble to recover the original NTP start time from the
database on restart (see Configuration). Templates with several chains
work the same way: one main protocol, everything else parallel.
Effectstream features used
| Feature | Where | Used for |
|---|---|---|
Grammar (@effectstream/concise) | packages/node/grammar.ts | One action, my_action_name, with a length-capped string argument |
State machine (@effectstream/sm) | packages/node/state-machine.ts | Stm.addStateTransition handling that single action |
Coroutine DB writes (@effectstream/coroutine) | packages/node/state-machine.ts | World.resolve(insertInput, …) queues the write instead of executing it |
PrimitiveTypeEVMEffectstreamL2 | packages/node/config.dev.ts, packages/node/config.mainnet.ts | Turns EffectstreamGameInteraction events into validated grammar commands |
ConfigSyncProtocolType.NTP_MAIN | packages/node/config.dev.ts | Wall-clock main sync protocol that sets the block cadence |
ConfigSyncProtocolType.EVM_RPC_PARALLEL | packages/node/config.dev.ts | Polls the EVM chain alongside the main protocol |
ConfigBuilder (@effectstream/config) | packages/node/config.dev.ts, packages/node/config.mainnet.ts | Networks → deployments → sync protocols → primitives, in that order |
Migrations (@effectstream/runtime) | packages/database/migration-order.ts | Ships 000-init.sql as the app's schema |
pgTyped queries (@effectstream/db) | packages/database/sql/queries.sql | Type-safe insertInput / getAllInputs |
Custom API router (@effectstream/runtime) | packages/node/api.ts | Adds GET /api/inputs to the built-in Fastify server |
EffectstreamL2Contract (@effectstream/evm-contracts) | packages/contracts-evm/src/contracts/MyEffectstreamL2.sol | The L2 "mailbox" contract, extended with no changes |
Hardhat + Ignition (@effectstream/evm-hardhat) | packages/contracts-evm/ | Compile, deploy, and generate TypeScript address bindings |
Wallet connect + submit (@effectstream/wallets) | packages/frontend/index.js | walletLogin with WalletMode.EvmInjected, then sendTransaction |
Orchestrator (@effectstream/orchestrator) | start.dev.ts | Brings up PGlite, Hardhat, contracts, sync node and frontend in dependency order |
Quick start
Prerequisites
- Bun
- Foundry —
forgemust be on yourPATH. The orchestrator checks for it before starting and fails with an install hint if it is missing: the TypeScript contract bindings are generated from the Forge artifacts, so a Hardhat-only install is not enough.
bun install
bun run dev
If you are working inside the Effectstream monorepo and want to run against the local
@effectstream/* sources rather than the published ones, run ./link.sh instead of
bun install.
Then open http://localhost:10599, connect an EVM wallet, type a message and press Send TX.
You need a funded account. This template has no batcher, so submitting an input is a real transaction and the connected account must hold gas on the local Hardhat chain. Hardhat pre-funds a set of deterministic dev accounts. In MetaMask: add the network (RPC
http://localhost:8545, chain ID31337), then Import account with Hardhat's default account #0:
- Address:
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266- Private key:
0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80These keys are published in Hardhat's own documentation. Local dev only — never reuse them or send real funds to them on any live network.
Local services
| Service | URL |
|---|---|
| Frontend | http://localhost:10599 |
| Sync node API | http://localhost:9999 |
| API reference (OpenAPI) | http://localhost:9999/documentation |
| Node health | http://localhost:9999/health |
| Hardhat JSON-RPC (chain ID 31337) | http://localhost:8545 |
| PGlite (Postgres wire protocol) | postgres://postgres:postgres@localhost:5432/postgres |
| Orchestrator API | http://localhost:4747 |
Ports come from start.dev.ts (frontend 10599), the orchestrator's launchEvm/
launchPglite helpers (8545, 5432, 4747) and the EFFECTSTREAM_API_PORT default
(9999). All are overridable through the usual Effectstream environment variables.
Project structure
minimal/
├── start.dev.ts # Orchestrator config; `bun run dev` finds it via package.json "effectstream.default"
├── link.sh # Symlink local monorepo @effectstream/* packages into node_modules
└── packages/
├── node/ # @minimal/node — the sync node
│ ├── grammar.ts # the one action, my_action_name
│ ├── state-machine.ts # the one state transition
│ ├── api.ts # GET /api/inputs
│ ├── config.dev.ts # ConfigBuilder for local Hardhat
│ ├── config.mainnet.ts # ConfigBuilder for Arbitrum, driven by env vars
│ ├── main.dev.ts # entrypoint: config.dev.ts + runtime start()
│ └── main.mainnet.ts # entrypoint: config.mainnet.ts + runtime start()
├── database/ # @minimal/database — schema and typed queries
│ ├── migrations/000-init.sql # creates inputs_log
│ ├── migration-order.ts # exports migrationTable consumed by start()
│ ├── sql/queries.sql # pgTyped source queries
│ ├── sql/queries.queries.ts # generated; do not edit by hand
│ └── mod.ts # re-exports queries + migrationTable
├── contracts-evm/ # @minimal/contracts-evm — Solidity, Hardhat, Ignition
│ ├── src/contracts/ # MyEffectstreamL2.sol — the L2 contract
│ ├── ignition/modules/ # effectstreamL2.ts — Ignition deployment module
│ ├── deploy.ts # deploys the module to the evmMainHttp network
│ ├── hardhat.config.ts # built by @effectstream/evm-hardhat
│ └── foundry.toml # Forge build config (artifacts feed the mod builder)
├── frontend/ # @minimal/frontend — vanilla JS, esbuild, Fastify static
│ ├── index.html # markup + the three inline handlers
│ ├── index.js # wallet login and transaction submission
│ ├── esbuild.js # bundle to dist/min.js
│ └── server.ts # serves dist/ on port 10599
└── tests/ # @minimal/tests — end-to-end suite
├── run-tests.ts # orchestrates infra, then runs the phases
├── start.test.ts # orchestrator config used by the tests
├── infra/ # chain reachable, contract deployed
└── stm/ # submit an input, assert DB row and API response
packages/contracts-evm/mod.ts and packages/contracts-evm/build/ are generated during
bun run dev and are gitignored, which is why contractAddressesEvmMain() resolves only
after the contracts have been deployed once.
How it works
Grammar
The grammar is the entire vocabulary of the app. Everything a user can do is here:
// packages/node/grammar.ts
import { Type } from "@sinclair/typebox";
import type { GrammarDefinition } from "@effectstream/concise";
export const grammar = {
my_action_name: [
["input", Type.String({ maxLength: 256 })],
],
} as const satisfies GrammarDefinition;
Each key is an action name; each entry is an ordered list of [argument name, TypeBox schema] pairs. On the wire an action is a JSON array whose first element is the action
name — ["my_action_name", "hello"] — and the grammar is what turns that array into
{ input: "hello" } with the types you declared. It is passed to the runtime in
main.dev.ts, so the primitive can reject malformed input before your state machine
runs.
State machine
// packages/node/state-machine.ts
const stm = new Stm<typeof grammar, {}>(grammar);
stm.addStateTransition("my_action_name", function* (data) {
const { parsedInput, signerAddress: signer, blockHeight } = data;
yield* World.resolve(insertInput, {
signer,
payload: parsedInput.input,
block_height: blockHeight,
});
});
export const gameStateTransitions: StartConfigGameStateTransitions = function* (
_blockHeight: number,
input: BaseStfInput,
): SyncStateUpdateStream<void> {
yield* stm.processInput(input);
};
Three things to take away:
- The handler is a generator, not an
asyncfunction. It does not execute the insert;World.resolveyields the query and its arguments, and the runtime collects every yielded query for the block and applies them in a single transaction. That is what makes a block's worth of state changes atomic and replayable. stm.addStateTransition("my_action_name", …)is checked againsttypeof grammar, soparsedInput.inputis astringwithout a cast, and a typo in the action name is a compile error.gameStateTransitionsis the single function the runtime calls per input. Here it just delegates to theStmrouter; larger templates branch on block height or version first.
signerAddress is the address recovered from the submitting transaction, lower-cased —
packages/tests/stm/submit-input.test.ts asserts on wallet0.address.toLowerCase().
Contracts
MyEffectstreamL2 adds nothing to the base contract but a constructor:
// packages/contracts-evm/src/contracts/MyEffectstreamL2.sol
import {EffectstreamL2Contract} from "@effectstream/evm-contracts/src/contracts/EffectstreamL2Contract.sol";
contract MyEffectstreamL2 is EffectstreamL2Contract {
constructor(address _owner, uint256 _fee) EffectstreamL2Contract(_owner, _fee) {}
}
The base contract's whole job is effectstreamSubmitGameInput(bytes calldata data), which
requires msg.value >= fee and then emits
EffectstreamGameInteraction(msg.sender, data, msg.value). There is no application state
on chain — the chain is a durable, ordered, signed log, and the state lives in Postgres.
Deployment goes through Hardhat Ignition, with the owner and fee supplied as module parameters:
// packages/contracts-evm/ignition/modules/effectstreamL2.ts
export default buildModule("EffectstreamL2Module", (m) => {
const owner = m.getParameter("owner");
const fee = m.getParameter("fee");
const contract = m.contract("MyEffectstreamL2", [owner, fee]);
return { contract };
});
packages/contracts-evm/deploy.ts supplies fee: 0 for local development, so submitting
an input costs only gas. After deployment the orchestrator's generate-evm-mod step writes
packages/contracts-evm/mod.ts and build/contractAddressesEvmMain.ts, giving the rest of
the workspace a typed contractAddressesEvmMain().chain31337["EffectstreamL2Module#MyEffectstreamL2"]
lookup instead of a pasted address.
Sync configuration
config.dev.ts is a ConfigBuilder chain, and the order of the stages is the dependency
order: networks, then deployments on those networks, then sync protocols over those
networks, then primitives over those sync protocols.
// packages/node/config.dev.ts
export const config = new ConfigBuilder()
.setNamespace((builder) => builder.setSecurityNamespace("minimal"))
.buildNetworks((builder) =>
builder
.addNetwork({
name: "ntp",
type: ConfigNetworkType.NTP,
startTime: new Date().getTime(),
blockTimeMS: 1000,
})
.addViemNetwork({ ...hardhat, name: "evmMain" })
)
addViemNetwork({ ...hardhat, name: "evmMain" }) is the entire local-chain definition —
viem's hardhat chain object already carries the chain ID and RPC URL.
The two sync protocols:
.buildSyncProtocols((builder) =>
builder
.addMain(
(networks) => networks.ntp,
(_network, _deployments) => ({
name: "mainNtp",
type: ConfigSyncProtocolType.NTP_MAIN,
chainUri: "",
startBlockHeight: 1,
pollingInterval: 1000,
}),
)
.addParallel(
(networks) => networks.evmMain,
(network, _deployments) => ({
name: "mainEvmRPC",
type: ConfigSyncProtocolType.EVM_RPC_PARALLEL,
chainUri: network.rpcUrls.default.http[0],
startBlockHeight: 1,
pollingInterval: 500,
confirmationDepth: 1,
}),
)
)
And the primitive that binds the L2 contract to the grammar:
.buildPrimitives((builder) =>
builder.addPrimitive(
(syncProtocols) => syncProtocols.mainEvmRPC,
(_network, _deployments, _syncProtocol) => ({
name: "EffectstreamL2",
type: PrimitiveTypeEVMEffectstreamL2,
startBlockHeight: 0,
contractAddress:
contractAddressesEvmMain().chain31337[
"EffectstreamL2Module#MyEffectstreamL2"
],
}),
)
)
main.dev.ts wires it all together and is the same file, modulo the config import, as
main.mainnet.ts:
// packages/node/main.dev.ts
main(function* () {
yield* init();
yield* withEffectstreamStaticConfig(config, function* () {
yield* start({
appName: "minimal",
appVersion: "1.0.0",
syncInfo: toSyncProtocolWithNetwork(config),
gameStateTransitions,
migrations: migrationTable,
apiRouter,
grammar,
});
});
yield* suspend();
});
API
The runtime already runs a Fastify server (with /health and an OpenAPI page at
/documentation); apiRouter is where you hang your own routes on it:
// packages/node/api.ts
export const apiRouter: StartConfigApiRouter = async function (
server: FastifyInstance,
dbConn: Pool,
): Promise<void> {
server.get("/api/inputs", async (_request, reply) => {
const result = await runPreparedQuery(
getAllInputs.run(undefined, dbConn),
"/api/inputs",
);
reply.send({ inputs: result });
});
};
| Method | Path | Response |
|---|---|---|
GET | /api/inputs | { inputs: [{ id, signer, payload, block_height }, …] } — newest 100 |
GET | /health | { status: "ok" } (built in) |
GET | /documentation | OpenAPI browser (built in) |
Note the split: reads go through the API using runPreparedQuery directly against the
connection pool, while writes only ever happen inside the state machine via
World.resolve. Nothing outside a state transition writes application state.
Database
One migration, one table:
-- packages/database/migrations/000-init.sql
CREATE TABLE inputs_log (
id SERIAL PRIMARY KEY,
signer TEXT NOT NULL,
payload TEXT NOT NULL,
block_height INTEGER NOT NULL
);
Migrations are shipped as data, not discovered from disk at runtime — migration-order.ts
imports the SQL as text and exports the ordered list that start() receives as
migrations:
// packages/database/migration-order.ts
import initSql from "./migrations/000-init.sql" with { type: "text" };
export const migrationTable: DBMigrations[] = [
{ name: "000-init.sql", sql: initSql },
];
Queries are written in SQL and compiled to typed functions by pgTyped:
-- packages/database/sql/queries.sql
/* @name insertInput */
INSERT INTO inputs_log (signer, payload, block_height)
VALUES (:signer!, :payload!, :block_height!);
/* @name getAllInputs */
SELECT * FROM inputs_log
ORDER BY id DESC
LIMIT 100;
bun run build:pgtypes regenerates sql/queries.queries.ts whenever you change the SQL or
the schema. It is self-contained: it starts its own PGlite instance, applies the system
migrations and migrationTable, and runs pgTyped against that — no database of your own
required. The generated IGetAllInputsResult is what gives /api/inputs its shape for
free.
Frontend
packages/frontend/index.js is the whole client. Its EffectstreamConfig must agree with
the node's config in three places — miss any one and the input is submitted but never
admitted:
// packages/frontend/index.js
export const effectstreamConfig = new EffectstreamConfig(
"minimal", // security namespace: setSecurityNamespace("minimal")
"mainEvmRPC", // sync protocol name from config.dev.ts
"0x5FbDB2315678afecb367f032d93F642f64180aa3", // deployed MyEffectstreamL2 address
hardhat,
undefined, // default EffectstreamL2 ABI
undefined, // no batcher URL
false, // do not prefer batched mode
);
The address here is hardcoded to the deterministic address of the first contract deployed
on a fresh Hardhat chain. The node reads the real address from the generated bindings, so
if you add contracts ahead of MyEffectstreamL2 in deploy.ts the node keeps working and
the frontend silently stops — update this constant, and replace it entirely for any other
network. Submitting is then two calls:
await walletLogin({ mode: WalletMode.EvmInjected, chain: effectstreamConfig.effectstreamL2Chain });
await sendTransaction(
wallet,
["my_action_name", input ?? "no-text"],
effectstreamConfig,
"wait-effectstream-processed",
);
"wait-effectstream-processed" makes the promise resolve only once the sync node has
actually processed the input, rather than when the transaction receipt lands — which is why
the page can refresh /api/inputs immediately afterwards and see the new row.
The build is deliberately unusual in one respect: packages/frontend/esbuild.js stubs out
@lucid-evolution/*, @midnight-ntwrk/* and @effectstream/midnight-contracts to empty
modules. @effectstream/wallets declares Cardano and Midnight helpers as optional
dependencies, and this EVM-only template never reaches those branches — the comments in
that file explain why marking them external is not enough.
Configuration
The template ships two configurations that differ only in their inputs. bun run dev runs
main.dev.ts → config.dev.ts; bun run start:mainnet runs main.mainnet.ts →
config.mainnet.ts.
| Aspect | Dev | Mainnet |
|---|---|---|
| Entrypoint | packages/node/main.dev.ts | packages/node/main.mainnet.ts |
| Config | packages/node/config.dev.ts | packages/node/config.mainnet.ts |
| Chain | viem hardhat (chain ID 31337, http://localhost:8545) | viem arbitrum with rpcUrls overridden by EVM_RPC_URL |
| Contract address | Generated contractAddressesEvmMain() | EFFECTSTREAM_L2_ADDRESS |
| EVM start block | 1 (protocol), 0 (primitive) | EVM_START_BLOCK for both |
| Polling / confirmations | 500 ms, depth 1 | 2000 ms, depth 10, stepSize: 100 |
| Command | bun run dev | bun run start:mainnet |
| Local services started | PGlite, Hardhat, contracts, sync node, frontend | None — the sync node only |
Mainnet environment variables
| Variable | Required | Description |
|---|---|---|
EVM_RPC_URL | yes | RPC endpoint for the target EVM chain |
EVM_START_BLOCK | yes | Block to begin syncing from — use the contract's deployment block |
EFFECTSTREAM_L2_ADDRESS | yes | Address of the deployed MyEffectstreamL2 |
NTP_START_TIME | no | Explicit NTP start timestamp in ms |
All three required variables are validated at module load and throw with a named error if missing.
NTP_START_TIME deserves a note, because it is the one piece of state a config file cannot
recompute. The NTP main protocol converts wall-clock time into block heights starting from
startTime; if a restart picked a new start time, every block height would shift and the
sync would no longer line up with what is already in the database. So config.mainnet.ts
recovers it:
// packages/node/config.mainnet.ts
const result = await dbConn.query(`
SELECT * FROM effectstream.sync_protocol_pagination
WHERE protocol_name = 'mainNtp'
ORDER BY page_number ASC LIMIT 1
`);
if (result?.rows.length) {
launchStartTime = result.rows[0].page.root - (result.rows[0].page_number * 1000);
}
Only when the environment variable is absent and the database has no history does it fall
back to Date.now() — a genuinely fresh deployment. config.dev.ts skips all of this and
uses new Date().getTime(), since a dev database is disposable.
Pointing this at a real chain
- Deploy
MyEffectstreamL2to your chain and note the address and deployment block. - Swap
arbitruminconfig.mainnet.tsfor the viem chain you are targeting. - Set
EVM_RPC_URL,EVM_START_BLOCKandEFFECTSTREAM_L2_ADDRESS, pointDB_HOST/DB_USER/DB_PW/DB_NAMEat a real Postgres, and runbun run start:mainnet. - Update
effectstreamConfiginpackages/frontend/index.js— the namespace and sync protocol name stay"minimal"and"mainEvmRPC", but the address and chain change — and change the hardcodedhttp://localhost:9999/api/inputsinpackages/frontend/index.htmlto your API host.
Testing
bun run test
packages/tests/run-tests.ts starts its own orchestrator using
packages/tests/start.test.ts (PGlite, Hardhat, contracts and the sync node — no
frontend), waits for the orchestrator on port 4747 and the node's /health, runs two
phases, prints a pass/fail summary and shuts everything down.
Phase A — infrastructure
infra/chain-ready.test.ts— aneth_chainIdcall tohttp://localhost:8545returns31337.infra/deploy.test.ts—contractAddressesEvmMain()yields a syntactically valid address forEffectstreamL2Module#MyEffectstreamL2.
Phase B — state machine, database, API
stm/submit-input.test.ts— submits["my_action_name", "hello-from-test"]by callingeffectstreamSubmitGameInputthrough viem with Hardhat account #0, then polls Postgres until aninputs_logrow exists with that signer and payload. It uses a hand-written one-function ABI rather than the generated bindings, which makes the on-chain call explicit.stm/api.test.ts—GET /api/inputsreturns that same payload.
Because there is no batcher, the test submits on chain directly with a Hardhat private key — exactly what the frontend does, minus the wallet. There is no frontend smoke test.
Where to go next
- Quick Start — the guided walkthrough,
which builds on
evm-midnight-v2. - Grammar and State Machine — the two concepts this template exists to demonstrate.
- Primitives and the EffectStream L2 Contract — how on-chain events become validated inputs.
- Database and API — migrations, pgTyped, and extending the built-in server.
- All templates — from here,
evm-midnight-v2adds a second chain, ZK contracts and a batcher;chess-v2adds lobbies, matchmaking and real game rules; andbatcher-validationsshows what changes once inputs stop being self-sequenced.