Keys
How the privacy keys are derived from one wallet signature, why every spend needs a fresh wallet signature, and how keys are registered.
noircash has no separate seed phrase. The app derives every privacy key from one signature of a fixed message, and binds the wallet's own secp256k1 key into the owner key, so the wallet must also sign every spend.
The activation message
The wallet signs (personal_sign) a Sign-In with Ethereum (EIP-4361) message:
{domain} wants you to sign in with your Ethereum account:
{address, EIP-55 checksummed}
Activate privacy for the noircash vault {vault, lowercase}. This signature derives the keys that reveal your private balance. Sign it only on {domain}.
URI: {https|http}://{domain}
Version: 1
Chain ID: {chainId}
Nonce: {first 16 hex digits of the vault address after 0x, lowercase}
Issued At: 2026-01-01T00:00:00.000ZThe scheme is http only for localhost and 127.0.0.1. Every field is fixed, so the same wallet always signs the
same text. The domain is noircash.app. Changing the domain, the address, the chain or the vault changes every key. Because the message
is SIWE, wallets warn when a site on another domain asks for it.
Derivation
seed = keccak256(signature bytes)
sk = HKDF-SHA256(ikm = seed, no salt, info = "noircash/v1/sk", 64 bytes) mod p
nk = H(sk)
wallet key = secp256k1 public key recovered from the same signature: (x, y), 32 bytes each, big-endian
opk = H(nk, x_hi, x_lo, y_hi, y_lo) the four 128-bit halves of x and y
view secret = HKDF-SHA256(ikm = seed, no salt, info = "noircash/v1/view", 32 bytes)
view public = X25519(view secret)H is Poseidon2 over BN254 and p the BN254 scalar field (see Notes).
| Key | Stays where | Used for |
|---|---|---|
sk | Device memory | Root secret; only nk is derived from it |
nk (nullifier key) | Device memory, and inside proofs | Computing nullifiers and proving ownership of a note |
| Wallet key | Public (recoverable from any signature) | Every spend must carry a signature by it, checked in the circuit |
opk (owner key) | Public, in the registry | Owner field of every note |
| Viewing key (X25519) | Secret half on the device, public half in the registry | Encrypting and finding notes |
At activation the app checks that the address recovered from the signature is the connected account. A smart-contract wallet fails this check.
Determinism
The keys come back on a new device only if the wallet produces the same signature for the same message (RFC 6979
deterministic ECDSA). PrivacyClient.activate enforces this:
- Already registered: one signature. The derived
opkmust equal the registered one, or activation fails. - First time: two signatures of the same message. They must give the same
opkbefore anything is registered.
A wallet that does not sign deterministically is refused. There is no other key source.
Any wallet that signs with an EOA key works, including EIP-7702 accounts and MPC wallets that output one ordinary ECDSA signature. Smart-contract accounts and contract multisigs cannot activate: the circuit checks an EOA signature.
Per-spend signature
For every private transaction the wallet signs EIP-712 typed data (eth_signTypedData_v4):
struct Spend { bytes32 transaction; }
// transaction = H(root, nf0, nf1, cm0, cm1, exitShares, extDataHash) (Poseidon2, as bytes32 big-endian)
// domain = EIP712Domain(name = "noircash", version = "1", chainId, verifyingContract = vault)
// digest = keccak256(0x1901 || domainSeparator || keccak256(SPEND_TYPEHASH || transaction))
// SPEND_TYPEHASH = keccak256("Spend(bytes32 transaction)")The circuit recomputes the digest with keccak256 and verifies the ECDSA secp256k1 signature against the wallet key
hashed into opk, without revealing the key. The domain separator is not chosen by the prover: the contract passes
its own _domainSeparatorV4() as the last two public inputs, so a signature is valid for one vault on one chain.
extDataHash covers the recipient, submitter, fee, sale terms and ciphertexts (see
The circuit), so the wallet approves exactly one transaction.
Hardware wallets work: the spend is a standard EIP-712 signature on the device. The typed data shows only an opaque
bytes32, so the wallet cannot display the amount; the app shows it.
What a leaked activation signature allows
The activation signature is the root of sk, nk and the viewing key. Whoever obtains it can:
- decrypt every note sent to the owner, past and future, and read amounts;
- compute each note's nullifier, and so see when each note is spent;
- link the owner's shields and private buys to the later spends of those notes.
They cannot spend: a spend needs a fresh Spend signature by the wallet key, which the activation signature
does not reveal. They also cannot change the registered keys, because register needs the address itself and
registerFor needs a separate EIP-712 signature. The response to a leak is to move the notes to keys the attacker
does not know, which today means a different wallet.
Registration
A key pair becomes usable for incoming payments once it is in the vault's registry:
function register(uint256 opk, bytes32 viewKey) external;
function registerFor(address owner, uint256 opk, bytes32 viewKey, uint256 deadline, bytes calldata signature)
external;
// EIP-712, under the vault's domain:
// Register(address owner,uint256 opk,bytes32 viewKey,uint256 nonce,uint256 deadline)
function keysOf(address owner) external view returns (uint256 opk, bytes32 viewKey);registerrecords keys formsg.sender.registerForlets anyone submit a registration signed byowner(EOA or ERC-1271 through OpenZeppelin'sSignatureChecker). The nonce isregisterNonces[owner], incremented on use, and the signature expires atdeadline.opkmust be non-zero and belowp. The call emitsKeysRegistered(owner, opk, viewKey).- Registering again replaces the keys. Notes already created for the old
opkstay spendable with the old keys.
Registration is public: it shows that an address uses noircash. It lets others send that address private notes,
because a sender needs the recipient's opk and viewing key to build and encrypt a note. It changes nothing for the
address's public NOIR: tokens sent to it stay an ordinary ERC-20 balance until the owner
shields them.
Notes, commitments and nullifiers
What a note contains, how its commitment and nullifier are computed, how notes are encrypted, and how shielded notes get their shares.
The transact circuit
The one zero-knowledge circuit behind every private spend - its inputs, constraints, public inputs, ExtData binding, sizes and toolchain.