Skip to content

Repository files navigation

midnight-rs

The Rust SDK for the Midnight blockchain. Deploy Compact smart contracts, call circuits on-chain, manage shielded and unshielded wallets, and query the indexer, all from Rust.

Warning

This project is under active development. APIs may change without notice.

Features

  • Deploy & call Compact smart contracts: typed Rust bindings generated from contract-info.json, with on-chain circuit calls that take typed arguments and return typed values.
  • Per-contract private state: pluggable PrivateStateProvider store with password-encrypted export/import; witnesses thread the state through circuit calls (see docs/private-state.md).
  • Contract maintenance / governance: deploy with a k-of-n maintenance committee, rotate verifier keys and replace the authority via externally-signed updates (see docs/contract-maintenance-governance.md).
  • Shielded & unshielded wallet: zswap shielded coins, unshielded UTXOs, and Dust (the fee token), all synced in parallel.
  • Indexer & node clients: a typed GraphQL client for the Midnight indexer plus node RPC over subxt.
  • Ledger 8 and ledger 9: one build runs on chains of both generations, reads the generation from the chain, and carries a wallet across the hard fork (see docs/ledger-generations.md).

Prerequisites

Circuit execution and transaction building require a forked Compact compiler (RomarQ/compact) that extends contract-info.json with circuit IR. It's pinned as a git submodule and built via Nix; the Makefile wraps the fetch + build:

make build-compactc          # fetch + nix-build the pinned compactc
make compile-contracts       # recompile devnet/contracts/* with it

Override with COMPACTC=<path> to use a system-installed binary instead. To invoke the built compiler directly:

tools/compact-compiler/result/bin/compactc my_contract.compact compiled/my_contract

Quick start

use midnight_provider::{MidnightProvider, Network};
use midnight_wallet::{LocalWallet, Seed, Wallet};

mod counter {
    compact_bindgen::contract!("compiled/contract-info.json");
}

const NODE_URL: &str = "ws://localhost:9944";
const INDEXER_URL: &str = "http://localhost:8088";

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let seed = Seed::from_hex(
        "0000000000000000000000000000000000000000000000000000000000000001",
    )?;
    // The wallet syncs on its own (zswap + dust + unshielded subscriptions
    // against the indexer) and is then attached to the provider. `pinned_to`
    // is the chain-reset guard: the wallet's cursors are counts, so without a
    // pin a recreated chain resumes cleanly and serves the old chain's balance.
    let provider = MidnightProvider::new(NODE_URL, INDEXER_URL)?;
    let wallet = Wallet::sync(provider.indexer_url(), seed, Network::Undeployed)
        .pinned_to(&provider)
        .await?;
    let provider = provider.with_wallet(LocalWallet::new(wallet));

    // Deploy: the builder is awaitable directly via `IntoFuture`.
    // `.with_zk_config` points at the compiled contract's keys/zkir directory
    // (or any custom ZkConfigProvider).
    let contract = counter::Contract::deploy(&provider)
        .with_initial_state(counter::LedgerInitialState::default())
        .with_zk_config("compiled")
        .await?;

    println!("deployed at {}", contract.address());
    println!("round = {}", contract.ledger().await?.round()?);

    // Call a circuit on-chain. `circuits()` defaults to no witnesses; add
    // `.with_witnesses(&w)` for stateful witnesses. Circuits with typed return
    // values hand them back to the caller.
    let returned: u64 = contract.circuits().increment().await?;
    println!("increment returned {returned}");
    println!("round = {}", contract.ledger().await?.round()?);

    // Typed arguments are supported for on-chain calls.
    let returned: u16 = contract.circuits().increment_by(5).await?;
    println!("increment_by(5) returned {returned}");
    println!("round = {}", contract.ledger().await?.round()?);

    Ok(())
}

The contract! macro validates contract-info.json before generating anything and rejects compiler or language versions outside the supported families (currently compiler 0.30.x/0.31.x, language 0.22.x/0.23.x) with a compile error. The error names the offending version and explains how to proceed: recompile the contract with a supported Compact compiler, or widen the supported range in compact-codegen.

See examples/ for complete working examples. They run against a local devnet (node + indexer): make dev-up from the repo root starts it (or docker compose -f devnet/docker-compose.yml up -d directly), and make e2e spins the devnet up, runs every example end-to-end, and tears it down.

Connecting to an existing contract

Given a contract address (from an earlier deploy, or another process), reconnect with Contract::at. It's synchronous and makes no network calls; the returned handle fetches fresh state per call, exactly like the one deploy hands back:

let contract = counter::Contract::at(&provider, &address)
    .with_zk_config("compiled")
    .build();

let returned: u64 = contract.circuits().increment().await?;
println!("increment returned {returned}");
println!("round = {}", contract.ledger().await?.round()?);

Wallet

