Skip to main content

Keys and identities

Every key, secret, identity, and capability zkCoins defines — first how they relate to one another, then the complete list with each value's origin, holder, and reach. Each value is defined normatively in the Specification; this page explains and indexes those definitions.

Every long-term key of the account descends from one seed. The name does not — it is a label the API and app layers issue and the holder attests — and neither does the fresh randomness a transition draws, nor any key belonging to another party. That split is what the rest of this page organises itself around.

How the keys fit together

The inventory below lists each value on its own. This section explains how they relate — what produces what, and what binds what to what.

Three kinds of arrow, and the difference between them is the whole point. Solid is derivation — one value computed from another, so the target cannot exist without the source. Thick is carriage — the profile publishes a copy of a value it did not compute; a wrong copy is caught by the signatures and by nothing else. Dotted is a signature by sk₀, the key that never leaves the device. The name hangs off the profile by carriage and off sk₀ by signature; nothing derives it.

One seed, one tree, five jobs

A user writes down twelve words. Those become one long secret number, the seed, and every other key is computed from it by a fixed recipe. That is why the twelve words are enough to restore an account: whoever holds the seed can recompute every long-term key it owns. What the seed does not cover is the name, the fresh per-transition randomness esk and npk_rand, and any key belonging to someone else — a grantee, a replica holder, a network trust root. nav_rand is the one piece of randomness that is reproducible from the seed, which is why it has a branch of its own.

The tree exists because an account does five separable jobs, and only the first of them must stay on the user's own device:

JobBranchWho should be able to do it
Spend moneyA/0'the user's device alone
See incoming coinsA/1'also the node, so it can watch around the clock
Speak — chat, profile, discoveryA/2'also the node, so the account is reachable while the user sleeps
Void spent coinsA/3'also the node, so it can build proofs
Reproduce a past proof's randomnessA/4'also the node — op_secret keys the nav_rand derivation, so a rebuilt node can reopen any prior commitment

The separations are hardened, which makes the tree a one-way street: a parent can compute its children, but a child can reach neither its parent nor its siblings. A node holding the view, speak, void, and reproduce branches therefore cannot compute the spend branch. It sees everything and can take nothing.

Two recurring notions

Secret and public key. From a secret number sk a public number Pk can be computed, never the reverse. Lower case means secret (sk₀, ivk, op, nk); a capitalised name or a pubkey suffix means public (Pk₀, IVPK, op_pubkey). The public half can be printed anywhere.

Commitment. A hash of a secret. nk_commit is a commitment to nk: it can be published, it proves later that the same nk is meant, and it reveals nothing about it — a sealed bet.

The address is two things in one

address = hash( Pk₀ ‖ nk_commit )
│ │
"who may spend" "which serial numbers belong to this account"

Pk₀ alone would say who may sign. Folding in nk_commit also fixes which nullifier the account will use to void a coin. Without that second ingredient a holder could register two accounts, with two different nk, under one address — the correspondence address ↔ account would stop being one-to-one. Double-spending is closed separately, by Bitcoin first-occurrence on Pk₀; nk_commit closes the equivocation. Because both are baked in at account creation, the address is immutable for the life of the account.

Note what is not in the address: neither IVPK nor op_pubkey. Those are joined to it by signature, not by arithmetic — see Two identities below.

Paying someone you have never spoken to

A sender knows only the recipient's public IVPK. That is enough to produce a secret only the recipient can reconstruct:

sender draws: esk a throwaway secret, one per coin
sender computes: epk = esk·G published alongside the coin
sender computes: ss = esk × IVPK a shared secret
recipient: ss = ivk × epk the same value, by a different route

Two different computations, one result, no interaction. A relay that sees epk cannot reach ss, because that would take either esk or ivk.

From ss come two separate things:

ss ──┬── K_tx the key that opens this one coin SECRET
└── detect_tag a sticker saying "this one is yours" PUBLIC

The sticker has to be public, or the recipient would have to trial-decrypt every message on the network. Instead it does one small computation per candidate and knows immediately whether the message concerns it. And because the sender draws a fresh esk for every coin, every sticker differs — so no observer can group two payments as going to the same recipient.

The two are safe to derive from one ss because they are separated by distinct context strings and distinct primitives: the public sticker does not lead back to the secret key.

Why the spend key changes every time

Each time the account moves, a new spend key is used — sk₀, then sk₁, sk₂, and so on. The old public key is spent and written to Bitcoin, which is what marks that state as consumed:

