Skip to main content
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.

Community-maintained, not officially supported

EffectStream maintains the template and the @effectstream/* packages this guide uses, not the Midnight Foundation. The template lives on the repository's default branch and its pinned versions change, so when the template and this guide disagree, follow the template. 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 or Apple silicon machine. The orchestrator runs the Midnight node, indexer, and proof server as native binaries, and the node ships for only those two platforms, so the template does not start anywhere else. Docker is not required.
  • Bun. See Set up Bun for Midnight development.
  • Foundry. forge compiles the Solidity artifacts, and the orchestrator checks for it on PATH before starting.
  • The Compact toolchain, which the orchestrator checks on PATH before starting. The template's build script selects toolchain 0.33.0-rc.2 explicitly, and compact update cannot install prereleases, so download compactc_v0.33.0-rc.2_<target>.zip from the Compact releases and unzip it into ~/.compact/versions/0.33.0-rc.2/<target>/, using the same <target> as the zip name, for example aarch64-darwin. Your default toolchain stays as it is. Confirm with compact compile +0.33.0-rc.2 --version, which prints 0.33.0.
  • Roughly 8 GB of free memory, and a raised Node heap for the frontend build. Vite transforms about 9000 modules and exceeds Node's default limit, so export NODE_OPTIONS=--max-old-space-size=8192 before 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 takes private inputs and discloses one resulting property, because the values behind it stay private. The two sides share a key, (contract_address, token_id), which you supply to both. The rollup joins on that key.

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, and you see it skip events while the two chains catch up.

Run the template​

Start the whole stack and mint a token.

Procedure​

  1. Clone the repository and enter the template:

    git clone https://github.com/effectstream/effectstream.git
    cd effectstream/templates/evm-midnight-v2
  2. Install dependencies:

    bun install
  3. Start the stack. This compiles the Compact circuit, compiles and deploys the Solidity contracts, deploys the Midnight contract, then starts the database, sync node, batcher, and frontend:

    bun run dev
  4. Open the DApp at http://localhost:10599, mint a token, and set a property on it.

Verification​

The sync node runs independently of the frontend, so check it directly. The merged view returns each token with its EVM owner and its Midnight properties:

curl http://localhost:9999/api/erc721

The sync node's own logs show both chains advancing together, which is the rollup working:

INFO effectstream-sync-block-merge: finalized block 145 @ 0xdf3de2... | {"mainNtp":[145,145],"mainEvmRPC":[794,797]}
[Midnight:undeployed] Fetching blocks from 32 to 32.

Local service endpoints​

The template starts these services on the ports shown.

ServiceURL
Frontendhttp://localhost:10599
Sync node APIhttp://localhost:9999
Sync node OpenAPI docshttp://localhost:9999/documentation
Batcherhttp://localhost:3334
Orchestrator APIhttp://localhost:4747
EVM chain (main)http://localhost:8545
EVM chain (parallel)http://localhost:8546
Midnight node RPChttp://localhost:9944
Midnight indexerhttp://localhost:8088/api/v4/graphql
Midnight proof serverhttp://localhost:6300
Databasepostgres://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 anything Compact can express, 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​

  1. Open packages/contracts-midnight/contract-round-value/src/counter.compact. Add a ledger field, take a matching argument in increment, and assign it through disclose(). 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>; // added

    export 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 two callers.

  2. Recompile the circuit so the generated ledger reader includes the new field:

    bun run build:midnight
  3. Add a rarity TEXT column to the evm_midnight_properties table in packages/database/migrations/000-init.sql, the single migration the template ships.

  4. In packages/database/sql/sm_example.sql, add rarity to the insertEvmMidnightProperty insert and to the columns getEvmMidnight selects, then regenerate the typed queries:

    bun run build:pgtypes
  5. Decode the field in the midnightContractState transition in packages/node/state-machine.ts. A Bytes<32> ledger field arrives as fixed-width bytes, so decodeField turns it back into a string. 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); // added

    Then pass rarity into the insertEvmMidnightProperty call further down the same transition, alongside the fields already written there.

  6. In packages/node/api.ts, add rarity: Type.String() to ResponseSchema. The GET /api/erc721 handler returns whatever getEvmMidnight selects, so the schema is the only change the route needs.

  7. Pass the new argument from the two callers: the frontend in packages/frontend/client/src/increment.ts, and the batcher in packages/batcher/midnight-balancing.ts. Nothing in the template's build checks these files against the circuit, so a caller you forget fails at runtime, the first time it calls increment.

  8. Restart the stack and set a property from the frontend.

Verification​

The endpoint returns the new field alongside the existing ones.

curl http://localhost:9999/api/erc721

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.

Some state survives on disk under packages/contracts-midnight/: the midnight-level-db* directories that hold private state, contract.json, and contract-round-value.undeployed.json, which records the deployed address. The next bun run dev runs the package's midnight-contract:clean script and deploys a fresh Midnight contract at a new address, so properties you set in an earlier run belong to a contract the new run no longer watches.

Additional resources​