Build/Wallets & walletd

Wallets & walletd

zkas-walletd scans the chain, holds witnesses, builds Halo 2 proofs and broadcasts. It can run with a viewing key only, in which case it can watch but never spend.

Spends per tx38notes, not coins
Proving cost2.4 score-seconds per note
Payees per tx37plus one change output
Fee range0.019–0.246ZKAS, by action count
Auto-consolidate500note ceiling, on by default

Payments are sized in notes, not coins

Almost every surprise operators hit comes from one fact:

A transaction can spend at most 38 notes — a count of notes, not an amount of ZKAS. How much value one transaction can move is 38 × your average note size.

Two wallets with the same balance behave completely differently:

WalletAvg. noteMax per transactionMoving 100,000 ZKAS
Pool treasury, raw coinbase notes~57 ZKAS~2,166 ZKAS1,754 notes → 47 transactions
Same treasury, consolidated~2,166 ZKAS~82,300 ZKAS47 notes → 2 transactions
Moving 100,000 ZKAS · one transaction spends at most 38 notes raw coinbase notes ~57 ZKAS each 1,754 notes → 47 transactions 47× consolidated ~2,166 ZKAS each 47 notes → 2 transactions 2× same balance, same chain, same fee schedule wall time = 2.4 core-seconds × notes spent ÷ effective cores
Cost tracks the number of notes, never the amount. This is why a fragmented treasury is slow even for a tiny payment, and why consolidation — not more cores — is the fix.

A fragmented wallet is slow no matter how small the payment, and consolidating is the only thing that changes the outcome by an order of magnitude. This is why --auto-consolidate is on by default.

Where 38 comes from

It is a bytes limit, not an economic one. Kaspa charges transient mass at 4 per serialized byte and caps a standard transaction at 500,000 mass, so the bundle must fit 500000/4 − 256 = 124,744 bytes.

PartBytes per action
Action data (ActionWire)884
Its slice of the Halo 2 proof2,272
Total3,156

Plus a 117-byte header and a 2,720-byte proof preamble, so wire_len(n) = 2837 + 3156n: n=38 → 122,765 bytes fits, n=39 → 125,921 does not. Raising it needs a consensus change and would buy nothing — proving cost per spend is flat from 4 to 38.

The three caps, and fees

CapValueApplies to
max_spends_per_tx()38notes consumed (inputs)
max_actions_per_tx()38max(spends, outputs) — what mass is charged on
max_payees_per_tx()37recipients in one send_many, plus one change output

One transaction can spend 38 notes to pay 1 recipient, or spend 2 notes to pay 37 — same mass ceiling, very different proving cost, because cost tracks spends. Minimum relay fee is charged on size, so on action count: 1–2 actions = 0.0186 ZKAS, 38 actions = 0.2458 ZKAS. The wallet raises any fee you pass to this floor automatically; passing less does not save money.

Running the daemon

zkas-walletd \
  --network mainnet \
  --rpc-server 127.0.0.1:16810 \
  --listen 127.0.0.1:8501 \
  --wallet-dir /var/lib/zkas/wallets \
  --wallet-secret "$ZKAS_WALLET_SECRET"
Every walletd flag15 entries
-s, --rpc-server
Default127.0.0.1:16810Notesnode gRPC. Point at a local node — a tunnel to a remote node was a large, measurable slowdown
-l, --listen
Default127.0.0.1:8501Notesloopback by default, on purpose
--wallet-dir
Default~/.ZKas/walletsNotesone <token>.scan per wallet
--wallet-secret
DefaultnoneNotesencrypts seeds at rest (XChaCha20-Poly1305 + Argon2). Without it seeds are stored in plaintext (mode 0600) and startup warns
--allow-origin
Defaultsame-origin onlyNotesrepeatable CORS allow-list. With none set, cross-origin browser calls are refused — this is what stops any page a user visits from reaching a daemon on their machine
--allow-default-token
DefaultfalseNotesallows the tokenless "default" wallet. Trusted single-user localhost only
--allow-remote
DefaultfalseNotesbind a non-loopback address directly. Prefer a TLS proxy
--serve-public ADDR:PORT
Default—Notesself-hosting: auto-provisioned TLS and a pairing QR for the mobile wallet. No proxy, domain or certbot. Implies a bearer token
--auto-consolidate N
Default500Noteson by default. Keeps custodial wallets under N notes by merging their oldest in the background. Wallets below the ceiling are untouched
--proof-threads N
Defaultevery coreNotesa throttle, not a tuning knob: caps threads Halo 2 proving may use so the daemon cannot starve a co-located node. Lowering it makes payments slower
--max-concurrent-proves N
Defaultmin(2, cores)Notesglobal proof admission limit
--sync-wallets N
Defaultcores − 1 (1–8)Notesmaximum concurrent wallet scans
--idle-evict SECONDS
Default1800Notesdrop an idle wallet checkpoint from RAM, not disk
--diagnose
DefaultfalseNotesoffline: print each wallet's note / base / stranded-note report, then exit. Daemon stopped
--graft TOKEN:/path/older.scan
Default—Notesoffline: repair a stranded wallet from an older snapshot of itself
Reverse proxy timeouts. Proving takes tens of seconds, so set the read timeout or clients appear to hang on a send that is actually progressing.
location /daemon/ {
    proxy_pass http://127.0.0.1:8501/;
    proxy_read_timeout 300s;
    proxy_connect_timeout 5s;
}

