Skip to content

About

Offline Verus transaction SDK in Rust — transparent and shielded. Builds and signs bytes; your application broadcasts.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

verus-rust-sdk

Offline-first Verus SDK, in Rust. Builds and signs bytes — transparent and shielded — without a node, a wallet daemon, or a network connection. Behind the network feature it also looks up, composes and broadcasts whole operations through a public node, which never sees a key.

Warning

Early development. Nothing here is ready for mainnet funds. The crates are unpublished and the API will change without notice. See Status.

The persisted shielded wallet format changed with reorg recovery: nullifiers gained the height they were seen at, and a checkpoints window was added. A store written before that will not load — the loss is a rescan, not funds.

Why

Verus tooling in JavaScript works but cannot leave Node: it depends on forks of @bitgo/utxo-lib and friends that assume Buffer and Node's crypto, and that cannot survive an ordinary consumer install. That rules out native desktop wallets, and makes browser wallets awkward.

This is the same capability with a different centre of gravity: a Rust core that compiles anywhere — a native desktop wallet links it directly, a browser extension reaches it through wasm, and the consensus-critical bytes live in one auditable place instead of three.

Quickstart

One dependency line is a working testnet wallet:

[dependencies]
verus-sdk = { git = "https://github.com/chainvue/verus-rust-sdk", features = ["network"] }
use verus_sdk::money::Amount;
use verus_sdk::network::{send, spendable, HttpTransport, RpcClient};
use verus_sdk::verus_keys::PrivateKey;

fn main() {
    let key = PrivateKey::from_wif(&std::env::var("VERUS_WIF").unwrap()).unwrap();
    let me = key.address().to_string();

    let node = RpcClient::new(HttpTransport::new("https://api.verustest.net").unwrap());
    let funding = spendable(&node, &me).unwrap();
    println!("{me}: {} VRSCTEST at tip {}", funding.total, funding.tip);

    let sent = send(&node, &node, &key, &me, Amount::from_coins_str("0.1").unwrap()).unwrap();
    println!("txid {}", sent.txid);
}

Generate a key with cargo run -p verus-sdk --example keygen, send the printed address some VRSCTEST from any testnet wallet (or ask in the Verus Discord — testnet coins are free), and the code above runs as-is. The node is only ever asked questions and handed finished bytes — lookup, build, sign and broadcast all happen locally, and the key never leaves the process. Without the network and light features the same crate is the offline half alone: builders in, bytes out, no socket anywhere in the dependency tree.

App templates

Each online example in crates/verus-sdk/examples/ is a complete operation meant to be copied as a starting point. The header of each says whether running it spends testnet coins.

→ the examples index — all 24, grouped by what they cost you: offline and free, read-only, or spends. Plus the feature flags, every environment variable, and a sixty-second path from a clone.

Example Shows
wallet_balance spendable vs immature vs token-bearing outputs
send_online a payment, including the BroadcastUncertain re-read that makes retrying safe
register_id_online the resumable two-step VerusID registration — salt persisted before any coins move
id_login sign a challenge as a VerusID and verify it, both halves
convert_online estimate, then convert with the estimate as a floor
make_offer_online fund and sign a marketplace offer (the hex is the offer)
take_offer_online inspect an offer against the chain, then take it at the verified terms
spend_note_online a shielded spend end to end, with the anchor checked against a second source before the prover runs
receive_online the receiving half: record a birthday, scan only what matters, show incoming payments with their memos
drive_async the non-blocking driver: advance in an async loop, each round's requests fetched concurrently
airgap_watch + airgap_sign a payment split across two machines: one sees the chain and cannot sign, the other holds the key and cannot reach a node

Two examples need neither funds nor a network, and run straight after a clone:

Example Shows
decode_tx what a transaction's outputs are — identities, tokens, conversions, commitments — and, where it cannot tell, whether the thing it cannot read is able to hold money
airgap_sign the offline half of the pair above, on its own: read a plan, check what it does, sign it

The air gap is a fact about the build, not a convention

airgap_watch takes an address and calls prepare_unsigned_send, which has no parameter a key could go in. airgap_sign is compiled without the network feature and therefore links no HTTP client at all — verus-rpc's tests/offline_crates_stay_offline.rs fails the build if that stops being true. Each half is missing the capability that would make it dangerous.

What travels between them is a PartialTransaction: the transaction plus the prevout scripts and values, because the sighash commits to those and a signer told them over a separate channel could be told the wrong ones.

