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.
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-65orsecp256k1.
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
| Amounts | to_raw / from_raw, string arithmetic only |
| Networks | aliases, ledger codes, native decimals, gas reserve |
| Keys | generation, phrase recovery, type detection, on-wire form, the Varnir identity hash |
| Errors | the full stable code taxonomy, with classification |
| Transactions | every $tx shape, byte-identical to the reference |
| Signing | delegated, dispatching on the key's own tag |
| Transport | the gateway list and the scanner API, behind a Transport trait |
| Onboarding | generate a key, mint the identity, idempotently |
| Money | streams, tokens, send, transfer, consolidate, deposits |
| Keys (ledger) | add, remove, approval thresholds; role/scopes/policy are READ back, not set - see below |
| Treasury | proposals, pending approvals, confirm and void |
| Wallets | list, get, create and assign, L1 balances |
| Exchange | orders, fills, matches, cancels, market orders, co-signed swaps |
| Commerce | invoices, 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_balancesreturns 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_keytakes a public key and its type, and nothing else.list_ledger_keysreads back whatever role, scopes and policy a key carries, andset_approval_thresholdarms 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_phraserecovers 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.speedis set only on an L1 withdrawal that went out: your choice, orEconomy. A ledger settlement or a held proposal hasNone.- 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_recordedisSome(true)if the API stored it,Some(false)if it could not, andNoneif 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 withErrorCode::Validationbefore 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.