zanolib 0.2.0

Zano wallet library: address handling, transaction parsing/signing, deposit scanning and threshold (MPC) signing.
Documentation

Rust

zanolib

Rust library for Zano cryptocurrency operations, including address parsing, offline transaction signing, deposit scanning, and zero-knowledge proof generation.

Features

  • Address handling — parse, create, and manipulate Zano addresses (standard, integrated, auditable)
  • Offline transaction signing — sign transactions generated by a view-only simplewallet without exposing the spend key to the network
  • Deposit scanning — detect incoming outputs for a wallet directly from a Zano daemon's JSON-RPC, decode amounts/assets, and recover integrated-address payment IDs (view key only)
  • Sending — build, sign and broadcast transactions from scanned deposits (decoy ring selection, multi-asset, fee handling) entirely from the library
  • Threshold spend key (MPC) — generate and hold the spend key as {t,n} shares across machines via FROST-ed25519 (tsslib); the secret is never reconstructed
  • Cryptographic primitives — CLSAG-GGX ring signatures, Bulletproof+ range proofs, BGE asset surjection proofs, balance proofs
  • Serialization — full binary serialization compatible with Zano's C++ implementation, plus the epee portable-storage codec used by the daemon's .bin endpoints

All cryptography comes from purecrypto; there is no foreign code in the dependency tree.

Install

cargo add zanolib

Cargo features (all on by default):

feature pulls in provides
rpc rsurl, base64 the daemon client, deposit scanner and sweep_to
mpc tsslib the threshold spend key

cargo build --no-default-features gives the pure offline core: addresses, wallets, parsing, signing and scanning.

Module map

module contents
crypto curve helpers, hash-to-point, key derivation, CLSAG-GGX, Bulletproof+, BGE
base varints, the binary codec, transactions and their variant payloads
epee the portable-storage codec used by the daemon's .bin endpoints
proof transaction-wide balance, range and asset surjection proofs
rpc JSON-RPC client and deposit scanner
mpc threshold spend key via FROST

Addresses, wallets, scanning and signing live at the crate root.

Offline Signatures

Compatible Zano version: 2.1.0.3822.1.19.477 (the ZC→ZC transaction format is unchanged across this range)

This library allows loading unsigned transactions produced by a view-only simplewallet and signing them offline. There are a few caveats:

  • The unsigned transaction is a binary format not meant to be portable — it only works between specific versions of Zano. This library is tested against the versions above and may not work with newer versions. Blob files aren't versioned so structure changes cannot be detected automatically.
  • For now this library only supports ZC→ZC transactions.

Usage

use zanolib::rng::OsRng;
use zanolib::Wallet;

// Initialize a wallet from a securely stored spend secret.
// Pass flags = 1 for auditable wallets.
let wallet = Wallet::load_spend_secret(&secret, 0)?;

// Parse the unsigned transaction produced by simplewallet.
let ftp = wallet.parse_ftp(&unsigned_tx_blob)?;

// Inspect `ftp` to verify this is the transaction you want to sign.
// ...

// Sign it.
let finalized = wallet.sign(&mut OsRng, &ftp, None)?;

// Encrypt and write to disk for broadcast via the view-only wallet.
std::fs::write("zano_tx_signed", wallet.encrypt(&finalized)?)?;

Deposit Scanning

zanolib can scan the chain for outputs paid to a wallet without running a local daemon — it fetches raw transactions over JSON-RPC from any Zano node and detects/decodes received outputs locally using only the view secret key. The caller stores each watched address together with the last block height it scanned, then resumes from there.

Wallet::scan_tx is the pure, offline core (it takes a parsed transaction and returns the owned outputs plus the decrypted payment ID). The rpc module adds a JSON-RPC client and a Scanner that walks confirmed block ranges.

use zanolib::rpc::{Deposit, Scanner};
use zanolib::Wallet;

// A view-only wallet is enough to detect deposits.
let wallet = view_wallet_data.load_view_wallet()?;

// "" = public modchain gateway; or a daemon base URL like "http://127.0.0.1:11211"
let scanner = Scanner::new(wallet, "");

// Resume from the last height you persisted, up to the current confirmed tip.
let (last, res) = scanner.scan_range(from_height, to_height, |d: Deposit| {
    // d.out.amount, d.out.asset_id, d.out.is_native — what was received
    // d.payment_id — integrated-address payment id (attributes the deposit to a user)
    // d.tx_id, d.height, d.global_index — where it landed (global_index lets you spend it later)
    credit_user(d);
    Ok(())
});
res?;
// persist `last` as the new watermark; on restart re-scan a small tail to absorb reorgs

Notes:

  • Attribution: give each user a distinct integrated address; d.payment_id maps a deposit back to the user. Per-wallet addresses work too (then payment IDs are simply absent).
  • Assets: native ZANO and confidential assets are both decoded; d.out.asset_id / d.out.is_native identify which.
  • Confirmations / reorgs: scan only finalized heights and re-scan a small tail on restart. Mempool (0-conf) scanning and spent/key-image tracking are not included.

Sending

With a full wallet (spend secret), the library can build, sign and broadcast a transaction that spends scanned deposits — including decoy ring selection (via getrandom_outs3.bin), multi-asset transfers, and fee handling. Client::sweep_to sends a set of deposits to a recipient, deducting the fee from the native amount:

use zanolib::rpc::Client;

let wallet = Wallet::load_spend_secret(&secret, 0)?; // full wallet (can sign)
let client = Client::new(""); // "" = modchain gateway; or a daemon base URL

