Build/Integrations

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 tiers4receive → fully local
Crypto to implementnonesigner WASM + SDK
Memo size512 Bencrypted, signed
Addresses per wallet1attribute by memo
Seed phrasesnoneraw 32-byte seed

Integration types

TierWhat the user getsWhat you run
On device start heresending and viewing with nothing outside the app seeing even a viewing keywalletd embedded as a native library; only a node is external
Full non-custodialsending, seed never leaves the device, daemon hosted by you or by uskeys on device, a walletd holding the viewing key, plus the signer for verify-and-sign
Receive + viewan address, a balance, incoming historythe same, without the signer
Custodialyou hold the fundswalletd 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.1 port, runs the daemon in-process, returns the port — or 0 on failure. Idempotent: called again while running, it returns the live port
stop()
shuts the daemon down
port()
the live port, 0 when 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.sh builds the crate for arm64-v8a, armeabi-v7a and x86_64 into jniLibs, 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_walletd lib crate
iOS
no engine artifact today
Two different mobile libraries — do not mix them up. 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.
If you build the bindings yourself: 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:

PieceRole
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-walletdkeyless daemon that scans, proves and broadcasts, hosted, or run your own
Trust model in one line. You send the daemon a viewing key so it can watch; only the seed can spend; and the device re-checks and signs every payment, so a hostile daemon can neither redirect funds nor inflate the fee.
Device seed · ask can spend cannot prove recomputes the sighash and checks the disclosure Daemon fvk · proving key can watch and prove cannot spend builds the bundle, signs only its dummies Chain verifies the proof, every spend-auth signature and the binding signature all or nothing fvk bundle + sighash + disclosure spend_auth_sig broadcast
The daemon proves what it cannot authorize; the device authorizes what it cannot prove. A compromised daemon leaks visibility into that wallet, never its coins, provided the device recomputes the sighash from the bundle instead of trusting the one it was handed.
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_hex only.
  • Refuse a daemon that changed the amount. Compare amount_sompi_exact to what you asked for, and require remaining_sompi_exact === 0 on the single-transaction path.
  • Cap the fee before signing, not after.
  • Recompute the sighash from bundle_hex. The returned sighash is a convenience; a compromised daemon could lie about it, and blind-signing lets it authorize a payment to an attacker.
  • Verify the bundle against disclosure so 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.

Do not surface a bare status code here. A 409 from 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”.

Integrate an exchange or custodian

Per-customer deposit addresses ship today. GET /api/wallet/address?index=N returns an unlimited supply of mutually unlinkable addresses that all fund the same wallet and are found by the same scan. A received note records which address it was paid to, so you can attribute deposits by address without asking payers to carry a memo. Landed in zkas-v1.0.9.
One wallet, an address per customer
How?index=N per customer; attribute by the address a note was paid toVerdictUse this. One wallet to sync, and nothing for the payer to get wrong
One wallet, memo as payment id
Howsame address for everyone, unique memo per customerVerdictStill valid, the destination-tag pattern. Use it when the payer cannot be told which address to use
One wallet per customer
Howa token per customer, each its own .scanVerdictDoes not scale. Every wallet syncs independently; 350 loaded wallets already strain a 4-core box
  1. Split visibility from spend authorityRun a watch-only walletd from the FVK alone to detect and credit deposits. The spending key never touches the deposit scanner. Sign withdrawals on separate infrastructure holding the seed, via prepare → submit.
  2. Credit deposits by addressIssue ?index=N per customer and match each received note to the address it was paid to, from /api/wallet/history. Memos remain available as a second channel, up to 512 bytes.
  3. Gate withdrawals on spend_readyNot on synced. And treat missing_history:true as "this balance is a lower bound", never as final.
  4. Consolidate continuouslyWithdrawal latency is 2.4 s × notes spent ÷ cores. Leave --auto-consolidate on, and issue one call for the full amount rather than splitting by hand.
  5. Decide your cold-backup conventionZKas has no BIP-39. You may derive the 32-byte seed from a mnemonic yourself and import it as seed_hex, but the derivation is your private convention and the official wallet cannot restore from it. Test the round-trip before funding anything.