The two paths build the same transaction — crates/verus-flows/tests/airgap_send.rs asserts the hex is equal byte for byte to what the one-machine prepare_send produces, since a fee differing by one satoshi is a different change output and therefore a different transaction.

In a browser

wasm-pack build crates/verus-wasm --target web --out-dir pkg --release
import init, { Key, parseCoins } from "./pkg/verus_wasm.js";
await init();

const key   = Key.fromWif(wif);
const utxos = await rpc("getaddressutxos", [{ addresses: [key.address()] }]);
const tip   = await rpc("getblockcount", []);

const signed = key.send({
  utxos: utxos.map(u => ({
    txid: u.txid, vout: u.outputIndex,
    satoshis: String(u.satoshis), scriptPubKey: u.script,
  })),
  recipients: [{ address: to, satoshis: parseCoins("1.5") }],
  changeAddress: key.address(),
  expiryHeight: tip + 20,
});

await rpc("sendrawtransaction", [signed.hex]);
key.free();

verus-wasm never opens a socket and never broadcasts — JavaScript does the fetching, WebAssembly holds the key and makes the bytes.

That much has always been true. What used to stand here alongside it was that the SDK's flows could not be bound at all, because verus-rpc is synchronous and a browser has no synchronous fetch, so binding them would mean an async duplicate of every one. The premise was right and the conclusion was wrong. The answer was not a duplicate but to stop the flows doing their own I/O:

import init, { Key, Answers, parseCoins } from "@chainvue/verus-wasm";
await init();

const key = Key.fromWif(wif);
const answers = new Answers();
for (;;) {
  const step = key.planSend({ to, satoshis: parseCoins("1.5") }, answers);
  if (step.kind === "ready") {
    // `post` takes complete JSON-RPC bodies, which is what `ask` holds — so a
    // broadcast has to be wrapped, not passed as bare hex.
    await post(JSON.stringify({ method: "sendrawtransaction", params: [step.value.hex] }));
    break;
  }
  // `ask` holds complete JSON-RPC bodies; post them verbatim, concurrently.
  await Promise.all(step.ask.map(async (b) => answers.record(b, await post(b))));
}
answers.free();
key.free();

Each call runs the same Rust a native caller runs, against what is already known, and hands back either the result or the requests it still needs. Nothing it returns can broadcast: the flows were split so that the re-runnable half takes no broadcaster, which is what makes re-running it safe.

Reach for it when you want the SDK's own judgement — planSend knows a coinbase output is unspendable for a hundred blocks and that getaddressutxos does not say which outputs are coinbases, so it asks. Reach for key.send above when the application already has the data and only wants bytes signed.

Three conventions carry through the whole JS API. Money is a decimal string, never a number — satoshis: 1e8 throws, because a float64 cannot hold a satoshi count above 2^53 and 90 million coins is not a hypothetical on a chain capped at 83.5 million. Errors are thrown Errors whose .name is the cause, so catch (e) { if (e.name === "InsufficientFunds") … } works. And an unknown field is refused: expiryHieght is a transposition away from a signed transaction that never expires, so requests are rebuilt from their declared fields rather than trusted — serde's own deny_unknown_fields is inert across this boundary, and checking Object.keys was not enough either, because the deserializer reads through the prototype chain and that does not.

Covered: native and token sends, VerusID message signing and verification (log in with Verus), VDXF key derivation, identity ids, and output decoding. The package ships hand-written TypeScript declarations, pinned against the Rust types by one test and type-checked in use by another, so they cannot drift.

Verification is worth a note. verifyMessage requires currentHeight and maxAgeBlocks — the height a signature commits to is chosen by whoever signed it, so a verifier that resolves the identity at that height and stops has built an authentication bypass: a rotated-out key still works, it just stamps an older height.

Design

Correctness is proven against the network, not against itself. Four independent checks, none of which is self-referential:

  1. Real transactions a Verus daemon produced and accepted (fixtures/daemon/) re-serialize byte for byte — and the daemon's own signature on one of them verifies against a sighash this crate recomputes.
  2. The transparent path is diffed byte for byte against the daemon-proven TypeScript SDK, which signs deterministically and so is an exact oracle.
  3. A live daemon decodes what we build and computes the same transaction id (opt-in: VERUS_LIVE_DECODE=1).
  4. The network accepted a transaction this SDK built and signed. See below.

A green suite that only agrees with itself would prove nothing about consensus.

Accepted on chain

The firsts are told below; the complete ledger of every proven capability and its txid is PROVEN.md.

VRSCTEST txid 59a1097f1162b8dfd7037b5933d7156700bb0fe4230f14f003ba5f1c087206b3, mined at height 1 166 191.