state 0 ──[Pk₀ published]──▶ state 1 ──[Pk₁]──▶ state 2 ──▶ …

A chain observer sees a sequence of unrelated numbers. It cannot attribute Pk₀ and Pk₁ to one account, because the next key never appears in the clear — it lives only inside a hashed, off-chain state.

Pk₀ is the exception: it is fixed in the address. That is why sk₀ — the very first key — is the one that proves ownership of this address. It signs addr_sig, name_sig, and every OwnershipProof. The rotating skᵢ sign only the individual transition.

Two identities, and the two signatures that join them

An account has two identities, both seed-derived, both immutable, with different jobs:

addressop_pubkey
Iswhere money goesthe Nostr identity — who speaks, sends, and is found
Appears inprofiles, invoices, coins, and proofs — not on Bitcoin, which carries the per-transition nullifier (Pkᵢ, Rᵢ) insteadNostr relays
Needssk₀, on the devicethe node may hold op

op_pubkey is an ordinary Nostr account of the kind any Nostr client understands. Everything human runs on it: encrypted chat under NIP-17, the kind-0 profile that carries the payment fields, the kind-10050 relay list that says where to reach the account, and the NIP-05 name that resolves to it. It is also what a contact is pinned to.

The two identities are joined by two signatures, both made with sk₀:

addr_sig"I, the holder of this address, confirm: encrypt to this IVPK, reach me on these relays, and the Nostr identity is this op_pubkey."

It is needed because Pk₀ and nk_commit are public. Without it, an attacker could publish a profile quoting the genuine address but substituting its own IVPK and op_pubkey; a payer would then encrypt the coin to the attacker. addr_sig makes that forgery infeasible, and it is why issuing an Invoice requires the wallet.

name_sig"I, the holder of this seed, consent to user@domain belonging to this op_pubkey."

What it closes: the node holds op and signs the profile, so without name_sig a hosting provider could attach a name to a payment identity it does not own and whose holder never agreed. With it, only the seed holder can.

What it does not close, and this matters: it says nothing about who is entitled to the name. A malicious NIP-05 provider — or whoever controls that domain — can point [email protected] at an identity that is wholly its own, sign a perfectly valid name_sig with that identity's sk₀, and every check passes. On first contact, DNS and the name provider are trusted for name ownership; name_sig narrows who can bind a name to an existing identity, not who may claim a name. Nothing but an out-of-band check of the key itself closes that.

Both use sk₀ rather than op because op is node-held: an op signature over a name would attest only that the node asserted it.

What a payer actually checks

user@domain
│ resolve via NIP-05

op_pubkey the Nostr identity
│ fetch the profile authored by it

profile { address, Pk₀, nk_commit, IVPK, relays }

├── hash(Pk₀ ‖ nk_commit) == address ? the parts match the address
├── addr_sig valid under Pk₀ ? the holder authorised these fields
├── event signature valid under op_pubkey ? it came from this identity
└── name_sig valid under Pk₀ ? the name really belongs to it


pin op_pubkey and {address, Pk₀, nk_commit, IVPK}, then encrypt to IVPK

If any check fails, the profile is not payable. What gets pinned is the key, never the name: a contact who changes name keeps the conversation, and the same name under a different key is a different contact.

The rest of this page is the inventory.

1 · Seed and roots

NameTypeOriginHeld by
mnemonic12-word BIP-39 phrasegenerated by the useruser
seed (seed64)512-bit secretPBKDF2-HMAC-SHA512(mnemonic, 2048 iterations, empty passphrase)wallet
mBIP-32 master nodefrom seedwallet
Aaccount rootm/1798'/account', hardened; 1798' is the zkCoins BIP-43 purposewallet
prf_salt32-byte constantSHA-256("zkCoins/v1/PasskeyPRF")public — v2 feature
prf_output32-byte secretWebAuthn PRF extension, eval.first = prf_saltauthenticator — v2 feature

A v1 wallet derives the seed exclusively from the mnemonic with an empty passphrase; the BIP-39 passphrase and the Passkey-PRF seed source are both v2 and a v1 wallet must not use them. Account indices are assigned densely and monotonically so seed recovery terminates. See §1.2.

2 · The derivation tree

