Quickstart

The fastest way to run Lethe locally. You need Rust (stable), cargo, and Node.js ≥ 18.

# 1. Clone the repository
git clone https://github.com/lethe-network/lethe
cd lethe

# 2. Build the node
cargo build -p lethe-node --release

# 3. Start the node + wallet together
./scripts/dev.sh

dev.sh builds the node, starts it on port 9000 (P2P) and 9001 (API), waits for the REST API to respond, then launches the Tauri wallet.

Mining

To mine, set your coinbase address before running: LETHE_COINBASE_ADDRESS=<your-address> ./scripts/dev.sh. The address comes from your wallet's address chip.

Install from source

Prerequisites

1

Rust (stable)

Install via rustup. The workspace uses stable Rust — no nightly needed for the node or wallet.

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
2

Tauri prerequisites (for the wallet)

Tauri needs the platform's WebView library and a few system tools.

# macOS
xcode-select --install

# Ubuntu / Debian
sudo apt install libwebkit2gtk-4.0-dev build-essential \
  libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
3

Node.js ≥ 18 (for the wallet UI)

The wallet frontend is React + Vite. Node.js is only needed to build the UI — not to run the node.

# via nvm
nvm install 20 && nvm use 20
4

Nargo (optional, for circuit development)

Only needed if you're working on the Noir ZK circuits in lethe-circuits/.

curl -L https://raw.githubusercontent.com/noir-lang/noirup/main/install | bash
noirup

Build

# Build the entire Rust workspace
cargo build --workspace

# Build just the node (faster)
cargo build -p lethe-node

# Build the wallet (Tauri compiles the Rust backend + bundles the React UI)
cd lethe-wallet && npm install && npm run tauri build

Run tests

# All Rust tests (requires cargo-nextest)
cargo nextest run --workspace

# Install nextest if you don't have it
cargo install cargo-nextest --locked

# Noir circuit tests
cd lethe-circuits && nargo test --workspace

# Solidity contract tests
cd lethe-contracts && npx hardhat test

Create a wallet

The Lethe wallet is a native desktop application (Tauri). Your keys are generated locally and stored in an encrypted keystore — nothing is sent to a server.

1

Open the wallet

Launch the app via ./scripts/dev.sh or run the built binary from lethe-wallet/src-tauri/target/release/. The Setup screen appears on first run.

2

Create — generate a new wallet

Click Create, enter a strong password, and click Generate. A 24-word BIP39 seed phrase is shown. Write it down and store it offline. This is your only backup.

3

Confirm backup

The wallet shows the seed phrase one more time. Confirm you've written it down. The keystore is then encrypted with Argon2id + your password and saved locally.

4

Dashboard

The wallet opens to the Dashboard. Your address (a 64-char hex string) appears in the top navigation chip. Share this address to receive LTH.

Security

The keystore is encrypted with Argon2id (memory-hard KDF) + AES-256-GCM. Your spending key never leaves the device in plaintext. There is no account recovery — the seed phrase is the only backup mechanism.

Overwrite protection

If a keystore already exists on disk, creating or importing a new wallet requires explicit confirmation (force: true via the Tauri API). The wallet UI displays a warning before overwriting. This prevents accidental loss of an existing wallet.

Receive LTH

Your Lethe address is the 64-character hex string shown in the top navigation bar. It is your pk_d — the BN254 public key from which all note ownership is derived.

To receive LTH:

1

Share your address

Copy your address from the navigation chip. Give it to the sender, or use it as your mining coinbase address.

2

Scan for notes

After the sender's transaction is mined, click Scan for notes on the Dashboard. The wallet fetches all notes from the node and tries to decrypt each one with your viewing key. Notes that decrypt successfully are yours.

Scanning is completely private. The node serves all encrypted notes to anyone who asks — it cannot tell which wallet is scanning or which notes belong to which wallet.

Send LTH

To send LTH, navigate to Send. The wallet will:

1

Select notes to spend

The wallet picks unspent notes from your local store that cover the amount + fee. This is done locally — the node never sees which notes you're spending until the transaction is broadcast.

2

Generate ZK proofs

A spend proof is generated for each input note, and an output proof for each new note. Each proof exposes a value_public field that the node uses to verify conservation (∑ inputs = ∑ outputs + fee) without learning amounts. This runs locally using the Barretenberg prover.

3

Broadcast

The signed transaction (proofs + nullifiers + encrypted output notes) is posted to the node's mempool via POST /v1/transactions. The node verifies the proofs and propagates the transaction over P2P gossip.

Change notes

If your selected notes exceed the amount + fee, a change output note is automatically created back to your own address. This change note appears in your balance after the next scan.

Scan for notes

