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

Peg LOVE to wETH with deposit and withdraw #3

Merged@julien wants to merge 3-peg-love-to-weth into main
Merged as ce13509fab5b.
8 files changed
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
Large diff collapsed to keep the page fast — load it when you need it.
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+}