Zenroom Interop Tutorial
Zenroom is a deterministic crypto VM that runs Zencode scripts. nXCC integrates Zenroom as the nxcc/zenroom VM so you can execute Zencode inside the enclave and safely post results out to other systems.
This tutorial picks up after the Blockchain Events and Identities & Policies guides. We will not re-cover worker scaffolding or policy basics. Instead, you’ll build a focused Zenroom workflow:
- verify an issuer-signed credential
- derive a bridge signing key from an identity-governed secret
- trigger the worker from an on-chain event
- post a signed consent ticket to a relay (or Chain B)
For full configuration details, keep the Zenroom VM Reference handy.
Scenario: cross-chain consent ticket
Section titled “Scenario: cross-chain consent ticket”An issuer (think: a credential authority in an EUDI/eIDAS-inspired ecosystem) signs a lightweight credential for a wallet holder. When a contract on Chain A emits an event (we will reuse the Transfer event from the blockchain guide), the Zenroom worker:
- verifies the issuer signature on the credential
- signs a consent ticket with a bridge key derived inside the enclave
- posts the signed ticket to a relay that can submit it to Chain B
We keep the cryptography simple (EdDSA signatures) so the focus stays on the nXCC integration. This is not an EUDI-compliant credential flow; it is a simplified pattern that maps to similar concepts. In a production setup, you would swap in your real credential format (SD-JWT, BBS+, etc.) and use a dedicated event.
Prerequisites
Section titled “Prerequisites”- Completed Blockchain Events and Identities & Policies.
- Local nXCC node running.
- An identity contract address and token id from the previous guide.
- Zenroom VM running and attached to the daemon (see the Zenroom VM Reference).
If you’re running the node in Docker, restart it with the Zenroom VM enabled and postbacks allowlisted:
docker run --rm \ --add-host=host.docker.internal:host-gateway \ -p 127.0.0.1:6922:6922 \ -p 127.0.0.1:9000:9000 \ -e NXCC_ZENROOM_VM_ENABLED=true \ -e NXCC_ZENROOM_VM_POSTBACK_ENABLED=true \ -e NXCC_ZENROOM_VM_POSTBACK_ALLOWED_HOST_SUFFIXES=host.docker.internal,localhost,127.0.0.1 \ -e NXCC_ZENROOM_VM_POSTBACK_ALLOWED_SCHEMES=http \ -e NXCC_ZENROOM_VM_POSTBACK_ALLOWED_PORTS=9911 \ -e NXCC_ZENROOM_VM_POSTBACK_BLOCK_PRIVATE_IPS=false \ ghcr.io/nxcc-bridge/node:latest1. Write the Zencode worker
Section titled “1. Write the Zencode worker”Create workers/consent_ticket.zen:
Rule check version 2.0.0Scenario 'consent-ticket': Verify credential and sign a ticketGiven that I am known as 'Bridge'and I have a 'base64' named 'bridge_secret'and I have a 'base64' named 'credential'and I have a 'eddsa signature'and I have a 'base64' named 'ticket'and I have a 'eddsa public key' from 'Issuer'When I create the eddsa key with secret key 'bridge_secret'and I verify the 'credential' has a eddsa signature in 'eddsa signature' by 'Issuer'and I rename the 'eddsa signature' to 'credential_signature'and I create the eddsa signature of 'ticket'and I rename the 'eddsa signature' to 'ticket_signature'Then print the 'ticket'and print the 'ticket_signature'Notes:
- The input key for
eddsa signatureiseddsa_signaturein JSON. bridge_secretwill be injected from an identity secret (next section).ticketandcredentialare base64 strings so the script stays deterministic and easy to sign.
2. Prepare a credential and consent ticket
Section titled “2. Prepare a credential and consent ticket”Create a small credential payload and a ticket payload, then base64-encode both.
mkdir -p zenroom
cat > zenroom/credential.json <<'JSON'{ "issuer": "Credential Authority", "subject": "did:example:alice", "claims": { "age_over_18": true, "residency": "EU" }, "issued_at": "2026-01-23", "expires_at": "2027-01-23"}JSON
cat > zenroom/ticket.json <<'JSON'{ "request_id": "demo-consent-001", "subject": "did:example:alice", "scope": "wallet:age_over_18", "chain": "eip155:31337", "requester": "0xYourContractAddress", "tx_hash": "0xYourEventTxHash"}JSON
python3 - <<'PY' > zenroom/credential.b64.jsonimport base64, jsondata = open("zenroom/credential.json", "rb").read()print(json.dumps({"credential": base64.b64encode(data).decode()}))PY
python3 - <<'PY' > zenroom/ticket.b64.jsonimport base64, jsondata = open("zenroom/ticket.json", "rb").read()print(json.dumps({"ticket": base64.b64encode(data).decode()}))PYIn a production workflow, the ticket values would be populated from the event payload or a policy decision. For the tutorial, placeholders are fine.
3. Generate issuer keys and a credential signature
Section titled “3. Generate issuer keys and a credential signature”Use Zenroom locally to create an issuer keypair and sign the credential. The zenroom binary ships alongside zencode-exec in Zenroom releases.
Create the helper scripts:
Rule check version 2.0.0Scenario 'eddsa': Issuer keygenGiven I am known as 'Issuer'When I create the eddsa keyThen print my 'keyring'Rule check version 2.0.0Scenario 'eddsa': Issuer public keyGiven I am known as 'Issuer'Given I have my 'keyring'When I create the eddsa public keyThen print my 'eddsa public key'Rule check version 2.0.0Scenario 'eddsa': Sign credentialGiven I am known as 'Issuer'and I have my 'keyring'and I have a 'base64' named 'credential'When I create the eddsa signature of 'credential'Then print the 'eddsa signature'Run them:
zenroom -z zenroom/issuer_keygen.zen > zenroom/issuer_keyring.jsonzenroom -z -k zenroom/issuer_keyring.json zenroom/issuer_pubkey.zen > zenroom/issuer_pubkey.jsonzenroom -z -a zenroom/credential.b64.json -k zenroom/issuer_keyring.json \ zenroom/issuer_sign.zen > zenroom/credential_signature.jsonKeep these three values handy:
zenroom/issuer_pubkey.json->eddsa_public_keyzenroom/credential_signature.json->eddsa_signaturezenroom/credential.b64.json->credential
4. Create the Zenroom worker manifest
Section titled “4. Create the Zenroom worker manifest”Create workers/manifest.zenroom.json (replace the placeholders):
{ "vm": "nxcc/zenroom", "bundle": { "source": "./consent_ticket.zen" }, "identities": [ [ { "chain": "http://host.docker.internal:8545", "identity_address": "IDENTITY_CONTRACT", "identity_id": "IDENTITY_ID" }, "BRIDGE_SIGNING_SEED" ] ], "userdata": { "zenroom": { "mode": "zencode", "conf": "debug=0", "inputs": { "data": { "credential": "CREDENTIAL_BASE64", "eddsa_signature": "ISSUER_SIGNATURE", "ticket": "TICKET_BASE64" }, "keys": { "Issuer": { "eddsa_public_key": "ISSUER_PUBLIC_KEY" }, "bridge_secret": { "$secret": "BRIDGE_SIGNING_SEED", "encoding": "base64", "kdf": { "info": "nxcc-eudi-bridge", "len": 32 } } } } }, "postback": { "enabled": true, "required": true, "targets": [ { "kind": "http", "url": "http://host.docker.internal:9911/ingest", "method": "POST", "headers": { "content-type": "application/json" }, "body": { "from": "stdout" } } ] } }, "events": [ { "handler": "handleTransfer", "kind": "web3_event", "chain": 31337, "address": ["TOKEN_CONTRACT_ADDRESS"], "gateways": ["ws://host.docker.internal:8545"], "topics": [["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"]] } ]}Tip: if you generate the file with a heredoc, use a single-quoted delimiter (for example <<'JSON') so $secret is not expanded by your shell.
What to substitute:
IDENTITY_CONTRACTandIDENTITY_IDfrom the identities guide.CREDENTIAL_BASE64fromzenroom/credential.b64.json.ISSUER_SIGNATUREfromzenroom/credential_signature.json.ISSUER_PUBLIC_KEYfromzenroom/issuer_pubkey.json.TICKET_BASE64fromzenroom/ticket.b64.json.TOKEN_CONTRACT_ADDRESSfrom the blockchain events guide. If you restarted Anvil in the identities guide, redeploy the token contract and use the new address.
If you’re running the nXCC node natively (not in Docker), replace host.docker.internal with 127.0.0.1 in the chain, gateways, and postback URL fields.
The manifest requests a new identity-governed secret named BRIDGE_SIGNING_SEED. With the allow-all policy from the previous guide, the request will be approved and the VM will derive a 32-byte EdDSA seed inside the enclave.
5. Run the workflow
Section titled “5. Run the workflow”- Start a local postback receiver (for the demo):
python3 node/tests/zenroom/postback_server.py \ --host 0.0.0.0 \ --port 9911 \ --output zenroom/postback.json-
Make sure the Zenroom VM is running and postbacks are allowlisted for
host.docker.internal(or127.0.0.1if running natively) on port9911(see the Zenroom VM Reference). -
Deploy the worker:
export WORKER_ID=$(nxcc worker deploy --bundle workers/manifest.zenroom.json | jq -r '.workOrderId')echo "Worker ID: $WORKER_ID"If you do not have jq, run the deploy command without the pipe and copy the workOrderId value from the output.
- Trigger a transfer on your local chain (reuse the contract from the blockchain events guide):
cast send TOKEN_CONTRACT_ADDRESS \ "transfer(address,uint256)" \ 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \ 1000000000000000000 \ --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80- Inspect the postback output:
cat zenroom/postback.jsonYou should see a JSON payload containing ticket and ticket_signature in zenroom/postback.json. That payload is what you would forward to Chain B or a cross-chain relay.
Optionally, confirm the worker logs:
nxcc worker logs "$WORKER_ID" --tail 50The log output includes Zenroom stdout plus a summary of each postback target.
Recap and next steps
Section titled “Recap and next steps”You now have a Zenroom worker that:
- verifies an issuer-signed credential,
- derives a signing key from an identity-managed secret,
- produces a consent ticket when a chain event fires, and
- ships the signed result out through a controlled postback.
Next steps:
- Explore the full schema in the Zenroom VM Reference.