Design goals

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:

01

Privacy mandatory

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.

02

No trusted setup

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.

03

Exchange-listable

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.

Coin model

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.

The one rule

Every unit of LTH is always inside a note. Notes are always encrypted. No balance is ever visible on-chain. No exceptions.

Notes

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
}
FieldTypePurpose
valueu64Amount in atoms. 1 LTH = 10⁸ atoms. Maximum ~1.84 × 10¹⁰ LTH.
ownerFq (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.

Note commitments

A note commitment is a collision-resistant hash of the note. It is safe to publish — it reveals nothing about the note's contents.

cm = Poseidon2(value, owner, rcm)

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).

Why Poseidon?

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.

Implementation

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.

Nullifiers

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.

nf = Poseidon2(spending_key, rho)

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.

Nullifier set

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.

PropertyValue
Hash functionPoseidon2 (BN254, width 4)
Output size32 bytes (1 Fq element, compressed)
UniquenessOne nullifier per (spending_key, rho) pair
Collision resistanceInherits from Poseidon2 over BN254

Merkle tree

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.

// merkle tree structure
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.

Note root in the block header

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.

Key derivation

Lethe uses a two-level key hierarchy derived from a BIP39 seed phrase.

sk

Spending key

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).

vk

Viewing key

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.

addr

Address (pk_d)

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)
BIP39 note

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.

Note encryption

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.

Transaction structure

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 balance

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:

∑ spend.value_public = ∑ output.value_public + fee

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.

Spend circuit

The spend circuit proves that the sender has the right to consume a specific note, without revealing which note or any amounts.

Input typeNameDescription
PrivatenoteThe full Note struct being spent
Privatespending_keyAuthorises the spend
Privatemerkle_path32 sibling hashes from leaf to root
Publicmerkle_rootMerkle root — must match merkle_path
Publicnf_publicPoseidon2(spending_key, note.rho) — the nullifier
PublicfeeTransaction fee; circuit asserts note.value ≥ fee
Publicvalue_publicnote.value — exposed for value conservation; circuit asserts note.value == value_public

The circuit asserts:

1

Membership

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.

2

Nullifier correctness

The public nf_public equals Poseidon2(spending_key, note.rho).

3

Ownership

The spending_key corresponds to the note's owner: owner = hash_2(hash_2(sk, 0), 0).

4

Fee bound

note.value ≥ fee (ensures the fee can be subtracted without underflow).

5

Value exposure

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).

Output circuit

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 typeNameDescription
PrivatenoteThe new Note being created (value, owner, rho, rcm)
Publiccm_publicThe output commitment — circuit asserts cm_public = Poseidon2(value, owner, rcm)
Publicvalue_publicnote.value — exposed for conservation; circuit asserts note.value == value_public and note.value > 0

Implemented in lethe-circuits/output/src/main.nr (Noir).

Proof verification

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.

On-chain verification

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().

Proof of Work

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 adjustment

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.

ParameterValue
Target block time120 seconds (2 minutes)
Retarget interval504 blocks (~1 week)
Adjustment cap±4× per interval
Initial bits0x207fffff (easy genesis target)
Bits encodingBitcoin-style compact (nBits)
Hash algorithmSHA-256d (double SHA-256)

Mining

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

Block structure

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.

Chain reorganisation

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.

Fork buffer

Orphan blocks are held in a ForkBuffer (up to 32 blocks deep). For each received block the node checks:

1

Extends tip

If the block's prev_hash matches the current tip, apply it immediately.

2

Fork candidate

Otherwise insert into the ForkBuffer. Walk the buffer chain back to the common ancestor and compute the fork's cumulative work.

3

Reorg trigger

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.

ParameterValue
Max fork depth32 blocks
Work formulau128::MAX / (target_leading_u128 + 1)
Selection ruleHighest cumulative work (heaviest chain)

Emission schedule

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.

ParameterValue
Initial block reward50 LTH (5,000,000,000 atoms)
Halving interval210,000 blocks (~1.6 years)
Tail emission0.01 LTH (minimum floor, never halved further)
Max halvings before tail12 halvings (~19 years)
Total supply (approx)~21 million LTH (converges, never exceeded)
reward(height) = max( 50 LTH ÷ 2⌊height / 210000⌋, 0.01 LTH )

HTTP API

The Lethe node exposes a minimal JSON REST API used by the wallet and tooling. It is not intended for direct user access.

MethodPathDescription
POST/v1/transactionsSubmit a signed transaction. Returns 200 on acceptance, 422 on validation failure.
GET/v1/stateReturns current tip hash, height, and note Merkle root.

Rate limiting

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 varDefaultDescription
LETHE_RATE_CAPACITY60Maximum tokens per bucket
LETHE_RATE_REFILL10Tokens refilled per second

Compliance proofs

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.

1

Exchange publishes a sanctions root

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.

2

User generates a non-membership proof

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.

3

Exchange verifies on-chain

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.

Non-membership circuit

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 typeNameDescription
Privatespending_keyThe user's spending key; the circuit derives the address internally as hash_2(hash_2(sk, 0), 0)
Privateneighbour_valueThe value of the adjacent leaf in the sorted sanctions tree
Privateneighbour_nextThe next-pointer of the adjacent leaf
Privateneighbour_path32-element Merkle path for the adjacent leaf
Privateneighbour_posLeaf positions along the path
Publicsanctions_rootPublished by the exchange/regulator
PublicaddressThe 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.