Merge 17-add-batch-envelope-deposit into main
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 | ||
| 51 | 51 | than against `MockWETH`, since a mock's codehash is not on the allowlist and |
| 52 | 52 | adding it would mean testing a different allowlist from the one that ships. |
| 53 | 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 | 60 | ### 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 | 70 | - `LICENSE`, the GPL-3.0 text the README already named, so the terms ship with |
| 58 | 71 | the repository rather than only being asserted. |
| 59 | 72 | - 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 and | 51 | 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 | ### Added | 60 | ### 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 with | 70 | - `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 | ||
| 11 | 11 | - [Code snippets](#code-snippets) |
| 12 | 12 | - [What's happening off-chain](#whats-happening-off-chain) |
| 13 | 13 | - [Bearer semantics and the front-running window](#bearer-semantics-and-the-front-running-window) |
| 14 | + - [Splitting one deposit](#splitting-one-deposit) | |
| 14 | 15 | - [Reference](#reference) |
| 15 | 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 | 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 | 26 | ## The workflow |
| 24 | 27 | |
| 25 | 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 | 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 | 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 | 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 | 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 | 36 | ## Code snippets |
| @@ -104,7 +107,7 @@ None of the steps above marked off-chain are visible to, or enforced by, the con | ||
| 104 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 147 | ## Reference |
| 122 | 148 | |
| 123 | 149 | | Function | Effect | |
| 124 | 150 | |---|---| |
| 125 | 151 | | `deposit(uint256 wethAmount)` | Plain peg deposit — mints LOVE straight to the caller. | |
| 126 | 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 | 154 | | `claim(uint256 depositId, bytes32 secret, uint256 loveAmount)` | Releases up to `loveAmount` LOVE from an envelope to whoever proves `secret`. | |
| 128 | 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 | 159 | ## Further reading |
| 133 | 160 | |
| @@ -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 workflow | 26 | ## 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 snippets | 36 | ## 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 | ## Reference | 147 | ## 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 reading | 159 | ## Further reading |
| 133 | 160 | ||
modified
src/Love.sol +51 -0 | @@ -80,6 +80,11 @@ contract Love is ERC20 { | ||
| 80 | 80 | /// that can never be claimed. |
| 81 | 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 | 88 | /// @notice Thrown when a secret doesn't hash to the envelope's `secretHash`. |
| 84 | 89 | /// @param depositId The envelope the wrong secret was tried against. |
| 85 | 90 | error WrongSecret(uint256 depositId); |
| @@ -178,6 +183,52 @@ contract Love is ERC20 { | ||
| 178 | 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 | 232 | /// @notice Burn `loveAmount` LOVE and release `loveAmount / RATE` wETH to the caller. |
| 182 | 233 | /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a |
| 183 | 234 | /// 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 a | 233 | /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a |
| 183 | /// multiple of `RATE`, so the peg never rounds against the caller or | 234 | /// 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 { | ||
| 250 | 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 | 408 | 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 | 425 | function testFuzz_PartialClaimsSumToTotal(uint256 wethAmount, uint256 first) public { |
| 258 | 426 | wethAmount = bound(wethAmount, 0, type(uint256).max / RATE); |
| 259 | 427 | uint256 total = wethAmount * RATE; |
| @@ -298,4 +466,33 @@ contract LoveEnvelopeTest is Test, Fixtures { | ||
| 298 | 466 | vm.stopPrank(); |
| 299 | 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 | } |
| @@ -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 | FUZZ | 408 | 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 | } |