Skip to content

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.

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:

  1. verifies the issuer signature on the credential
  2. signs a consent ticket with a bridge key derived inside the enclave
  3. 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.

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:latest

Create workers/consent_ticket.zen:

Rule check version 2.0.0
Scenario 'consent-ticket': Verify credential and sign a ticket
Given 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 signature is eddsa_signature in JSON.
  • bridge_secret will be injected from an identity secret (next section).
  • ticket and credential are base64 strings so the script stays deterministic and easy to sign.
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.json
import base64, json
data = open("zenroom/credential.json", "rb").read()
print(json.dumps({"credential": base64.b64encode(data).decode()}))
PY
python3 - <<'PY' > zenroom/ticket.b64.json
import base64, json
data = open("zenroom/ticket.json", "rb").read()
print(json.dumps({"ticket": base64.b64encode(data).decode()}))
PY

In 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:

zenroom/issuer_keygen.zen
Rule check version 2.0.0
Scenario 'eddsa': Issuer keygen
Given I am known as 'Issuer'
When I create the eddsa key
Then print my 'keyring'
zenroom/issuer_pubkey.zen
Rule check version 2.0.0
Scenario 'eddsa': Issuer public key
Given I am known as 'Issuer'
Given I have my 'keyring'
When I create the eddsa public key
Then print my 'eddsa public key'
zenroom/issuer_sign.zen
Rule check version 2.0.0
Scenario 'eddsa': Sign credential
Given 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.json
zenroom -z -k zenroom/issuer_keyring.json zenroom/issuer_pubkey.zen > zenroom/issuer_pubkey.json
zenroom -z -a zenroom/credential.b64.json -k zenroom/issuer_keyring.json \
zenroom/issuer_sign.zen > zenroom/credential_signature.json

Keep these three values handy:

  • zenroom/issuer_pubkey.json -> eddsa_public_key
  • zenroom/credential_signature.json -> eddsa_signature
  • zenroom/credential.b64.json -> credential

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_CONTRACT and IDENTITY_ID from the identities guide.
  • CREDENTIAL_BASE64 from zenroom/credential.b64.json.
  • ISSUER_SIGNATURE from zenroom/credential_signature.json.
  • ISSUER_PUBLIC_KEY from zenroom/issuer_pubkey.json.
  • TICKET_BASE64 from zenroom/ticket.b64.json.
  • TOKEN_CONTRACT_ADDRESS from 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.

  1. 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
  1. Make sure the Zenroom VM is running and postbacks are allowlisted for host.docker.internal (or 127.0.0.1 if running natively) on port 9911 (see the Zenroom VM Reference).

  2. 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.

  1. 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
  1. Inspect the postback output:
cat zenroom/postback.json

You 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 50

The log output includes Zenroom stdout plus a summary of each postback target.

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: