Skip to main content

The Varnir SDK for Rust

varnir-sdk is the Rust port of @varnir/chain-client. Shared rules (amounts, field order, $sigs, send results, recovery phrases, golden vectors) are on SDK overview and backends.

Falcon-512 keys cannot sign in Rust

Activeledger's Rust SDK signs with ML-DSA-65 and secp256k1, not with Falcon-512. Falcon is Varnir's client-side default, not something the ledger requires — the onboard transaction names its own key type in $i.otk.type — so:

  • Cannot: act for an identity whose owner key is Falcon-512. Such a key is refused when the client is constructed, not at the first signature.
  • Can: generate a key, onboard a brand-new identity, recover one from its phrase, and act as a scoped key on somebody else's identity — as long as the key is ml-dsa-65 or secp256k1.

Mint the key as ml-dsa-65 and this SDK does the whole flow itself. generate_key(None) defaults to exactly that, unlike the TypeScript and Kotlin SDKs, which default to Falcon, because this client would refuse a Falcon key.

Install​

The Activeledger dependency comes from crates.io, pinned exactly (already in the crate's Cargo.toml):

[dependencies]
activeledger = "=2.3.0"

Rust 1.88 or later. The floor comes from the dependency graph rather than from this crate's own code — reqwest pulls in the icu crates — and it was already the floor through activeledger itself.

Cargo.lock is committed, which is not the convention for a library. It was added while the Activeledger dependency was a movable git tag; now that it comes from crates.io, where versions are immutable, it can go back to the convention.

Cryptography​

Signing, key handling, canonical JSON (canonical::to_string) and transaction submission come from Activeledger's Rust SDK, as do key generation, BIP-39 validation and the recovery-phrase seed derivation; this SDK chooses the encoding and the default, not the algorithm. See Cryptography comes from Activeledger.

canonical.rs writes numbers through f64, and $expire is 1_800_000_000_000. That value is exactly representable, and it is verified to format as 1800000000000, not 1800000000000.0 or scientific notation; a different byte would be a different signed message.

The identity hash's SHA-256 comes from the sha2 crate, since Rust has none in std.

Use it​

use varnir_sdk::{Client, ClientOptions};

let mut options = ClientOptions::new(identity, private_key);
options.gateways = vec!["https://gate1-testnet.varnir.site".into()];
options.api_base_url = Some("https://scanner.varnir.site".into());
options.transfer_contract = Some(crypto_transfer_contract);
options.token_registry_stream = Some(token_registry_stream);
let client = Client::new(options)?;

// In order after the amount: the token (`None` is the chain's own coin),
// `settlement` (`None` is `Settlement::Auto`), `ref_`, `repeat`, and the
// payout `speed` (`None` is `PayoutSpeed::Economy`).
let result = client.send("niles", "TSC9KBw...", "1.5", None, None, None, false, None)?;

if result.settled() {
println!("sent: {}", result.umid);
} else if let Some(proposal) = &result.proposal {
// Over the key's co-sign threshold. NO MONEY HAS MOVED.
println!("held for a co-signature: {}", proposal.id);
}

send returns a SendResult; a held send is not an error (why).

Starting from nothing​

use varnir_sdk::{generate_key, Client, ClientOptions};

// ml-dsa-65 by default, and deliberately so - see the note at the top.
let key = generate_key(None)?;

let mut options = ClientOptions::new("", &key.private_key);
options.gateways = vec!["https://gate1-testnet.varnir.site".into()];
options.onboard_contract = Some(onboard_contract_id);

// The identity is computed from the key - sha256(publicKey + "identity"),
// the same name Varnir.Onboard gives the stream - so the empty one above is
// replaced rather than trusted. Idempotent: a key already on the ledger
// submits nothing at all.
let client = Client::onboard(options)?;

Keep key.private_key; nothing can hand it back. derive_key_pair_from_phrase(phrase, None) recovers an ml-dsa-65 key from twelve words, and Some("secp256k1") recovers a classical one, both through upstream's derivation. See Recovery phrases for identities created before the switch.

Contract ids and the token registry stream have no defaults; see Two base URLs.

Calls are blocking. The transport is a trait, so a caller with an async runtime, a proxy or a queue implements Transport and keeps their own scheduling.

Or sign without a client​

The offline half is still its own entry point, for a service that submits through something of its own:

use varnir_sdk::{transactions, sign_payload, to_raw, chain_config};

let chain = chain_config("niles").expect("known chain");
let raw = to_raw("1.5", chain.native_decimals)?; // "1500000"

let tx = transactions::send(
&identity, chain.ledger_code, "#native", &raw, Some("TSC9KBw..."),
&transfer_contract, "varnir", expire_ms,
);

// Serialise ONCE. These exact bytes are what gets signed and what gets sent -
// re-serialising is not guaranteed to reproduce them.
let message = transactions::canonical_string(&tx)?;
let signature = sign_payload(&message, &private_key)?;
let envelope = transactions::envelope(tx, &identity, &signature);

What is covered​

Amountsto_raw / from_raw, string arithmetic only
Networksaliases, ledger codes, native decimals, gas reserve
Keysgeneration, phrase recovery, type detection, on-wire form, the Varnir identity hash
Errorsthe full stable code taxonomy, with classification
Transactionsevery $tx shape, byte-identical to the reference
Signingdelegated, dispatching on the key's own tag
Transportthe gateway list and the scanner API, behind a Transport trait
Onboardinggenerate a key, mint the identity, idempotently
Moneystreams, tokens, send, transfer, consolidate, deposits
Keys (ledger)add, remove, approval thresholds; role/scopes/policy are READ back, not set - see below
Treasuryproposals, pending approvals, confirm and void
Walletslist, get, create and assign, L1 balances
Exchangeorders, fills, matches, cancels, market orders, co-signed swaps
Commerceinvoices, webhooks, deposits, receipts, transactions

The gateway list and api_base_url (the scanner origin for wallet, invoice and webhook routes) are described in Two base URLs and Where each method runs.

Transport is a trait, so the test double is a real implementation of it rather than a mock of HTTP.

Not supported​

  • Derived balances. get_balances returns the identity stream, not a list of numbers: deriving them properly needs the token-registry read and the reserved-order calculation, and a balance that omits what is already committed to your own open orders reads as spendable when it is not.
  • Attaching a role, scopes or a spend policy to a key. add_key takes a public key and its type, and nothing else. list_ledger_keys reads back whatever role, scopes and policy a key carries, and set_approval_threshold arms or suspends the identity's approval gate — but the key itself has to be provisioned from an SDK that can express a role.
  • Generating a recovery phrase. derive_key_pair_from_phrase recovers from a phrase; nothing upstream generates one.
  • Identities created under Varnir's retired phrase derivations. See Recovery phrases.

Beyond a send​

The rest of the surface reads the same way.

use varnir_sdk::{OrderLeg, SignedOrder};

// A resting order is signed off-ledger and nothing is submitted: the maker
// commits until `expire_ms`, and a taker fills it later while they are
// offline. `None` takes a fresh random salt.
let leg = OrderLeg::native("niles", "1.5");
let order = client.create_order(&leg, &leg, expire_ms, None)?;

// Posted to EVERY gateway, not the first that answers - each keeps its own
// book, so an order posted to one is invisible to a taker reading another.
client.post_order(&order)?;
// Filling someone else's. The maker's signature is what makes an entry
// fillable: the contract verifies it, so no gateway can alter the terms.
let entry = &client.get_order_book()?[0];
let signed = SignedOrder {
order: entry.order.clone(),
sig: entry.sig.clone().expect("the book carried the maker's signature"),
order_hash: entry.order_hash.clone(),
};
// `None, None` fills it whole. The two options are a partial amount and this
// taker's own floor, which the CONTRACT enforces rather than this client.
let umid = client.fill_order(&signed, None, None)?;
use varnir_sdk::NewInvoice;

// An invoice goes to the scanner API, not to the ledger: it is authenticated
// with the `X-Varnir-*` headers over a fixed route tag, not with `$sigs`.
let wallet = client.get_wallet("niles")?;
let invoice = client.create_invoice(&NewInvoice {
wallet_ids: vec![wallet.id],
name: "Consulting, May".into(),
amount: "25.00".into(),
..NewInvoice::default()
})?;
println!("{}", invoice.url);

To give an invoice a new wallet of its own on each chain, pass new_wallets instead of wallet_ids. Nobody has paid into these wallets before, so a payment can only be for this invoice. A native-coin invoice takes one chain; USDT can take several. The new wallets are yours to keep and count towards your plan's wallet limit. They are priced in credits as ordinary wallets; see How credits work.

use varnir_sdk::{NewInvoice, NewInvoiceWallets};

let invoice = client.create_invoice(&NewInvoice {
new_wallets: Some(NewInvoiceWallets {
chains: vec!["sep".into(), "niles".into()],
key_method: None, // "standard"
}),
name: "Consulting, June".into(),
amount: "25.00".into(),
currency: Some("USDT".into()),
..NewInvoice::default()
})?;
for wallet in invoice.wallets.unwrap_or_default() {
println!("{}: {}", wallet.chain, wallet.address);
}
// A scoped key for another process. Mint it `ml-dsa-65` or `secp256k1`:
// `add_key` refuses falcon-512 outright, because this SDK could never then
// sign with the key it had just attached.
client.add_key(&agent_public_key, varnir_sdk::ML_DSA_65)?;
// Confirming what a co-sign threshold held. It needs a DIFFERENT key with
// treasury authority - the client checks that, and refuses a proposal this
// same key raised, before submitting anything.
for approval in client.list_pending_approvals()? {
if !approval.is_expired {
client.confirm_transfer(&approval.id)?;
}
}

Sending with a speed​

An L1 withdrawal can ask how quickly it should go out: PayoutSpeed::Economy (within 24 hours, the default), Standard (within an hour) or Instant (straight away, on its own).

use varnir_sdk::PayoutSpeed;

let result = client.send("niles", "TSC9KBw...", "1.5", None, None, None, false, Some(PayoutSpeed::Standard))?;
println!("{:?} recorded: {:?}", result.speed, result.speed_recorded);

Payout speeds are not built on the network yet. Every withdrawal still goes out immediately and is charged at the Standard rate; the choice is only recorded.

  • result.speed is set only on an L1 withdrawal that went out: your choice, or Economy. A ledger settlement or a held proposal has None.
  • Only an explicit speed is sent anywhere, after the withdrawal is accepted, as a signed POST /api/withdrawals/speed. It never fails the send. result.speed_recorded is Some(true) if the API stored it, Some(false) if it could not, and None if you passed no speed.
  • The speed is not part of the signed transaction.
  • A speed from text goes through PayoutSpeed::parse, which refuses anything else with ErrorCode::Validation before anything is sent.

Verifying webhooks​

A receiver needs only the webhook secret, no client. Pass the raw body exactly as it arrived, and check it before you parse the JSON: re-serialised JSON is different bytes and will not match.

use std::collections::HashMap;
use varnir_sdk::verify_webhook_request;

fn accept(secret: &str, raw_body: &[u8], headers: &HashMap<String, String>) -> bool {
// Header names are matched case-insensitively. An `&http::HeaderMap`
// works here too.
verify_webhook_request(secret, raw_body, headers, None, None)
}

It returns false, never panics, for a missing or repeated header, a signature without v1=, a timestamp more than 300 seconds either side of your clock, or a signature that does not match. verify_webhook_signature takes the two header values directly, plus an optional tolerance and clock.

Committed but not confirmed: OUTCOME_UNKNOWN​

send can return Err after the transaction already committed: if the stream it created can't be read back in time (read-after-write lag, more likely under Activeledger v5's P2P commit), the SDK doesn't know whether it moved money or is held for co-sign. That's ErrorCode::OutcomeUnknown — never resend. Confirm first:

use varnir_sdk::ErrorCode;

match client.send("niles", to, "1.5", None, None, Some("invoice-42"), false, None) {
Ok(result) => { /* ... */ }
Err(err) if err.code == ErrorCode::OutcomeUnknown => {
eprintln!("commit unknown: {:?}", err.committed_umid);
// err.message names the created stream to check:
// client.get_transfer_proposal(&id)
// or look the committed umid up in the transaction history. Do not resend.
}
Err(err) => return Err(err),
}

See the error taxonomy for the full table.

Language-specific notes​

No f64 entry point​

varnir_sdk::to_raw("1.5", 18)?; // "1500000000000000000"
varnir_sdk::to_raw("0.1", 0); // Err: more precision than 0 decimals

See Amounts are strings.

Use upstream's ordered Object​

Transactions are built with upstream's ordered Object. A HashMap produces arbitrary order, and a BTreeMap sorts the keys, producing a stable, plausible, wrong order. See Field order is the contract and $sigs is keyed by stream id.

Bitcoin addresses​

Check a Bitcoin address before paying it. Networks are btc, btc-testnet4 and btc-regtest; anything else, an address for the other network, a bad checksum or a witness version we do not pay is refused with VALIDATION. Bech32 comes back lower-case. Amounts need nothing new: Bitcoin is 8 decimal places, so "0.0001" is "10000" satoshis, and send() returns the ledger umid as on every chain (core contract section 5.1). send() itself refuses a destination that is not valid for the requested network (EVM with its EIP-55 checksum, Tron, Bitcoin) with VALIDATION, before the Varnir-known lookup and before anything is signed (section 5.2). The cases are generated from packages/btc-core.

use varnir_sdk::{is_valid_btc_address, network_family, normalise_btc_address};

is_valid_btc_address("btc-testnet4", "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"); // true
normalise_btc_address("btc-testnet4", "TB1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KXPJZSX")?; // lower-case
network_family("btc-testnet4"); // Some("bitcoin"), even while the network is disabled

Tests​

./copy-vectors.sh
cargo test

87 tests: the shared golden vectors, key generation and phrase recovery, signature round trips for both key types this SDK can use, payout speed and webhook verification (against HMACs computed outside Rust), and — the half core.json cannot reach — a vector-driven test that drives every client method through a fake Transport and compares the REQUEST it built, byte for byte, against requests.json. It iterates that file rather than a hand-written list, so a recorded method nobody ported is a red test rather than silence.

The suite also asserts explicitly that the leading-dot decimal ".5" is accepted (pinned to 500000 at 6 decimals). See Golden vectors.