Merge 6-add-love-envelope into main
13964fb parent: 2f6da5e modified
CHANGELOG.md +10 -0 | @@ -72,3 +72,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | ||
| 72 | 72 | different chain IDs and failing if the two addresses don't match. |
| 73 | 73 | - `remappings.txt` for `@openzeppelin/contracts/` and `forge-std/`. |
| 74 | 74 | - `.env.example` documenting required environment variables, including `WETH`. |
| 75 | +- `Love.deposit(bytes32 secretHash, uint256 wethAmount)`, an overload that pulls | |
| 76 | + wETH and mints LOVE the same way as the plain `deposit`, but locks it into a | |
| 77 | + secret-locked envelope (auto-incrementing `depositId`, decrementing | |
| 78 | + `remaining`) instead of crediting the caller, keeping the peg invariant | |
| 79 | + exactly. `claim(uint256 depositId, bytes32 secret, uint256 loveAmount)` | |
| 80 | + releases LOVE from an envelope to whoever proves the secret — bearer-note | |
| 81 | + semantics, reusable across partial claims — after which it's an ordinary | |
| 82 | + balance, redeemable through the unchanged `withdraw`. This is the on-chain | |
| 83 | + half of the off-chain processes documented in | |
| 84 | + `docs/notes/add-privacy-to-love.md`. | |
| @@ -72,3 +72,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | |||
| 72 | different chain IDs and failing if the two addresses don't match. | 72 | different chain IDs and failing if the two addresses don't match. |
| 73 | - `remappings.txt` for `@openzeppelin/contracts/` and `forge-std/`. | 73 | - `remappings.txt` for `@openzeppelin/contracts/` and `forge-std/`. |
| 74 | - `.env.example` documenting required environment variables, including `WETH`. | 74 | - `.env.example` documenting required environment variables, including `WETH`. |
| 75 | +- `Love.deposit(bytes32 secretHash, uint256 wethAmount)`, an overload that pulls | ||
| 76 | + wETH and mints LOVE the same way as the plain `deposit`, but locks it into a | ||
| 77 | + secret-locked envelope (auto-incrementing `depositId`, decrementing | ||
| 78 | + `remaining`) instead of crediting the caller, keeping the peg invariant | ||
| 79 | + exactly. `claim(uint256 depositId, bytes32 secret, uint256 loveAmount)` | ||
| 80 | + releases LOVE from an envelope to whoever proves the secret — bearer-note | ||
| 81 | + semantics, reusable across partial claims — after which it's an ordinary | ||
| 82 | + balance, redeemable through the unchanged `withdraw`. This is the on-chain | ||
| 83 | + half of the off-chain processes documented in | ||
| 84 | + `docs/notes/add-privacy-to-love.md`. | ||
modified
src/Love.sol +98 -0 | @@ -42,6 +42,21 @@ contract Love is ERC20 { | ||
| 42 | 42 | /// is itself write-once, so nothing can move the token under the peg. |
| 43 | 43 | IERC20 public immutable WETH; |
| 44 | 44 | |
| 45 | + /// @notice One secret-locked deposit: a hash to unlock it, and what's left of it. | |
| 46 | + /// @dev Opened by the `deposit(bytes32,uint256)` overload; see | |
| 47 | + /// docs/notes/add-privacy-to-love.md for the off-chain processes | |
| 48 | + /// (secret generation, sharing, timing discipline) this depends on. | |
| 49 | + struct Envelope { | |
| 50 | + bytes32 secretHash; | |
| 51 | + uint256 remaining; | |
| 52 | + } | |
| 53 | + | |
| 54 | + /// @notice Envelope storage, by `depositId`. | |
| 55 | + mapping(uint256 => Envelope) public envelopes; | |
| 56 | + | |
| 57 | + /// @notice The `depositId` the next secret-locked `deposit` call will be assigned. | |
| 58 | + uint256 public nextDepositId; | |
| 59 | + | |
| 45 | 60 | /// @notice Thrown when a withdrawal amount is not a multiple of `RATE`. |
| 46 | 61 | /// @param loveAmount The rejected LOVE amount. |
| 47 | 62 | /// @param rate The rate it has to be a multiple of. |
| @@ -55,18 +70,49 @@ contract Love is ERC20 { | ||
| 55 | 70 | /// @param registry The registry that was asked. |
| 56 | 71 | error WethNotRegistered(address registry); |
| 57 | 72 | |
| 73 | + /// @notice Thrown when `depositId` has never been deposited into. | |
| 74 | + /// @param depositId The id that doesn't exist. | |
| 75 | + error UnknownDeposit(uint256 depositId); | |
| 76 | + | |
| 77 | + /// @notice Thrown when a secret doesn't hash to the envelope's `secretHash`. | |
| 78 | + /// @param depositId The envelope the wrong secret was tried against. | |
| 79 | + error WrongSecret(uint256 depositId); | |
| 80 | + | |
| 81 | + /// @notice Thrown when a claim amount exceeds what's left in the envelope. | |
| 82 | + /// @param depositId The envelope that was asked. | |
| 83 | + /// @param requested The amount asked for. | |
| 84 | + /// @param remaining What was actually left. | |
| 85 | + error InsufficientRemaining(uint256 depositId, uint256 requested, uint256 remaining); | |
| 86 | + | |
| 58 | 87 | /// @notice Emitted when wETH is locked and LOVE minted. |
| 59 | 88 | /// @param account The depositor, who pays the wETH and receives the LOVE. |
| 60 | 89 | /// @param wethAmount The wETH pulled in. |
| 61 | 90 | /// @param loveAmount The LOVE minted, `wethAmount * RATE`. |
| 62 | 91 | event Deposit(address indexed account, uint256 wethAmount, uint256 loveAmount); |
| 63 | 92 | |
| 93 | + /// @notice Emitted when wETH is locked and LOVE minted into a secret-locked envelope. | |
| 94 | + /// @param depositId The id this envelope was assigned. | |
| 95 | + /// @param account The depositor, who pays the wETH. | |
| 96 | + /// @param secretHash `keccak256` of the secret that unlocks this envelope. | |
| 97 | + /// @param wethAmount The wETH pulled in. | |
| 98 | + /// @param loveAmount The LOVE minted, `wethAmount * RATE`. | |
| 99 | + event Deposit( | |
| 100 | + uint256 indexed depositId, address indexed account, bytes32 secretHash, uint256 wethAmount, uint256 loveAmount | |
| 101 | + ); | |
| 102 | + | |
| 64 | 103 | /// @notice Emitted when LOVE is burned and wETH released. |
| 65 | 104 | /// @param account The redeemer, who burns the LOVE and receives the wETH. |
| 66 | 105 | /// @param loveAmount The LOVE burned. |
| 67 | 106 | /// @param wethAmount The wETH released, `loveAmount / RATE`. |
| 68 | 107 | event Withdraw(address indexed account, uint256 loveAmount, uint256 wethAmount); |
| 69 | 108 | |
| 109 | + /// @notice Emitted when LOVE is released from an envelope against a correct secret. | |
| 110 | + /// @param depositId The envelope claimed from. | |
| 111 | + /// @param account The claimant, who proved the secret and receives the LOVE. | |
| 112 | + /// @param loveAmount The LOVE released. | |
| 113 | + /// @param remaining What's left in the envelope after this claim. | |
| 114 | + event Claim(uint256 indexed depositId, address indexed account, uint256 loveAmount, uint256 remaining); | |
| 115 | + | |
| 70 | 116 | /// @notice Peg to whatever wETH the registry has accepted on this chain. |
| 71 | 117 | /// @dev Takes no arguments on purpose. Anything passed in here would land |
| 72 | 118 | /// in the creation code and give the token a different address on |
| @@ -99,6 +145,31 @@ contract Love is ERC20 { | ||
| 99 | 145 | emit Deposit(msg.sender, wethAmount, loveAmount); |
| 100 | 146 | } |
| 101 | 147 | |
| 148 | + /// @notice Lock `wethAmount` wETH behind `secretHash` instead of crediting the caller directly. | |
| 149 | + /// @dev Same wETH pull and mint as the plain `deposit`, but the LOVE lands | |
| 150 | + /// in an envelope this contract escrows rather than in the caller's | |
| 151 | + /// balance. Since minting still happens 1:1 against the wETH pulled | |
| 152 | + /// in, `totalSupply() == WETH.balanceOf(address(this)) * RATE` holds | |
| 153 | + /// exactly as it does for a plain deposit. | |
| 154 | + /// | |
| 155 | + /// Claim it with `claim`, proving a `secret` that hashes to | |
| 156 | + /// `secretHash` — see docs/notes/add-privacy-to-love.md for how that | |
| 157 | + /// secret should be generated and shared off-chain. | |
| 158 | + /// @param secretHash `keccak256(abi.encodePacked(secret))` for the secret that will unlock this envelope. | |
| 159 | + /// @param wethAmount The wETH to lock, in wei. | |
| 160 | + /// @return depositId The id this envelope was assigned. | |
| 161 | + function deposit(bytes32 secretHash, uint256 wethAmount) external returns (uint256 depositId) { | |
| 162 | + uint256 loveAmount = wethAmount * RATE; | |
| 163 | + | |
| 164 | + WETH.safeTransferFrom(msg.sender, address(this), wethAmount); | |
| 165 | + _mint(address(this), loveAmount); | |
| 166 | + | |
| 167 | + depositId = nextDepositId++; | |
| 168 | + envelopes[depositId] = Envelope({secretHash: secretHash, remaining: loveAmount}); | |
| 169 | + | |
| 170 | + emit Deposit(depositId, msg.sender, secretHash, wethAmount, loveAmount); | |
| 171 | + } | |
| 172 | + | |
| 102 | 173 | /// @notice Burn `loveAmount` LOVE and release `loveAmount / RATE` wETH to the caller. |
| 103 | 174 | /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a |
| 104 | 175 | /// multiple of `RATE`, so the peg never rounds against the caller or |
| @@ -115,4 +186,31 @@ contract Love is ERC20 { | ||
| 115 | 186 | |
| 116 | 187 | emit Withdraw(msg.sender, loveAmount, wethAmount); |
| 117 | 188 | } |
| 189 | + | |
| 190 | + /// @notice Prove knowledge of the secret behind `depositId` and claim `loveAmount` LOVE from escrow. | |
| 191 | + /// @dev No recipient check: whoever calls this with the right secret is | |
| 192 | + /// paid, by design — see docs/notes/add-privacy-to-love.md for the | |
| 193 | + /// front-running window this opens and how to mitigate it off-chain. | |
| 194 | + /// `remaining` is decremented before the transfer, so this is reusable | |
| 195 | + /// across several partial claims until it hits zero. | |
| 196 | + /// | |
| 197 | + /// LOVE claimed this way is an ordinary balance afterwards; redeem it | |
| 198 | + /// for wETH through the regular, unrelated `withdraw`. | |
| 199 | + /// @param depositId The envelope to claim from. | |
| 200 | + /// @param secret The preimage of the envelope's `secretHash`. | |
| 201 | + /// @param loveAmount The LOVE to claim, at most what's `remaining`. | |
| 202 | + function claim(uint256 depositId, bytes32 secret, uint256 loveAmount) external { | |
| 203 | + Envelope storage envelope = envelopes[depositId]; | |
| 204 | + if (envelope.secretHash == bytes32(0)) revert UnknownDeposit(depositId); | |
| 205 | + if (keccak256(abi.encodePacked(secret)) != envelope.secretHash) revert WrongSecret(depositId); | |
| 206 | + if (loveAmount > envelope.remaining) { | |
| 207 | + revert InsufficientRemaining(depositId, loveAmount, envelope.remaining); | |
| 208 | + } | |
| 209 | + | |
| 210 | + envelope.remaining -= loveAmount; | |
| 211 | + | |
| 212 | + _transfer(address(this), msg.sender, loveAmount); | |
| 213 | + | |
| 214 | + emit Claim(depositId, msg.sender, loveAmount, envelope.remaining); | |
| 215 | + } | |
| 118 | 216 | } |
| @@ -42,6 +42,21 @@ contract Love is ERC20 { | |||
| 42 | /// is itself write-once, so nothing can move the token under the peg. | 42 | /// is itself write-once, so nothing can move the token under the peg. |
| 43 | IERC20 public immutable WETH; | 43 | IERC20 public immutable WETH; |
| 44 | 44 | ||
| 45 | + /// @notice One secret-locked deposit: a hash to unlock it, and what's left of it. | ||
| 46 | + /// @dev Opened by the `deposit(bytes32,uint256)` overload; see | ||
| 47 | + /// docs/notes/add-privacy-to-love.md for the off-chain processes | ||
| 48 | + /// (secret generation, sharing, timing discipline) this depends on. | ||
| 49 | + struct Envelope { | ||
| 50 | + bytes32 secretHash; | ||
| 51 | + uint256 remaining; | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + /// @notice Envelope storage, by `depositId`. | ||
| 55 | + mapping(uint256 => Envelope) public envelopes; | ||
| 56 | + | ||
| 57 | + /// @notice The `depositId` the next secret-locked `deposit` call will be assigned. | ||
| 58 | + uint256 public nextDepositId; | ||
| 59 | + | ||
| 45 | /// @notice Thrown when a withdrawal amount is not a multiple of `RATE`. | 60 | /// @notice Thrown when a withdrawal amount is not a multiple of `RATE`. |
| 46 | /// @param loveAmount The rejected LOVE amount. | 61 | /// @param loveAmount The rejected LOVE amount. |
| 47 | /// @param rate The rate it has to be a multiple of. | 62 | /// @param rate The rate it has to be a multiple of. |
| @@ -55,18 +70,49 @@ contract Love is ERC20 { | |||
| 55 | /// @param registry The registry that was asked. | 70 | /// @param registry The registry that was asked. |
| 56 | error WethNotRegistered(address registry); | 71 | error WethNotRegistered(address registry); |
| 57 | 72 | ||
| 73 | + /// @notice Thrown when `depositId` has never been deposited into. | ||
| 74 | + /// @param depositId The id that doesn't exist. | ||
| 75 | + error UnknownDeposit(uint256 depositId); | ||
| 76 | + | ||
| 77 | + /// @notice Thrown when a secret doesn't hash to the envelope's `secretHash`. | ||
| 78 | + /// @param depositId The envelope the wrong secret was tried against. | ||
| 79 | + error WrongSecret(uint256 depositId); | ||
| 80 | + | ||
| 81 | + /// @notice Thrown when a claim amount exceeds what's left in the envelope. | ||
| 82 | + /// @param depositId The envelope that was asked. | ||
| 83 | + /// @param requested The amount asked for. | ||
| 84 | + /// @param remaining What was actually left. | ||
| 85 | + error InsufficientRemaining(uint256 depositId, uint256 requested, uint256 remaining); | ||
| 86 | + | ||
| 58 | /// @notice Emitted when wETH is locked and LOVE minted. | 87 | /// @notice Emitted when wETH is locked and LOVE minted. |
| 59 | /// @param account The depositor, who pays the wETH and receives the LOVE. | 88 | /// @param account The depositor, who pays the wETH and receives the LOVE. |
| 60 | /// @param wethAmount The wETH pulled in. | 89 | /// @param wethAmount The wETH pulled in. |
| 61 | /// @param loveAmount The LOVE minted, `wethAmount * RATE`. | 90 | /// @param loveAmount The LOVE minted, `wethAmount * RATE`. |
| 62 | event Deposit(address indexed account, uint256 wethAmount, uint256 loveAmount); | 91 | event Deposit(address indexed account, uint256 wethAmount, uint256 loveAmount); |
| 63 | 92 | ||
| 93 | + /// @notice Emitted when wETH is locked and LOVE minted into a secret-locked envelope. | ||
| 94 | + /// @param depositId The id this envelope was assigned. | ||
| 95 | + /// @param account The depositor, who pays the wETH. | ||
| 96 | + /// @param secretHash `keccak256` of the secret that unlocks this envelope. | ||
| 97 | + /// @param wethAmount The wETH pulled in. | ||
| 98 | + /// @param loveAmount The LOVE minted, `wethAmount * RATE`. | ||
| 99 | + event Deposit( | ||
| 100 | + uint256 indexed depositId, address indexed account, bytes32 secretHash, uint256 wethAmount, uint256 loveAmount | ||
| 101 | + ); | ||
| 102 | + | ||
| 64 | /// @notice Emitted when LOVE is burned and wETH released. | 103 | /// @notice Emitted when LOVE is burned and wETH released. |
| 65 | /// @param account The redeemer, who burns the LOVE and receives the wETH. | 104 | /// @param account The redeemer, who burns the LOVE and receives the wETH. |
| 66 | /// @param loveAmount The LOVE burned. | 105 | /// @param loveAmount The LOVE burned. |
| 67 | /// @param wethAmount The wETH released, `loveAmount / RATE`. | 106 | /// @param wethAmount The wETH released, `loveAmount / RATE`. |
| 68 | event Withdraw(address indexed account, uint256 loveAmount, uint256 wethAmount); | 107 | event Withdraw(address indexed account, uint256 loveAmount, uint256 wethAmount); |
| 69 | 108 | ||
| 109 | + /// @notice Emitted when LOVE is released from an envelope against a correct secret. | ||
| 110 | + /// @param depositId The envelope claimed from. | ||
| 111 | + /// @param account The claimant, who proved the secret and receives the LOVE. | ||
| 112 | + /// @param loveAmount The LOVE released. | ||
| 113 | + /// @param remaining What's left in the envelope after this claim. | ||
| 114 | + event Claim(uint256 indexed depositId, address indexed account, uint256 loveAmount, uint256 remaining); | ||
| 115 | + | ||
| 70 | /// @notice Peg to whatever wETH the registry has accepted on this chain. | 116 | /// @notice Peg to whatever wETH the registry has accepted on this chain. |
| 71 | /// @dev Takes no arguments on purpose. Anything passed in here would land | 117 | /// @dev Takes no arguments on purpose. Anything passed in here would land |
| 72 | /// in the creation code and give the token a different address on | 118 | /// in the creation code and give the token a different address on |
| @@ -99,6 +145,31 @@ contract Love is ERC20 { | |||
| 99 | emit Deposit(msg.sender, wethAmount, loveAmount); | 145 | emit Deposit(msg.sender, wethAmount, loveAmount); |
| 100 | } | 146 | } |
| 101 | 147 | ||
| 148 | + /// @notice Lock `wethAmount` wETH behind `secretHash` instead of crediting the caller directly. | ||
| 149 | + /// @dev Same wETH pull and mint as the plain `deposit`, but the LOVE lands | ||
| 150 | + /// in an envelope this contract escrows rather than in the caller's | ||
| 151 | + /// balance. Since minting still happens 1:1 against the wETH pulled | ||
| 152 | + /// in, `totalSupply() == WETH.balanceOf(address(this)) * RATE` holds | ||
| 153 | + /// exactly as it does for a plain deposit. | ||
| 154 | + /// | ||
| 155 | + /// Claim it with `claim`, proving a `secret` that hashes to | ||
| 156 | + /// `secretHash` — see docs/notes/add-privacy-to-love.md for how that | ||
| 157 | + /// secret should be generated and shared off-chain. | ||
| 158 | + /// @param secretHash `keccak256(abi.encodePacked(secret))` for the secret that will unlock this envelope. | ||
| 159 | + /// @param wethAmount The wETH to lock, in wei. | ||
| 160 | + /// @return depositId The id this envelope was assigned. | ||
| 161 | + function deposit(bytes32 secretHash, uint256 wethAmount) external returns (uint256 depositId) { | ||
| 162 | + uint256 loveAmount = wethAmount * RATE; | ||
| 163 | + | ||
| 164 | + WETH.safeTransferFrom(msg.sender, address(this), wethAmount); | ||
| 165 | + _mint(address(this), loveAmount); | ||
| 166 | + | ||
| 167 | + depositId = nextDepositId++; | ||
| 168 | + envelopes[depositId] = Envelope({secretHash: secretHash, remaining: loveAmount}); | ||
| 169 | + | ||
| 170 | + emit Deposit(depositId, msg.sender, secretHash, wethAmount, loveAmount); | ||
| 171 | + } | ||
| 172 | + | ||
| 102 | /// @notice Burn `loveAmount` LOVE and release `loveAmount / RATE` wETH to the caller. | 173 | /// @notice Burn `loveAmount` LOVE and release `loveAmount / RATE` wETH to the caller. |
| 103 | /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a | 174 | /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a |
| 104 | /// multiple of `RATE`, so the peg never rounds against the caller or | 175 | /// multiple of `RATE`, so the peg never rounds against the caller or |
| @@ -115,4 +186,31 @@ contract Love is ERC20 { | |||
| 115 | 186 | ||
| 116 | emit Withdraw(msg.sender, loveAmount, wethAmount); | 187 | emit Withdraw(msg.sender, loveAmount, wethAmount); |
| 117 | } | 188 | } |
| 189 | + | ||
| 190 | + /// @notice Prove knowledge of the secret behind `depositId` and claim `loveAmount` LOVE from escrow. | ||
| 191 | + /// @dev No recipient check: whoever calls this with the right secret is | ||
| 192 | + /// paid, by design — see docs/notes/add-privacy-to-love.md for the | ||
| 193 | + /// front-running window this opens and how to mitigate it off-chain. | ||
| 194 | + /// `remaining` is decremented before the transfer, so this is reusable | ||
| 195 | + /// across several partial claims until it hits zero. | ||
| 196 | + /// | ||
| 197 | + /// LOVE claimed this way is an ordinary balance afterwards; redeem it | ||
| 198 | + /// for wETH through the regular, unrelated `withdraw`. | ||
| 199 | + /// @param depositId The envelope to claim from. | ||
| 200 | + /// @param secret The preimage of the envelope's `secretHash`. | ||
| 201 | + /// @param loveAmount The LOVE to claim, at most what's `remaining`. | ||
| 202 | + function claim(uint256 depositId, bytes32 secret, uint256 loveAmount) external { | ||
| 203 | + Envelope storage envelope = envelopes[depositId]; | ||
| 204 | + if (envelope.secretHash == bytes32(0)) revert UnknownDeposit(depositId); | ||
| 205 | + if (keccak256(abi.encodePacked(secret)) != envelope.secretHash) revert WrongSecret(depositId); | ||
| 206 | + if (loveAmount > envelope.remaining) { | ||
| 207 | + revert InsufficientRemaining(depositId, loveAmount, envelope.remaining); | ||
| 208 | + } | ||
| 209 | + | ||
| 210 | + envelope.remaining -= loveAmount; | ||
| 211 | + | ||
| 212 | + _transfer(address(this), msg.sender, loveAmount); | ||
| 213 | + | ||
| 214 | + emit Claim(depositId, msg.sender, loveAmount, envelope.remaining); | ||
| 215 | + } | ||
| 118 | } | 216 | } |
added
test/LoveEnvelope.t.sol +292 -0 | new file mode 100644 | ||
| @@ -0,0 +1,292 @@ | ||
| 1 | +// SPDX-License-Identifier: MIT | |
| 2 | +pragma solidity ^0.8.30; | |
| 3 | + | |
| 4 | +import {IWETH} from "../src/IWETH.sol"; | |
| 5 | +import {Love} from "../src/Love.sol"; | |
| 6 | +import {Fixtures} from "./Fixtures.sol"; | |
| 7 | +import {Test} from "forge-std/Test.sol"; | |
| 8 | + | |
| 9 | +/// @title LoveEnvelopeTest | |
| 10 | +/// @notice Exercises the secret-locked `deposit` overload and `claim`: locking | |
| 11 | +/// wETH behind a secret hash instead of crediting the caller, bearer | |
| 12 | +/// claiming, partial-claim accounting, and the revert paths that guard | |
| 13 | +/// wrong secrets, unknown envelopes and over-claiming. `withdraw` is | |
| 14 | +/// untouched by any of this — a claimed balance redeems for wETH the | |
| 15 | +/// same way any other LOVE balance does, exercised in Love.t.sol. | |
| 16 | +contract LoveEnvelopeTest is Test, Fixtures { | |
| 17 | + Love public love; | |
| 18 | + IWETH public weth; | |
| 19 | + | |
| 20 | + address alice = makeAddr("alice"); | |
| 21 | + address bob = makeAddr("bob"); | |
| 22 | + address stranger = makeAddr("stranger"); | |
| 23 | + | |
| 24 | + bytes32 constant SECRET = keccak256("correct secret"); | |
| 25 | + bytes32 constant SECRET_HASH = keccak256(abi.encodePacked(SECRET)); | |
| 26 | + | |
| 27 | + uint256 constant RATE = 100_000; | |
| 28 | + | |
| 29 | + address constant WETH_AT = 0x4200000000000000000000000000000000000006; | |
| 30 | + | |
| 31 | + function setUp() public { | |
| 32 | + (, weth) = setUpChain(WETH_AT, WETH9_OP_LEGACY); | |
| 33 | + love = new Love(); | |
| 34 | + | |
| 35 | + _fund(alice, 10 ether); | |
| 36 | + _fund(bob, 10 ether); | |
| 37 | + } | |
| 38 | + | |
| 39 | + /*////////////////////////////////////////////////////////////// | |
| 40 | + DEPOSIT | |
| 41 | + //////////////////////////////////////////////////////////////*/ | |
| 42 | + | |
| 43 | + function test_Deposit() public { | |
| 44 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 45 | + | |
| 46 | + (bytes32 secretHash, uint256 remaining) = love.envelopes(depositId); | |
| 47 | + assertEq(depositId, 0); | |
| 48 | + assertEq(secretHash, SECRET_HASH); | |
| 49 | + assertEq(remaining, 1 ether * RATE); | |
| 50 | + assertEq(love.balanceOf(address(love)), 1 ether * RATE); | |
| 51 | + assertEq(love.balanceOf(alice), 0); | |
| 52 | + assertEq(weth.balanceOf(address(love)), 1 ether); | |
| 53 | + } | |
| 54 | + | |
| 55 | + function test_DepositIsPermissionless() public { | |
| 56 | + _fund(stranger, 1 ether); | |
| 57 | + | |
| 58 | + uint256 depositId = _depositEnvelope(stranger, SECRET_HASH, 1 ether); | |
| 59 | + | |
| 60 | + (, uint256 remaining) = love.envelopes(depositId); | |
| 61 | + assertEq(remaining, 1 ether * RATE); | |
| 62 | + } | |
| 63 | + | |
| 64 | + function test_DepositEmitsDeposit() public { | |
| 65 | + vm.prank(alice); | |
| 66 | + weth.approve(address(love), 1 ether); | |
| 67 | + | |
| 68 | + vm.expectEmit(true, true, false, true); | |
| 69 | + emit Love.Deposit(0, alice, SECRET_HASH, 1 ether, 1 ether * RATE); | |
| 70 | + | |
| 71 | + vm.prank(alice); | |
| 72 | + love.deposit(SECRET_HASH, 1 ether); | |
| 73 | + } | |
| 74 | + | |
| 75 | + function test_DepositIncrementsDepositId() public { | |
| 76 | + uint256 first = _depositEnvelope(alice, SECRET_HASH, 0.25 ether); | |
| 77 | + uint256 second = _depositEnvelope(bob, keccak256("another secret"), 0.25 ether); | |
| 78 | + | |
| 79 | + assertEq(first, 0); | |
| 80 | + assertEq(second, 1); | |
| 81 | + assertEq(love.nextDepositId(), 2); | |
| 82 | + } | |
| 83 | + | |
| 84 | + function test_DepositKeepsPegInvariant() public { | |
| 85 | + _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 86 | + | |
| 87 | + assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE); | |
| 88 | + } | |
| 89 | + | |
| 90 | + function test_RevertWhen_DepositWithoutApproval() public { | |
| 91 | + vm.prank(alice); | |
| 92 | + vm.expectRevert(bytes("")); | |
| 93 | + love.deposit(SECRET_HASH, 1 ether); | |
| 94 | + } | |
| 95 | + | |
| 96 | + function test_RevertWhen_DepositExceedsWethBalance() public { | |
| 97 | + vm.prank(alice); | |
| 98 | + weth.approve(address(love), 100 ether); | |
| 99 | + | |
| 100 | + vm.prank(alice); | |
| 101 | + vm.expectRevert(bytes("")); | |
| 102 | + love.deposit(SECRET_HASH, 100 ether); | |
| 103 | + } | |
| 104 | + | |
| 105 | + /*////////////////////////////////////////////////////////////// | |
| 106 | + CLAIM | |
| 107 | + //////////////////////////////////////////////////////////////*/ | |
| 108 | + | |
| 109 | + function test_Claim() public { | |
| 110 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 111 | + | |
| 112 | + vm.prank(stranger); | |
| 113 | + love.claim(depositId, SECRET, 1 ether * RATE); | |
| 114 | + | |
| 115 | + (, uint256 remaining) = love.envelopes(depositId); | |
| 116 | + assertEq(remaining, 0); | |
| 117 | + assertEq(love.balanceOf(stranger), 1 ether * RATE); | |
| 118 | + assertEq(love.balanceOf(address(love)), 0); | |
| 119 | + } | |
| 120 | + | |
| 121 | + /// @dev Bearer semantics: knowledge of the secret is what pays out, not | |
| 122 | + /// being the original depositor. | |
| 123 | + function test_ClaimByAnyoneWhoKnowsSecret() public { | |
| 124 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 125 | + | |
| 126 | + vm.prank(bob); | |
| 127 | + love.claim(depositId, SECRET, 1 ether * RATE); | |
| 128 | + | |
| 129 | + assertEq(love.balanceOf(bob), 1 ether * RATE); | |
| 130 | + } | |
| 131 | + | |
| 132 | + function test_PartialClaim() public { | |
| 133 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 134 | + | |
| 135 | + vm.prank(bob); | |
| 136 | + love.claim(depositId, SECRET, 0.25 ether * RATE); | |
| 137 | + | |
| 138 | + (, uint256 remaining) = love.envelopes(depositId); | |
| 139 | + assertEq(remaining, 0.75 ether * RATE); | |
| 140 | + assertEq(love.balanceOf(bob), 0.25 ether * RATE); | |
| 141 | + } | |
| 142 | + | |
| 143 | + function test_MultiplePartialClaimsDrainEnvelope() public { | |
| 144 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 145 | + | |
| 146 | + vm.startPrank(bob); | |
| 147 | + love.claim(depositId, SECRET, 0.4 ether * RATE); | |
| 148 | + love.claim(depositId, SECRET, 0.35 ether * RATE); | |
| 149 | + love.claim(depositId, SECRET, 0.25 ether * RATE); | |
| 150 | + vm.stopPrank(); | |
| 151 | + | |
| 152 | + (, uint256 remaining) = love.envelopes(depositId); | |
| 153 | + assertEq(remaining, 0); | |
| 154 | + assertEq(love.balanceOf(address(love)), 0); | |
| 155 | + } | |
| 156 | + | |
| 157 | + function test_ClaimEmitsClaim() public { | |
| 158 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 159 | + | |
| 160 | + vm.expectEmit(true, true, false, true); | |
| 161 | + emit Love.Claim(depositId, bob, 0.4 ether * RATE, 0.6 ether * RATE); | |
| 162 | + | |
| 163 | + vm.prank(bob); | |
| 164 | + love.claim(depositId, SECRET, 0.4 ether * RATE); | |
| 165 | + } | |
| 166 | + | |
| 167 | + /// @dev A claimed balance is an ordinary LOVE balance: `withdraw` (untouched | |
| 168 | + /// by the envelope feature) redeems it for wETH like any other. | |
| 169 | + function test_ClaimedLoveIsRedeemableThroughWithdraw() public { | |
| 170 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 171 | + | |
| 172 | + vm.prank(bob); | |
| 173 | + love.claim(depositId, SECRET, 1 ether * RATE); | |
| 174 | + | |
| 175 | + vm.prank(bob); | |
| 176 | + love.withdraw(1 ether * RATE); | |
| 177 | + | |
| 178 | + assertEq(weth.balanceOf(bob), 11 ether); | |
| 179 | + assertEq(love.totalSupply(), 0); | |
| 180 | + } | |
| 181 | + | |
| 182 | + function test_ClaimZero() public { | |
| 183 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 184 | + | |
| 185 | + vm.prank(bob); | |
| 186 | + love.claim(depositId, SECRET, 0); | |
| 187 | + | |
| 188 | + (, uint256 remaining) = love.envelopes(depositId); | |
| 189 | + assertEq(remaining, 1 ether * RATE); | |
| 190 | + } | |
| 191 | + | |
| 192 | + function test_EnvelopesAreIndependent() public { | |
| 193 | + bytes32 otherSecret = keccak256("another secret"); | |
| 194 | + uint256 aliceEnvelope = _depositEnvelope(alice, SECRET_HASH, 0.5 ether); | |
| 195 | + uint256 bobEnvelope = _depositEnvelope(bob, keccak256(abi.encodePacked(otherSecret)), 0.5 ether); | |
| 196 | + | |
| 197 | + vm.prank(stranger); | |
| 198 | + love.claim(aliceEnvelope, SECRET, 0.5 ether * RATE); | |
| 199 | + | |
| 200 | + (, uint256 bobRemaining) = love.envelopes(bobEnvelope); | |
| 201 | + assertEq(bobRemaining, 0.5 ether * RATE); | |
| 202 | + | |
| 203 | + vm.prank(stranger); | |
| 204 | + love.claim(bobEnvelope, otherSecret, 0.5 ether * RATE); | |
| 205 | + | |
| 206 | + assertEq(love.balanceOf(stranger), 1 ether * RATE); | |
| 207 | + } | |
| 208 | + | |
| 209 | + function test_RevertWhen_ClaimWrongSecret() public { | |
| 210 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 211 | + | |
| 212 | + vm.prank(stranger); | |
| 213 | + vm.expectRevert(abi.encodeWithSelector(Love.WrongSecret.selector, depositId)); | |
| 214 | + love.claim(depositId, keccak256("wrong guess"), 1 ether * RATE); | |
| 215 | + } | |
| 216 | + | |
| 217 | + function test_RevertWhen_ClaimUnknownDeposit() public { | |
| 218 | + vm.prank(stranger); | |
| 219 | + vm.expectRevert(abi.encodeWithSelector(Love.UnknownDeposit.selector, 42)); | |
| 220 | + love.claim(42, SECRET, 1); | |
| 221 | + } | |
| 222 | + | |
| 223 | + function test_RevertWhen_ClaimExceedsRemaining() public { | |
| 224 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 225 | + | |
| 226 | + vm.prank(stranger); | |
| 227 | + vm.expectRevert( | |
| 228 | + abi.encodeWithSelector(Love.InsufficientRemaining.selector, depositId, 2 ether * RATE, 1 ether * RATE) | |
| 229 | + ); | |
| 230 | + love.claim(depositId, SECRET, 2 ether * RATE); | |
| 231 | + } | |
| 232 | + | |
| 233 | + function test_RevertWhen_ClaimAfterFullyDrained() public { | |
| 234 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 235 | + | |
| 236 | + vm.prank(bob); | |
| 237 | + love.claim(depositId, SECRET, 1 ether * RATE); | |
| 238 | + | |
| 239 | + vm.prank(stranger); | |
| 240 | + vm.expectRevert(abi.encodeWithSelector(Love.InsufficientRemaining.selector, depositId, 1, 0)); | |
| 241 | + love.claim(depositId, SECRET, 1); | |
| 242 | + } | |
| 243 | + | |
| 244 | + /*////////////////////////////////////////////////////////////// | |
| 245 | + FUZZ | |
| 246 | + //////////////////////////////////////////////////////////////*/ | |
| 247 | + | |
| 248 | + function testFuzz_PartialClaimsSumToTotal(uint256 wethAmount, uint256 first) public { | |
| 249 | + wethAmount = bound(wethAmount, 0, type(uint256).max / RATE); | |
| 250 | + uint256 total = wethAmount * RATE; | |
| 251 | + first = bound(first, 0, total); | |
| 252 | + uint256 second = total - first; | |
| 253 | + | |
| 254 | + _fund(alice, wethAmount); | |
| 255 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, wethAmount); | |
| 256 | + | |
| 257 | + vm.prank(stranger); | |
| 258 | + love.claim(depositId, SECRET, first); | |
| 259 | + vm.prank(stranger); | |
| 260 | + love.claim(depositId, SECRET, second); | |
| 261 | + | |
| 262 | + (, uint256 remaining) = love.envelopes(depositId); | |
| 263 | + assertEq(remaining, 0); | |
| 264 | + assertEq(love.balanceOf(stranger), total); | |
| 265 | + } | |
| 266 | + | |
| 267 | + function testFuzz_WrongSecretAlwaysReverts(bytes32 wrongSecret) public { | |
| 268 | + vm.assume(wrongSecret != SECRET); | |
| 269 | + | |
| 270 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | |
| 271 | + | |
| 272 | + vm.prank(stranger); | |
| 273 | + vm.expectRevert(abi.encodeWithSelector(Love.WrongSecret.selector, depositId)); | |
| 274 | + love.claim(depositId, wrongSecret, 1); | |
| 275 | + } | |
| 276 | + | |
| 277 | + /// @dev Gives `account` `amount` wETH by wrapping fresh ETH. | |
| 278 | + function _fund(address account, uint256 amount) internal { | |
| 279 | + vm.deal(account, amount); | |
| 280 | + vm.prank(account); | |
| 281 | + weth.deposit{value: amount}(); | |
| 282 | + } | |
| 283 | + | |
| 284 | + /// @dev Approves and opens a secret-locked envelope with `wethAmount`, in one step. | |
| 285 | + function _depositEnvelope(address account, bytes32 secretHash, uint256 wethAmount) internal returns (uint256) { | |
| 286 | + vm.startPrank(account); | |
| 287 | + weth.approve(address(love), wethAmount); | |
| 288 | + uint256 depositId = love.deposit(secretHash, wethAmount); | |
| 289 | + vm.stopPrank(); | |
| 290 | + return depositId; | |
| 291 | + } | |
| 292 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,292 @@ | |||
| 1 | +// SPDX-License-Identifier: MIT | ||
| 2 | +pragma solidity ^0.8.30; | ||
| 3 | + | ||
| 4 | +import {IWETH} from "../src/IWETH.sol"; | ||
| 5 | +import {Love} from "../src/Love.sol"; | ||
| 6 | +import {Fixtures} from "./Fixtures.sol"; | ||
| 7 | +import {Test} from "forge-std/Test.sol"; | ||
| 8 | + | ||
| 9 | +/// @title LoveEnvelopeTest | ||
| 10 | +/// @notice Exercises the secret-locked `deposit` overload and `claim`: locking | ||
| 11 | +/// wETH behind a secret hash instead of crediting the caller, bearer | ||
| 12 | +/// claiming, partial-claim accounting, and the revert paths that guard | ||
| 13 | +/// wrong secrets, unknown envelopes and over-claiming. `withdraw` is | ||
| 14 | +/// untouched by any of this — a claimed balance redeems for wETH the | ||
| 15 | +/// same way any other LOVE balance does, exercised in Love.t.sol. | ||
| 16 | +contract LoveEnvelopeTest is Test, Fixtures { | ||
| 17 | + Love public love; | ||
| 18 | + IWETH public weth; | ||
| 19 | + | ||
| 20 | + address alice = makeAddr("alice"); | ||
| 21 | + address bob = makeAddr("bob"); | ||
| 22 | + address stranger = makeAddr("stranger"); | ||
| 23 | + | ||
| 24 | + bytes32 constant SECRET = keccak256("correct secret"); | ||
| 25 | + bytes32 constant SECRET_HASH = keccak256(abi.encodePacked(SECRET)); | ||
| 26 | + | ||
| 27 | + uint256 constant RATE = 100_000; | ||
| 28 | + | ||
| 29 | + address constant WETH_AT = 0x4200000000000000000000000000000000000006; | ||
| 30 | + | ||
| 31 | + function setUp() public { | ||
| 32 | + (, weth) = setUpChain(WETH_AT, WETH9_OP_LEGACY); | ||
| 33 | + love = new Love(); | ||
| 34 | + | ||
| 35 | + _fund(alice, 10 ether); | ||
| 36 | + _fund(bob, 10 ether); | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + /*////////////////////////////////////////////////////////////// | ||
| 40 | + DEPOSIT | ||
| 41 | + //////////////////////////////////////////////////////////////*/ | ||
| 42 | + | ||
| 43 | + function test_Deposit() public { | ||
| 44 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 45 | + | ||
| 46 | + (bytes32 secretHash, uint256 remaining) = love.envelopes(depositId); | ||
| 47 | + assertEq(depositId, 0); | ||
| 48 | + assertEq(secretHash, SECRET_HASH); | ||
| 49 | + assertEq(remaining, 1 ether * RATE); | ||
| 50 | + assertEq(love.balanceOf(address(love)), 1 ether * RATE); | ||
| 51 | + assertEq(love.balanceOf(alice), 0); | ||
| 52 | + assertEq(weth.balanceOf(address(love)), 1 ether); | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + function test_DepositIsPermissionless() public { | ||
| 56 | + _fund(stranger, 1 ether); | ||
| 57 | + | ||
| 58 | + uint256 depositId = _depositEnvelope(stranger, SECRET_HASH, 1 ether); | ||
| 59 | + | ||
| 60 | + (, uint256 remaining) = love.envelopes(depositId); | ||
| 61 | + assertEq(remaining, 1 ether * RATE); | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + function test_DepositEmitsDeposit() public { | ||
| 65 | + vm.prank(alice); | ||
| 66 | + weth.approve(address(love), 1 ether); | ||
| 67 | + | ||
| 68 | + vm.expectEmit(true, true, false, true); | ||
| 69 | + emit Love.Deposit(0, alice, SECRET_HASH, 1 ether, 1 ether * RATE); | ||
| 70 | + | ||
| 71 | + vm.prank(alice); | ||
| 72 | + love.deposit(SECRET_HASH, 1 ether); | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + function test_DepositIncrementsDepositId() public { | ||
| 76 | + uint256 first = _depositEnvelope(alice, SECRET_HASH, 0.25 ether); | ||
| 77 | + uint256 second = _depositEnvelope(bob, keccak256("another secret"), 0.25 ether); | ||
| 78 | + | ||
| 79 | + assertEq(first, 0); | ||
| 80 | + assertEq(second, 1); | ||
| 81 | + assertEq(love.nextDepositId(), 2); | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + function test_DepositKeepsPegInvariant() public { | ||
| 85 | + _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 86 | + | ||
| 87 | + assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE); | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + function test_RevertWhen_DepositWithoutApproval() public { | ||
| 91 | + vm.prank(alice); | ||
| 92 | + vm.expectRevert(bytes("")); | ||
| 93 | + love.deposit(SECRET_HASH, 1 ether); | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + function test_RevertWhen_DepositExceedsWethBalance() public { | ||
| 97 | + vm.prank(alice); | ||
| 98 | + weth.approve(address(love), 100 ether); | ||
| 99 | + | ||
| 100 | + vm.prank(alice); | ||
| 101 | + vm.expectRevert(bytes("")); | ||
| 102 | + love.deposit(SECRET_HASH, 100 ether); | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + /*////////////////////////////////////////////////////////////// | ||
| 106 | + CLAIM | ||
| 107 | + //////////////////////////////////////////////////////////////*/ | ||
| 108 | + | ||
| 109 | + function test_Claim() public { | ||
| 110 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 111 | + | ||
| 112 | + vm.prank(stranger); | ||
| 113 | + love.claim(depositId, SECRET, 1 ether * RATE); | ||
| 114 | + | ||
| 115 | + (, uint256 remaining) = love.envelopes(depositId); | ||
| 116 | + assertEq(remaining, 0); | ||
| 117 | + assertEq(love.balanceOf(stranger), 1 ether * RATE); | ||
| 118 | + assertEq(love.balanceOf(address(love)), 0); | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + /// @dev Bearer semantics: knowledge of the secret is what pays out, not | ||
| 122 | + /// being the original depositor. | ||
| 123 | + function test_ClaimByAnyoneWhoKnowsSecret() public { | ||
| 124 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 125 | + | ||
| 126 | + vm.prank(bob); | ||
| 127 | + love.claim(depositId, SECRET, 1 ether * RATE); | ||
| 128 | + | ||
| 129 | + assertEq(love.balanceOf(bob), 1 ether * RATE); | ||
| 130 | + } | ||
| 131 | + | ||
| 132 | + function test_PartialClaim() public { | ||
| 133 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 134 | + | ||
| 135 | + vm.prank(bob); | ||
| 136 | + love.claim(depositId, SECRET, 0.25 ether * RATE); | ||
| 137 | + | ||
| 138 | + (, uint256 remaining) = love.envelopes(depositId); | ||
| 139 | + assertEq(remaining, 0.75 ether * RATE); | ||
| 140 | + assertEq(love.balanceOf(bob), 0.25 ether * RATE); | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + function test_MultiplePartialClaimsDrainEnvelope() public { | ||
| 144 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 145 | + | ||
| 146 | + vm.startPrank(bob); | ||
| 147 | + love.claim(depositId, SECRET, 0.4 ether * RATE); | ||
| 148 | + love.claim(depositId, SECRET, 0.35 ether * RATE); | ||
| 149 | + love.claim(depositId, SECRET, 0.25 ether * RATE); | ||
| 150 | + vm.stopPrank(); | ||
| 151 | + | ||
| 152 | + (, uint256 remaining) = love.envelopes(depositId); | ||
| 153 | + assertEq(remaining, 0); | ||
| 154 | + assertEq(love.balanceOf(address(love)), 0); | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + function test_ClaimEmitsClaim() public { | ||
| 158 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 159 | + | ||
| 160 | + vm.expectEmit(true, true, false, true); | ||
| 161 | + emit Love.Claim(depositId, bob, 0.4 ether * RATE, 0.6 ether * RATE); | ||
| 162 | + | ||
| 163 | + vm.prank(bob); | ||
| 164 | + love.claim(depositId, SECRET, 0.4 ether * RATE); | ||
| 165 | + } | ||
| 166 | + | ||
| 167 | + /// @dev A claimed balance is an ordinary LOVE balance: `withdraw` (untouched | ||
| 168 | + /// by the envelope feature) redeems it for wETH like any other. | ||
| 169 | + function test_ClaimedLoveIsRedeemableThroughWithdraw() public { | ||
| 170 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 171 | + | ||
| 172 | + vm.prank(bob); | ||
| 173 | + love.claim(depositId, SECRET, 1 ether * RATE); | ||
| 174 | + | ||
| 175 | + vm.prank(bob); | ||
| 176 | + love.withdraw(1 ether * RATE); | ||
| 177 | + | ||
| 178 | + assertEq(weth.balanceOf(bob), 11 ether); | ||
| 179 | + assertEq(love.totalSupply(), 0); | ||
| 180 | + } | ||
| 181 | + | ||
| 182 | + function test_ClaimZero() public { | ||
| 183 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 184 | + | ||
| 185 | + vm.prank(bob); | ||
| 186 | + love.claim(depositId, SECRET, 0); | ||
| 187 | + | ||
| 188 | + (, uint256 remaining) = love.envelopes(depositId); | ||
| 189 | + assertEq(remaining, 1 ether * RATE); | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + function test_EnvelopesAreIndependent() public { | ||
| 193 | + bytes32 otherSecret = keccak256("another secret"); | ||
| 194 | + uint256 aliceEnvelope = _depositEnvelope(alice, SECRET_HASH, 0.5 ether); | ||
| 195 | + uint256 bobEnvelope = _depositEnvelope(bob, keccak256(abi.encodePacked(otherSecret)), 0.5 ether); | ||
| 196 | + | ||
| 197 | + vm.prank(stranger); | ||
| 198 | + love.claim(aliceEnvelope, SECRET, 0.5 ether * RATE); | ||
| 199 | + | ||
| 200 | + (, uint256 bobRemaining) = love.envelopes(bobEnvelope); | ||
| 201 | + assertEq(bobRemaining, 0.5 ether * RATE); | ||
| 202 | + | ||
| 203 | + vm.prank(stranger); | ||
| 204 | + love.claim(bobEnvelope, otherSecret, 0.5 ether * RATE); | ||
| 205 | + | ||
| 206 | + assertEq(love.balanceOf(stranger), 1 ether * RATE); | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + function test_RevertWhen_ClaimWrongSecret() public { | ||
| 210 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 211 | + | ||
| 212 | + vm.prank(stranger); | ||
| 213 | + vm.expectRevert(abi.encodeWithSelector(Love.WrongSecret.selector, depositId)); | ||
| 214 | + love.claim(depositId, keccak256("wrong guess"), 1 ether * RATE); | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + function test_RevertWhen_ClaimUnknownDeposit() public { | ||
| 218 | + vm.prank(stranger); | ||
| 219 | + vm.expectRevert(abi.encodeWithSelector(Love.UnknownDeposit.selector, 42)); | ||
| 220 | + love.claim(42, SECRET, 1); | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + function test_RevertWhen_ClaimExceedsRemaining() public { | ||
| 224 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 225 | + | ||
| 226 | + vm.prank(stranger); | ||
| 227 | + vm.expectRevert( | ||
| 228 | + abi.encodeWithSelector(Love.InsufficientRemaining.selector, depositId, 2 ether * RATE, 1 ether * RATE) | ||
| 229 | + ); | ||
| 230 | + love.claim(depositId, SECRET, 2 ether * RATE); | ||
| 231 | + } | ||
| 232 | + | ||
| 233 | + function test_RevertWhen_ClaimAfterFullyDrained() public { | ||
| 234 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 235 | + | ||
| 236 | + vm.prank(bob); | ||
| 237 | + love.claim(depositId, SECRET, 1 ether * RATE); | ||
| 238 | + | ||
| 239 | + vm.prank(stranger); | ||
| 240 | + vm.expectRevert(abi.encodeWithSelector(Love.InsufficientRemaining.selector, depositId, 1, 0)); | ||
| 241 | + love.claim(depositId, SECRET, 1); | ||
| 242 | + } | ||
| 243 | + | ||
| 244 | + /*////////////////////////////////////////////////////////////// | ||
| 245 | + FUZZ | ||
| 246 | + //////////////////////////////////////////////////////////////*/ | ||
| 247 | + | ||
| 248 | + function testFuzz_PartialClaimsSumToTotal(uint256 wethAmount, uint256 first) public { | ||
| 249 | + wethAmount = bound(wethAmount, 0, type(uint256).max / RATE); | ||
| 250 | + uint256 total = wethAmount * RATE; | ||
| 251 | + first = bound(first, 0, total); | ||
| 252 | + uint256 second = total - first; | ||
| 253 | + | ||
| 254 | + _fund(alice, wethAmount); | ||
| 255 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, wethAmount); | ||
| 256 | + | ||
| 257 | + vm.prank(stranger); | ||
| 258 | + love.claim(depositId, SECRET, first); | ||
| 259 | + vm.prank(stranger); | ||
| 260 | + love.claim(depositId, SECRET, second); | ||
| 261 | + | ||
| 262 | + (, uint256 remaining) = love.envelopes(depositId); | ||
| 263 | + assertEq(remaining, 0); | ||
| 264 | + assertEq(love.balanceOf(stranger), total); | ||
| 265 | + } | ||
| 266 | + | ||
| 267 | + function testFuzz_WrongSecretAlwaysReverts(bytes32 wrongSecret) public { | ||
| 268 | + vm.assume(wrongSecret != SECRET); | ||
| 269 | + | ||
| 270 | + uint256 depositId = _depositEnvelope(alice, SECRET_HASH, 1 ether); | ||
| 271 | + | ||
| 272 | + vm.prank(stranger); | ||
| 273 | + vm.expectRevert(abi.encodeWithSelector(Love.WrongSecret.selector, depositId)); | ||
| 274 | + love.claim(depositId, wrongSecret, 1); | ||
| 275 | + } | ||
| 276 | + | ||
| 277 | + /// @dev Gives `account` `amount` wETH by wrapping fresh ETH. | ||
| 278 | + function _fund(address account, uint256 amount) internal { | ||
| 279 | + vm.deal(account, amount); | ||
| 280 | + vm.prank(account); | ||
| 281 | + weth.deposit{value: amount}(); | ||
| 282 | + } | ||
| 283 | + | ||
| 284 | + /// @dev Approves and opens a secret-locked envelope with `wethAmount`, in one step. | ||
| 285 | + function _depositEnvelope(address account, bytes32 secretHash, uint256 wethAmount) internal returns (uint256) { | ||
| 286 | + vm.startPrank(account); | ||
| 287 | + weth.approve(address(love), wethAmount); | ||
| 288 | + uint256 depositId = love.deposit(secretHash, wethAmount); | ||
| 289 | + vm.stopPrank(); | ||
| 290 | + return depositId; | ||
| 291 | + } | ||
| 292 | +} | ||