Docs / Developers
Build on Lethe

Lethe is a privacy pool for HYPE on Elysium. Everything is on-chain and open: any wallet, bot or app can deposit, read its own private balance and send, with or without our relayer. Start with the SDK, or go straight to the contracts.
Quick start: the SDK
The fastest way in is the Lethe SDK for Node 22+. It signs the key message, finds your private balance, builds proofs and talks to the relayer for you.
npm i viem snarkjs
curl -O https://lethe-production-a90c.up.railway.app/sdk/lethe.mjs
import { privateKeyToAccount } from "viem/accounts";
import { parseEther } from "viem";
import { Lethe } from "./lethe.mjs";
const lethe = await Lethe.connect({ account: privateKeyToAccount(process.env.KEY) });
await lethe.deposit(parseEther("1")); // public, from your wallet
console.log(await lethe.balance()); // private balance in wei
await lethe.send({ to: "0x…", amount: parseEther("0.5") });
| Method | Does |
|---|---|
Lethe.connect({ account, api }) | signs the key message and loads the proving files |
deposit(amount) | adds HYPE to the private balance (public) |
balance() | private balance in wei |
send({ to, amount, delay }) | sends through the relayer, returns the tx hash. With delay, returns a schedule id |
scheduled(id) | status of a delayed send |
sendMany(payments, { delay }) | pays a list of { to, amount }, each as its own private send |
sendToFreshWallet(amount) | sends to a new wallet, returns its address and key |
feeFor(amount) | the relayer fee in wei |
Private swaps, anywhere
Lethe doesn't run its own exchange, so you can swap wherever you like. Send HYPE privately to a fresh wallet, then swap from it on any DEX on Elysium. The DEX only sees a new wallet funded by the Lethe relayer, with no link back to yours.
const { address, privateKey } = await lethe.sendToFreshWallet(parseEther("2"));
// Swap from `address` on any DEX with `privateKey`.
The swap itself is a normal public transaction from the fresh wallet. To keep it unlinked, don't send the swapped tokens straight back to your main wallet.
Delayed sends
If a deposit and a send of the same amount land a minute apart, anyone can match them by timing. Pass delay in seconds, from 60 to 21600, and the relayer holds the send and submits it at a random time within that window.
const id = await lethe.send({ to: "0x…", amount: parseEther("1"), delay: 6 * 3600 });
await lethe.scheduled(id); // { status: "scheduled" }, later "sent" with a hash
The relayer checks the proof when you schedule, so a bad one fails straight away. The proof's deadline has to cover the whole window, and the SDK sets it for you. If a send can't go out, for example because the pool's root has moved past it, it's marked failed and your balance stays where it was.
Contracts
| Network | Elysium, chain ID 99901 |
|---|---|
| RPC | https://rpc.stockium.fun |
| LethePool | 0x688671bd3d4fe1cf33ee732485a3619884e8bcf0 |
| Groth16 verifier | 0x0287ee5d7f307c667C9a0fC352D7151cD49B949d |
| Poseidon hasher | 0x695bB037D38f13077440AE7CEAf83bdEc2401D49 |
| First block | 226607 |
How the pool works
Balances are UTXOs. Each one is a commitment in a 20-level Poseidon Merkle tree. Every transaction spends two and creates two, and a Groth16 proof shows the spent ones are in the tree without saying which. Any difference between inputs and outputs is paid in or out publicly.
commitment = poseidon(amount, pubkey, blinding)
pubkey = poseidon(privkey)
nullifier = poseidon(commitment, index, poseidon(privkey, commitment, index))
A spent balance publishes its nullifier, which the contract stores so it can't be spent twice. Empty slots use an amount of zero.
transact
Deposits, sends and merges all go through one function.
struct Ext {
address recipient;
int256 extAmount; // positive pays in, negative pays out
address relayer;
uint256 fee;
uint256 deadline;
bytes encryptedOutput1;
bytes encryptedOutput2;
}
function transact(
uint256[2] a, uint256[2][2] b, uint256[2] c,
uint256 root,
uint256[2] inputNullifier,
uint256[2] outputCommitment,
Ext ext
) payable
- Deposit:
extAmountabove zero, sent asmsg.value. Fails past the pool cap. - Send:
extAmountbelow zero paysrecipient, andfeepaysrelayer. rootmust be one of the last 100 roots, and the transaction must land beforedeadline.
The proof's public inputs are, in order: root, publicAmount, extHash, the two nullifiers and the two commitments. publicAmount is extAmount − fee mod the field, and extHash is keccak256(abi.encode(ext)) mod the field. Because extHash is in the proof, nobody can change the recipient, amount or fee after it's made.
Events
event NewCommitment(uint256 commitment, uint32 index, bytes encryptedOutput);
event NewNullifier(uint256 nullifier);
Replay every NewCommitment from the first block to rebuild the tree. Read view functions such as maxPoolBalance(), nextIndex() and isKnownRoot(root) directly.
Keys and balances
Keys come from a wallet signature over a fixed message, so the same wallet always gets the same keys.
message = "Sign to open your Lethe private balance.\n\nThis costs no gas and sends no transaction. Your signature is the key to your balance, so only sign this on the real Lethe site."
privkey = keccak256(signature ‖ "lethe-spend") mod field
aesKey = keccak256(signature ‖ "lethe-encrypt")
Each output is encrypted with AES-256-GCM: a 12-byte IV followed by the ciphertext of 47 bytes, 16 for the amount and 31 for the blinding. To find a balance, try to decrypt every NewCommitment, check that the decrypted values rebuild the commitment, and skip any whose nullifier has been published.
Using the same message and derivation means your app and the Lethe site see the same balance.
Proving
The circuit is Transaction(20): two inputs, two outputs. Prove in the browser or Node with snarkjs using the published files:
Relayer and API
You can send transact yourself, but your wallet then pays the gas and appears on the send. To stay hidden, use a relayer.
| Endpoint | Returns |
|---|---|
GET /api/config | pool, chainId, relayer, feeBps, flatFee, cap, balance |
GET /api/events | every commitment and nullifier so far |
GET /api/quote?amount=1.5 | the fee and total for sending that many HYPE |
GET /api/stats | HYPE sent out of the pool (volume) and $LETHE token trading volume in HYPE (tokenVolume), in wei, plus tokenVolumeUsd at the current HYPE price |
POST /api/relay | the transaction hash, or a schedule id with delay |
GET /api/scheduled/:id | status of a delayed send, and its hash once sent |
Every endpoint allows requests from any site. POST /api/relay takes { proof: { a, b, c }, root, inputNullifier, outputCommitment, ext }, with numbers as decimal strings, plus an optional delay in seconds. It accepts sends only, ext.relayer must be the relayer from /api/config, and the fee must be at least 0.25% of the amount plus 0.0005 HYPE. The relayer simulates first, so a bad proof never reaches the chain.
Anyone can run a relayer. It only needs a funded wallet to pay gas, and it earns the fee.
Ideas
- Private payouts: pay a list of addresses without linking them to the treasury.
- Private tipping or payroll bots.
- A wallet plugin that shows a Lethe balance next to the normal one.
- Independent relayers, so users aren't relying on ours.
Independent project, not affiliated with Elysium or Kinetiq. · X · Telegram