Protocol specification
The complete technical reference for the Lethe protocol — notes, commitments, the Merkle tree, ZK proof system, circuit-enforced value conservation, PoW consensus with chain reorganisation, and compliance proofs.
Overview
Lethe is a UTXO-free, fully-shielded payment network. There are no transparent addresses. Every unit of value exists as an encrypted note. The public chain contains only commitments and nullifiers — nothing that reveals who owns what or what amounts changed hands.
Three constraints guided every design decision:
There is no transparent mode. Zcash's mistake was making privacy opt-in — most users never used it, and the small shielded pool made shielded users identifiable by exclusion. Lethe forces all value through the shielded pool at all times.
Zcash's Groth16 proofs required a "Powers of Tau" ceremony. If any participant kept their randomness, they can silently create fake proofs that mint unlimited coins. Lethe uses Noir circuits with a transparent proof system — no ceremony, no trapdoor.
Regulators require exchanges to screen for sanctioned addresses. Lethe addresses this without breaking privacy: users generate a ZK non-membership proof that their address does not appear in a published sanctions list, without revealing the address itself.
Lethe does not use the UTXO model or an account model. Value exists as notes. A note is a tuple of value, owner, and randomness. Notes are never updated — they are created (output) and destroyed (spent). The chain records only the cryptographic fingerprints of this process.
Every unit of LTH is always inside a note. Notes are always encrypted. No balance is ever visible on-chain. No exceptions.
Cryptography
A note represents a discrete amount of LTH assigned to a specific owner.
struct Note {
value: u64, // amount in atoms (1 LTH = 100_000_000 atoms)
owner: Address, // recipient's public key (pk_d on BN254)
rho: [u8; 32], // random seed for nullifier derivation
rcm: [u8; 32], // random commitment trapdoor
}
| Field | Type | Purpose |
|---|---|---|
| value | u64 | Amount in atoms. 1 LTH = 10⁸ atoms. Maximum ~1.84 × 10¹⁰ LTH. |
| owner | Fq (BN254) | The recipient's pk_d — the public spend authorization key. |
| rho | [u8; 32] | Fresh random bytes chosen by the sender. Used to derive the nullifier so the note can only be spent once. |
| rcm | [u8; 32] | Commitment randomness. Hides the note contents from the commitment alone. |
Notes are never stored on-chain in plaintext. The chain stores only the note commitment (a hash). The encrypted note is broadcast alongside the commitment so the recipient can recover it.
A note commitment is a collision-resistant hash of the note. It is safe to publish — it reveals nothing about the note's contents.
The commitment covers three fields: value, owner (pk_d), and rcm (commitment randomness). The rho field is not included in the commitment — it is used only for nullifier derivation. We use Poseidon2 over the BN254 scalar field (Fq). Poseidon2 is far more efficient inside ZK circuits than SHA-256 — a Poseidon2 hash costs roughly 300 constraints vs ~30,000 for SHA-256 in R1CS. Every note commitment is a single Fq element (32 bytes).
ZK-friendliness is the only reason. Outside circuits, we use SHA-256d (for PoW block hashing) and Argon2id (for keystore KDF). Inside circuits, Poseidon2 minimises constraint count, which directly reduces proof generation time.
Poseidon2 is implemented in lethe-core/src/hash/poseidon2.rs using arkworks-rs. Known-answer tests verify the output matches Noir's built-in poseidon2_permutation with width=4.
A nullifier is a unique tag derived from a note and its owner's spending key. When a note is spent, its nullifier is published. Nodes reject any transaction that includes a nullifier already in the nullifier set — this is the double-spend prevention mechanism.
The nullifier reveals nothing about which note was spent. An observer sees only that some note was spent — not which one, not the value, not the sender or recipient.
The node maintains an append-only nullifier set. Before including a transaction in a block, every nullifier is checked against this set. The set is persisted to the data directory on shutdown.
| Property | Value |
|---|---|
| Hash function | Poseidon2 (BN254, width 4) |
| Output size | 32 bytes (1 Fq element, compressed) |
| Uniqueness | One nullifier per (spending_key, rho) pair |
| Collision resistance | Inherits from Poseidon2 over BN254 |
All note commitments are stored in an incremental Merkle tree of depth 32, with Poseidon2 as the internal hash. This gives a capacity of 2³² ≈ 4.3 billion notes — more than enough for the foreseeable future.
depth 0 (root) root = H(left_child, right_child)
depth 1 H(H(cm0,cm1), H(cm2,cm3)) ...
depth 2 H(cm0,cm1) H(cm2,cm3) ...
depth 32 (leaves) cm0 cm1 cm2 cm3 ... cm_n empty empty
// Internal node hash:
H(left, right) = Poseidon2([left, right, 0, 0])[0]
// Empty subtree roots are precomputed (EMPTY_ROOTS[depth]).
// New commitments are appended at the next available leaf.
The tree root is a commitment to the entire set of notes that have ever existed. A Merkle proof (path of 32 sibling hashes) demonstrates that a specific note commitment is in the tree — this is the core of the spend proof.
Each block header includes the note_root — the Merkle root after all note commitments in that block are applied. Wallets record the root at the block height where they received a note; this root is the anchor used in the spend proof.
Lethe uses a two-level key hierarchy derived from a BIP39 seed phrase.
A random 32-byte value derived from the BIP39 seed. The spending key authorises outgoing transactions. It must never leave the device. Used to derive nullifiers via Poseidon2(sk, rho).
Derived from the spending key. Used to scan the chain and decrypt incoming notes. Can be exported and given to a third party (e.g. an auditor) without granting spend authority.
A BN254 Fq field element derived from the viewing key — the diversified public key. This is what you share to receive funds. It is 32 bytes, hex-encoded (64 characters).
// Key derivation chain
seed_phrase → seed_bytes (BIP39 PBKDF2, first 32 bytes of 64-byte output)
→ spending_key (Fq::from_le_bytes(seed_bytes[0..32]))
→ viewing_key (hash_2(spending_key, 0) — Poseidon2)
→ pk_d/address (hash_2(viewing_key, 0) — Poseidon2)
Lethe uses standard BIP39 mnemonic generation but derives only the first 32 bytes of the 64-byte PBKDF2 output as the seed. This deviates from BIP32 and means mnemonics are not interoperable with BIP32/BIP44 HD wallets.
When a sender creates an output note, they encrypt it to the recipient's viewing key using ChaCha20-Poly1305. The ciphertext is broadcast with the note commitment so the recipient can scan and recover it.
struct EncryptedNote {
ephem_bytes: [u8; 32], // ephemeral public key for ECDH key derivation
nonce: [u8; 12], // ChaCha20-Poly1305 nonce (random, 96-bit)
ciphertext: Vec<u8>, // bincode(Note) encrypted to viewing_key
}
The encryption key is derived as SHA-256("LetheNoteEnc v2" || ephem_bytes || pk_d). The sender generates a fresh ephemeral keypair for each note; the recipient uses the ephem_bytes together with their viewing key to recover the same key. Both coinbase notes and transaction output notes use full ChaCha20-Poly1305 encryption.
Transactions
A Lethe transaction consumes one or more existing notes (spends) and creates one or more new notes (outputs). Every spend reveals a nullifier and a ZK proof. Every output reveals an encrypted note and a commitment.
struct Transaction {
nullifiers: Vec<[u8; 32]>, // one per spent note
output_commitments: Vec<[u8; 32]>, // one per new note
encrypted_outputs: Vec<EncryptedNote>, // encrypted notes for recipient scanning
fee: u64, // atoms paid to the miner
proof: Vec<u8>, // spend proofs, one per nullifier (concatenated)
output_proofs: Vec<Vec<u8>>, // output proofs, one per commitment
anchor: [u8; 32], // Merkle root at time of proof generation
}
Value conservation is enforced at two layers. First, each spend circuit exposes a value_public public input (equal to the note's value) and asserts note.value ≥ fee. Each output circuit independently exposes its own value_public. Second, the node checks the balance equation before admitting a transaction to the mempool:
Neither the wallet nor the node can inflate or deflate amounts — any imbalance causes the ZK proof to be invalid or the node's balance check to fail. This is enforced at the circuit level, not just by the wallet.
The spend circuit proves that the sender has the right to consume a specific note, without revealing which note or any amounts.
| Input type | Name | Description |
|---|---|---|
| Private | note | The full Note struct being spent |
| Private | spending_key | Authorises the spend |
| Private | merkle_path | 32 sibling hashes from leaf to root |
| Public | merkle_root | Merkle root — must match merkle_path |
| Public | nf_public | Poseidon2(spending_key, note.rho) — the nullifier |
| Public | fee | Transaction fee; circuit asserts note.value ≥ fee |
| Public | value_public | note.value — exposed for value conservation; circuit asserts note.value == value_public |
The circuit asserts:
The note commitment (Poseidon2(value, owner, rcm), computed from private inputs) lies on the provided Merkle path and the computed root matches the public merkle_root.
The public nf_public equals Poseidon2(spending_key, note.rho).
The spending_key corresponds to the note's owner: owner = hash_2(hash_2(sk, 0), 0).
note.value ≥ fee (ensures the fee can be subtracted without underflow).
value_public == note.value — the note value is exposed as a public integer so the node can verify conservation across the full transaction without learning anything about individual addresses.
Implemented in lethe-circuits/spend/src/main.nr (Noir).
The output circuit proves that a new note commitment was constructed correctly — that it genuinely encodes the claimed value and owner, and that the encrypted note the recipient receives can be decrypted to the same note.
| Input type | Name | Description |
|---|---|---|
| Private | note | The new Note being created (value, owner, rho, rcm) |
| Public | cm_public | The output commitment — circuit asserts cm_public = Poseidon2(value, owner, rcm) |
| Public | value_public | note.value — exposed for conservation; circuit asserts note.value == value_public and note.value > 0 |
Implemented in lethe-circuits/output/src/main.nr (Noir).
The node verifies every ZK proof before including a transaction in the mempool. Each proof is self-contained — the verifier needs only the public inputs and the proof bytes.
The node verifies every ZK proof using BbVerifier — Barretenberg's UltraHonk verifier — before admitting a transaction to the mempool. Each proof is self-contained: the verifier needs only the public inputs and the proof bytes. Spend proofs and output proofs are verified separately; an output proof mismatch (wrong count or failing verification) causes the transaction to be rejected with a descriptive error.
The LetheAnchor Solidity contract provides a trustless on-chain proof path. Calling submitRootWithProof(root, nullifier, fee, valuePublic, proof) invokes the pluggable ILetheVerifier interface — if the verifier accepts the proof, the root is marked valid on-chain. The verifier address can be upgraded by the contract owner via setVerifier().
Consensus
Lethe uses SHA-256d Proof of Work — the same algorithm as Bitcoin. The block hash is computed as SHA256(SHA256(bincode(header))). A block is valid when its hash is less than or equal to the current target.
Difficulty retargets every 504 blocks (approximately one week at the 2-minute block target). The adjustment is clamped to ±4× per interval to prevent wild oscillations.
| Parameter | Value |
|---|---|
| Target block time | 120 seconds (2 minutes) |
| Retarget interval | 504 blocks (~1 week) |
| Adjustment cap | ±4× per interval |
| Initial bits | 0x207fffff (easy genesis target) |
| Bits encoding | Bitcoin-style compact (nBits) |
| Hash algorithm | SHA-256d (double SHA-256) |
To mine, run a full node with a coinbase address set. The coinbase reward for each block goes into a new note locked to your address — it appears in your wallet after scanning.
# Set your wallet address from the wallet's address chip
LETHE_COINBASE_ADDRESS=<your-64-char-hex-address> lethe-node
struct BlockHeader {
prev_hash: [u8; 32], // SHA256d of the previous block header
height: u64, // block height (genesis = 0)
timestamp: u64, // Unix seconds
note_root: [u8; 32], // Merkle root after all commitments in this block
coinbase_cm: [u8; 32], // note commitment of the miner's reward note
coinbase_enc: Vec<u8>, // encrypted coinbase note for miner scanning
nonce: u64, // incremented during PoW mining
}
struct Block {
header: BlockHeader,
transactions: Vec<Transaction>,
}
The block hash covers all header fields including the nonce. The note_root commits to every note commitment created by this block (coinbase first, then tx outputs in order). Nodes reject a block if the note_root does not match the computed root after applying all commitments.
Lethe uses a cumulative proof-of-work chain selection rule (identical to Bitcoin). Every block contributes work proportional to 1 / target. When a node receives a competing fork whose cumulative work exceeds the main chain, it reorganises.
Orphan blocks are held in a ForkBuffer (up to 32 blocks deep). For each received block the node checks:
If the block's prev_hash matches the current tip, apply it immediately.
Otherwise insert into the ForkBuffer. Walk the buffer chain back to the common ancestor and compute the fork's cumulative work.
If the fork's cumulative work exceeds the main chain, roll back to the common ancestor and replay the fork chain. All rolled-back transactions return to the mempool.
| Parameter | Value |
|---|---|
| Max fork depth | 32 blocks |
| Work formula | u128::MAX / (target_leading_u128 + 1) |
| Selection rule | Highest cumulative work (heaviest chain) |
LTH is distributed exclusively through mining. There is no pre-mine, no ICO, no developer allocation. The emission schedule follows Bitcoin's halving model with a tail emission floor.
| Parameter | Value |
|---|---|
| Initial block reward | 50 LTH (5,000,000,000 atoms) |
| Halving interval | 210,000 blocks (~1.6 years) |
| Tail emission | 0.01 LTH (minimum floor, never halved further) |
| Max halvings before tail | 12 halvings (~19 years) |
| Total supply (approx) | ~21 million LTH (converges, never exceeded) |
Node
The Lethe node exposes a minimal JSON REST API used by the wallet and tooling. It is not intended for direct user access.
| Method | Path | Description |
|---|---|---|
| POST | /v1/transactions | Submit a signed transaction. Returns 200 on acceptance, 422 on validation failure. |
| GET | /v1/state | Returns current tip hash, height, and note Merkle root. |
Each IP address is assigned an independent token bucket. The bucket refills at a configurable rate (default 10 tokens/s) up to a capacity ceiling (default 60). Requests that arrive when the bucket is empty receive a 429 Too Many Requests response. Stale buckets are evicted when the table exceeds 1,000 entries.
| Env var | Default | Description |
|---|---|---|
| LETHE_RATE_CAPACITY | 60 | Maximum tokens per bucket |
| LETHE_RATE_REFILL | 10 | Tokens refilled per second |
Compliance
The compliance module is Lethe's key differentiator for exchange listings. It allows a user to prove, in zero knowledge, that their address is not in a published list of sanctioned addresses — without revealing the address itself.
The exchange builds a Merkle tree of sanctioned addresses from the OFAC SDN list (or their own compliance database) and publishes the Merkle root on-chain via the ComplianceRegistry contract.
In one click in the Lethe wallet, the user generates a ZK proof that their address does not appear anywhere in the Merkle tree whose root was published. This is the compliance circuit.
The exchange calls ComplianceRegistry.verifyCompliance(proof, root). The contract verifies the proof. If valid, the exchange grants listing / withdrawal access. The exchange learns only "this user is not sanctioned" — nothing else.
The compliance circuit implements a ZK non-membership proof. It proves that a secret value (the user's address) does not appear in a Merkle tree with a known root.
| Input type | Name | Description |
|---|---|---|
| Private | spending_key | The user's spending key; the circuit derives the address internally as hash_2(hash_2(sk, 0), 0) |
| Private | neighbour_value | The value of the adjacent leaf in the sorted sanctions tree |
| Private | neighbour_next | The next-pointer of the adjacent leaf |
| Private | neighbour_path | 32-element Merkle path for the adjacent leaf |
| Private | neighbour_pos | Leaf positions along the path |
| Public | sanctions_root | Published by the exchange/regulator |
| Public | address | The derived address (computed inside the circuit, exposed as a public output) |
The sanctions tree is a sorted Merkle tree. Non-membership is proved by finding the neighbour leaf whose range straddles the target address — if the neighbour's value < address < neighbour.next and the neighbour is correctly placed in the tree (path verifies against sanctions_root), then the address cannot be in the tree. The circuit derives the address from the spending key internally so the raw address is never transmitted to the prover.
Implemented in lethe-circuits/compliance/src/main.nr (Noir). The Solidity verifier and registry contract is in lethe-contracts/ComplianceRegistry.sol.