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

Merge 3-peg-love-to-weth into main

rickub committed 2026-09-17T20:08:23Z Browse files
ce13509 parent: 7c6b16b
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 +56 -12
@@ -2,25 +2,41 @@
22 pragma solidity ^0.8.30;
33
44 import {Love} from "../src/Love.sol";
5+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
56 import {Script, console} from "forge-std/Script.sol";
67
8+/// @title LoveScript
9+/// @author Julien Béranger
710 /// @notice Deploys `Love` with CREATE2 through the canonical deterministic
8-/// deployer, so the token gets the same address on every EVM network.
9-/// The address depends only on the salt and the creation code — keep
11+/// deployer. The address depends on the salt and the creation code, and
12+/// the creation code embeds the wETH address — so the token only gets
13+/// the same address on chains where wETH sits at the same address. Keep
1014 /// `solc`, the optimizer settings and `bytecode_hash = "none"` as they
11-/// are in `foundry.toml`, or the address changes.
15+/// are in `foundry.toml`, or the address changes too.
1216 contract LoveScript is Script {
17+ /// @notice Salt used when `SALT` is not set in the environment.
1318 bytes32 public constant DEFAULT_SALT = keccak256("LOVE");
1419
20+ /// @notice wETH address used when `WETH` is not set in the environment.
21+ /// @dev wETH on Base, Optimism and every other OP-Stack chain.
22+ address public constant DEFAULT_WETH = 0x4200000000000000000000000000000000000006;
23+
24+ /// @notice The token, once `run()` has deployed or found it.
1525 Love public love;
1626
27+ /// @notice Deploy `Love`, or return it untouched if it is already there.
28+ /// @dev Reads `SALT` and `WETH` from the environment, falling back to the
29+ /// defaults, and asserts the deployed address matches the prediction.
30+ /// @return The deployed token.
1731 function run() public returns (Love) {
1832 bytes32 s = salt();
19- address predicted = predict(s);
33+ address w = wethAddress();
34+ address predicted = predict(s, w);
2035
2136 console.log("deployer ", CREATE2_FACTORY);
2237 console.log("salt ", vm.toString(s));
23- console.log("initCodeHash ", vm.toString(initCodeHash()));
38+ console.log("weth ", w);
39+ console.log("initCodeHash ", vm.toString(initCodeHash(w)));
2440 console.log("predicted ", predicted);
2541
2642 if (predicted.code.length > 0) {
@@ -30,7 +46,7 @@ contract LoveScript is Script {
3046 }
3147
3248 vm.startBroadcast();
33- love = new Love{salt: s}();
49+ love = new Love{salt: s}(IERC20(w));
3450 vm.stopBroadcast();
3551
3652 require(address(love) == predicted, "LoveScript: address mismatch");
@@ -38,31 +54,59 @@ contract LoveScript is Script {
3854 console.log("Love deployed at", address(love));
3955 console.log("name ", love.name());
4056 console.log("symbol ", love.symbol());
57+ console.log("weth ", address(love.WETH()));
58+ console.log("rate ", love.RATE());
4159 console.log("totalSupply ", love.totalSupply());
4260
4361 return love;
4462 }
4563
4664 /// @notice Print the address `run()` would deploy to, without broadcasting.
65+ /// @return predicted The address the current salt and wETH derive to.
4766 function predict() public view returns (address predicted) {
4867 bytes32 s = salt();
49- predicted = predict(s);
68+ address w = wethAddress();
69+ predicted = predict(s, w);
5070
5171 console.log("salt ", vm.toString(s));
52- console.log("initCodeHash ", vm.toString(initCodeHash()));
72+ console.log("weth ", w);
73+ console.log("initCodeHash ", vm.toString(initCodeHash(w)));
5374 console.log("predicted ", predicted);
5475 }
5576
56- function predict(bytes32 s) public pure returns (address) {
57- return vm.computeCreate2Address(s, initCodeHash(), CREATE2_FACTORY);
77+ /// @notice Derive the deployment address from a salt and a wETH address.
78+ /// @param s The CREATE2 salt.
79+ /// @param w The wETH the token would be pegged to.
80+ /// @return The address `Love` would land at.
81+ function predict(bytes32 s, address w) public pure returns (address) {
82+ return vm.computeCreate2Address(s, initCodeHash(w), CREATE2_FACTORY);
83+ }
84+
85+ /// @notice The creation code CREATE2 is handed, constructor argument included.
86+ /// @param w The wETH the token would be pegged to.
87+ /// @return The creation code, with `w` ABI-encoded onto it.
88+ function initCode(address w) public pure returns (bytes memory) {
89+ return abi.encodePacked(type(Love).creationCode, abi.encode(w));
5890 }
5991
60- function initCodeHash() public pure returns (bytes32) {
61- return keccak256(type(Love).creationCode);
92+ /// @notice Hash of the creation code, the second input to the address derivation.
93+ /// @param w The wETH the token would be pegged to.
94+ /// @return The keccak256 of `initCode(w)`.
95+ function initCodeHash(address w) public pure returns (bytes32) {
96+ return keccak256(initCode(w));
6297 }
6398
99+ /// @notice The salt to deploy with.
64100 /// @dev `SALT` overrides the default, e.g. to mine a vanity address.
101+ /// @return The configured salt, or `DEFAULT_SALT`.
65102 function salt() public view returns (bytes32) {
66103 return vm.envOr("SALT", DEFAULT_SALT);
67104 }
105+
106+ /// @notice The wETH address to peg the deployment to.
107+ /// @dev `WETH` overrides the default, which only holds on OP-Stack chains.
108+ /// @return The configured wETH address, or `DEFAULT_WETH`.
109+ function wethAddress() public view returns (address) {
110+ return vm.envOr("WETH", DEFAULT_WETH);
111+ }
68112 }
@@ -2,25 +2,41 @@
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 {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5 import {Script, console} from "forge-std/Script.sol";6 import {Script, console} from "forge-std/Script.sol";
6 7
8+/// @title LoveScript
9+/// @author Julien Béranger
7 /// @notice Deploys `Love` with CREATE2 through the canonical deterministic10 /// @notice Deploys `Love` with CREATE2 through the canonical deterministic
8-/// deployer, so the token gets the same address on every EVM network.11+/// deployer. The address depends on the salt and the creation code, and
9-/// The address depends only on the salt and the creation code — keep12+/// the creation code embeds the wETH address — so the token only gets
13+/// the same address on chains where wETH sits at the same address. Keep
10 /// `solc`, the optimizer settings and `bytecode_hash = "none"` as they14 /// `solc`, the optimizer settings and `bytecode_hash = "none"` as they
11-/// are in `foundry.toml`, or the address changes.15+/// are in `foundry.toml`, or the address changes too.
12 contract LoveScript is Script {16 contract LoveScript is Script {
17+ /// @notice Salt used when `SALT` is not set in the environment.
13 bytes32 public constant DEFAULT_SALT = keccak256("LOVE");18 bytes32 public constant DEFAULT_SALT = keccak256("LOVE");
14 19
20+ /// @notice wETH address used when `WETH` is not set in the environment.
21+ /// @dev wETH on Base, Optimism and every other OP-Stack chain.
22+ address public constant DEFAULT_WETH = 0x4200000000000000000000000000000000000006;
23+
24+ /// @notice The token, once `run()` has deployed or found it.
15 Love public love;25 Love public love;
16 26
27+ /// @notice Deploy `Love`, or return it untouched if it is already there.
28+ /// @dev Reads `SALT` and `WETH` from the environment, falling back to the
29+ /// defaults, and asserts the deployed address matches the prediction.
30+ /// @return The deployed token.
17 function run() public returns (Love) {31 function run() public returns (Love) {
18 bytes32 s = salt();32 bytes32 s = salt();
19- address predicted = predict(s);33+ address w = wethAddress();
34+ address predicted = predict(s, w);
20 35
21 console.log("deployer ", CREATE2_FACTORY);36 console.log("deployer ", CREATE2_FACTORY);
22 console.log("salt ", vm.toString(s));37 console.log("salt ", vm.toString(s));
23- console.log("initCodeHash ", vm.toString(initCodeHash()));38+ console.log("weth ", w);
39+ console.log("initCodeHash ", vm.toString(initCodeHash(w)));
24 console.log("predicted ", predicted);40 console.log("predicted ", predicted);
25 41
26 if (predicted.code.length > 0) {42 if (predicted.code.length > 0) {
@@ -30,7 +46,7 @@ contract LoveScript is Script {
30 }46 }
31 47
32 vm.startBroadcast();48 vm.startBroadcast();
33- love = new Love{salt: s}();49+ love = new Love{salt: s}(IERC20(w));
34 vm.stopBroadcast();50 vm.stopBroadcast();
35 51
36 require(address(love) == predicted, "LoveScript: address mismatch");52 require(address(love) == predicted, "LoveScript: address mismatch");
@@ -38,31 +54,59 @@ contract LoveScript is Script {
38 console.log("Love deployed at", address(love));54 console.log("Love deployed at", address(love));
39 console.log("name ", love.name());55 console.log("name ", love.name());
40 console.log("symbol ", love.symbol());56 console.log("symbol ", love.symbol());
57+ console.log("weth ", address(love.WETH()));
58+ console.log("rate ", love.RATE());
41 console.log("totalSupply ", love.totalSupply());59 console.log("totalSupply ", love.totalSupply());
42 60
43 return love;61 return love;
44 }62 }
45 63
46 /// @notice Print the address `run()` would deploy to, without broadcasting.64 /// @notice Print the address `run()` would deploy to, without broadcasting.
65+ /// @return predicted The address the current salt and wETH derive to.
47 function predict() public view returns (address predicted) {66 function predict() public view returns (address predicted) {
48 bytes32 s = salt();67 bytes32 s = salt();
49- predicted = predict(s);68+ address w = wethAddress();
69+ predicted = predict(s, w);
50 70
51 console.log("salt ", vm.toString(s));71 console.log("salt ", vm.toString(s));
52- console.log("initCodeHash ", vm.toString(initCodeHash()));72+ console.log("weth ", w);
73+ console.log("initCodeHash ", vm.toString(initCodeHash(w)));
53 console.log("predicted ", predicted);74 console.log("predicted ", predicted);
54 }75 }
55 76
56- function predict(bytes32 s) public pure returns (address) {77+ /// @notice Derive the deployment address from a salt and a wETH address.
57- return vm.computeCreate2Address(s, initCodeHash(), CREATE2_FACTORY);78+ /// @param s The CREATE2 salt.
79+ /// @param w The wETH the token would be pegged to.
80+ /// @return The address `Love` would land at.
81+ function predict(bytes32 s, address w) public pure returns (address) {
82+ return vm.computeCreate2Address(s, initCodeHash(w), CREATE2_FACTORY);
83+ }
84+
85+ /// @notice The creation code CREATE2 is handed, constructor argument included.
86+ /// @param w The wETH the token would be pegged to.
87+ /// @return The creation code, with `w` ABI-encoded onto it.
88+ function initCode(address w) public pure returns (bytes memory) {
89+ return abi.encodePacked(type(Love).creationCode, abi.encode(w));
58 }90 }
59 91
60- function initCodeHash() public pure returns (bytes32) {92+ /// @notice Hash of the creation code, the second input to the address derivation.
61- return keccak256(type(Love).creationCode);93+ /// @param w The wETH the token would be pegged to.
94+ /// @return The keccak256 of `initCode(w)`.
95+ function initCodeHash(address w) public pure returns (bytes32) {
96+ return keccak256(initCode(w));
62 }97 }
63 98
99+ /// @notice The salt to deploy with.
64 /// @dev `SALT` overrides the default, e.g. to mine a vanity address.100 /// @dev `SALT` overrides the default, e.g. to mine a vanity address.
101+ /// @return The configured salt, or `DEFAULT_SALT`.
65 function salt() public view returns (bytes32) {102 function salt() public view returns (bytes32) {
66 return vm.envOr("SALT", DEFAULT_SALT);103 return vm.envOr("SALT", DEFAULT_SALT);
67 }104 }
105+
106+ /// @notice The wETH address to peg the deployment to.
107+ /// @dev `WETH` overrides the default, which only holds on OP-Stack chains.
108+ /// @return The configured wETH address, or `DEFAULT_WETH`.
109+ function wethAddress() public view returns (address) {
110+ return vm.envOr("WETH", DEFAULT_WETH);
111+ }
68 }112 }
modified script/multichain-check.sh +7 -4
@@ -1,11 +1,13 @@
11 #!/usr/bin/env bash
22 #
33 # Deploys Love on two local anvil nodes with different chain ids and checks
4-# that CREATE2 gives the token the same address on both.
4+# that CREATE2 gives the token the same address on both. The address embeds the
5+# wETH address, so this only holds for chains sharing one wETH deployment —
6+# both nodes here are given the same WETH.
57 #
68 # Usage: ./script/multichain-check.sh
79 #
8-# Env overrides: PORT_A, PORT_B, CHAIN_ID_A, CHAIN_ID_B, SALT
10+# Env overrides: PORT_A, PORT_B, CHAIN_ID_A, CHAIN_ID_B, SALT, WETH
911
1012 set -euo pipefail
1113
@@ -77,10 +79,11 @@ report() {
7779 exit 1
7880 fi
7981
80- printf '%s %s %s bytes %s / %s\n' \
82+ printf '%s %s %s bytes %s / %s weth %s\n' \
8183 "$label" "$addr" "$(((${#code} - 2) / 2))" \
8284 "$(cast call "$addr" 'name()(string)' --rpc-url "$rpc")" \
83- "$(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")"
8487 }
8588
8689 cd "$ROOT"
@@ -1,11 +1,13 @@
1 #!/usr/bin/env bash1 #!/usr/bin/env bash
2 #2 #
3 # Deploys Love on two local anvil nodes with different chain ids and checks3 # Deploys Love on two local anvil nodes with different chain ids and checks
4-# that CREATE2 gives the token the same address on both.4+# that CREATE2 gives the token the same address on both. The address embeds the
5+# wETH address, so this only holds for chains sharing one wETH deployment —
6+# both nodes here are given the same WETH.
5 #7 #
6 # Usage: ./script/multichain-check.sh8 # Usage: ./script/multichain-check.sh
7 #9 #
8-# Env overrides: PORT_A, PORT_B, CHAIN_ID_A, CHAIN_ID_B, SALT10+# Env overrides: PORT_A, PORT_B, CHAIN_ID_A, CHAIN_ID_B, SALT, WETH
9 11
10 set -euo pipefail12 set -euo pipefail
11 13
@@ -77,10 +79,11 @@ report() {
77 exit 179 exit 1
78 fi80 fi
79 81
80- printf '%s %s %s bytes %s / %s\n' \82+ printf '%s %s %s bytes %s / %s weth %s\n' \
81 "$label" "$addr" "$(((${#code} - 2) / 2))" \83 "$label" "$addr" "$(((${#code} - 2) / 2))" \
82 "$(cast call "$addr" 'name()(string)' --rpc-url "$rpc")" \84 "$(cast call "$addr" 'name()(string)' --rpc-url "$rpc")" \
83- "$(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")"
84 }87 }
85 88
86 cd "$ROOT"89 cd "$ROOT"
modified src/Love.sol +71 -7
@@ -2,16 +2,80 @@
22 pragma solidity ^0.8.30;
33
44 import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
6+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
57
68 /// @title Love
7-/// @notice A basic ERC-20 with an unrestricted mint: anyone can mint any amount
8-/// to any address. Supply is therefore unbounded and meaningless — this
9-/// is a faucet/demo token, not a token with economic value.
9+/// @author Julien Béranger
10+/// @notice An ERC-20 pegged to wETH at a fixed rate of 100000 LOVE per wETH.
11+/// Every LOVE in circulation is backed by wETH held by this contract:
12+/// supply only moves through `deposit` and `withdraw`, and both are
13+/// permissionless.
14+/// @dev There is no mint entrypoint, no owner and no upgrade path, so the peg
15+/// cannot be diluted. `totalSupply() == WETH.balanceOf(address(this)) * RATE`
16+/// holds after every call; wETH transferred straight to this contract
17+/// raises the backing and is not redeemable.
1018 contract Love is ERC20 {
11- constructor() ERC20("Love", "LOVE") {}
19+ using SafeERC20 for IERC20;
1220
13- /// @notice Mint `amount` tokens to `to`. Callable by anyone, without limit.
14- function mint(address to, uint256 amount) external {
15- _mint(to, amount);
21+ /// @notice LOVE minted per unit of wETH, and burned per unit released.
22+ uint256 public constant RATE = 100_000;
23+
24+ /// @notice The wETH this token is pegged to and collateralised with.
25+ /// @dev Immutable, and part of the creation code — two chains only give this
26+ /// contract the same CREATE2 address if they share a wETH address.
27+ IERC20 public immutable WETH;
28+
29+ /// @notice Thrown when a withdrawal amount is not a multiple of `RATE`.
30+ /// @param loveAmount The rejected LOVE amount.
31+ /// @param rate The rate it has to be a multiple of.
32+ error AmountNotDivisibleByRate(uint256 loveAmount, uint256 rate);
33+
34+ /// @notice Emitted when wETH is locked and LOVE minted.
35+ /// @param account The depositor, who pays the wETH and receives the LOVE.
36+ /// @param wethAmount The wETH pulled in.
37+ /// @param loveAmount The LOVE minted, `wethAmount * RATE`.
38+ event Deposit(address indexed account, uint256 wethAmount, uint256 loveAmount);
39+
40+ /// @notice Emitted when LOVE is burned and wETH released.
41+ /// @param account The redeemer, who burns the LOVE and receives the wETH.
42+ /// @param loveAmount The LOVE burned.
43+ /// @param wethAmount The wETH released, `loveAmount / RATE`.
44+ event Withdraw(address indexed account, uint256 loveAmount, uint256 wethAmount);
45+
46+ /// @param weth_ The wETH to peg to. Set once, never changed.
47+ constructor(IERC20 weth_) ERC20("Love", "LOVE") {
48+ WETH = weth_;
49+ }
50+
51+ /// @notice Lock `wethAmount` wETH and mint `wethAmount * RATE` LOVE to the caller.
52+ /// @dev The caller must have approved this contract for `wethAmount` first.
53+ /// Reverts on overflow of `wethAmount * RATE`, and on the wETH transfer
54+ /// failing for want of balance or allowance.
55+ /// @param wethAmount The wETH to lock, in wei.
56+ function deposit(uint256 wethAmount) external {
57+ uint256 loveAmount = wethAmount * RATE;
58+
59+ WETH.safeTransferFrom(msg.sender, address(this), wethAmount);
60+ _mint(msg.sender, loveAmount);
61+
62+ emit Deposit(msg.sender, wethAmount, loveAmount);
63+ }
64+
65+ /// @notice Burn `loveAmount` LOVE and release `loveAmount / RATE` wETH to the caller.
66+ /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a
67+ /// multiple of `RATE`, so the peg never rounds against the caller or
68+ /// the remaining holders. LOVE is burned before the wETH leaves, and
69+ /// the burn already caps the amount at the caller's balance.
70+ /// @param loveAmount The LOVE to burn, a multiple of `RATE`.
71+ function withdraw(uint256 loveAmount) external {
72+ if (loveAmount % RATE != 0) revert AmountNotDivisibleByRate(loveAmount, RATE);
73+
74+ uint256 wethAmount = loveAmount / RATE;
75+
76+ _burn(msg.sender, loveAmount);
77+ WETH.safeTransfer(msg.sender, wethAmount);
78+
79+ emit Withdraw(msg.sender, loveAmount, wethAmount);
1680 }
1781 }
@@ -2,16 +2,80 @@
2 pragma solidity ^0.8.30;2 pragma solidity ^0.8.30;
3 3
4 import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";4 import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
6+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
5 7
6 /// @title Love8 /// @title Love
7-/// @notice A basic ERC-20 with an unrestricted mint: anyone can mint any amount9+/// @author Julien Béranger
8-/// to any address. Supply is therefore unbounded and meaningless — this10+/// @notice An ERC-20 pegged to wETH at a fixed rate of 100000 LOVE per wETH.
9-/// is a faucet/demo token, not a token with economic value.11+/// Every LOVE in circulation is backed by wETH held by this contract:
12+/// supply only moves through `deposit` and `withdraw`, and both are
13+/// permissionless.
14+/// @dev There is no mint entrypoint, no owner and no upgrade path, so the peg
15+/// cannot be diluted. `totalSupply() == WETH.balanceOf(address(this)) * RATE`
16+/// holds after every call; wETH transferred straight to this contract
17+/// raises the backing and is not redeemable.
10 contract Love is ERC20 {18 contract Love is ERC20 {
11- constructor() ERC20("Love", "LOVE") {}19+ using SafeERC20 for IERC20;
12 20
13- /// @notice Mint `amount` tokens to `to`. Callable by anyone, without limit.21+ /// @notice LOVE minted per unit of wETH, and burned per unit released.
14- function mint(address to, uint256 amount) external {22+ uint256 public constant RATE = 100_000;
15- _mint(to, amount);23+
24+ /// @notice The wETH this token is pegged to and collateralised with.
25+ /// @dev Immutable, and part of the creation code — two chains only give this
26+ /// contract the same CREATE2 address if they share a wETH address.
27+ IERC20 public immutable WETH;
28+
29+ /// @notice Thrown when a withdrawal amount is not a multiple of `RATE`.
30+ /// @param loveAmount The rejected LOVE amount.
31+ /// @param rate The rate it has to be a multiple of.
32+ error AmountNotDivisibleByRate(uint256 loveAmount, uint256 rate);
33+
34+ /// @notice Emitted when wETH is locked and LOVE minted.
35+ /// @param account The depositor, who pays the wETH and receives the LOVE.
36+ /// @param wethAmount The wETH pulled in.
37+ /// @param loveAmount The LOVE minted, `wethAmount * RATE`.
38+ event Deposit(address indexed account, uint256 wethAmount, uint256 loveAmount);
39+
40+ /// @notice Emitted when LOVE is burned and wETH released.
41+ /// @param account The redeemer, who burns the LOVE and receives the wETH.
42+ /// @param loveAmount The LOVE burned.
43+ /// @param wethAmount The wETH released, `loveAmount / RATE`.
44+ event Withdraw(address indexed account, uint256 loveAmount, uint256 wethAmount);
45+
46+ /// @param weth_ The wETH to peg to. Set once, never changed.
47+ constructor(IERC20 weth_) ERC20("Love", "LOVE") {
48+ WETH = weth_;
49+ }
50+
51+ /// @notice Lock `wethAmount` wETH and mint `wethAmount * RATE` LOVE to the caller.
52+ /// @dev The caller must have approved this contract for `wethAmount` first.
53+ /// Reverts on overflow of `wethAmount * RATE`, and on the wETH transfer
54+ /// failing for want of balance or allowance.
55+ /// @param wethAmount The wETH to lock, in wei.
56+ function deposit(uint256 wethAmount) external {
57+ uint256 loveAmount = wethAmount * RATE;
58+
59+ WETH.safeTransferFrom(msg.sender, address(this), wethAmount);
60+ _mint(msg.sender, loveAmount);
61+
62+ emit Deposit(msg.sender, wethAmount, loveAmount);
63+ }
64+
65+ /// @notice Burn `loveAmount` LOVE and release `loveAmount / RATE` wETH to the caller.
66+ /// @dev Reverts with `AmountNotDivisibleByRate` unless `loveAmount` is a
67+ /// multiple of `RATE`, so the peg never rounds against the caller or
68+ /// the remaining holders. LOVE is burned before the wETH leaves, and
69+ /// the burn already caps the amount at the caller's balance.
70+ /// @param loveAmount The LOVE to burn, a multiple of `RATE`.
71+ function withdraw(uint256 loveAmount) external {
72+ if (loveAmount % RATE != 0) revert AmountNotDivisibleByRate(loveAmount, RATE);
73+
74+ uint256 wethAmount = loveAmount / RATE;
75+
76+ _burn(msg.sender, loveAmount);
77+ WETH.safeTransfer(msg.sender, wethAmount);
78+
79+ emit Withdraw(msg.sender, loveAmount, wethAmount);
16 }80 }
17 }81 }
modified test/Love.t.sol +271 -50
@@ -2,19 +2,34 @@
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
10+/// @title LoveTest
11+/// @notice Exercises the peg end to end against a WETH9 stand-in: minting on
12+/// deposit, redemption on withdraw, the divisibility rule, and the
13+/// invariant that supply always equals the wETH backing times the rate.
914 contract LoveTest is Test {
15+ /// @notice The token under test.
1016 Love public love;
1117
18+ /// @notice The wETH it is pegged to.
19+ MockWETH public weth;
20+
1221 address alice = makeAddr("alice");
1322 address bob = makeAddr("bob");
1423 address stranger = makeAddr("stranger");
1524
25+ uint256 constant RATE = 100_000;
26+
1627 function setUp() public {
17- love = new Love();
28+ weth = new MockWETH();
29+ love = new Love(IERC20(address(weth)));
30+
31+ _fund(alice, 10 ether);
32+ _fund(bob, 10 ether);
1833 }
1934
2035 /*//////////////////////////////////////////////////////////////
@@ -31,56 +46,191 @@ contract LoveTest is Test {
3146 assertEq(love.totalSupply(), 0);
3247 }
3348
49+ function test_PegParameters() public view {
50+ assertEq(love.RATE(), RATE);
51+ assertEq(address(love.WETH()), address(weth));
52+ }
53+
54+ /// @dev No mint entrypoint may survive: supply moves only through the peg.
55+ function test_NoMintFunction() public view {
56+ assertEq(address(love).code.length > 0, true);
57+ (bool ok,) = address(love).staticcall(abi.encodeWithSignature("mint(address,uint256)", alice, 1e18));
58+ assertFalse(ok);
59+ }
60+
3461 /*//////////////////////////////////////////////////////////////
35- MINT
62+ DEPOSIT
3663 //////////////////////////////////////////////////////////////*/
3764
38- function test_Mint() public {
39- vm.prank(alice);
40- love.mint(alice, 100e18);
65+ function test_Deposit() public {
66+ _deposit(alice, 1 ether);
4167
42- assertEq(love.balanceOf(alice), 100e18);
43- assertEq(love.totalSupply(), 100e18);
68+ assertEq(love.balanceOf(alice), 1 ether * RATE);
69+ assertEq(love.totalSupply(), 1 ether * RATE);
70+ assertEq(weth.balanceOf(address(love)), 1 ether);
71+ assertEq(weth.balanceOf(alice), 9 ether);
4472 }
4573
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);
74+ function test_DepositIsPermissionless() public {
75+ _fund(stranger, 1 ether);
76+ _deposit(stranger, 1 ether);
5077
51- assertEq(love.balanceOf(bob), 1000e18);
52- assertEq(love.balanceOf(stranger), 0);
53- assertEq(love.totalSupply(), 1000e18);
78+ assertEq(love.balanceOf(stranger), 1 ether * RATE);
5479 }
5580
56- function test_MintEmitsTransferFromZero() public {
81+ function test_DepositEmitsDeposit() public {
82+ vm.prank(alice);
83+ weth.approve(address(love), 2 ether);
84+
85+ vm.expectEmit(true, false, false, true);
86+ emit Love.Deposit(alice, 2 ether, 2 ether * RATE);
87+
88+ vm.prank(alice);
89+ love.deposit(2 ether);
90+ }
91+
92+ function test_DepositEmitsTransferFromZero() public {
93+ vm.prank(alice);
94+ weth.approve(address(love), 1 ether);
95+
5796 vm.expectEmit(true, true, false, true);
58- emit IERC20.Transfer(address(0), alice, 5e18);
97+ emit IERC20.Transfer(address(0), alice, 1 ether * RATE);
5998
6099 vm.prank(alice);
61- love.mint(alice, 5e18);
100+ love.deposit(1 ether);
62101 }
63102
64- function test_MintAccumulates() public {
65- vm.startPrank(alice);
66- love.mint(alice, 1e18);
67- love.mint(alice, 2e18);
68- vm.stopPrank();
103+ function test_DepositAccumulates() public {
104+ _deposit(alice, 1 ether);
105+ _deposit(alice, 3 ether);
69106
70- assertEq(love.balanceOf(alice), 3e18);
71- assertEq(love.totalSupply(), 3e18);
107+ assertEq(love.balanceOf(alice), 4 ether * RATE);
108+ assertEq(weth.balanceOf(address(love)), 4 ether);
72109 }
73110
74- function test_RevertWhen_MintingToZeroAddress() public {
75- vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidReceiver.selector, address(0)));
76- love.mint(address(0), 1e18);
111+ function test_DepositZero() public {
112+ _deposit(alice, 0);
113+
114+ assertEq(love.balanceOf(alice), 0);
115+ assertEq(love.totalSupply(), 0);
77116 }
78117
79- function test_RevertWhen_MintOverflowsTotalSupply() public {
80- love.mint(alice, type(uint256).max);
118+ function test_RevertWhen_DepositWithoutApproval() public {
119+ vm.prank(alice);
120+ vm.expectRevert(
121+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, address(love), 0, 1 ether)
122+ );
123+ love.deposit(1 ether);
124+ }
125+
126+ function test_RevertWhen_DepositExceedsWethBalance() public {
127+ vm.prank(alice);
128+ weth.approve(address(love), 100 ether);
129+
130+ vm.prank(alice);
131+ vm.expectRevert(
132+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 10 ether, 100 ether)
133+ );
134+ love.deposit(100 ether);
135+ }
81136
137+ function test_RevertWhen_DepositOverflows() public {
138+ vm.prank(alice);
82139 vm.expectRevert();
83- love.mint(bob, 1);
140+ love.deposit(type(uint256).max);
141+ }
142+
143+ /*//////////////////////////////////////////////////////////////
144+ WITHDRAW
145+ //////////////////////////////////////////////////////////////*/
146+
147+ function test_Withdraw() public {
148+ _deposit(alice, 1 ether);
149+
150+ vm.prank(alice);
151+ love.withdraw(1 ether * RATE);
152+
153+ assertEq(love.balanceOf(alice), 0);
154+ assertEq(love.totalSupply(), 0);
155+ assertEq(weth.balanceOf(alice), 10 ether);
156+ assertEq(weth.balanceOf(address(love)), 0);
157+ }
158+
159+ function test_PartialWithdraw() public {
160+ _deposit(alice, 1 ether);
161+
162+ vm.prank(alice);
163+ love.withdraw(0.25 ether * RATE);
164+
165+ assertEq(love.balanceOf(alice), 0.75 ether * RATE);
166+ assertEq(weth.balanceOf(alice), 9.25 ether);
167+ assertEq(weth.balanceOf(address(love)), 0.75 ether);
168+ }
169+
170+ function test_WithdrawEmitsWithdraw() public {
171+ _deposit(alice, 1 ether);
172+
173+ vm.expectEmit(true, false, false, true);
174+ emit Love.Withdraw(alice, 1 ether * RATE, 1 ether);
175+
176+ vm.prank(alice);
177+ love.withdraw(1 ether * RATE);
178+ }
179+
180+ /// @dev LOVE received from someone else is redeemable all the same.
181+ function test_WithdrawAfterTransfer() public {
182+ _deposit(alice, 1 ether);
183+
184+ vm.prank(alice);
185+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
186+ love.transfer(bob, 1 ether * RATE);
187+
188+ vm.prank(bob);
189+ love.withdraw(1 ether * RATE);
190+
191+ assertEq(weth.balanceOf(bob), 11 ether);
192+ assertEq(love.totalSupply(), 0);
193+ }
194+
195+ function test_WithdrawZero() public {
196+ _deposit(alice, 1 ether);
197+
198+ vm.prank(alice);
199+ love.withdraw(0);
200+
201+ assertEq(love.balanceOf(alice), 1 ether * RATE);
202+ }
203+
204+ /// @dev Anything not a multiple of the rate would round wETH out of the peg.
205+ function test_RevertWhen_WithdrawNotDivisibleByRate() public {
206+ _deposit(alice, 1 ether);
207+
208+ vm.prank(alice);
209+ vm.expectRevert(abi.encodeWithSelector(Love.AmountNotDivisibleByRate.selector, RATE + 1, RATE));
210+ love.withdraw(RATE + 1);
211+ }
212+
213+ function test_RevertWhen_WithdrawExceedsBalance() public {
214+ _deposit(alice, 1 ether);
215+
216+ vm.prank(alice);
217+ vm.expectRevert(
218+ abi.encodeWithSelector(
219+ IERC20Errors.ERC20InsufficientBalance.selector, alice, 1 ether * RATE, 2 ether * RATE
220+ )
221+ );
222+ love.withdraw(2 ether * RATE);
223+ }
224+
225+ /// @dev One holder cannot redeem against another holder's collateral.
226+ function test_RevertWhen_WithdrawWithoutDepositing() public {
227+ _deposit(alice, 1 ether);
228+
229+ vm.prank(stranger);
230+ vm.expectRevert(
231+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, stranger, 0, 1 ether * RATE)
232+ );
233+ love.withdraw(1 ether * RATE);
84234 }
85235
86236 /*//////////////////////////////////////////////////////////////
@@ -88,20 +238,18 @@ contract LoveTest is Test {
88238 //////////////////////////////////////////////////////////////*/
89239
90240 function test_Transfer() public {
91- love.mint(alice, 10e18);
241+ _deposit(alice, 1 ether);
92242
93243 vm.prank(alice);
94244 assertTrue(love.transfer(bob, 4e18));
95245
96- assertEq(love.balanceOf(alice), 6e18);
246+ assertEq(love.balanceOf(alice), 1 ether * RATE - 4e18);
97247 assertEq(love.balanceOf(bob), 4e18);
98248 }
99249
100250 function test_RevertWhen_TransferExceedsBalance() public {
101- love.mint(alice, 1e18);
102-
103251 vm.prank(alice);
104- vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 1e18, 2e18));
252+ vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, 2e18));
105253 // forge-lint: disable-next-line(erc20-unchecked-transfer)
106254 love.transfer(bob, 2e18);
107255 }
@@ -111,7 +259,7 @@ contract LoveTest is Test {
111259 //////////////////////////////////////////////////////////////*/
112260
113261 function test_ApproveAndTransferFrom() public {
114- love.mint(alice, 10e18);
262+ _deposit(alice, 1 ether);
115263
116264 vm.prank(alice);
117265 assertTrue(love.approve(bob, 6e18));
@@ -120,13 +268,13 @@ contract LoveTest is Test {
120268 vm.prank(bob);
121269 assertTrue(love.transferFrom(alice, stranger, 6e18));
122270
123- assertEq(love.balanceOf(alice), 4e18);
271+ assertEq(love.balanceOf(alice), 1 ether * RATE - 6e18);
124272 assertEq(love.balanceOf(stranger), 6e18);
125273 assertEq(love.allowance(alice, bob), 0);
126274 }
127275
128276 function test_RevertWhen_TransferFromExceedsAllowance() public {
129- love.mint(alice, 10e18);
277+ _deposit(alice, 1 ether);
130278
131279 vm.prank(alice);
132280 love.approve(bob, 1e18);
@@ -137,29 +285,102 @@ contract LoveTest is Test {
137285 love.transferFrom(alice, stranger, 2e18);
138286 }
139287
288+ /*//////////////////////////////////////////////////////////////
289+ PEG
290+ //////////////////////////////////////////////////////////////*/
291+
292+ function test_RoundTripIsLossless() public {
293+ uint256 before = weth.balanceOf(alice);
294+
295+ _deposit(alice, 3 ether);
296+ vm.prank(alice);
297+ love.withdraw(3 ether * RATE);
298+
299+ assertEq(weth.balanceOf(alice), before);
300+ assertEq(love.balanceOf(alice), 0);
301+ assertEq(love.totalSupply(), 0);
302+ }
303+
304+ function test_SupplyStaysFullyBacked() public {
305+ _deposit(alice, 1 ether);
306+ _deposit(bob, 2 ether);
307+
308+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
309+
310+ vm.prank(alice);
311+ love.withdraw(0.5 ether * RATE);
312+
313+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
314+ }
315+
316+ /// @dev wETH sent straight to the contract is not claimable — it only lifts
317+ /// the backing, it never mints LOVE.
318+ function test_DonatedWethDoesNotMint() public {
319+ _deposit(alice, 1 ether);
320+
321+ vm.prank(bob);
322+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
323+ weth.transfer(address(love), 5 ether);
324+
325+ assertEq(love.totalSupply(), 1 ether * RATE);
326+ assertEq(weth.balanceOf(address(love)), 6 ether);
327+ }
328+
140329 /*//////////////////////////////////////////////////////////////
141330 FUZZ
142331 //////////////////////////////////////////////////////////////*/
143332
144- function testFuzz_MintByAnyCaller(address caller, address to, uint256 amount) public {
145- vm.assume(to != address(0));
333+ function testFuzz_DepositByAnyCaller(address caller, uint256 wethAmount) public {
334+ vm.assume(caller != address(0) && caller != address(love) && caller != address(weth));
335+ wethAmount = bound(wethAmount, 0, type(uint256).max / RATE);
146336
147- vm.prank(caller);
148- love.mint(to, amount);
337+ _fund(caller, wethAmount);
338+ _deposit(caller, wethAmount);
149339
150- assertEq(love.balanceOf(to), amount);
151- assertEq(love.totalSupply(), amount);
340+ assertEq(love.balanceOf(caller), wethAmount * RATE);
341+ assertEq(love.totalSupply(), wethAmount * RATE);
342+ assertEq(weth.balanceOf(address(love)), wethAmount);
152343 }
153344
154- function testFuzz_Transfer(uint256 minted, uint256 amount) public {
155- amount = bound(amount, 0, minted);
345+ function testFuzz_RoundTrip(uint256 wethAmount) public {
346+ wethAmount = bound(wethAmount, 0, type(uint256).max / RATE);
156347
157- love.mint(alice, minted);
348+ _fund(alice, wethAmount);
349+ uint256 before = weth.balanceOf(alice);
158350
351+ _deposit(alice, wethAmount);
159352 vm.prank(alice);
160- assertTrue(love.transfer(bob, amount));
353+ love.withdraw(wethAmount * RATE);
354+
355+ assertEq(weth.balanceOf(alice), before);
356+ assertEq(love.totalSupply(), 0);
357+ }
358+
359+ function testFuzz_WithdrawRevertsUnlessDivisible(uint256 loveAmount) public {
360+ _deposit(alice, 10 ether);
161361
162- assertEq(love.balanceOf(alice), minted - amount);
163- assertEq(love.balanceOf(bob), amount);
362+ vm.prank(alice);
363+ if (loveAmount % RATE == 0 && loveAmount <= 10 ether * RATE) {
364+ love.withdraw(loveAmount);
365+ assertEq(weth.balanceOf(address(love)), 10 ether - loveAmount / RATE);
366+ } else {
367+ vm.expectRevert();
368+ love.withdraw(loveAmount);
369+ }
370+ }
371+
372+ /// @dev Gives `account` `amount` wETH by wrapping fresh ETH.
373+ function _fund(address account, uint256 amount) internal {
374+ vm.deal(account, amount);
375+ vm.prank(account);
376+ weth.deposit{value: amount}();
377+ }
378+
379+ /// @dev Approves and deposits `wethAmount` as `account`, in one step.
380+ function _deposit(address account, uint256 wethAmount) internal {
381+ vm.startPrank(account);
382+ weth.approve(address(love), wethAmount);
383+ love.deposit(wethAmount);
384+ vm.stopPrank();
164385 }
165386 }
@@ -2,19 +2,34 @@
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
10+/// @title LoveTest
11+/// @notice Exercises the peg end to end against a WETH9 stand-in: minting on
12+/// deposit, redemption on withdraw, the divisibility rule, and the
13+/// invariant that supply always equals the wETH backing times the rate.
9 contract LoveTest is Test {14 contract LoveTest is Test {
15+ /// @notice The token under test.
10 Love public love;16 Love public love;
11 17
18+ /// @notice The wETH it is pegged to.
19+ MockWETH public weth;
20+
12 address alice = makeAddr("alice");21 address alice = makeAddr("alice");
13 address bob = makeAddr("bob");22 address bob = makeAddr("bob");
14 address stranger = makeAddr("stranger");23 address stranger = makeAddr("stranger");
15 24
25+ uint256 constant RATE = 100_000;
26+
16 function setUp() public {27 function setUp() public {
17- love = new Love();28+ weth = new MockWETH();
29+ love = new Love(IERC20(address(weth)));
30+
31+ _fund(alice, 10 ether);
32+ _fund(bob, 10 ether);
18 }33 }
19 34
20 /*//////////////////////////////////////////////////////////////35 /*//////////////////////////////////////////////////////////////
@@ -31,56 +46,191 @@ contract LoveTest is Test {
31 assertEq(love.totalSupply(), 0);46 assertEq(love.totalSupply(), 0);
32 }47 }
33 48
49+ function test_PegParameters() public view {
50+ assertEq(love.RATE(), RATE);
51+ assertEq(address(love.WETH()), address(weth));
52+ }
53+
54+ /// @dev No mint entrypoint may survive: supply moves only through the peg.
55+ function test_NoMintFunction() public view {
56+ assertEq(address(love).code.length > 0, true);
57+ (bool ok,) = address(love).staticcall(abi.encodeWithSignature("mint(address,uint256)", alice, 1e18));
58+ assertFalse(ok);
59+ }
60+
34 /*//////////////////////////////////////////////////////////////61 /*//////////////////////////////////////////////////////////////
35- MINT62+ DEPOSIT
36 //////////////////////////////////////////////////////////////*/63 //////////////////////////////////////////////////////////////*/
37 64
38- function test_Mint() public {65+ function test_Deposit() public {
39- vm.prank(alice);66+ _deposit(alice, 1 ether);
40- love.mint(alice, 100e18);
41 67
42- assertEq(love.balanceOf(alice), 100e18);68+ assertEq(love.balanceOf(alice), 1 ether * RATE);
43- assertEq(love.totalSupply(), 100e18);69+ assertEq(love.totalSupply(), 1 ether * RATE);
70+ assertEq(weth.balanceOf(address(love)), 1 ether);
71+ assertEq(weth.balanceOf(alice), 9 ether);
44 }72 }
45 73
46- /// @dev The point of this contract: `mint` has no access control.74+ function test_DepositIsPermissionless() public {
47- function test_AnyoneCanMintToAnyone() public {75+ _fund(stranger, 1 ether);
48- vm.prank(stranger);76+ _deposit(stranger, 1 ether);
49- love.mint(bob, 1000e18);
50 77
51- assertEq(love.balanceOf(bob), 1000e18);78+ assertEq(love.balanceOf(stranger), 1 ether * RATE);
52- assertEq(love.balanceOf(stranger), 0);
53- assertEq(love.totalSupply(), 1000e18);
54 }79 }
55 80
56- function test_MintEmitsTransferFromZero() public {81+ function test_DepositEmitsDeposit() public {
82+ vm.prank(alice);
83+ weth.approve(address(love), 2 ether);
84+
85+ vm.expectEmit(true, false, false, true);
86+ emit Love.Deposit(alice, 2 ether, 2 ether * RATE);
87+
88+ vm.prank(alice);
89+ love.deposit(2 ether);
90+ }
91+
92+ function test_DepositEmitsTransferFromZero() public {
93+ vm.prank(alice);
94+ weth.approve(address(love), 1 ether);
95+
57 vm.expectEmit(true, true, false, true);96 vm.expectEmit(true, true, false, true);
58- emit IERC20.Transfer(address(0), alice, 5e18);97+ emit IERC20.Transfer(address(0), alice, 1 ether * RATE);
59 98
60 vm.prank(alice);99 vm.prank(alice);
61- love.mint(alice, 5e18);100+ love.deposit(1 ether);
62 }101 }
63 102
64- function test_MintAccumulates() public {103+ function test_DepositAccumulates() public {
65- vm.startPrank(alice);104+ _deposit(alice, 1 ether);
66- love.mint(alice, 1e18);105+ _deposit(alice, 3 ether);
67- love.mint(alice, 2e18);
68- vm.stopPrank();
69 106
70- assertEq(love.balanceOf(alice), 3e18);107+ assertEq(love.balanceOf(alice), 4 ether * RATE);
71- assertEq(love.totalSupply(), 3e18);108+ assertEq(weth.balanceOf(address(love)), 4 ether);
72 }109 }
73 110
74- function test_RevertWhen_MintingToZeroAddress() public {111+ function test_DepositZero() public {
75- vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidReceiver.selector, address(0)));112+ _deposit(alice, 0);
76- love.mint(address(0), 1e18);113+
114+ assertEq(love.balanceOf(alice), 0);
115+ assertEq(love.totalSupply(), 0);
77 }116 }
78 117
79- function test_RevertWhen_MintOverflowsTotalSupply() public {118+ function test_RevertWhen_DepositWithoutApproval() public {
80- love.mint(alice, type(uint256).max);119+ vm.prank(alice);
120+ vm.expectRevert(
121+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, address(love), 0, 1 ether)
122+ );
123+ love.deposit(1 ether);
124+ }
125+
126+ function test_RevertWhen_DepositExceedsWethBalance() public {
127+ vm.prank(alice);
128+ weth.approve(address(love), 100 ether);
129+
130+ vm.prank(alice);
131+ vm.expectRevert(
132+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 10 ether, 100 ether)
133+ );
134+ love.deposit(100 ether);
135+ }
81 136
137+ function test_RevertWhen_DepositOverflows() public {
138+ vm.prank(alice);
82 vm.expectRevert();139 vm.expectRevert();
83- love.mint(bob, 1);140+ love.deposit(type(uint256).max);
141+ }
142+
143+ /*//////////////////////////////////////////////////////////////
144+ WITHDRAW
145+ //////////////////////////////////////////////////////////////*/
146+
147+ function test_Withdraw() public {
148+ _deposit(alice, 1 ether);
149+
150+ vm.prank(alice);
151+ love.withdraw(1 ether * RATE);
152+
153+ assertEq(love.balanceOf(alice), 0);
154+ assertEq(love.totalSupply(), 0);
155+ assertEq(weth.balanceOf(alice), 10 ether);
156+ assertEq(weth.balanceOf(address(love)), 0);
157+ }
158+
159+ function test_PartialWithdraw() public {
160+ _deposit(alice, 1 ether);
161+
162+ vm.prank(alice);
163+ love.withdraw(0.25 ether * RATE);
164+
165+ assertEq(love.balanceOf(alice), 0.75 ether * RATE);
166+ assertEq(weth.balanceOf(alice), 9.25 ether);
167+ assertEq(weth.balanceOf(address(love)), 0.75 ether);
168+ }
169+
170+ function test_WithdrawEmitsWithdraw() public {
171+ _deposit(alice, 1 ether);
172+
173+ vm.expectEmit(true, false, false, true);
174+ emit Love.Withdraw(alice, 1 ether * RATE, 1 ether);
175+
176+ vm.prank(alice);
177+ love.withdraw(1 ether * RATE);
178+ }
179+
180+ /// @dev LOVE received from someone else is redeemable all the same.
181+ function test_WithdrawAfterTransfer() public {
182+ _deposit(alice, 1 ether);
183+
184+ vm.prank(alice);
185+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
186+ love.transfer(bob, 1 ether * RATE);
187+
188+ vm.prank(bob);
189+ love.withdraw(1 ether * RATE);
190+
191+ assertEq(weth.balanceOf(bob), 11 ether);
192+ assertEq(love.totalSupply(), 0);
193+ }
194+
195+ function test_WithdrawZero() public {
196+ _deposit(alice, 1 ether);
197+
198+ vm.prank(alice);
199+ love.withdraw(0);
200+
201+ assertEq(love.balanceOf(alice), 1 ether * RATE);
202+ }
203+
204+ /// @dev Anything not a multiple of the rate would round wETH out of the peg.
205+ function test_RevertWhen_WithdrawNotDivisibleByRate() public {
206+ _deposit(alice, 1 ether);
207+
208+ vm.prank(alice);
209+ vm.expectRevert(abi.encodeWithSelector(Love.AmountNotDivisibleByRate.selector, RATE + 1, RATE));
210+ love.withdraw(RATE + 1);
211+ }
212+
213+ function test_RevertWhen_WithdrawExceedsBalance() public {
214+ _deposit(alice, 1 ether);
215+
216+ vm.prank(alice);
217+ vm.expectRevert(
218+ abi.encodeWithSelector(
219+ IERC20Errors.ERC20InsufficientBalance.selector, alice, 1 ether * RATE, 2 ether * RATE
220+ )
221+ );
222+ love.withdraw(2 ether * RATE);
223+ }
224+
225+ /// @dev One holder cannot redeem against another holder's collateral.
226+ function test_RevertWhen_WithdrawWithoutDepositing() public {
227+ _deposit(alice, 1 ether);
228+
229+ vm.prank(stranger);
230+ vm.expectRevert(
231+ abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, stranger, 0, 1 ether * RATE)
232+ );
233+ love.withdraw(1 ether * RATE);
84 }234 }
85 235
86 /*//////////////////////////////////////////////////////////////236 /*//////////////////////////////////////////////////////////////
@@ -88,20 +238,18 @@ contract LoveTest is Test {
88 //////////////////////////////////////////////////////////////*/238 //////////////////////////////////////////////////////////////*/
89 239
90 function test_Transfer() public {240 function test_Transfer() public {
91- love.mint(alice, 10e18);241+ _deposit(alice, 1 ether);
92 242
93 vm.prank(alice);243 vm.prank(alice);
94 assertTrue(love.transfer(bob, 4e18));244 assertTrue(love.transfer(bob, 4e18));
95 245
96- assertEq(love.balanceOf(alice), 6e18);246+ assertEq(love.balanceOf(alice), 1 ether * RATE - 4e18);
97 assertEq(love.balanceOf(bob), 4e18);247 assertEq(love.balanceOf(bob), 4e18);
98 }248 }
99 249
100 function test_RevertWhen_TransferExceedsBalance() public {250 function test_RevertWhen_TransferExceedsBalance() public {
101- love.mint(alice, 1e18);
102-
103 vm.prank(alice);251 vm.prank(alice);
104- vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 1e18, 2e18));252+ vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, 2e18));
105 // forge-lint: disable-next-line(erc20-unchecked-transfer)253 // forge-lint: disable-next-line(erc20-unchecked-transfer)
106 love.transfer(bob, 2e18);254 love.transfer(bob, 2e18);
107 }255 }
@@ -111,7 +259,7 @@ contract LoveTest is Test {
111 //////////////////////////////////////////////////////////////*/259 //////////////////////////////////////////////////////////////*/
112 260
113 function test_ApproveAndTransferFrom() public {261 function test_ApproveAndTransferFrom() public {
114- love.mint(alice, 10e18);262+ _deposit(alice, 1 ether);
115 263
116 vm.prank(alice);264 vm.prank(alice);
117 assertTrue(love.approve(bob, 6e18));265 assertTrue(love.approve(bob, 6e18));
@@ -120,13 +268,13 @@ contract LoveTest is Test {
120 vm.prank(bob);268 vm.prank(bob);
121 assertTrue(love.transferFrom(alice, stranger, 6e18));269 assertTrue(love.transferFrom(alice, stranger, 6e18));
122 270
123- assertEq(love.balanceOf(alice), 4e18);271+ assertEq(love.balanceOf(alice), 1 ether * RATE - 6e18);
124 assertEq(love.balanceOf(stranger), 6e18);272 assertEq(love.balanceOf(stranger), 6e18);
125 assertEq(love.allowance(alice, bob), 0);273 assertEq(love.allowance(alice, bob), 0);
126 }274 }
127 275
128 function test_RevertWhen_TransferFromExceedsAllowance() public {276 function test_RevertWhen_TransferFromExceedsAllowance() public {
129- love.mint(alice, 10e18);277+ _deposit(alice, 1 ether);
130 278
131 vm.prank(alice);279 vm.prank(alice);
132 love.approve(bob, 1e18);280 love.approve(bob, 1e18);
@@ -137,29 +285,102 @@ contract LoveTest is Test {
137 love.transferFrom(alice, stranger, 2e18);285 love.transferFrom(alice, stranger, 2e18);
138 }286 }
139 287
288+ /*//////////////////////////////////////////////////////////////
289+ PEG
290+ //////////////////////////////////////////////////////////////*/
291+
292+ function test_RoundTripIsLossless() public {
293+ uint256 before = weth.balanceOf(alice);
294+
295+ _deposit(alice, 3 ether);
296+ vm.prank(alice);
297+ love.withdraw(3 ether * RATE);
298+
299+ assertEq(weth.balanceOf(alice), before);
300+ assertEq(love.balanceOf(alice), 0);
301+ assertEq(love.totalSupply(), 0);
302+ }
303+
304+ function test_SupplyStaysFullyBacked() public {
305+ _deposit(alice, 1 ether);
306+ _deposit(bob, 2 ether);
307+
308+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
309+
310+ vm.prank(alice);
311+ love.withdraw(0.5 ether * RATE);
312+
313+ assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE);
314+ }
315+
316+ /// @dev wETH sent straight to the contract is not claimable — it only lifts
317+ /// the backing, it never mints LOVE.
318+ function test_DonatedWethDoesNotMint() public {
319+ _deposit(alice, 1 ether);
320+
321+ vm.prank(bob);
322+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
323+ weth.transfer(address(love), 5 ether);
324+
325+ assertEq(love.totalSupply(), 1 ether * RATE);
326+ assertEq(weth.balanceOf(address(love)), 6 ether);
327+ }
328+
140 /*//////////////////////////////////////////////////////////////329 /*//////////////////////////////////////////////////////////////
141 FUZZ330 FUZZ
142 //////////////////////////////////////////////////////////////*/331 //////////////////////////////////////////////////////////////*/
143 332
144- function testFuzz_MintByAnyCaller(address caller, address to, uint256 amount) public {333+ function testFuzz_DepositByAnyCaller(address caller, uint256 wethAmount) public {
145- vm.assume(to != address(0));334+ vm.assume(caller != address(0) && caller != address(love) && caller != address(weth));
335+ wethAmount = bound(wethAmount, 0, type(uint256).max / RATE);
146 336
147- vm.prank(caller);337+ _fund(caller, wethAmount);
148- love.mint(to, amount);338+ _deposit(caller, wethAmount);
149 339
150- assertEq(love.balanceOf(to), amount);340+ assertEq(love.balanceOf(caller), wethAmount * RATE);
151- assertEq(love.totalSupply(), amount);341+ assertEq(love.totalSupply(), wethAmount * RATE);
342+ assertEq(weth.balanceOf(address(love)), wethAmount);
152 }343 }
153 344
154- function testFuzz_Transfer(uint256 minted, uint256 amount) public {345+ function testFuzz_RoundTrip(uint256 wethAmount) public {
155- amount = bound(amount, 0, minted);346+ wethAmount = bound(wethAmount, 0, type(uint256).max / RATE);
156 347
157- love.mint(alice, minted);348+ _fund(alice, wethAmount);
349+ uint256 before = weth.balanceOf(alice);
158 350
351+ _deposit(alice, wethAmount);
159 vm.prank(alice);352 vm.prank(alice);
160- assertTrue(love.transfer(bob, amount));353+ love.withdraw(wethAmount * RATE);
354+
355+ assertEq(weth.balanceOf(alice), before);
356+ assertEq(love.totalSupply(), 0);
357+ }
358+
359+ function testFuzz_WithdrawRevertsUnlessDivisible(uint256 loveAmount) public {
360+ _deposit(alice, 10 ether);
161 361
162- assertEq(love.balanceOf(alice), minted - amount);362+ vm.prank(alice);
163- assertEq(love.balanceOf(bob), amount);363+ if (loveAmount % RATE == 0 && loveAmount <= 10 ether * RATE) {
364+ love.withdraw(loveAmount);
365+ assertEq(weth.balanceOf(address(love)), 10 ether - loveAmount / RATE);
366+ } else {
367+ vm.expectRevert();
368+ love.withdraw(loveAmount);
369+ }
370+ }
371+
372+ /// @dev Gives `account` `amount` wETH by wrapping fresh ETH.
373+ function _fund(address account, uint256 amount) internal {
374+ vm.deal(account, amount);
375+ vm.prank(account);
376+ weth.deposit{value: amount}();
377+ }
378+
379+ /// @dev Approves and deposits `wethAmount` as `account`, in one step.
380+ function _deposit(address account, uint256 wethAmount) internal {
381+ vm.startPrank(account);
382+ weth.approve(address(love), wethAmount);
383+ love.deposit(wethAmount);
384+ vm.stopPrank();
164 }385 }
165 }386 }
modified test/LoveCreate2.t.sol +32 -17
@@ -5,11 +5,14 @@ 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+/// @title LoveCreate2Test
9+/// @notice The deployment address derives from the salt, the creation code and
10+/// the wETH constructor argument. These tests pin that derivation: same
11+/// salt and same wETH give the same address on any chain, a different
12+/// wETH gives a different one.
1113 contract LoveCreate2Test is Test {
1214 bytes32 constant SALT = keccak256("LOVE");
15+ address constant WETH = 0x4200000000000000000000000000000000000006;
1316
1417 LoveScript script;
1518
@@ -21,53 +24,65 @@ contract LoveCreate2Test is Test {
2124 }
2225
2326 function test_DeploysAtPredictedAddress() public {
24- address predicted = vm.computeCreate2Address(SALT, keccak256(type(Love).creationCode), CREATE2_FACTORY);
27+ address predicted = vm.computeCreate2Address(SALT, keccak256(script.initCode(WETH)), CREATE2_FACTORY);
2528
26- Love deployed = Love(_deploy(SALT));
29+ Love deployed = Love(_deploy(SALT, WETH));
2730
2831 assertEq(address(deployed), predicted);
2932 assertEq(deployed.symbol(), "LOVE");
33+ assertEq(address(deployed.WETH()), WETH);
3034 }
3135
3236 /// @dev What the deploy script prints must be what the chain gives back.
3337 function test_ScriptPredictionMatchesDeployment() public {
34- assertEq(script.predict(SALT), _deploy(SALT));
38+ assertEq(script.predict(SALT, WETH), _deploy(SALT, WETH));
3539 }
3640
3741 function test_ScriptUsesKeccakOfLoveAsDefaultSalt() public view {
3842 assertEq(script.DEFAULT_SALT(), SALT);
3943 }
4044
41- /// @dev Same salt, same code, same address — that is the whole point.
45+ function test_ScriptDefaultsToOpStackWeth() public view {
46+ assertEq(script.DEFAULT_WETH(), WETH);
47+ }
48+
49+ /// @dev Same salt, same code, same wETH, same address.
4250 function test_PredictionIsChainAgnostic() public {
43- address onThisChain = script.predict(SALT);
51+ address onThisChain = script.predict(SALT, WETH);
4452
4553 vm.chainId(137);
46- assertEq(script.predict(SALT), onThisChain);
54+ assertEq(script.predict(SALT, WETH), onThisChain);
4755
4856 vm.chainId(42_161);
49- assertEq(script.predict(SALT), onThisChain);
57+ assertEq(script.predict(SALT, WETH), onThisChain);
5058 }
5159
5260 function test_DifferentSaltsGiveDifferentAddresses() public view {
53- assertTrue(script.predict(SALT) != script.predict(keccak256("LOVE2")));
61+ assertTrue(script.predict(SALT, WETH) != script.predict(keccak256("LOVE2"), WETH));
62+ }
63+
64+ /// @dev The wETH address is part of the creation code, so chains with their
65+ /// own wETH get their own token address.
66+ function test_DifferentWethGivesDifferentAddress() public view {
67+ assertTrue(script.predict(SALT, WETH) != script.predict(SALT, address(0xBEEF)));
5468 }
5569
5670 /// @dev Redeploying with the same salt must fail, not silently return the
5771 /// existing token.
5872 function test_RevertWhen_RedeployingWithSameSalt() public {
59- _deploy(SALT);
73+ _deploy(SALT, WETH);
6074
61- (bool ok,) = CREATE2_FACTORY.call(abi.encodePacked(SALT, type(Love).creationCode));
75+ (bool ok,) = CREATE2_FACTORY.call(abi.encodePacked(SALT, script.initCode(WETH)));
6276 assertFalse(ok);
6377 }
6478
65- function testFuzz_PredictionMatchesDeployment(bytes32 salt) public {
66- assertEq(script.predict(salt), _deploy(salt));
79+ function testFuzz_PredictionMatchesDeployment(bytes32 salt, address weth) public {
80+ assertEq(script.predict(salt, weth), _deploy(salt, weth));
6781 }
6882
69- function _deploy(bytes32 salt) internal returns (address deployed) {
70- (bool ok, bytes memory ret) = CREATE2_FACTORY.call(abi.encodePacked(salt, type(Love).creationCode));
83+ /// @dev Deploys through the canonical deterministic deployer, as the script does.
84+ function _deploy(bytes32 salt, address weth) internal returns (address deployed) {
85+ (bool ok, bytes memory ret) = CREATE2_FACTORY.call(abi.encodePacked(salt, script.initCode(weth)));
7186 require(ok, "create2 deployment failed");
7287 // casting to 'bytes20' is safe because the deployer returns the 20-byte address, raw
7388 // forge-lint: disable-next-line(unsafe-typecast)
@@ -5,11 +5,14 @@ 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+/// @title LoveCreate2Test
9-/// so these tests pin the CREATE2 deployment path and the inputs the9+/// @notice The deployment address derives from the salt, the creation code and
10-/// address derives from.10+/// the wETH constructor argument. These tests pin that derivation: same
11+/// salt and same wETH give the same address on any chain, a different
12+/// wETH gives a different one.
11 contract LoveCreate2Test is Test {13 contract LoveCreate2Test is Test {
12 bytes32 constant SALT = keccak256("LOVE");14 bytes32 constant SALT = keccak256("LOVE");
15+ address constant WETH = 0x4200000000000000000000000000000000000006;
13 16
14 LoveScript script;17 LoveScript script;
15 18
@@ -21,53 +24,65 @@ contract LoveCreate2Test is Test {
21 }24 }
22 25
23 function test_DeploysAtPredictedAddress() public {26 function test_DeploysAtPredictedAddress() public {
24- address predicted = vm.computeCreate2Address(SALT, keccak256(type(Love).creationCode), CREATE2_FACTORY);27+ address predicted = vm.computeCreate2Address(SALT, keccak256(script.initCode(WETH)), CREATE2_FACTORY);
25 28
26- Love deployed = Love(_deploy(SALT));29+ Love deployed = Love(_deploy(SALT, WETH));
27 30
28 assertEq(address(deployed), predicted);31 assertEq(address(deployed), predicted);
29 assertEq(deployed.symbol(), "LOVE");32 assertEq(deployed.symbol(), "LOVE");
33+ assertEq(address(deployed.WETH()), WETH);
30 }34 }
31 35
32 /// @dev What the deploy script prints must be what the chain gives back.36 /// @dev What the deploy script prints must be what the chain gives back.
33 function test_ScriptPredictionMatchesDeployment() public {37 function test_ScriptPredictionMatchesDeployment() public {
34- assertEq(script.predict(SALT), _deploy(SALT));38+ assertEq(script.predict(SALT, WETH), _deploy(SALT, WETH));
35 }39 }
36 40
37 function test_ScriptUsesKeccakOfLoveAsDefaultSalt() public view {41 function test_ScriptUsesKeccakOfLoveAsDefaultSalt() public view {
38 assertEq(script.DEFAULT_SALT(), SALT);42 assertEq(script.DEFAULT_SALT(), SALT);
39 }43 }
40 44
41- /// @dev Same salt, same code, same address — that is the whole point.45+ function test_ScriptDefaultsToOpStackWeth() public view {
46+ assertEq(script.DEFAULT_WETH(), WETH);
47+ }
48+
49+ /// @dev Same salt, same code, same wETH, same address.
42 function test_PredictionIsChainAgnostic() public {50 function test_PredictionIsChainAgnostic() public {
43- address onThisChain = script.predict(SALT);51+ address onThisChain = script.predict(SALT, WETH);
44 52
45 vm.chainId(137);53 vm.chainId(137);
46- assertEq(script.predict(SALT), onThisChain);54+ assertEq(script.predict(SALT, WETH), onThisChain);
47 55
48 vm.chainId(42_161);56 vm.chainId(42_161);
49- assertEq(script.predict(SALT), onThisChain);57+ assertEq(script.predict(SALT, WETH), onThisChain);
50 }58 }
51 59
52 function test_DifferentSaltsGiveDifferentAddresses() public view {60 function test_DifferentSaltsGiveDifferentAddresses() public view {
53- assertTrue(script.predict(SALT) != script.predict(keccak256("LOVE2")));61+ assertTrue(script.predict(SALT, WETH) != script.predict(keccak256("LOVE2"), WETH));
62+ }
63+
64+ /// @dev The wETH address is part of the creation code, so chains with their
65+ /// own wETH get their own token address.
66+ function test_DifferentWethGivesDifferentAddress() public view {
67+ assertTrue(script.predict(SALT, WETH) != script.predict(SALT, address(0xBEEF)));
54 }68 }
55 69
56 /// @dev Redeploying with the same salt must fail, not silently return the70 /// @dev Redeploying with the same salt must fail, not silently return the
57 /// existing token.71 /// existing token.
58 function test_RevertWhen_RedeployingWithSameSalt() public {72 function test_RevertWhen_RedeployingWithSameSalt() public {
59- _deploy(SALT);73+ _deploy(SALT, WETH);
60 74
61- (bool ok,) = CREATE2_FACTORY.call(abi.encodePacked(SALT, type(Love).creationCode));75+ (bool ok,) = CREATE2_FACTORY.call(abi.encodePacked(SALT, script.initCode(WETH)));
62 assertFalse(ok);76 assertFalse(ok);
63 }77 }
64 78
65- function testFuzz_PredictionMatchesDeployment(bytes32 salt) public {79+ function testFuzz_PredictionMatchesDeployment(bytes32 salt, address weth) public {
66- assertEq(script.predict(salt), _deploy(salt));80+ assertEq(script.predict(salt, weth), _deploy(salt, weth));
67 }81 }
68 82
69- function _deploy(bytes32 salt) internal returns (address deployed) {83+ /// @dev Deploys through the canonical deterministic deployer, as the script does.
70- (bool ok, bytes memory ret) = CREATE2_FACTORY.call(abi.encodePacked(salt, type(Love).creationCode));84+ function _deploy(bytes32 salt, address weth) internal returns (address deployed) {
85+ (bool ok, bytes memory ret) = CREATE2_FACTORY.call(abi.encodePacked(salt, script.initCode(weth)));
71 require(ok, "create2 deployment failed");86 require(ok, "create2 deployment failed");
72 // casting to 'bytes20' is safe because the deployer returns the 20-byte address, raw87 // casting to 'bytes20' is safe because the deployer returns the 20-byte address, raw
73 // forge-lint: disable-next-line(unsafe-typecast)88 // forge-lint: disable-next-line(unsafe-typecast)
added test/mocks/MockWETH.sol +28 -0
new file mode 100644
@@ -0,0 +1,28 @@
1+// SPDX-License-Identifier: MIT
2+pragma solidity ^0.8.30;
3+
4+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5+
6+/// @title MockWETH
7+/// @notice Minimal stand-in for WETH9: wraps ETH 1:1, same 18 decimals, same
8+/// `deposit`/`withdraw` entrypoints. Test-only.
9+contract MockWETH is ERC20 {
10+ constructor() ERC20("Wrapped Ether", "WETH") {}
11+
12+ /// @notice Wrap any ETH sent with no calldata.
13+ receive() external payable {
14+ deposit();
15+ }
16+
17+ /// @notice Wrap the ETH sent with this call, minting the same amount of wETH.
18+ function deposit() public payable {
19+ _mint(msg.sender, msg.value);
20+ }
21+
22+ /// @notice Burn `amount` wETH and send the caller the same amount of ETH.
23+ /// @param amount The wETH to unwrap, in wei.
24+ function withdraw(uint256 amount) external {
25+ _burn(msg.sender, amount);
26+ payable(msg.sender).transfer(amount);
27+ }
28+}
new file mode 100644
@@ -0,0 +1,28 @@
1+// SPDX-License-Identifier: MIT
2+pragma solidity ^0.8.30;
3+
4+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5+
6+/// @title MockWETH
7+/// @notice Minimal stand-in for WETH9: wraps ETH 1:1, same 18 decimals, same
8+/// `deposit`/`withdraw` entrypoints. Test-only.
9+contract MockWETH is ERC20 {
10+ constructor() ERC20("Wrapped Ether", "WETH") {}
11+
12+ /// @notice Wrap any ETH sent with no calldata.
13+ receive() external payable {
14+ deposit();
15+ }
16+
17+ /// @notice Wrap the ETH sent with this call, minting the same amount of wETH.
18+ function deposit() public payable {
19+ _mint(msg.sender, msg.value);
20+ }
21+
22+ /// @notice Burn `amount` wETH and send the caller the same amount of ETH.
23+ /// @param amount The wETH to unwrap, in wei.
24+ function withdraw(uint256 amount) external {
25+ _burn(msg.sender, amount);
26+ payable(msg.sender).transfer(amount);
27+ }
28+}