Skip to main content

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:

  1. Submit. A wallet calls effectstreamSubmitGameInput(bytes) on MyEffectstreamL2 (packages/contracts-evm/src/contracts/MyEffectstreamL2.sol). The bytes are the hex-encoded JSON of a command tuple["my_action_name", "hello"]. The contract emits EffectstreamGameInteraction(address userAddress, bytes data, uint256 value) and does nothing else. The browser side of this is sendTransactionEffectstreamL2 in packages/frontend/index.js.
  2. Sync. packages/node/config.dev.ts registers an EVM_RPC_PARALLEL sync protocol pointed at the Hardhat RPC, so the node polls that chain for blocks.
  3. Decode and validate. The same file attaches a PrimitiveTypeEVMEffectstreamL2 primitive, pinned to the deployed contract address. The primitive listens for EffectstreamGameInteraction, parses data as a command tuple and checks it against packages/node/grammar.ts. Anything that is not my_action_name with a string input of at most 256 characters never reaches your code.
  4. Transition. packages/node/state-machine.ts receives the parsed input together with the signer address and the block height, and queues a single INSERT.
  5. Persist. The queued query is insertInput, generated by pgTyped from packages/database/sql/queries.sql, writing into the inputs_log table created by packages/database/migrations/000-init.sql.
  6. Read back. packages/node/api.ts serves GET /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

FeatureWhereUsed for
Grammar (@effectstream/concise)packages/node/grammar.tsOne action, my_action_name, with a length-capped string argument
State machine (@effectstream/sm)packages/node/state-machine.tsStm.addStateTransition handling that single action
Coroutine DB writes (@effectstream/coroutine)packages/node/state-machine.tsWorld.resolve(insertInput, …) queues the write instead of executing it
PrimitiveTypeEVMEffectstreamL2packages/node/config.dev.ts, packages/node/config.mainnet.tsTurns EffectstreamGameInteraction events into validated grammar commands
ConfigSyncProtocolType.NTP_MAINpackages/node/config.dev.tsWall-clock main sync protocol that sets the block cadence
ConfigSyncProtocolType.EVM_RPC_PARALLELpackages/node/config.dev.tsPolls the EVM chain alongside the main protocol
ConfigBuilder (@effectstream/config)packages/node/config.dev.ts, packages/node/config.mainnet.tsNetworks → deployments → sync protocols → primitives, in that order
Migrations (@effectstream/runtime)packages/database/migration-order.tsShips 000-init.sql as the app's schema
pgTyped queries (@effectstream/db)packages/database/sql/queries.sqlType-safe insertInput / getAllInputs
Custom API router (@effectstream/runtime)packages/node/api.tsAdds GET /api/inputs to the built-in Fastify server
EffectstreamL2Contract (@effectstream/evm-contracts)packages/contracts-evm/src/contracts/MyEffectstreamL2.solThe 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.jswalletLogin with WalletMode.EvmInjected, then sendTransaction
Orchestrator (@effectstream/orchestrator)start.dev.tsBrings up PGlite, Hardhat, contracts, sync node and frontend in dependency order

Quick start

Prerequisites

  • Bun
  • Foundryforge must be on your PATH. 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 ID 31337), then Import account with Hardhat's default account #0:

  • Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
  • Private key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

These 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

ServiceURL
Frontendhttp://localhost:10599
Sync node APIhttp://localhost:9999
API reference (OpenAPI)http://localhost:9999/documentation
Node healthhttp://localhost:9999/health
Hardhat JSON-RPC (chain ID 31337)http://localhost:8545
PGlite (Postgres wire protocol)postgres://postgres:postgres@localhost:5432/postgres
Orchestrator APIhttp://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 async function. It does not execute the insert; World.resolve yields 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 against typeof grammar, so parsedInput.input is a string without a cast, and a typo in the action name is a compile error.
  • gameStateTransitions is the single function the runtime calls per input. Here it just delegates to the Stm router; 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 });
});
};
MethodPathResponse
GET/api/inputs{ inputs: [{ id, signer, payload, block_height }, …] } — newest 100
GET/health{ status: "ok" } (built in)
GET/documentationOpenAPI 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.tsconfig.dev.ts; bun run start:mainnet runs main.mainnet.tsconfig.mainnet.ts.

AspectDevMainnet
Entrypointpackages/node/main.dev.tspackages/node/main.mainnet.ts
Configpackages/node/config.dev.tspackages/node/config.mainnet.ts
Chainviem hardhat (chain ID 31337, http://localhost:8545)viem arbitrum with rpcUrls overridden by EVM_RPC_URL
Contract addressGenerated contractAddressesEvmMain()EFFECTSTREAM_L2_ADDRESS
EVM start block1 (protocol), 0 (primitive)EVM_START_BLOCK for both
Polling / confirmations500 ms, depth 12000 ms, depth 10, stepSize: 100
Commandbun run devbun run start:mainnet
Local services startedPGlite, Hardhat, contracts, sync node, frontendNone — the sync node only

Mainnet environment variables

VariableRequiredDescription
EVM_RPC_URLyesRPC endpoint for the target EVM chain
EVM_START_BLOCKyesBlock to begin syncing from — use the contract's deployment block
EFFECTSTREAM_L2_ADDRESSyesAddress of the deployed MyEffectstreamL2
NTP_START_TIMEnoExplicit 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

  1. Deploy MyEffectstreamL2 to your chain and note the address and deployment block.
  2. Swap arbitrum in config.mainnet.ts for the viem chain you are targeting.
  3. Set EVM_RPC_URL, EVM_START_BLOCK and EFFECTSTREAM_L2_ADDRESS, point DB_HOST / DB_USER / DB_PW / DB_NAME at a real Postgres, and run bun run start:mainnet.
  4. Update effectstreamConfig in packages/frontend/index.js — the namespace and sync protocol name stay "minimal" and "mainEvmRPC", but the address and chain change — and change the hardcoded http://localhost:9999/api/inputs in packages/frontend/index.html to 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 — an eth_chainId call to http://localhost:8545 returns 31337.
  • infra/deploy.test.tscontractAddressesEvmMain() yields a syntactically valid address for EffectstreamL2Module#MyEffectstreamL2.

Phase B — state machine, database, API

  • stm/submit-input.test.ts — submits ["my_action_name", "hello-from-test"] by calling effectstreamSubmitGameInput through viem with Hardhat account #0, then polls Postgres until an inputs_log row 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.tsGET /api/inputs returns 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