Proof of reserves is scoped disclosure, not zero-knowledge. You hand an auditor the viewing key of a dedicated reserve seed. That reveals every flow of that seed permanently and irrevocably, so never use a seed holding anything else.

Accept merchant payments

The gateway issues invoices, watches for payment and fires webhooks. Same API hosted or self-hosted. Call it from your server, the API key must never reach browser code.

POST /api/v1/invoices
Authorization: Bearer YOUR_API_KEY
Idempotency-Key: order-123
Content-Type: application/json

{
  "orderId": "order-123",
  "amountSompi": "10000000",
  "expiresIn": 900,
  "requiredBlueScore": 10,
  "redirectUrl": "https://shop.example.com/order/123"
}

-> { "id": "inv_...", "address": "zkas:...", "amountSompi": "10000000",
     "status": "new", "checkoutUrl": "https://pay.example.com/checkout/inv_..." }

10000000 sompi is 0.1 ZKAS. Amounts are decimal strings so JavaScript cannot lose integer precision.

GET /api/v1/invoices/inv_...
StatusMeaning
newno payment received
partialsome value received, not enough
paidfull amount detected, confirmation policy not yet reached
confirmedfull amount received and confirmed
overpaidmore than requested received
expiredunpaid invoice expired
paidLatepayment arrived after expiry
Fulfil an order only for confirmed or overpaid.

Set ZKAS_GATEWAY_WEBHOOK_URL and ZKAS_GATEWAY_WEBHOOK_SECRET. Events arrive as JSON with:

ZKas-Signature: t=TIMESTAMP,v1=HEX_HMAC

expected = HMAC-SHA256(secret, timestamp + "." + exact_request_body)
  • Use a constant-time comparison.
  • Reject timestamps outside a short tolerance.
  • Process event ids idempotently, delivery may repeat.
const response = await fetch("/api/create-zkas-invoice", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ orderId: "order-123" })
});
const invoice = await response.json();
location.href = invoice.checkoutUrl;

gateway/integrations/web/zkas-pay.js ships the same redirect flow as a small browser helper. For WordPress, install gateway/integrations/woocommerce/zkas-gateway.php, enable ZKAS Gateway in WooCommerce payment settings, and supply the gateway URL, API key, webhook secret, conversion rate and required blue-score distance. Point the gateway webhook at https://shop.example.com/wp-json/zkas/v1/webhook.

Non-negotiables. One unique gateway address per invoice · never expose the merchant API key in frontend JavaScript · store the invoice id against your order id · use a unique, stable Idempotency-Key for every order.

Atomic multi-party settlement landed, unreleased

A sale, swap or escrow where both sides move or neither does, the seller spends their note, the buyer spends payment notes, both receive, and a public record is written, in one bundle.

Why the bundle shape allows it

Orchard authorizes each action separately: every action carries its own spend_auth_sig over its own randomized alpha, so one bundle can hold spends belonging to different people. What welds them together is one sighash over every action, one proof, and one binding signature that only verifies if the bundle's true net value equals its declared value_balance. Because spend_auth_sig is excluded from the sighash, signatures can be collected independently and cannot be lifted onto a different bundle.

The plumbing is already here. ZKas builds payments as a PCZT, build_for_pczt → create_proof → per-action sign → finalize_io → apply_binding_signature, and the finalizer applies a list of (action_index, signature) pairs without caring which key produced each one. The non-custodial flow you use today is already a partial-signing protocol with a prover that holds no spend authority.

What now exists

Built, on main, not released. prepare_multiparty() in shielded-core builds a bundle whose spends belong to different owners, and /api/bundle/{offer,prepare,submit} expose it behind --enable-multiparty (default off). Proven: a two-party sale — item note and payment note in, payment + item + a value-0 registry memo out — verifies against consensus, and a signature from the wrong party fails to assemble rather than merely failing to verify, because the PCZT checks each signature against its action’s rk as it applies it. Not proven: any settlement against a live node with funded wallets. Treat it as a primitive that exists, not a capability you can ship on.

What is still missing

A release, an end-to-end run against a live node, and SDK support. The prepare entry point now takes a viewing key per spend, but:

