Peg LOVE to wETH with deposit and withdraw #3
Merged@julien wants to merge
3-peg-love-to-weth into mainMerged as
ce13509fab5b.8 files changed
modified
.env.example +3 -0 | @@ -1,6 +1,9 @@ | ||
| 1 | 1 | # RPC endpoints |
| 2 | 2 | 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 | 7 | # Block explorer verification |
| 5 | 8 | ETHERSCAN_API_KEY= |
| 6 | 9 | |
| @@ -1,6 +1,9 @@ | |||
| 1 | # RPC endpoints | 1 | # RPC endpoints |
| 2 | BASE_SEPOLIA_RPC_URL=https://sepolia.base.org | 2 | 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 verification | 7 | # 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 | ||
| 10 | 10 | ### Added |
| 11 | 11 | |
| 12 | 12 | - 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. | |
| 16 | 22 | - 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. | |
| 19 | 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 | 30 | - `foundry.toml` with pinned solc 0.8.30, optimizer, fmt rules, fuzz/invariant |
| 22 | 31 | defaults, and Base Sepolia RPC/Etherscan endpoints driven by env vars. |
| 23 | 32 | - `script/multichain-check.sh`, deploying `Love` on two local anvil nodes with |
| 24 | 33 | different chain IDs and failing if the two addresses don't match. |
| 25 | 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`. | |
| @@ -10,17 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | |||
| 10 | ### Added | 10 | ### 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 an | 13 | +- `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 the | 22 | - Deployment script `script/Love.s.sol`, deploying with CREATE2 through the |
| 17 | - canonical deterministic deployer so `Love` gets the same address on every EVM | 23 | + 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/invariant | 30 | - `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 with | 32 | - `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 @@ | ||
| 2 | 2 | pragma solidity ^0.8.30; |
| 3 | 3 | |
| 4 | 4 | import {Love} from "../src/Love.sol"; |
| 5 | +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | |
| 5 | 6 | import {Script, console} from "forge-std/Script.sol"; |
| 6 | 7 | |
| 8 | +/// @title LoveScript | |
| 9 | +/// @author Julien Béranger | |
| 7 | 10 | /// @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 | |
| 10 | 14 | /// `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 | 16 | contract LoveScript is Script { |
| 17 | + /// @notice Salt used when `SALT` is not set in the environment. | |
| 13 | 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 | 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 | 31 | function run() public returns (Love) { |
| 18 | 32 | bytes32 s = salt(); |
| 19 | - address predicted = predict(s); | |
| 33 | + address w = wethAddress(); | |
| 34 | + address predicted = predict(s, w); | |
| 20 | 35 | |
| 21 | 36 | console.log("deployer ", CREATE2_FACTORY); |
| 22 | 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 | 40 | console.log("predicted ", predicted); |
| 25 | 41 | |
| 26 | 42 | if (predicted.code.length > 0) { |
| @@ -30,7 +46,7 @@ contract LoveScript is Script { | ||
| 30 | 46 | } |
| 31 | 47 | |
| 32 | 48 | vm.startBroadcast(); |
| 33 | - love = new Love{salt: s}(); | |
| 49 | + love = new Love{salt: s}(IERC20(w)); | |
| 34 | 50 | vm.stopBroadcast(); |
| 35 | 51 | |
| 36 | 52 | require(address(love) == predicted, "LoveScript: address mismatch"); |
| @@ -38,31 +54,59 @@ contract LoveScript is Script { | ||
| 38 | 54 | console.log("Love deployed at", address(love)); |
| 39 | 55 | console.log("name ", love.name()); |
| 40 | 56 | console.log("symbol ", love.symbol()); |
| 57 | + console.log("weth ", address(love.WETH())); | |
| 58 | + console.log("rate ", love.RATE()); | |
| 41 | 59 | console.log("totalSupply ", love.totalSupply()); |
| 42 | 60 | |
| 43 | 61 | return love; |
| 44 | 62 | } |
| 45 | 63 | |
| 46 | 64 | /// @notice Print the address `run()` would deploy to, without broadcasting. |
| 65 | + /// @return predicted The address the current salt and wETH derive to. | |
| 47 | 66 | function predict() public view returns (address predicted) { |
| 48 | 67 | bytes32 s = salt(); |
| 49 | - predicted = predict(s); | |
| 68 | + address w = wethAddress(); | |
| 69 | + predicted = predict(s, w); | |
| 50 | 70 | |
| 51 | 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 | 74 | console.log("predicted ", predicted); |
| 54 | 75 | } |
| 55 | 76 | |
| 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)); | |
| 58 | 90 | } |
| 59 | 91 | |
| 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)); | |
| 62 | 97 | } |
| 63 | 98 | |
| 99 | + /// @notice The salt to deploy with. | |
| 64 | 100 | /// @dev `SALT` overrides the default, e.g. to mine a vanity address. |
| 101 | + /// @return The configured salt, or `DEFAULT_SALT`. | |
| 65 | 102 | function salt() public view returns (bytes32) { |
| 66 | 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 | } |
| @@ -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 deterministic | 10 | /// @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 — keep | 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 | ||
| 10 | /// `solc`, the optimizer settings and `bytecode_hash = "none"` as they | 14 | /// `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 @@ | ||
| 1 | 1 | #!/usr/bin/env bash |
| 2 | 2 | # |
| 3 | 3 | # 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 | 8 | # Usage: ./script/multichain-check.sh |
| 7 | 9 | # |
| 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 | |
| 9 | 11 | |
| 10 | 12 | set -euo pipefail |
| 11 | 13 | |
| @@ -77,10 +79,11 @@ report() { | ||
| 77 | 79 | exit 1 |
| 78 | 80 | fi |
| 79 | 81 | |
| 80 | - printf '%s %s %s bytes %s / %s\n' \ | |
| 82 | + printf '%s %s %s bytes %s / %s weth %s\n' \ | |
| 81 | 83 | "$label" "$addr" "$(((${#code} - 2) / 2))" \ |
| 82 | 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 | 89 | cd "$ROOT" |
| @@ -1,11 +1,13 @@ | |||
| 1 | #!/usr/bin/env bash | 1 | #!/usr/bin/env bash |
| 2 | # | 2 | # |
| 3 | # Deploys Love on two local anvil nodes with different chain ids and checks | 3 | # 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.sh | 8 | # Usage: ./script/multichain-check.sh |
| 7 | # | 9 | # |
| 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 |
| 9 | 11 | ||
| 10 | set -euo pipefail | 12 | set -euo pipefail |
| 11 | 13 | ||
| @@ -77,10 +79,11 @@ report() { | |||
| 77 | exit 1 | 79 | exit 1 |
| 78 | fi | 80 | 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 @@ | ||
| 2 | 2 | pragma solidity ^0.8.30; |
| 3 | 3 | |
| 4 | 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 | 8 | /// @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. | |
| 10 | 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. | |
| 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); | |
| 16 | 80 | } |
| 17 | 81 | } |
| @@ -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 Love | 8 | /// @title Love |
| 7 | -/// @notice A basic ERC-20 with an unrestricted mint: anyone can mint any amount | 9 | +/// @author Julien Béranger |
| 8 | -/// to any address. Supply is therefore unbounded and meaningless — this | 10 | +/// @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"; | ||
| 5 | 5 | import {Love} from "../src/Love.sol"; |
| 6 | 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, | |
| 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. | |
| 11 | 13 | contract LoveCreate2Test is Test { |
| 12 | 14 | bytes32 constant SALT = keccak256("LOVE"); |
| 15 | + address constant WETH = 0x4200000000000000000000000000000000000006; | |
| 13 | 16 | |
| 14 | 17 | LoveScript script; |
| 15 | 18 | |
| @@ -21,53 +24,65 @@ contract LoveCreate2Test is Test { | ||
| 21 | 24 | } |
| 22 | 25 | |
| 23 | 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 | 31 | assertEq(address(deployed), predicted); |
| 29 | 32 | assertEq(deployed.symbol(), "LOVE"); |
| 33 | + assertEq(address(deployed.WETH()), WETH); | |
| 30 | 34 | } |
| 31 | 35 | |
| 32 | 36 | /// @dev What the deploy script prints must be what the chain gives back. |
| 33 | 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 | 41 | function test_ScriptUsesKeccakOfLoveAsDefaultSalt() public view { |
| 38 | 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 | 50 | function test_PredictionIsChainAgnostic() public { |
| 43 | - address onThisChain = script.predict(SALT); | |
| 51 | + address onThisChain = script.predict(SALT, WETH); | |
| 44 | 52 | |
| 45 | 53 | vm.chainId(137); |
| 46 | - assertEq(script.predict(SALT), onThisChain); | |
| 54 | + assertEq(script.predict(SALT, WETH), onThisChain); | |
| 47 | 55 | |
| 48 | 56 | vm.chainId(42_161); |
| 49 | - assertEq(script.predict(SALT), onThisChain); | |
| 57 | + assertEq(script.predict(SALT, WETH), onThisChain); | |
| 50 | 58 | } |
| 51 | 59 | |
| 52 | 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 | 70 | /// @dev Redeploying with the same salt must fail, not silently return the |
| 57 | 71 | /// existing token. |
| 58 | 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 | 76 | assertFalse(ok); |
| 63 | 77 | } |
| 64 | 78 | |
| 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)); | |
| 67 | 81 | } |
| 68 | 82 | |
| 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))); | |
| 71 | 86 | require(ok, "create2 deployment failed"); |
| 72 | 87 | // casting to 'bytes20' is safe because the deployer returns the 20-byte address, raw |
| 73 | 88 | // 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 the | 9 | +/// @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 the | 70 | /// @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, raw | 87 | // 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 | +} | ||