Lethe does not track your balance on-chain. Your balance is computed locally by scanning all encrypted notes on the chain and trying to decrypt each one with your viewing key.

Click Scan for notes on the Dashboard to trigger a scan. The wallet:

  1. Fetches all spent nullifiers from GET /v1/nullifiers and removes any matching notes from your local store
  2. Fetches all EncryptedNote objects from GET /v1/notes
  3. Tries to decrypt each note with your viewing key using the ephem_bytes for key derivation
  4. Notes that decrypt to a valid Note struct with matching commitment are added to your note store
  5. Your confirmed balance is updated as the sum of stored unspent note values
  6. Your pending balance is updated by also scanning GET /v1/mempool/notes for notes in the mempool (not yet confirmed)

Scanning is idempotent. Already-known notes are skipped. The node cannot determine which wallet is scanning — all encrypted notes are served to all callers, by design.

Compliance proof

Navigate to Compliance in the wallet to generate a ZK non-membership proof for an exchange listing or regulatory requirement.

1

Enter the sanctions root

Paste the Merkle root hash provided by the exchange or compliance authority. This is a 64-char hex string published by the exchange (on-chain or in their docs).

2

Generate proof

Click Generate. The wallet runs the compliance Noir circuit locally, producing a ZK proof that your address is not in the sanctions tree.

3

Submit to exchange

Export the proof and submit it to the exchange. They call ComplianceRegistry.verifyCompliance() on-chain to confirm it's valid.

Run a node

Running a node validates transactions, propagates blocks over P2P, and (optionally) mines new blocks.

# Build the node binary
cargo build -p lethe-node --release

# Start with defaults (P2P :9000, API :9001, data in ~/.local/share/lethe)
./target/release/lethe-node

# With custom port and data directory
LETHE_PORT=9000 \
LETHE_DATA_DIR=/var/lib/lethe \
RUST_LOG=info \
  ./target/release/lethe-node

The node starts listening for P2P connections on TCP port 9000 and exposes a REST API on port 9001. The genesis block is created automatically if no chain state exists.

Mine LTH

Mining is controlled by a single environment variable. If it is not set, the node runs in relay-only mode.

# Get your address from the wallet's navigation chip, then:
LETHE_COINBASE_ADDRESS=a8b38ec87209511e9088a80add9e0c684c7e9d5375349ab88a4d9e9cab91752a \
RUST_LOG=info \
  ./target/release/lethe-node

The coinbase address is your wallet's pk_d — the 64-character hex string shown in the top navigation bar. Every block mined creates a new encrypted note locked to this address. Scan for notes in your wallet to see the rewards.

Initial difficulty

The genesis difficulty (0x207fffff) is intentionally easy — roughly 50% of hashes satisfy the target. Blocks mine in milliseconds on any hardware at genesis. Difficulty retargets every 504 blocks toward the 2-minute target.

Node configuration

All configuration is via environment variables. No config file needed.

VariableDefaultDescription
LETHE_PORT9000TCP port for P2P connections. The REST API listens on port+1 (default 9001).
LETHE_DATA_DIR~/.local/share/letheDirectory for chain state, nullifier set, and node keypair.
LETHE_COINBASE_ADDRESSHex-encoded 32-byte pk_d (64 chars). Required to enable mining. If unset, node is relay-only.
LETHE_BLOCK_TX_LIMIT100Maximum transactions included per block.
LETHE_BOOTSTRAP_PEERSComma-separated list of Multiaddr bootstrap peers.
LETHE_INITIAL_BITS0x207fffffOverride the genesis difficulty (compact nBits format). Use 0x1e00ffff for mainnet-level genesis.
LETHE_NODE_URLhttp://127.0.0.1:9001Used by the wallet to locate the node REST API.
LETHE_RATE_CAPACITY60Token bucket capacity per IP address for API rate limiting.
LETHE_RATE_REFILL10Tokens refilled per second per IP. Requests above capacity get 429.
RUST_LOGwarnLog level. Use info or debug for verbose output.

Bootstrap peers

To join an existing network, provide bootstrap peer addresses in libp2p Multiaddr format.

LETHE_BOOTSTRAP_PEERS=/ip4/192.168.1.10/tcp/9000/p2p/12D3KooW...,/ip4/10.0.0.5/tcp/9000/p2p/12D3KooW... \
  ./target/release/lethe-node

The node will dial each bootstrap peer on startup. Peer IDs are derived from the node's ed25519 keypair, which is generated on first run and stored in LETHE_DATA_DIR/node.key. Your node's peer ID and Multiaddr are logged on startup.

Example startup log:
[INFO] node listening                    listen_addr=/ip4/0.0.0.0/tcp/9000
[INFO] dialing bootstrap peer            addr=/ip4/10.0.0.5/tcp/9000/p2p/12D3KooW...
[INFO] connection established            peer_id=12D3KooW...
[INFO] ⛏  starting mining job           height=507 bits=520164710

GET /v1/state

Returns the current chain state. No authentication required.

GET http://127.0.0.1:9001/v1/state
Response 200 OK
{ "height": 506, "merkle_root": "c3161b3643187306adede62583b8e3fee463be2b94c850d648f8a01c3103e025", "mempool_size": 0, "note_count": 506 }
FieldTypeDescription
heightu64Current chain height. 0 = genesis.
merkle_roothex stringPoseidon2 Merkle root of all note commitments, compressed BN254 Fq (32 bytes).
mempool_sizeusizeNumber of unconfirmed transactions in the mempool.
note_countu64Total number of note commitments in the tree (= number of notes ever created).

GET /v1/notes

Returns all encrypted notes stored on-chain. The wallet scanner calls this to find received notes.

GET http://127.0.0.1:9001/v1/notes
Response 200 OK
[ { "enc": { "ephem_bytes": [178, 34, 91, 200, ...], "nonce": [142, 57, 23, 88, 0, 0, 0, 0, 0, 0, 0, 0], "ciphertext": [123, 34, 118, 97, ...] }, "leaf_index": 0 }, ... ]
FieldTypeDescription
enc.ephem_bytes[u8; 32]Ephemeral public key used for ECDH key agreement. Combined with the recipient's viewing key to derive the decryption key.
enc.nonce[u8; 12]ChaCha20-Poly1305 nonce (random 96-bit value).
enc.ciphertextVec<u8>Bincode-serialised Note encrypted with ChaCha20-Poly1305. Includes a 16-byte Poly1305 authentication tag.
leaf_indexu64Position of this note's commitment in the Merkle tree. Used to fetch the Merkle auth path for spending (GET /v1/auth_path/:leaf_index).
Privacy note

All notes are served to all callers. The node cannot determine which notes belong to which wallet. This is by design — differential serving would leak information.

GET /v1/nullifiers

Returns all spent nullifiers currently in the on-chain nullifier set. The wallet calls this during a scan to detect and remove notes that have already been spent.

GET http://127.0.0.1:9001/v1/nullifiers
Response 200 OK
[ "a3f8c2d1e4b5069f...", "01cc34aa87120f5e...", ... ]
FieldTypeDescription
(array element)hex stringCompressed 32-byte Fq nullifier value.

GET /v1/auth_path/:leaf_index

Returns a Merkle authentication path and the corresponding tree root for a given note commitment leaf. The wallet uses this to obtain the witness needed to generate a spend proof.

GET http://127.0.0.1:9001/v1/auth_path/42
Response 200 OK
{ "siblings": ["c3161b36...", "a8b38ec8...", ...], "position": 42, "merkle_root": "d4f12a89..." }
FieldTypeDescription
siblingsVec<hex string>32 sibling hashes from the leaf to the root (compressed Fq, 32 bytes each).
positionu64Leaf index — same as the request parameter. Use as the path position in the spend circuit.
merkle_roothex stringThe tree root at the moment the path was snapshotted. Use this as the spend circuit anchor — it is guaranteed to be consistent with the returned siblings.
Anchoring

Always use the merkle_root from this response as your spend proof anchor. The root and path are returned as an atomic snapshot, so they are always consistent with each other.

GET /v1/mempool/notes

Returns all encrypted output notes from transactions currently in the mempool (not yet confirmed on-chain). The wallet uses this to compute the pending balance shown in the dashboard.

GET http://127.0.0.1:9001/v1/mempool/notes
Response 200 OK
[ { "ephem_bytes": [178, 34, 91, 200, ...], "nonce": [142, 57, 23, 88, 0, 0, 0, 0, 0, 0, 0, 0], "ciphertext": [123, 34, 118, 97, ...] }, ... ]

The response is a flat list of EncryptedNote objects — the same format as the enc field in GET /v1/notes, but without a leaf_index (mempool notes have not yet been assigned a tree position). The wallet scans these with the viewing key to estimate the pending balance.

POST /v1/transactions

Submit a transaction to the node's mempool. The body is the hex-encoded bincode serialisation of a Transaction.

POST http://127.0.0.1:9001/v1/transactions
Content-Type: text/plain

7b22737065...  (hex-encoded bincode Transaction)
Success 200 OK
{ "tx_id": "a3f8c2d1..." }
Error 400 / 422
{ "error": "double spend: nullifier already in set" }
StatusMeaning
200Transaction accepted into mempool. Returns hex tx_id.
400Malformed request — hex decode failed or bincode deserialise failed.
422Transaction rejected — double spend, invalid proof, or balance mismatch.