The key was generated by cargo run -p verus-sdk --example keygen, the address derived by verus-keys, and the spend built and signed by verus-tx — coin selection, the 10 000 satoshi fee, the change output, the ZIP-243 sighash and the signature. The daemon only held the coins and accepted the result. The txid this SDK computed before broadcasting is the one the chain recorded, so the serialization is byte-identical to the daemon's.

Paying a VerusID

VRSCTEST txid 5e19de6d3f77b5e1f49ec92db23027d5f026db92004b026465a61bff8ab13d7e, mined at height 1 166 385. The daemon reports that output as type cryptocondition paying i8jHXEEYEQ7KEoYe6eKXBib8cUBZ6vjWSd — an identity, not an address.

Paying a VerusID is not a P2PKH output with a different hash in it. It is a CryptoCondition with no eval code, where the identity is expressed purely by the destination's kind. And destinations are not uniformly encoded: a key hash and a public key go in bare, identified by length alone, while an identity carries a leading 0x04 type byte. Writing one as a bare 20-byte hash produces a script that pays a transparent address which merely shares the identity's hash — spendable by nobody.

So the encoding was taken from consensus rather than inferred: the tests build pay-to-identity scripts for three live VRSCTEST identities and require them to equal the bytes already on chain, and a separate test asserts that dropping the type byte yields a different script.

A shield, end to end

VRSCTEST txid 35eccaca9518b2d105bd791c74dc1bd25c3377d2a18a115e5ca67f1d86c9aa79, mined at height 1 166 308: 0.0996 VRSC moved into the shielded pool.

This one is signed by two crates in sequence. verus-sapling proved the Sapling bundle and applied the binding signature over the ZIP-243 shielded sighash; verus-tx then signed the transparent input. That works because the shielded sighash has no transparent-input section and scriptSig bytes never reach hashPrevouts, hashSequence or hashOutputs — so neither signature invalidates the other.

Then the loop was closed from the other end: the transaction was fetched back off the chain and the note recovered from it with a viewing key alone — 9 960 000 zatoshi, the right address, memo intact.

cargo run -p verus-sdk --features shielded --example read_notes < spec.json

And spending it back out

VRSCTEST txid 4c16d1e73c3c9579575ae171169aee01261bdd1f35c488f972fe07c6dc20deba, mined at height 1 166 356: that same note spent back to a transparent address. The nullifier is published, the 0.0993 VRSC arrived, t → z → t is closed on chain with real value.

The hard part of a shielded spend is not the proof — it is the witness. Proving a note is in the commitment tree needs the tree's frontier as it stood immediately before the note's block, and that is the one input a signing host cannot compute for itself. Worse, it cannot generally be recovered afterwards: a frontier only moves forward, and once further notes are added the earlier state cascades away for good.

getsaplingtree returns only the tip, and this endpoint blocks z_gettreestate. What made this spend possible is that a Sapling frontier keeps its last two leaves in left/right — so while a note is still the second-to-last commitment on the chain, clearing those two recovers exactly the tree that preceded it. scan.rs pins that against real chain data: the reconstructed witness roots to the finalsaplingroot in the block header. It is a narrow trick, not a general answer; for anything older, capture the frontier before broadcasting, or ask a node.

Which is why witness_anchor exists and needs no proving parameters. A frontier from the wrong height fails nowhere else — the note decrypts, the witness builds, the proof generates, the transaction serializes — and the daemon then rejects it with 18: bad-txns-shielded-requirements-not-met. Confirmed the hard way. Check the anchor first; it costs microseconds instead of a 30-second proof.

And a transfer that never surfaces

VRSCTEST txid 9478fe9bee08db2dcf49d84288932adde89950a9c01d4f81848d0e361b6b3728, mined at height 1 166 368: vin 0, vout 0, one shielded spend, two shielded outputs, valueBalance exactly the fee. A second account then scanned that transaction with only its viewing key and found 9 870 000 zatoshi and a memo addressed to it. Nothing about the transfer is visible on the transparent side.

This one used the frontier workflow that actually generalises: getsaplingtree captured before broadcasting the shield, then checked — nothing else was shielded in the two blocks in between, and witness_anchor matched the block's finalsaplingroot before any proving ran.

One trap on the way. Your note is not reliably output 0. The Sapling builder shuffles a bundle's outputs — concealing which is the real recipient is the point of the padding output — so the index moves between transactions built the same way. The first shield here landed at index 0, the second at index 1. Find it by trial decryption; guessing builds a witness for the wrong leaf, which proves and serializes happily before failing.

