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 / note
Payees per tx37plus a change output
Fee range0.019–0.246ZKAS, per action
Auto-consolidate500notes, on by default

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

GET /api/wallet/address            # index 0 — the wallet's own address
GET /api/wallet/address?index=7    # a distinct, unlinkable address for customer 7

Every diversified address of a wallet is paid into the same notes and the same balance, and is found by the same scan: trial decryption uses the incoming viewing key, which does not depend on the diversifier. A received note records the diversified address it was sent to in its history row, so a service can hand each customer their own address and attribute deposits by address, with no memo required.

One bound, and it only affects mining payouts. Coinbase recognition pre-derives indices 0..255 (COINBASE_DIVERSIFIER_SCAN = 256), because a coinbase publishes its recipient in the clear and is matched by raw equality rather than trial decryption. A coinbase paid to an index ≥ 256 is not credited. Ordinary receives work at any index. The limit is pinned by a test rather than left to chance, if you pay miners to diversified addresses, stay inside the window.
One wallet, an address per customer
How?index=N, one scan, attribution by addressVerdictUse this. One wallet to sync, no memo discipline required of the payer
One wallet, memo as a payment id
Howsame address for everyone, unique memo per customerVerdictStill valid, the destination-tag pattern. Useful when the payer's wallet 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

Full walkthrough in Integrations → exchange.

Performance

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. If this page contradicts the code, the code is right. Report an error.