Replace hash envelopes with a ZK pool #15
Merged@julien wants to merge
18-replace-envelopes-with-zk into mainMerged 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 | ||
| 56 | 56 | same `depositId`, so depositor and claimant are linked whenever the claim |
| 57 | 57 | happens; an envelope hides who the claimant is, not that the depositor paid |
| 58 | 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 | 90 | ### 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 | 109 | - `Love.deposit(bytes32[] secretHashes, uint256[] wethAmounts)`, which opens one |
| 63 | 110 | secret-locked envelope per entry in a single call, with one wETH pull and one |
| 64 | 111 | 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 claim | 56 | same `depositId`, so depositor and claimant are linked whenever the claim |
| 57 | happens; an envelope hides who the claimant is, not that the depositor paid | 57 | 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 | ### Added | 90 | ### 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 one | 109 | - `Love.deposit(bytes32[] secretHashes, uint256[] wethAmounts)`, which opens one |
| 63 | secret-locked envelope per entry in a single call, with one wETH pull and one | 110 | 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 own | 111 | 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`, | ||
| 54 | 54 | |
| 55 | 55 | ## Privacy |
| 56 | 56 | |
| 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). | |
| 63 | 64 | |
| 64 | 65 | ## How wETH is chosen |
| 65 | 66 | |
| @@ -115,7 +116,7 @@ yourself: see [docs/MULTICHAIN.md](docs/MULTICHAIN.md). | ||
| 115 | 116 | ## Docs |
| 116 | 117 | |
| 117 | 118 | - [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 | |
| 119 | 120 | - [Supported networks](docs/SUPPORTED_NETWORKS.md) — where LOVE deploys today and what could be added |
| 120 | 121 | |
| 121 | 122 | ## License |
| @@ -54,12 +54,13 @@ non-zero if the two LOVE addresses differ. Override `PORT_A`, `PORT_B`, | |||
| 54 | 54 | ||
| 55 | ## Privacy | 55 | ## Privacy |
| 56 | 56 | ||
| 57 | -Besides the plain `deposit(uint256)`, LOVE can be locked behind a secret | 57 | +Besides the plain `deposit(uint256)`, LOVE has a private pool. `deposit(bytes32 |
| 58 | -instead of a recipient address — `deposit(bytes32 secretHash, uint256)` mints | 58 | +commitment)` locks 0.01 wETH against a note, and `claim(proof, root, |
| 59 | -into an escrowed envelope, and `claim(depositId, secret, loveAmount)` releases | 59 | +nullifierHash, recipient)` pays its LOVE to any address whose owner can prove, |
| 60 | -it to whoever proves the secret. Full workflow, the off-chain requirements | 60 | +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: see | 61 | +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 chosen | 65 | ## How wETH is chosen |
| 65 | 66 | ||
| @@ -115,7 +116,7 @@ yourself: see [docs/MULTICHAIN.md](docs/MULTICHAIN.md). | |||
| 115 | ## Docs | 116 | ## Docs |
| 116 | 117 | ||
| 117 | - [Multichain deployment](docs/MULTICHAIN.md) — CREATE2, the codehash allowlist, and verifying determinism | 118 | - [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 |
| 119 | - [Supported networks](docs/SUPPORTED_NETWORKS.md) — where LOVE deploys today and what could be added | 120 | - [Supported networks](docs/SUPPORTED_NETWORKS.md) — where LOVE deploys today and what could be added |
| 120 | 121 | ||
| 121 | ## License | 122 | ## 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" | ||
| 40 | 40 | number_underscore = "thousands" |
| 41 | 41 | multiline_func_header = "attributes_first" |
| 42 | 42 | 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 | 48 | [rpc_endpoints] |
| 45 | 49 | 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 = true | 42 | 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. | ||
| 20 | 20 | Both hashes appear in `script/weth-codehashes.sh` output and in the allowlist in |
| 21 | 21 | `src/WETHRegistry.sol`, so a fixture that has drifted shows up as a test |
| 22 | 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`. | |
| @@ -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 in | 20 | 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 test | 21 | `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 differ | Binary 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 differ | Binary 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.