// deposits: gathered from scanning (see Deposit Scanning)
let res = client.sweep_to(
    &wallet,
    &deposits,
    "Zx...recipient...", // destination address
    10_000_000_000,      // fee in native atomic units (0.01 ZANO)
    true,                // broadcast
)?;
// res.status == Some("OK") once accepted into the mempool

For finer control, Wallet::build_transfer assembles and signs an arbitrary set of TransferInputs and TransferDests (you supply the decoy rings), returning a ready-to-serialize transaction. Native ZANO and confidential assets can be mixed in a single transaction; the fee is the native surplus and every non-native asset must balance.

Roadmap / not yet implemented

The sending path currently spends an explicit, caller-provided set of deposits. Planned follow-ups:

  • Automatic coin/input selection — pick which deposits to spend for a target amount (per asset), instead of requiring the caller to choose.
  • Change splitting — produce change outputs (and sensible output splitting) rather than requiring inputs to sum exactly to outputs + fee.
  • Payment IDs on outgoing transactions — attach an (encrypted) integrated-address payment ID when sending, not just recover it when scanning.
  • Gamma decoy distribution — match Zano's decoy_selection_generator gamma curve for ring members; the current selection samples uniformly below the real output's index (valid, but not privacy-optimal).

Threshold signing (MPC)

The spend key can be generated and held as a {t,n} threshold across machines using FROST-ed25519 (via tsslib), so the spend secret never exists whole on any single host. The mpc module converts a FROST group key into a Zano spend public key and address:

use zanolib::mpc;

// after running the frosttss DKG across N machines, each holds a tsslib::frosttss::Key:
let addr = mpc::address(&key.group_public_key, &view_public_key, 0)?;

The view key is independent of the threshold spend key: Zano's view = keccak(spend) derivation can't run under MPC because the spend secret is never reconstructed. Instead of supplying a random view key out of band, the committee derives a deterministic one from the shares with ThresholdInputSigner::derive_view_secret(), which runs tsslib's threshold key-image ceremony — a distributed PRF — under the identifier ZANO_THRESHOLD_VIEWKEY: each party contributes W_i = λ_i·s_i·P over a point P bound to the identifier and the group public key, the partials sum to V = x·P, and the view secret is HashToScalar(identifier, V) (Keccak-256, matching the rest of Zano). The shared secret never assembles, every party derives the identical value, and each partial carries a DLEQ proof so a wrong contribution is caught and its sender named. Partials travel point-to-point rather than by broadcast, since t+1 of them would reconstruct V. The result is stable across runs and across any qualifying committee, so a threshold wallet has a fixed view key and therefore a fixed address. A Zano address is just (spend_pub, view_pub), so this is valid on-chain — at the cost of seed-phrase recovery.

The derived value differs from the removed Go implementation's DeriveViewSecret(), which hand-rolled the same construction with Zano's own hash-to-point, no DLEQ proofs and broadcast partials. Nothing to migrate: no threshold view key was ever deployed from the Go path.

Implemented and tested with local in-process peers ({t,n}, e.g. 2-of-3):

  • Distributed key generation → address (mpc::address).
  • Threshold key image — partial key images combine to the same key image for any signing subset (mpc::partial_key_image), as Zano's double-spend detection requires.
  • Deterministic threshold view keyThresholdInputSigner::derive_view_secret() derives a stable view secret from the shares (see above); tested to be identical across all parties, reproducible across runs, and unchanged when a different qualifying committee runs the ceremony.
  • Threshold CLSAG-GGX signing — a two-round protocol computes the key image and layer-0 response from the shares; the spend secret is never reconstructed, and the resulting signature verifies with crypto::verify_clsag_ggx. It runs over tsslib's own transport (the same MessageBroker / json_wrap machinery frosttss uses), so it plugs into whatever transport you already use for FROST — the X-layer nonce and decoy responses are derived deterministically from the round-1 transcript so every party reaches the identical signature with no coordinator. (ClsagParty / ClsagCoordinator expose the same math for non-broker drivers.)
  • Wallet integrationWallet::sign_with(rnd, ftp, one_time_key, signer) signs a transfer through the InputSigner trait, so the spend-key operations (key image + CLSAG) are delegated to either a local secret (LocalInputSigner, the default sign path) or an MPC session. mpc::ThresholdInputSigner implements that trait over tsslib's broker: the committee jointly produces each input's key image and signature without reconstructing the spend secret. (Tested with all parties signing concurrently: the threshold key image matches a local signer's and the CLSAG verifies.)

Remaining: an end-to-end threshold-signed broadcast (all committee members running sign_with on a shared transaction — needs a shared one-time key and deterministic proof randomness so every party builds the identical tx, plus frosttss key import/reshare to put an existing wallet's secret under threshold).

Verifying the port

This crate replaces a Go implementation, removed once the port was complete (it remains in the history, last present at 9269204). The test suite pins the Rust code to that reference behaviour at three levels:

  • Known-answer vectors (tests/crypto_vectors.rs) — ~1,900 vectors for hash_to_point, hash_to_ec, hash_to_scalar, key derivations and key images, carried over from the Go tests (which in turn track Zano's C++ code).
  • Real mainnet blobs (tests/onchain.rs) — captured coinbase and transfer transactions must parse, re-serialize to the exact same bytes, and hash to their known transaction ids.
  • End-to-end signing (tests/sign_scan.rs) — a committed finalized_tx fixture is decrypted, re-signed with a fixed RNG, and the resulting 7,285-byte transaction (prefix + CLSAG signatures + range, surjection and balance proofs) is compared against a stored fingerprint. That fingerprint is the transaction the Go implementation this crate replaces produced from the same inputs, so it pins the port to the behaviour that was in production.
cargo test --all-features
cargo test --no-default-features

License

See LICENSE file.