Build a Cross-Chain NFT with ZK Traits
Sync EVM NFTs with ZK-disclosed traits on Midnight via EffectStream.
Introduction
30 Second Summary
Games with hidden information are everywhere. Think of a card game where each player's hand is secret, or an RPG where your character's stats stay private until the perfect reveal.
In this project, you will build a cross-chain NFT prototype where tokens live on a local EVM chain and each token gets a secretly-derived rarity trait computed on Midnight through zero-knowledge proofs. EffectStream merges both chains into one API so you can query any token and get its owner alongside its rarity in a single response.
What You'll Build
You will query one API and get back each NFT's public owner alongside its secretly-derived rarity trait, merged from two independent blockchains into a single response using the same cross-chain pattern behind production games like Kachina Kolosseum and Go Fish.
By the end of this project, you'll have:
- Mint NFTs through a React frontend and watch them sync across two independent blockchains in real time.
- Write a Compact circuit that takes a private rarity value and selectively discloses it on-chain through a zero-knowledge proof.
- Query a unified EffectStream API that returns each token's EVM owner and Midnight rarity in a single response, as if both chains were one database.
- Secret Mission: Add a second hidden trait (power level) so each NFT has two ZK-disclosed properties.
Are there any prerequisites?
This project assumes familiarity with Solidity and EVM development. You will need Bun, Docker Desktop, and Node.js 22+ installed on a Windows machine with WSL2.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Launch the EffectStream Cross-Chain Stack
Building a cross-chain NFT requires two independent blockchains running simultaneously. You need an EVM chain for NFT ownership and a Midnight node for zero-knowledge proofs.
EffectStream handles this complexity for you. Its orchestrator launches an entire multi-chain environment in one command so you can focus on game logic instead of infrastructure plumbing.
In this step, get ready to:
- Verify Bun, Docker, and Node.js 22+ are available inside WSL2.
- Clone the EffectStream repository and navigate to the cross-chain template.
- Install dependencies and launch the full multi-chain stack.
Verify your tools in WSL2
EffectStream requires a Linux environment. All commands in this project run inside WSL2, a real Linux kernel running on your Windows machine.
- Press the Windows key to open your search bar.
- Type Ubuntu (or the name of your installed Linux distribution) and press Enter to open a WSL2 terminal.
What is WSL2?
WSL2 (Windows Subsystem for Linux) runs a full Linux kernel inside Windows. EffectStream's official docs note that Linux and macOS are supported, and Windows WSL is experimental. It works for this project because Docker Desktop already configures WSL2 on your machine.
- Confirm your tools are installed by running each of these commands:
bun --version
docker --version
node --version
Each command prints a version number. For node --version, confirm the number is 22 or higher.
Seeing command not found?
A tool might be installed on Windows but not available inside WSL2. Tools need to be installed in your Linux distribution separately. For Bun, visit the official Bun install page and run the installer inside your WSL2 terminal. For Node.js, install it via nvm or your distribution's package manager inside WSL2.
Docker should be available automatically if Docker Desktop's WSL2 integration is enabled. Open Docker Desktop, go to Settings then Resources then WSL Integration, and make sure your distribution is toggled on.
help me troubleshoot why my tools aren't found in WSL2
Clone the EffectStream repository
- Navigate to your home directory by running:
cd ~
- Clone the EffectStream repository by running:
git clone https://github.com/effectstream/effectstream.git
You should see cloning progress in the terminal, ending with a completion message.
Why EffectStream for cross-chain?
Most cross-chain tools require bridges or relayers where each chain must know about the other. EffectStream takes a different approach. It ingests both chains independently and uses a state machine to merge them into one database.
This "consistent view" pattern is a natural fit for games with hidden traits. The EVM side tracks ownership. The Midnight side tracks zero-knowledge properties. EffectStream joins them on a shared key.
- Navigate into the cross-chain template directory by running:
cd effectstream/templates/evm-midnight-v2
- Confirm you're in the right place by running:
ls
You should see folders including packages/, bun.lock, and package.json. This is the workspace where the entire multi-chain stack lives.
Clone failed?
Make sure git is installed inside WSL2 by running git --version. If it is not found, install it with sudo apt-get update && sudo apt-get install git.
help me fix a git clone failure in WSL2
Install dependencies and launch the stack
- Install all project dependencies by running:
bun i
You should see Bun download and link packages across all workspace folders. This takes a moment.
- Launch the full multi-chain stack by running:
bun run dev
What does this command start?
The bun run dev command starts the EffectStream orchestrator. It compiles your Compact contracts and launches every service the app needs in dependency order.
- Hardhat EVM chain on port 8545.
- Midnight node on port 9944.
- Midnight indexer on port 8088.
- Proof server on port 6300.
- Sync node API on port 9999.
- React frontend on port 10599.
This command takes over your terminal and needs a minute or two to fully start. You will see each service log a checkmark as it comes online. Wait until you see the frontend service marked as ready.
Stopping the stack
Press Ctrl+C in the terminal to stop all services at any time. You can restart them later with bun run dev.
Before you open the browser, what do you think the frontend will show? The template is a pre-built cross-chain dApp. Take a guess at what a ready-made EVM + Midnight template might look like.
- Open your browser and navigate to http://localhost:10599.
You should see the EffectStream template app load with a React frontend. This confirms the full stack is running: two blockchains, a sync node, and a frontend all wired together locally.
Page not loading?
Check your WSL2 terminal for error messages. If a service failed to start, the orchestrator logs the failure inline.
Make sure Docker Desktop is running. The Midnight node and other services depend on Docker containers. If Docker is not running, the orchestrator cannot start those services.
If services started but the browser shows nothing, wait another minute. The frontend is the last service to come online.
help me troubleshoot why the EffectStream frontend is not loading at localhost:10599
Your multi-chain dev environment is live. Next up, you will mint an NFT on the EVM chain and see cross-chain sync in action.
Mint an NFT and See Cross-Chain Sync
Your multi-chain stack is running, with six services up and the React frontend loaded in the browser. Before writing any code, take the template for a test drive to see what it gives you out of the box.
You'll mint an NFT on the EVM chain and check how EffectStream's sync node merges data from both chains into one API. Along the way, you'll discover a key limitation on the Midnight side that sets up everything you build next.
In this step, get ready to:
- Mint an ERC-721 NFT through the React frontend.
- Query the EffectStream sync node API and confirm cross-chain data flows.
- Discover why the Midnight side needs per-NFT properties.
Mint an NFT on the EVM chain
The React frontend is wired to an ERC-721 smart contract on the local Hardhat chain. Minting through the UI creates a real on-chain token that EffectStream can track.
- Return to the React frontend at http://localhost:10599 in your browser.
- Find the mint controls on the page and mint a new NFT.
After minting, you should see the newly minted token appear on the page.
What just happened?
When you clicked mint, the frontend sent a transaction to the ERC-721 contract running on the local Hardhat chain at port 8545. The token now exists on-chain with a unique ID and your wallet as its owner.
EffectStream's sync node is watching that chain. It picks up the new token event and records the ownership data in its database, ready for you to query through the API.
NFT didn't mint?
- Check that the bun run dev process is still running in your WSL2 terminal. If it crashed, restart it.
- Make sure you're on the correct URL. The frontend runs on port 10599, not 3000 or 8080.
- Help me troubleshoot my NFT minting issue with the EffectStream evm-midnight-v2 template.
Query the sync node API
The sync node exposes an API that merges data from both chains. It also ships with built-in OpenAPI docs so you can explore and query every endpoint directly in the browser.
- Open http://localhost:9999/documentation in a new browser tab.
- Find the endpoint that returns token data.
- Expand it, click the button to try it out, and execute the query.
The response includes your minted token with its EVM owner address. Cross-chain sync is working.
What is the sync node doing?
The sync node watches both chains independently. It picks up EVM events (like your NFT mint) and Midnight ledger updates, then merges them into a single database keyed by token ID. The API serves whatever is in that database.
Neither chain knows the other exists. The sync node is the only piece that sees both.
Not seeing any tokens?
- Each chain syncs independently, so data can take a few seconds to appear. Wait a moment and execute the query again.
- Confirm the sync node is running by checking that http://localhost:9999/documentation loads. If it does not, restart the stack.
- Help me figure out why the sync node API is not returning my minted token.
Increment the Midnight counter and spot the gap
The EVM side is sorted. Now check what the Midnight side contributes to each token.
- Switch back to the React frontend at http://localhost:10599.
- Find the controls for the Midnight counter and increment it.
The counter value updates on the page. Now, before you query the sync node API again, think about this: do you expect each token to have its own unique Midnight data, or will they all share the same value?
- Return to the sync node API docs at http://localhost:9999/documentation and execute the token query again.
The counter value appears in the API response, but look closely. Every token shows the same counter value. The Midnight side has a single global counter shared by all tokens. There is nothing unique or private per NFT.
Why is this a problem?
A global counter is useful for proving the Midnight connection works. But for a real game, each token needs its own properties. Imagine an NFT with a hidden rarity trait that only the owner knows. Right now, the Compact contract has no way to store that.
The template gives you working cross-chain plumbing. Your job in the next steps is to fill it with meaningful per-NFT data using zero-knowledge proofs.
You've confirmed that EVM ownership syncs to the API perfectly. The gap is clear: the Midnight side has no per-NFT properties. In the next step, you'll add a rarity trait to the Compact contract using Midnight's privacy-first disclosure pattern.
Add a ZK-Disclosed Rarity Trait to the Midnight Contract
You saw the gap in the last step. Every NFT has an EVM owner, but the Midnight side is just a shared counter with nothing unique per token.
This step fixes that by adding a rarity trait to the Compact circuit. The rarity value enters the circuit as private input, passes through a zero-knowledge proof, and only the disclosed result lands on Midnight's public ledger. The private input never leaves the client.
In this step, get ready to:
- Add a rarity ledger field to the Compact contract and disclose it through a ZK proof.
- Update both circuit callers (frontend and batcher) to pass the new rarity argument.
Add the rarity ledger field
The Compact contract currently has a single counter ledger field. You need a second field that stores a per-NFT rarity value on Midnight's public ledger.
- In VS Code, open packages/contracts-midnight/contract-round-value/src/counter.compact
- Find the line that declares the counter ledger field:
export ledger counter: Counter;
- Add the following line directly below it:
export ledger rarity: Bytes<32>;
What is a Bytes<32> ledger field?
A Bytes<32> field holds exactly 32 bytes of fixed-width data on Midnight's ledger. It can store a short string like a rarity label ("legendary", "common") padded to 32 characters.
Declaring it with export ledger makes it part of the contract's public state. Anyone can read it from the ledger, but the input that produced it stays private.
Wire rarity through the increment circuit with disclose()
A ledger field on its own does nothing. You need the circuit to accept a private rarity value and selectively publish it using disclose(). Without disclose(), the Compact compiler rejects any assignment to a ledger field from a private input.
- In the same file, find the current circuit signature:
export circuit increment(): [] {
- Replace that line with this updated signature that accepts a private rarity parameter:
export circuit increment(_rarity: Bytes<32>): [] {
- Find the closing brace of the circuit. Add the following line just above it, after counter.increment(1);:
rarity = disclose(_rarity);
- Save counter.compact.
What does disclose() do?
The disclose() function marks a witness-derived value as safe to store publicly. The private _rarity parameter is a witness. It enters the circuit on the client side and never appears on-chain in its raw form.
When the Compact compiler sees rarity = disclose(_rarity), it generates a zero-knowledge proof that the assignment is valid. The proof is posted on-chain. The disclosed value becomes public. The original private input stays secret.
Why the underscore prefix on _rarity?
In Compact, circuit parameters prefixed with an underscore (like _rarity) are private witnesses. They exist only on the client that builds the transaction. The underscore is a convention that signals "this value is private and never leaves the prover."
✔️ Awesome, I've got everything!
Great. Double check you have saved counter.compact.
ⓧ I'd like to double check the full code
Your counter.compact file should look exactly like this:
pragma language_version 0.23;
import CompactStandardLibrary;
export ledger counter: Counter;
export ledger rarity: Bytes<32>;
export circuit increment(_rarity: Bytes<32>): [] {
counter.increment(1);
rarity = disclose(_rarity);
}
Unsure about the circuit structure?
Make sure rarity = disclose(_rarity); is inside the circuit body (between the curly braces), not outside it.
Check that export ledger rarity: Bytes<32>; is at the top level, below the counter declaration, not inside the circuit.
help me check my counter.compact file for errors
Update the circuit callers to pass rarity
Adding a parameter changes the circuit's signature. Every caller that invokes increment must now pass a rarity value, or the call will fail with a signature mismatch.
- Open packages/frontend/client/src/increment.ts in VS Code.
- Search for the line where callTx.increment is invoked. This is inside the increment function.
- Add a new encoded argument for rarity to the callTx.increment() call. The value should be encoded the same way as the existing arguments, using toEncodedString with a length of 32 to match Bytes<32>.
- Save increment.ts.
What is the rarity value at this point?
For now, you can pass any string value as the rarity (like "legendary" or "common"). The exact value does not matter yet. What matters is that the caller passes a Bytes<32>-encoded argument that matches the circuit's new parameter.
- Open packages/batcher/midnight-balancing.ts in VS Code.
- Find the line where the batcher calls the increment circuit. This is in the createMidnightBalancingAdapter function or its associated adapter logic.
- Add the same rarity argument to align with the updated circuit signature.
- Save midnight-balancing.ts.
Why update both callers?
The Compact compiler generates a typed interface from the circuit signature. Any code that calls increment must pass the exact arguments the circuit expects. The frontend caller handles user-initiated transactions. The batcher caller handles automated background transactions.
If either caller is out of sync with the circuit, the transaction will be rejected at proof generation time.
Not sure where the circuit call is?
In increment.ts, search for callTx.increment. That is the function call that invokes the Compact circuit from the frontend.
In midnight-balancing.ts, look for any reference to the increment circuit. The batcher adapter wraps it in its own abstraction.
help me find and update the circuit callers
All three files are saved. The stack is still running the old version of the contract. Do not restart yet.
Why not restart now?
You could restart the stack right now. But in the next step, you will restart and then discover that rarity values are stored on Midnight's ledger yet missing from the API response. Seeing that gap firsthand is the most important lesson in this project.
Your Compact contract now has per-NFT rarity built in. Next up, you will restart the stack, set a rarity value, and discover that the EffectStream API does not return it. That gap is the key to understanding how state machines bridge on-chain data to your app.
Hit the State Machine Gap
Your Compact contract now has a rarity field, and both the frontend and batcher callers are passing the new argument. Everything is aligned on the code side.
Time to restart the stack and put it to the test. You'll pass a rarity value through the updated circuit and check whether it surfaces in the EffectStream API response.
In this step, get ready to:
- Restart the dev stack so the orchestrator recompiles the updated Compact contract.
- Pass a rarity value through the frontend and confirm the transaction succeeds.
- Query the EffectStream API to check if rarity appears alongside each token's owner.
Restart the dev stack
The orchestrator recompiles the Compact contract and redeploys all services whenever you restart. This picks up your new rarity field automatically.
- Switch back to the WSL2 terminal where the stack is running.
- Press Ctrl+C to stop all services.
- Restart the full stack by running:
bun run dev
What happens on restart?
The orchestrator recompiles the Compact circuit with your new rarity field and redeploys the contract to the local Midnight node.
It also restarts every other service: the Hardhat EVM chain, the Midnight indexer, the proof server, the sync node, and the React frontend. Watch for each service to report that it is online in the terminal output.
- Wait for the terminal to show all services coming online. You should see log lines for the EVM chain, Midnight node, indexer, proof server, sync node, and the frontend.
Services not starting?
- Make sure Docker Desktop is running. The Midnight node and indexer depend on Docker containers.
- Check for port conflicts. If another process is using port 8545, 9944, or 10599, the corresponding service will fail to bind.
- If you see a Compact compilation error, double check that counter.compact is saved and matches the code from the previous step.
Help me debug why EffectStream services are not starting after I modified the Compact contract
Pass a rarity value through the frontend
The frontend and batcher callers were updated in the previous step to pass the rarity argument. The contract now accepts it. Time to confirm the full circuit works.
- Return to the React frontend at http://localhost:10599 in your browser.
- Use the UI to interact with the updated circuit. Pass a rarity value when prompted.
- Confirm that the transaction succeeds. The frontend should show a successful submission.
Why does this work?
The Compact contract, the frontend caller in increment.ts, and the batcher caller in midnight-balancing.ts all agree on the circuit's signature. Every caller passes the _rarity argument the circuit now expects.
Behind the scenes, disclose() marks your rarity value for public disclosure. The Midnight proof server generates a zero-knowledge proof that verifies the assignment without revealing the original private input.
Transaction failing?
- If the frontend shows an error about mismatched arguments, one of the callers may not have been updated. Verify that both packages/frontend/client/src/increment.ts and packages/batcher/midnight-balancing.ts pass the rarity parameter.
- Make sure you restarted the stack after saving all files. Changes only take effect after the orchestrator recompiles.
Help me fix a failing transaction after adding a rarity field to the Compact contract
Query the sync node API
You've updated the Compact contract, aligned every caller, restarted the stack, and successfully passed a rarity value. Before you check the API, what do you expect to see when you query a token's data?
- Return to the sync node API docs at http://localhost:9999/documentation in your browser.
- Find the token endpoint and query it to see the latest token data.
The token entry shows its EVM owner. But the rarity field is null or missing from the response entirely.
This is not a bug. This is the designed obstacle of this step.
- Open http://localhost:8088/api/v3/graphql in a new browser tab. This is the Midnight indexer's GraphQL playground.
- Browse the contract state. You'll find that your rarity value IS stored on Midnight's public ledger. The disclose() call placed it there, and a zero-knowledge proof verified it.
The data exists on-chain. The EffectStream API just cannot see it.
Why is rarity missing from the API?
EffectStream only surfaces what the state machine explicitly decodes. The data sits on Midnight's public ledger, verified by a ZK proof. But the state machine in packages/node/state-machine.ts was written before the rarity field existed.
The midnightContractState function processes each Midnight transaction and writes decoded fields to the database. It knows about the fields from the original template. It knows nothing about rarity, so it skips it entirely.
This is what the EffectStream docs call the state machine gap. Designing the Compact circuit designs the sync surface, but only the fields the state machine decodes will reach your API.
Why does this gap exist by design?
EffectStream ingests two chains independently. It does not assume any schema. You decide which fields from which chain matter for your application.
This gives you full control over what your API serves. But it also means every new on-chain field must be wired through four layers: the circuit, the database, the state machine, and the API route.
The rarity data is on Midnight's ledger, but the API cannot see it. Next up, you'll wire the rarity field through EffectStream's state machine to close this gap.
Wire Rarity Through the EffectStream State Machine
In the last step, you discovered the state machine gap. Rarity values are sitting on Midnight's public ledger, verified by zero-knowledge proofs. But when you query the EffectStream API, rarity is nowhere to be found.
The state machine is the bridge between Midnight's raw ledger data and your API. In this step, you will decode the new Bytes<32> field from the ledger, store it in the database, and serve it through the API.
In this step, get ready to:
- Decode the rarity field in the EffectStream state machine.
- Pass the decoded rarity into the database insert call.
- Restart the stack and verify rarity appears in the API response.
Decode the rarity field in the state machine
The midnightContractState transition function is where EffectStream converts raw Midnight ledger bytes into usable values. It already decodes fields like contract_address, token_id, property_name, and value. You need to add one more line for rarity.
- Switch back to VS Code. Open packages/node/state-machine.ts.
- Find the midnightContractState state transition function.
- Locate the group of decodeField calls. They look like this:
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);
- Add a new decodeField line for rarity directly below the existing ones, so the block now looks like this:
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);
// Decode the new rarity field from Midnight's ledger bytes
const rarity = decodeField(payload.rarity);
What does decodeField do?
A Bytes<32> ledger field arrives from Midnight as fixed-width bytes. decodeField converts those raw bytes back into a usable string so the state machine can store it in the database.
Without this decode step, the state machine sees the raw byte payload but has no idea what it represents. This is why rarity was missing from the API response in the last step.
- Save state-machine.ts.
Not sure where to add the line?
Search for decodeField in state-machine.ts using Cmd+F (macOS) or Ctrl+F (Windows). You should see a group of four consecutive decodeField calls. Add your new line immediately after the last one.
help me find the decodeField calls in my state-machine.ts file
Pass rarity into the database insert
Decoding the field is only half the job. EffectStream stores cross-chain data in a database so the API can serve it. You need to pass rarity into the existing insertEvmMidnightProperty call so it gets persisted alongside the other fields.
- In the same midnightContractState transition, scroll down to the insertEvmMidnightProperty call.
- Add rarity to the arguments, alongside the existing fields already written there.
- Save state-machine.ts.
Why does a single field touch so many layers?
Nothing generates this path for you. A new on-chain field has to flow through the circuit, the state machine, the database, and the API. This is the tradeoff of the EffectStream pattern.
The upside is full control over what gets synced and how. That matters when you are merging two independent blockchains into one view.
Not sure which arguments to update?
Look for insertEvmMidnightProperty in the same function. It already passes contract_address, token_id, property_name, and value. Add rarity after value in that same call.
help me find and update the insertEvmMidnightProperty call in state-machine.ts
Restart the stack and verify the full cross-chain response
The state machine now decodes rarity and stores it in the database. Time to restart everything and see the complete cross-chain loop in action.
- Return to your WSL2 terminal and stop the running stack by pressing Ctrl+C.
- Restart the full stack by running:
bun run dev
- Wait for all services to come online in the terminal output.
- Open the React frontend at http://localhost:10599.
- Mint a new NFT through the UI.
- Set a rarity value through the frontend.
Before you check the API, predict what the response will look like. Do you think rarity will appear this time, or will it still be missing?
- Query the token endpoint by running this in a new terminal:
curl http://localhost:9999/api/erc721
Each token entry now returns both its EVM owner AND its Midnight rarity trait in a single response. The full cross-chain loop is complete.
What just happened end to end?
You minted an NFT on the local EVM chain. You set a rarity value that entered the Midnight Compact circuit as private input. disclose() published it to Midnight's public ledger through a zero-knowledge proof.
EffectStream's state machine decoded that ledger data, stored it in the database, and served it alongside the EVM owner through one API. Two independent blockchains, one unified view.
✔️ Awesome, I've got everything!
Great. Double check you have saved state-machine.ts and your API response includes both the EVM owner and the rarity trait.
ⓧ I'd like to double check my changes
You only edited one file in this step. Scroll up and confirm both edits are present in packages/node/state-machine.ts:
- The const rarity = decodeField(payload.rarity); line sits directly after the four existing decodeField calls.
- The rarity variable is passed into the insertEvmMidnightProperty call alongside the existing fields.
Rarity still missing from the API response?
Make sure you saved state-machine.ts before restarting the stack. Unsaved changes are not picked up by the orchestrator.
Double check that you added rarity in both places: the decodeField line and the insertEvmMidnightProperty call. Missing either one means rarity is decoded but not stored, or stored but not decoded.
If you see stale data from before the restart, mint a new NFT and set a rarity value. Tokens created before the state machine update will not have rarity populated.
help me debug why rarity is not showing up in my EffectStream API response
This closes the loop. EVM ownership plus Midnight rarity in one unified cross-chain response. In the Secret Mission, you will extend this pattern to a second trait.
Secret mission
Add a Power Level Trait
You added one ZK-disclosed trait to the cross-chain stack. Can you add a second? Add a powerLevel field that flows through every layer of the project: the Compact contract, both circuit callers, the EffectStream state machine, and the API response. If you can extend the pattern once, you can extend it to any number of properties.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing cloud costs.
Resources you used:
- Local Docker containers: Hardhat EVM chain, Midnight node, Midnight indexer, proof server, and the EffectStream sync node.
- PGLite database storing cross-chain state (runs inside the sync node process).
- Cloned effectstream repository on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with additional traits.
- Leave your WSL2 terminal open. All services (EVM chain, Midnight node, indexer, proof server, sync node, and React frontend) stay up.
- Everything runs locally, so there is no cost while services are running.
Pause - I'll come back to this later
Shut down running processes to free up memory and CPU. Your code and project files stay on disk so you can pick up where you left off.
- Press Ctrl+C in your WSL2 terminal. All Docker containers stop automatically.
- When you are ready to come back, open a WSL2 terminal, navigate back to the template folder, and restart with:
cd ~/Desktop/effectstream/templates/evm-midnight-v2
bun run dev
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes the cloned repository and cleans up Docker artifacts.
- Press Ctrl+C in your WSL2 terminal to stop the running stack.
- Navigate back to your Desktop and remove the cloned repository by running:
cd ~/Desktop
rm -rf effectstream
- Remove stopped Docker containers, unused images, and dangling build cache by running:
docker system prune
Docker will ask Are you sure you want to continue? [y/N]. Type y and press Enter to confirm.
- Confirm the repository is gone by running:
ls ~/Desktop
You should no longer see an effectstream folder in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built a cross-chain NFT prototype where EVM ownership and Midnight's zero-knowledge traits live on separate blockchains but appear as one unified dataset through EffectStream.
You've learned how to:
- Written a Compact smart contract that takes private input and selectively discloses a rarity trait on-chain via disclose() and a zero-knowledge proof.
- Discovered the state machine gap, where on-chain data stays invisible to the API until the state machine explicitly decodes it.
- Wired a new field end-to-end through EffectStream's deterministic state machine, from Midnight's ledger through the database to the API response.
- Secret Mission: Added a second trait (power level), proving you can extend the pattern to any number of ZK-disclosed properties.
Ready to quiz yourself?