julien/lovepublic Fork 0
5376202
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.

add weth peg tests and docs

julienbrg committed 2026-09-17T22:05:36+02:00 Browse files
5376202 parent: 92dc0c7
modified .env.example +3 -0
@@ -1,6 +1,9 @@
11 # RPC endpoints
22 BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
33
4+# wETH the token is pegged to — defaults to the OP-Stack address below
5+WETH=0x4200000000000000000000000000000000000006
6+
47 # Block explorer verification
58 ETHERSCAN_API_KEY=
69
@@ -1,6 +1,9 @@
1 # RPC endpoints1 # RPC endpoints
2 BASE_SEPOLIA_RPC_URL=https://sepolia.base.org2 BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
3 3
4+# wETH the token is pegged to — defaults to the OP-Stack address below
5+WETH=0x4200000000000000000000000000000000000006
6+
4 # Block explorer verification7 # Block explorer verification
5 ETHERSCAN_API_KEY=8 ETHERSCAN_API_KEY=
6 9
modified CHANGELOG.md +16 -7
@@ -10,17 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010 ### Added
1111
1212 - Foundry project scaffolding (`forge-std`, CI workflow, `.gitignore`).
13-- `Love`, an ERC-20 token (`LOVE`, 18 decimals) on OpenZeppelin v5, with an
14- unrestricted public `mint(address,uint256)` — anyone can mint any amount.
15-- Metadata, mint, transfer, approve/`transferFrom`, revert and fuzz tests.
13+- `Love`, an ERC-20 token (`LOVE`, 18 decimals) on OpenZeppelin v5, pegged to
14+ wETH at 100000 LOVE per wETH. No mint function and no owner: supply moves
15+ only through the permissionless `deposit(uint256)` and `withdraw(uint256)`,
16+ so circulating LOVE is always backed by wETH held by the contract.
17+ `withdraw` reverts with `AmountNotDivisibleByRate` unless the amount is a
18+ multiple of the rate, keeping the peg free of rounding dust.
19+- `MockWETH`, a test-only WETH9 stand-in, and tests covering deposit, withdraw,
20+ the divisibility revert, backing invariants, round-trips, transfers,
21+ approve/`transferFrom`, reverts and fuzzing.
1622 - Deployment script `script/Love.s.sol`, deploying with CREATE2 through the
17- canonical deterministic deployer so `Love` gets the same address on every EVM
18- network; salt defaults to `keccak256("LOVE")` and is overridable via `SALT`.
23+ canonical deterministic deployer; salt defaults to `keccak256("LOVE")` and is
24+ overridable via `SALT`, wETH defaults to the OP-Stack address and is
25+ overridable via `WETH`. The address embeds the wETH argument, so it is
26+ identical across chains that share one wETH deployment.
1927 - `predict()` entrypoint printing the deployment address without broadcasting,
20- and CREATE2 tests pinning address determinism across chain IDs.
28+ and CREATE2 tests pinning address determinism across chain IDs and its
29+ dependence on the wETH argument.
2130 - `foundry.toml` with pinned solc 0.8.30, optimizer, fmt rules, fuzz/invariant
2231 defaults, and Base Sepolia RPC/Etherscan endpoints driven by env vars.
2332 - `script/multichain-check.sh`, deploying `Love` on two local anvil nodes with
2433 different chain IDs and failing if the two addresses don't match.
2534 - `remappings.txt` for `@openzeppelin/contracts/` and `forge-std/`.
26-- `.env.example` documenting required environment variables.
35+- `.env.example` documenting required environment variables, including `WETH`.
@@ -10,17 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10 ### Added10 ### Added
11 11
12 - Foundry project scaffolding (`forge-std`, CI workflow, `.gitignore`).12 - Foundry project scaffolding (`forge-std`, CI workflow, `.gitignore`).
13-- `Love`, an ERC-20 token (`LOVE`, 18 decimals) on OpenZeppelin v5, with an13+- `Love`, an ERC-20 token (`LOVE`, 18 decimals) on OpenZeppelin v5, pegged to
14- unrestricted public `mint(address,uint256)` — anyone can mint any amount.14+ wETH at 100000 LOVE per wETH. No mint function and no owner: supply moves
15-- Metadata, mint, transfer, approve/`transferFrom`, revert and fuzz tests.15+ only through the permissionless `deposit(uint256)` and `withdraw(uint256)`,
16+ so circulating LOVE is always backed by wETH held by the contract.
17+ `withdraw` reverts with `AmountNotDivisibleByRate` unless the amount is a
18+ multiple of the rate, keeping the peg free of rounding dust.
19+- `MockWETH`, a test-only WETH9 stand-in, and tests covering deposit, withdraw,
20+ the divisibility revert, backing invariants, round-trips, transfers,
21+ approve/`transferFrom`, reverts and fuzzing.
16 - Deployment script `script/Love.s.sol`, deploying with CREATE2 through the22 - Deployment script `script/Love.s.sol`, deploying with CREATE2 through the
17- canonical deterministic deployer so `Love` gets the same address on every EVM23+ canonical deterministic deployer; salt defaults to `keccak256("LOVE")` and is
18- network; salt defaults to `keccak256("LOVE")` and is overridable via `SALT`.24+ overridable via `SALT`, wETH defaults to the OP-Stack address and is
25+ overridable via `WETH`. The address embeds the wETH argument, so it is
26+ identical across chains that share one wETH deployment.
19 - `predict()` entrypoint printing the deployment address without broadcasting,27 - `predict()` entrypoint printing the deployment address without broadcasting,
20- and CREATE2 tests pinning address determinism across chain IDs.28+ and CREATE2 tests pinning address determinism across chain IDs and its
29+ dependence on the wETH argument.
21 - `foundry.toml` with pinned solc 0.8.30, optimizer, fmt rules, fuzz/invariant30 - `foundry.toml` with pinned solc 0.8.30, optimizer, fmt rules, fuzz/invariant
22 defaults, and Base Sepolia RPC/Etherscan endpoints driven by env vars.31 defaults, and Base Sepolia RPC/Etherscan endpoints driven by env vars.
23 - `script/multichain-check.sh`, deploying `Love` on two local anvil nodes with32 - `script/multichain-check.sh`, deploying `Love` on two local anvil nodes with
24 different chain IDs and failing if the two addresses don't match.33 different chain IDs and failing if the two addresses don't match.
25 - `remappings.txt` for `@openzeppelin/contracts/` and `forge-std/`.34 - `remappings.txt` for `@openzeppelin/contracts/` and `forge-std/`.
26-- `.env.example` documenting required environment variables.35+- `.env.example` documenting required environment variables, including `WETH`.
modified script/Love.s.sol +1 -1
@@ -45,7 +45,7 @@ contract LoveScript is Script {
4545 console.log("Love deployed at", address(love));
4646 console.log("name ", love.name());
4747 console.log("symbol ", love.symbol());
48- console.log("weth ", address(love.weth()));
48+ console.log("weth ", address(love.WETH()));
4949 console.log("rate ", love.RATE());
5050 console.log("totalSupply ", love.totalSupply());
5151
@@ -45,7 +45,7 @@ contract LoveScript is Script {
45 console.log("Love deployed at", address(love));45 console.log("Love deployed at", address(love));
46 console.log("name ", love.name());46 console.log("name ", love.name());
47 console.log("symbol ", love.symbol());47 console.log("symbol ", love.symbol());
48- console.log("weth ", address(love.weth()));48+ console.log("weth ", address(love.WETH()));
49 console.log("rate ", love.RATE());49 console.log("rate ", love.RATE());
50 console.log("totalSupply ", love.totalSupply());50 console.log("totalSupply ", love.totalSupply());
51 51
modified script/multichain-check.sh +1 -1
@@ -83,7 +83,7 @@ report() {
8383 "$label" "$addr" "$(((${#code} - 2) / 2))" \
8484 "$(cast call "$addr" 'name()(string)' --rpc-url "$rpc")" \
8585 "$(cast call "$addr" 'symbol()(string)' --rpc-url "$rpc")" \
86- "$(cast call "$addr" 'weth()(address)' --rpc-url "$rpc")"
86+ "$(cast call "$addr" 'WETH()(address)' --rpc-url "$rpc")"
8787 }
8888
8989 cd "$ROOT"
@@ -83,7 +83,7 @@ report() {
83 "$label" "$addr" "$(((${#code} - 2) / 2))" \83 "$label" "$addr" "$(((${#code} - 2) / 2))" \
84 "$(cast call "$addr" 'name()(string)' --rpc-url "$rpc")" \84 "$(cast call "$addr" 'name()(string)' --rpc-url "$rpc")" \
85 "$(cast call "$addr" 'symbol()(string)' --rpc-url "$rpc")" \85 "$(cast call "$addr" 'symbol()(string)' --rpc-url "$rpc")" \
86- "$(cast call "$addr" 'weth()(address)' --rpc-url "$rpc")"86+ "$(cast call "$addr" 'WETH()(address)' --rpc-url "$rpc")"
87 }87 }
88 88
89 cd "$ROOT"89 cd "$ROOT"
modified src/Love.sol +4 -4
@@ -15,7 +15,7 @@ contract Love is ERC20 {
1515
1616 uint256 public constant RATE = 100_000;
1717
18- IERC20 public immutable weth;
18+ IERC20 public immutable WETH;
1919
2020 error AmountNotDivisibleByRate(uint256 loveAmount, uint256 rate);
2121
@@ -23,14 +23,14 @@ contract Love is ERC20 {
2323 event Withdraw(address indexed account, uint256 loveAmount, uint256 wethAmount);
2424
2525 constructor(IERC20 weth_) ERC20("Love", "LOVE") {
26- weth = weth_;
26+ WETH = weth_;
2727 }
2828
2929 /// @notice Lock `wethAmount` wETH and mint `wethAmount * RATE` LOVE to the caller.
3030 function deposit(uint256 wethAmount) external {
3131 uint256 loveAmount = wethAmount * RATE;
3232
33- weth.safeTransferFrom(msg.sender, address(this), wethAmount);
33+ WETH.safeTransferFrom(msg.sender, address(this), wethAmount);
3434 _mint(msg.sender, loveAmount);
3535
3636 emit Deposit(msg.sender, wethAmount, loveAmount);
@@ -45,7 +45,7 @@ contract Love is ERC20 {
4545 uint256 wethAmount = loveAmount / RATE;
4646
4747 _burn(msg.sender, loveAmount);
48- weth.safeTransfer(msg.sender, wethAmount);
48+ WETH.safeTransfer(msg.sender, wethAmount);
4949
5050 emit Withdraw(msg.sender, loveAmount, wethAmount);
5151 }
@@ -15,7 +15,7 @@ contract Love is ERC20 {
15 15
16 uint256 public constant RATE = 100_000;16 uint256 public constant RATE = 100_000;
17 17
18- IERC20 public immutable weth;18+ IERC20 public immutable WETH;
19 19
20 error AmountNotDivisibleByRate(uint256 loveAmount, uint256 rate);20 error AmountNotDivisibleByRate(uint256 loveAmount, uint256 rate);
21 21
@@ -23,14 +23,14 @@ contract Love is ERC20 {
23 event Withdraw(address indexed account, uint256 loveAmount, uint256 wethAmount);23 event Withdraw(address indexed account, uint256 loveAmount, uint256 wethAmount);
24 24
25 constructor(IERC20 weth_) ERC20("Love", "LOVE") {25 constructor(IERC20 weth_) ERC20("Love", "LOVE") {
26- weth = weth_;26+ WETH = weth_;
27 }27 }
28 28
29 /// @notice Lock `wethAmount` wETH and mint `wethAmount * RATE` LOVE to the caller.29 /// @notice Lock `wethAmount` wETH and mint `wethAmount * RATE` LOVE to the caller.
30 function deposit(uint256 wethAmount) external {30 function deposit(uint256 wethAmount) external {
31 uint256 loveAmount = wethAmount * RATE;31 uint256 loveAmount = wethAmount * RATE;
32 32
33- weth.safeTransferFrom(msg.sender, address(this), wethAmount);33+ WETH.safeTransferFrom(msg.sender, address(this), wethAmount);
34 _mint(msg.sender, loveAmount);34 _mint(msg.sender, loveAmount);
35 35
36 emit Deposit(msg.sender, wethAmount, loveAmount);36 emit Deposit(msg.sender, wethAmount, loveAmount);
@@ -45,7 +45,7 @@ contract Love is ERC20 {
45 uint256 wethAmount = loveAmount / RATE;45 uint256 wethAmount = loveAmount / RATE;
46 46
47 _burn(msg.sender, loveAmount);47 _burn(msg.sender, loveAmount);
48- weth.safeTransfer(msg.sender, wethAmount);48+ WETH.safeTransfer(msg.sender, wethAmount);
49 49
50 emit Withdraw(msg.sender, loveAmount, wethAmount);50 emit Withdraw(msg.sender, loveAmount, wethAmount);
51 }51 }
modified test/Love.t.sol +262 -50
@@ -2,19 +2,27 @@
22 pragma solidity ^0.8.30;
33
44 import {Love} from "../src/Love.sol";
5+import {MockWETH} from "./mocks/MockWETH.sol";
56 import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
67 import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
78 import {Test} from "forge-std/Test.sol";
89
910 contract LoveTest is Test {
1011 Love public love;
12+ MockWETH public weth;
1113
1214 address alice = makeAddr("alice");
1315 address bob = makeAddr("bob");
1416 address stranger = makeAddr("stranger");
1517
18+ uint256 constant RATE = 100_000;
19+
1620 function setUp() public {
17- love = new Love();
21+ weth = new MockWETH();
22+ love = new Love(IERC20(address(weth)));
23+
24+ _fund(alice, 10 ether);
25+ _fund(bob, 10 ether);
1826 }
1927
2028 /*//////////////////////////////////////////////////////////////
@@ -31,56 +39,191 @@ contract LoveTest is Test {
3139 assertEq(love.totalSupply(), 0);
3240 }
3341
42+ function test_PegParameters() public view {
43+ assertEq(love.RATE(), RATE);
44+ assertEq(address(love.WETH()), address(weth));
45+ }
46+
47+ /// @dev No mint entrypoint may survive: supply moves only through the peg.
48+ function test_NoMintFunction() public view {
49+ assertEq(address(love).code.length > 0, true);
50+ (bool ok,) = address(love).staticcall(abi.encodeWithSignature("mint(address,uint256)", alice, 1e18));
51+ assertFalse(ok);
52+ }
53+
3454 /*//////////////////////////////////////////////////////////////
35- MINT
55+ DEPOSIT
3656 //////////////////////////////////////////////////////////////*/
3757
38- function test_Mint() public {
39- vm.prank(alice);
40- love.mint(alice, 100e18);
58+ function test_Deposit() public {
59+ _deposit(alice, 1 ether);
4160
42- assertEq(love.balanceOf(alice), 100e18);
43- assertEq(love.totalSupply(), 100e18);
61+ assertEq(love.balanceOf(alice), 1 ether * RATE);
62+ assertEq(love.totalSupply(), 1 ether * RATE);
63+ assertEq(weth.balanceOf(address(love)), 1 ether);
64+ assertEq(weth.balanceOf(alice), 9 ether);
4465 }
4566
46- /// @dev The point of this contract: `mint` has no access control.
47- function test_AnyoneCanMintToAnyone() public {
48- vm.prank(stranger);
49- love.mint(bob, 1000e18);
67+ function test_DepositIsPermissionless() public {
68+ _fund(stranger, 1 ether);
69+ _deposit(stranger, 1 ether);
70+
71+ assertEq(love.balanceOf(stranger), 1 ether * RATE);
72+ }
73+
74+ function test_DepositEmitsDeposit() public {
75+ vm.prank(alice);
76+ weth.approve(address(love), 2 ether);
77+
78+ vm.expectEmit(true, false, false, true);
79+ emit Love.Deposit(alice, 2 ether, 2 ether * RATE);
5080
51- assertEq(love.balanceOf(bob), 1000e18);
52- assertEq(love.balanceOf(stranger), 0);
53- assertEq(love.totalSupply(), 1000e18);
81+ vm.prank(alice);
82+ love.deposit(2 ether);
5483 }
5584
56- function test_MintEmitsTransferFromZero() public {
85+ function test_DepositEmitsTransferFromZero() public {
86+ vm.prank(alice);
87+ weth.approve(address(love), 1 ether);
88+
5789 vm.expectEmit(true, true, false, true);
58- emit IERC20.Transfer(address(0), alice, 5e18);
90+ emit IERC20.Transfer(address(0), alice, 1 ether * RATE);
5991
6092 vm.prank(alice);
61- love.mint(alice, 5e18);
93+ love.deposit(1 ether);
6294 }
6395
64- function test_MintAccumulates() public {
65- vm.startPrank(alice);
66- love.mint(alice, 1e18);
67- love.mint(alice, 2e18);
68- vm.stopPrank();
96+ function test_DepositAccumulates() public {
97+ _deposit(alice, 1 ether);
98+ _deposit(alice, 3 ether);
6999
70- assertEq(love.balanceOf(alice), 3e18);
71- assertEq(love.totalSupply(), 3e18);
100+ assertEq(love.balanceOf(alice), 4 ether * RATE);
101+ assertEq(weth.balanceOf(address(love)), 4 ether);
72102 }
73103
74- function test_RevertWhen_MintingToZeroAddress() public {
75- vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidReceiver.selector, address(0)));
76- love.mint(address(0), 1e18);
104+ function test_DepositZero() public {
105+ _deposit(alice, 0);
106+
107+ assertEq(love.balanceOf(alice), 0);
108+ assertEq(love.totalSupply(), 0);
77109 }
78110
79- function test_RevertWhen_MintOverflowsTotalSupply() public {
80- love.mint(alice, type(uint256).max);
111+ function test_RevertWhen_DepositWithoutApproval() public {
112+ vm.prank(alice);
113+ vm.expectRevert(
114+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, address(love), 0, 1 ether)
115+ );
116+ love.deposit(1 ether);
117+ }
118+
119+ function test_RevertWhen_DepositExceedsWethBalance() public {
120+ vm.prank(alice);
121+ weth.approve(address(love), 100 ether);
122+
123+ vm.prank(alice);
124+ vm.expectRevert(
125+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 10 ether, 100 ether)
126+ );
127+ love.deposit(100 ether);
128+ }
81129
130+ function test_RevertWhen_DepositOverflows() public {
131+ vm.prank(alice);
82132 vm.expectRevert();
83- love.mint(bob, 1);
133+ love.deposit(type(uint256).max);
134+ }
135+
136+ /*//////////////////////////////////////////////////////////////
137+ WITHDRAW
138+ //////////////////////////////////////////////////////////////*/
139+
140+ function test_Withdraw() public {
141+ _deposit(alice, 1 ether);
142+
143+ vm.prank(alice);
144+ love.withdraw(1 ether * RATE);
145+
146+ assertEq(love.balanceOf(alice), 0);
147+ assertEq(love.totalSupply(), 0);
148+ assertEq(weth.balanceOf(alice), 10 ether);
149+ assertEq(weth.balanceOf(address(love)), 0);
150+ }
151+
152+ function test_PartialWithdraw() public {
153+ _deposit(alice, 1 ether);
154+
155+ vm.prank(alice);
156+ love.withdraw(0.25 ether * RATE);
157+
158+ assertEq(love.balanceOf(alice), 0.75 ether * RATE);
159+ assertEq(weth.balanceOf(alice), 9.25 ether);
160+ assertEq(weth.balanceOf(address(love)), 0.75 ether);
161+ }
162+
163+ function test_WithdrawEmitsWithdraw() public {
164+ _deposit(alice, 1 ether);
165+
166+ vm.expectEmit(true, false, false, true);
167+ emit Love.Withdraw(alice, 1 ether * RATE, 1 ether);
168+
169+ vm.prank(alice);
170+ love.withdraw(1 ether * RATE);
171+ }
172+
173+ /// @dev LOVE received from someone else is redeemable all the same.
174+ function test_WithdrawAfterTransfer() public {
175+ _deposit(alice, 1 ether);
176+
177+ vm.prank(alice);
178+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
179+ love.transfer(bob, 1 ether * RATE);
180+
181+ vm.prank(bob);
182+ love.withdraw(1 ether * RATE);
183+
184+ assertEq(weth.balanceOf(bob), 11 ether);
185+ assertEq(love.totalSupply(), 0);
186+ }
187+
188+ function test_WithdrawZero() public {
189+ _deposit(alice, 1 ether);
190+
191+ vm.prank(alice);
192+ love.withdraw(0);
193+
194+ assertEq(love.balanceOf(alice), 1 ether * RATE);
195+ }
196+
197+ /// @dev Anything not a multiple of the rate would round wETH out of the peg.
198+ function test_RevertWhen_WithdrawNotDivisibleByRate() public {
199+ _deposit(alice, 1 ether);
200+
201+ vm.prank(alice);
202+ vm.expectRevert(abi.encodeWithSelector(Love.AmountNotDivisibleByRate.selector, RATE + 1, RATE));
203+ love.withdraw(RATE + 1);
204+ }
205+
206+ function test_RevertWhen_WithdrawExceedsBalance() public {
207+ _deposit(alice, 1 ether);
208+
209+ vm.prank(alice);
210+ vm.expectRevert(
211+ abi.encodeWithSelector(
212+ IERC20Errors.ERC20InsufficientBalance.selector, alice, 1 ether * RATE, 2 ether * RATE
213+ )
214+ );
215+ love.withdraw(2 ether * RATE);
216+ }
217+
218+ /// @dev One holder cannot redeem against another holder's collateral.
219+ function test_RevertWhen_WithdrawWithoutDepositing() public {
220+ _deposit(alice, 1 ether);
221+
222+ vm.prank(stranger);
223+ vm.expectRevert(
224+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, stranger, 0, 1 ether * RATE)
225+ );
226+ love.withdraw(1 ether * RATE);
84227 }
85228
86229 /*//////////////////////////////////////////////////////////////
@@ -88,20 +231,18 @@ contract LoveTest is Test {
88231 //////////////////////////////////////////////////////////////*/
89232
90233 function test_Transfer() public {
91- love.mint(alice, 10e18);
234+ _deposit(alice, 1 ether);
92235
93236 vm.prank(alice);
94237 assertTrue(love.transfer(bob, 4e18));
95238
96- assertEq(love.balanceOf(alice), 6e18);
239+ assertEq(love.balanceOf(alice), 1 ether * RATE - 4e18);
97240 assertEq(love.balanceOf(bob), 4e18);
98241 }
99242
100243 function test_RevertWhen_TransferExceedsBalance() public {
101- love.mint(alice, 1e18);
102-
103244 vm.prank(alice);
104- vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 1e18, 2e18));
245+ vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, 2e18));
105246 // forge-lint: disable-next-line(erc20-unchecked-transfer)
106247 love.transfer(bob, 2e18);
107248 }
@@ -111,7 +252,7 @@ contract LoveTest is Test {
111252 //////////////////////////////////////////////////////////////*/
112253
113254 function test_ApproveAndTransferFrom() public {
114- love.mint(alice, 10e18);
255+ _deposit(alice, 1 ether);
115256
116257 vm.prank(alice);
117258 assertTrue(love.approve(bob, 6e18));
@@ -120,13 +261,13 @@ contract LoveTest is Test {
120261 vm.prank(bob);
121262 assertTrue(love.transferFrom(alice, stranger, 6e18));
122263
123- assertEq(love.balanceOf(alice), 4e18);
264+ assertEq(love.balanceOf(alice), 1 ether * RATE - 6e18);
124265 assertEq(love.balanceOf(stranger), 6e18);
125266 assertEq(love.allowance(alice, bob), 0);
126267 }
127268
128269 function test_RevertWhen_TransferFromExceedsAllowance() public {
129- love.mint(alice, 10e18);
270+ _deposit(alice, 1 ether);
130271
131272 vm.prank(alice);
132273 love.approve(bob, 1e18);
@@ -137,29 +278,100 @@ contract LoveTest is Test {
137278 love.transferFrom(alice, stranger, 2e18);
138279 }
139280
281+ /*//////////////////////////////////////////////////////////////
282+ PEG
283+ //////////////////////////////////////////////////////////////*/
284+
285+ function test_RoundTripIsLossless() public {
286+ uint256 before = weth.balanceOf(alice);
287+
288+ _deposit(alice, 3 ether);
289+ vm.prank(alice);
290+ love.withdraw(3 ether * RATE);
291+
292+ assertEq(weth.balanceOf(alice), before);
293+ assertEq(love.balanceOf(alice), 0);
294+ assertEq(love.totalSupply(), 0);
295+ }
296+
297+ function test_SupplyStaysFullyBacked() public {
298+ _deposit(alice, 1 ether);
299+ _deposit(bob, 2 ether);
300+
301+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
302+
303+ vm.prank(alice);
304+ love.withdraw(0.5 ether * RATE);
305+
306+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
307+ }
308+
309+ /// @dev wETH sent straight to the contract is not claimable — it only lifts
310+ /// the backing, it never mints LOVE.
311+ function test_DonatedWethDoesNotMint() public {
312+ _deposit(alice, 1 ether);
313+
314+ vm.prank(bob);
315+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
316+ weth.transfer(address(love), 5 ether);
317+
318+ assertEq(love.totalSupply(), 1 ether * RATE);
319+ assertEq(weth.balanceOf(address(love)), 6 ether);
320+ }
321+
140322 /*//////////////////////////////////////////////////////////////
141323 FUZZ
142324 //////////////////////////////////////////////////////////////*/
143325
144- function testFuzz_MintByAnyCaller(address caller, address to, uint256 amount) public {
145- vm.assume(to != address(0));
326+ function testFuzz_DepositByAnyCaller(address caller, uint256 wethAmount) public {
327+ vm.assume(caller != address(0) && caller != address(love) && caller != address(weth));
328+ wethAmount = bound(wethAmount, 0, type(uint256).max / RATE);
146329
147- vm.prank(caller);
148- love.mint(to, amount);
330+ _fund(caller, wethAmount);
331+ _deposit(caller, wethAmount);
149332
150- assertEq(love.balanceOf(to), amount);
151- assertEq(love.totalSupply(), amount);
333+ assertEq(love.balanceOf(caller), wethAmount * RATE);
334+ assertEq(love.totalSupply(), wethAmount * RATE);
335+ assertEq(weth.balanceOf(address(love)), wethAmount);
152336 }
153337
154- function testFuzz_Transfer(uint256 minted, uint256 amount) public {
155- amount = bound(amount, 0, minted);
338+ function testFuzz_RoundTrip(uint256 wethAmount) public {
339+ wethAmount = bound(wethAmount, 0, type(uint256).max / RATE);
156340
157- love.mint(alice, minted);
341+ _fund(alice, wethAmount);
342+ uint256 before = weth.balanceOf(alice);
158343
344+ _deposit(alice, wethAmount);
159345 vm.prank(alice);
160- assertTrue(love.transfer(bob, amount));
346+ love.withdraw(wethAmount * RATE);
347+
348+ assertEq(weth.balanceOf(alice), before);
349+ assertEq(love.totalSupply(), 0);
350+ }
351+
352+ function testFuzz_WithdrawRevertsUnlessDivisible(uint256 loveAmount) public {
353+ _deposit(alice, 10 ether);
161354
162- assertEq(love.balanceOf(alice), minted - amount);
163- assertEq(love.balanceOf(bob), amount);
355+ vm.prank(alice);
356+ if (loveAmount % RATE == 0 && loveAmount <= 10 ether * RATE) {
357+ love.withdraw(loveAmount);
358+ assertEq(weth.balanceOf(address(love)), 10 ether - loveAmount / RATE);
359+ } else {
360+ vm.expectRevert();
361+ love.withdraw(loveAmount);
362+ }
363+ }
364+
365+ function _fund(address account, uint256 amount) internal {
366+ vm.deal(account, amount);
367+ vm.prank(account);
368+ weth.deposit{value: amount}();
369+ }
370+
371+ function _deposit(address account, uint256 wethAmount) internal {
372+ vm.startPrank(account);
373+ weth.approve(address(love), wethAmount);
374+ love.deposit(wethAmount);
375+ vm.stopPrank();
164376 }
165377 }
@@ -2,19 +2,27 @@
2 pragma solidity ^0.8.30;2 pragma solidity ^0.8.30;
3 3
4 import {Love} from "../src/Love.sol";4 import {Love} from "../src/Love.sol";
5+import {MockWETH} from "./mocks/MockWETH.sol";
5 import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";6 import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
6 import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";7 import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7 import {Test} from "forge-std/Test.sol";8 import {Test} from "forge-std/Test.sol";
8 9
9 contract LoveTest is Test {10 contract LoveTest is Test {
10 Love public love;11 Love public love;
12+ MockWETH public weth;
11 13
12 address alice = makeAddr("alice");14 address alice = makeAddr("alice");
13 address bob = makeAddr("bob");15 address bob = makeAddr("bob");
14 address stranger = makeAddr("stranger");16 address stranger = makeAddr("stranger");
15 17
18+ uint256 constant RATE = 100_000;
19+
16 function setUp() public {20 function setUp() public {
17- love = new Love();21+ weth = new MockWETH();
22+ love = new Love(IERC20(address(weth)));
23+
24+ _fund(alice, 10 ether);
25+ _fund(bob, 10 ether);
18 }26 }
19 27
20 /*//////////////////////////////////////////////////////////////28 /*//////////////////////////////////////////////////////////////
@@ -31,56 +39,191 @@ contract LoveTest is Test {
31 assertEq(love.totalSupply(), 0);39 assertEq(love.totalSupply(), 0);
32 }40 }
33 41
42+ function test_PegParameters() public view {
43+ assertEq(love.RATE(), RATE);
44+ assertEq(address(love.WETH()), address(weth));
45+ }
46+
47+ /// @dev No mint entrypoint may survive: supply moves only through the peg.
48+ function test_NoMintFunction() public view {
49+ assertEq(address(love).code.length > 0, true);
50+ (bool ok,) = address(love).staticcall(abi.encodeWithSignature("mint(address,uint256)", alice, 1e18));
51+ assertFalse(ok);
52+ }
53+
34 /*//////////////////////////////////////////////////////////////54 /*//////////////////////////////////////////////////////////////
35- MINT55+ DEPOSIT
36 //////////////////////////////////////////////////////////////*/56 //////////////////////////////////////////////////////////////*/
37 57
38- function test_Mint() public {58+ function test_Deposit() public {
39- vm.prank(alice);59+ _deposit(alice, 1 ether);
40- love.mint(alice, 100e18);
41 60
42- assertEq(love.balanceOf(alice), 100e18);61+ assertEq(love.balanceOf(alice), 1 ether * RATE);
43- assertEq(love.totalSupply(), 100e18);62+ assertEq(love.totalSupply(), 1 ether * RATE);
63+ assertEq(weth.balanceOf(address(love)), 1 ether);
64+ assertEq(weth.balanceOf(alice), 9 ether);
44 }65 }
45 66
46- /// @dev The point of this contract: `mint` has no access control.67+ function test_DepositIsPermissionless() public {
47- function test_AnyoneCanMintToAnyone() public {68+ _fund(stranger, 1 ether);
48- vm.prank(stranger);69+ _deposit(stranger, 1 ether);
49- love.mint(bob, 1000e18);70+
71+ assertEq(love.balanceOf(stranger), 1 ether * RATE);
72+ }
73+
74+ function test_DepositEmitsDeposit() public {
75+ vm.prank(alice);
76+ weth.approve(address(love), 2 ether);
77+
78+ vm.expectEmit(true, false, false, true);
79+ emit Love.Deposit(alice, 2 ether, 2 ether * RATE);
50 80
51- assertEq(love.balanceOf(bob), 1000e18);81+ vm.prank(alice);
52- assertEq(love.balanceOf(stranger), 0);82+ love.deposit(2 ether);
53- assertEq(love.totalSupply(), 1000e18);
54 }83 }
55 84
56- function test_MintEmitsTransferFromZero() public {85+ function test_DepositEmitsTransferFromZero() public {
86+ vm.prank(alice);
87+ weth.approve(address(love), 1 ether);
88+
57 vm.expectEmit(true, true, false, true);89 vm.expectEmit(true, true, false, true);
58- emit IERC20.Transfer(address(0), alice, 5e18);90+ emit IERC20.Transfer(address(0), alice, 1 ether * RATE);
59 91
60 vm.prank(alice);92 vm.prank(alice);
61- love.mint(alice, 5e18);93+ love.deposit(1 ether);
62 }94 }
63 95
64- function test_MintAccumulates() public {96+ function test_DepositAccumulates() public {
65- vm.startPrank(alice);97+ _deposit(alice, 1 ether);
66- love.mint(alice, 1e18);98+ _deposit(alice, 3 ether);
67- love.mint(alice, 2e18);
68- vm.stopPrank();
69 99
70- assertEq(love.balanceOf(alice), 3e18);100+ assertEq(love.balanceOf(alice), 4 ether * RATE);
71- assertEq(love.totalSupply(), 3e18);101+ assertEq(weth.balanceOf(address(love)), 4 ether);
72 }102 }
73 103
74- function test_RevertWhen_MintingToZeroAddress() public {104+ function test_DepositZero() public {
75- vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidReceiver.selector, address(0)));105+ _deposit(alice, 0);
76- love.mint(address(0), 1e18);106+
107+ assertEq(love.balanceOf(alice), 0);
108+ assertEq(love.totalSupply(), 0);
77 }109 }
78 110
79- function test_RevertWhen_MintOverflowsTotalSupply() public {111+ function test_RevertWhen_DepositWithoutApproval() public {
80- love.mint(alice, type(uint256).max);112+ vm.prank(alice);
113+ vm.expectRevert(
114+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, address(love), 0, 1 ether)
115+ );
116+ love.deposit(1 ether);
117+ }
118+
119+ function test_RevertWhen_DepositExceedsWethBalance() public {
120+ vm.prank(alice);
121+ weth.approve(address(love), 100 ether);
122+
123+ vm.prank(alice);
124+ vm.expectRevert(
125+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 10 ether, 100 ether)
126+ );
127+ love.deposit(100 ether);
128+ }
81 129
130+ function test_RevertWhen_DepositOverflows() public {
131+ vm.prank(alice);
82 vm.expectRevert();132 vm.expectRevert();
83- love.mint(bob, 1);133+ love.deposit(type(uint256).max);
134+ }
135+
136+ /*//////////////////////////////////////////////////////////////
137+ WITHDRAW
138+ //////////////////////////////////////////////////////////////*/
139+
140+ function test_Withdraw() public {
141+ _deposit(alice, 1 ether);
142+
143+ vm.prank(alice);
144+ love.withdraw(1 ether * RATE);
145+
146+ assertEq(love.balanceOf(alice), 0);
147+ assertEq(love.totalSupply(), 0);
148+ assertEq(weth.balanceOf(alice), 10 ether);
149+ assertEq(weth.balanceOf(address(love)), 0);
150+ }
151+
152+ function test_PartialWithdraw() public {
153+ _deposit(alice, 1 ether);
154+
155+ vm.prank(alice);
156+ love.withdraw(0.25 ether * RATE);
157+
158+ assertEq(love.balanceOf(alice), 0.75 ether * RATE);
159+ assertEq(weth.balanceOf(alice), 9.25 ether);
160+ assertEq(weth.balanceOf(address(love)), 0.75 ether);
161+ }
162+
163+ function test_WithdrawEmitsWithdraw() public {
164+ _deposit(alice, 1 ether);
165+
166+ vm.expectEmit(true, false, false, true);
167+ emit Love.Withdraw(alice, 1 ether * RATE, 1 ether);
168+
169+ vm.prank(alice);
170+ love.withdraw(1 ether * RATE);
171+ }
172+
173+ /// @dev LOVE received from someone else is redeemable all the same.
174+ function test_WithdrawAfterTransfer() public {
175+ _deposit(alice, 1 ether);
176+
177+ vm.prank(alice);
178+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
179+ love.transfer(bob, 1 ether * RATE);
180+
181+ vm.prank(bob);
182+ love.withdraw(1 ether * RATE);
183+
184+ assertEq(weth.balanceOf(bob), 11 ether);
185+ assertEq(love.totalSupply(), 0);
186+ }
187+
188+ function test_WithdrawZero() public {
189+ _deposit(alice, 1 ether);
190+
191+ vm.prank(alice);
192+ love.withdraw(0);
193+
194+ assertEq(love.balanceOf(alice), 1 ether * RATE);
195+ }
196+
197+ /// @dev Anything not a multiple of the rate would round wETH out of the peg.
198+ function test_RevertWhen_WithdrawNotDivisibleByRate() public {
199+ _deposit(alice, 1 ether);
200+
201+ vm.prank(alice);
202+ vm.expectRevert(abi.encodeWithSelector(Love.AmountNotDivisibleByRate.selector, RATE + 1, RATE));
203+ love.withdraw(RATE + 1);
204+ }
205+
206+ function test_RevertWhen_WithdrawExceedsBalance() public {
207+ _deposit(alice, 1 ether);
208+
209+ vm.prank(alice);
210+ vm.expectRevert(
211+ abi.encodeWithSelector(
212+ IERC20Errors.ERC20InsufficientBalance.selector, alice, 1 ether * RATE, 2 ether * RATE
213+ )
214+ );
215+ love.withdraw(2 ether * RATE);
216+ }
217+
218+ /// @dev One holder cannot redeem against another holder's collateral.
219+ function test_RevertWhen_WithdrawWithoutDepositing() public {
220+ _deposit(alice, 1 ether);
221+
222+ vm.prank(stranger);
223+ vm.expectRevert(
224+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, stranger, 0, 1 ether * RATE)
225+ );
226+ love.withdraw(1 ether * RATE);
84 }227 }
85 228
86 /*//////////////////////////////////////////////////////////////229 /*//////////////////////////////////////////////////////////////
@@ -88,20 +231,18 @@ contract LoveTest is Test {
88 //////////////////////////////////////////////////////////////*/231 //////////////////////////////////////////////////////////////*/
89 232
90 function test_Transfer() public {233 function test_Transfer() public {
91- love.mint(alice, 10e18);234+ _deposit(alice, 1 ether);
92 235
93 vm.prank(alice);236 vm.prank(alice);
94 assertTrue(love.transfer(bob, 4e18));237 assertTrue(love.transfer(bob, 4e18));
95 238
96- assertEq(love.balanceOf(alice), 6e18);239+ assertEq(love.balanceOf(alice), 1 ether * RATE - 4e18);
97 assertEq(love.balanceOf(bob), 4e18);240 assertEq(love.balanceOf(bob), 4e18);
98 }241 }
99 242
100 function test_RevertWhen_TransferExceedsBalance() public {243 function test_RevertWhen_TransferExceedsBalance() public {
101- love.mint(alice, 1e18);
102-
103 vm.prank(alice);244 vm.prank(alice);
104- vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 1e18, 2e18));245+ vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, 2e18));
105 // forge-lint: disable-next-line(erc20-unchecked-transfer)246 // forge-lint: disable-next-line(erc20-unchecked-transfer)
106 love.transfer(bob, 2e18);247 love.transfer(bob, 2e18);
107 }248 }
@@ -111,7 +252,7 @@ contract LoveTest is Test {
111 //////////////////////////////////////////////////////////////*/252 //////////////////////////////////////////////////////////////*/
112 253
113 function test_ApproveAndTransferFrom() public {254 function test_ApproveAndTransferFrom() public {
114- love.mint(alice, 10e18);255+ _deposit(alice, 1 ether);
115 256
116 vm.prank(alice);257 vm.prank(alice);
117 assertTrue(love.approve(bob, 6e18));258 assertTrue(love.approve(bob, 6e18));
@@ -120,13 +261,13 @@ contract LoveTest is Test {
120 vm.prank(bob);261 vm.prank(bob);
121 assertTrue(love.transferFrom(alice, stranger, 6e18));262 assertTrue(love.transferFrom(alice, stranger, 6e18));
122 263
123- assertEq(love.balanceOf(alice), 4e18);264+ assertEq(love.balanceOf(alice), 1 ether * RATE - 6e18);
124 assertEq(love.balanceOf(stranger), 6e18);265 assertEq(love.balanceOf(stranger), 6e18);
125 assertEq(love.allowance(alice, bob), 0);266 assertEq(love.allowance(alice, bob), 0);
126 }267 }
127 268
128 function test_RevertWhen_TransferFromExceedsAllowance() public {269 function test_RevertWhen_TransferFromExceedsAllowance() public {
129- love.mint(alice, 10e18);270+ _deposit(alice, 1 ether);
130 271
131 vm.prank(alice);272 vm.prank(alice);
132 love.approve(bob, 1e18);273 love.approve(bob, 1e18);
@@ -137,29 +278,100 @@ contract LoveTest is Test {
137 love.transferFrom(alice, stranger, 2e18);278 love.transferFrom(alice, stranger, 2e18);
138 }279 }
139 280
281+ /*//////////////////////////////////////////////////////////////
282+ PEG
283+ //////////////////////////////////////////////////////////////*/
284+
285+ function test_RoundTripIsLossless() public {
286+ uint256 before = weth.balanceOf(alice);
287+
288+ _deposit(alice, 3 ether);
289+ vm.prank(alice);
290+ love.withdraw(3 ether * RATE);
291+
292+ assertEq(weth.balanceOf(alice), before);
293+ assertEq(love.balanceOf(alice), 0);
294+ assertEq(love.totalSupply(), 0);
295+ }
296+
297+ function test_SupplyStaysFullyBacked() public {
298+ _deposit(alice, 1 ether);
299+ _deposit(bob, 2 ether);
300+
301+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
302+
303+ vm.prank(alice);
304+ love.withdraw(0.5 ether * RATE);
305+
306+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
307+ }
308+
309+ /// @dev wETH sent straight to the contract is not claimable — it only lifts
310+ /// the backing, it never mints LOVE.
311+ function test_DonatedWethDoesNotMint() public {
312+ _deposit(alice, 1 ether);
313+
314+ vm.prank(bob);
315+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
316+ weth.transfer(address(love), 5 ether);
317+
318+ assertEq(love.totalSupply(), 1 ether * RATE);
319+ assertEq(weth.balanceOf(address(love)), 6 ether);
320+ }
321+
140 /*//////////////////////////////////////////////////////////////322 /*//////////////////////////////////////////////////////////////
141 FUZZ323 FUZZ
142 //////////////////////////////////////////////////////////////*/324 //////////////////////////////////////////////////////////////*/
143 325
144- function testFuzz_MintByAnyCaller(address caller, address to, uint256 amount) public {326+ function testFuzz_DepositByAnyCaller(address caller, uint256 wethAmount) public {
145- vm.assume(to != address(0));327+ vm.assume(caller != address(0) && caller != address(love) && caller != address(weth));
328+ wethAmount = bound(wethAmount, 0, type(uint256).max / RATE);
146 329
147- vm.prank(caller);330+ _fund(caller, wethAmount);
148- love.mint(to, amount);331+ _deposit(caller, wethAmount);
149 332
150- assertEq(love.balanceOf(to), amount);333+ assertEq(love.balanceOf(caller), wethAmount * RATE);
151- assertEq(love.totalSupply(), amount);334+ assertEq(love.totalSupply(), wethAmount * RATE);
335+ assertEq(weth.balanceOf(address(love)), wethAmount);
152 }336 }
153 337
154- function testFuzz_Transfer(uint256 minted, uint256 amount) public {338+ function testFuzz_RoundTrip(uint256 wethAmount) public {
155- amount = bound(amount, 0, minted);339+ wethAmount = bound(wethAmount, 0, type(uint256).max / RATE);
156 340
157- love.mint(alice, minted);341+ _fund(alice, wethAmount);
342+ uint256 before = weth.balanceOf(alice);
158 343
344+ _deposit(alice, wethAmount);
159 vm.prank(alice);345 vm.prank(alice);
160- assertTrue(love.transfer(bob, amount));346+ love.withdraw(wethAmount * RATE);
347+
348+ assertEq(weth.balanceOf(alice), before);
349+ assertEq(love.totalSupply(), 0);
350+ }
351+
352+ function testFuzz_WithdrawRevertsUnlessDivisible(uint256 loveAmount) public {
353+ _deposit(alice, 10 ether);
161 354
162- assertEq(love.balanceOf(alice), minted - amount);355+ vm.prank(alice);
163- assertEq(love.balanceOf(bob), amount);356+ if (loveAmount % RATE == 0 && loveAmount <= 10 ether * RATE) {
357+ love.withdraw(loveAmount);
358+ assertEq(weth.balanceOf(address(love)), 10 ether - loveAmount / RATE);
359+ } else {
360+ vm.expectRevert();
361+ love.withdraw(loveAmount);
362+ }
363+ }
364+
365+ function _fund(address account, uint256 amount) internal {
366+ vm.deal(account, amount);
367+ vm.prank(account);
368+ weth.deposit{value: amount}();
369+ }
370+
371+ function _deposit(address account, uint256 wethAmount) internal {
372+ vm.startPrank(account);
373+ weth.approve(address(love), wethAmount);
374+ love.deposit(wethAmount);
375+ vm.stopPrank();
164 }376 }
165 }377 }
modified test/LoveCreate2.t.sol +30 -17
@@ -5,11 +5,13 @@ import {LoveScript} from "../script/Love.s.sol";
55 import {Love} from "../src/Love.sol";
66 import {Test} from "forge-std/Test.sol";
77
8-/// @notice The token is meant to live at the same address on every EVM network,
9-/// so these tests pin the CREATE2 deployment path and the inputs the
10-/// address derives from.
8+/// @notice The deployment address derives from the salt, the creation code and
9+/// the wETH constructor argument. These tests pin that derivation: same
10+/// salt and same wETH give the same address on any chain, a different
11+/// wETH gives a different one.
1112 contract LoveCreate2Test is Test {
1213 bytes32 constant SALT = keccak256("LOVE");
14+ address constant WETH = 0x4200000000000000000000000000000000000006;
1315
1416 LoveScript script;
1517
@@ -21,53 +23,64 @@ contract LoveCreate2Test is Test {
2123 }
2224
2325 function test_DeploysAtPredictedAddress() public {
24- address predicted = vm.computeCreate2Address(SALT, keccak256(type(Love).creationCode), CREATE2_FACTORY);
26+ address predicted = vm.computeCreate2Address(SALT, keccak256(script.initCode(WETH)), CREATE2_FACTORY);
2527
26- Love deployed = Love(_deploy(SALT));
28+ Love deployed = Love(_deploy(SALT, WETH));
2729
2830 assertEq(address(deployed), predicted);
2931 assertEq(deployed.symbol(), "LOVE");
32+ assertEq(address(deployed.WETH()), WETH);
3033 }
3134
3235 /// @dev What the deploy script prints must be what the chain gives back.
3336 function test_ScriptPredictionMatchesDeployment() public {
34- assertEq(script.predict(SALT), _deploy(SALT));
37+ assertEq(script.predict(SALT, WETH), _deploy(SALT, WETH));
3538 }
3639
3740 function test_ScriptUsesKeccakOfLoveAsDefaultSalt() public view {
3841 assertEq(script.DEFAULT_SALT(), SALT);
3942 }
4043
41- /// @dev Same salt, same code, same address — that is the whole point.
44+ function test_ScriptDefaultsToOpStackWeth() public view {
45+ assertEq(script.DEFAULT_WETH(), WETH);
46+ }
47+
48+ /// @dev Same salt, same code, same wETH, same address.
4249 function test_PredictionIsChainAgnostic() public {
43- address onThisChain = script.predict(SALT);
50+ address onThisChain = script.predict(SALT, WETH);
4451
4552 vm.chainId(137);
46- assertEq(script.predict(SALT), onThisChain);
53+ assertEq(script.predict(SALT, WETH), onThisChain);
4754
4855 vm.chainId(42_161);
49- assertEq(script.predict(SALT), onThisChain);
56+ assertEq(script.predict(SALT, WETH), onThisChain);
5057 }
5158
5259 function test_DifferentSaltsGiveDifferentAddresses() public view {
53- assertTrue(script.predict(SALT) != script.predict(keccak256("LOVE2")));
60+ assertTrue(script.predict(SALT, WETH) != script.predict(keccak256("LOVE2"), WETH));
61+ }
62+
63+ /// @dev The wETH address is part of the creation code, so chains with their
64+ /// own wETH get their own token address.
65+ function test_DifferentWethGivesDifferentAddress() public view {
66+ assertTrue(script.predict(SALT, WETH) != script.predict(SALT, address(0xBEEF)));
5467 }
5568
5669 /// @dev Redeploying with the same salt must fail, not silently return the
5770 /// existing token.
5871 function test_RevertWhen_RedeployingWithSameSalt() public {
59- _deploy(SALT);
72+ _deploy(SALT, WETH);
6073
61- (bool ok,) = CREATE2_FACTORY.call(abi.encodePacked(SALT, type(Love).creationCode));
74+ (bool ok,) = CREATE2_FACTORY.call(abi.encodePacked(SALT, script.initCode(WETH)));
6275 assertFalse(ok);
6376 }
6477
65- function testFuzz_PredictionMatchesDeployment(bytes32 salt) public {
66- assertEq(script.predict(salt), _deploy(salt));
78+ function testFuzz_PredictionMatchesDeployment(bytes32 salt, address weth) public {
79+ assertEq(script.predict(salt, weth), _deploy(salt, weth));
6780 }
6881
69- function _deploy(bytes32 salt) internal returns (address deployed) {
70- (bool ok, bytes memory ret) = CREATE2_FACTORY.call(abi.encodePacked(salt, type(Love).creationCode));
82+ function _deploy(bytes32 salt, address weth) internal returns (address deployed) {
83+ (bool ok, bytes memory ret) = CREATE2_FACTORY.call(abi.encodePacked(salt, script.initCode(weth)));
7184 require(ok, "create2 deployment failed");
7285 // casting to 'bytes20' is safe because the deployer returns the 20-byte address, raw
7386 // forge-lint: disable-next-line(unsafe-typecast)
@@ -5,11 +5,13 @@ import {LoveScript} from "../script/Love.s.sol";
5 import {Love} from "../src/Love.sol";5 import {Love} from "../src/Love.sol";
6 import {Test} from "forge-std/Test.sol";6 import {Test} from "forge-std/Test.sol";
7 7
8-/// @notice The token is meant to live at the same address on every EVM network,8+/// @notice The deployment address derives from the salt, the creation code and
9-/// so these tests pin the CREATE2 deployment path and the inputs the9+/// the wETH constructor argument. These tests pin that derivation: same
10-/// address derives from.10+/// salt and same wETH give the same address on any chain, a different
11+/// wETH gives a different one.
11 contract LoveCreate2Test is Test {12 contract LoveCreate2Test is Test {
12 bytes32 constant SALT = keccak256("LOVE");13 bytes32 constant SALT = keccak256("LOVE");
14+ address constant WETH = 0x4200000000000000000000000000000000000006;
13 15
14 LoveScript script;16 LoveScript script;
15 17
@@ -21,53 +23,64 @@ contract LoveCreate2Test is Test {
21 }23 }
22 24
23 function test_DeploysAtPredictedAddress() public {25 function test_DeploysAtPredictedAddress() public {
24- address predicted = vm.computeCreate2Address(SALT, keccak256(type(Love).creationCode), CREATE2_FACTORY);26+ address predicted = vm.computeCreate2Address(SALT, keccak256(script.initCode(WETH)), CREATE2_FACTORY);
25 27
26- Love deployed = Love(_deploy(SALT));28+ Love deployed = Love(_deploy(SALT, WETH));
27 29
28 assertEq(address(deployed), predicted);30 assertEq(address(deployed), predicted);
29 assertEq(deployed.symbol(), "LOVE");31 assertEq(deployed.symbol(), "LOVE");
32+ assertEq(address(deployed.WETH()), WETH);
30 }33 }
31 34
32 /// @dev What the deploy script prints must be what the chain gives back.35 /// @dev What the deploy script prints must be what the chain gives back.
33 function test_ScriptPredictionMatchesDeployment() public {36 function test_ScriptPredictionMatchesDeployment() public {
34- assertEq(script.predict(SALT), _deploy(SALT));37+ assertEq(script.predict(SALT, WETH), _deploy(SALT, WETH));
35 }38 }
36 39
37 function test_ScriptUsesKeccakOfLoveAsDefaultSalt() public view {40 function test_ScriptUsesKeccakOfLoveAsDefaultSalt() public view {
38 assertEq(script.DEFAULT_SALT(), SALT);41 assertEq(script.DEFAULT_SALT(), SALT);
39 }42 }
40 43
41- /// @dev Same salt, same code, same address — that is the whole point.44+ function test_ScriptDefaultsToOpStackWeth() public view {
45+ assertEq(script.DEFAULT_WETH(), WETH);
46+ }
47+
48+ /// @dev Same salt, same code, same wETH, same address.
42 function test_PredictionIsChainAgnostic() public {49 function test_PredictionIsChainAgnostic() public {
43- address onThisChain = script.predict(SALT);50+ address onThisChain = script.predict(SALT, WETH);
44 51
45 vm.chainId(137);52 vm.chainId(137);
46- assertEq(script.predict(SALT), onThisChain);53+ assertEq(script.predict(SALT, WETH), onThisChain);
47 54
48 vm.chainId(42_161);55 vm.chainId(42_161);
49- assertEq(script.predict(SALT), onThisChain);56+ assertEq(script.predict(SALT, WETH), onThisChain);
50 }57 }
51 58
52 function test_DifferentSaltsGiveDifferentAddresses() public view {59 function test_DifferentSaltsGiveDifferentAddresses() public view {
53- assertTrue(script.predict(SALT) != script.predict(keccak256("LOVE2")));60+ assertTrue(script.predict(SALT, WETH) != script.predict(keccak256("LOVE2"), WETH));
61+ }
62+
63+ /// @dev The wETH address is part of the creation code, so chains with their
64+ /// own wETH get their own token address.
65+ function test_DifferentWethGivesDifferentAddress() public view {
66+ assertTrue(script.predict(SALT, WETH) != script.predict(SALT, address(0xBEEF)));
54 }67 }
55 68
56 /// @dev Redeploying with the same salt must fail, not silently return the69 /// @dev Redeploying with the same salt must fail, not silently return the
57 /// existing token.70 /// existing token.
58 function test_RevertWhen_RedeployingWithSameSalt() public {71 function test_RevertWhen_RedeployingWithSameSalt() public {
59- _deploy(SALT);72+ _deploy(SALT, WETH);
60 73
61- (bool ok,) = CREATE2_FACTORY.call(abi.encodePacked(SALT, type(Love).creationCode));74+ (bool ok,) = CREATE2_FACTORY.call(abi.encodePacked(SALT, script.initCode(WETH)));
62 assertFalse(ok);75 assertFalse(ok);
63 }76 }
64 77
65- function testFuzz_PredictionMatchesDeployment(bytes32 salt) public {78+ function testFuzz_PredictionMatchesDeployment(bytes32 salt, address weth) public {
66- assertEq(script.predict(salt), _deploy(salt));79+ assertEq(script.predict(salt, weth), _deploy(salt, weth));
67 }80 }
68 81
69- function _deploy(bytes32 salt) internal returns (address deployed) {82+ function _deploy(bytes32 salt, address weth) internal returns (address deployed) {
70- (bool ok, bytes memory ret) = CREATE2_FACTORY.call(abi.encodePacked(salt, type(Love).creationCode));83+ (bool ok, bytes memory ret) = CREATE2_FACTORY.call(abi.encodePacked(salt, script.initCode(weth)));
71 require(ok, "create2 deployment failed");84 require(ok, "create2 deployment failed");
72 // casting to 'bytes20' is safe because the deployer returns the 20-byte address, raw85 // casting to 'bytes20' is safe because the deployer returns the 20-byte address, raw
73 // forge-lint: disable-next-line(unsafe-typecast)86 // forge-lint: disable-next-line(unsafe-typecast)