Integrations
Four shapes cover almost everything: add ZKAS to an existing wallet, take deposits as an exchange, accept merchant payments, or publish authenticated app data. You implement no cryptography in any of them.
Integrate once, against one HTTP API, then choose where it runs. The recommended shape for a wallet is to run the daemon on the user's own device as a library: nothing outside the app sees even a viewing key, and the code you write is identical to the hosted case.
Integration types
| Tier | What the user gets | What you run |
|---|---|---|
| On device start here | sending and viewing with nothing outside the app seeing even a viewing key | walletd embedded as a native library; only a node is external |
| Full non-custodial | sending, seed never leaves the device, daemon hosted by you or by us | keys on device, a walletd holding the viewing key, plus the signer for verify-and-sign |
| Receive + view | an address, a balance, incoming history | the same, without the signer |
| Custodial | you hold the funds | walletd with the seed, plus a payout strategy, see exchange |
Run the daemon on the device
The wallet daemon is packaged as a library, zkas-walletd-mobile, with UniFFI bindings. It starts the same zkas-walletd inside your process, bound to a loopback port, and hands you the port. Everything downstream — the REST calls, the SDK, the send flow — is then byte-for-byte what you would write against a hosted daemon.
start(nodeAddr, walletDir, secret?, socks?)- binds a free
127.0.0.1port, runs the daemon in-process, returns the port — or0on failure. Idempotent: called again while running, it returns the live port stop()- shuts the daemon down
port()- the live port,
0when not running logs()/setDebugLogs(bool)- the daemon's own log buffer, for your crash reports
nodeAddr is any ZKas node's gRPC host:port — your own, or a public one. socks routes the node connection through a SOCKS proxy, which is how the shipped wallet offers Tor. The engine sizes its thread pool to every core on the device, because the wallet is the only thing running.
// Kotlin, via the generated UniFFI bindings
val port = start(
nodeAddr = "185.147.157.125:16110", // or your own node
walletDir = context.filesDir.resolve("zkas").absolutePath,
secret = null,
socks = null, // "127.0.0.1:9050" for Tor
)
check(port != 0) { "engine failed to start - see logs()" }
// From here the daemon is just an HTTP server you happen to host.
val client = ZKasClient(baseUrl = "http://127.0.0.1:$port", network = "mainnet")- Android
scripts/build-engine.shbuilds the crate forarm64-v8a,armeabi-v7aandx86_64intojniLibs, then generates the Kotlin bindings. There is no published package for it — you build it into your app- Desktop
- the Tauri shell embeds the same
zkas_walletdlib crate - iOS
- no engine artifact today
zkas-walletd-mobile (above) is the whole daemon: it scans, proves and serves the REST API on loopback, and you build it into your app yourself. zkas-mobile is the device half only — keys, viewing key and verifyAndSignPayment — and it deliberately does not prove. That one is published, for both platforms: a signed AAR (info.zkas:zkas-mobile on GitHub Packages) and ZkasMobile.xcframework via SwiftPM at tag mobile-v0.1.3. Use zkas-mobile when a daemon does the proving; use zkas-walletd-mobile when you want no daemon at all.uniffi-bindgen reads the library's full symbol table, not .dynsym, so build unstripped and strip on the way into jniLibs. It also exits 0 when it produces nothing — assert the generated file exists or you will ship an app whose engine cannot be called.Add ZKAS to your wallet
Three ready-made pieces, no cryptography to write. They are the same three whether the daemon runs on the device or on a server — only baseUrl changes:
| Piece | Role |
|---|---|
zkas-signer (WASM) | on-device keys: derive address, derive viewing key, verify and sign a payment |
@zkas/sdk (npm) | typed client for the whole send flow, with progress callbacks and auto-chunking |
zkas-walletd | keyless daemon that scans, proves and broadcasts, hosted, or run your own |
npm install @zkas/sdk
import { ZKasClient, wasmPaymentSigner, DEFAULT_MAX_FEE_SOMPI } from "@zkas/sdk";
import { generateWallet, fvkHex, verifyAndSignPayment } from "./signer";
// 1. Keys on the device. seedHex is the secret - store it like a private key.
const { seedHex, address } = await generateWallet("mainnet");
// 2. Register the VIEWING KEY with a daemon. Never the seed.
const client = new ZKasClient({ baseUrl: WALLETD_URL, token: perWalletToken });
await client.watch(await fvkHex(seedHex), /* birthday DAA */ 0);
// 3. Balances are decimal strings. BigInt them.
const bal = BigInt((await client.balance()).balance_sompi);
// 4. Send: prepare -> verify-on-device -> sign -> submit, all inside send().
const signer = wasmPaymentSigner({ seedHex, fvkHex, verifyAndSignPayment });
const res = await client.send(
signer,
{ to: "zkas:...", amountSompi: 5_000_000_000n, maxFeeSompi: DEFAULT_MAX_FEE_SOMPI },
(stage) => console.log(stage), // "proving" | "signing" | "broadcasting"
);
console.log(res.txids, res.feeSompi);That is a complete non-custodial integration.
If you drive the WASM yourself, another framework, another language over FFI, or no dependency at all, the flow is four HTTP calls plus one local signature. The checks are the point.
// 1. Register the viewing key.
POST /api/wallet/watch { fvk_hex, birthday }
header: X-Wallet-Token: <random 16-byte hex>
// 2. Daemon builds AND PROVES an unsigned bundle. It cannot sign it.
POST /api/wallet/prepare { fvk_hex, to, amount_sompi, allow_partial: false }
-> { session, sighash, bundle_hex, spend_auth[], disclosure[],
amount_sompi_exact, fee_sompi_exact, remaining_sompi_exact }
// 3. ON DEVICE, before signing anything:
// - paid === requested and remaining === 0, else refuse
// - fee <= your ceiling, else refuse
// - recompute the sighash from bundle_hex; do NOT trust the returned one
// - check the bundle against disclosure (recipient, amounts)
// then sign each spend_auth[].alpha
// 4. Hand the signatures back.
POST /api/wallet/submit { session, sigs: [{ index, sig }] }
-> { txid, txids, tx_count, amount_sompi, fee_sompi }- Never send the seed anywhere. Register
fvk_hexonly. - Refuse a daemon that changed the amount. Compare
amount_sompi_exactto what you asked for, and requireremaining_sompi_exact === 0on the single-transaction path. - Cap the fee before signing, not after.
- Recompute the sighash from
bundle_hex. The returnedsighashis a convenience; a compromised daemon could lie about it, and blind-signing lets it authorize a payment to an attacker. - Verify the bundle against
disclosureso the device checks what it signs. - Parse balances with BigInt, they are decimal strings precisely so JavaScript cannot lose integer precision.
- Set a long client timeout. Proving takes tens of seconds; a send that looks hung is usually progressing.
Gate sending on the wallet, not on the chain
A spend is rooted at an anchor: a past shielded tree state. Consensus accepts one only inside a window — at least shielded_anchor_depth deep so the note has matured, and at most max_shielded_anchor_age old. On mainnet at 1 BPS that window is 600 to 27,000 blue units (max_shielded_anchor_age is pruning_depth / 4, about 7.5 hours).
A wallet still catching up can only anchor at its own scan position. Once that falls more than the window behind the tip, every payment it builds is rejected by every node — so check before you let someone pay:
GET /api/status
-> { spend_ready, blocks_behind, caught_up, synced, scanned_blocks, chain_len, ... }
// Enable Send on spend_ready. NOT on synced.
// spend_ready means: a tip is known, no reorg repair is in flight, the tree is
// usable, and the anchor this wallet would root a payment at is still in the
// consensus window.If you ask anyway, /api/wallet/prepare refuses with 409 and a message naming the gap, before building a proof. Show that message. The failure is not permanent and not the user's fault: it clears when the scan catches up.
prepare carries the reason in the body. Rendering it as a generic failure is how a syncing wallet ends up looking broken — and how an out-of-window anchor reaches the user as an unreadable consensus string instead of “still catching up”.