julien/lovepublic Fork
⑂ main
Commits
⬇ Clone ▾
git clone https://git.rickub.com/julien/love.git
git clone ssh://git@rickub.com/julien/love.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Replace hash envelopes with a ZK pool #15

Merged@julien wants to merge 18-replace-envelopes-with-zk into main
Merged as 48af16313c57.
24 files changed
modified CHANGELOG.md +47 -0
@@ -56,9 +56,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5656 same `depositId`, so depositor and claimant are linked whenever the claim
5757 happens; an envelope hides who the claimant is, not that the depositor paid
5858 them. It is a bearer envelope, not a mixer.
59+- Private transfers are now a Groth16 pool instead of hash-locked envelopes.
60+ `Love.deposit(bytes32 commitment)` locks a fixed `DENOMINATION` of 0.01 wETH
61+ and appends the commitment to a depth-10 Poseidon Merkle tree.
62+ `Love.claim(pA, pB, pC, root, nullifierHash, recipient)` pays that note's LOVE
63+ to `recipient` against a proof that the note is in the tree, without saying
64+ which. `Deposit` and `Claim` no longer share an id, so a claim is not linked
65+ to its deposit; the plain `deposit(uint256)` and `withdraw` are unchanged.
66+ **Breaking:** `Love`'s creation code changed, so its CREATE2 address moves to
67+ `0x986BA449d448912B2dC6C6C1A4E7E92512893f17` at the default salt. Instances
68+ already deployed are immutable and unaffected.
69+- `Love` deploys its own `Groth16Verifier` in its constructor rather than taking
70+ one, so the creation code stays byte-identical on every chain.
71+- The proof binds the `recipient`, so a front-runner who copies a claim out of
72+ the mempool pays the recipient and nobody else. This closes the front-running
73+ window `claim` had when it paid whoever proved the secret first.
74+- `docs/ZK.md` replaces `docs/PRIVACY.md`, and the README's "Privacy" section
75+ and "Docs" list point at it.
76+- `foundry.toml` excludes the generated `Groth16Verifier.sol` and the vendored
77+ `PoseidonT3.sol` from `forge fmt` and `forge lint`.
78+
79+### Removed
80+
81+- The envelope entrypoints `deposit(bytes32,uint256)`,
82+ `deposit(bytes32[],uint256[])` and `claim(uint256,bytes32,uint256)`, with the
83+ `Envelope` struct, `envelopes`, `nextDepositId`, the errors `UnknownDeposit`,
84+ `ZeroSecretHash`, `InvalidBatch`, `WrongSecret` and `InsufficientRemaining`,
85+ and the envelope `Deposit` and `Claim` events. **Breaking.** An envelope's
86+ secret was published with its claim and its `depositId` tied the claim to the
87+ deposit, so it was a bearer note with an anonymity set of one.
88+- `docs/PRIVACY.md` and `test/LoveEnvelope.t.sol`.
5989
6090 ### Added
6191
92+- `zk/`: the `withdraw` circom circuit (Poseidon Merkle membership at depth 10,
93+ a nullifier, and the recipient bound to the proof, about 6,100 constraints),
94+ `pnpm setup` for the Groth16 trusted setup, and `zk/lib.mjs` for making a
95+ note, rebuilding the tree and proving a claim. The setup has a single
96+ contributor in each of its two phases, so it is testnet-grade only; its
97+ outputs (`withdraw.zkey`, `withdraw.wasm`, `verification_key.json`) are
98+ committed together with the `Groth16Verifier.sol` generated from them, and a
99+ rerun makes a different pair.
100+- `src/MerkleTree.sol`, an append-only incremental Poseidon tree that keeps
101+ every root it has had, and `src/PoseidonT3.sol`, vendored from
102+ poseidon-solidity 0.0.5 with its entrypoint made inlinable. The tree holds
103+ 1,024 leaves, after which `deposit(bytes32)` reverts with `TreeFull`.
104+- `test/LoveZK.t.sol` and `test/MerkleTree.t.sol`, run against
105+ `test/fixtures/proofs.json`, real proofs made with the committed zkey.
106+ `pnpm fixture` regenerates it after a new setup.
107+- `docs/ZK.md`, covering the workflow, what the pool hides, the trusted setup
108+ and its limits.
62109 - `Love.deposit(bytes32[] secretHashes, uint256[] wethAmounts)`, which opens one
63110 secret-locked envelope per entry in a single call, with one wETH pull and one
64111 mint. A depositor can split one deposit across recipients, each with their own
@@ -56,9 +56,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
56 same `depositId`, so depositor and claimant are linked whenever the claim56 same `depositId`, so depositor and claimant are linked whenever the claim
57 happens; an envelope hides who the claimant is, not that the depositor paid57 happens; an envelope hides who the claimant is, not that the depositor paid
58 them. It is a bearer envelope, not a mixer.58 them. It is a bearer envelope, not a mixer.
59+- Private transfers are now a Groth16 pool instead of hash-locked envelopes.
60+ `Love.deposit(bytes32 commitment)` locks a fixed `DENOMINATION` of 0.01 wETH
61+ and appends the commitment to a depth-10 Poseidon Merkle tree.
62+ `Love.claim(pA, pB, pC, root, nullifierHash, recipient)` pays that note's LOVE
63+ to `recipient` against a proof that the note is in the tree, without saying
64+ which. `Deposit` and `Claim` no longer share an id, so a claim is not linked
65+ to its deposit; the plain `deposit(uint256)` and `withdraw` are unchanged.
66+ **Breaking:** `Love`'s creation code changed, so its CREATE2 address moves to
67+ `0x986BA449d448912B2dC6C6C1A4E7E92512893f17` at the default salt. Instances
68+ already deployed are immutable and unaffected.
69+- `Love` deploys its own `Groth16Verifier` in its constructor rather than taking
70+ one, so the creation code stays byte-identical on every chain.
71+- The proof binds the `recipient`, so a front-runner who copies a claim out of
72+ the mempool pays the recipient and nobody else. This closes the front-running
73+ window `claim` had when it paid whoever proved the secret first.
74+- `docs/ZK.md` replaces `docs/PRIVACY.md`, and the README's "Privacy" section
75+ and "Docs" list point at it.
76+- `foundry.toml` excludes the generated `Groth16Verifier.sol` and the vendored
77+ `PoseidonT3.sol` from `forge fmt` and `forge lint`.
78+
79+### Removed
80+
81+- The envelope entrypoints `deposit(bytes32,uint256)`,
82+ `deposit(bytes32[],uint256[])` and `claim(uint256,bytes32,uint256)`, with the
83+ `Envelope` struct, `envelopes`, `nextDepositId`, the errors `UnknownDeposit`,
84+ `ZeroSecretHash`, `InvalidBatch`, `WrongSecret` and `InsufficientRemaining`,
85+ and the envelope `Deposit` and `Claim` events. **Breaking.** An envelope's
86+ secret was published with its claim and its `depositId` tied the claim to the
87+ deposit, so it was a bearer note with an anonymity set of one.
88+- `docs/PRIVACY.md` and `test/LoveEnvelope.t.sol`.
59 89
60 ### Added90 ### Added
61 91
92+- `zk/`: the `withdraw` circom circuit (Poseidon Merkle membership at depth 10,
93+ a nullifier, and the recipient bound to the proof, about 6,100 constraints),
94+ `pnpm setup` for the Groth16 trusted setup, and `zk/lib.mjs` for making a
95+ note, rebuilding the tree and proving a claim. The setup has a single
96+ contributor in each of its two phases, so it is testnet-grade only; its
97+ outputs (`withdraw.zkey`, `withdraw.wasm`, `verification_key.json`) are
98+ committed together with the `Groth16Verifier.sol` generated from them, and a
99+ rerun makes a different pair.
100+- `src/MerkleTree.sol`, an append-only incremental Poseidon tree that keeps
101+ every root it has had, and `src/PoseidonT3.sol`, vendored from
102+ poseidon-solidity 0.0.5 with its entrypoint made inlinable. The tree holds
103+ 1,024 leaves, after which `deposit(bytes32)` reverts with `TreeFull`.
104+- `test/LoveZK.t.sol` and `test/MerkleTree.t.sol`, run against
105+ `test/fixtures/proofs.json`, real proofs made with the committed zkey.
106+ `pnpm fixture` regenerates it after a new setup.
107+- `docs/ZK.md`, covering the workflow, what the pool hides, the trusted setup
108+ and its limits.
62 - `Love.deposit(bytes32[] secretHashes, uint256[] wethAmounts)`, which opens one109 - `Love.deposit(bytes32[] secretHashes, uint256[] wethAmounts)`, which opens one
63 secret-locked envelope per entry in a single call, with one wETH pull and one110 secret-locked envelope per entry in a single call, with one wETH pull and one
64 mint. A depositor can split one deposit across recipients, each with their own111 mint. A depositor can split one deposit across recipients, each with their own
modified README.md +8 -7
@@ -54,12 +54,13 @@ non-zero if the two LOVE addresses differ. Override `PORT_A`, `PORT_B`,
5454
5555 ## Privacy
5656
57-Besides the plain `deposit(uint256)`, LOVE can be locked behind a secret
58-instead of a recipient address — `deposit(bytes32 secretHash, uint256)` mints
59-into an escrowed envelope, and `claim(depositId, secret, loveAmount)` releases
60-it to whoever proves the secret. Full workflow, the off-chain requirements
61-that make it actually private, and the front-running window it opens: see
62-[docs/PRIVACY.md](docs/PRIVACY.md).
57+Besides the plain `deposit(uint256)`, LOVE has a private pool. `deposit(bytes32
58+commitment)` locks 0.01 wETH against a note, and `claim(proof, root,
59+nullifierHash, recipient)` pays its LOVE to any address whose owner can prove,
60+in zero knowledge, that they hold a note in the pool, without saying which. The
61+circuit and its [Groth16](https://eprint.iacr.org/2016/260) setup live in
62+[`zk/`](zk/). The setup has a single contributor, so treat it as testnet-grade.
63+Full workflow, what the pool hides and its limits: see [docs/ZK.md](docs/ZK.md).
6364
6465 ## How wETH is chosen
6566
@@ -115,7 +116,7 @@ yourself: see [docs/MULTICHAIN.md](docs/MULTICHAIN.md).
115116 ## Docs
116117
117118 - [Multichain deployment](docs/MULTICHAIN.md) — CREATE2, the codehash allowlist, and verifying determinism
118-- [Privacy](docs/PRIVACY.md) — sending LOVE by secret instead of by address
119+- [ZK pool](docs/ZK.md) — sending LOVE privately with Groth16 proofs, and where the trust and capacity limits are
119120 - [Supported networks](docs/SUPPORTED_NETWORKS.md) — where LOVE deploys today and what could be added
120121
121122 ## License
@@ -54,12 +54,13 @@ non-zero if the two LOVE addresses differ. Override `PORT_A`, `PORT_B`,
54 54
55 ## Privacy55 ## Privacy
56 56
57-Besides the plain `deposit(uint256)`, LOVE can be locked behind a secret57+Besides the plain `deposit(uint256)`, LOVE has a private pool. `deposit(bytes32
58-instead of a recipient address — `deposit(bytes32 secretHash, uint256)` mints58+commitment)` locks 0.01 wETH against a note, and `claim(proof, root,
59-into an escrowed envelope, and `claim(depositId, secret, loveAmount)` releases59+nullifierHash, recipient)` pays its LOVE to any address whose owner can prove,
60-it to whoever proves the secret. Full workflow, the off-chain requirements60+in zero knowledge, that they hold a note in the pool, without saying which. The
61-that make it actually private, and the front-running window it opens: see61+circuit and its [Groth16](https://eprint.iacr.org/2016/260) setup live in
62-[docs/PRIVACY.md](docs/PRIVACY.md).62+[`zk/`](zk/). The setup has a single contributor, so treat it as testnet-grade.
63+Full workflow, what the pool hides and its limits: see [docs/ZK.md](docs/ZK.md).
63 64
64 ## How wETH is chosen65 ## How wETH is chosen
65 66
@@ -115,7 +116,7 @@ yourself: see [docs/MULTICHAIN.md](docs/MULTICHAIN.md).
115 ## Docs116 ## Docs
116 117
117 - [Multichain deployment](docs/MULTICHAIN.md) — CREATE2, the codehash allowlist, and verifying determinism118 - [Multichain deployment](docs/MULTICHAIN.md) — CREATE2, the codehash allowlist, and verifying determinism
118-- [Privacy](docs/PRIVACY.md) — sending LOVE by secret instead of by address119+- [ZK pool](docs/ZK.md) — sending LOVE privately with Groth16 proofs, and where the trust and capacity limits are
119 - [Supported networks](docs/SUPPORTED_NETWORKS.md) — where LOVE deploys today and what could be added120 - [Supported networks](docs/SUPPORTED_NETWORKS.md) — where LOVE deploys today and what could be added
120 121
121 ## License122 ## License
deleted docs/PRIVACY.md +0 -164
deleted file mode 100644
@@ -1,164 +0,0 @@
1-# Privacy
2-
3-How to send LOVE by secret instead of by address, what the depositor and recipient each do, and which parts of that flow the contract cannot enforce.
4-
5-## Contents
6-
7-- [Privacy](#privacy)
8- - [Contents](#contents)
9- - [Overview](#overview)
10- - [The workflow](#the-workflow)
11- - [Code snippets](#code-snippets)
12- - [What's happening off-chain](#whats-happening-off-chain)
13- - [Bearer semantics and the front-running window](#bearer-semantics-and-the-front-running-window)
14- - [Splitting one deposit](#splitting-one-deposit)
15- - [Reference](#reference)
16- - [Further reading](#further-reading)
17-
18-## Overview
19-
20-[`Love`](../src/Love.sol) normally works like any [ERC-20](https://eips.ethereum.org/EIPS/eip-20): `deposit(uint256 wethAmount)` locks wETH and mints LOVE straight to the caller. A second, overloaded entrypoint, `deposit(bytes32 secretHash, uint256 wethAmount)`, mints that same LOVE into an escrowed envelope instead — unlockable by whoever can later produce the secret behind `secretHash`, not by whoever the depositor names on-chain. `claim` releases it.
21-
22-That on-chain pair is deliberately minimal: it stores a hash and moves tokens. Whether using it actually keeps a transfer private depends entirely on what the depositor and recipient do around it, off-chain, before and after those two calls. This document walks through that whole flow, on-chain and off.
23-
24-Be clear about what that buys. `Deposit` and `Claim` carry the same `depositId`, so the depositor's address and the claimant's address are linked on-chain, whenever the claim happens. What an envelope hides is who the claimant is: the depositor never names them, and the claiming address can be one nobody ties to the recipient. It is a bearer envelope, not a mixer — there is no shared pool to hide in, and no anonymity set larger than one.
25-
26-## The workflow
27-
28-1. **The depositor generates a secret — off-chain.** A random `bytes32`, produced locally with a cryptographically secure generator ([`crypto.getRandomValues`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) in a browser, or a wallet's native RNG), never sent anywhere.
29-2. **The depositor opens an envelope — on-chain.** `deposit(keccak256(abi.encodePacked(secret)), wethAmount)` pulls the wETH, mints `wethAmount * RATE` LOVE, and locks it under a fresh `depositId`. Only the hash goes on-chain; the secret itself never does.
30-3. **The depositor packages the claim code — off-chain.** `depositId` and `secret` together, typically as a URL fragment (`#depositId=...&secret=...`) or a QR code — a fragment specifically, since fragments never reach a server, unlike a query string.
31-4. **The depositor shares it — off-chain.** Over whatever out-of-band channel they already trust: an end-to-end encrypted chat, a QR code shown in person, a printed card. Never a channel with a persistent public log.
32-5. **The recipient stores it — off-chain.** As a bearer instrument: whoever holds the code can claim, and there is no recovery if it's lost.
33-6. **The recipient claims — on-chain.** `claim(depositId, secret, loveAmount)` proves knowledge of the secret and moves `loveAmount` LOVE from escrow to whoever called it. No address is checked, so this can be anyone who has the code — including, deliberately, someone other than the depositor's intended counterpart. The claim names the same `depositId` as the deposit, so the two stay linked on-chain; what the depositor never publishes is who the claimant is.
34-7. **The recipient redeems for wETH — on-chain, optional, and later.** The claimed LOVE is now an ordinary balance. `withdraw(loveAmount)`, entirely unrelated to any of the above, burns it for wETH whenever the recipient chooses.
35-
36-## Code snippets
37-
38-The off-chain steps above, in [TypeScript](https://www.typescriptlang.org/) using [ethers](https://docs.ethers.org/v6/) v6. `love` is a `Contract` instance for [`Love`](../src/Love.sol); `weth` one for the registered wETH.
39-
40-**Generating a secret.** Client-side, with the browser's CSPRNG — never a library seeded from anything predictable, and never sent to a server.
41-
42-```ts
43-function generateSecret(): `0x${string}` {
44- const bytes = new Uint8Array(32);
45- crypto.getRandomValues(bytes);
46- return ethers.hexlify(bytes) as `0x${string}`;
47-}
48-```
49-
50-**Opening an envelope.** Hash the secret, approve the wETH, then call the secret-locked `deposit` overload — ethers needs the full signature to pick it over the plain `deposit(uint256)`.
51-
52-```ts
53-async function openEnvelope(wethAmount: bigint) {
54- const secret = generateSecret();
55- const secretHash = ethers.keccak256(secret);
56-
57- await (await weth.approve(love.target, wethAmount)).wait();
58-
59- const tx = await love.getFunction("deposit(bytes32,uint256)")(secretHash, wethAmount);
60- const receipt = await tx.wait();
61-
62- const deposited = receipt.logs
63- .map((log: any) => love.interface.parseLog(log))
64- .find((parsed: any) => parsed?.name === "Deposit" && parsed.args.length === 5);
65-
66- const depositId: bigint = deposited!.args.depositId;
67- return { depositId, secret };
68-}
69-```
70-
71-**Packaging the claim link.** A URL *fragment*, so a link-preview bot hitting the claim page server-side never sees the secret.
72-
73-```ts
74-function buildClaimUrl(depositId: bigint, secret: `0x${string}`): string {
75- const url = new URL("https://app.example/claim");
76- url.hash = `depositId=${depositId}&secret=${secret}`;
77- return url.toString();
78-}
79-```
80-
81-**Parsing it back out**, on the claim page — reading `location.hash` client-side only, never anything the server saw.
82-
83-```ts
84-function parseClaimUrl(url: string): { depositId: bigint; secret: `0x${string}` } {
85- const params = new URLSearchParams(new URL(url).hash.slice(1));
86- return {
87- depositId: BigInt(params.get("depositId")!),
88- secret: params.get("secret") as `0x${string}`,
89- };
90-}
91-```
92-
93-**Claiming.**
94-
95-```ts
96-async function claimEnvelope(depositId: bigint, secret: `0x${string}`, loveAmount: bigint) {
97- const tx = await love.claim(depositId, secret, loveAmount);
98- await tx.wait();
99-}
100-```
101-
102-## What's happening off-chain
103-
104-None of the steps above marked off-chain are visible to, or enforced by, the contract. They are the actual privacy mechanism; the contract just custodies funds against a hash. Six things have to go right:
105-
106-- **Generation.** The secret must never exist on a server — not for "generating a shareable link," not for anything. A secret a backend could reconstruct or log defeats the design before the first transaction.
107-- **Encoding.** Packing `depositId` and `secret` into a URL *fragment* rather than a query parameter means a link-preview bot, a server access log, or an analytics pixel on the claim page can't capture the secret just from the page being visited.
108-- **Distribution.** Privacy comes from the channel's own properties — end-to-end encryption, no third-party log — not from anything on-chain. Posting a claim link in a public Discord or a public GitHub issue undoes the whole design before anyone even claims.
109-- **Storage and recovery.** An unclaimed code is a bearer note. There's no on-chain notion of "identity," only "knowledge of the secret," so a lost code means permanently locked funds — the wallet holding it should treat it with the same care as a seed phrase fragment.
110-- **Timing discipline.** Waiting does not unlink anything: the `depositId` ties a `Claim` to its `Deposit` whenever it lands. Timing still matters for who the claimant is. A `Claim` seconds after a `Deposit`, or right after the depositor's known contact with someone, points at the owner of the claiming address. Claim when nothing else ties the claimant to the depositor's activity.
111-- **Operational hygiene.** No indexer should build a queryable depositId-to-claimant lookup, and no front-end should let a server "preview" a claim URL — both leak exactly the correlation this design exists to avoid.
112-
113-The full detail behind each of these — including recommendations for wallets and front-ends building on top of LOVE — is in [`docs/notes/add-privacy-to-love.md`](notes/add-privacy-to-love.md).
114-
115-## Bearer semantics and the front-running window
116-
117-`claim` has no recipient check on purpose: paying out to whoever proves the secret first is what makes the envelope a bearer instrument rather than an address-bound transfer. That same property opens a front-running window. `remaining` supports partial claims, so if a second, smaller claim is expected later, the *first* `claim`'s calldata already reveals `secret` publicly — in the mempool before confirmation, and permanently once mined. A bot watching for that calldata can submit its own `claim` for whatever is `remaining` before the intended recipient's second call lands.
118-
119-Two mitigations, both off-chain, both covered in the note above:
120-
121-- Submit `claim` through a private transaction relay (e.g. [Flashbots Protect](https://docs.flashbots.net/flashbots-protect/overview)) whenever a further partial claim is expected against the same envelope.
122-- Prefer a single full claim for high-value envelopes, reserving multi-claim convenience for lower-stakes amounts where the exposure matters less.
123-
124-## Splitting one deposit
125-
126-To pay several recipients from one deposit, open one envelope per recipient with `deposit(bytes32[] secretHashes, uint256[] wethAmounts)`. It takes one approval, pulls the summed wETH once, mints once, and opens an envelope per entry, each with its own secret and its own amount. Every recipient gets their own claim code and can claim exactly their share, in one full `claim`; the code for one envelope opens nothing else.
127-
128-A single secret cannot do this: whoever holds it can claim everything `remaining`, and nothing caps a claimant at their share.
129-
130-Splitting 1 wETH into 0.2 and 0.8 — ethers needs the full signature to pick this overload:
131-
132-```ts
133-const secrets = [generateSecret(), generateSecret()];
134-const secretHashes = secrets.map((secret) => ethers.keccak256(secret));
135-const wethAmounts = [ethers.parseEther("0.2"), ethers.parseEther("0.8")];
136-
137-await (await weth.approve(love.target, ethers.parseEther("1"))).wait();
138-
139-const tx = await love.getFunction("deposit(bytes32[],uint256[])")(secretHashes, wethAmounts);
140-const receipt = await tx.wait();
141-```
142-
143-The ids are consecutive, starting at `nextDepositId`, and come back in input order; read them from the `Deposit` events as in the single-envelope flow. Package each `depositId` with its own `secret` and hand each recipient only theirs.
144-
145-Splitting is a convenience, not a privacy feature. Every envelope still names the same depositor, and consecutive ids from one transaction show that the envelopes belong together. It does close the front-running window above: each recipient claims in full, once, so no partial claim ever reveals a secret that still guards funds.
146-
147-## Reference
148-
149-| Function | Effect |
150-|---|---|
151-| `deposit(uint256 wethAmount)` | Plain peg deposit — mints LOVE straight to the caller. |
152-| `deposit(bytes32 secretHash, uint256 wethAmount) returns (uint256 depositId)` | Opens an envelope: mints LOVE into escrow, locked behind `secretHash`. |
153-| `deposit(bytes32[] secretHashes, uint256[] wethAmounts) returns (uint256[] depositIds)` | Opens one envelope per entry in a single call, each with its own secret and amount. |
154-| `claim(uint256 depositId, bytes32 secret, uint256 loveAmount)` | Releases up to `loveAmount` LOVE from an envelope to whoever proves `secret`. |
155-| `withdraw(uint256 loveAmount)` | Burns the caller's own LOVE for wETH. Unrelated to envelopes; unchanged by this feature. |
156-
157-Events: `Deposit` (both overloads), `Claim`, `Withdraw`. Errors specific to envelopes: `UnknownDeposit`, `ZeroSecretHash`, `InvalidBatch`, `WrongSecret`, `InsufficientRemaining`.
158-
159-## Further reading
160-
161-- [`docs/notes/add-privacy-to-love.md`](notes/add-privacy-to-love.md) — the off-chain processes this document summarizes, in full
162-- [MDN: Crypto.getRandomValues()](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues)
163-- [Flashbots Protect — private transaction relay](https://docs.flashbots.net/flashbots-protect/overview)
164-- [URL fragment identifier — MDN](https://developer.mozilla.org/en-US/docs/Web/URI/Fragment)
deleted file mode 100644
@@ -1,164 +0,0 @@
1-# Privacy
2-
3-How to send LOVE by secret instead of by address, what the depositor and recipient each do, and which parts of that flow the contract cannot enforce.
4-
5-## Contents
6-
7-- [Privacy](#privacy)
8- - [Contents](#contents)
9- - [Overview](#overview)
10- - [The workflow](#the-workflow)
11- - [Code snippets](#code-snippets)
12- - [What's happening off-chain](#whats-happening-off-chain)
13- - [Bearer semantics and the front-running window](#bearer-semantics-and-the-front-running-window)
14- - [Splitting one deposit](#splitting-one-deposit)
15- - [Reference](#reference)
16- - [Further reading](#further-reading)
17-
18-## Overview
19-
20-[`Love`](../src/Love.sol) normally works like any [ERC-20](https://eips.ethereum.org/EIPS/eip-20): `deposit(uint256 wethAmount)` locks wETH and mints LOVE straight to the caller. A second, overloaded entrypoint, `deposit(bytes32 secretHash, uint256 wethAmount)`, mints that same LOVE into an escrowed envelope instead — unlockable by whoever can later produce the secret behind `secretHash`, not by whoever the depositor names on-chain. `claim` releases it.
21-
22-That on-chain pair is deliberately minimal: it stores a hash and moves tokens. Whether using it actually keeps a transfer private depends entirely on what the depositor and recipient do around it, off-chain, before and after those two calls. This document walks through that whole flow, on-chain and off.
23-
24-Be clear about what that buys. `Deposit` and `Claim` carry the same `depositId`, so the depositor's address and the claimant's address are linked on-chain, whenever the claim happens. What an envelope hides is who the claimant is: the depositor never names them, and the claiming address can be one nobody ties to the recipient. It is a bearer envelope, not a mixer — there is no shared pool to hide in, and no anonymity set larger than one.
25-
26-## The workflow
27-
28-1. **The depositor generates a secret — off-chain.** A random `bytes32`, produced locally with a cryptographically secure generator ([`crypto.getRandomValues`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) in a browser, or a wallet's native RNG), never sent anywhere.
29-2. **The depositor opens an envelope — on-chain.** `deposit(keccak256(abi.encodePacked(secret)), wethAmount)` pulls the wETH, mints `wethAmount * RATE` LOVE, and locks it under a fresh `depositId`. Only the hash goes on-chain; the secret itself never does.
30-3. **The depositor packages the claim code — off-chain.** `depositId` and `secret` together, typically as a URL fragment (`#depositId=...&secret=...`) or a QR code — a fragment specifically, since fragments never reach a server, unlike a query string.
31-4. **The depositor shares it — off-chain.** Over whatever out-of-band channel they already trust: an end-to-end encrypted chat, a QR code shown in person, a printed card. Never a channel with a persistent public log.
32-5. **The recipient stores it — off-chain.** As a bearer instrument: whoever holds the code can claim, and there is no recovery if it's lost.
33-6. **The recipient claims — on-chain.** `claim(depositId, secret, loveAmount)` proves knowledge of the secret and moves `loveAmount` LOVE from escrow to whoever called it. No address is checked, so this can be anyone who has the code — including, deliberately, someone other than the depositor's intended counterpart. The claim names the same `depositId` as the deposit, so the two stay linked on-chain; what the depositor never publishes is who the claimant is.
34-7. **The recipient redeems for wETH — on-chain, optional, and later.** The claimed LOVE is now an ordinary balance. `withdraw(loveAmount)`, entirely unrelated to any of the above, burns it for wETH whenever the recipient chooses.
35-
36-## Code snippets
37-
38-The off-chain steps above, in [TypeScript](https://www.typescriptlang.org/) using [ethers](https://docs.ethers.org/v6/) v6. `love` is a `Contract` instance for [`Love`](../src/Love.sol); `weth` one for the registered wETH.
39-
40-**Generating a secret.** Client-side, with the browser's CSPRNG — never a library seeded from anything predictable, and never sent to a server.
41-
42-```ts
43-function generateSecret(): `0x${string}` {
44- const bytes = new Uint8Array(32);
45- crypto.getRandomValues(bytes);
46- return ethers.hexlify(bytes) as `0x${string}`;
47-}
48-```
49-
50-**Opening an envelope.** Hash the secret, approve the wETH, then call the secret-locked `deposit` overload — ethers needs the full signature to pick it over the plain `deposit(uint256)`.
51-
52-```ts
53-async function openEnvelope(wethAmount: bigint) {
54- const secret = generateSecret();
55- const secretHash = ethers.keccak256(secret);
56-
57- await (await weth.approve(love.target, wethAmount)).wait();
58-
59- const tx = await love.getFunction("deposit(bytes32,uint256)")(secretHash, wethAmount);
60- const receipt = await tx.wait();
61-
62- const deposited = receipt.logs
63- .map((log: any) => love.interface.parseLog(log))
64- .find((parsed: any) => parsed?.name === "Deposit" && parsed.args.length === 5);
65-
66- const depositId: bigint = deposited!.args.depositId;
67- return { depositId, secret };
68-}
69-```
70-
71-**Packaging the claim link.** A URL *fragment*, so a link-preview bot hitting the claim page server-side never sees the secret.
72-
73-```ts
74-function buildClaimUrl(depositId: bigint, secret: `0x${string}`): string {
75- const url = new URL("https://app.example/claim");
76- url.hash = `depositId=${depositId}&secret=${secret}`;
77- return url.toString();
78-}
79-```
80-
81-**Parsing it back out**, on the claim page — reading `location.hash` client-side only, never anything the server saw.
82-
83-```ts
84-function parseClaimUrl(url: string): { depositId: bigint; secret: `0x${string}` } {
85- const params = new URLSearchParams(new URL(url).hash.slice(1));
86- return {
87- depositId: BigInt(params.get("depositId")!),
88- secret: params.get("secret") as `0x${string}`,
89- };
90-}
91-```
92-
93-**Claiming.**
94-
95-```ts
96-async function claimEnvelope(depositId: bigint, secret: `0x${string}`, loveAmount: bigint) {
97- const tx = await love.claim(depositId, secret, loveAmount);
98- await tx.wait();
99-}
100-```
101-
102-## What's happening off-chain
103-
104-None of the steps above marked off-chain are visible to, or enforced by, the contract. They are the actual privacy mechanism; the contract just custodies funds against a hash. Six things have to go right:
105-
106-- **Generation.** The secret must never exist on a server — not for "generating a shareable link," not for anything. A secret a backend could reconstruct or log defeats the design before the first transaction.
107-- **Encoding.** Packing `depositId` and `secret` into a URL *fragment* rather than a query parameter means a link-preview bot, a server access log, or an analytics pixel on the claim page can't capture the secret just from the page being visited.
108-- **Distribution.** Privacy comes from the channel's own properties — end-to-end encryption, no third-party log — not from anything on-chain. Posting a claim link in a public Discord or a public GitHub issue undoes the whole design before anyone even claims.
109-- **Storage and recovery.** An unclaimed code is a bearer note. There's no on-chain notion of "identity," only "knowledge of the secret," so a lost code means permanently locked funds — the wallet holding it should treat it with the same care as a seed phrase fragment.
110-- **Timing discipline.** Waiting does not unlink anything: the `depositId` ties a `Claim` to its `Deposit` whenever it lands. Timing still matters for who the claimant is. A `Claim` seconds after a `Deposit`, or right after the depositor's known contact with someone, points at the owner of the claiming address. Claim when nothing else ties the claimant to the depositor's activity.
111-- **Operational hygiene.** No indexer should build a queryable depositId-to-claimant lookup, and no front-end should let a server "preview" a claim URL — both leak exactly the correlation this design exists to avoid.
112-
113-The full detail behind each of these — including recommendations for wallets and front-ends building on top of LOVE — is in [`docs/notes/add-privacy-to-love.md`](notes/add-privacy-to-love.md).
114-
115-## Bearer semantics and the front-running window
116-
117-`claim` has no recipient check on purpose: paying out to whoever proves the secret first is what makes the envelope a bearer instrument rather than an address-bound transfer. That same property opens a front-running window. `remaining` supports partial claims, so if a second, smaller claim is expected later, the *first* `claim`'s calldata already reveals `secret` publicly — in the mempool before confirmation, and permanently once mined. A bot watching for that calldata can submit its own `claim` for whatever is `remaining` before the intended recipient's second call lands.
118-
119-Two mitigations, both off-chain, both covered in the note above:
120-
121-- Submit `claim` through a private transaction relay (e.g. [Flashbots Protect](https://docs.flashbots.net/flashbots-protect/overview)) whenever a further partial claim is expected against the same envelope.
122-- Prefer a single full claim for high-value envelopes, reserving multi-claim convenience for lower-stakes amounts where the exposure matters less.
123-
124-## Splitting one deposit
125-
126-To pay several recipients from one deposit, open one envelope per recipient with `deposit(bytes32[] secretHashes, uint256[] wethAmounts)`. It takes one approval, pulls the summed wETH once, mints once, and opens an envelope per entry, each with its own secret and its own amount. Every recipient gets their own claim code and can claim exactly their share, in one full `claim`; the code for one envelope opens nothing else.
127-
128-A single secret cannot do this: whoever holds it can claim everything `remaining`, and nothing caps a claimant at their share.
129-
130-Splitting 1 wETH into 0.2 and 0.8 — ethers needs the full signature to pick this overload:
131-
132-```ts
133-const secrets = [generateSecret(), generateSecret()];
134-const secretHashes = secrets.map((secret) => ethers.keccak256(secret));
135-const wethAmounts = [ethers.parseEther("0.2"), ethers.parseEther("0.8")];
136-
137-await (await weth.approve(love.target, ethers.parseEther("1"))).wait();
138-
139-const tx = await love.getFunction("deposit(bytes32[],uint256[])")(secretHashes, wethAmounts);
140-const receipt = await tx.wait();
141-```
142-
143-The ids are consecutive, starting at `nextDepositId`, and come back in input order; read them from the `Deposit` events as in the single-envelope flow. Package each `depositId` with its own `secret` and hand each recipient only theirs.
144-
145-Splitting is a convenience, not a privacy feature. Every envelope still names the same depositor, and consecutive ids from one transaction show that the envelopes belong together. It does close the front-running window above: each recipient claims in full, once, so no partial claim ever reveals a secret that still guards funds.
146-
147-## Reference
148-
149-| Function | Effect |
150-|---|---|
151-| `deposit(uint256 wethAmount)` | Plain peg deposit — mints LOVE straight to the caller. |
152-| `deposit(bytes32 secretHash, uint256 wethAmount) returns (uint256 depositId)` | Opens an envelope: mints LOVE into escrow, locked behind `secretHash`. |
153-| `deposit(bytes32[] secretHashes, uint256[] wethAmounts) returns (uint256[] depositIds)` | Opens one envelope per entry in a single call, each with its own secret and amount. |
154-| `claim(uint256 depositId, bytes32 secret, uint256 loveAmount)` | Releases up to `loveAmount` LOVE from an envelope to whoever proves `secret`. |
155-| `withdraw(uint256 loveAmount)` | Burns the caller's own LOVE for wETH. Unrelated to envelopes; unchanged by this feature. |
156-
157-Events: `Deposit` (both overloads), `Claim`, `Withdraw`. Errors specific to envelopes: `UnknownDeposit`, `ZeroSecretHash`, `InvalidBatch`, `WrongSecret`, `InsufficientRemaining`.
158-
159-## Further reading
160-
161-- [`docs/notes/add-privacy-to-love.md`](notes/add-privacy-to-love.md) — the off-chain processes this document summarizes, in full
162-- [MDN: Crypto.getRandomValues()](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues)
163-- [Flashbots Protect — private transaction relay](https://docs.flashbots.net/flashbots-protect/overview)
164-- [URL fragment identifier — MDN](https://developer.mozilla.org/en-US/docs/Web/URI/Fragment)
added docs/ZK.md +152 -0
new file mode 100644
@@ -0,0 +1,152 @@
1+# Private LOVE with zero-knowledge proofs
2+
3+How LOVE's Groth16 pool works, what the depositor and recipient each do, what it hides, and where its trust and capacity limits are.
4+
5+## Overview
6+
7+[`Love`](../src/Love.sol) is an [ERC-20](https://eips.ethereum.org/EIPS/eip-20) pegged to wETH. Besides the plain `deposit(uint256 wethAmount)`, it has a private pool built on a [zero-knowledge proof](https://en.wikipedia.org/wiki/Zero-knowledge_proof):
8+
9+- `deposit(bytes32 commitment)` locks a fixed `DENOMINATION` of wETH (0.01, worth 1,000 LOVE) and adds `commitment` to a [Merkle tree](https://en.wikipedia.org/wiki/Merkle_tree).
10+- `claim(pA, pB, pC, root, nullifierHash, recipient)` pays that much LOVE to `recipient`, given a proof that the caller knows *a* note in the tree. The proof does not say which one.
11+
12+A claim is not linked to its deposit. The contract can't tell which of the deposits in the tree a claim came from, and neither can anyone reading the chain. That is the anonymity set: every deposit under the root the claim was proved against.
13+
14+The commitment-and-nullifier scheme is the one [Tornado Cash](https://en.wikipedia.org/wiki/Tornado_Cash) popularised. The circuit is written in [circom](https://docs.circom.io/), proved and set up with [snarkjs](https://github.com/iden3/snarkjs), and uses the [Groth16](https://eprint.iacr.org/2016/260) proof system, which the EVM verifies cheaply through the alt_bn128 precompiles ([EIP-196](https://eips.ethereum.org/EIPS/eip-196), [EIP-197](https://eips.ethereum.org/EIPS/eip-197)).
15+
16+This replaces the earlier hash-locked envelopes. An envelope's `Deposit` and `Claim` shared a `depositId`, and the claim published the secret's preimage, so the two were linked on-chain by construction. A proof is what lets a claim leave that link out.
17+
18+## What it hides, and what it doesn't
19+
20+| Public | Hidden |
21+|---|---|
22+| Who deposited, and when | Which deposit a claim came from |
23+| That the amount is 0.01 wETH (it always is) | Who is behind the claiming address |
24+| Who was paid, and when | Which note a recipient holds, until they claim |
25+| The `nullifierHash` of a spent note | The note's `nullifier` and `secret` |
26+
27+Two things follow:
28+
29+- **Privacy grows with the pool.** With one deposit in the tree, a claim can only have come from it. The guarantee is "one of the N deposits under this root".
30+- **A root leaks an upper bound.** Proving against an old root says the note was deposited by then. Proving against the latest root gives the largest set.
31+
32+## The workflow
33+
34+1. **The depositor makes a note, off-chain.** `newNote()` draws a random `nullifier` and `secret`, each 248 bits, with the platform's CSPRNG. `noteHashes(note)` gives the `commitment`, `Poseidon(nullifier, secret)`.
35+2. **The depositor deposits, on-chain.** `deposit(commitment)` pulls 0.01 wETH, mints its LOVE into escrow with the contract, and inserts the commitment as the next leaf. Only the commitment goes on-chain.
36+3. **The depositor hands over the note, off-chain.** `encodeNote(note)` is one `0x` string of 124 hex digits. It is a bearer instrument: whoever holds it can claim, and there is no recovery if it is lost. Send it over a channel with no public log.
37+4. **The recipient rebuilds the tree, off-chain.** From the `Deposit` events, in `leafIndex` order.
38+5. **The recipient proves, off-chain.** `prove({ note, commitments, recipient })` produces a Groth16 proof bound to `recipient`, on the recipient's own machine. This takes a couple of seconds.
39+6. **Anyone claims, on-chain.** `claim(...)` verifies the proof, marks the `nullifierHash` spent, and transfers the LOVE to `recipient`. The caller can be any address, because the proof only pays `recipient`.
40+7. **The recipient redeems, on-chain, optional.** Claimed LOVE is an ordinary balance. `withdraw(loveAmount)` burns it for wETH whenever they choose.
41+
42+## Code snippets
43+
44+The client library is [`zk/lib.mjs`](../zk/lib.mjs). The snippets use [ethers](https://docs.ethers.org/v6/) v6, with `love` a `Contract` for [`Love`](../src/Love.sol) and `weth` one for the registered wETH. Ethers needs the full signature to pick between the overloaded `deposit` and `Deposit`.
45+
46+**Making a note and depositing.**
47+
48+```js
49+import { newNote, encodeNote, noteHashes } from "./zk/lib.mjs";
50+
51+const note = newNote();
52+const { commitment } = await noteHashes(note);
53+
54+const denomination = await love.DENOMINATION();
55+await (await weth.approve(love.target, denomination)).wait();
56+await (await love.getFunction("deposit(bytes32)")(commitment)).wait();
57+
58+const claimCode = encodeNote(note); // hand this over, and nothing else
59+```
60+
61+**Proving.** The commitments, in leaf order, are what rebuilds the tree.
62+
63+```js
64+import { decodeNote, prove } from "./zk/lib.mjs";
65+
66+const logs = await love.queryFilter(love.filters["Deposit(bytes32,uint256)"]());
67+const commitments = logs
68+ .sort((a, b) => Number(a.args.leafIndex - b.args.leafIndex))
69+ .map((log) => log.args.commitment);
70+
71+const claim = await prove({ note: decodeNote(claimCode), commitments, recipient });
72+```
73+
74+**Claiming.** From any funded address. Using one that isn't tied to the recipient matters, as the limits below explain.
75+
76+```js
77+await love.claim(claim.pA, claim.pB, claim.pC, claim.root, claim.nullifierHash, claim.recipient);
78+```
79+
80+`prove` rebuilds the tree from whatever `commitments` it is given, so passing a prefix proves against an older root. Some RPC providers cap the range of a `queryFilter`, in which case page through it.
81+
82+## How it works
83+
84+**The note.** `commitment = Poseidon(nullifier, secret)` and `nullifierHash = Poseidon(nullifier)`, with [Poseidon](https://www.poseidon-hash.info/) as the hash, since it is cheap inside a circuit. Publishing `nullifierHash` on claim marks the note spent without revealing which leaf it is, because nobody can go from `nullifierHash` back to `nullifier` and so to `commitment`.
85+
86+**The tree.** [`MerkleTree`](../src/MerkleTree.sol) is an append-only, incremental Poseidon tree of depth 10, so 1,024 leaves. An insert costs ten hashes and a couple of storage writes however full the tree is. Every root it has ever had stays valid, so a proof made against a slightly stale tree still lands after later deposits. [`PoseidonT3`](../src/PoseidonT3.sol) is vendored from [poseidon-solidity](https://github.com/vimwitch/poseidon-solidity), with its entrypoint changed to be inlinable and its round function untouched; its header says exactly what changed. Its output equals [circomlib](https://github.com/iden3/circomlib)'s `Poseidon(2)`, which is what [`MerkleTree.t.sol`](../test/MerkleTree.t.sol) checks against roots built by `zk/lib.mjs`.
87+
88+**The circuit.** [`withdraw.circom`](../zk/circuits/withdraw.circom) has about 6,100 constraints.
89+
90+| Input | Visibility | |
91+|---|---|---|
92+| `root` | public | A root of the tree |
93+| `nullifierHash` | public | Published on claim |
94+| `recipient` | public | Who gets paid |
95+| `nullifier`, `secret` | private | The note |
96+| `pathElements`, `pathIndices` | private | The Merkle path to the leaf |
97+
98+It proves that hashing the note gives a leaf, that the path takes that leaf to `root`, and that `nullifierHash` belongs to the note. `recipient` is bound into the proof, so a front-runner who copies a claim out of the mempool pays the recipient and nobody else. That closes the [front-running](https://en.wikipedia.org/wiki/Front_running) window the hash envelopes had.
99+
100+**The peg.** `deposit(bytes32)` mints into escrow at the same 1:1 rate as the plain deposit, and `claim` moves LOVE out of escrow instead of minting, so `totalSupply() == WETH.balanceOf(address(this)) * RATE` still holds after every call.
101+
102+## Trusted setup
103+
104+Groth16 needs a [trusted setup](https://en.wikipedia.org/wiki/Trusted_setup): anyone who learns the secret randomness it was made from can forge proofs, and so drain the pool. `zk/scripts/setup.sh` runs both phases, the circuit-independent powers of tau and the circuit-specific key, **with a single contributor each**. That is only as trustworthy as the machine it ran on and the person who ran it.
105+
106+```shell
107+cd zk
108+pnpm install
109+pnpm setup # about three minutes
110+pnpm fixture # regenerate test/fixtures/proofs.json
111+```
112+
113+`pnpm setup` writes `zk/build/withdraw.zkey`, `withdraw.wasm` and `verification_key.json`, and the verifier to [`src/Groth16Verifier.sol`](../src/Groth16Verifier.sol). All of them are committed, and they belong together: a rerun makes a different key and a different verifier, so commit the outputs as a set and redeploy. The toolchain is circom 2.2.2 and snarkjs 0.7.
114+
115+This is fine for a testnet. Before it holds real value:
116+
117+- Replace phase 1 with a public, many-party powers of tau, such as the [Perpetual Powers of Tau](https://github.com/privacy-scaling-explorations/perpetualpowersoftau) files [snarkjs](https://github.com/iden3/snarkjs) can consume.
118+- Run phase 2 with several independent contributors.
119+- Have the circuit and the vendored Poseidon audited.
120+
121+## Limits
122+
123+- **Small anonymity set.** It starts at one and grows only with deposits. Below a few dozen, treat a claim as weakly private.
124+- **Fixed denomination.** Sending 0.05 wETH takes five notes. Free amounts would let an observer match a deposit to a claim by value.
125+- **1,024 deposits, ever.** The tree does not roll over. Once it is full, `deposit(bytes32)` reverts with `TreeFull`, while the plain deposit and `withdraw` carry on. A bigger tree means a new circuit, a new setup and a new deployment.
126+- **No relayer.** The claimant pays gas, so the submitting address needs funds. If that address is one you have used with the depositor, it links you. Use an address with no history, funded from somewhere unrelated.
127+- **Timing.** A claim minutes after a deposit, or right after the depositor's contact with someone, points at the owner of the claiming address. Claim when nothing else ties you to the depositor's activity.
128+- **Note handling.** The secret must never exist on a server: generate and prove locally, and share the claim code over a channel with no public log. Anything that lets a server preview a claim, or an indexer map notes to recipients, undoes the design.
129+- **Gas.** A deposit costs roughly 0.4 to 0.7 million gas, most of it the ten hashes, and a claim about 0.29 million. That is fine on an L2 and heavy on Ethereum mainnet.
130+
131+## Reference
132+
133+| Function | Effect |
134+|---|---|
135+| `deposit(uint256 wethAmount)` | Plain peg deposit: mints LOVE straight to the caller. |
136+| `deposit(bytes32 commitment)` | Locks `DENOMINATION` wETH and adds `commitment` to the tree. LOVE goes into escrow. |
137+| `claim(pA, pB, pC, root, nullifierHash, recipient)` | Pays `DENOMINATION * RATE` LOVE to `recipient` against a valid proof. |
138+| `withdraw(uint256 loveAmount)` | Burns the caller's own LOVE for wETH. Unrelated to the pool. |
139+| `isKnownRoot(root)`, `isSpent(nullifierHash)`, `nextLeafIndex()` | Read the tree and the spent set. |
140+
141+Events: `Deposit(bytes32 indexed commitment, uint256 leafIndex)` and `Deposit(address indexed account, uint256 wethAmount, uint256 loveAmount)` for the plain one, `Claim(address indexed recipient, bytes32 nullifierHash)`, `Withdraw`.
142+
143+Errors specific to the pool: `UnknownRoot`, `NoteAlreadySpent`, `InvalidProof`, `TreeFull`, `LeafNotInField`.
144+
145+## Further reading
146+
147+- [Groth16, "On the Size of Pairing-based Non-interactive Arguments"](https://eprint.iacr.org/2016/260)
148+- [Poseidon: a new hash function for zero-knowledge proof systems](https://eprint.iacr.org/2019/458)
149+- [circom documentation](https://docs.circom.io/)
150+- [snarkjs](https://github.com/iden3/snarkjs), including its guide to powers of tau ceremonies
151+- [circomlib](https://github.com/iden3/circomlib) and [circomlibjs](https://github.com/iden3/circomlibjs)
152+- [Foundry Book](https://book.getfoundry.sh/), for running the Solidity tests
new file mode 100644
@@ -0,0 +1,152 @@
1+# Private LOVE with zero-knowledge proofs
2+
3+How LOVE's Groth16 pool works, what the depositor and recipient each do, what it hides, and where its trust and capacity limits are.
4+
5+## Overview
6+
7+[`Love`](../src/Love.sol) is an [ERC-20](https://eips.ethereum.org/EIPS/eip-20) pegged to wETH. Besides the plain `deposit(uint256 wethAmount)`, it has a private pool built on a [zero-knowledge proof](https://en.wikipedia.org/wiki/Zero-knowledge_proof):
8+
9+- `deposit(bytes32 commitment)` locks a fixed `DENOMINATION` of wETH (0.01, worth 1,000 LOVE) and adds `commitment` to a [Merkle tree](https://en.wikipedia.org/wiki/Merkle_tree).
10+- `claim(pA, pB, pC, root, nullifierHash, recipient)` pays that much LOVE to `recipient`, given a proof that the caller knows *a* note in the tree. The proof does not say which one.
11+
12+A claim is not linked to its deposit. The contract can't tell which of the deposits in the tree a claim came from, and neither can anyone reading the chain. That is the anonymity set: every deposit under the root the claim was proved against.
13+
14+The commitment-and-nullifier scheme is the one [Tornado Cash](https://en.wikipedia.org/wiki/Tornado_Cash) popularised. The circuit is written in [circom](https://docs.circom.io/), proved and set up with [snarkjs](https://github.com/iden3/snarkjs), and uses the [Groth16](https://eprint.iacr.org/2016/260) proof system, which the EVM verifies cheaply through the alt_bn128 precompiles ([EIP-196](https://eips.ethereum.org/EIPS/eip-196), [EIP-197](https://eips.ethereum.org/EIPS/eip-197)).
15+
16+This replaces the earlier hash-locked envelopes. An envelope's `Deposit` and `Claim` shared a `depositId`, and the claim published the secret's preimage, so the two were linked on-chain by construction. A proof is what lets a claim leave that link out.
17+
18+## What it hides, and what it doesn't
19+
20+| Public | Hidden |
21+|---|---|
22+| Who deposited, and when | Which deposit a claim came from |
23+| That the amount is 0.01 wETH (it always is) | Who is behind the claiming address |
24+| Who was paid, and when | Which note a recipient holds, until they claim |
25+| The `nullifierHash` of a spent note | The note's `nullifier` and `secret` |
26+
27+Two things follow:
28+
29+- **Privacy grows with the pool.** With one deposit in the tree, a claim can only have come from it. The guarantee is "one of the N deposits under this root".
30+- **A root leaks an upper bound.** Proving against an old root says the note was deposited by then. Proving against the latest root gives the largest set.
31+
32+## The workflow
33+
34+1. **The depositor makes a note, off-chain.** `newNote()` draws a random `nullifier` and `secret`, each 248 bits, with the platform's CSPRNG. `noteHashes(note)` gives the `commitment`, `Poseidon(nullifier, secret)`.
35+2. **The depositor deposits, on-chain.** `deposit(commitment)` pulls 0.01 wETH, mints its LOVE into escrow with the contract, and inserts the commitment as the next leaf. Only the commitment goes on-chain.
36+3. **The depositor hands over the note, off-chain.** `encodeNote(note)` is one `0x` string of 124 hex digits. It is a bearer instrument: whoever holds it can claim, and there is no recovery if it is lost. Send it over a channel with no public log.
37+4. **The recipient rebuilds the tree, off-chain.** From the `Deposit` events, in `leafIndex` order.
38+5. **The recipient proves, off-chain.** `prove({ note, commitments, recipient })` produces a Groth16 proof bound to `recipient`, on the recipient's own machine. This takes a couple of seconds.
39+6. **Anyone claims, on-chain.** `claim(...)` verifies the proof, marks the `nullifierHash` spent, and transfers the LOVE to `recipient`. The caller can be any address, because the proof only pays `recipient`.
40+7. **The recipient redeems, on-chain, optional.** Claimed LOVE is an ordinary balance. `withdraw(loveAmount)` burns it for wETH whenever they choose.
41+
42+## Code snippets
43+
44+The client library is [`zk/lib.mjs`](../zk/lib.mjs). The snippets use [ethers](https://docs.ethers.org/v6/) v6, with `love` a `Contract` for [`Love`](../src/Love.sol) and `weth` one for the registered wETH. Ethers needs the full signature to pick between the overloaded `deposit` and `Deposit`.
45+
46+**Making a note and depositing.**
47+
48+```js
49+import { newNote, encodeNote, noteHashes } from "./zk/lib.mjs";
50+
51+const note = newNote();
52+const { commitment } = await noteHashes(note);
53+
54+const denomination = await love.DENOMINATION();
55+await (await weth.approve(love.target, denomination)).wait();
56+await (await love.getFunction("deposit(bytes32)")(commitment)).wait();
57+
58+const claimCode = encodeNote(note); // hand this over, and nothing else
59+```
60+
61+**Proving.** The commitments, in leaf order, are what rebuilds the tree.
62+
63+```js
64+import { decodeNote, prove } from "./zk/lib.mjs";
65+
66+const logs = await love.queryFilter(love.filters["Deposit(bytes32,uint256)"]());
67+const commitments = logs
68+ .sort((a, b) => Number(a.args.leafIndex - b.args.leafIndex))
69+ .map((log) => log.args.commitment);
70+
71+const claim = await prove({ note: decodeNote(claimCode), commitments, recipient });
72+```
73+
74+**Claiming.** From any funded address. Using one that isn't tied to the recipient matters, as the limits below explain.
75+
76+```js
77+await love.claim(claim.pA, claim.pB, claim.pC, claim.root, claim.nullifierHash, claim.recipient);
78+```
79+
80+`prove` rebuilds the tree from whatever `commitments` it is given, so passing a prefix proves against an older root. Some RPC providers cap the range of a `queryFilter`, in which case page through it.
81+
82+## How it works
83+
84+**The note.** `commitment = Poseidon(nullifier, secret)` and `nullifierHash = Poseidon(nullifier)`, with [Poseidon](https://www.poseidon-hash.info/) as the hash, since it is cheap inside a circuit. Publishing `nullifierHash` on claim marks the note spent without revealing which leaf it is, because nobody can go from `nullifierHash` back to `nullifier` and so to `commitment`.
85+
86+**The tree.** [`MerkleTree`](../src/MerkleTree.sol) is an append-only, incremental Poseidon tree of depth 10, so 1,024 leaves. An insert costs ten hashes and a couple of storage writes however full the tree is. Every root it has ever had stays valid, so a proof made against a slightly stale tree still lands after later deposits. [`PoseidonT3`](../src/PoseidonT3.sol) is vendored from [poseidon-solidity](https://github.com/vimwitch/poseidon-solidity), with its entrypoint changed to be inlinable and its round function untouched; its header says exactly what changed. Its output equals [circomlib](https://github.com/iden3/circomlib)'s `Poseidon(2)`, which is what [`MerkleTree.t.sol`](../test/MerkleTree.t.sol) checks against roots built by `zk/lib.mjs`.
87+
88+**The circuit.** [`withdraw.circom`](../zk/circuits/withdraw.circom) has about 6,100 constraints.
89+
90+| Input | Visibility | |
91+|---|---|---|
92+| `root` | public | A root of the tree |
93+| `nullifierHash` | public | Published on claim |
94+| `recipient` | public | Who gets paid |
95+| `nullifier`, `secret` | private | The note |
96+| `pathElements`, `pathIndices` | private | The Merkle path to the leaf |
97+
98+It proves that hashing the note gives a leaf, that the path takes that leaf to `root`, and that `nullifierHash` belongs to the note. `recipient` is bound into the proof, so a front-runner who copies a claim out of the mempool pays the recipient and nobody else. That closes the [front-running](https://en.wikipedia.org/wiki/Front_running) window the hash envelopes had.
99+
100+**The peg.** `deposit(bytes32)` mints into escrow at the same 1:1 rate as the plain deposit, and `claim` moves LOVE out of escrow instead of minting, so `totalSupply() == WETH.balanceOf(address(this)) * RATE` still holds after every call.
101+
102+## Trusted setup
103+
104+Groth16 needs a [trusted setup](https://en.wikipedia.org/wiki/Trusted_setup): anyone who learns the secret randomness it was made from can forge proofs, and so drain the pool. `zk/scripts/setup.sh` runs both phases, the circuit-independent powers of tau and the circuit-specific key, **with a single contributor each**. That is only as trustworthy as the machine it ran on and the person who ran it.
105+
106+```shell
107+cd zk
108+pnpm install
109+pnpm setup # about three minutes
110+pnpm fixture # regenerate test/fixtures/proofs.json
111+```
112+
113+`pnpm setup` writes `zk/build/withdraw.zkey`, `withdraw.wasm` and `verification_key.json`, and the verifier to [`src/Groth16Verifier.sol`](../src/Groth16Verifier.sol). All of them are committed, and they belong together: a rerun makes a different key and a different verifier, so commit the outputs as a set and redeploy. The toolchain is circom 2.2.2 and snarkjs 0.7.
114+
115+This is fine for a testnet. Before it holds real value:
116+
117+- Replace phase 1 with a public, many-party powers of tau, such as the [Perpetual Powers of Tau](https://github.com/privacy-scaling-explorations/perpetualpowersoftau) files [snarkjs](https://github.com/iden3/snarkjs) can consume.
118+- Run phase 2 with several independent contributors.
119+- Have the circuit and the vendored Poseidon audited.
120+
121+## Limits
122+
123+- **Small anonymity set.** It starts at one and grows only with deposits. Below a few dozen, treat a claim as weakly private.
124+- **Fixed denomination.** Sending 0.05 wETH takes five notes. Free amounts would let an observer match a deposit to a claim by value.
125+- **1,024 deposits, ever.** The tree does not roll over. Once it is full, `deposit(bytes32)` reverts with `TreeFull`, while the plain deposit and `withdraw` carry on. A bigger tree means a new circuit, a new setup and a new deployment.
126+- **No relayer.** The claimant pays gas, so the submitting address needs funds. If that address is one you have used with the depositor, it links you. Use an address with no history, funded from somewhere unrelated.
127+- **Timing.** A claim minutes after a deposit, or right after the depositor's contact with someone, points at the owner of the claiming address. Claim when nothing else ties you to the depositor's activity.
128+- **Note handling.** The secret must never exist on a server: generate and prove locally, and share the claim code over a channel with no public log. Anything that lets a server preview a claim, or an indexer map notes to recipients, undoes the design.
129+- **Gas.** A deposit costs roughly 0.4 to 0.7 million gas, most of it the ten hashes, and a claim about 0.29 million. That is fine on an L2 and heavy on Ethereum mainnet.
130+
131+## Reference
132+
133+| Function | Effect |
134+|---|---|
135+| `deposit(uint256 wethAmount)` | Plain peg deposit: mints LOVE straight to the caller. |
136+| `deposit(bytes32 commitment)` | Locks `DENOMINATION` wETH and adds `commitment` to the tree. LOVE goes into escrow. |
137+| `claim(pA, pB, pC, root, nullifierHash, recipient)` | Pays `DENOMINATION * RATE` LOVE to `recipient` against a valid proof. |
138+| `withdraw(uint256 loveAmount)` | Burns the caller's own LOVE for wETH. Unrelated to the pool. |
139+| `isKnownRoot(root)`, `isSpent(nullifierHash)`, `nextLeafIndex()` | Read the tree and the spent set. |
140+
141+Events: `Deposit(bytes32 indexed commitment, uint256 leafIndex)` and `Deposit(address indexed account, uint256 wethAmount, uint256 loveAmount)` for the plain one, `Claim(address indexed recipient, bytes32 nullifierHash)`, `Withdraw`.
142+
143+Errors specific to the pool: `UnknownRoot`, `NoteAlreadySpent`, `InvalidProof`, `TreeFull`, `LeafNotInField`.
144+
145+## Further reading
146+
147+- [Groth16, "On the Size of Pairing-based Non-interactive Arguments"](https://eprint.iacr.org/2016/260)
148+- [Poseidon: a new hash function for zero-knowledge proof systems](https://eprint.iacr.org/2019/458)
149+- [circom documentation](https://docs.circom.io/)
150+- [snarkjs](https://github.com/iden3/snarkjs), including its guide to powers of tau ceremonies
151+- [circomlib](https://github.com/iden3/circomlib) and [circomlibjs](https://github.com/iden3/circomlibjs)
152+- [Foundry Book](https://book.getfoundry.sh/), for running the Solidity tests
modified foundry.toml +4 -0
@@ -40,6 +40,10 @@ quote_style = "double"
4040 number_underscore = "thousands"
4141 multiline_func_header = "attributes_first"
4242 sort_imports = true
43+ignore = ["src/Groth16Verifier.sol", "src/PoseidonT3.sol"]
44+
45+[lint]
46+ignore = ["src/Groth16Verifier.sol", "src/PoseidonT3.sol"]
4347
4448 [rpc_endpoints]
4549 base_sepolia = "${BASE_SEPOLIA_RPC_URL}"
@@ -40,6 +40,10 @@ quote_style = "double"
40 number_underscore = "thousands"40 number_underscore = "thousands"
41 multiline_func_header = "attributes_first"41 multiline_func_header = "attributes_first"
42 sort_imports = true42 sort_imports = true
43+ignore = ["src/Groth16Verifier.sol", "src/PoseidonT3.sol"]
44+
45+[lint]
46+ignore = ["src/Groth16Verifier.sol", "src/PoseidonT3.sol"]
43 47
44 [rpc_endpoints]48 [rpc_endpoints]
45 base_sepolia = "${BASE_SEPOLIA_RPC_URL}"49 base_sepolia = "${BASE_SEPOLIA_RPC_URL}"
added src/Groth16Verifier.sol +182 -0
new file mode 100644
@@ -0,0 +1,182 @@
1+// SPDX-License-Identifier: GPL-3.0
2+/*
3+ Copyright 2021 0KIMS association.
4+
5+ This file is generated with [snarkJS](https://github.com/iden3/snarkjs).
6+
7+ snarkJS is a free software: you can redistribute it and/or modify it
8+ under the terms of the GNU General Public License as published by
9+ the Free Software Foundation, either version 3 of the License, or
10+ (at your option) any later version.
11+
12+ snarkJS is distributed in the hope that it will be useful, but WITHOUT
13+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
14+ or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
15+ License for more details.
16+
17+ You should have received a copy of the GNU General Public License
18+ along with snarkJS. If not, see <https://www.gnu.org/licenses/>.
19+*/
20+
21+pragma solidity >=0.7.0 <0.9.0;
22+
23+contract Groth16Verifier {
24+ // Scalar field size
25+ uint256 constant r = 21888242871839275222246405745257275088548364400416034343698204186575808495617;
26+ // Base field size
27+ uint256 constant q = 21888242871839275222246405745257275088696311157297823662689037894645226208583;
28+
29+ // Verification Key data
30+ uint256 constant alphax = 2723532631684974918406645525943558732354673276204281662712066914695900576277;
31+ uint256 constant alphay = 17514683612750309974728178682926232556971957023838913535669239847332050299446;
32+ uint256 constant betax1 = 7823948186918456580333006997609901789595273233568743751946614638907978883711;
33+ uint256 constant betax2 = 6034205146605614654431717757495133361402453255257043866720288300974269646538;
34+ uint256 constant betay1 = 11638984221817658074004925648741757789613528713645236774002925369248027221209;
35+ uint256 constant betay2 = 2508951487354739574077255816074521971727349482553381458702706323318855000618;
36+ uint256 constant gammax1 = 11559732032986387107991004021392285783925812861821192530917403151452391805634;
37+ uint256 constant gammax2 = 10857046999023057135944570762232829481370756359578518086990519993285655852781;
38+ uint256 constant gammay1 = 4082367875863433681332203403145435568316851327593401208105741076214120093531;
39+ uint256 constant gammay2 = 8495653923123431417604973247489272438418190587263600148770280649306958101930;
40+ uint256 constant deltax1 = 3163504613947300382025047233129943754368165108353942835225928277874778583773;
41+ uint256 constant deltax2 = 10111709658227999298687264682571617846867694365717728896787905680829339798912;
42+ uint256 constant deltay1 = 1104241400641344221595907754664029095916435027517876883451581136875208434535;
43+ uint256 constant deltay2 = 8588597490669533320277373248052240038745202137023885169306697052179170691253;
44+
45+
46+ uint256 constant IC0x = 4212190150813188043278099338020867811573069332260264154238648301447210039371;
47+ uint256 constant IC0y = 1764764369750003778637898487216316888935235560815878582035231064669751096562;
48+
49+ uint256 constant IC1x = 11591500916652557381190324906157840239104175179480966128102692187195568131210;
50+ uint256 constant IC1y = 5118730949595120121305886043182374039722377242808578639452659793403109061323;
51+
52+ uint256 constant IC2x = 17181191618870716694328767264500886479216739164760510841062537495596168566180;
53+ uint256 constant IC2y = 6745348094417067550528385806914234773573246785057338772569961117791460595409;
54+
55+ uint256 constant IC3x = 17245225309580886468417139451106822105824195874584748226991661740342948111559;
56+ uint256 constant IC3y = 9416262901358787120529716248550998086567235238905571238886470859668393020882;
57+
58+
59+ // Memory data
60+ uint16 constant pVk = 0;
61+ uint16 constant pPairing = 128;
62+
63+ uint16 constant pLastMem = 896;
64+
65+ function verifyProof(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[3] calldata _pubSignals) public view returns (bool) {
66+ assembly {
67+ function checkField(v) {
68+ if iszero(lt(v, r)) {
69+ mstore(0, 0)
70+ return(0, 0x20)
71+ }
72+ }
73+
74+ // G1 function to multiply a G1 value(x,y) to value in an address
75+ function g1_mulAccC(pR, x, y, s) {
76+ let success
77+ let mIn := mload(0x40)
78+ mstore(mIn, x)
79+ mstore(add(mIn, 32), y)
80+ mstore(add(mIn, 64), s)
81+
82+ success := staticcall(sub(gas(), 2000), 7, mIn, 96, mIn, 64)
83+
84+ if iszero(success) {
85+ mstore(0, 0)
86+ return(0, 0x20)
87+ }
88+
89+ mstore(add(mIn, 64), mload(pR))
90+ mstore(add(mIn, 96), mload(add(pR, 32)))
91+
92+ success := staticcall(sub(gas(), 2000), 6, mIn, 128, pR, 64)
93+
94+ if iszero(success) {
95+ mstore(0, 0)
96+ return(0, 0x20)
97+ }
98+ }
99+
100+ function checkPairing(pA, pB, pC, pubSignals, pMem) -> isOk {
101+ let _pPairing := add(pMem, pPairing)
102+ let _pVk := add(pMem, pVk)
103+
104+ mstore(_pVk, IC0x)
105+ mstore(add(_pVk, 32), IC0y)
106+
107+ // Compute the linear combination vk_x
108+
109+ g1_mulAccC(_pVk, IC1x, IC1y, calldataload(add(pubSignals, 0)))
110+
111+ g1_mulAccC(_pVk, IC2x, IC2y, calldataload(add(pubSignals, 32)))
112+
113+ g1_mulAccC(_pVk, IC3x, IC3y, calldataload(add(pubSignals, 64)))
114+
115+
116+ // -A
117+ mstore(_pPairing, calldataload(pA))
118+ mstore(add(_pPairing, 32), mod(sub(q, calldataload(add(pA, 32))), q))
119+
120+ // B
121+ mstore(add(_pPairing, 64), calldataload(pB))
122+ mstore(add(_pPairing, 96), calldataload(add(pB, 32)))
123+ mstore(add(_pPairing, 128), calldataload(add(pB, 64)))
124+ mstore(add(_pPairing, 160), calldataload(add(pB, 96)))
125+
126+ // alpha1
127+ mstore(add(_pPairing, 192), alphax)
128+ mstore(add(_pPairing, 224), alphay)
129+
130+ // beta2
131+ mstore(add(_pPairing, 256), betax1)
132+ mstore(add(_pPairing, 288), betax2)
133+ mstore(add(_pPairing, 320), betay1)
134+ mstore(add(_pPairing, 352), betay2)
135+
136+ // vk_x
137+ mstore(add(_pPairing, 384), mload(add(pMem, pVk)))
138+ mstore(add(_pPairing, 416), mload(add(pMem, add(pVk, 32))))
139+
140+
141+ // gamma2
142+ mstore(add(_pPairing, 448), gammax1)
143+ mstore(add(_pPairing, 480), gammax2)
144+ mstore(add(_pPairing, 512), gammay1)
145+ mstore(add(_pPairing, 544), gammay2)
146+
147+ // C
148+ mstore(add(_pPairing, 576), calldataload(pC))
149+ mstore(add(_pPairing, 608), calldataload(add(pC, 32)))
150+
151+ // delta2
152+ mstore(add(_pPairing, 640), deltax1)
153+ mstore(add(_pPairing, 672), deltax2)
154+ mstore(add(_pPairing, 704), deltay1)
155+ mstore(add(_pPairing, 736), deltay2)
156+
157+
158+ let success := staticcall(sub(gas(), 2000), 8, _pPairing, 768, _pPairing, 0x20)
159+
160+ isOk := and(success, mload(_pPairing))
161+ }
162+
163+ let pMem := mload(0x40)
164+ mstore(0x40, add(pMem, pLastMem))
165+
166+ // Validate that all evaluations ∈ F
167+
168+ checkField(calldataload(add(_pubSignals, 0)))
169+
170+ checkField(calldataload(add(_pubSignals, 32)))
171+
172+ checkField(calldataload(add(_pubSignals, 64)))
173+
174+
175+ // Validate all evaluations
176+ let isValid := checkPairing(_pA, _pB, _pC, _pubSignals, pMem)
177+
178+ mstore(0, isValid)
179+ return(0, 0x20)
180+ }
181+ }
182+ }
new file mode 100644
@@ -0,0 +1,182 @@
1+// SPDX-License-Identifier: GPL-3.0
2+/*
3+ Copyright 2021 0KIMS association.
4+
5+ This file is generated with [snarkJS](https://github.com/iden3/snarkjs).
6+
7+ snarkJS is a free software: you can redistribute it and/or modify it
8+ under the terms of the GNU General Public License as published by
9+ the Free Software Foundation, either version 3 of the License, or
10+ (at your option) any later version.
11+
12+ snarkJS is distributed in the hope that it will be useful, but WITHOUT
13+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
14+ or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
15+ License for more details.
16+
17+ You should have received a copy of the GNU General Public License
18+ along with snarkJS. If not, see <https://www.gnu.org/licenses/>.
19+*/
20+
21+pragma solidity >=0.7.0 <0.9.0;
22+
23+contract Groth16Verifier {
24+ // Scalar field size
25+ uint256 constant r = 21888242871839275222246405745257275088548364400416034343698204186575808495617;
26+ // Base field size
27+ uint256 constant q = 21888242871839275222246405745257275088696311157297823662689037894645226208583;
28+
29+ // Verification Key data
30+ uint256 constant alphax = 2723532631684974918406645525943558732354673276204281662712066914695900576277;
31+ uint256 constant alphay = 17514683612750309974728178682926232556971957023838913535669239847332050299446;
32+ uint256 constant betax1 = 7823948186918456580333006997609901789595273233568743751946614638907978883711;
33+ uint256 constant betax2 = 6034205146605614654431717757495133361402453255257043866720288300974269646538;
34+ uint256 constant betay1 = 11638984221817658074004925648741757789613528713645236774002925369248027221209;
35+ uint256 constant betay2 = 2508951487354739574077255816074521971727349482553381458702706323318855000618;
36+ uint256 constant gammax1 = 11559732032986387107991004021392285783925812861821192530917403151452391805634;
37+ uint256 constant gammax2 = 10857046999023057135944570762232829481370756359578518086990519993285655852781;
38+ uint256 constant gammay1 = 4082367875863433681332203403145435568316851327593401208105741076214120093531;
39+ uint256 constant gammay2 = 8495653923123431417604973247489272438418190587263600148770280649306958101930;
40+ uint256 constant deltax1 = 3163504613947300382025047233129943754368165108353942835225928277874778583773;
41+ uint256 constant deltax2 = 10111709658227999298687264682571617846867694365717728896787905680829339798912;
42+ uint256 constant deltay1 = 1104241400641344221595907754664029095916435027517876883451581136875208434535;
43+ uint256 constant deltay2 = 8588597490669533320277373248052240038745202137023885169306697052179170691253;
44+
45+
46+ uint256 constant IC0x = 4212190150813188043278099338020867811573069332260264154238648301447210039371;
47+ uint256 constant IC0y = 1764764369750003778637898487216316888935235560815878582035231064669751096562;
48+
49+ uint256 constant IC1x = 11591500916652557381190324906157840239104175179480966128102692187195568131210;
50+ uint256 constant IC1y = 5118730949595120121305886043182374039722377242808578639452659793403109061323;
51+
52+ uint256 constant IC2x = 17181191618870716694328767264500886479216739164760510841062537495596168566180;
53+ uint256 constant IC2y = 6745348094417067550528385806914234773573246785057338772569961117791460595409;
54+
55+ uint256 constant IC3x = 17245225309580886468417139451106822105824195874584748226991661740342948111559;
56+ uint256 constant IC3y = 9416262901358787120529716248550998086567235238905571238886470859668393020882;
57+
58+
59+ // Memory data
60+ uint16 constant pVk = 0;
61+ uint16 constant pPairing = 128;
62+
63+ uint16 constant pLastMem = 896;
64+
65+ function verifyProof(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[3] calldata _pubSignals) public view returns (bool) {
66+ assembly {
67+ function checkField(v) {
68+ if iszero(lt(v, r)) {
69+ mstore(0, 0)
70+ return(0, 0x20)
71+ }
72+ }
73+
74+ // G1 function to multiply a G1 value(x,y) to value in an address
75+ function g1_mulAccC(pR, x, y, s) {
76+ let success
77+ let mIn := mload(0x40)
78+ mstore(mIn, x)
79+ mstore(add(mIn, 32), y)
80+ mstore(add(mIn, 64), s)
81+
82+ success := staticcall(sub(gas(), 2000), 7, mIn, 96, mIn, 64)
83+
84+ if iszero(success) {
85+ mstore(0, 0)
86+ return(0, 0x20)
87+ }
88+
89+ mstore(add(mIn, 64), mload(pR))
90+ mstore(add(mIn, 96), mload(add(pR, 32)))
91+
92+ success := staticcall(sub(gas(), 2000), 6, mIn, 128, pR, 64)
93+
94+ if iszero(success) {
95+ mstore(0, 0)
96+ return(0, 0x20)
97+ }
98+ }
99+
100+ function checkPairing(pA, pB, pC, pubSignals, pMem) -> isOk {
101+ let _pPairing := add(pMem, pPairing)
102+ let _pVk := add(pMem, pVk)
103+
104+ mstore(_pVk, IC0x)
105+ mstore(add(_pVk, 32), IC0y)
106+
107+ // Compute the linear combination vk_x
108+
109+ g1_mulAccC(_pVk, IC1x, IC1y, calldataload(add(pubSignals, 0)))
110+
111+ g1_mulAccC(_pVk, IC2x, IC2y, calldataload(add(pubSignals, 32)))
112+
113+ g1_mulAccC(_pVk, IC3x, IC3y, calldataload(add(pubSignals, 64)))
114+
115+
116+ // -A
117+ mstore(_pPairing, calldataload(pA))
118+ mstore(add(_pPairing, 32), mod(sub(q, calldataload(add(pA, 32))), q))
119+
120+ // B
121+ mstore(add(_pPairing, 64), calldataload(pB))
122+ mstore(add(_pPairing, 96), calldataload(add(pB, 32)))
123+ mstore(add(_pPairing, 128), calldataload(add(pB, 64)))
124+ mstore(add(_pPairing, 160), calldataload(add(pB, 96)))
125+
126+ // alpha1
127+ mstore(add(_pPairing, 192), alphax)
128+ mstore(add(_pPairing, 224), alphay)
129+
130+ // beta2
131+ mstore(add(_pPairing, 256), betax1)
132+ mstore(add(_pPairing, 288), betax2)
133+ mstore(add(_pPairing, 320), betay1)
134+ mstore(add(_pPairing, 352), betay2)
135+
136+ // vk_x
137+ mstore(add(_pPairing, 384), mload(add(pMem, pVk)))
138+ mstore(add(_pPairing, 416), mload(add(pMem, add(pVk, 32))))
139+
140+
141+ // gamma2
142+ mstore(add(_pPairing, 448), gammax1)
143+ mstore(add(_pPairing, 480), gammax2)
144+ mstore(add(_pPairing, 512), gammay1)
145+ mstore(add(_pPairing, 544), gammay2)
146+
147+ // C
148+ mstore(add(_pPairing, 576), calldataload(pC))
149+ mstore(add(_pPairing, 608), calldataload(add(pC, 32)))
150+
151+ // delta2
152+ mstore(add(_pPairing, 640), deltax1)
153+ mstore(add(_pPairing, 672), deltax2)
154+ mstore(add(_pPairing, 704), deltay1)
155+ mstore(add(_pPairing, 736), deltay2)
156+
157+
158+ let success := staticcall(sub(gas(), 2000), 8, _pPairing, 768, _pPairing, 0x20)
159+
160+ isOk := and(success, mload(_pPairing))
161+ }
162+
163+ let pMem := mload(0x40)
164+ mstore(0x40, add(pMem, pLastMem))
165+
166+ // Validate that all evaluations ∈ F
167+
168+ checkField(calldataload(add(_pubSignals, 0)))
169+
170+ checkField(calldataload(add(_pubSignals, 32)))
171+
172+ checkField(calldataload(add(_pubSignals, 64)))
173+
174+
175+ // Validate all evaluations
176+ let isValid := checkPairing(_pA, _pB, _pC, _pubSignals, pMem)
177+
178+ mstore(0, isValid)
179+ return(0, 0x20)
180+ }
181+ }
182+ }
modified src/Love.sol +84 -141
Large diff collapsed to keep the page fast — load it when you need it.
added src/MerkleTree.sol +86 -0
new file mode 100644
@@ -0,0 +1,86 @@
1+// SPDX-License-Identifier: MIT
2+pragma solidity ^0.8.30;
3+
4+import {PoseidonT3} from "./PoseidonT3.sol";
5+
6+/// @title MerkleTree
7+/// @author Julien Béranger
8+/// @notice An append-only Merkle tree of Poseidon hashes that remembers every
9+/// root it has ever had.
10+/// @dev Incremental, so an insert costs `TREE_DEPTH` hashes and a couple of
11+/// storage writes however full the tree is, and nothing is ever
12+/// recomputed. Empty leaves are zero and the empty subtree at each level
13+/// is the hash of two empty subtrees below it. `zk/lib.mjs` builds the
14+/// same tree, which is what makes a proof made off-chain verify against a
15+/// root made here.
16+///
17+/// Every root stays valid forever, so a proof made against a slightly
18+/// stale tree still lands after later deposits. The price is that a note
19+/// proved against an old root hides among fewer deposits than the tree
20+/// holds now.
21+abstract contract MerkleTree {
22+ /// @notice Levels between a leaf and the root. Fixed by the circuit: changing it means a new
23+ /// circuit, a new setup and a new verifier.
24+ uint256 public constant TREE_DEPTH = 10;
25+
26+ /// @notice The BN254 scalar field. A leaf outside it would not be the value the circuit hashes.
27+ uint256 public constant FIELD_SIZE =
28+ 21_888_242_871_839_275_222_246_405_745_257_275_088_548_364_400_416_034_343_698_204_186_575_808_495_617;
29+
30+ /// @notice The leaf the next insert lands in, and so the number of leaves so far.
31+ uint256 public nextLeafIndex;
32+
33+ /// @notice Whether `root` is the root of the tree after some insert.
34+ mapping(bytes32 root => bool) public isKnownRoot;
35+
36+ /// @dev The root of an all-empty subtree of each height, 0 upwards.
37+ bytes32[TREE_DEPTH] private _zeros;
38+
39+ /// @dev The latest left node at each level: what the next right sibling has to be hashed with.
40+ bytes32[TREE_DEPTH] private _filledSubtrees;
41+
42+ /// @notice Thrown when every leaf is taken.
43+ error TreeFull();
44+
45+ /// @notice Thrown when a leaf is not a field element.
46+ /// @param leaf The rejected leaf.
47+ error LeafNotInField(bytes32 leaf);
48+
49+ constructor() {
50+ bytes32 zero;
51+ for (uint256 level; level < TREE_DEPTH; ++level) {
52+ _zeros[level] = zero;
53+ zero = _hash(zero, zero);
54+ }
55+ }
56+
57+ /// @dev Appends `leaf` and records the new root.
58+ /// @param leaf The value to append, a field element.
59+ /// @return index Where it landed.
60+ function _insert(bytes32 leaf) internal returns (uint256 index) {
61+ if (uint256(leaf) >= FIELD_SIZE) revert LeafNotInField(leaf);
62+
63+ index = nextLeafIndex;
64+ if (index == 2 ** TREE_DEPTH) revert TreeFull();
65+
66+ bytes32 node = leaf;
67+ uint256 position = index;
68+
69+ for (uint256 level; level < TREE_DEPTH; ++level) {
70+ if (position & 1 == 0) {
71+ _filledSubtrees[level] = node;
72+ node = _hash(node, _zeros[level]);
73+ } else {
74+ node = _hash(_filledSubtrees[level], node);
75+ }
76+ position >>= 1;
77+ }
78+
79+ nextLeafIndex = index + 1;
80+ isKnownRoot[node] = true;
81+ }
82+
83+ function _hash(bytes32 left, bytes32 right) private pure returns (bytes32) {
84+ return bytes32(PoseidonT3.hash(uint256(left), uint256(right)));
85+ }
86+}
new file mode 100644
@@ -0,0 +1,86 @@
1+// SPDX-License-Identifier: MIT
2+pragma solidity ^0.8.30;
3+
4+import {PoseidonT3} from "./PoseidonT3.sol";
5+
6+/// @title MerkleTree
7+/// @author Julien Béranger
8+/// @notice An append-only Merkle tree of Poseidon hashes that remembers every
9+/// root it has ever had.
10+/// @dev Incremental, so an insert costs `TREE_DEPTH` hashes and a couple of
11+/// storage writes however full the tree is, and nothing is ever
12+/// recomputed. Empty leaves are zero and the empty subtree at each level
13+/// is the hash of two empty subtrees below it. `zk/lib.mjs` builds the
14+/// same tree, which is what makes a proof made off-chain verify against a
15+/// root made here.
16+///
17+/// Every root stays valid forever, so a proof made against a slightly
18+/// stale tree still lands after later deposits. The price is that a note
19+/// proved against an old root hides among fewer deposits than the tree
20+/// holds now.
21+abstract contract MerkleTree {
22+ /// @notice Levels between a leaf and the root. Fixed by the circuit: changing it means a new
23+ /// circuit, a new setup and a new verifier.
24+ uint256 public constant TREE_DEPTH = 10;
25+
26+ /// @notice The BN254 scalar field. A leaf outside it would not be the value the circuit hashes.
27+ uint256 public constant FIELD_SIZE =
28+ 21_888_242_871_839_275_222_246_405_745_257_275_088_548_364_400_416_034_343_698_204_186_575_808_495_617;
29+
30+ /// @notice The leaf the next insert lands in, and so the number of leaves so far.
31+ uint256 public nextLeafIndex;
32+
33+ /// @notice Whether `root` is the root of the tree after some insert.
34+ mapping(bytes32 root => bool) public isKnownRoot;
35+
36+ /// @dev The root of an all-empty subtree of each height, 0 upwards.
37+ bytes32[TREE_DEPTH] private _zeros;
38+
39+ /// @dev The latest left node at each level: what the next right sibling has to be hashed with.
40+ bytes32[TREE_DEPTH] private _filledSubtrees;
41+
42+ /// @notice Thrown when every leaf is taken.
43+ error TreeFull();
44+
45+ /// @notice Thrown when a leaf is not a field element.
46+ /// @param leaf The rejected leaf.
47+ error LeafNotInField(bytes32 leaf);
48+
49+ constructor() {
50+ bytes32 zero;
51+ for (uint256 level; level < TREE_DEPTH; ++level) {
52+ _zeros[level] = zero;
53+ zero = _hash(zero, zero);
54+ }
55+ }
56+
57+ /// @dev Appends `leaf` and records the new root.
58+ /// @param leaf The value to append, a field element.
59+ /// @return index Where it landed.
60+ function _insert(bytes32 leaf) internal returns (uint256 index) {
61+ if (uint256(leaf) >= FIELD_SIZE) revert LeafNotInField(leaf);
62+
63+ index = nextLeafIndex;
64+ if (index == 2 ** TREE_DEPTH) revert TreeFull();
65+
66+ bytes32 node = leaf;
67+ uint256 position = index;
68+
69+ for (uint256 level; level < TREE_DEPTH; ++level) {
70+ if (position & 1 == 0) {
71+ _filledSubtrees[level] = node;
72+ node = _hash(node, _zeros[level]);
73+ } else {
74+ node = _hash(_filledSubtrees[level], node);
75+ }
76+ position >>= 1;
77+ }
78+
79+ nextLeafIndex = index + 1;
80+ isKnownRoot[node] = true;
81+ }
82+
83+ function _hash(bytes32 left, bytes32 right) private pure returns (bytes32) {
84+ return bytes32(PoseidonT3.hash(uint256(left), uint256(right)));
85+ }
86+}
added src/PoseidonT3.sol +398 -0
Large diff collapsed to keep the page fast — load it when you need it.
deleted test/LoveEnvelope.t.sol +0 -498
Large diff collapsed to keep the page fast — load it when you need it.
added test/LoveZK.t.sol +299 -0
Large diff collapsed to keep the page fast — load it when you need it.
added test/MerkleTree.t.sol +87 -0
new file mode 100644
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: MIT
2+pragma solidity ^0.8.30;
3+
4+import {MerkleTree} from "../src/MerkleTree.sol";
5+import {StdStorage, stdStorage} from "forge-std/StdStorage.sol";
6+import {Test} from "forge-std/Test.sol";
7+
8+/// @dev `_insert` is internal; this is the smallest thing that lets a test reach it.
9+contract MerkleTreeHarness is MerkleTree {
10+ function insert(bytes32 leaf) external returns (uint256) {
11+ return _insert(leaf);
12+ }
13+}
14+
15+/// @title MerkleTreeTest
16+/// @notice The on-chain tree against the roots `zk/lib.mjs` computed for the
17+/// same leaves. A proof only verifies if both sides hash identically,
18+/// so agreeing here is what ties the contract's Poseidon and its
19+/// empty-subtree values to the circuit's.
20+contract MerkleTreeTest is Test {
21+ using stdStorage for StdStorage;
22+
23+ MerkleTreeHarness tree;
24+ bytes32[] commitments;
25+ bytes32 rootAfterOne;
26+ bytes32 rootAfterThree;
27+
28+ function setUp() public {
29+ tree = new MerkleTreeHarness();
30+
31+ string memory fixture = vm.readFile("test/fixtures/proofs.json");
32+ commitments = abi.decode(vm.parseJson(fixture, ".commitments"), (bytes32[]));
33+ rootAfterOne = vm.parseJsonBytes32(fixture, ".claims[0].root");
34+ rootAfterThree = vm.parseJsonBytes32(fixture, ".claims[1].root");
35+ }
36+
37+ function test_InsertBuildsTheRootTheProverExpects() public {
38+ assertFalse(tree.isKnownRoot(rootAfterOne));
39+
40+ tree.insert(commitments[0]);
41+ assertTrue(tree.isKnownRoot(rootAfterOne));
42+ assertFalse(tree.isKnownRoot(rootAfterThree));
43+
44+ tree.insert(commitments[1]);
45+ tree.insert(commitments[2]);
46+ assertTrue(tree.isKnownRoot(rootAfterThree));
47+ }
48+
49+ function test_InsertKeepsOldRoots() public {
50+ tree.insert(commitments[0]);
51+ tree.insert(commitments[1]);
52+ tree.insert(commitments[2]);
53+
54+ assertTrue(tree.isKnownRoot(rootAfterOne));
55+ }
56+
57+ function test_InsertAssignsConsecutiveIndexes() public {
58+ assertEq(tree.nextLeafIndex(), 0);
59+
60+ assertEq(tree.insert(commitments[0]), 0);
61+ assertEq(tree.insert(commitments[1]), 1);
62+ assertEq(tree.nextLeafIndex(), 2);
63+ }
64+
65+ function test_RevertWhen_LeafIsNotAFieldElement() public {
66+ bytes32 outside = bytes32(tree.FIELD_SIZE());
67+
68+ vm.expectRevert(abi.encodeWithSelector(MerkleTree.LeafNotInField.selector, outside));
69+ tree.insert(outside);
70+ }
71+
72+ function test_RevertWhen_TreeIsFull() public {
73+ stdstore.target(address(tree)).sig("nextLeafIndex()").checked_write(2 ** tree.TREE_DEPTH());
74+
75+ vm.expectRevert(MerkleTree.TreeFull.selector);
76+ tree.insert(commitments[0]);
77+ }
78+
79+ /// @dev The last leaf that fits still goes in.
80+ function test_InsertFillsTheTree() public {
81+ stdstore.target(address(tree)).sig("nextLeafIndex()").checked_write(2 ** tree.TREE_DEPTH() - 1);
82+
83+ tree.insert(commitments[0]);
84+
85+ assertEq(tree.nextLeafIndex(), 2 ** tree.TREE_DEPTH());
86+ }
87+}
new file mode 100644
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: MIT
2+pragma solidity ^0.8.30;
3+
4+import {MerkleTree} from "../src/MerkleTree.sol";
5+import {StdStorage, stdStorage} from "forge-std/StdStorage.sol";
6+import {Test} from "forge-std/Test.sol";
7+
8+/// @dev `_insert` is internal; this is the smallest thing that lets a test reach it.
9+contract MerkleTreeHarness is MerkleTree {
10+ function insert(bytes32 leaf) external returns (uint256) {
11+ return _insert(leaf);
12+ }
13+}
14+
15+/// @title MerkleTreeTest
16+/// @notice The on-chain tree against the roots `zk/lib.mjs` computed for the
17+/// same leaves. A proof only verifies if both sides hash identically,
18+/// so agreeing here is what ties the contract's Poseidon and its
19+/// empty-subtree values to the circuit's.
20+contract MerkleTreeTest is Test {
21+ using stdStorage for StdStorage;
22+
23+ MerkleTreeHarness tree;
24+ bytes32[] commitments;
25+ bytes32 rootAfterOne;
26+ bytes32 rootAfterThree;
27+
28+ function setUp() public {
29+ tree = new MerkleTreeHarness();
30+
31+ string memory fixture = vm.readFile("test/fixtures/proofs.json");
32+ commitments = abi.decode(vm.parseJson(fixture, ".commitments"), (bytes32[]));
33+ rootAfterOne = vm.parseJsonBytes32(fixture, ".claims[0].root");
34+ rootAfterThree = vm.parseJsonBytes32(fixture, ".claims[1].root");
35+ }
36+
37+ function test_InsertBuildsTheRootTheProverExpects() public {
38+ assertFalse(tree.isKnownRoot(rootAfterOne));
39+
40+ tree.insert(commitments[0]);
41+ assertTrue(tree.isKnownRoot(rootAfterOne));
42+ assertFalse(tree.isKnownRoot(rootAfterThree));
43+
44+ tree.insert(commitments[1]);
45+ tree.insert(commitments[2]);
46+ assertTrue(tree.isKnownRoot(rootAfterThree));
47+ }
48+
49+ function test_InsertKeepsOldRoots() public {
50+ tree.insert(commitments[0]);
51+ tree.insert(commitments[1]);
52+ tree.insert(commitments[2]);
53+
54+ assertTrue(tree.isKnownRoot(rootAfterOne));
55+ }
56+
57+ function test_InsertAssignsConsecutiveIndexes() public {
58+ assertEq(tree.nextLeafIndex(), 0);
59+
60+ assertEq(tree.insert(commitments[0]), 0);
61+ assertEq(tree.insert(commitments[1]), 1);
62+ assertEq(tree.nextLeafIndex(), 2);
63+ }
64+
65+ function test_RevertWhen_LeafIsNotAFieldElement() public {
66+ bytes32 outside = bytes32(tree.FIELD_SIZE());
67+
68+ vm.expectRevert(abi.encodeWithSelector(MerkleTree.LeafNotInField.selector, outside));
69+ tree.insert(outside);
70+ }
71+
72+ function test_RevertWhen_TreeIsFull() public {
73+ stdstore.target(address(tree)).sig("nextLeafIndex()").checked_write(2 ** tree.TREE_DEPTH());
74+
75+ vm.expectRevert(MerkleTree.TreeFull.selector);
76+ tree.insert(commitments[0]);
77+ }
78+
79+ /// @dev The last leaf that fits still goes in.
80+ function test_InsertFillsTheTree() public {
81+ stdstore.target(address(tree)).sig("nextLeafIndex()").checked_write(2 ** tree.TREE_DEPTH() - 1);
82+
83+ tree.insert(commitments[0]);
84+
85+ assertEq(tree.nextLeafIndex(), 2 ** tree.TREE_DEPTH());
86+ }
87+}
modified test/fixtures/README.md +6 -0
@@ -20,3 +20,9 @@ cast code 0x4200000000000000000000000000000000000006 --rpc-url https://base-rpc.
2020 Both hashes appear in `script/weth-codehashes.sh` output and in the allowlist in
2121 `src/WETHRegistry.sol`, so a fixture that has drifted shows up as a test
2222 failure rather than as a silent pass.
23+
24+## proofs.json
25+
26+Three note commitments and two Groth16 claims against them, for `LoveZK.t.sol` and `MerkleTree.t.sol`: note 0 against the root after one deposit, and note 2 against the root after all three. The proofs are real, made with `zk/build/withdraw.zkey`, and verify only against the verifier generated from it.
27+
28+Regenerate with `pnpm fixture` in `zk/`, after every `pnpm setup`.
@@ -20,3 +20,9 @@ cast code 0x4200000000000000000000000000000000000006 --rpc-url https://base-rpc.
20 Both hashes appear in `script/weth-codehashes.sh` output and in the allowlist in20 Both hashes appear in `script/weth-codehashes.sh` output and in the allowlist in
21 `src/WETHRegistry.sol`, so a fixture that has drifted shows up as a test21 `src/WETHRegistry.sol`, so a fixture that has drifted shows up as a test
22 failure rather than as a silent pass.22 failure rather than as a silent pass.
23+
24+## proofs.json
25+
26+Three note commitments and two Groth16 claims against them, for `LoveZK.t.sol` and `MerkleTree.t.sol`: note 0 against the root after one deposit, and note 2 against the root after all three. The proofs are real, made with `zk/build/withdraw.zkey`, and verify only against the verifier generated from it.
27+
28+Regenerate with `pnpm fixture` in `zk/`, after every `pnpm setup`.
added test/fixtures/proofs.json +58 -0
Large diff collapsed to keep the page fast — load it when you need it.
added zk/.gitignore +2 -0
new file mode 100644
@@ -0,0 +1,2 @@
1+node_modules/
2+build/tmp/
new file mode 100644
@@ -0,0 +1,2 @@
1+node_modules/
2+build/tmp/
added zk/build/verification_key.json +104 -0
Large diff collapsed to keep the page fast — load it when you need it.
added zk/build/withdraw.wasm +0 -0
new file mode 100644
Binary files /dev/null and b/zk/build/withdraw.wasm differ
new file mode 100644
Binary files /dev/null and b/zk/build/withdraw.wasm differBinary files /dev/null and b/zk/build/withdraw.wasm differ
added zk/build/withdraw.zkey +0 -0
new file mode 100644
Binary files /dev/null and b/zk/build/withdraw.zkey differ
new file mode 100644
Binary files /dev/null and b/zk/build/withdraw.zkey differBinary files /dev/null and b/zk/build/withdraw.zkey differ
added zk/circuits/withdraw.circom +70 -0
Large diff collapsed to keep the page fast — load it when you need it.
added zk/lib.mjs +120 -0
Large diff collapsed to keep the page fast — load it when you need it.
added zk/package.json +16 -0
Large diff collapsed to keep the page fast — load it when you need it.
added zk/pnpm-lock.yaml +867 -0
Large diff collapsed to keep the page fast — load it when you need it.
added zk/scripts/fixture.mjs +41 -0
Large diff collapsed to keep the page fast — load it when you need it.
added zk/scripts/setup.sh +41 -0
Large diff collapsed to keep the page fast — load it when you need it.