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

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

Download ZIP

Merge 19-add-anvil-e2e-test into main

rickub committed 2026-09-19T11:23:03Z Browse files
2d802ed parent: 48af163
modified CHANGELOG.md +8 -0
@@ -89,6 +89,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8989
9090 ### Added
9191
92+- `zk/scripts/e2e.mjs`, run with `pnpm e2e` in `zk/`: brings up a local anvil,
93+ deploys LOVE with `script/Love.s.sol`, deposits three notes, rebuilds the tree
94+ from the `Deposit` events, proves with `zk/lib.mjs` and claims from an
95+ unrelated address, then checks an old root still works and that a spent note
96+ and a claim paid to someone else are refused. It follows `docs/ZK.md`, so it
97+ is also what checks the snippets there. `zk/` gains `ethers` v6 for it.
98+- `zk/.npmrc`, setting `manage-package-manager-versions=false` so pnpm stops
99+ writing a `packageManager` field into `zk/package.json`.
92100 - `zk/`: the `withdraw` circom circuit (Poseidon Merkle membership at depth 10,
93101 a nullifier, and the recipient bound to the proof, about 6,100 constraints),
94102 `pnpm setup` for the Groth16 trusted setup, and `zk/lib.mjs` for making a
@@ -89,6 +89,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
89 89
90 ### Added90 ### Added
91 91
92+- `zk/scripts/e2e.mjs`, run with `pnpm e2e` in `zk/`: brings up a local anvil,
93+ deploys LOVE with `script/Love.s.sol`, deposits three notes, rebuilds the tree
94+ from the `Deposit` events, proves with `zk/lib.mjs` and claims from an
95+ unrelated address, then checks an old root still works and that a spent note
96+ and a claim paid to someone else are refused. It follows `docs/ZK.md`, so it
97+ is also what checks the snippets there. `zk/` gains `ethers` v6 for it.
98+- `zk/.npmrc`, setting `manage-package-manager-versions=false` so pnpm stops
99+ writing a `packageManager` field into `zk/package.json`.
92 - `zk/`: the `withdraw` circom circuit (Poseidon Merkle membership at depth 10,100 - `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),101 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 a102 `pnpm setup` for the Groth16 trusted setup, and `zk/lib.mjs` for making a
modified README.md +6 -0
@@ -21,6 +21,12 @@ forge build
2121 forge test
2222 ```
2323
24+The private pool has an end-to-end run against a local anvil, which needs `anvil` and `forge` on your `PATH`:
25+
26+```shell
27+cd zk && pnpm install && pnpm e2e
28+```
29+
2430 ## Deploy
2531
2632 Deployment uses CREATE2, so the token has the same address on every EVM network.
@@ -21,6 +21,12 @@ forge build
21 forge test21 forge test
22 ```22 ```
23 23
24+The private pool has an end-to-end run against a local anvil, which needs `anvil` and `forge` on your `PATH`:
25+
26+```shell
27+cd zk && pnpm install && pnpm e2e
28+```
29+
24 ## Deploy30 ## Deploy
25 31
26 Deployment uses CREATE2, so the token has the same address on every EVM network.32 Deployment uses CREATE2, so the token has the same address on every EVM network.
modified docs/ZK.md +12 -0
@@ -118,6 +118,18 @@ This is fine for a testnet. Before it holds real value:
118118 - Run phase 2 with several independent contributors.
119119 - Have the circuit and the vendored Poseidon audited.
120120
121+## Testing
122+
123+`forge test` covers the contracts against the proofs in `test/fixtures/proofs.json`, which are replayed, so it never runs the prover. `pnpm e2e` does: it needs [anvil](https://book.getfoundry.sh/anvil/) and `forge` on your `PATH`, and runs the whole cycle on a local chain.
124+
125+```shell
126+cd zk
127+pnpm install
128+pnpm e2e
129+```
130+
131+It starts anvil as Base (chain 8453), puts Base's wETH at the OP Stack predeploy address, and deploys with [`script/Love.s.sol`](../script/Love.s.sol). It then deposits three notes, rebuilds the tree from the events, proves with `zk/lib.mjs` and claims to a fresh address from an unrelated one. It also claims against an older root, and checks that a spent note and a claim paid to someone else both revert. Set `E2E_PORT` if 8599 is taken. Nothing leaves your machine.
132+
121133 ## Limits
122134
123135 - **Small anonymity set.** It starts at one and grows only with deposits. Below a few dozen, treat a claim as weakly private.
@@ -118,6 +118,18 @@ This is fine for a testnet. Before it holds real value:
118 - Run phase 2 with several independent contributors.118 - Run phase 2 with several independent contributors.
119 - Have the circuit and the vendored Poseidon audited.119 - Have the circuit and the vendored Poseidon audited.
120 120
121+## Testing
122+
123+`forge test` covers the contracts against the proofs in `test/fixtures/proofs.json`, which are replayed, so it never runs the prover. `pnpm e2e` does: it needs [anvil](https://book.getfoundry.sh/anvil/) and `forge` on your `PATH`, and runs the whole cycle on a local chain.
124+
125+```shell
126+cd zk
127+pnpm install
128+pnpm e2e
129+```
130+
131+It starts anvil as Base (chain 8453), puts Base's wETH at the OP Stack predeploy address, and deploys with [`script/Love.s.sol`](../script/Love.s.sol). It then deposits three notes, rebuilds the tree from the events, proves with `zk/lib.mjs` and claims to a fresh address from an unrelated one. It also claims against an older root, and checks that a spent note and a claim paid to someone else both revert. Set `E2E_PORT` if 8599 is taken. Nothing leaves your machine.
132+
121 ## Limits133 ## Limits
122 134
123 - **Small anonymity set.** It starts at one and grows only with deposits. Below a few dozen, treat a claim as weakly private.135 - **Small anonymity set.** It starts at one and grows only with deposits. Below a few dozen, treat a claim as weakly private.
added zk/.npmrc +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+manage-package-manager-versions=false
new file mode 100644
@@ -0,0 +1 @@
1+manage-package-manager-versions=false
modified zk/package.json +3 -1
@@ -6,11 +6,13 @@
66 "scripts": {
77 "compile": "circom circuits/withdraw.circom --r1cs --wasm -l node_modules -o build/tmp",
88 "setup": "./scripts/setup.sh",
9- "fixture": "node scripts/fixture.mjs"
9+ "fixture": "node scripts/fixture.mjs",
10+ "e2e": "node scripts/e2e.mjs"
1011 },
1112 "dependencies": {
1213 "circomlib": "^2.0.5",
1314 "circomlibjs": "^0.1.7",
15+ "ethers": "^6.17.0",
1416 "snarkjs": "^0.7.6"
1517 }
1618 }
@@ -6,11 +6,13 @@
6 "scripts": {6 "scripts": {
7 "compile": "circom circuits/withdraw.circom --r1cs --wasm -l node_modules -o build/tmp",7 "compile": "circom circuits/withdraw.circom --r1cs --wasm -l node_modules -o build/tmp",
8 "setup": "./scripts/setup.sh",8 "setup": "./scripts/setup.sh",
9- "fixture": "node scripts/fixture.mjs"9+ "fixture": "node scripts/fixture.mjs",
10+ "e2e": "node scripts/e2e.mjs"
10 },11 },
11 "dependencies": {12 "dependencies": {
12 "circomlib": "^2.0.5",13 "circomlib": "^2.0.5",
13 "circomlibjs": "^0.1.7",14 "circomlibjs": "^0.1.7",
15+ "ethers": "^6.17.0",
14 "snarkjs": "^0.7.6"16 "snarkjs": "^0.7.6"
15 }17 }
16 }18 }
modified zk/pnpm-lock.yaml +74 -0
@@ -14,12 +14,18 @@ importers:
1414 circomlibjs:
1515 specifier: ^0.1.7
1616 version: 0.1.7
17+ ethers:
18+ specifier: ^6.17.0
19+ version: 6.17.0
1720 snarkjs:
1821 specifier: ^0.7.6
1922 version: 0.7.6
2023
2124 packages:
2225
26+ '@adraffy/ens-normalize@1.11.1':
27+ resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==}
28+
2329 '@ethersproject/abi@5.8.0':
2430 resolution: {integrity: sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==}
2531
@@ -116,13 +122,26 @@ packages:
116122 '@iden3/binfileutils@0.0.12':
117123 resolution: {integrity: sha512-naAmzuDufRIcoNfQ1d99d7hGHufLA3wZSibtr4dMe6ZeiOPV1KwOZWTJ1YVz4HbaWlpDuzVU72dS4ATQS4PXBQ==}
118124
125+ '@noble/curves@1.2.0':
126+ resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
127+
128+ '@noble/hashes@1.3.2':
129+ resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
130+ engines: {node: '>= 16'}
131+
119132 '@noble/hashes@1.8.0':
120133 resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
121134 engines: {node: ^14.21.3 || >=16}
122135
136+ '@types/node@22.7.5':
137+ resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==}
138+
123139 aes-js@3.0.0:
124140 resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==}
125141
142+ aes-js@4.0.0-beta.5:
143+ resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==}
144+
126145 async@3.2.6:
127146 resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
128147
@@ -216,6 +235,10 @@ packages:
216235 ethers@5.8.0:
217236 resolution: {integrity: sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==}
218237
238+ ethers@6.17.0:
239+ resolution: {integrity: sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==}
240+ engines: {node: '>=14.0.0'}
241+
219242 fastfile@0.0.20:
220243 resolution: {integrity: sha512-r5ZDbgImvVWCP0lA/cGNgQcZqR+aYdFx3u+CtJqUE510pBUVGMn4ulL/iRTI4tACTYsNJ736uzFxEBXesPAktA==}
221244
@@ -311,9 +334,15 @@ packages:
311334 tryer@1.0.1:
312335 resolution: {integrity: sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==}
313336
337+ tslib@2.7.0:
338+ resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
339+
314340 underscore@1.13.6:
315341 resolution: {integrity: sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==}
316342
343+ undici-types@6.19.8:
344+ resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
345+
317346 util-deprecate@1.0.2:
318347 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
319348
@@ -338,8 +367,22 @@ packages:
338367 utf-8-validate:
339368 optional: true
340369
370+ ws@8.21.0:
371+ resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
372+ engines: {node: '>=10.0.0'}
373+ peerDependencies:
374+ bufferutil: ^4.0.1
375+ utf-8-validate: '>=5.0.2'
376+ peerDependenciesMeta:
377+ bufferutil:
378+ optional: true
379+ utf-8-validate:
380+ optional: true
381+
341382 snapshots:
342383
384+ '@adraffy/ens-normalize@1.11.1': {}
385+
343386 '@ethersproject/abi@5.8.0':
344387 dependencies:
345388 '@ethersproject/address': 5.8.0
@@ -602,10 +645,22 @@ snapshots:
602645 fastfile: 0.0.20
603646 ffjavascript: 0.3.1
604647
648+ '@noble/curves@1.2.0':
649+ dependencies:
650+ '@noble/hashes': 1.3.2
651+
652+ '@noble/hashes@1.3.2': {}
653+
605654 '@noble/hashes@1.8.0': {}
606655
656+ '@types/node@22.7.5':
657+ dependencies:
658+ undici-types: 6.19.8
659+
607660 aes-js@3.0.0: {}
608661
662+ aes-js@4.0.0-beta.5: {}
663+
609664 async@3.2.6: {}
610665
611666 b4a@1.9.0: {}
@@ -739,6 +794,19 @@ snapshots:
739794 - bufferutil
740795 - utf-8-validate
741796
797+ ethers@6.17.0:
798+ dependencies:
799+ '@adraffy/ens-normalize': 1.11.1
800+ '@noble/curves': 1.2.0
801+ '@noble/hashes': 1.3.2
802+ '@types/node': 22.7.5
803+ aes-js: 4.0.0-beta.5
804+ tslib: 2.7.0
805+ ws: 8.21.0
806+ transitivePeerDependencies:
807+ - bufferutil
808+ - utf-8-validate
809+
742810 fastfile@0.0.20: {}
743811
744812 ffjavascript@0.2.63:
@@ -852,8 +920,12 @@ snapshots:
852920
853921 tryer@1.0.1: {}
854922
923+ tslib@2.7.0: {}
924+
855925 underscore@1.13.6: {}
856926
927+ undici-types@6.19.8: {}
928+
857929 util-deprecate@1.0.2: {}
858930
859931 wasmbuilder@0.0.16: {}
@@ -865,3 +937,5 @@ snapshots:
865937 web-worker@1.2.0: {}
866938
867939 ws@8.18.0: {}
940+
941+ ws@8.21.0: {}
@@ -14,12 +14,18 @@ importers:
14 circomlibjs:14 circomlibjs:
15 specifier: ^0.1.715 specifier: ^0.1.7
16 version: 0.1.716 version: 0.1.7
17+ ethers:
18+ specifier: ^6.17.0
19+ version: 6.17.0
17 snarkjs:20 snarkjs:
18 specifier: ^0.7.621 specifier: ^0.7.6
19 version: 0.7.622 version: 0.7.6
20 23
21 packages:24 packages:
22 25
26+ '@adraffy/ens-normalize@1.11.1':
27+ resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==}
28+
23 '@ethersproject/abi@5.8.0':29 '@ethersproject/abi@5.8.0':
24 resolution: {integrity: sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==}30 resolution: {integrity: sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==}
25 31
@@ -116,13 +122,26 @@ packages:
116 '@iden3/binfileutils@0.0.12':122 '@iden3/binfileutils@0.0.12':
117 resolution: {integrity: sha512-naAmzuDufRIcoNfQ1d99d7hGHufLA3wZSibtr4dMe6ZeiOPV1KwOZWTJ1YVz4HbaWlpDuzVU72dS4ATQS4PXBQ==}123 resolution: {integrity: sha512-naAmzuDufRIcoNfQ1d99d7hGHufLA3wZSibtr4dMe6ZeiOPV1KwOZWTJ1YVz4HbaWlpDuzVU72dS4ATQS4PXBQ==}
118 124
125+ '@noble/curves@1.2.0':
126+ resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
127+
128+ '@noble/hashes@1.3.2':
129+ resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
130+ engines: {node: '>= 16'}
131+
119 '@noble/hashes@1.8.0':132 '@noble/hashes@1.8.0':
120 resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}133 resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
121 engines: {node: ^14.21.3 || >=16}134 engines: {node: ^14.21.3 || >=16}
122 135
136+ '@types/node@22.7.5':
137+ resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==}
138+
123 aes-js@3.0.0:139 aes-js@3.0.0:
124 resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==}140 resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==}
125 141
142+ aes-js@4.0.0-beta.5:
143+ resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==}
144+
126 async@3.2.6:145 async@3.2.6:
127 resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}146 resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
128 147
@@ -216,6 +235,10 @@ packages:
216 ethers@5.8.0:235 ethers@5.8.0:
217 resolution: {integrity: sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==}236 resolution: {integrity: sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==}
218 237
238+ ethers@6.17.0:
239+ resolution: {integrity: sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==}
240+ engines: {node: '>=14.0.0'}
241+
219 fastfile@0.0.20:242 fastfile@0.0.20:
220 resolution: {integrity: sha512-r5ZDbgImvVWCP0lA/cGNgQcZqR+aYdFx3u+CtJqUE510pBUVGMn4ulL/iRTI4tACTYsNJ736uzFxEBXesPAktA==}243 resolution: {integrity: sha512-r5ZDbgImvVWCP0lA/cGNgQcZqR+aYdFx3u+CtJqUE510pBUVGMn4ulL/iRTI4tACTYsNJ736uzFxEBXesPAktA==}
221 244
@@ -311,9 +334,15 @@ packages:
311 tryer@1.0.1:334 tryer@1.0.1:
312 resolution: {integrity: sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==}335 resolution: {integrity: sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==}
313 336
337+ tslib@2.7.0:
338+ resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
339+
314 underscore@1.13.6:340 underscore@1.13.6:
315 resolution: {integrity: sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==}341 resolution: {integrity: sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==}
316 342
343+ undici-types@6.19.8:
344+ resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
345+
317 util-deprecate@1.0.2:346 util-deprecate@1.0.2:
318 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}347 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
319 348
@@ -338,8 +367,22 @@ packages:
338 utf-8-validate:367 utf-8-validate:
339 optional: true368 optional: true
340 369
370+ ws@8.21.0:
371+ resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
372+ engines: {node: '>=10.0.0'}
373+ peerDependencies:
374+ bufferutil: ^4.0.1
375+ utf-8-validate: '>=5.0.2'
376+ peerDependenciesMeta:
377+ bufferutil:
378+ optional: true
379+ utf-8-validate:
380+ optional: true
381+
341 snapshots:382 snapshots:
342 383
384+ '@adraffy/ens-normalize@1.11.1': {}
385+
343 '@ethersproject/abi@5.8.0':386 '@ethersproject/abi@5.8.0':
344 dependencies:387 dependencies:
345 '@ethersproject/address': 5.8.0388 '@ethersproject/address': 5.8.0
@@ -602,10 +645,22 @@ snapshots:
602 fastfile: 0.0.20645 fastfile: 0.0.20
603 ffjavascript: 0.3.1646 ffjavascript: 0.3.1
604 647
648+ '@noble/curves@1.2.0':
649+ dependencies:
650+ '@noble/hashes': 1.3.2
651+
652+ '@noble/hashes@1.3.2': {}
653+
605 '@noble/hashes@1.8.0': {}654 '@noble/hashes@1.8.0': {}
606 655
656+ '@types/node@22.7.5':
657+ dependencies:
658+ undici-types: 6.19.8
659+
607 aes-js@3.0.0: {}660 aes-js@3.0.0: {}
608 661
662+ aes-js@4.0.0-beta.5: {}
663+
609 async@3.2.6: {}664 async@3.2.6: {}
610 665
611 b4a@1.9.0: {}666 b4a@1.9.0: {}
@@ -739,6 +794,19 @@ snapshots:
739 - bufferutil794 - bufferutil
740 - utf-8-validate795 - utf-8-validate
741 796
797+ ethers@6.17.0:
798+ dependencies:
799+ '@adraffy/ens-normalize': 1.11.1
800+ '@noble/curves': 1.2.0
801+ '@noble/hashes': 1.3.2
802+ '@types/node': 22.7.5
803+ aes-js: 4.0.0-beta.5
804+ tslib: 2.7.0
805+ ws: 8.21.0
806+ transitivePeerDependencies:
807+ - bufferutil
808+ - utf-8-validate
809+
742 fastfile@0.0.20: {}810 fastfile@0.0.20: {}
743 811
744 ffjavascript@0.2.63:812 ffjavascript@0.2.63:
@@ -852,8 +920,12 @@ snapshots:
852 920
853 tryer@1.0.1: {}921 tryer@1.0.1: {}
854 922
923+ tslib@2.7.0: {}
924+
855 underscore@1.13.6: {}925 underscore@1.13.6: {}
856 926
927+ undici-types@6.19.8: {}
928+
857 util-deprecate@1.0.2: {}929 util-deprecate@1.0.2: {}
858 930
859 wasmbuilder@0.0.16: {}931 wasmbuilder@0.0.16: {}
@@ -865,3 +937,5 @@ snapshots:
865 web-worker@1.2.0: {}937 web-worker@1.2.0: {}
866 938
867 ws@8.18.0: {}939 ws@8.18.0: {}
940+
941+ ws@8.21.0: {}
added zk/scripts/e2e.mjs +162 -0
new file mode 100644
@@ -0,0 +1,162 @@
1+// End-to-end run of the private pool against a local anvil: deploy with the real
2+// script, deposit three notes, prove with zk/lib.mjs, claim, and try to break it.
3+//
4+// This is what docs/ZK.md tells a user to do, so it is written the same way.
5+// Needs `anvil` and `forge` on PATH. Run it with `pnpm e2e`.
6+import assert from "node:assert/strict";
7+import { execFileSync, spawn } from "node:child_process";
8+import { mkdtempSync, readFileSync } from "node:fs";
9+import { tmpdir } from "node:os";
10+import { join } from "node:path";
11+import { fileURLToPath } from "node:url";
12+import { Contract, HDNodeWallet, JsonRpcProvider, Network, Wallet } from "ethers";
13+import { buildTree, decodeNote, encodeNote, newNote, noteHashes, prove } from "../lib.mjs";
14+
15+const ROOT = fileURLToPath(new URL("../../", import.meta.url));
16+const PORT = process.env.E2E_PORT ?? "8599";
17+const RPC = `http://127.0.0.1:${PORT}`;
18+
19+// The registry only accepts wETH code it has reviewed for the chain it is on, so
20+// the chain is Base and the wETH is Base's, put at the OP Stack predeploy address.
21+const CHAIN_ID = 8453;
22+const WETH_AT = "0x4200000000000000000000000000000000000006";
23+const WETH_CODE = readFileSync(join(ROOT, "test/fixtures/weth9-op-legacy.hex"), "utf8").trim();
24+
25+const MNEMONIC = "test test test test test test test test test test test junk";
26+const account = (i) => HDNodeWallet.fromPhrase(MNEMONIC, "", `m/44'/60'/0'/0/${i}`);
27+
28+const WETH_ABI = [
29+ "function deposit() payable",
30+ "function approve(address spender, uint256 amount) returns (bool)",
31+];
32+const LOVE_ABI = [
33+ "function DENOMINATION() view returns (uint256)",
34+ "function RATE() view returns (uint256)",
35+ "function balanceOf(address account) view returns (uint256)",
36+ "function nextLeafIndex() view returns (uint256)",
37+ "function isKnownRoot(bytes32 root) view returns (bool)",
38+ "function isSpent(bytes32 nullifierHash) view returns (bool)",
39+ "function deposit(uint256 wethAmount)",
40+ "function deposit(bytes32 commitment)",
41+ "function claim(uint256[2] pA, uint256[2][2] pB, uint256[2] pC, bytes32 root, bytes32 nullifierHash, address recipient)",
42+ "event Deposit(address indexed account, uint256 wethAmount, uint256 loveAmount)",
43+ "event Deposit(bytes32 indexed commitment, uint256 leafIndex)",
44+ "event Claim(address indexed recipient, bytes32 nullifierHash)",
45+ "error NoteAlreadySpent(bytes32 nullifierHash)",
46+ "error InvalidProof()",
47+];
48+
49+const step = (msg) => console.log(`- ${msg}`);
50+const revertsWith = (name) => (e) => e.revert?.name === name;
51+
52+async function startAnvil() {
53+ const anvil = spawn("anvil", ["--chain-id", String(CHAIN_ID), "--port", PORT, "--silent"], { stdio: "inherit" });
54+ anvil.on("error", (e) => {
55+ console.error(`could not start anvil: ${e.message}`);
56+ process.exit(1);
57+ });
58+
59+ const provider = new JsonRpcProvider(RPC, undefined, {
60+ staticNetwork: Network.from(CHAIN_ID),
61+ // Back-to-back transactions from one wallet would otherwise reuse a cached nonce.
62+ cacheTimeout: -1,
63+ });
64+ for (let i = 0; i < 50; i++) {
65+ try {
66+ await provider.getBlockNumber();
67+ return { anvil, provider };
68+ } catch {
69+ await new Promise((r) => setTimeout(r, 200));
70+ }
71+ }
72+ anvil.kill();
73+ throw new Error(`anvil did not come up on ${RPC}`);
74+}
75+
76+// Runs script/Love.s.sol as a user would, and reads the token's address off its log.
77+function deployLove(deployer) {
78+ // Keeps the broadcast log out of the repo.
79+ const broadcast = mkdtempSync(join(tmpdir(), "love-e2e-"));
80+ const out = execFileSync(
81+ "forge",
82+ ["script", "script/Love.s.sol", "--rpc-url", RPC, "--broadcast", "--private-key", deployer.privateKey],
83+ { cwd: ROOT, encoding: "utf8", env: { ...process.env, FOUNDRY_BROADCAST: broadcast } },
84+ );
85+ const address = out.match(/Love deployed at\s+(0x[0-9a-fA-F]{40})/)?.[1];
86+ assert.ok(address, `no "Love deployed at" line in the script output:\n${out}`);
87+ return address;
88+}
89+
90+async function main() {
91+ const { anvil, provider } = await startAnvil();
92+ try {
93+ const [deployer, alice, bob, carol, claimer] = [0, 1, 2, 3, 4].map((i) => account(i).connect(provider));
94+ const recipient = Wallet.createRandom().address;
95+
96+ step("deploying LOVE");
97+ await provider.send("anvil_setCode", [WETH_AT, WETH_CODE.startsWith("0x") ? WETH_CODE : `0x${WETH_CODE}`]);
98+ const love = new Contract(deployLove(deployer), LOVE_ABI, provider);
99+ const denomination = await love.DENOMINATION();
100+ const noteLove = denomination * (await love.RATE());
101+
102+ step("depositing three notes");
103+ const notes = [];
104+ for (const depositor of [alice, bob, carol]) {
105+ const note = newNote();
106+ const { commitment } = await noteHashes(note);
107+
108+ const weth = new Contract(WETH_AT, WETH_ABI, depositor);
109+ await (await weth.deposit({ value: denomination })).wait();
110+ await (await weth.approve(love.target, denomination)).wait();
111+ await (await love.connect(depositor).getFunction("deposit(bytes32)")(commitment)).wait();
112+
113+ notes.push(encodeNote(note));
114+ }
115+ assert.equal(await love.nextLeafIndex(), 3n);
116+
117+ step("rebuilding the tree from Deposit events");
118+ const logs = await love.queryFilter(love.filters["Deposit(bytes32,uint256)"]());
119+ const commitments = logs
120+ .sort((a, b) => Number(a.args.leafIndex - b.args.leafIndex))
121+ .map((log) => log.args.commitment);
122+ assert.equal(commitments.length, 3);
123+ assert.ok(await love.isKnownRoot((await buildTree(commitments)).root), "off-chain root is unknown on-chain");
124+
125+ step("proving and claiming note 1 from a fresh address");
126+ const claim = await prove({ note: decodeNote(notes[1]), commitments, recipient });
127+ const args = [claim.pA, claim.pB, claim.pC, claim.root, claim.nullifierHash, claim.recipient];
128+
129+ await (await love.connect(claimer).claim(...args)).wait();
130+ assert.equal(await love.balanceOf(recipient), noteLove);
131+ assert.ok(await love.isSpent(claim.nullifierHash));
132+
133+ step("claiming note 0 against the root from after its own deposit");
134+ const old = await prove({ note: decodeNote(notes[0]), commitments: commitments.slice(0, 1), recipient });
135+ await (await love.connect(claimer).claim(old.pA, old.pB, old.pC, old.root, old.nullifierHash, recipient)).wait();
136+ assert.equal(await love.balanceOf(recipient), 2n * noteLove);
137+
138+ step("checking a spent note, and a copied claim, are refused");
139+ await assert.rejects(love.connect(claimer).claim.staticCall(...args), revertsWith("NoteAlreadySpent"));
140+ const fresh = await prove({ note: decodeNote(notes[2]), commitments, recipient });
141+ await assert.rejects(
142+ love
143+ .connect(claimer)
144+ .claim.staticCall(fresh.pA, fresh.pB, fresh.pC, fresh.root, fresh.nullifierHash, claimer.address),
145+ revertsWith("InvalidProof"),
146+ );
147+ assert.equal(await love.isSpent(fresh.nullifierHash), false);
148+
149+ console.log("ok");
150+ } finally {
151+ anvil.kill();
152+ }
153+}
154+
155+main().then(
156+ // snarkjs keeps worker threads alive.
157+ () => process.exit(0),
158+ (e) => {
159+ console.error(e);
160+ process.exit(1);
161+ },
162+);
new file mode 100644
@@ -0,0 +1,162 @@
1+// End-to-end run of the private pool against a local anvil: deploy with the real
2+// script, deposit three notes, prove with zk/lib.mjs, claim, and try to break it.
3+//
4+// This is what docs/ZK.md tells a user to do, so it is written the same way.
5+// Needs `anvil` and `forge` on PATH. Run it with `pnpm e2e`.
6+import assert from "node:assert/strict";
7+import { execFileSync, spawn } from "node:child_process";
8+import { mkdtempSync, readFileSync } from "node:fs";
9+import { tmpdir } from "node:os";
10+import { join } from "node:path";
11+import { fileURLToPath } from "node:url";
12+import { Contract, HDNodeWallet, JsonRpcProvider, Network, Wallet } from "ethers";
13+import { buildTree, decodeNote, encodeNote, newNote, noteHashes, prove } from "../lib.mjs";
14+
15+const ROOT = fileURLToPath(new URL("../../", import.meta.url));
16+const PORT = process.env.E2E_PORT ?? "8599";
17+const RPC = `http://127.0.0.1:${PORT}`;
18+
19+// The registry only accepts wETH code it has reviewed for the chain it is on, so
20+// the chain is Base and the wETH is Base's, put at the OP Stack predeploy address.
21+const CHAIN_ID = 8453;
22+const WETH_AT = "0x4200000000000000000000000000000000000006";
23+const WETH_CODE = readFileSync(join(ROOT, "test/fixtures/weth9-op-legacy.hex"), "utf8").trim();
24+
25+const MNEMONIC = "test test test test test test test test test test test junk";
26+const account = (i) => HDNodeWallet.fromPhrase(MNEMONIC, "", `m/44'/60'/0'/0/${i}`);
27+
28+const WETH_ABI = [
29+ "function deposit() payable",
30+ "function approve(address spender, uint256 amount) returns (bool)",
31+];
32+const LOVE_ABI = [
33+ "function DENOMINATION() view returns (uint256)",
34+ "function RATE() view returns (uint256)",
35+ "function balanceOf(address account) view returns (uint256)",
36+ "function nextLeafIndex() view returns (uint256)",
37+ "function isKnownRoot(bytes32 root) view returns (bool)",
38+ "function isSpent(bytes32 nullifierHash) view returns (bool)",
39+ "function deposit(uint256 wethAmount)",
40+ "function deposit(bytes32 commitment)",
41+ "function claim(uint256[2] pA, uint256[2][2] pB, uint256[2] pC, bytes32 root, bytes32 nullifierHash, address recipient)",
42+ "event Deposit(address indexed account, uint256 wethAmount, uint256 loveAmount)",
43+ "event Deposit(bytes32 indexed commitment, uint256 leafIndex)",
44+ "event Claim(address indexed recipient, bytes32 nullifierHash)",
45+ "error NoteAlreadySpent(bytes32 nullifierHash)",
46+ "error InvalidProof()",
47+];
48+
49+const step = (msg) => console.log(`- ${msg}`);
50+const revertsWith = (name) => (e) => e.revert?.name === name;
51+
52+async function startAnvil() {
53+ const anvil = spawn("anvil", ["--chain-id", String(CHAIN_ID), "--port", PORT, "--silent"], { stdio: "inherit" });
54+ anvil.on("error", (e) => {
55+ console.error(`could not start anvil: ${e.message}`);
56+ process.exit(1);
57+ });
58+
59+ const provider = new JsonRpcProvider(RPC, undefined, {
60+ staticNetwork: Network.from(CHAIN_ID),
61+ // Back-to-back transactions from one wallet would otherwise reuse a cached nonce.
62+ cacheTimeout: -1,
63+ });
64+ for (let i = 0; i < 50; i++) {
65+ try {
66+ await provider.getBlockNumber();
67+ return { anvil, provider };
68+ } catch {
69+ await new Promise((r) => setTimeout(r, 200));
70+ }
71+ }
72+ anvil.kill();
73+ throw new Error(`anvil did not come up on ${RPC}`);
74+}
75+
76+// Runs script/Love.s.sol as a user would, and reads the token's address off its log.
77+function deployLove(deployer) {
78+ // Keeps the broadcast log out of the repo.
79+ const broadcast = mkdtempSync(join(tmpdir(), "love-e2e-"));
80+ const out = execFileSync(
81+ "forge",
82+ ["script", "script/Love.s.sol", "--rpc-url", RPC, "--broadcast", "--private-key", deployer.privateKey],
83+ { cwd: ROOT, encoding: "utf8", env: { ...process.env, FOUNDRY_BROADCAST: broadcast } },
84+ );
85+ const address = out.match(/Love deployed at\s+(0x[0-9a-fA-F]{40})/)?.[1];
86+ assert.ok(address, `no "Love deployed at" line in the script output:\n${out}`);
87+ return address;
88+}
89+
90+async function main() {
91+ const { anvil, provider } = await startAnvil();
92+ try {
93+ const [deployer, alice, bob, carol, claimer] = [0, 1, 2, 3, 4].map((i) => account(i).connect(provider));
94+ const recipient = Wallet.createRandom().address;
95+
96+ step("deploying LOVE");
97+ await provider.send("anvil_setCode", [WETH_AT, WETH_CODE.startsWith("0x") ? WETH_CODE : `0x${WETH_CODE}`]);
98+ const love = new Contract(deployLove(deployer), LOVE_ABI, provider);
99+ const denomination = await love.DENOMINATION();
100+ const noteLove = denomination * (await love.RATE());
101+
102+ step("depositing three notes");
103+ const notes = [];
104+ for (const depositor of [alice, bob, carol]) {
105+ const note = newNote();
106+ const { commitment } = await noteHashes(note);
107+
108+ const weth = new Contract(WETH_AT, WETH_ABI, depositor);
109+ await (await weth.deposit({ value: denomination })).wait();
110+ await (await weth.approve(love.target, denomination)).wait();
111+ await (await love.connect(depositor).getFunction("deposit(bytes32)")(commitment)).wait();
112+
113+ notes.push(encodeNote(note));
114+ }
115+ assert.equal(await love.nextLeafIndex(), 3n);
116+
117+ step("rebuilding the tree from Deposit events");
118+ const logs = await love.queryFilter(love.filters["Deposit(bytes32,uint256)"]());
119+ const commitments = logs
120+ .sort((a, b) => Number(a.args.leafIndex - b.args.leafIndex))
121+ .map((log) => log.args.commitment);
122+ assert.equal(commitments.length, 3);
123+ assert.ok(await love.isKnownRoot((await buildTree(commitments)).root), "off-chain root is unknown on-chain");
124+
125+ step("proving and claiming note 1 from a fresh address");
126+ const claim = await prove({ note: decodeNote(notes[1]), commitments, recipient });
127+ const args = [claim.pA, claim.pB, claim.pC, claim.root, claim.nullifierHash, claim.recipient];
128+
129+ await (await love.connect(claimer).claim(...args)).wait();
130+ assert.equal(await love.balanceOf(recipient), noteLove);
131+ assert.ok(await love.isSpent(claim.nullifierHash));
132+
133+ step("claiming note 0 against the root from after its own deposit");
134+ const old = await prove({ note: decodeNote(notes[0]), commitments: commitments.slice(0, 1), recipient });
135+ await (await love.connect(claimer).claim(old.pA, old.pB, old.pC, old.root, old.nullifierHash, recipient)).wait();
136+ assert.equal(await love.balanceOf(recipient), 2n * noteLove);
137+
138+ step("checking a spent note, and a copied claim, are refused");
139+ await assert.rejects(love.connect(claimer).claim.staticCall(...args), revertsWith("NoteAlreadySpent"));
140+ const fresh = await prove({ note: decodeNote(notes[2]), commitments, recipient });
141+ await assert.rejects(
142+ love
143+ .connect(claimer)
144+ .claim.staticCall(fresh.pA, fresh.pB, fresh.pC, fresh.root, fresh.nullifierHash, claimer.address),
145+ revertsWith("InvalidProof"),
146+ );
147+ assert.equal(await love.isSpent(fresh.nullifierHash), false);
148+
149+ console.log("ok");
150+ } finally {
151+ anvil.kill();
152+ }
153+}
154+
155+main().then(
156+ // snarkjs keeps worker threads alive.
157+ () => process.exit(0),
158+ (e) => {
159+ console.error(e);
160+ process.exit(1);
161+ },
162+);