seed ─ BIP-32 ─▶ m
└─ m/1798'/account' = A account root
├─ A/0' SPEND branch wallet only
│ ├─ A/0'/0' = sk₀ → Pk₀ fixes the address
│ └─ A/0'/i' = skᵢ → Pkᵢ rotates per transition
├─ A/1' VIEW branch delegable to a node
│ ├─ A/1'/0' = ivk → IVPK = ivk·G incoming
│ └─ A/1'/1' = ovk outgoing
├─ A/2' = op → op_pubkey = op·G Nostr identity
├─ A/3' = nk nullifier key
├─ A/4' = op_secret keys the nav_rand HKDF
└─ A/5'/j' = op_j RESERVED, unused in v1

Every branch separation is hardened, so a party holding the VIEW branch, op, nk, or op_secret cannot derive the SPEND branch.

SPEND branch — A/0', wallet only

NameTypeOriginRole
sk₀secp256k1 scalar, secretA/0'/0'signs addr_sig, name_sig, and every OwnershipProof
Pk₀BIP-340 x-only, 32 Bsk₀·Gfixes the address; is creator_pubkey at issuance
skᵢsecp256k1 scalar, secretA/0'/i', i = send_countersigns transition i's txn_sig
Pkᵢx-only, 32 Bskᵢ·Gthe state's current_pubkey; published as the on-chain nullifier key
Pkᵢ₊₁ (next_pubkey)x-only, 32 BA/0'/(i+1)'the rotation target; never appears on-chain

There is no per-coin signing key. One transition signature authorises the whole transition (§1.2 Spend-key model).

VIEW branch — A/1', delegable

NameTypeOriginCan
ivkscalar, secretA/1'/0'detect and decrypt incoming coins; it is also the recovery scan key
IVPKx-only, 32 B, publicivk·Gthe target of every ECDH encryption to this account
ovkscalar, secretA/1'/1'recover outgoing coin plaintext through out_ciphertext

Operational keys — A/2' to A/4'

NameTypeOriginRole
opscalar, secretA/2'the Nostr identity key; held by the node
op_pubkeyx-only, 32 Bop·Gthe Nostr identity itself
nkscalar, secretA/3'computes nullifiers in the proving witness; links the account's own spends
nk_commitPoseidon digest, 32 B, publicHc("NkCommit", nk)a committed AccountState field and part of the address preimage
op_secret256-bit secretA/4'keys the nav_rand HKDF; kept separate from op so NAV randomness never shares material with the Nostr signature

Who holds what

KeyHeld byCan doCannot do
skᵢwallet onlyauthorise spends
nkwallet and the wallet's own nodecompute nullifiersspend; it links the account's own spends, so it goes to no foreign node
ivkwallet and any node it delegates todetect and decrypt incoming coinsspend
ovksamerecover outgoing plaintextspend
opthe nodeact as the Nostr identity, send and read NIP-17, sign profiles, relay lists, grants, and acknowledgementsspend, decrypt others' coins
K_txderived per coin, shareabledecrypt exactly one coinspend, see any other coin

The operational bundle is {ivk, ovk, op, nk, op_secret} — what a wallet entrusts to its own node so the node can receive, prove, and serve on its behalf. None of it can spend. A foreign node receives a scoped view grant instead (§6.2, §5.2).

3 · Per-coin keys

Fresh for every output coin, so one per-coin capability discloses one coin and nothing else (§1.3).

NameTypeOriginReach
eskfresh random scalarsender, per coinone coin
epkx-only, 32 B, publicesk·Gpublished with the coin; carried as the zkepk scan tag
ss32-byte shared secretECDH(esk, IVPK) = ECDH(ivk, epk)one coin
K_tx32-byte symmetric, secretHKDF("zkCoins/v1/NoteKey", ss ‖ epk)decrypts exactly one coin
detect_tagPoseidon digest, publicHc("zkCoins/v1/DetectTag", ss ‖ epk)the scan tag; all of one recipient's tags are distinct
K_out32-byte symmetric, secretHKDF("zkCoins/v1/OutKey", ovk ‖ epk)the sender's own recovery of K_tx
kb32-byte ChaCha20-Poly1305 keyHKDF("zkCoins/v1/BlobKey", K_tx)one ZBE blob

K_tx and detect_tag share ss but are domain-separated by distinct context strings and distinct primitives — HKDF-SHA-256 against Poseidon — so the public tag does not weaken the secret key. The tag must derive from ss because the sender sets it and holds only the recipient's public IVPK. A K_tx must encrypt at most one bundle plaintext, or (kb, nonce_i) repeats (§4.2.1).

