How the Loch keeps a payment private.
The technical version: what is hashed, what is proved, what is encrypted, and what an observer still sees. The plain version is on the start page.
v0.1, devnet only, unaudited
Neither the circuit nor the program has had an external audit, and the proving key comes from a one-person development setup. This page describes what is built. It is not a reason to trust it with real funds.
The model
The Loch is a shielded pool of SOL held by one Solana program, loch_pool. Value in the pool exists as notes. A note is spent whole, and what is left over comes back as a new note. This is the unspent-output model that Zcash introduced, here with two inputs and two outputs in every transaction: a 2-in, 2-out join-split.
One instruction, transact, does everything. Its proof enforces one equation over the BN254 scalar field, whose modulus is r:
sum(inputs) + (ext_amount - fee) = sum(outputs) (mod r)The sign of ext_amount is the type of the transaction. The fee comes out of the shielded value in every type.
| Type | ext_amount | Lamports the program moves | Signed by |
|---|---|---|---|
| Dive in (deposit) | positive | The amount, from the depositor to the vault | The depositor and the screening authority |
| Send privately (transfer) | zero | The fee, from the vault to the relayer | The relayer |
| Surface (withdrawal) | negative | The amount, from the vault to the destination. The fee, from the vault to the relayer | The relayer |
Clients follow three rules so that all transactions look alike. There are always two inputs: an unused one is a dummy note of amount zero with a fresh random key. There are always two outputs: an unused one is a zero-amount note to the sender’s own address, encrypted like any other. The order of the outputs is random.
Keys
Every shielded key follows from one message signed by the wallet the user already has.
message = "Loch key derivation v1"
signature = wallet.signMessage(message) 64-byte Ed25519 signature
seed = sha256(signature)
privateKey = seed mod r spends notes
publicKey = Poseidon(privateKey)
encryptionSecret = sha256("Loch note encryption v1" || seed) an X25519 key: reads notes
encryptionPublic = X25519(encryptionSecret, base point)
selfNoteKey = sha256("Loch self note v1" || seed) reads the wallet’s own notes
paymentCodeRoot = sha256("Loch payment code v1" || seed) reads notes paid to its codes
address = bech32m("loch", publicKey || encryptionPublic)
payment code = bech32m("lochpay", publicKey || encryptionPublic || sha256(paymentCodeRoot || index))A shielded address is 114 characters and starts with loch1. It carries both public keys: one to own notes, one to encrypt them to. A payment code is the address with a secret for one payer added: 168 characters, starting with lochpay1.
Ed25519 signatures are deterministic, so the same wallet derives the same keys again. That is why there is no new seed phrase. It is also the weak point. The message is not bound to a site, and a wallet shows it as ordinary text: whoever obtains that one signature holds both keys for good, and the keys cannot be rotated without moving the funds.
The spending key determines the decryption key. privateKey is seed mod r, so seed is one of at most six values, and the right one is recognised from the address. A prover that ran anywhere but on the user’s device would therefore hold the whole wallet, and not one transaction. The reverse does not hold: the three keys that read say nothing about privateKey. Separating the two fully changes every address, and is an open decision.
Notes and commitments
note = (amount, publicKey, blinding)
commitment = Poseidon(amount, publicKey, blinding)The amount is in lamports. The blinding is 31 random bytes. Only the commitment goes on chain: it says nothing about the amount or the owner, and it cannot be opened as a different note. Poseidon is circomlib’s, over BN254. Solana offers the same hash as a syscall, so the program and the circuit compute it identically.
Commitments are the leaves of a Merkle tree of depth 20, with Poseidon at every node, kept in one account. Leaves are appended left to right, two per transaction. The program keeps the 64 most recent roots and accepts a proof against any of them, so a proof made a few transactions ago still verifies.
The tree is finite. It holds 1 048 576 leaves, which is 524 288 transactions for the life of the pool. A full tree refuses every transaction, withdrawals included. A deeper tree or a rollover to a new one both change the circuit, and neither is decided.
Nullifiers
nullifier = Poseidon(commitment, leafIndex,
Poseidon(privateKey, commitment, leafIndex))Spending a note publishes its nullifier. The program creates an account at an address derived from it, ["nullifier", nullifier], and creating an account that exists fails. That is the whole double-spend protection.
The private key is inside the hash. Only the owner of a note can compute its nullifier, and nobody else can match a nullifier to a commitment. Someone who sent you a note knows its amount and blinding, and still cannot tell when you spend it.
The program refuses a nullifier that is not below r. A value N and N + r are the same field element and two different account addresses, so without that check one note could be spent twice.
The two nullifier accounts of a transaction cost rent that is never returned. Whoever signs the transaction pays it.
The proof
The proof system is Groth16 over BN254. The circuit is written in Circom, compiled with circom 2.2.3, and has 12 698 constraints. A proof is 256 bytes: three curve points of 64, 128 and 64 bytes.
For one transaction the circuit proves all of the following, and shows none of the private values:
- each input note with a non-zero amount is a leaf of the tree with the given root;
- the prover knows the private key that owns each input note;
- each nullifier is the one that belongs to its note, its leaf and that key;
- each output commitment is the hash of a note whose amount is in range;
- the inputs, the outputs and the public amount balance.
It has seven public inputs, in this order.
| Input | What it is | Where the program gets it |
|---|---|---|
root | A root of the tree | From the client. It must be one of the 64 recent roots |
publicAmount | ext_amount - fee, modulo r | It works it out again from the instruction |
extDataHash | A hash of everything public about the transaction | It works it out again from the instruction |
inputNullifier, two | The nullifiers of the two inputs | From the client. Each becomes an account |
outputCommitment, two | The commitments of the two outputs | From the client. Each becomes a leaf |
The ext data are the recipient, the relayer, ext_amount, the fee and the two encrypted notes. Their hash is sha256 over their Borsh encoding, with the first byte set to zero so that it fits in the field. The program recomputes it from what it was actually sent. Change the recipient, the fee or an encrypted note, and the proof no longer verifies. This is what stops a relayer from redirecting a payment.
The program verifies the proof with Solana’s alt_bn128 syscalls. One transact took between 159 241 and 175 768 compute units on a local validator. The verifier refuses a public input that is not below r, and a proof with a part that is all zero.
The proof is made on the user’s device. In the extension that takes about 3.5 seconds, on one thread.
The setup has one contributor
Groth16 needs a setup for each circuit, and whoever keeps the randomness of that setup can forge proofs. Loch’s proving key comes from a development setup with a single contributor, who could therefore drain the pool. A public ceremony with many contributors has to replace it. It has not been held.
Encrypted notes
Each output is published with an encrypted copy of its note, so that the recipient can find it and spend it. An encrypted note is exactly 87 bytes.
encrypted = ephemeralPublic (32) || ciphertext (39) || tag (16)
ephemeralSecret = 32 random bytes, fresh for every note
ephemeralPublic = X25519(ephemeralSecret, base point)
shared = X25519(ephemeralSecret, recipient.encryptionPublic)
salt = ephemeralPublic || recipient.encryptionPublic
key = HKDF-SHA256(shared, salt, info = "loch-note-v1") address note
key = HKDF-SHA256(shared || psk, salt, info = "loch-note-psk-v1") keyed note
plaintext = amount (8) || blinding (31)
ciphertext||tag = ChaCha20-Poly1305(key, nonce = 0, plaintext)There are two ways to derive the key, and on chain they look the same. An address note is keyed by the X25519 exchange alone: that is a payment to a plain address. A keyed note also takes a 32-byte secret, so that reading it takes the recipient’s X25519 key and the secret, both. Every note a wallet keeps is keyed with its own selfNoteKey: the note of a deposit, the change of every payment, every empty output. A payment to a payment code is keyed with the secret in the code.
The nonce is constant. That is safe here because every key encrypts exactly one message: a fresh ephemeral secret is drawn for every note.
To receive, a wallet tries to decrypt the encrypted note beside every new commitment in the pool. That costs one X25519 operation per output, then one cheap attempt per key: the wallet’s own, the plain one, and one for each payment code it is looking for. There are no view tags. A note that decrypts is accepted only if its commitment, computed again with the wallet’s own public key, equals the one on chain.
The plaintext holds the amount and the blinding, and nothing about the sender. The encrypted notes are part of the ext data, so the proof binds them and a relayer cannot swap them. There is no forward secrecy: the keys are long-lived, and whoever learns them later can read what they read.
The relayer
To send privately or to surface, the user’s wallet signs nothing on chain. The proof authorises the spend, and a relayer signs and pays for the transaction. A public wallet that paid its own network fee would be named beside the payment.
The relayer pays the network fee and the rent of the two nullifier accounts, and is paid a fee out of the vault. That fee is part of what the proof fixes. The relayer names its own fee, the default being 0.0025 SOL, and a client refuses to prove above a maximum the user has set.
The relayer cannot change the recipient, an amount or the fee. It can refuse a transaction, or delay it. It sees the client’s IP address and the moment of a transfer or a withdrawal, and can tie both to the transaction it then sends.
Deposit screening
A deposit goes through only with a second signature, from the screening authority named in the pool’s configuration, and only up to the cap per deposit set there. Without that signature the program refuses it.
The screening service decides on the public facts of a deposit: the address it comes from and the amount. It also sees the client’s IP address. It cannot see inside the pool, so it bounds who can put funds in and not who ends up with them.
The pool can be paused. The pause stops deposits and nothing else: the program has no instruction that stops a withdrawal.
Reading the chain
A wallet has to read the pool before it can pay: the tree, the new commitments, the nullifiers already spent. A sync asks only for what every client asks for, the history of the tree account. It does not name the wallet’s own notes.
The RPC endpoint still sees the client’s IP address and everything it asks. The Loch RPC proxy keeps no record of either. That is a statement about software this project runs, and a client cannot check it.
Oblivious HTTP (RFC 9458) is the way to stop depending on that statement. The client encrypts each request to the proxy’s key and sends it through a relay run by someone else. The relay sees who is asking and not what; the proxy sees what and not who. The key exchange is X-Wing, a hybrid of ML-KEM-768 and X25519, so a recording of the relay’s traffic does not open if X25519 is broken later. The gateway and the client are built. No independent relay exists yet, so this path is not in use. Either way, the RPC provider behind the proxy sees every query, coming from the proxy.
Leaks outside the Loch
A shielded balance does nothing for the history a public wallet already has. The leak scanner reads that history and scores it out of 100, with six checks.
| Finding | It fires when |
|---|---|
| Funding source is public | The wallet ever received anything |
| Linked to identified services | A counterparty carries a known label |
| Recurring counterparties | A counterparty appears three times or more |
| Your time zone is guessable | The quietest six hours of the day hold under 5% of at least 20 timed transfers |
| Exact amounts passed straight through | An outgoing amount equals an incoming one that arrived less than 600 seconds earlier |
| Balance is visible to everyone | The wallet holds over 10 SOL, or more than 10 token accounts |
The extension uses the same analysis before a public payment. A plain SOL transfer or a token transfer from the user’s wallet is assessed against the last scan, and a finding of medium severity or higher interrupts with a dialog: cancel, or send anyway. The extension also refuses to pass on a request to sign the key-derivation message.
These checks are help and not a barrier. They see only the requests that come through the Loch wallet a site was offered. A site can ask the real wallet directly, and then the wallet’s own prompt is all there is.
What an observer sees
Everything in the left column is on chain for anyone to read. The right column is what stays with the people involved.
| Transaction | Public | Shielded |
|---|---|---|
| Dive in | The depositing address, the exact amount and the time. That the screening authority signed. Two new commitments, each with an encrypted note | Which notes the deposit became, and what is done with them afterwards |
| Send privately | That it is a transfer. The root it was proved against, two nullifiers, two new commitments, two encrypted notes, the fee and the relayer | The sender, the recipient and the amount |
| Surface | The destination, the exact amount and the time. Two nullifiers, two new commitments, the fee and the relayer | Which deposit, or which payments, the funds came from |
Amounts and times correlate. Diving in with 3.1337 SOL and surfacing 3.1337 SOL an hour later links the two for any observer. The crowd a withdrawal hides in is the set of deposits that could plausibly have funded it, and on devnet that set is tiny.
What this version does not protect
- The signature is the wallet
- Any site that gets the user to sign the key-derivation message obtains the shielded keys for good. Whoever holds the wallet’s seed phrase holds the shielded balance too. There is no separate recovery and no rotation.
- One key spends and reads
- Anything given the spending key can read the wallet’s whole history and everything it will receive. There are no viewing keys to hand to a third party, and no way to revoke one.
- Dive in and surface are public
- The address, the exact amount and the time are on chain, and so are the type of every transaction, its fee and its relayer. Only the link between a deposit and what follows is hidden.
- One address for each person
- A shielded address is a stable identifier. Everyone who is given it knows they are paying the same recipient, and two people can compare. A payment code starts with the same address, so it does not change this. Anyone who knows the address can send notes to it, wanted or not.
- The network sees you
- The relayer, the screening service and the RPC endpoint each see the client’s IP address. The RPC endpoint is also trusted for what the chain contains: it can hide a spend or invent one until the wallet syncs elsewhere. Neither moves funds.
- A one-person setup, and no audit
- Whoever ran the development setup could forge proofs and drain the pool. Neither the circuit nor the program has had an external audit.
- An admin key
- The program’s upgrade authority can replace the program, and with it every rule here. The pool’s admin can replace the screening authority, change the deposit cap and pause deposits. The admin key cannot be rotated.
- A finite tree
- After 524 288 transactions the pool is frozen, withdrawals included. A transaction of zero value is valid and needs no screening, so capacity can be used up for the price of rent and fees.
A quantum computer
No machine exists that breaks today’s public-key cryptography. The chain is permanent, though, so the question is what someone who records it now could read with such a machine later. Forging is different: it needs the machine at the time, and cannot reach back.
- Stays unreadable
- Everything a wallet keeps: its deposits, its change, its balance. Those are keyed notes, and their second key is in no address. So is what a payer sends to a payment code, as long as the code itself stayed private. Proofs already on chain say nothing to anyone, whatever they can compute, and commitments and nullifiers are hashes.
- The shielded keys
- They do not follow from the wallet’s public key. A quantum computer gets the signing key from it, but the keys come from one exact signature, and making that signature again takes a second secret that is behind a hash. This argument has not been reviewed.
- Becomes readable
- A payment to a plain
loch1address, for anyone who knows the address: the amount, and which commitment it is. Not who paid, and not when it was spent. Ask for a payment code if that matters. - Can be forged, from then on
- Proofs, and with them a withdrawal of whatever the pool holds. And every Solana signature, the program’s upgrade authority included. Funds here are exactly as safe from a quantum computer as Solana is.
A post-quantum key exchange does not fit in a note: its smallest ciphertext is 1088 bytes, and a deposit has 24 bytes to spare. A secret shared in advance costs nothing on chain. Its price is that the recipient has to get the code to the payer privately.
Not built yet
- The public setup ceremony that replaces the development proving key.
- Two external audits, one of the circuit and one of the program.
- Stealth receive: an address used once for each payment. It has no design yet. A payment code is not one: it names the same owner every time.
- Viewing keys, and any way to revoke one.
- An independent Oblivious HTTP relay.
- A pool for USDC. The program handles SOL only.
- Checking the wallet’s own Ed25519 signature inside the proof, so that no derived key has to exist. This is research: Ed25519 is expensive to verify in a BN254 circuit, and a quantum computer forges that signature, which it cannot do to the derived key.
The numbers
Measured on a local validator against the current build, with real proofs.
| What | Value |
|---|---|
| Constraints in the circuit | 12 698 |
| Public inputs | 7 |
| Proof, bytes | 256 |
| Encrypted note, bytes | 87 |
| Shielded address, characters | 114 |
| Payment code, characters | 168 |
| Depth of the tree | 20 |
| Transactions the tree holds | 524 288 |
| Recent roots accepted | 64 |
Compute units for one transact | 159 241 to 175 768 |
| Largest transaction, of 1232 bytes allowed | 1208 |
| Proving time in the extension, seconds | about 3.5 |
| Default relayer fee, SOL | 0.0025 |
This website
The site you are reading is a handful of plain HTML pages. It is built once, into static files, with no framework and no application server behind it. That is a choice, and these are the reasons for it.
- A policy the browser enforces
- Every page carries a Content-Security-Policy that allows no inline script and no inline style, and names the few places a page may connect to. Pages with nothing inline can keep that policy strict. A framework that writes script or style into the page would need exceptions to it.
- Nothing from anyone else
- The fonts, the styles, the scripts and the one drawing are this site’s own files. A page makes no request to another server until you start a scan or join the waitlist.
- It reads without scripts
- The text, the cautions, these docs and the waitlist form work with JavaScript off. Only the live leak scan and the palette switch need it.
- Nothing behind the pages
- What is served is files. No program runs for each visitor, so there is none that could keep a record of visitors or be broken into, and any static host can serve the site.
- What you read is what was written
- The page in your browser is the file in the repository, with a few values filled in at build time. Once the repository is published, anyone can compare the two.
The specifications
This page is a summary. The documents that bind the code are in the repository, under docs, and where this page and one of them disagree, the document is right. The repository is not public yet. It will be published before any mainnet release.
protocol.md- The client protocol, normative: keys, addresses, notes, the tree, the transaction, sync and the wire formats. Its section 15 is the full list of what v1 does not protect.
post-quantum.md- What a quantum computer could and could not do here, part by part, and what may be claimed.
program.md- The
loch_poolprogram: instructions, accounts, errors and the invariants it relies on. services.md- The relayer and the screening service: what each learns, and the limits of screening.
rpc-proxy.md- The RPC proxy and Oblivious HTTP: the trust model and the known gaps.
benchmarks.md- Compute units, transaction sizes, proving time and rent, with the method and the raw numbers.