Authentication and key material

Every request carries X-Wallet-Token, which selects the wallet. Mint one once and keep it — it is the credential; whoever holds it controls that wallet.

TOK=$(head -c16 /dev/urandom | xxd -p)
curl -X POST -H "X-Wallet-Token: $TOK" http://127.0.0.1:8501/api/wallet/create
There are no mnemonics in ZKas. No BIP-39 anywhere — not in walletd, not in shielded-core, not in the SDK, not in the apps. The unit of key material is a raw 32-byte seed, hex-encoded. You may derive that seed from a mnemonic on your own side and post it as seed_hex, but ZKas defines no derivation standard, so the mnemonic is your private convention and the official wallet cannot restore from it. Document your derivation and test the round-trip before funding anything.

birthday is the DAA score to start scanning from. Set it to the tip for a fresh wallet — omitting it scans the whole chain for funds that do not exist.

Wallet daemon endpoints

GET /api/status
the one call a client should poll — see the example below
POST /api/wallet/create
{birthday} → seed returned once, plus address
POST /api/wallet/import
{seed_hex, birthday}
POST /api/wallet/watch
{fvk_hex, birthday} → watch-only. Syncs and builds proofs, holds no spend authority
GET /api/wallet/reveal
seed_hex of a custodial wallet; refused on watch-only
GET /api/wallet/address
this wallet's receive address. Takes no parameters and always returns the same string — see deposit attribution
GET /api/wallet/balance
total / spendable / maturing / pending
POST /api/wallet/send
{to, amount_fc|amount_sompi, memo, fee} → {txid, txids, tx_count, amount_sompi, fee_sompi}. Rejects amount 0. Read txids — a large send splits across transactions
POST /api/wallet/send_many
{payees[], fee} → one proof for the batch, up to 37 payees per transaction
POST /api/wallet/prepare
{fvk_hex, to, amount_*, fee, memo, allow_partial} → {session, sighash, bundle_hex, spend_auth[], disclosure[], remaining_sompi}
POST /api/wallet/submit
{session, sigs:[{index, sig}]} → same shape as send. Sessions are single-use and expire
POST /api/wallet/consolidate
{fee, heal} → merge notes. heal:true takes the oldest notes, advancing the witness base and speeding every later send; default merges smallest-value dust
GET /api/wallet/history
rows with txid, kind, amount, memo, recipient
POST /api/wallet/settings
{recoverable_history} → OVK capture. Opt-in, not retroactive
POST /api/wallet/sign
{message} → address-control proof. Discloses the FVK — use a dedicated seed
POST /api/verify
{address, message, signature} → {valid, reason}; needs no wallet
POST /api/wallet/rescan
rebuild the scan from the wallet birthday
GET /api/status

{
  "has_wallet": true, "network": "mainnet",
  "node_connected": true, "daa_score": 5529882,
  "synced": true, "spend_ready": true, "blocks_behind": 12,
  "loading": false, "warming": false, "watch_only": false,
  "missing_history": false, "note_count": 41,
  "balance_sompi":   "412000000000",
  "spendable_sompi": "374000000000",
  "maturing_sompi":   "38000000000",
  "pending_in_sompi": "0", "pending_out_sompi": "0",
  "updated_unix": 1790603316
}
  • blocks_behind > 0 is normal. A wallet deliberately never ingests the last few blocks near the tip.
  • Gate sends on spend_ready, never on synced alone.
  • loading:true with balance 0 means opening, not empty. Rendering it as a balance is what asks a user to retype their seed.
  • missing_history:true means the balance is a lower bound — the view was rebuilt from a pruning point below its birthday. Say so in the UI.
  • Balances are decimal strings, so parse with BigInt. _fc is the human form, _sompi the exact one.
  • Several fields are serde(default) — an older daemon omits them. Treat absent as the safe value; never hard-fail on an unknown field.

Deposit attribution: one wallet, one address