And the whole thing composed, not hand-assembled

VRSCTEST txids 2e2b04df1161e220f6a3dfd80abb821e15723f42fea05dec2dc451da5bcd27f5 (z→z, two notes in one bundle) and 2db1cc11c74dc72b9e4e174659404ac58c16599a8442cf9e93e6a23c2c06ae3d (z→t), mined at 1 173 695 and 1 173 696 — and f46ed415cbbdde407ab8d45113b41542246dd3cda52d7526ff6be9903b4056e3 at 1 173 730, the same path keeping a shielded change note rather than consuming the whole one.

Every piece above was already proven and left unjoined: the builder had done z→z, z→t and multi-note spends, and witness_note assembled what it took as input. What a wallet still had to write for itself was the join — which notes, witnessed to which height, and against what. flows::shielded::spend is that join, and it is one call.

The interesting part is what it refuses. A witness is computed entirely from what one light server says: the frontier, the commitments, the tree sizes. Every continuity check in the scan compares those values against each other, so a server that lies consistently passes all of them — the module docs said so already. The spend path therefore takes a ChainReader as well as a light client, and compares the anchor it computed against the finalsaplingroot in that block's header, which consensus fixed and the light server does not control. For the transaction above: db1d7a7d… from lightwalletd, and getblock 1173695 on api.verustest.net reporting 0735f7fc… — the same 32 bytes, header display order being reversed like a txid.

That comparison happens before the first proof, which is why witness_anchor exists without the prover feature. A frontier from the wrong height fails nowhere else, and finding out from the daemon costs thirty seconds of proving to be told bad-txns-shielded-requirements-not-met.

Two other things the flow will not do. It will not estimate the fee — estimatefee prices a transaction by serialized size against a transparent fee-per-kilobyte, and a shielded transaction is mostly Groth16 proof, so the number would be confidently wrong. And it will not truncate a selection: if covering the amount would take more notes than it will prove in one transaction, it says so, because building a transaction that pays less than was asked for is the one outcome worse than an error.

The offline gate re-proves the whole shielded path on every opt-in run, with no chain and no coins:

cargo run --release -p verus-sapling --features prover,multicore --example prove_and_verify

It shields, spends the very note it created, and checks every proof and signature with SaplingVerificationContext — the verifier a consensus node runs. Every shielded flow is now also proven on chain; tokens remain byte-identical to the TypeScript SDK and never broadcast.

Money is integers. Satoshis are u64/i128 end to end, parsed from decimal strings. There is no float in the value path.

Signing is deterministic. RFC6979 with low-S normalization — no RNG on the transparent path. That is a security property and what makes byte-for-byte differential testing possible at all.

Key material is never generated for you. There is no RNG in verus-keys and no generate() for a key or a recovery phrase — the bytes are the caller's, always. (verus-flows does draw one random value of its own: the salt joining the two halves of a VerusID registration, which is not key material and never leaves the Pending it is returned in.) bip39::mnemonic_from_entropy turns 32 bytes you supply into the 24 words, so where those bytes came from stays visible in the application's own code rather than buried in a dependency. keygen and keygen_phrase show one way, four lines apart.

Keys are handled deliberately. Transparent private key material zeroizes on drop, with an opaque Debug and no PartialEq. Shielded key handling is bounded by the upstream sapling-crypto types, which do not zeroize — their Debug is redacted and nothing here serializes them, but spending keys do linger in memory, and saying otherwise would overclaim.

Outside the network/light features nothing in the dependency tree can open a socket — a test pins that. The networked crates ask questions and hand over finished bytes; they can never ask a node to sign.

Layout

Crate What it does
verus-wire v4 transaction serialization and parsing, ZIP-243 sighashes. No keys, no network.
verus-keys WIF, base58check, R/i addresses, P2PKH scripts, ECDSA
verus-tx transparent transactions: sends, tokens, VerusIDs, offers, conversions, multisig, currency launch — a facade over the six below
verus-tx-primitives money, ids, expiry, errors, CryptoCondition encoding, fee and coin-selection rules. Builds no transaction; depend on this if you only need to talk about an Amount
verus-tx-transparent transparent sends, and the assembly every other builder reuses
verus-tx-protocol what an output means: identities, VDXF keys, token outputs, reserve transfers, and the decoder that reads them back
verus-tx-currency defining and launching a currency
verus-tx-identity the VerusID lifecycle: commit, register, update, revoke, recover, spend, sign
verus-tx-market offers, partially-signed transactions, script-layer multisig
verus-sapling shielded: note scanning, ZIP-32 derivation, and t→z / z→z / z→t building behind prover
verus-rpc typed read-only JSON-RPC client + broadcast; can never ask a node to sign
verus-flows lookup → build → sign → broadcast, composed into operations a wallet calls
verus-light Sapling chain data from a lightwalletd server, over grpc-web
verus-sdk the facade you actually depend on
verus-wasm the browser binding: the same builders, from JavaScript, with the key inside the module
[dependencies]
verus-sdk = { git = "https://github.com/chainvue/verus-rust-sdk" }   # offline builders only
verus-sdk = { git = "…", features = ["network"] }                   # + node lookup, flows, broadcast
verus-sdk = { git = "…", features = ["shielded"] }                  # + find notes, derive keys
verus-sdk = { git = "…", features = ["prover"] }                    # + build shielded transactions
verus-sdk = { git = "…", features = ["light"] }                     # + scan/witness notes via lightwalletd

The shielded half is priced in two steps, because seeing your notes and spending them cost very different things. shielded is trial decryption and ZIP-32 — milliseconds, no bellman in the dependency graph. prover adds Groth16 and expects ~50 MB of Sapling parameters at runtime. A balance-only wallet takes the first and stops. The networked half is priced the same way: without network, nothing in the tree can open a socket — and a test pins that.

Status

Everything below marked on chain was built and signed by this SDK and accepted by VRSCTEST — every txid is in PROVEN.md.

Native and VerusID-addressed sends ✅ on chain, incl. through the public node via flows
Token send ✅ byte-identical to the TypeScript SDK; not yet broadcast
VerusID lifecycle — commit, register, referred, token-parent sub-ID, update, 2-of-2 multisig, revoke, recover ✅ on chain, all eight
VerusID message signing / login ✅ verified live against the daemon, both directions
Shielded — t→z, z→t, z→z, multi-note, via lightwalletd end to end ✅ on chain
Shielded spend composed through flows — scan → select → witness → anchor-check → prove → broadcast ✅ on chain, z→z and z→t
Marketplace offers — make, inspect against the chain, take ✅ on chain, including a token demand settled from a reserve input
Marketplace discovery — every offer standing against a currency or an identity ✅ parsed from both public nodes; 54 and 1843 listings
Transaction history — what already happened, which a UTXO set cannot tell you ✅ live, native and per-currency, signed movements
Pending transactions — the mempool, which neither a UTXO set nor a delta list reports ✅ live, and reached through an arity re-probe rather than one guess
Second-sourced policy reads — corroborate the one lie that costs money ✅ live, across two independent VRSCTEST nodes
Persisting a shielded wallet — the notes a scan found and bytes proved but not sent ✅ behind serde, round-trip tested against a real on-chain note
Incremental scanning — the tail only, proving it is the same chain ✅ scan_after + absorb, refusing a reorg rather than shifting note positions
Recovering from a reorg — roll back to a block the live chain still has ✅ rewind_to against kept checkpoints, so too shallow a rollback fails loudly
The shielded fee floor — the rule the daemon actually applies ✅ min_relay_fee, transcribed from GetMinRelayFeeByOutputs, not a per-kilobyte guess
Receiving — a birthday, and memos read with a viewing key alone ✅ on chain, received + memo_text, 3 blocks scanned instead of 1.17 million
Application data on a VerusID — read current, read history, publish ✅ on chain, twice over, with the erase invariant proven
Conversions ✅ on chain, exactly the estimate; burns byte-verified
Currency launch — fractional basket and centralized token, preconvert ✅ on chain
Transparent P2SH multisig, SIGHASH variants, identity timelocks ✅ byte-verified, not broadcast
Mint new supply, spend from a VerusID ✅ on chain — spent-by-identity, per consensus
wasm bindings — build and sign from JavaScript ✅ byte-identical to the TypeScript SDK, checked under node in CI
wasm flows — whole SDK operations in the browser, the page doing the fetching ✅ a planned payment is byte-identical to a directly built one, under node in CI
PBaaS / cross-chain export ⬜ needs a second system

Not published to crates.io yet — the API has not settled, and a crate name is a promise about stability.

Related

  • @chainvue/verus-sdk — the TypeScript SDK (transparent), daemon-proven, the differential oracle for this repo
  • @chainvue/verus-sapling — shielded signing for JavaScript, where the Sapling implementation ported here comes from

License

Apache-2.0. See LICENSE and NOTICE.

About

Offline Verus transaction SDK in Rust — transparent and shielded. Builds and signs bytes; your application broadcasts.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages