// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {IWETH} from "../src/IWETH.sol"; import {Love} from "../src/Love.sol"; import {Fixtures} from "./Fixtures.sol"; import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Test} from "forge-std/Test.sol"; /// @title LoveTest /// @notice Exercises the peg end to end against real WETH9 bytecode: minting on /// deposit, redemption on withdraw, the divisibility rule, and the /// invariant that supply always equals the wETH backing times the rate. contract LoveTest is Test, Fixtures { /// @notice The token under test. Love public love; /// @notice The wETH it is pegged to, etched from a live chain. IWETH public weth; address alice = makeAddr("alice"); address bob = makeAddr("bob"); address stranger = makeAddr("stranger"); uint256 constant RATE = 100_000; /// @dev The OP Stack predeploy address, so the setup mirrors a real chain. address constant WETH_AT = 0x4200000000000000000000000000000000000006; function setUp() public { (, weth) = setUpChain(WETH_AT, WETH9_OP_LEGACY); love = new Love(); _fund(alice, 10 ether); _fund(bob, 10 ether); } /*////////////////////////////////////////////////////////////// METADATA //////////////////////////////////////////////////////////////*/ function test_Metadata() public view { assertEq(love.name(), "Love"); assertEq(love.symbol(), "LOVE"); assertEq(love.decimals(), 18); } function test_InitialSupplyIsZero() public view { assertEq(love.totalSupply(), 0); } function test_PegParameters() public view { assertEq(love.RATE(), RATE); assertEq(address(love.WETH()), address(weth)); } /// @dev No mint entrypoint may survive: supply moves only through the peg. function test_NoMintFunction() public view { assertEq(address(love).code.length > 0, true); (bool ok,) = address(love).staticcall(abi.encodeWithSignature("mint(address,uint256)", alice, 1e18)); assertFalse(ok); } /*////////////////////////////////////////////////////////////// DEPOSIT //////////////////////////////////////////////////////////////*/ function test_Deposit() public { _deposit(alice, 1 ether); assertEq(love.balanceOf(alice), 1 ether * RATE); assertEq(love.totalSupply(), 1 ether * RATE); assertEq(weth.balanceOf(address(love)), 1 ether); assertEq(weth.balanceOf(alice), 9 ether); } function test_DepositIsPermissionless() public { _fund(stranger, 1 ether); _deposit(stranger, 1 ether); assertEq(love.balanceOf(stranger), 1 ether * RATE); } function test_DepositEmitsDeposit() public { vm.prank(alice); weth.approve(address(love), 2 ether); vm.expectEmit(true, false, false, true); emit Love.Deposit(alice, 2 ether, 2 ether * RATE); vm.prank(alice); love.deposit(2 ether); } function test_DepositEmitsTransferFromZero() public { vm.prank(alice); weth.approve(address(love), 1 ether); vm.expectEmit(true, true, false, true); emit IERC20.Transfer(address(0), alice, 1 ether * RATE); vm.prank(alice); love.deposit(1 ether); } function test_DepositAccumulates() public { _deposit(alice, 1 ether); _deposit(alice, 3 ether); assertEq(love.balanceOf(alice), 4 ether * RATE); assertEq(weth.balanceOf(address(love)), 4 ether); } function test_DepositZero() public { _deposit(alice, 0); assertEq(love.balanceOf(alice), 0); assertEq(love.totalSupply(), 0); } /// @dev WETH9 predates custom errors and guards `transferFrom` with a bare /// `require`, so a shortfall comes back as a revert carrying no data /// at all rather than as a typed ERC-20 error. Asserting the empty /// data keeps that documented: anything integrating with LOVE has a /// reason to fail, but not a machine-readable one. function test_RevertWhen_DepositWithoutApproval() public { vm.prank(alice); vm.expectRevert(bytes("")); love.deposit(1 ether); } function test_RevertWhen_DepositExceedsWethBalance() public { vm.prank(alice); weth.approve(address(love), 100 ether); vm.prank(alice); vm.expectRevert(bytes("")); love.deposit(100 ether); } function test_RevertWhen_DepositOverflows() public { vm.prank(alice); vm.expectRevert(); love.deposit(type(uint256).max); } /*////////////////////////////////////////////////////////////// WITHDRAW //////////////////////////////////////////////////////////////*/ function test_Withdraw() public { _deposit(alice, 1 ether); vm.prank(alice); love.withdraw(1 ether * RATE); assertEq(love.balanceOf(alice), 0); assertEq(love.totalSupply(), 0); assertEq(weth.balanceOf(alice), 10 ether); assertEq(weth.balanceOf(address(love)), 0); } function test_PartialWithdraw() public { _deposit(alice, 1 ether); vm.prank(alice); love.withdraw(0.25 ether * RATE); assertEq(love.balanceOf(alice), 0.75 ether * RATE); assertEq(weth.balanceOf(alice), 9.25 ether); assertEq(weth.balanceOf(address(love)), 0.75 ether); } function test_WithdrawEmitsWithdraw() public { _deposit(alice, 1 ether); vm.expectEmit(true, false, false, true); emit Love.Withdraw(alice, 1 ether * RATE, 1 ether); vm.prank(alice); love.withdraw(1 ether * RATE); } /// @dev LOVE received from someone else is redeemable all the same. function test_WithdrawAfterTransfer() public { _deposit(alice, 1 ether); vm.prank(alice); // forge-lint: disable-next-line(erc20-unchecked-transfer) love.transfer(bob, 1 ether * RATE); vm.prank(bob); love.withdraw(1 ether * RATE); assertEq(weth.balanceOf(bob), 11 ether); assertEq(love.totalSupply(), 0); } function test_WithdrawZero() public { _deposit(alice, 1 ether); vm.prank(alice); love.withdraw(0); assertEq(love.balanceOf(alice), 1 ether * RATE); } /// @dev Anything not a multiple of the rate would round wETH out of the peg. function test_RevertWhen_WithdrawNotDivisibleByRate() public { _deposit(alice, 1 ether); vm.prank(alice); vm.expectRevert(abi.encodeWithSelector(Love.AmountNotDivisibleByRate.selector, RATE + 1, RATE)); love.withdraw(RATE + 1); } function test_RevertWhen_WithdrawExceedsBalance() public { _deposit(alice, 1 ether); vm.prank(alice); vm.expectRevert( abi.encodeWithSelector( IERC20Errors.ERC20InsufficientBalance.selector, alice, 1 ether * RATE, 2 ether * RATE ) ); love.withdraw(2 ether * RATE); } /// @dev One holder cannot redeem against another holder's collateral. function test_RevertWhen_WithdrawWithoutDepositing() public { _deposit(alice, 1 ether); vm.prank(stranger); vm.expectRevert( abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, stranger, 0, 1 ether * RATE) ); love.withdraw(1 ether * RATE); } /*////////////////////////////////////////////////////////////// TRANSFER //////////////////////////////////////////////////////////////*/ function test_Transfer() public { _deposit(alice, 1 ether); vm.prank(alice); assertTrue(love.transfer(bob, 4e18)); assertEq(love.balanceOf(alice), 1 ether * RATE - 4e18); assertEq(love.balanceOf(bob), 4e18); } function test_RevertWhen_TransferExceedsBalance() public { vm.prank(alice); vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, 2e18)); // forge-lint: disable-next-line(erc20-unchecked-transfer) love.transfer(bob, 2e18); } /*////////////////////////////////////////////////////////////// APPROVE / TRANSFERFROM //////////////////////////////////////////////////////////////*/ function test_ApproveAndTransferFrom() public { _deposit(alice, 1 ether); vm.prank(alice); assertTrue(love.approve(bob, 6e18)); assertEq(love.allowance(alice, bob), 6e18); vm.prank(bob); assertTrue(love.transferFrom(alice, stranger, 6e18)); assertEq(love.balanceOf(alice), 1 ether * RATE - 6e18); assertEq(love.balanceOf(stranger), 6e18); assertEq(love.allowance(alice, bob), 0); } function test_RevertWhen_TransferFromExceedsAllowance() public { _deposit(alice, 1 ether); vm.prank(alice); love.approve(bob, 1e18); vm.prank(bob); vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, bob, 1e18, 2e18)); // forge-lint: disable-next-line(erc20-unchecked-transfer) love.transferFrom(alice, stranger, 2e18); } /*////////////////////////////////////////////////////////////// PEG //////////////////////////////////////////////////////////////*/ function test_RoundTripIsLossless() public { uint256 before = weth.balanceOf(alice); _deposit(alice, 3 ether); vm.prank(alice); love.withdraw(3 ether * RATE); assertEq(weth.balanceOf(alice), before); assertEq(love.balanceOf(alice), 0); assertEq(love.totalSupply(), 0); } function test_SupplyStaysFullyBacked() public { _deposit(alice, 1 ether); _deposit(bob, 2 ether); assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE); vm.prank(alice); love.withdraw(0.5 ether * RATE); assertEq(love.totalSupply(), weth.balanceOf(address(love)) * RATE); } /// @dev wETH sent straight to the contract is not claimable — it only lifts /// the backing, it never mints LOVE. function test_DonatedWethDoesNotMint() public { _deposit(alice, 1 ether); vm.prank(bob); // forge-lint: disable-next-line(erc20-unchecked-transfer) weth.transfer(address(love), 5 ether); assertEq(love.totalSupply(), 1 ether * RATE); assertEq(weth.balanceOf(address(love)), 6 ether); } /*////////////////////////////////////////////////////////////// FUZZ //////////////////////////////////////////////////////////////*/ function testFuzz_DepositByAnyCaller(address caller, uint256 wethAmount) public { vm.assume(caller != address(0) && caller != address(love) && caller != address(weth)); wethAmount = bound(wethAmount, 0, type(uint256).max / RATE); _fund(caller, wethAmount); _deposit(caller, wethAmount); assertEq(love.balanceOf(caller), wethAmount * RATE); assertEq(love.totalSupply(), wethAmount * RATE); assertEq(weth.balanceOf(address(love)), wethAmount); } function testFuzz_RoundTrip(uint256 wethAmount) public { wethAmount = bound(wethAmount, 0, type(uint256).max / RATE); _fund(alice, wethAmount); uint256 before = weth.balanceOf(alice); _deposit(alice, wethAmount); vm.prank(alice); love.withdraw(wethAmount * RATE); assertEq(weth.balanceOf(alice), before); assertEq(love.totalSupply(), 0); } function testFuzz_WithdrawRevertsUnlessDivisible(uint256 loveAmount) public { _deposit(alice, 10 ether); vm.prank(alice); if (loveAmount % RATE == 0 && loveAmount <= 10 ether * RATE) { love.withdraw(loveAmount); assertEq(weth.balanceOf(address(love)), 10 ether - loveAmount / RATE); } else { vm.expectRevert(); love.withdraw(loveAmount); } } /// @dev Gives `account` `amount` wETH by wrapping fresh ETH. function _fund(address account, uint256 amount) internal { vm.deal(account, amount); vm.prank(account); weth.deposit{value: amount}(); } /// @dev Approves and deposits `wethAmount` as `account`, in one step. function _deposit(address account, uint256 wethAmount) internal { vm.startPrank(account); weth.approve(address(love), wethAmount); love.deposit(wethAmount); vm.stopPrank(); } }