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
networkfeature 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.
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.
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.
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 |
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.
wasm-pack build crates/verus-wasm --target web --out-dir pkg --releaseimport 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.
Correctness is proven against the network, not against itself. Four independent checks, none of which is self-referential:
- 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. - The transparent path is diffed byte for byte against the daemon-proven TypeScript SDK, which signs deterministically and so is an exact oracle.
- A live daemon decodes what we build and computes the same transaction id
(opt-in:
VERUS_LIVE_DECODE=1). - 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.
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.
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.
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.jsonVRSCTEST 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.
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.
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_verifyIt 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.
| 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 lightwalletdThe 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.
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.
@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