The provider holds a wallet through WalletFacade and the WalletBuilds of each ledger generation. midnight-wallet's Wallet, attached as LocalWallet, is the local implementation, tracking shielded coins, unshielded UTXOs, and Dust (the fee token). Wallet::sync above runs all three subscriptions in parallel and can persist progress to disk. Balance queries, transfers, Dust registration, and submission helpers all hang off MidnightProvider:

let balance = provider.balance().await.expect("wallet attached");
let pending = provider.transfer_unshielded(midnight_wallet::NIGHT, 100, &recipient).await?;
let (_, _)  = pending.wait_best().await?;

See docs/wallet.md for sync, balances, transfers, Dust registration, persistence layout, and pending-spend reservations. The examples/wallet-sync crate is a runnable end-to-end walkthrough.

Observing inclusion explicitly

The simple .await? path above submits, waits for the best block, then waits for the indexer. If you want to observe both Best and Finalized block hashes, use .send().await?:

let pending = counter::Contract::deploy(&provider)
    .with_initial_state(counter::LedgerInitialState::default())
    .with_zk_config("compiled")
    .send().await?;
println!("ext: {}", pending.extrinsic_hash_hex());
let (best, pending)      = pending.wait_best().await?;
let (finalized, pending) = pending.wait_finalized().await?;
let contract             = pending.into_contract().await?;

wait_best / wait_finalized consume self and return it back so callers re-bind through each step without let mut. Cancelling either future is safe but does not retract the transaction from the mempool; see PendingTx for details.

Failed waits surface ProviderError::Submission carrying a typed SubmitError: match its variants (Invalid is a definitive rejection, safe to rebuild and resubmit; Dropped / NodeError mean the tx may still land, so resubmitting risks a double spend; WatchStream is transport trouble; VerdictFetch means the tx landed but its events couldn't be decoded, so don't resubmit, re-query the chain) instead of parsing error text. See SubmitError for the full variant set, including the pre-watch NotSubmitted / SubmitRpc cases.

A completed wait_best / wait_finalized means the extrinsic carrying your transaction reached a block. It does not mean the transaction applied. TxInBlock::verdict says what the transaction did, read from the events the Midnight pallet emits for it, so no indexer is involved: Success means every phase applied, PartialSuccess means the guaranteed phase committed and at least one fallible segment did not, and Failure means the dispatch errored and nothing applied at all. Match all three. Failure is indistinguishable from success at the wait, which returns Ok either way. The events name the transaction but not which segment failed; for a transaction with more than one fallible segment, provider.get_transactions(TransactionOffset::hash(in_block.transaction_hash.to_string())) reads the indexer's per-segment breakdown. See docs/midnight-js-comparison.md for the two-phase model.

Crates

Crate Description
midnight-core Meta-crate, re-exports all sub-crates
midnight-provider Provider trait + MidnightProvider (indexer + node RPC + wallet ownership)
midnight-contract Typed contract interactions: deploy, call, query, prove, submit
midnight-wallet Wallet state machine: sync, balances, transfers, dust, address derivation
midnight-private-state PrivateStateProvider store for per-contract private state + signing keys, with encrypted export/import
compact-bindgen contract! macro: generates typed bindings from contract-info.json
midnight-indexer-client Typed GraphQL client for the Midnight indexer API
midnight-crypto Facade re-exporting midnight-base-crypto, midnight-curves, midnight-transient-crypto as namespaced modules
midnight-helpers A ledger_8 and a ledger_9 module over the upstream node helpers (the single pinning point for them), and the items both generations share

Development

The Makefile wraps the workflow; the CI in .github/workflows/ci.yml calls the same targets.

make ci              # the full CI gate: fmt-check + clippy -D warnings + check + test
make test            # cargo test --workspace
make dev-up          # start the local devnet (node + indexer)
make test-e2e        # devnet integration tests
make examples        # run the example crates against the devnet
make conformance     # circuit interpreter vs @midnight-ntwrk/compact-runtime goldens

The conformance suite (tests/conformance) cross-checks the Rust circuit interpreter against the canonical TypeScript Compact runtime over a corpus of compiled contracts; make conformance-regen (Node 22+) regenerates the goldens and CI fails when they drift.

Run make (no args) for the full list.

Stack size in a debug build

Building and proving a transaction runs deep. At opt-level = 0 a single deploy against the local devnet needs between 1.5 and 1.75 MiB of stack. libtest gives each test thread 2 MiB, so an unoptimized test has little room left.

Every public entry point that builds, proves, or resyncs hands back a boxed future, so a caller's own future stays a few hundred bytes instead of tens of kilobytes. Raise RUST_MIN_STACK (RUST_MIN_STACK=16777216) if a test of your own still runs out.

About

Rust SDK for the Midnight blockchain. Deploy contracts, call circuits on-chain, query state.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages