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.

Unaudited. The pool has a balance cap, and the proving key comes from a single-party setup. Don't build anything that holds more than you can afford to lose.

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") });
MethodDoes
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 fresh wallet's key is returned once. Store it before you do anything else. It needs a little HYPE for gas, so send slightly more than you'll swap.

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

NetworkElysium, chain ID 99901
RPChttps://rpc.stockium.fun
LethePool0x688671bd3d4fe1cf33ee732485a3619884e8bcf0
Groth16 verifier0x0287ee5d7f307c667C9a0fC352D7151cD49B949d
Poseidon hasher0x695bB037D38f13077440AE7CEAf83bdEc2401D49
First block226607

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

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.

EndpointReturns
GET /api/configpool, chainId, relayer, feeBps, flatFee, cap, balance
GET /api/eventsevery commitment and nullifier so far
GET /api/quote?amount=1.5the fee and total for sending that many HYPE
GET /api/statsHYPE 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/relaythe transaction hash, or a schedule id with delay
GET /api/scheduled/:idstatus 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

Independent project, not affiliated with Elysium or Kinetiq. · X · Telegram