The privacy cost you cannot engineer away. Proving a spend needs the spender's full viewing key, the circuit takes ak, nk, rivk, the note and its Merkle path. It never needs the secret spend key, but whoever proves sees both sides. There is no MPC proving here, so one party or a coordinator holds the counterparty's viewing key for the trade.

The fix is a per-trade key. Diversified addresses give you a fresh, unlinkable address under one viewing key, but they do not help here, because the viewing key is exactly what the prover needs, and it sees every address derived from it. Scoping disclosure still means a separate seed, since there is no ZIP-32. Fund a single-use trade seed, and the viewing key you hand over reveals nothing but that trade.

What atomicity does and does not buy

Theft
Impossible. There is no partial state, the whole bundle confirms or nothing moves.
Stalling
Costs time, not money. If one party never signs, no funds move; your wallet may soft-lock the selected notes.
Double-spend race
The real residual risk. A counterparty can sign your bundle and also spend the same note elsewhere; whichever confirms first wins and the loser is dropped with its fee uncollected. Denial, not loss.
Signing window
Not unlimited. The shared anchor must be matured (~10 min) and used before it exceeds the maximum age (~7.5 h), after which consensus drops the transaction.

Note that adaptor signatures, FROST and cross-curve DLEQ are all unbuilt, so there is no cross-transaction atomic swap primitive. The single bundle is the only atomic settlement path the chain has.

Prove a payment you made

A default send is unprovable, even to the sender. The rseed is one-time, memory-only and never persisted, and out_ciphertext is sealed under a random key. If you may need to prove an outgoing payment later, you must capture it at send time; it cannot be reconstructed afterwards.
A third party can verify
Build the send through POST /api/wallet/prepare and store the returned disclosure, spend_value, out_value, out_recipient, out_rseed, rcv per action. A verifier recomputes cmx and cv_net from it and matches them against the on-chain action. No keys needed. There is no flag on /api/wallet/send that produces this
Your own records
POST /api/wallet/settings {"recoverable_history":true} attaches your outgoing viewing key at send time so your wallet can re-read its own sends. Opt-in per wallet, not retroactive, and proves nothing to a third party
Recipient proving receipt
Always possible with their IVK

Publish authenticated public app data no fork needed

For protocols that need publicly indexable data, listings, prices, token metadata, while addresses and balances stay private.

The 512-byte memo travels in enc_ciphertext, which is hashed into the shielded sighash, so memo bytes are already committed by the bundle's signatures. They are only encrypted, not unauthenticated. Make them public by publishing a viewing key:

  1. Create a registry address and publish its incoming viewing keyAnyone can now decrypt notes sent to it. Spending still requires the spend key, so this is safe.
  2. Send a value-0 note to that addressWith your protocol data in the memo. A zero-value note is a real note.
  3. Indexers recover it with scan_bundle(ivk, bundle)Integrity comes free from the signature.
Authenticated is not authorized. Signatures stop anyone altering your data, not posting it, and the sender is shielded, so an indexer cannot tell who wrote a memo. Make the announcement action spend the note that represents the object; the nullifier then proves the poster controlled it, inside the same signed bundle.

Budget: 512 B per action, each action costs 3,156 B of a 124,744 B transaction, and each announcement displaces a spend slot. Keep bulk metadata off-chain behind a content hash. Build these with the SDK, the walletd send endpoint rejects zero amounts.

SDK and repositories

@zkas/sdk · firecash/zkas-sdk
typed client: prepare/verify/sign/submit, progress, auto-chunking, fee ceiling
firecash/zkas-signer
signer WASM, key derivation and on-device verify-and-sign. No circuit, so it is small
firecash/zkas-rusty
node, consensus, shielded-core, zkas-walletd, shielded-pay CLI
firecash/zkas-payment-gateway
merchant gateway plus web and WooCommerce integrations
firecash/zkas-pool
reference pool and stratum bridge
firecash/zkas-paper-wallet
paper wallet, served from its own GitHub Pages so the page matches the audited source
Verified against zkas-rusty at zkas-v1.0.9. If this page contradicts the code, the code is right. Report an error.