Merge 3-peg-love-to-weth into main
ce13509 parent: 7c6b16b 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 | @@ -2,19 +2,34 @@ | ||
| 2 | 2 | pragma solidity ^0.8.30; |
| 3 | 3 | |
| 4 | 4 | import {Love} from "../src/Love.sol"; |
| 5 | +import {MockWETH} from "./mocks/MockWETH.sol"; | |
| 5 | 6 | import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; |
| 6 | 7 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; |
| 7 | 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 | 14 | contract LoveTest is Test { |
| 15 | + /// @notice The token under test. | |
| 10 | 16 | Love public love; |
| 11 | 17 | |
| 18 | + /// @notice The wETH it is pegged to. | |
| 19 | + MockWETH public weth; | |
| 20 | + | |
| 12 | 21 | address alice = makeAddr("alice"); |
| 13 | 22 | address bob = makeAddr("bob"); |
| 14 | 23 | address stranger = makeAddr("stranger"); |
| 15 | 24 | |
| 25 | + uint256 constant RATE = 100_000; | |
| 26 | + | |
| 16 | 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 | 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 | - MINT | |
| 62 | + DEPOSIT | |
| 36 | 63 | //////////////////////////////////////////////////////////////*/ |
| 37 | 64 | |
| 38 | - function test_Mint() public { | |
| 39 | - vm.prank(alice); | |
| 40 | - love.mint(alice, 100e18); | |
| 65 | + function test_Deposit() public { | |
| 66 | + _deposit(alice, 1 ether); | |
| 41 | 67 | |
| 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); | |
| 44 | 72 | } |
| 45 | 73 | |
| 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); | |
| 50 | 77 | |
| 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); | |
| 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 | 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 | 99 | vm.prank(alice); |
| 61 | - love.mint(alice, 5e18); | |
| 100 | + love.deposit(1 ether); | |
| 62 | 101 | } |
| 63 | 102 | |
| 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); | |
| 69 | 106 | |
| 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); | |
| 72 | 109 | } |
| 73 | 110 | |
| 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); | |
| 77 | 116 | } |
| 78 | 117 | |
| 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 | + } | |
| 81 | 136 | |
| 137 | + function test_RevertWhen_DepositOverflows() public { | |
| 138 | + vm.prank(alice); | |
| 82 | 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 | 240 | function test_Transfer() public { |
| 91 | - love.mint(alice, 10e18); | |
| 241 | + _deposit(alice, 1 ether); | |
| 92 | 242 | |
| 93 | 243 | vm.prank(alice); |
| 94 | 244 | assertTrue(love.transfer(bob, 4e18)); |
| 95 | 245 | |
| 96 | - assertEq(love.balanceOf(alice), 6e18); | |
| 246 | + assertEq(love.balanceOf(alice), 1 ether * RATE - 4e18); | |
| 97 | 247 | assertEq(love.balanceOf(bob), 4e18); |
| 98 | 248 | } |
| 99 | 249 | |
| 100 | 250 | function test_RevertWhen_TransferExceedsBalance() public { |
| 101 | - love.mint(alice, 1e18); | |
| 102 | - | |
| 103 | 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 | 253 | // forge-lint: disable-next-line(erc20-unchecked-transfer) |
| 106 | 254 | love.transfer(bob, 2e18); |
| 107 | 255 | } |
| @@ -111,7 +259,7 @@ contract LoveTest is Test { | ||
| 111 | 259 | //////////////////////////////////////////////////////////////*/ |
| 112 | 260 | |
| 113 | 261 | function test_ApproveAndTransferFrom() public { |
| 114 | - love.mint(alice, 10e18); | |
| 262 | + _deposit(alice, 1 ether); | |
| 115 | 263 | |
| 116 | 264 | vm.prank(alice); |
| 117 | 265 | assertTrue(love.approve(bob, 6e18)); |
| @@ -120,13 +268,13 @@ contract LoveTest is Test { | ||
| 120 | 268 | vm.prank(bob); |
| 121 | 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 | 272 | assertEq(love.balanceOf(stranger), 6e18); |
| 125 | 273 | assertEq(love.allowance(alice, bob), 0); |
| 126 | 274 | } |
| 127 | 275 | |
| 128 | 276 | function test_RevertWhen_TransferFromExceedsAllowance() public { |
| 129 | - love.mint(alice, 10e18); | |
| 277 | + _deposit(alice, 1 ether); | |
| 130 | 278 | |
| 131 | 279 | vm.prank(alice); |
| 132 | 280 | love.approve(bob, 1e18); |
| @@ -137,29 +285,102 @@ contract LoveTest is Test { | ||
| 137 | 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 | 330 | FUZZ |
| 142 | 331 | //////////////////////////////////////////////////////////////*/ |
| 143 | 332 | |
| 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); | |
| 146 | 336 | |
| 147 | - vm.prank(caller); | |
| 148 | - love.mint(to, amount); | |
| 337 | + _fund(caller, wethAmount); | |
| 338 | + _deposit(caller, wethAmount); | |
| 149 | 339 | |
| 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); | |
| 152 | 343 | } |
| 153 | 344 | |
| 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); | |
| 156 | 347 | |
| 157 | - love.mint(alice, minted); | |
| 348 | + _fund(alice, wethAmount); | |
| 349 | + uint256 before = weth.balanceOf(alice); | |
| 158 | 350 | |
| 351 | + _deposit(alice, wethAmount); | |
| 159 | 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); | |
| 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(); | |
| 164 | 385 | } |
| 165 | 386 | } |
| @@ -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 | - MINT | 62 | + 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 | FUZZ | 330 | 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"; | ||
| 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 | +} | ||