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

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

Download ZIP

Merge 6-add-love-envelope into main

rickub committed 2026-09-18T13:20:45Z Browse files
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
7272 different chain IDs and failing if the two addresses don't match.
7373 - `remappings.txt` for `@openzeppelin/contracts/` and `forge-std/`.
7474 - `.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 {
4242 /// is itself write-once, so nothing can move the token under the peg.
4343 IERC20 public immutable WETH;
4444
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+
4560 /// @notice Thrown when a withdrawal amount is not a multiple of `RATE`.
4661 /// @param loveAmount The rejected LOVE amount.
4762 /// @param rate The rate it has to be a multiple of.
@@ -55,18 +70,49 @@ contract Love is ERC20 {
5570 /// @param registry The registry that was asked.
5671 error WethNotRegistered(address registry);
5772
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+
5887 /// @notice Emitted when wETH is locked and LOVE minted.
5988 /// @param account The depositor, who pays the wETH and receives the LOVE.
6089 /// @param wethAmount The wETH pulled in.
6190 /// @param loveAmount The LOVE minted, `wethAmount * RATE`.
6291 event Deposit(address indexed account, uint256 wethAmount, uint256 loveAmount);
6392
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+
64103 /// @notice Emitted when LOVE is burned and wETH released.
65104 /// @param account The redeemer, who burns the LOVE and receives the wETH.
66105 /// @param loveAmount The LOVE burned.
67106 /// @param wethAmount The wETH released, `loveAmount / RATE`.
68107 event Withdraw(address indexed account, uint256 loveAmount, uint256 wethAmount);
69108
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+
70116 /// @notice Peg to whatever wETH the registry has accepted on this chain.
71117 /// @dev Takes no arguments on purpose. Anything passed in here would land
72118 /// in the creation code and give the token a different address on
@@ -99,6 +145,31 @@ contract Love is ERC20 {
99145 emit Deposit(msg.sender, wethAmount, loveAmount);
100146 }
101147
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+
102173 /// @notice Burn `loveAmount` LOVE and release `loveAmount / RATE` wETH to the caller.
103174 /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a
104175 /// multiple of `RATE`, so the peg never rounds against the caller or
@@ -115,4 +186,31 @@ contract Love is ERC20 {
115186
116187 emit Withdraw(msg.sender, loveAmount, wethAmount);
117188 }
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+ }
118216 }
@@ -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 land117 /// @dev Takes no arguments on purpose. Anything passed in here would land
72 /// in the creation code and give the token a different address on118 /// 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 a174 /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a
104 /// multiple of `RATE`, so the peg never rounds against the caller or175 /// 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+}