Private Wallets
A private balance is a set of unspent notes that a wallet can decrypt and spend. It is not attached to a transparent address and the node does not track it. The wallet rebuilds it from the chain every time it scans.
Three kinds of address
| Kind | Form | Used for |
|---|---|---|
| Transparent | Legacy P2PKH (t…), strict PQ (tpq1z…, OP_2 <32 bytes>) or strict ECDSA (tnq1r…, OP_3 <32 bytes>) | Funding deposits, paying fees, receiving withdrawals |
| Contract | tnc1p…, generic AuthScript v1 | The pool's state and reserve outputs. Not a wallet address |
| Private receiving | tnzk1… | Receiving notes inside the pool |
P2SH, witness v0 and generic AuthScript outputs are not accepted as funding, sponsor or payout scripts.
A transparent address does not contain what a sender needs to create a note. To pay someone inside the pool, ask for their private receiving address.
Key derivation: NeuraiZK/v2
A private wallet is derived from the same words as the transparent wallet, so the words recover it. NeuraiZK/v2 never derives private keys from a transparent address, a public key, a WIF or a BIP44 path.
| Parameter | Rule |
|---|---|
| Seed | 64 bytes from the BIP39 words and the BIP39 passphrase |
| ZK passphrase | Optional extra passphrase. Empty is a valid value |
| Family | legacy, ecdsa or pq. Mandatory, no default. Each family is an independent private wallet |
| Account | 0 to 2³¹−1, default 0 |
| Branch and index | Branch 0 for receiving addresses. Branch 1, index 0 for deposits and change |
| Pool | The verified domain and asset ID of the instance |
seed = PBKDF2-HMAC-SHA512(words, "mnemonic" || bip39Passphrase, 2048 rounds)
R = Argon2id(seed || u32le(len(Z)) || Z, salt "NeuraiZK/v2/root",
64 MiB, 3 passes, 1 lane, 64 bytes) Z = ZK passphrase
PRK = HMAC-SHA256(key "NeuraiZK/v2/account", R)
P = family || u32le(account) || u32le(branch) || u32le(index) || domain || assetId
spendSecret = HMAC-SHA256(key PRK, "NeuraiZK/v2/spend" || P || 0x01)
viewSeed = HMAC-SHA256(key PRK, "NeuraiZK/v2/view" || P || 0x01)
Passphrases are NFKD-normalized and never trimmed. From spendSecret the wallet derives owner and nk with Poseidon. From viewSeed it derives the X25519 viewing key pair with the HPKE DeriveKeyPair function, not by using the seed directly as a scalar.
- The family separates wallets. It does not make the pool post-quantum. Groth16, BN254 and X25519 are the same for every family, and one seed shared by three families is a single point of compromise.
- A wrong ZK passphrase opens a different, usually empty wallet without any error. A forgotten one cannot be recovered.
- The Argon2id parameters are part of the protocol. They are not lowered for slow devices. The 64 MiB is the Argon2 memory parameter, not the browser's peak memory.
Test vectors computed with an independent implementation pin every intermediate value for the three families.
Private receiving addresses
tag = first 4 bytes of SHA256("NeuraiZK/v1/instance" || domain || assetId)
payload = 0x01 || owner (32) || viewPublic (32) || tag (4) 69 bytes
address = Bech32m(hrp, payload) hrp: nzk mainnet, tnzk testnet, rnzk regtest
The address format stays at version 1 while the derivation is at version 2. They are independent version spaces. The addresses are longer than the usual 90-character limit, so a SegWit codec with that limit cannot decode them. The 4-byte tag catches mistakes such as an address from another pool, but it does not authenticate a pool.
The family is not encoded in the address. A Legacy wallet can assign to a PQ or ECDSA recipient and the other way round, and any recipient can later withdraw to any accepted transparent family.
The wallet hands out a new receiving address per payment. Each address has its own keys, so an observer cannot link the addresses of one wallet to each other.
Scanning
The wallet keeps no balance of its own. Every scan:
- Validates the manifest against the pinned commitment and genesis, and checks that the node's block 0 is that genesis.
- Starts at the birth transaction, or at an encrypted checkpoint, and follows the state output with
getspentinfo, one pool transaction at a time. - Checks every transition: the leaf, control block and verification key of its form, the consumed state and reserve, the publication or nullifier, the new state digest recomputed from the replayed trees and the change in the reserve.
- Tries to decrypt every new record with the viewing keys of the wallet's addresses and keeps the notes whose commitment and owner match.
- Marks a note as spent when its nullifier appears later, and reads every block hash it used again to detect a reorganization during the scan.
The scanner relies on the node for consensus, including the proofs of past transactions. It does not verify past Groth16 proofs itself.
The balance is the sum of the owned, unspent notes. It is not the pool reserve, which is the total of all users.
Address gap. The wallet tries its internal address and its receiving addresses up to 20 unused ones past the last used one. The gap is configurable up to 1000. A scan cannot know which addresses were handed out but never paid, so the wallet stores that non-secret state locally to avoid showing the same address twice.
Checkpoints. A scan produces a checkpoint with the pool trees, all records and the owned notes in plaintext. The worker encrypts it with ChaCha20-Poly1305 under a key derived from the private wallet, and the page only stores the ciphertext, for example in IndexedDB. The next scan resumes from it if it decrypts, matches the manifest and its block is still in the active chain. Otherwise the scan starts again from the pool birth. Trial decryption costs about 4 ms per record and address on a desktop computer, so checkpoints matter as the pool grows.
Recovery
To recover a private wallet on another device you need the words, the BIP39 passphrase, the ZK passphrase, the family, the account and the pool manifest. Note records are on chain, so no other backup is needed. The words do not tell which pools were used, so keep a record of pools, accounts and any extended address range.
A private wallet can also be created at random instead of from the words. It is then recovered only from its encrypted JSON file (Argon2id and ChaCha20-Poly1305) and that file's password.
From request to confirmation
The wallet is split in two. A dedicated Web Worker holds the private wallet: keys, note plaintexts, circuit witnesses and proofs. The page holds the transparent wallet and the node connection, and forwards only read-only RPC calls from the worker.
- Choose coins. A sponsor coin for the fee and, for deposits, a funding coin of the exact amount. Both must be confirmed XNA coins with an accepted script.
- Prepare. The worker rescans, checks the coins, picks the form, creates the new notes with a fresh
rho, encrypts them to their recipients and builds the witness and the transaction template. - Load parameters. It downloads the form's
.wasm, proving key and verification key, and rejects any file whose size or SHA-256 differs from the pinned value. - Prove and verify. snarkjs generates the Groth16 proof on one thread. The worker verifies it against the pinned key and compares every public input before compressing the proof to 128 bytes.
- Sign. The page signs the funding and sponsor inputs with the transparent wallet. The proof does not cover signatures, so signing does not invalidate it.
- Publish. The page checks the inputs again, runs
testmempoolaccept, records the transaction ID and broadcasts. - Confirm. After one confirmation, the next scan shows the new balance. The recipient finds its note in its own scan.
If the reply to a broadcast is lost, the wallet first checks the recorded transaction and only resends the same bytes. It never builds a replacement while the earlier transaction might still confirm: a transfer or withdrawal rebuilt on a newer state would run twice.
Proving cost
C4 uses 24 public files of about 691 MiB in total. The largest, the T4 proving key, is about 192 MiB. C5 uses about 167 MiB. File size is not peak memory, and whether files are downloaded again depends on the HTTP cache headers of the server that hosts them.
On a Pixel 3a XL phone, the median C5 proof took 6.2 s for a deposit, 15.3 s for a withdrawal, and from 23.3 s (T1) to 37.6 s (T4) for assignments. Peak browser memory was about 935 MiB. These figures cover proof generation only, not scanning, signing or confirmation.
The library
@neuraiproject/neurai-privacy implements the wallet side. The published package has no runtime dependencies. snarkjs (GPL-3.0) is passed in by the application instead of being bundled.
| Import | Runs in | Holds secrets | Purpose |
|---|---|---|---|
@neuraiproject/neurai-privacy/client | Page | No | Amounts, RPC checks, coin selection, publication and PoolWorkerClient. No cryptography |
@neuraiproject/neurai-privacy/worker | Web Worker | Yes | startPoolWorker: derivation, scanning, planning, proving and transaction building |
@neuraiproject/neurai-privacy/browser | Browser or worker | If used | Everything above plus identities, addresses, the scanner and the building blocks |
@neuraiproject/neurai-privacy | Node.js | If used | The browser entry plus a backend for the node's Python TEST wallet |
Start the worker with snarkjs and the location of the proving files:
import * as snarkjs from 'snarkjs';
import { startPoolWorker } from '@neuraiproject/neurai-privacy/worker';
startPoolWorker({
scope: self,
snarkjs,
network: 'testnet',
artifactBaseUrl: new URL('/privacy-c4/', self.location.origin).href,
singleThread: true,
});
Without a manifest, the worker uses the bundled, pinned C4 TEST deployment. An application that passes its own manifest must also pass expectedCommitment and expectedGenesis from a reviewed configuration.
From the page, a PoolWorkerClient prepares operations. Amounts are atomic units as decimal strings:
// Deposit 5 XNA.
const { funding, sponsor } = selectPoolCoins(coins, { action: 'deposit', amountAtomic: 500000000n, feeAtomic: 10000000n });
await pool.prepare({ action: 'deposit', amountAtomic: '500000000', feeAtomic: '10000000', funding, sponsor });
// Assign 2 XNA from a note. Any remainder becomes a change note.
await pool.prepare({ action: 'transfer', amountAtomic: '200000000', feeAtomic: '10000000', sponsor,
note: note.cm, recipient: 'tnzk1…' });
// Withdraw a whole note to a transparent address.
const payout = await withdrawalScript(rpc, 'tXYZ…');
await pool.prepare({ action: 'withdraw', amountAtomic: note.amountAtomic, feeAtomic: '10000000', sponsor,
note: note.cm, payout });
The result is a transaction with the pool inputs complete and the funding and sponsor inputs unsigned. The application signs them with its transparent signer, for example @neuraiproject/neurai-sign-transaction, and publishes with the library's recheckInputs, admitTransaction and publishTransaction helpers.