julien/lovepublic Fork
⑂ a9f7492
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 17-add-batch-envelope-deposit into main

rickub committed 2026-09-19T07:55:09Z Browse files
a9f7492 parent: c8588ca
modified CHANGELOG.md +13 -0
@@ -51,9 +51,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5151 than against `MockWETH`, since a mock's codehash is not on the allowlist and
5252 adding it would mean testing a different allowlist from the one that ships.
5353 `MockWETH` is now the "correct behaviour, wrong bytecode" rejection case.
54+- `docs/PRIVACY.md` no longer calls envelope transfers "unlinkable on-chain" or
55+ "untraceable if not claimed at the same time". `Deposit` and `Claim` carry the
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
58+ them. It is a bearer envelope, not a mixer.
5459
5560 ### Added
5661
62+- `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
64+ mint. A depositor can split one deposit across recipients, each with their own
65+ claim code and amount, and each recipient claims their share in full. It
66+ reverts with `InvalidBatch` on an empty batch or mismatched array lengths, and
67+ with `ZeroSecretHash` if any hash is zero. **Breaking:** `Love`'s creation code
68+ changed, so its CREATE2 address moves; instances already deployed are
69+ immutable and unaffected.
5770 - `LICENSE`, the GPL-3.0 text the README already named, so the terms ship with
5871 the repository rather than only being asserted.
5972 - A "Docs" section in the README linking `docs/MULTICHAIN.md`,
@@ -51,9 +51,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
51 than against `MockWETH`, since a mock's codehash is not on the allowlist and51 than against `MockWETH`, since a mock's codehash is not on the allowlist and
52 adding it would mean testing a different allowlist from the one that ships.52 adding it would mean testing a different allowlist from the one that ships.
53 `MockWETH` is now the "correct behaviour, wrong bytecode" rejection case.53 `MockWETH` is now the "correct behaviour, wrong bytecode" rejection case.
54+- `docs/PRIVACY.md` no longer calls envelope transfers "unlinkable on-chain" or
55+ "untraceable if not claimed at the same time". `Deposit` and `Claim` carry the
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
58+ them. It is a bearer envelope, not a mixer.
54 59
55 ### Added60 ### Added
56 61
62+- `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
64+ mint. A depositor can split one deposit across recipients, each with their own
65+ claim code and amount, and each recipient claims their share in full. It
66+ reverts with `InvalidBatch` on an empty batch or mismatched array lengths, and
67+ with `ZeroSecretHash` if any hash is zero. **Breaking:** `Love`'s creation code
68+ changed, so its CREATE2 address moves; instances already deployed are
69+ immutable and unaffected.
57 - `LICENSE`, the GPL-3.0 text the README already named, so the terms ship with70 - `LICENSE`, the GPL-3.0 text the README already named, so the terms ship with
58 the repository rather than only being asserted.71 the repository rather than only being asserted.
59 - A "Docs" section in the README linking `docs/MULTICHAIN.md`,72 - A "Docs" section in the README linking `docs/MULTICHAIN.md`,
modified docs/PRIVACY.md +30 -3
@@ -11,6 +11,7 @@ How to send LOVE by secret instead of by address, what the depositor and recipie
1111 - [Code snippets](#code-snippets)
1212 - [What's happening off-chain](#whats-happening-off-chain)
1313 - [Bearer semantics and the front-running window](#bearer-semantics-and-the-front-running-window)
14+ - [Splitting one deposit](#splitting-one-deposit)
1415 - [Reference](#reference)
1516 - [Further reading](#further-reading)
1617
@@ -20,6 +21,8 @@ How to send LOVE by secret instead of by address, what the depositor and recipie
2021
2122 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.
2223
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+
2326 ## The workflow
2427
2528 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.
@@ -27,7 +30,7 @@ That on-chain pair is deliberately minimal: it stores a hash and moves tokens. W
2730 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.
2831 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.
2932 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.
30-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 original depositor's counterpart, which is what makes the transfer unlinkable on-chain.
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.
3134 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.
3235
3336 ## Code snippets
@@ -104,7 +107,7 @@ None of the steps above marked off-chain are visible to, or enforced by, the con
104107 - **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.
105108 - **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.
106109 - **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.
107-- **Timing discipline.** A `Deposit` event followed within seconds by a `Claim` is a timing correlation an observer can use to link depositor and recipient even without any secret ever leaking. Instant-claim flows undermine the "untraceable if not claimed at the same time" property the design is going for.
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.
108111 - **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.
109112
110113 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).
@@ -118,16 +121,40 @@ Two mitigations, both off-chain, both covered in the note above:
118121 - 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.
119122 - Prefer a single full claim for high-value envelopes, reserving multi-claim convenience for lower-stakes amounts where the exposure matters less.
120123
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+
121147 ## Reference
122148
123149 | Function | Effect |
124150 |---|---|
125151 | `deposit(uint256 wethAmount)` | Plain peg deposit — mints LOVE straight to the caller. |
126152 | `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. |
127154 | `claim(uint256 depositId, bytes32 secret, uint256 loveAmount)` | Releases up to `loveAmount` LOVE from an envelope to whoever proves `secret`. |
128155 | `withdraw(uint256 loveAmount)` | Burns the caller's own LOVE for wETH. Unrelated to envelopes; unchanged by this feature. |
129156
130-Events: `Deposit` (both overloads), `Claim`, `Withdraw`. Errors specific to envelopes: `UnknownDeposit`, `WrongSecret`, `InsufficientRemaining`.
157+Events: `Deposit` (both overloads), `Claim`, `Withdraw`. Errors specific to envelopes: `UnknownDeposit`, `ZeroSecretHash`, `InvalidBatch`, `WrongSecret`, `InsufficientRemaining`.
131158
132159 ## Further reading
133160
@@ -11,6 +11,7 @@ How to send LOVE by secret instead of by address, what the depositor and recipie
11 - [Code snippets](#code-snippets)11 - [Code snippets](#code-snippets)
12 - [What's happening off-chain](#whats-happening-off-chain)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)13 - [Bearer semantics and the front-running window](#bearer-semantics-and-the-front-running-window)
14+ - [Splitting one deposit](#splitting-one-deposit)
14 - [Reference](#reference)15 - [Reference](#reference)
15 - [Further reading](#further-reading)16 - [Further reading](#further-reading)
16 17
@@ -20,6 +21,8 @@ How to send LOVE by secret instead of by address, what the depositor and recipie
20 21
21 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.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.
22 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+
23 ## The workflow26 ## The workflow
24 27
25 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.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.
@@ -27,7 +30,7 @@ That on-chain pair is deliberately minimal: it stores a hash and moves tokens. W
27 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.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.
28 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.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.
29 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.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.
30-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 original depositor's counterpart, which is what makes the transfer unlinkable on-chain.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.
31 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.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.
32 35
33 ## Code snippets36 ## Code snippets
@@ -104,7 +107,7 @@ None of the steps above marked off-chain are visible to, or enforced by, the con
104 - **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.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.
105 - **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.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.
106 - **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.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.
107-- **Timing discipline.** A `Deposit` event followed within seconds by a `Claim` is a timing correlation an observer can use to link depositor and recipient even without any secret ever leaking. Instant-claim flows undermine the "untraceable if not claimed at the same time" property the design is going for.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.
108 - **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.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.
109 112
110 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).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).
@@ -118,16 +121,40 @@ Two mitigations, both off-chain, both covered in the note above:
118 - 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.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.
119 - Prefer a single full claim for high-value envelopes, reserving multi-claim convenience for lower-stakes amounts where the exposure matters less.122 - Prefer a single full claim for high-value envelopes, reserving multi-claim convenience for lower-stakes amounts where the exposure matters less.
120 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+
121 ## Reference147 ## Reference
122 148
123 | Function | Effect |149 | Function | Effect |
124 |---|---|150 |---|---|
125 | `deposit(uint256 wethAmount)` | Plain peg deposit — mints LOVE straight to the caller. |151 | `deposit(uint256 wethAmount)` | Plain peg deposit — mints LOVE straight to the caller. |
126 | `deposit(bytes32 secretHash, uint256 wethAmount) returns (uint256 depositId)` | Opens an envelope: mints LOVE into escrow, locked behind `secretHash`. |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. |
127 | `claim(uint256 depositId, bytes32 secret, uint256 loveAmount)` | Releases up to `loveAmount` LOVE from an envelope to whoever proves `secret`. |154 | `claim(uint256 depositId, bytes32 secret, uint256 loveAmount)` | Releases up to `loveAmount` LOVE from an envelope to whoever proves `secret`. |
128 | `withdraw(uint256 loveAmount)` | Burns the caller's own LOVE for wETH. Unrelated to envelopes; unchanged by this feature. |155 | `withdraw(uint256 loveAmount)` | Burns the caller's own LOVE for wETH. Unrelated to envelopes; unchanged by this feature. |
129 156
130-Events: `Deposit` (both overloads), `Claim`, `Withdraw`. Errors specific to envelopes: `UnknownDeposit`, `WrongSecret`, `InsufficientRemaining`.157+Events: `Deposit` (both overloads), `Claim`, `Withdraw`. Errors specific to envelopes: `UnknownDeposit`, `ZeroSecretHash`, `InvalidBatch`, `WrongSecret`, `InsufficientRemaining`.
131 158
132 ## Further reading159 ## Further reading
133 160
modified src/Love.sol +51 -0
@@ -80,6 +80,11 @@ contract Love is ERC20 {
8080 /// that can never be claimed.
8181 error ZeroSecretHash();
8282
83+ /// @notice Thrown when a batch deposit has no envelopes, or its two arrays differ in length.
84+ /// @param secretHashesLength The number of secret hashes passed.
85+ /// @param wethAmountsLength The number of wETH amounts passed.
86+ error InvalidBatch(uint256 secretHashesLength, uint256 wethAmountsLength);
87+
8388 /// @notice Thrown when a secret doesn't hash to the envelope's `secretHash`.
8489 /// @param depositId The envelope the wrong secret was tried against.
8590 error WrongSecret(uint256 depositId);
@@ -178,6 +183,52 @@ contract Love is ERC20 {
178183 emit Deposit(depositId, msg.sender, secretHash, wethAmount, loveAmount);
179184 }
180185
186+ /// @notice Lock the sum of `wethAmounts` wETH in one go, opening one envelope per entry.
187+ /// @dev Equivalent to calling `deposit(bytes32,uint256)` once per entry,
188+ /// with a single wETH pull and a single mint. Each envelope has its
189+ /// own secret and amount, so each recipient can claim exactly their
190+ /// share and no more, with a single full `claim` — no partial claim,
191+ /// so none of the front-running window that comes with one. The ids
192+ /// are consecutive, starting at `nextDepositId`.
193+ ///
194+ /// Splitting one deposit this way is not more private than one
195+ /// envelope: every envelope still names the same depositor.
196+ /// @param secretHashes `keccak256(abi.encodePacked(secret))` for each envelope, none of them zero.
197+ /// @param wethAmounts The wETH to lock in each envelope, in wei, one per hash.
198+ /// @return depositIds The ids the envelopes were assigned, in input order.
199+ function deposit(bytes32[] calldata secretHashes, uint256[] calldata wethAmounts)
200+ external
201+ returns (uint256[] memory depositIds)
202+ {
203+ uint256 count = secretHashes.length;
204+ if (count == 0 || count != wethAmounts.length) revert InvalidBatch(count, wethAmounts.length);
205+
206+ depositIds = new uint256[](count);
207+ uint256 nextId = nextDepositId;
208+ uint256 totalWeth;
209+
210+ for (uint256 i; i < count; ++i) {
211+ bytes32 secretHash = secretHashes[i];
212+ if (secretHash == bytes32(0)) revert ZeroSecretHash();
213+
214+ uint256 wethAmount = wethAmounts[i];
215+ uint256 loveAmount = wethAmount * RATE;
216+
217+ totalWeth += wethAmount;
218+ depositIds[i] = nextId;
219+ envelopes[nextId] = Envelope({secretHash: secretHash, remaining: loveAmount});
220+
221+ emit Deposit(nextId, msg.sender, secretHash, wethAmount, loveAmount);
222+
223+ ++nextId;
224+ }
225+
226+ nextDepositId = nextId;
227+
228+ WETH.safeTransferFrom(msg.sender, address(this), totalWeth);
229+ _mint(address(this), totalWeth * RATE);
230+ }
231+
181232 /// @notice Burn `loveAmount` LOVE and release `loveAmount / RATE` wETH to the caller.
182233 /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a
183234 /// multiple of `RATE`, so the peg never rounds against the caller or
@@ -80,6 +80,11 @@ contract Love is ERC20 {
80 /// that can never be claimed.80 /// that can never be claimed.
81 error ZeroSecretHash();81 error ZeroSecretHash();
82 82
83+ /// @notice Thrown when a batch deposit has no envelopes, or its two arrays differ in length.
84+ /// @param secretHashesLength The number of secret hashes passed.
85+ /// @param wethAmountsLength The number of wETH amounts passed.
86+ error InvalidBatch(uint256 secretHashesLength, uint256 wethAmountsLength);
87+
83 /// @notice Thrown when a secret doesn't hash to the envelope's `secretHash`.88 /// @notice Thrown when a secret doesn't hash to the envelope's `secretHash`.
84 /// @param depositId The envelope the wrong secret was tried against.89 /// @param depositId The envelope the wrong secret was tried against.
85 error WrongSecret(uint256 depositId);90 error WrongSecret(uint256 depositId);
@@ -178,6 +183,52 @@ contract Love is ERC20 {
178 emit Deposit(depositId, msg.sender, secretHash, wethAmount, loveAmount);183 emit Deposit(depositId, msg.sender, secretHash, wethAmount, loveAmount);
179 }184 }
180 185
186+ /// @notice Lock the sum of `wethAmounts` wETH in one go, opening one envelope per entry.
187+ /// @dev Equivalent to calling `deposit(bytes32,uint256)` once per entry,
188+ /// with a single wETH pull and a single mint. Each envelope has its
189+ /// own secret and amount, so each recipient can claim exactly their
190+ /// share and no more, with a single full `claim` — no partial claim,
191+ /// so none of the front-running window that comes with one. The ids
192+ /// are consecutive, starting at `nextDepositId`.
193+ ///
194+ /// Splitting one deposit this way is not more private than one
195+ /// envelope: every envelope still names the same depositor.
196+ /// @param secretHashes `keccak256(abi.encodePacked(secret))` for each envelope, none of them zero.
197+ /// @param wethAmounts The wETH to lock in each envelope, in wei, one per hash.
198+ /// @return depositIds The ids the envelopes were assigned, in input order.
199+ function deposit(bytes32[] calldata secretHashes, uint256[] calldata wethAmounts)
200+ external
201+ returns (uint256[] memory depositIds)
202+ {
203+ uint256 count = secretHashes.length;
204+ if (count == 0 || count != wethAmounts.length) revert InvalidBatch(count, wethAmounts.length);
205+
206+ depositIds = new uint256[](count);
207+ uint256 nextId = nextDepositId;
208+ uint256 totalWeth;
209+
210+ for (uint256 i; i < count; ++i) {
211+ bytes32 secretHash = secretHashes[i];
212+ if (secretHash == bytes32(0)) revert ZeroSecretHash();
213+
214+ uint256 wethAmount = wethAmounts[i];
215+ uint256 loveAmount = wethAmount * RATE;
216+
217+ totalWeth += wethAmount;
218+ depositIds[i] = nextId;
219+ envelopes[nextId] = Envelope({secretHash: secretHash, remaining: loveAmount});
220+
221+ emit Deposit(nextId, msg.sender, secretHash, wethAmount, loveAmount);
222+
223+ ++nextId;
224+ }
225+
226+ nextDepositId = nextId;
227+
228+ WETH.safeTransferFrom(msg.sender, address(this), totalWeth);
229+ _mint(address(this), totalWeth * RATE);
230+ }
231+
181 /// @notice Burn `loveAmount` LOVE and release `loveAmount / RATE` wETH to the caller.232 /// @notice Burn `loveAmount` LOVE and release `loveAmount / RATE` wETH to the caller.
182 /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a233 /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a
183 /// multiple of `RATE`, so the peg never rounds against the caller or234 /// multiple of `RATE`, so the peg never rounds against the caller or
modified test/LoveEnvelope.t.sol +197 -0
@@ -250,10 +250,178 @@ contract LoveEnvelopeTest is Test, Fixtures {
250250 love.claim(depositId, SECRET, 1);
251251 }
252252
253+ /*//////////////////////////////////////////////////////////////
254+ BATCH DEPOSIT
255+ //////////////////////////////////////////////////////////////*/
256+
257+ bytes32 constant SECRET_B = keccak256("second secret");
258+ bytes32 constant SECRET_B_HASH = keccak256(abi.encodePacked(SECRET_B));
259+
260+ function test_BatchDeposit() public {
261+ uint256[] memory depositIds =
262+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
263+
264+ assertEq(depositIds.length, 2);
265+ assertEq(depositIds[0], 0);
266+ assertEq(depositIds[1], 1);
267+ assertEq(love.nextDepositId(), 2);
268+
269+ (bytes32 firstHash, uint256 firstRemaining) = love.envelopes(0);
270+ (bytes32 secondHash, uint256 secondRemaining) = love.envelopes(1);
271+ assertEq(firstHash, SECRET_HASH);
272+ assertEq(firstRemaining, 0.2 ether * RATE);
273+ assertEq(secondHash, SECRET_B_HASH);
274+ assertEq(secondRemaining, 0.8 ether * RATE);
275+
276+ assertEq(love.balanceOf(address(love)), 1 ether * RATE);
277+ assertEq(love.balanceOf(alice), 0);
278+ assertEq(weth.balanceOf(address(love)), 1 ether);
279+ assertEq(weth.balanceOf(alice), 9 ether);
280+ }
281+
282+ function test_BatchDepositContinuesFromNextDepositId() public {
283+ _depositEnvelope(alice, keccak256("earlier"), 0.1 ether);
284+
285+ uint256[] memory depositIds =
286+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
287+
288+ assertEq(depositIds[0], 1);
289+ assertEq(depositIds[1], 2);
290+ assertEq(love.nextDepositId(), 3);
291+ }
292+
293+ function test_BatchDepositEmitsOneDepositPerEnvelope() public {
294+ vm.prank(alice);
295+ weth.approve(address(love), 1 ether);
296+
297+ vm.expectEmit(true, true, false, true);
298+ emit Love.Deposit(0, alice, SECRET_HASH, 0.2 ether, 0.2 ether * RATE);
299+ vm.expectEmit(true, true, false, true);
300+ emit Love.Deposit(1, alice, SECRET_B_HASH, 0.8 ether, 0.8 ether * RATE);
301+
302+ vm.prank(alice);
303+ love.deposit(_hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
304+ }
305+
306+ function test_BatchDepositKeepsPegInvariant() public {
307+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
308+
309+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
310+ }
311+
312+ /// @dev The point of the batch: each recipient gets their own share and
313+ /// nothing else, with a single full claim each.
314+ function test_BatchRecipientsClaimOnlyTheirShare() public {
315+ uint256[] memory depositIds =
316+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
317+
318+ vm.prank(bob);
319+ love.claim(depositIds[0], SECRET, 0.2 ether * RATE);
320+ vm.prank(stranger);
321+ love.claim(depositIds[1], SECRET_B, 0.8 ether * RATE);
322+
323+ assertEq(love.balanceOf(bob), 0.2 ether * RATE);
324+ assertEq(love.balanceOf(stranger), 0.8 ether * RATE);
325+ assertEq(love.balanceOf(address(love)), 0);
326+ }
327+
328+ function test_RevertWhen_BatchRecipientClaimsPastTheirShare() public {
329+ uint256[] memory depositIds =
330+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
331+
332+ vm.prank(bob);
333+ vm.expectRevert(
334+ abi.encodeWithSelector(
335+ Love.InsufficientRemaining.selector, depositIds[0], 0.5 ether * RATE, 0.2 ether * RATE
336+ )
337+ );
338+ love.claim(depositIds[0], SECRET, 0.5 ether * RATE);
339+ }
340+
341+ function test_RevertWhen_BatchSecretOpensTheWrongEnvelope() public {
342+ uint256[] memory depositIds =
343+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
344+
345+ vm.prank(bob);
346+ vm.expectRevert(abi.encodeWithSelector(Love.WrongSecret.selector, depositIds[1]));
347+ love.claim(depositIds[1], SECRET, 0.8 ether * RATE);
348+ }
349+
350+ function test_BatchOfOneMatchesSingleDeposit() public {
351+ uint256 single = _depositEnvelope(alice, SECRET_HASH, 1 ether);
352+ bytes32[] memory hashes = new bytes32[](1);
353+ hashes[0] = SECRET_HASH;
354+ uint256[] memory amounts = new uint256[](1);
355+ amounts[0] = 1 ether;
356+ uint256[] memory batch = _depositBatch(alice, hashes, amounts);
357+
358+ (bytes32 singleHash, uint256 singleRemaining) = love.envelopes(single);
359+ (bytes32 batchHash, uint256 batchRemaining) = love.envelopes(batch[0]);
360+ assertEq(batchHash, singleHash);
361+ assertEq(batchRemaining, singleRemaining);
362+ }
363+
364+ function test_RevertWhen_BatchIsEmpty() public {
365+ vm.prank(alice);
366+ vm.expectRevert(abi.encodeWithSelector(Love.InvalidBatch.selector, 0, 0));
367+ love.deposit(new bytes32[](0), new uint256[](0));
368+ }
369+
370+ function test_RevertWhen_BatchLengthsDiffer() public {
371+ bytes32[] memory hashes = _hashes(SECRET_HASH, SECRET_B_HASH);
372+ uint256[] memory amounts = new uint256[](1);
373+ amounts[0] = 1 ether;
374+
375+ vm.prank(alice);
376+ vm.expectRevert(abi.encodeWithSelector(Love.InvalidBatch.selector, 2, 1));
377+ love.deposit(hashes, amounts);
378+ }
379+
380+ function test_RevertWhen_BatchContainsZeroSecretHash() public {
381+ vm.prank(alice);
382+ weth.approve(address(love), 1 ether);
383+
384+ vm.prank(alice);
385+ vm.expectRevert(Love.ZeroSecretHash.selector);
386+ love.deposit(_hashes(SECRET_HASH, bytes32(0)), _amounts(0.2 ether, 0.8 ether));
387+
388+ assertEq(love.nextDepositId(), 0);
389+ assertEq(weth.balanceOf(address(love)), 0);
390+ }
391+
392+ function test_RevertWhen_BatchWithoutApproval() public {
393+ vm.prank(alice);
394+ vm.expectRevert(bytes(""));
395+ love.deposit(_hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
396+ }
397+
398+ function test_RevertWhen_BatchApprovalCoversOnlyPartOfTheSum() public {
399+ vm.prank(alice);
400+ weth.approve(address(love), 0.8 ether);
401+
402+ vm.prank(alice);
403+ vm.expectRevert(bytes(""));
404+ love.deposit(_hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
405+ }
406+
253407 /*//////////////////////////////////////////////////////////////
254408 FUZZ
255409 //////////////////////////////////////////////////////////////*/
256410
411+ function testFuzz_BatchDepositTotalsMatch(uint256 first, uint256 second) public {
412+ first = bound(first, 0, type(uint256).max / RATE / 2);
413+ second = bound(second, 0, type(uint256).max / RATE / 2);
414+
415+ _fund(alice, first + second);
416+ uint256[] memory depositIds = _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(first, second));
417+
418+ (, uint256 firstRemaining) = love.envelopes(depositIds[0]);
419+ (, uint256 secondRemaining) = love.envelopes(depositIds[1]);
420+ assertEq(firstRemaining, first * RATE);
421+ assertEq(secondRemaining, second * RATE);
422+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
423+ }
424+
257425 function testFuzz_PartialClaimsSumToTotal(uint256 wethAmount, uint256 first) public {
258426 wethAmount = bound(wethAmount, 0, type(uint256).max / RATE);
259427 uint256 total = wethAmount * RATE;
@@ -298,4 +466,33 @@ contract LoveEnvelopeTest is Test, Fixtures {
298466 vm.stopPrank();
299467 return depositId;
300468 }
469+
470+ /// @dev Approves the sum of `wethAmounts` and opens one envelope per entry, in one step.
471+ function _depositBatch(address account, bytes32[] memory secretHashes, uint256[] memory wethAmounts)
472+ internal
473+ returns (uint256[] memory)
474+ {
475+ uint256 total;
476+ for (uint256 i; i < wethAmounts.length; ++i) {
477+ total += wethAmounts[i];
478+ }
479+
480+ vm.startPrank(account);
481+ weth.approve(address(love), total);
482+ uint256[] memory depositIds = love.deposit(secretHashes, wethAmounts);
483+ vm.stopPrank();
484+ return depositIds;
485+ }
486+
487+ function _hashes(bytes32 a, bytes32 b) internal pure returns (bytes32[] memory hashes) {
488+ hashes = new bytes32[](2);
489+ hashes[0] = a;
490+ hashes[1] = b;
491+ }
492+
493+ function _amounts(uint256 a, uint256 b) internal pure returns (uint256[] memory amounts) {
494+ amounts = new uint256[](2);
495+ amounts[0] = a;
496+ amounts[1] = b;
497+ }
301498 }
@@ -250,10 +250,178 @@ contract LoveEnvelopeTest is Test, Fixtures {
250 love.claim(depositId, SECRET, 1);250 love.claim(depositId, SECRET, 1);
251 }251 }
252 252
253+ /*//////////////////////////////////////////////////////////////
254+ BATCH DEPOSIT
255+ //////////////////////////////////////////////////////////////*/
256+
257+ bytes32 constant SECRET_B = keccak256("second secret");
258+ bytes32 constant SECRET_B_HASH = keccak256(abi.encodePacked(SECRET_B));
259+
260+ function test_BatchDeposit() public {
261+ uint256[] memory depositIds =
262+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
263+
264+ assertEq(depositIds.length, 2);
265+ assertEq(depositIds[0], 0);
266+ assertEq(depositIds[1], 1);
267+ assertEq(love.nextDepositId(), 2);
268+
269+ (bytes32 firstHash, uint256 firstRemaining) = love.envelopes(0);
270+ (bytes32 secondHash, uint256 secondRemaining) = love.envelopes(1);
271+ assertEq(firstHash, SECRET_HASH);
272+ assertEq(firstRemaining, 0.2 ether * RATE);
273+ assertEq(secondHash, SECRET_B_HASH);
274+ assertEq(secondRemaining, 0.8 ether * RATE);
275+
276+ assertEq(love.balanceOf(address(love)), 1 ether * RATE);
277+ assertEq(love.balanceOf(alice), 0);
278+ assertEq(weth.balanceOf(address(love)), 1 ether);
279+ assertEq(weth.balanceOf(alice), 9 ether);
280+ }
281+
282+ function test_BatchDepositContinuesFromNextDepositId() public {
283+ _depositEnvelope(alice, keccak256("earlier"), 0.1 ether);
284+
285+ uint256[] memory depositIds =
286+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
287+
288+ assertEq(depositIds[0], 1);
289+ assertEq(depositIds[1], 2);
290+ assertEq(love.nextDepositId(), 3);
291+ }
292+
293+ function test_BatchDepositEmitsOneDepositPerEnvelope() public {
294+ vm.prank(alice);
295+ weth.approve(address(love), 1 ether);
296+
297+ vm.expectEmit(true, true, false, true);
298+ emit Love.Deposit(0, alice, SECRET_HASH, 0.2 ether, 0.2 ether * RATE);
299+ vm.expectEmit(true, true, false, true);
300+ emit Love.Deposit(1, alice, SECRET_B_HASH, 0.8 ether, 0.8 ether * RATE);
301+
302+ vm.prank(alice);
303+ love.deposit(_hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
304+ }
305+
306+ function test_BatchDepositKeepsPegInvariant() public {
307+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
308+
309+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
310+ }
311+
312+ /// @dev The point of the batch: each recipient gets their own share and
313+ /// nothing else, with a single full claim each.
314+ function test_BatchRecipientsClaimOnlyTheirShare() public {
315+ uint256[] memory depositIds =
316+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
317+
318+ vm.prank(bob);
319+ love.claim(depositIds[0], SECRET, 0.2 ether * RATE);
320+ vm.prank(stranger);
321+ love.claim(depositIds[1], SECRET_B, 0.8 ether * RATE);
322+
323+ assertEq(love.balanceOf(bob), 0.2 ether * RATE);
324+ assertEq(love.balanceOf(stranger), 0.8 ether * RATE);
325+ assertEq(love.balanceOf(address(love)), 0);
326+ }
327+
328+ function test_RevertWhen_BatchRecipientClaimsPastTheirShare() public {
329+ uint256[] memory depositIds =
330+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
331+
332+ vm.prank(bob);
333+ vm.expectRevert(
334+ abi.encodeWithSelector(
335+ Love.InsufficientRemaining.selector, depositIds[0], 0.5 ether * RATE, 0.2 ether * RATE
336+ )
337+ );
338+ love.claim(depositIds[0], SECRET, 0.5 ether * RATE);
339+ }
340+
341+ function test_RevertWhen_BatchSecretOpensTheWrongEnvelope() public {
342+ uint256[] memory depositIds =
343+ _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
344+
345+ vm.prank(bob);
346+ vm.expectRevert(abi.encodeWithSelector(Love.WrongSecret.selector, depositIds[1]));
347+ love.claim(depositIds[1], SECRET, 0.8 ether * RATE);
348+ }
349+
350+ function test_BatchOfOneMatchesSingleDeposit() public {
351+ uint256 single = _depositEnvelope(alice, SECRET_HASH, 1 ether);
352+ bytes32[] memory hashes = new bytes32[](1);
353+ hashes[0] = SECRET_HASH;
354+ uint256[] memory amounts = new uint256[](1);
355+ amounts[0] = 1 ether;
356+ uint256[] memory batch = _depositBatch(alice, hashes, amounts);
357+
358+ (bytes32 singleHash, uint256 singleRemaining) = love.envelopes(single);
359+ (bytes32 batchHash, uint256 batchRemaining) = love.envelopes(batch[0]);
360+ assertEq(batchHash, singleHash);
361+ assertEq(batchRemaining, singleRemaining);
362+ }
363+
364+ function test_RevertWhen_BatchIsEmpty() public {
365+ vm.prank(alice);
366+ vm.expectRevert(abi.encodeWithSelector(Love.InvalidBatch.selector, 0, 0));
367+ love.deposit(new bytes32[](0), new uint256[](0));
368+ }
369+
370+ function test_RevertWhen_BatchLengthsDiffer() public {
371+ bytes32[] memory hashes = _hashes(SECRET_HASH, SECRET_B_HASH);
372+ uint256[] memory amounts = new uint256[](1);
373+ amounts[0] = 1 ether;
374+
375+ vm.prank(alice);
376+ vm.expectRevert(abi.encodeWithSelector(Love.InvalidBatch.selector, 2, 1));
377+ love.deposit(hashes, amounts);
378+ }
379+
380+ function test_RevertWhen_BatchContainsZeroSecretHash() public {
381+ vm.prank(alice);
382+ weth.approve(address(love), 1 ether);
383+
384+ vm.prank(alice);
385+ vm.expectRevert(Love.ZeroSecretHash.selector);
386+ love.deposit(_hashes(SECRET_HASH, bytes32(0)), _amounts(0.2 ether, 0.8 ether));
387+
388+ assertEq(love.nextDepositId(), 0);
389+ assertEq(weth.balanceOf(address(love)), 0);
390+ }
391+
392+ function test_RevertWhen_BatchWithoutApproval() public {
393+ vm.prank(alice);
394+ vm.expectRevert(bytes(""));
395+ love.deposit(_hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
396+ }
397+
398+ function test_RevertWhen_BatchApprovalCoversOnlyPartOfTheSum() public {
399+ vm.prank(alice);
400+ weth.approve(address(love), 0.8 ether);
401+
402+ vm.prank(alice);
403+ vm.expectRevert(bytes(""));
404+ love.deposit(_hashes(SECRET_HASH, SECRET_B_HASH), _amounts(0.2 ether, 0.8 ether));
405+ }
406+
253 /*//////////////////////////////////////////////////////////////407 /*//////////////////////////////////////////////////////////////
254 FUZZ408 FUZZ
255 //////////////////////////////////////////////////////////////*/409 //////////////////////////////////////////////////////////////*/
256 410
411+ function testFuzz_BatchDepositTotalsMatch(uint256 first, uint256 second) public {
412+ first = bound(first, 0, type(uint256).max / RATE / 2);
413+ second = bound(second, 0, type(uint256).max / RATE / 2);
414+
415+ _fund(alice, first + second);
416+ uint256[] memory depositIds = _depositBatch(alice, _hashes(SECRET_HASH, SECRET_B_HASH), _amounts(first, second));
417+
418+ (, uint256 firstRemaining) = love.envelopes(depositIds[0]);
419+ (, uint256 secondRemaining) = love.envelopes(depositIds[1]);
420+ assertEq(firstRemaining, first * RATE);
421+ assertEq(secondRemaining, second * RATE);
422+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
423+ }
424+
257 function testFuzz_PartialClaimsSumToTotal(uint256 wethAmount, uint256 first) public {425 function testFuzz_PartialClaimsSumToTotal(uint256 wethAmount, uint256 first) public {
258 wethAmount = bound(wethAmount, 0, type(uint256).max / RATE);426 wethAmount = bound(wethAmount, 0, type(uint256).max / RATE);
259 uint256 total = wethAmount * RATE;427 uint256 total = wethAmount * RATE;
@@ -298,4 +466,33 @@ contract LoveEnvelopeTest is Test, Fixtures {
298 vm.stopPrank();466 vm.stopPrank();
299 return depositId;467 return depositId;
300 }468 }
469+
470+ /// @dev Approves the sum of `wethAmounts` and opens one envelope per entry, in one step.
471+ function _depositBatch(address account, bytes32[] memory secretHashes, uint256[] memory wethAmounts)
472+ internal
473+ returns (uint256[] memory)
474+ {
475+ uint256 total;
476+ for (uint256 i; i < wethAmounts.length; ++i) {
477+ total += wethAmounts[i];
478+ }
479+
480+ vm.startPrank(account);
481+ weth.approve(address(love), total);
482+ uint256[] memory depositIds = love.deposit(secretHashes, wethAmounts);
483+ vm.stopPrank();
484+ return depositIds;
485+ }
486+
487+ function _hashes(bytes32 a, bytes32 b) internal pure returns (bytes32[] memory hashes) {
488+ hashes = new bytes32[](2);
489+ hashes[0] = a;
490+ hashes[1] = b;
491+ }
492+
493+ function _amounts(uint256 a, uint256 b) internal pure returns (uint256[] memory amounts) {
494+ amounts = new uint256[](2);
495+ amounts[0] = a;
496+ amounts[1] = b;
497+ }
301 }498 }