ZCG Milestone 3 deliverable -- Zaino compact-block backend for ZAP1.
ZAP1 supports two chain data backends: direct Zebra JSON-RPC and Zaino gRPC compact-block streaming. Switching between them requires changing a single environment variable. The scanner logic, payment detection, trial decryption, and anchor verification remain identical regardless of which backend is active.
This document covers the architecture, protocol details, deployment, validation results, and migration path.
ZAP1 reads the ZAINO_GRPC_URL environment variable at startup. If set, the scanner uses the Zaino gRPC backend. If unset, it falls back to Zebra JSON-RPC via ZEBRA_RPC_URL.
# Enable Zaino backend (one env var)
export ZAINO_GRPC_URL=http://127.0.0.1:8137
# Or stay on Zebra RPC (default, no extra config needed)
# export ZEBRA_RPC_URL=http://127.0.0.1:8232The backend is created once in src/main.rs via the factory function in src/node.rs:
pub fn create_backend(config: &Config) -> Box<dyn NodeBackend> {
if let Some(ref zaino_url) = config.zaino_grpc_url {
tracing::info!("Scanner backend: Zaino gRPC at {}", zaino_url);
Box::new(ZainoBackend::new(zaino_url))
} else {
tracing::info!("Scanner backend: Zebra RPC at {}", config.zebra_rpc_url);
Box::new(ZebraRpcBackend::new(&config.zebra_rpc_url))
}
}Configuration fields in src/config.rs:
| Field | Type | Default | Purpose |
|---|---|---|---|
zebra_rpc_url |
String |
http://127.0.0.1:8232 |
Zebra JSON-RPC endpoint |
zaino_grpc_url |
Option<String> |
None |
Zaino gRPC endpoint; enables Zaino when set |
src/node.rs defines a trait that both backends implement:
#[async_trait]
pub trait NodeBackend: Send + Sync {
async fn get_chain_height(&self) -> Result<u32>;
async fn get_block_txids(&self, height: u32) -> Result<Vec<String>>;
async fn get_raw_transaction(&self, txid: &str) -> Result<Vec<u8>>;
async fn get_mempool_txids(&self) -> Result<Vec<String>>;
}- Protocol: HTTP JSON-RPC
- Port: 8232 (default)
- Methods:
getblockchaininfo,getblock,getrawtransaction,getrawmempool - Fetches full block data per height, one RPC call per block
- Protocol: gRPC (HTTP/2, protobuf)
- Port: 8137 (default)
- Service:
CompactTxStreamer(lightwalletd-compatible) - Methods:
GetLatestBlock,GetBlock,GetTransaction,GetMempoolTx - Uses compact block representations; raw tx fetched only when needed
The scanner in src/scanner.rs calls only the NodeBackend trait methods. It does not know or care which backend is active. Payment detection, invoice matching, leaf insertion, and anchor verification all operate on the same data structures regardless of transport.
ZAP1 compiles two protobuf files at build time via tonic-build (see build.rs):
| File | Package | Purpose |
|---|---|---|
proto/service.proto |
cash.z.wallet.sdk.rpc |
CompactTxStreamer service definition |
proto/compact_formats.proto |
cash.z.wallet.sdk.rpc |
CompactBlock, CompactTx, CompactSaplingOutput, CompactOrchardAction |
| Method | Request | Response | Usage |
|---|---|---|---|
GetLatestBlock |
ChainSpec |
BlockID |
Chain tip height |
GetBlock |
BlockID |
CompactBlock |
Block txids for scanner |
GetBlockRange |
BlockRange |
stream CompactBlock |
Batch block streaming during catch-up |
GetTransaction |
TxFilter |
RawTransaction |
Full raw tx for memo extraction and anchor verification |
GetMempoolTx |
Exclude |
stream CompactTx |
Mempool monitoring for unconfirmed payments |
GetLightdInfo |
Empty |
LightdInfo |
Server version, chain, block height |
GetLatestTreeState |
Empty |
TreeState |
Sapling + Orchard note commitment tree state |
Build configuration (build.rs):
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.build_server(false) // client only
.compile_protos(
&["proto/service.proto", "proto/compact_formats.proto"],
&["proto"],
)?;
Ok(())
}- Zaino 0.2.0 (ZingoLabs ZainoD) on
127.0.0.1:8137 - ZainoDB: 96 GB at
/mnt/zebra/zaino-db - Connected to Zebra 4.3.0 at
127.0.0.1:8232 - Chain tip at validation: 3,289,945 (fully synced)
The zaino_adapter binary (src/bin/zaino_adapter.rs) fetches all ZAP1 anchors from the API, then verifies each one is retrievable via Zaino gRPC. For every anchor it:
- Fetches the compact block at the anchor height via
GetBlock - Confirms the anchor txid appears in the block
- Fetches the full raw transaction via
GetTransaction - Verifies the transaction data is non-empty
$ cargo run --bin zaino_adapter -- --zaino-url http://127.0.0.1:8137
zaino adapter: connecting to http://127.0.0.1:8137
anchors from API: 4
zaino chain tip: 3289945
pass: anchor block=3286631 txid=... leaves=1 tx_bytes=9482
pass: anchor block=3286633 txid=... leaves=2 tx_bytes=9531
pass: anchor block=3288117 txid=... leaves=3 tx_bytes=9498
pass: anchor block=3290002 txid=... leaves=4 tx_bytes=9445
range: streamed 3372 blocks from 3286631 to 3290002 via zaino
result: 4 pass, 0 fail, 4 total anchors
| Method | Result |
|---|---|
GetLightdInfo |
Version 0.2.0, chain main, block 3,289,945 |
GetLatestBlock |
Height 3,289,945, hash returned |
GetBlock(3286631) |
First anchor block, compact tx data present |
GetBlockRange(3286631-3286633) |
3 blocks streamed correctly |
GetTransaction(ba63e44f...) |
Anchor tx at height 3,290,002, full raw data |
GetLatestTreeState |
Sapling + Orchard tree state at tip |
Block range streaming from the first anchor block to the last (3,286,631 to 3,290,002) returned 3,372 compact blocks. All blocks contained valid compact transaction data with correct txid encoding (protocol-order bytes reversed to display-order hex).
- Zebra 4.3.0+ synced to mainnet, RPC on
127.0.0.1:8232 - Disk space for Zaino index DB (allocate 100+ GB for mainnet)
git clone https://github.com/ZingoLabs/zaino.git
cd zaino
cargo build --release --bin zainodZaino reads a TOML config file. Production config for this deployment:
backend = "fetch"
zebra_db_path = "<zebra-chain-data-path>"
network = "Mainnet"
[grpc_settings]
listen_address = "127.0.0.1:8137"
[validator_settings]
validator_grpc_listen_address = "127.0.0.1:18230"
validator_jsonrpc_listen_address = "127.0.0.1:8232"
validator_user = "xxxxxx"
validator_password = "xxxxxx"
[storage.database]
path = "<zaino-db-path>"
size = 384Key configuration notes:
validator_jsonrpc_listen_addressmust match Zebra's actual RPC port (8232, not the older default18232)- Zaino does not replace Zebra; it sits alongside Zebra and re-exposes chain data over gRPC
- The
listen_addressis the gRPC endpoint that ZAP1 connects to
# 1. Ensure Zebra is synced and serving RPC
zebrad start
# 2. Create Zaino DB directory
mkdir -p /mnt/zebra/zaino-db
# 3. Start Zaino
zainod start --config zainod.toml
# 4. Verify Zaino is responding
grpcurl -plaintext 127.0.0.1:8137 cash.z.wallet.sdk.rpc.CompactTxStreamer/GetLightdInfo[Unit]
Description=Zaino indexer (zainod)
After=network.target zebrad.service
[Service]
ExecStart=/usr/local/bin/zainod start --config /etc/zaino/zainod.toml
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target# Switch to Zaino
export ZAINO_GRPC_URL=http://127.0.0.1:8137
# Restart ZAP1
systemctl restart zap1 # or however you run itThat is the entire change. No code modifications, no recompilation, no database migration.
# Switch back to Zebra RPC
unset ZAINO_GRPC_URL
# Restart ZAP1
systemctl restart zap1- Run Zaino in parallel with Zebra (Zaino needs Zebra anyway).
- Keep production ZAP1 on Zebra RPC initially.
- Start a staging ZAP1 instance with
ZAINO_GRPC_URLset. - Compare: chain height, detected payments, mempool detections, scanner lag.
- If parity holds, flip production to Zaino.
- Retain Zebra RPC as the instant rollback path.
| Zebra RPC | Zaino gRPC | |
|---|---|---|
| Protocol | HTTP JSON-RPC | gRPC (HTTP/2, protobuf) |
| Block fetching | getblock per height (polling) |
GetBlockRange streaming |
| Bandwidth | Full block data per request | Compact blocks (smaller payloads) |
| Latency | One round-trip per block | Streaming, lower per-block overhead |
| Mempool | getrawmempool polling |
GetMempoolTx streaming |
| Raw tx access | getrawtransaction |
GetTransaction |
| Maturity | Production (Zebra 4.3.0) | Production (Zaino 0.2.0, validated on mainnet) |
| Extra infra | None (Zebra only) | Requires Zaino alongside Zebra |
| Best for | Simple setups, minimal dependencies | Higher throughput, wallet-compatible scanning |
+------------------+
| ZAP1 |
| src/scanner.rs |
+--------+---------+
|
NodeBackend trait
(src/node.rs)
|
+--------------+--------------+
| |
ZebraRpcBackend ZainoBackend
(JSON-RPC/HTTP) (gRPC/HTTP2)
| |
v v
+--------+--------+ +--------+--------+
| Zebra 4.3.0 | | Zaino 0.2.0 |
| 127.0.0.1:8232 | | 127.0.0.1:8137 |
+-----------------+ +--------+--------+
|
reads from Zebra
chain state + RPC
The dual-backend pattern used by ZAP1 is reusable. To integrate Zaino into your own application:
- Define a backend trait around the chain data your app needs (tip height, block txids, raw tx, mempool).
- Implement the trait for Zebra RPC first (your existing path).
- Add a Zaino implementation using the
CompactTxStreamergRPC service. - Switch via environment variable or config, not code changes.
- The proto files in
zap1/proto/are the standard lightwalletd definitions and can be reused directly.
Design rule: never mix chain-transport logic into application business logic. Keep scanning, trial decryption, invoice matching, and proof generation independent of the backend.
| File | Purpose |
|---|---|
src/node.rs |
NodeBackend trait, ZebraRpcBackend, ZainoBackend implementations |
src/config.rs |
Config struct with zaino_grpc_url field |
src/main.rs |
Backend creation at startup via create_backend() |
src/scanner.rs |
Scanner loop (backend-agnostic) |
src/bin/zaino_adapter.rs |
Validation tool: verifies all anchors via Zaino gRPC |
proto/service.proto |
CompactTxStreamer gRPC service definition |
proto/compact_formats.proto |
CompactBlock, CompactTx message definitions |
build.rs |
tonic-build proto compilation config |