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.
Payments are sized in notes, not coins
Almost every surprise operators hit comes from one fact:
38 × your average note size.Two wallets with the same balance behave completely differently:
| Wallet | Avg. note | Max per transaction | Moving 100,000 ZKAS |
|---|---|---|---|
| Pool treasury, raw coinbase notes | ~57 ZKAS | ~2,166 ZKAS | 1,754 notes → 47 transactions |
| Same treasury, consolidated | ~2,166 ZKAS | ~82,300 ZKAS | 47 notes → 2 transactions |
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.
| Part | Bytes per action |
|---|---|
Action data (ActionWire) | 884 |
| Its slice of the Halo 2 proof | 2,272 |
| Total | 3,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
| Cap | Value | Applies to |
|---|---|---|
max_spends_per_tx() | 38 | notes consumed (inputs) |
max_actions_per_tx() | 38 | max(spends, outputs) — what mass is charged on |
max_payees_per_tx() | 37 | recipients 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- Default
127.0.0.1:16810Notesnode gRPC. Point at a local node — a tunnel to a remote node was a large, measurable slowdown -l, --listen- Default
127.0.0.1:8501Notesloopback by default, on purpose --wallet-dir- Default
~/.ZKas/walletsNotesone<token>.scanper 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
Nnotes 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- Default
min(2, cores)Notesglobal proof admission limit --sync-wallets N- Default
cores − 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
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
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 addressPOST /api/wallet/import{seed_hex, birthday}POST /api/wallet/watch{fvk_hex, birthday}→ watch-only. Syncs and builds proofs, holds no spend authorityGET /api/wallet/revealseed_hexof a custodial wallet; refused on watch-onlyGET /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. Readtxids— a large send splits across transactionsPOST /api/wallet/send_many{payees[], fee}→ one proof for the batch, up to 37 payees per transactionPOST /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 assend. Sessions are single-use and expirePOST /api/wallet/consolidate{fee, heal}→ merge notes.heal:truetakes the oldest notes, advancing the witness base and speeding every later send; default merges smallest-value dustGET /api/wallet/history- rows with
txid, kind, amount, memo, recipient POST /api/wallet/settings{recoverable_history}→ OVK capture. Opt-in, not retroactivePOST /api/wallet/sign{message}→ address-control proof. Discloses the FVK — use a dedicated seedPOST /api/verify{address, message, signature}→{valid, reason}; needs no walletPOST /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 > 0is normal. A wallet deliberately never ingests the last few blocks near the tip.- Gate sends on
spend_ready, never onsyncedalone. loading:truewith balance 0 means opening, not empty. Rendering it as a balance is what asks a user to retype their seed.missing_history:truemeans 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.
_fcis the human form,_sompithe 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
/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
.scanfileVerdictDoes 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) | Before | After |
|---|---|---|
| 1 note | 38.8 s | — |
| 2 notes | 94.7 s | 58.5 ms |
| 22 notes | — | 243.9 ms |
| 38 notes | — | 538.8 ms |
| At 200K leaves, per send | 29.36 s | 0.357 s (82×) |
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:
| Spends | Wall | CPU | Cores used | s / spend |
|---|---|---|---|---|
| 1 | 4.3 s | 10.4 s | 2.42× | includes one-time setup |
| 4 | 3.4 s | 10.7 s | 3.14× | 0.85 |
| 12 | 9.6 s | 30.2 s | 3.15× | 0.80 |
| 38 | 30.0 s | 93.8 s | 3.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
Sending 5,000 ZKAS from a treasury of ~57 ZKAS coinbase notes needs ~88 notes whichever way you do it:
| Approach | Notes | Txs | Proving (4 cores) | Fees |
|---|---|---|---|---|
1 call, amount_fc: 5000 | 88 | 3 (38+38+12) | ~60 s | 0.573 ZKAS |
| 3 calls of ~1,667 | 90 | 3 | ~70 s | 0.586 ZKAS |
| 88 calls of one note | 88 | 88 | ~68 s + 88 round-trips | 1.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.
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.
--insecureonly 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
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.