For the complete documentation index, see llms.txt
Build a cross-chain DApp with EffectStream
Use this guide to build a DApp whose state lives on Midnight and an EVM chain at once. You run a working template, learn how it joins the two chains, then add a field of your own end to end.
Index contract state with EffectStream covers reading one Midnight contract. This guide covers the case EffectStream exists for: correlating two chains, and writing to them.
EffectStream maintains the template and the @effectstream/* packages this guide uses, not the Midnight Foundation. This guide uses the template at a tested commit on the midnight-1 branch, which EffectStream maintains for the current Midnight networks. The default branch, v-next, targets the next ledger release, and its template does not start from a clean clone at the moment. See effectstream#895. Report EffectStream problems on the EffectStream tracker, and problems with this page on the Midnight docs repository.
Prerequisites
These apply to every procedure in this guide:
- Git for cloning the template repository.
- A Linux x86-64, Linux arm64, or Apple silicon machine. The orchestrator runs the Midnight node, indexer, and proof server as native binaries, so Docker is not required. The binary packages ship for only those three platforms. Anywhere else, such as an Intel Mac, the template stops with
Unsupported platformbefore it downloads anything. - Bun. See Set up Bun for Midnight development.
- Foundry.
forgecompiles the Solidity artifacts, and the orchestrator checks for it onPATHbefore starting. - The Compact toolchain, which the orchestrator checks on
PATHbefore starting. The template's build script selects toolchain0.31.0explicitly, so install it alongside your default withcompact update 0.31.0 --no-set-default. Confirm withcompact compile +0.31.0 --version, which prints0.31.0. Keep this version for the template only, and compile your own contracts with the toolchain in the support matrix. - Enough memory for the frontend build, the heaviest step. Vite transforms about 9000 modules and uses about 3 GB. Node sizes its default heap from the machine's memory, so on a machine with less than 16 GB, export
NODE_OPTIONS=--max-old-space-size=4096before starting.
The template launches its own Midnight stack, so you need no separately running node or proof server.
How a rollup joins two chains
Splitting a DApp across chains usually means a bridge: a message-passing contract, a relayer, and a light client that lets one chain verify the other. That machinery exists to make execution atomic across chains.
EffectStream targets the weaker requirement. When you need a consistent view of two chains rather than atomic execution across them, neither chain has to know the other exists. Both are ingested independently, and a state machine you write merges them into one database.
The template splits an NFT along that line. An ERC-721 contract on the EVM chain owns transfers, because everyone needs to agree on who holds a token. A Compact circuit on Midnight records a property for a token. It takes the contract address, token ID, property name, and value as arguments, and writes each one to the public ledger with disclose(). The template's circuit keeps nothing private. In a real DApp, the circuit computes the disclosed value from private inputs. The two sides share a key, (contract_address, token_id), which you supply to both. The database stores and links rows by that key. The template's merged API query joins on token_id alone, though, so its results mix up tokens from two contracts that share an ID.
One consequence shapes the code you write. EffectStream reads Midnight's public ledger, so values a circuit discloses and writes to the ledger are what your state machine can see. Designing the circuit designs the sync surface.
A second one shows up in the logs. Each chain syncs independently, so a Midnight property can arrive before the EVM transfer that created the token. The midnightContractState transition handles this by inserting a placeholder row with an empty owner. It also skips the freshly deployed contract, whose fields are all still empty, and logs empty token_id, skipping.
Run the template
Start the whole stack and mint a token.
Procedure
-
Clone the repository, check out the tested commit, and enter the template:
git clone --branch midnight-1 https://github.com/effectstream/effectstream.gitcd effectstreamgit checkout 667173ac79aad6c51bf5d97e87355c3438a48be8cd templates/evm-midnight-v2 -
Install dependencies:
bun install -
Start the stack. The
devscript launches the database and the Midnight node, indexer, and proof server, and compiles the Compact circuit and the Solidity contracts. It then starts the local EVM chains, deploys both contracts, and starts the sync node, batcher, and frontend:bun run dev -
Open the DApp at
http://localhost:10599and connect both wallets. Neither needs a browser extension. Select Connect EVM, then Connect Local Wallet, which uses an account the local EVM chain funds at startup. Then select Connect Midnight. The page builds a wallet from the local network's genesis seed and joins the deployed contract. The button shows the wallet address when it finishes. -
Enter a token ID next to Mint # and select MINT.
-
Select the new token, enter a name under Update Token Name, and select Save.
Verification
The sync node runs independently of the frontend, and starts before the frontend build finishes, so check it directly. The merged view returns each token with its EVM owner and its Midnight properties:
curl http://localhost:9999/api/erc721
[{"token_id":"1","owner":"0x14dc79964da2c08b23698b3d3cc7ca32193d9955","block_height":348,"property_name":"Name","value":"Aurora","property_block_height":465}]
The owner comes from the EVM chain, and the property name and value come from Midnight. A token with no property yet shows null in the three property fields, and a new property can take several seconds to appear. Your values differ.
The sync node's own logs show both chains advancing together, which is the rollup working:
INFO effectstream-sync-block-merge: finalized block 465 @ 0x8d64ba... | {"mainNtp":[465,465],"mainEvmRPC":[2136,2138],"parallelMidnight":[87,87]}
[Midnight:undeployed] Fetching blocks from 89 to 89.
finalized block 465 is EffectStream's own block number, the same number as the mainNtp pair. Each other pair is the lowest and highest block of one chain merged into that EffectStream block. This one carried EVM blocks 2136 to 2138 and Midnight block 87. The parallelMidnight pair appears only on blocks that carried a Midnight block. The API reports these EffectStream block numbers too, so property_block_height above is 465, not 87.
Local service endpoints
The template starts these services on the ports shown.
| Service | URL |
|---|---|
| Frontend | http://localhost:10599 |
| Sync node API | http://localhost:9999 |
| Sync node OpenAPI docs | http://localhost:9999/documentation |
| Batcher | http://localhost:3334 |
| Orchestrator API | http://localhost:4747 |
| EVM chain (main) | http://localhost:8545 |
| EVM chain (parallel) | http://localhost:8546 |
| Midnight node RPC | http://localhost:9944 |
| Midnight indexer | http://localhost:8088/api/v4/graphql |
| Midnight proof server | http://localhost:6300 |
| Database | postgres://postgres:postgres@localhost:5432/postgres |
The ingestion pipeline
Four files carry a chain event from the wire into your API. Read them in order to understand the template.
packages/node/config.dev.ts declares networks, sync protocols, and primitives. One primitive per chain, each naming a stateMachinePrefix:
.addPrimitive(
(syncProtocols) => syncProtocols.parallelMidnight,
(network, deployments, syncProtocol) => ({
name: "MidnightContractState",
type: PrimitiveTypeMidnightGeneric,
startBlockHeight: 1,
contractAddress: readMidnightContract("contract-round-value", {
networkId: midnightNetworkConfig.id,
}).contractAddress,
stateMachinePrefix: "midnightContractState",
contract: { ledger: CounterContract.ledger },
networkId: midnightNetworkConfig.id,
}),
)
contract: { ledger: CounterContract.ledger } hands the primitive the reader that compact compile generates. That reader decodes every exported ledger field, including struct fields, lists, and Merkle trees, which is the difference from the declarative schema in Index contract state with EffectStream.
packages/node/grammar.ts maps each prefix to a parser. Both prefixes use builtin grammars, so the template writes none of its own.
packages/node/state-machine.ts holds one state transition function per prefix. Each receives the parsed payload and writes to the database.
packages/node/api.ts serves the merged result over HTTP.
Add a field end to end
Carry one new value from the Compact circuit to the API. Nothing generates this path for you, so a single field touches the circuit, the database schema, the queries, the state transition, and the route. Knowing that cost up front is part of choosing this pattern.
Procedure
-
Open
packages/contracts-midnight/contract-round-value/src/counter.compact. Add a ledger field, take a matching argument inincrement, and assign it throughdisclose(). The additions are marked:pragma language_version >= 0.17;import CompactStandardLibrary;export ledger round: Counter;export ledger contract_address: Bytes<64>;export ledger token_id: Bytes<64>;export ledger property_name: Bytes<32>;export ledger value: Bytes<32>;export ledger rarity: Bytes<32>; // addedexport circuit increment(contract_address_: Bytes<64>,token_id_: Bytes<64>,property_name_: Bytes<32>,value_: Bytes<32>,rarity_: Bytes<32>, // added): [] {round.increment(1);contract_address = disclose(contract_address_);token_id = disclose(token_id_);property_name = disclose(property_name_);value = disclose(value_);rarity = disclose(rarity_); // added}Adding an argument changes the circuit's signature, so every caller needs the new value. Step 7 covers the callers.
-
Recompile the circuit so the generated ledger reader includes the new field:
bun run build:midnight -
Add a
rarity TEXTcolumn to theevm_midnight_propertiestable inpackages/database/migrations/000-init.sql, the single migration the template ships. -
In
packages/database/sql/sm_example.sql, addrarityto theinsertEvmMidnightPropertyinsert and to the columnsgetEvmMidnightselects, then regenerate the typed queries:bun run build:pgtypes -
Decode the field in the
midnightContractStatetransition inpackages/node/state-machine.ts. ABytes<32>ledger field arrives as fixed-width bytes, sodecodeFieldturns it back into a string. Addrarityto thepayloadtype at the top of the transition, then add a line next to the existing ones:const contract_address = decodeField(payload.contract_address);const token_id = decodeField(payload.token_id);const property_name = decodeField(payload.property_name);const value = decodeField(payload.value);const rarity = decodeField(payload.rarity); // addedThen pass
rarityinto theinsertEvmMidnightPropertycall further down the same transition, alongside the fields already written there. -
In
packages/node/api.ts, addrarity: Type.String()toResponseSchema. TheGET /api/erc721handler returns whatevergetEvmMidnightselects, so the schema is the only change the route needs. -
Pass the new argument where the template calls the circuit, the
callTx.incrementcall inpackages/frontend/client/src/increment.ts. The frontend has no input forrarity, so pass a fixed value after the property value:const finalizedTxData = await counterContract.callTx.increment(toEncodedString(contractAddress, 64),toEncodedString(tokenId, 64),toEncodedString(propertyName, 32),toEncodedString(propertyValue, 32),toEncodedString("legendary", 32), // added);The end-to-end test in
packages/tests/stm/midnight-property.test.tsmakes the same call, so add a value there too if you runbun run test. Nothing in the template's build checks these calls against the circuit. A call you forget fails at runtime withincrement: expected 6 arguments (as invoked from Typescript), received 5. -
Restart the stack, then mint a token and set a property from the frontend as before. A restart begins from an empty database, new local chains, and a new Midnight contract, so tokens from the earlier run are gone.
Verification
The endpoint returns the new field alongside the existing ones:
curl http://localhost:9999/api/erc721
[{"token_id":"1","owner":"0x14dc79964da2c08b23698b3d3cc7ca32193d9955","block_height":26,"property_name":"Name","value":"Aurora","rarity":"legendary","property_block_height":129}]
Stop and reset the stack
Press Ctrl+C in the terminal running bun run dev. The orchestrator stops every process it started and frees their ports, so nothing survives in the background.
Two things survive on disk under packages/contracts-midnight/: the midnight-level-db-deploy directory, which holds private state, and contract-round-value.undeployed.json, which records the deployed address. The database and the local chains do not survive. The next bun run dev starts them empty and deploys a fresh Midnight contract at a new address, so tokens and properties from an earlier run are gone.
Template troubleshooting
Failures you are likely to hit on this guide's path, and their fixes. Every quoted message comes from a real run.
| Symptom | Cause | Fix |
|---|---|---|
The contract compile fails with Couldn't find compiler for aarch64-darwin (0.33.0-rc.2), and compact update 0.33.0-rc.2 fails with Couldn't find version 0.33.0-rc.2 | You cloned the default branch, v-next. Its template compiles with a prerelease toolchain that compact update cannot install. See effectstream#895 | Clone the tested commit on midnight-1, as in Run the template |
The frontend build stops with JavaScript heap out of memory | Node sizes its default heap from the machine's memory, and on a smaller machine that heap is too small for the build | Export NODE_OPTIONS=--max-old-space-size=4096, then run bun run dev again |
The frontend build warns "WebSocket" is not exported by ".../isomorphic-ws/browser.js" | The Midnight.js indexer provider imports a named WebSocket that the browser build of isomorphic-ws does not export. In the browser, the provider uses the browser's own WebSocket instead | None needed. Connect Midnight works with this warning |
The sync log shows [STM:midnight] empty token_id, skipping | The sync node read the freshly deployed contract, whose fields are all still empty | None needed. It appears before you set the first property |
A circuit call fails with increment: expected 6 arguments (as invoked from Typescript), received 5 | A caller still passes the old arguments after you added rarity to the circuit | Add the new argument to every callTx.increment call, as in step 7 of Add a field end to end |
Additional resources
- Index contract state with EffectStream: the read path, one Midnight contract, no local stack.
- EffectStream documentation: the project's own documentation, including the batcher and the other cross-chain templates.
- Security and best practices: how
disclose()permits private data to cross into public ledger state.