4 · Other secrets and blinding values

NameTypeOriginRule
npk_rand32 unmodified CSPRNG bytesdrawn fresh per proving attemptnever derived deterministically, never reused; fail-closed if no CSPRNG
npk_commitSHA-256 digest, publicH("zkCoins/v1/NpkCommit" ‖ next_pubkey ‖ npk_rand)the sixth ProofData field, recomputable by a thin wallet
nav_rand32 bytesHKDF("zkCoins/v1/NavRand", op_secret ‖ u64-be(send_counter))deterministic, so a fresh node rebuilds any prior opening; never derived from nav
nav / nav_commitmentaccumulator value / Hc("NavCommit", nav_root ‖ nav_rand)chain-derivedthe opening {nav, nav_rand} travels only to a coin's recipient or a disclosure verifier
terms_salt32-byte secretissuer, token standard 2 onlyblinds cap_total in AssetIdV2 and terms_hash
R' / Rsecp256k1 pointsR = R' + H(bytes(R') ‖ H(ProofData))·GR is the on-chain nonce; the pre-tweak R' goes to the publisher

npk_rand is why a hosted prover cannot capture the rotation: the wallet computes npk_commit from its own next_pubkey and its own randomness and refuses to sign unless the surfaced value matches (§2.1 clause 2).

5 · Identities

IdentityOriginRole
address = H(Pk₀ ‖ nk_commit)seed-derived, fixed at account creationBech32m HRP zk; the protocol's only value-bearing payment identifier
op_pubkey = op·Gseed-derivedthe Nostr identity, and what a contact is pinned to
Name user@domainnot seed-derived; issued by the API and app layersthe public label; exactly one in force, attested by name_sig
nprofileop_pubkey plus relay hintsthe NIP-19 identifier, the DNS-free route to a contact
publisher_pubkeythe publisher's op_pubkey — a role of that identity, not a separate onethe author of kind 30421; how a spender names a publisher
operator_id — a role of an op_pubkey, not a separate identitythe operator's own identifier in OperatorEndpointV1endpoint gossip; global infrastructure only, never account- or blob-specific

Binding nk_commit into the address preimage makes the nullifier key part of the payment identity, so a coin sent to an address has exactly one valid nullifier and a holder cannot equivocate two accounts under one address (§1.4). Accounts and addresses are one-to-one: there are no diversified addresses, sub-addresses, or change addresses, so the account is the unit of every isolation boundary (§1.2).

A client pins op_pubkey and the payment identity {address, pk0, nk_commit, ivpk} on first successful verification. A later profile may silently update only zkcoins.relays; a changed address, pk0, nk_commit, or ivpk must raise a warning even under a valid signature, because the holder of op is not necessarily the holder of the address. A changed name is adopted only once its name_sig verifies under the pinned pk0 and a forward NIP-05 lookup returns the pinned op_pubkey; while that lookup is unavailable the contact shows as unresolved and neither the old nor the new name is presented, though messaging and payment continue by key (§4.3).

6 · Bearer capabilities

Each is a Bech32m string under its own HRP, so the encodings can never be confused; a value under the wrong HRP must be rejected (§1.7.7, §5.4).

HRPPayloadReachForward-stoppable?
zkaddress, 32 B
zkviewK_tx, 32 Bexactly one coinno — pure bearer
zkavkivk ‖ ovk (64 B), or ivk alone (32 B, incoming-only)the whole account historyno
zkgrantthe full ViewGrant serialization, op-signedan asset list × a time window, bound to grantee key Dyes at nodes you operate — never retroactive
zkbidblob_id = H(ciphertext), 32 Ba locator, not a key
zkattSHA-256(BalanceAttestationV1), 32 Ba balance attestation's content handle

The 90-character limit of BIP-173/BIP-350 does not apply to these HRPs: a zkavk and a zkgrant payload exceed it by construction.

zkview, zkavk, and the balance attestation are not node authorisations. They are client-side decryption secrets an explorer applies to material it obtains from the relay mesh, so they widen only what their holder can already decrypt. Each must therefore travel in the URL fragment, never in a path or query string (§5.1, §5.6).

For an account-wide disclosure, prefer a scoped zkgrant over zkavk — not because it can be retracted, since nothing un-sees what a grantee already read, but because it carries an expiry and can be refused for future pulls at nodes you operate (§5 Consent is one-way).

7 · Request authentication

ItemKeyPurpose
nonce32 bytes, node-issuedthe challenge; consumed on use, 60-second window
chan_bindfrom the API layer's authoritative public hostname or onion key — never re-derived by the kernel from forwarded request metadatachannel binding that survives TLS-terminating proxies and is computable in a browser
tls-exporterRFC 9266, 32 bytesoptional extra hardening; must never be required
OwnershipProofsk₀ over chalthe only authority for /v1/pull, /v1/attest/balance, and /v1/grants
GrantProofthe grantee's secret d for Ddelegated pull; must not mint further grants
Pull session tokennode-generated, opaquebearer credential for the follow-up GETs, with its own expiry independent of the nonce window; the client cannot mint, narrow, or forge it

D is an ordinary BIP-340 key pair belonging to the grantee — it is not derived from the subject's seed. Possession of the operational bundle on a hosted node never substitutes for this authentication: the bundle enables the node to perform the action, the proof authenticates who may trigger it (§5.1, §7.5).

8 · Signatures

SignatureKeyAttests
txn_sig (in the SpendRecord)skᵢthe transition, over the per-network constant m_state, sign-to-contract-bound to H(ProofData)
addr_sigsk₀that the address holder authorised exactly these payment fields, including the choice of ivpk and op_pubkey
name_sigsk₀that the seed holder consented to this name
sig (on an Invoice)opthe per-issuance authorisation by the recipient's online key
Event signaturesopkinds 0, 10050, 30421, and the NIP-59 kind-13 seal
Gift-wrap signaturea fresh one-time key, per eventthe kind-1059 wrapper — never op, which is what keeps the two copies unlinkable
op_sigopthat the recipient acknowledged a delivery (kind-1421 ACK)
op_signature (in a ViewGrant)opthe scope and window of a delegation
op_sig (in OperatorEndpointV1)the operator's opendpoint gossip
receipt_sig (in ReplicaReceiptV1)the replica holder's op, verified under holder_op_pubkeythat both the blob and its delivery event are durably committed
manifest_sig (in BootstrapManifestV1)bootstrap_pubkeythe network's bootstrap infrastructure list
name_message = H( "zkCoins/v1/NameConsent" ‖ network ‖ u32-be name_len ‖ UTF-8(name) ‖ op_pubkey )
name_sig = BIP-340(sk₀, name_message)

addr_sig and name_sig come from sk₀ rather than op because op is node-held: an op signature over a name attests only that the node asserted it, while an sk₀ signature attests the seed holder. The operational consequence is that issuing an Invoice requires the wallet, the same custody boundary that governs sending. txn_sig is the only Schnorr object placed on-chain and half-aggregated — there is no separate publisher proof or publisher signature over shared state (§1.4, §3.2, §4.3).

9 · Network-level keys

NameTypeOrigin
bootstrap_pubkeyBIP-340 x-only, 32 Bone per network, pinned in the frozen network parameter set; mainnet pins at genesis, testnet and regtest pin their fixtures
Network parameter set{network_tag, circuit_digest(C), circuit_digest(C_balance), activation_height, finality_confirmations = 6, bootstrap_pubkey}content-addressed and byte-identical across every node of a network
Reveal-transaction keya Bitcoin keythe publisher's Bitcoin identity

bootstrap_pubkey is the only authority permitted to sign a BootstrapManifestV1; a manifest that does not verify under the pinned key is rejected, and there is no other trust root for bootstrap (§3.6, §4.3).

The publisher has no protocol key material of its own. Because v1 publishing is sponsored and carries no fee (§3.8), there is no fee address to bind and therefore no payment-identity block to sign: its kind-30421 profile is {version, relays} under its op key, and its Bitcoin identity is the reveal-transaction key. Running a publisher takes an op key, a relay presence, and an HTTP endpoint for the hand-off — no account, no address, no sk₀, no wallet. The deferred paid variant would need all four (§3.8.1).

10 · Derived values that are not keys

These appear alongside the key material and are sometimes mistaken for it. All are public digests or commitments, reproducible from their inputs (§1.4, §1.5).

ash (account state hash) · ocr (output coins root) · inr (input nullifiers root) · coin_history_root · nf (the in-circuit per-coin nullifier) · coin.identifier · asset_id and AssetIdV2 · terms_hash · blob_id · record_id and coin_id · manifest_id · network_id · m_state · circuit_digest.

Where to read further