One wallet has exactly one address. /api/wallet/address returns address_at(0, External) and always the same string. There is no endpoint that mints a fresh per-customer address, and no shipped component derives a diversifier index above 0 — Orchard supports unlimited unlinkable addresses under one viewing key, but nothing in this stack exposes them yet.
One wallet per customer
Howa token per customer, each its own .scan fileVerdictDoes not scale. Every wallet syncs independently; 350 loaded wallets already strain a 4-core box. Fine for hundreds, not hundreds of thousands
One deposit wallet + memo
Howevery customer gets the same address plus a unique memo / payment idVerdictUse this. The XRP/XLM destination-tag pattern — one wallet, one scan

Memos survive on the receive side and are readable per transaction from /api/wallet/history. Full walkthrough in Integrations → exchange.

Performance — what actually costs time

The proof is not the expensive part; the Merkle witness used to be. The node keeps only the tree's ~32-node frontier, so wallets hold their own witnesses. Building one meant replaying the note-commitment stream, and a single Sinsemilla combine costs ~150–260 µs — one hash per leaf, so ~30–50 s per send on a 200K-leaf chain, growing forever.

Fixed by SubtreeCache: for a note at position p witnessed at anchor S, every sibling is a subtree either entirely below S (complete, immutable forever) or entirely above it (the empty root), with exactly one exception — the subtree straddling S, which is O(depth).

Witness build (62K-note wallet)BeforeAfter
1 note38.8 s—
2 notes94.7 s58.5 ms
22 notes—243.9 ms
38 notes—538.8 ms
At 200K leaves, per send29.36 s0.357 s (82×)
A wrong witness cache can only decline to help. Three layers: a fresh cache must reproduce the tip mirror's root or it is discarded; every path served is verified against the anchor root before use; a rejected build is remembered so an O(chain) rebuild is never re-paid per tick.

Proving is now the entire cost

Total CPU is flat whatever the thread count — 91.7 s at one thread, 93.4 s at four, for 38 spends — so the only honest formula is wall = 2.4 s × spends ÷ effective cores. Measured on 4 cores:

SpendsWallCPUCores useds / spend
14.3 s10.4 s2.42×includes one-time setup
43.4 s10.7 s3.14×0.85
129.6 s30.2 s3.15×0.80
3830.0 s93.8 s3.13×0.79

One proof already uses 3.13 of 4 cores, so there is no idle CPU to parallelise into. More cores help; more threads do not.

Paying many recipients

Always issue one call for the full amount. The daemon splits it into transactions itself. Splitting by hand is worse on every axis.

Sending 5,000 ZKAS from a treasury of ~57 ZKAS coinbase notes needs ~88 notes whichever way you do it:

ApproachNotesTxsProving (4 cores)Fees
1 call, amount_fc: 5000883 (38+38+12)~60 s0.573 ZKAS
3 calls of ~1,667903~70 s0.586 ZKAS
88 calls of one note8888~68 s + 88 round-trips1.63 ZKAS

One plan across the whole amount (selection runs once, value-descending, so separate calls round up their own change and spend more notes), chunk proofs get grouped for 1.21×, one witness pass at one anchor under one lock, and fees stop multiplying the fixed 2,837-byte header.

Never issue payment calls concurrently on the same wallet. They contend for the same cores and can select the same notes — the loser is rejected for reusing a nullifier.

All three rows are within ~15% of each other because they all spend ~88 notes. That is the point: note size is the whole game. Consolidated to ~2,166 ZKAS notes, the same 5,000 ZKAS payment is 3 notes, 1 transaction, ~7 seconds.

Health checks for a payout service

# the number that decides your payout time
curl -s -H "X-Wallet-Token: $TOK" localhost:8501/api/status | jq .note_count

grep "auto-consolidate: merged" walletd.log | tail -5       # is merging keeping up?
grep -E "send: (building|tx .* proven)" walletd.log | tail   # payout in progress

If note_count climbs steadily while auto-consolidate: merged lines are rare, merging is being starved — check whether something keeps a payment permanently in flight.

Self-hosting and the apps

kaspad --wallet-api
embeds walletd in the node with auto-provisioned TLS and a pairing QR. No reverse proxy, domain or certbot
zkas-walletd --serve-public
the same standalone. --insecure only behind a VPN, or viewing keys cross the wire in clear
Android
seed stays on device; walletd runs in-process as a native library, so nothing outside sees even a viewing key. Back up the keystore
Desktop (mac / linux)
embeds the same crate. Watch-only: the seed lives in app storage, so backup and passphrase are client-side
Web wallet
wallet.zkas.info — hosted walletd, token-scoped multi-wallet, plus the reference SPA
Paper wallet
served from GitHub Pages straight out of its own repo, so the page always matches the audited source
Tor
one-click "Connect over Tor" — an onion endpoint in front of a hosted walletd, baked into the app
Cross-device sync
a second device with the same seed adopts a twin checkpoint and clones a scan in ~32 ms
Verified against zkas-rusty at zkas-v1.0.9. Byte layouts, endpoint shapes and parameters are read from source; figures marked measured come from the live mainnet node. If this page contradicts the code, the code is right — report it.