From 6c59c74dee8edcf5040d4c833c9699d6549e32f9 Mon Sep 17 00:00:00 2001 From: davidbrai Date: Fri, 27 Dec 2024 11:57:11 +0100 Subject: [PATCH 1/6] add steth token buyer --- src/STETHTokenBuyer.sol | 455 ++++++++++++++ test/STETHTokenBuyer.t.sol | 1021 +++++++++++++++++++++++++++++++ test/helpers/MaliciousBuyer.sol | 54 ++ test/helpers/STETHBuyerBot.sol | 50 ++ 4 files changed, 1580 insertions(+) create mode 100644 src/STETHTokenBuyer.sol create mode 100644 test/STETHTokenBuyer.t.sol create mode 100644 test/helpers/STETHBuyerBot.sol diff --git a/src/STETHTokenBuyer.sol b/src/STETHTokenBuyer.sol new file mode 100644 index 0000000..97b9ed4 --- /dev/null +++ b/src/STETHTokenBuyer.sol @@ -0,0 +1,455 @@ +// SPDX-License-Identifier: GPL-3.0 + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +pragma solidity ^0.8.17; + +import { Ownable } from 'openzeppelin-contracts/contracts/access/Ownable.sol'; +import { Pausable } from 'openzeppelin-contracts/contracts/security/Pausable.sol'; +import { IERC20Metadata } from 'openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol'; +import { SafeERC20 } from 'openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol'; +import { ReentrancyGuard } from 'openzeppelin-contracts/contracts/security/ReentrancyGuard.sol'; +import { Math } from 'openzeppelin-contracts/contracts/utils/math/Math.sol'; +import { IPriceFeed } from './IPriceFeed.sol'; +import { IBuyETHCallback } from './IBuyETHCallback.sol'; +import { IPayer } from './IPayer.sol'; + +/// @title STETHTokenBuyer +/// @notice Buys ERC20 tokens for STETH at oracle prices +/// It limits the amount of tokens it wants to buy using 2 factors: +/// 1. The amount of debt registered in a `Payer` contract +/// 2. A minimal "buffer" amount of tokens it wants to maintain +contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { + using SafeERC20 for IERC20Metadata; + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + ERRORS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + error FailedSendingETH(bytes data); + error FailedWithdrawingETH(bytes data); + error ReceivedInsufficientTokens(uint256 expected, uint256 actual); + error OnlyAdminOrOwner(); + error InvalidBotDiscountBPs(); + error InvalidBaselinePaymentTokenAmount(); + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + EVENTS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + event SoldSTETH(address indexed to, uint256 ethOut, uint256 tokenIn); + event BotDiscountBPsSet(uint16 oldBPs, uint16 newBPs); + event BaselinePaymentTokenAmountSet(uint256 oldAmount, uint256 newAmount); + event ETHWithdrawn(address indexed to, uint256 amount); + event MinAdminBotDiscountBPsSet(uint16 oldBPs, uint16 newBPs); + event MaxAdminBotDiscountBPsSet(uint16 oldBPs, uint16 newBPs); + event MinAdminBaselinePaymentTokenAmountSet(uint256 oldAmount, uint256 newAmount); + event MaxAdminBaselinePaymentTokenAmountSet(uint256 oldAmount, uint256 newAmount); + event PriceFeedSet(address oldFeed, address newFeed); + event PayerSet(address oldPayer, address newPayer); + event AdminSet(address oldAdmin, address newAdmin); + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + IMMUTABLES + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + uint256 public constant MAX_BPS = 10_000; + + /// @notice The ERC20 token the owner of this contract wants to exchange for ETH + IERC20Metadata public immutable paymentToken; + + IERC20Metadata public immutable stETH; + + /// @notice 10**paymentTokenDecimals, for the calculation for ETH price + uint256 public immutable paymentTokenDecimalsDigits; + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + STORAGE VARIABLES + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + /// @notice a `Payer` contract to which `TokenBuyer` sends the ERC20 tokens. Also used for checking how much debt there is + IPayer public payer; + + /// @notice The contract used to fetch the price of ETH in `paymentToken` + IPriceFeed public priceFeed; + + /// @notice The minimum `paymentToken` balance the `payer` contract should have + uint256 public baselinePaymentTokenAmount; + + /// @notice The minimum allowed value for `baselinePaymentTokenAmount` + uint256 public minAdminBaselinePaymentTokenAmount; + + /// @notice The maximum allowed value for `baselinePaymentTokenAmount` + uint256 public maxAdminBaselinePaymentTokenAmount; + + /// @notice the amount of basis points to decrease the price by, to increase the incentive to transact with this contract + uint16 public botDiscountBPs; + + /// @notice The minimum discount allowed in bps + uint16 public minAdminBotDiscountBPs; + + /// @notice The maximum discount allowed in bps + uint16 public maxAdminBotDiscountBPs; + + /// @notice Contract admin, allowed to do certain lower risk operations + address public admin; + + /// @notice The contract that stETH will be transfered from + address public treasury; + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + MODIFIERS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + modifier onlyAdminOrOwner() { + if (admin != msg.sender && owner() != msg.sender) { + revert OnlyAdminOrOwner(); + } + _; + } + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + CONSTRUCTOR + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + constructor( + IPriceFeed _priceFeed, + uint256 _baselinePaymentTokenAmount, + uint256 _minAdminBaselinePaymentTokenAmount, + uint256 _maxAdminBaselinePaymentTokenAmount, + uint16 _botDiscountBPs, + uint16 _minAdminBotDiscountBPs, + uint16 _maxAdminBotDiscountBPs, + address _owner, + address _admin, + address _payer, + address _stETH, + address _treasury + ) { + payer = IPayer(_payer); + + address _paymentToken = address(payer.paymentToken()); + paymentToken = IERC20Metadata(_paymentToken); + paymentTokenDecimalsDigits = 10**IERC20Metadata(_paymentToken).decimals(); + priceFeed = _priceFeed; + + baselinePaymentTokenAmount = _baselinePaymentTokenAmount; + minAdminBaselinePaymentTokenAmount = _minAdminBaselinePaymentTokenAmount; + maxAdminBaselinePaymentTokenAmount = _maxAdminBaselinePaymentTokenAmount; + + if ( + (_botDiscountBPs > MAX_BPS) || + (_maxAdminBotDiscountBPs > MAX_BPS) || + (_minAdminBotDiscountBPs > _maxAdminBotDiscountBPs) + ) { + revert InvalidBotDiscountBPs(); + } + botDiscountBPs = _botDiscountBPs; + minAdminBotDiscountBPs = _minAdminBotDiscountBPs; + maxAdminBotDiscountBPs = _maxAdminBotDiscountBPs; + + _transferOwnership(_owner); + admin = _admin; + stETH = IERC20Metadata(_stETH); + treasury = _treasury; + } + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + EXTERNAL TRANSACTIONS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + /// @notice Buy STETH from this contract in exchange for `paymentToken` tokens. + /// The price is determined using `priceFeed` plus `botDiscountBPs` + /// Immediately invokes `payer` to pay back outstanding debt + /// @dev Caps `tokenAmount` by the amount of tokens the contract needs + /// @param tokenAmount the amount of ERC20 tokens msg.sender wishes to sell to this contract in exchange for ETH + function buySTETH(uint256 tokenAmount) external nonReentrant whenNotPaused { + uint256 amount = Math.min(tokenAmount, tokenAmountNeeded()); + + // Cache payer + IPayer _payer = payer; + + // Transfer tokens from msg.sender to `payer` + paymentToken.safeTransferFrom(msg.sender, address(_payer), amount); + + // Invoke `payer` to pay back outstanding debt + _payer.payBackDebt(amount); + + // Send msg.sender STETH + uint256 ethAmount = stethAmountPerTokenAmount(amount); + safeSendSTETH(msg.sender, ethAmount); + + emit SoldSTETH(msg.sender, ethAmount, amount); + } + + /// @notice Buy ETH from this contract in exchange for `paymentToken` tokens. + /// The price is determined using `priceFeed` plus `botDiscountBPs` + /// Immediately invokes `payer` to pay back outstanding debt + /// @dev First sends ETH by calling a callback, and then checks it received tokens. + /// This allowed the caller to swap the ETH for tokens instead of holding tokens in advance + /// @param tokenAmount the amount of ERC20 tokens msg.sender wishes to sell to this contract in exchange for ETH + /// @param to the address to send ETH to by calling the callback function on it + /// @param data arbitrary data passed through by the caller, usually used for callback verification + function buySTETH( + uint256 tokenAmount, + address to, + bytes calldata data + ) external nonReentrant whenNotPaused { + uint256 amount = Math.min(tokenAmount, tokenAmountNeeded()); + + IPayer _payer = payer; + + // Starting balance of `payer` + uint256 balanceBefore = paymentToken.balanceOf(address(_payer)); + + // Send ETH to `to` + uint256 ethAmount = stethAmountPerTokenAmount(amount); + safeSendSTETH(to, ethAmount); + IBuyETHCallback(to).buyETHCallback(msg.sender, amount, data); + + // Check that `payers` balance increased by the expected amount + uint256 tokensReceived = paymentToken.balanceOf(address(_payer)) - balanceBefore; + if (tokensReceived < amount) { + revert ReceivedInsufficientTokens(amount, tokensReceived); + } + + // Invoke `payer` to pay back outstanding debt + _payer.payBackDebt(tokensReceived); + + emit SoldSTETH(to, ethAmount, tokensReceived); + } + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + VIEW FUNCTIONS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + /// @notice Get how much STETH this contract needs in order to fund its current obligations plus `additionalTokens`, with + /// a safety buffer `bufferBPs` basis points. + /// @param additionalTokens an additional amount of `paymentToken` liability to use in this STETH requirement calculation, in payment token decimals. + /// @param bufferBPs the number of basis points to add on top of the token liability price in STETH as a safety buffer, e.g. + /// if `bufferBPs` is 10K, the function will return twice the amount it needs according to price alone. + /// @return the amount of STETH needed + function stethNeeded(uint256 additionalTokens, uint256 bufferBPs) public view returns (uint256) { + uint256 tokenAmount = tokenAmountNeeded() + additionalTokens; + uint256 ethCostOfTokens = stethAmountPerTokenAmount(tokenAmount); + uint256 ethCostWithBuffer = (ethCostOfTokens * (bufferBPs + 10_000)) / 10_000; + + if (address(this).balance > ethCostWithBuffer) { + return 0; + } else { + return ethCostWithBuffer - address(this).balance; + } + } + + /// @notice Returns the amount of tokens this contract is willing to exchange of ETH + /// @return amount of tokens + function tokenAmountNeeded() public view returns (uint256) { + IPayer _payer = payer; + uint256 _tokensAvailable = paymentToken.balanceOf(address(_payer)); + uint256 totalDebt = _payer.totalDebt(); + unchecked { + uint256 neededTokens = baselinePaymentTokenAmount + totalDebt; + if (_tokensAvailable > neededTokens) { + return 0; + } + return neededTokens - _tokensAvailable; + } + } + + /// @notice Returns the ETH/`paymentToken` price this contract is willing to exchange ETH at, including the discount + /// @return The price, in 18 decimal format + function price() public view returns (uint256) { + unchecked { + return (priceFeed.price() * (10_000 - botDiscountBPs)) / 10_000; + } + } + + /// @notice Returns the amount of ETH this contract will send in exchange for `tokenAmount` tokens + /// @param tokenAmount the amount of tokens + /// @return amount of ETH the contract will sell for `tokenAmount` of tokens + function stethAmountPerTokenAmount(uint256 tokenAmount) public view returns (uint256) { + unchecked { + // Example: + // if tokenAmount == 3400000000 (3400 USDC) (6 decimals) + // and price() == 1745910000000000000000 (1745.91) (18 decimals) + // ((3400000000 * 1e36) / 1745910000000000000000) / 1e6 = 1.947408515e18 (3400/1745.91) + return ((tokenAmount * 1e36) / price()) / paymentTokenDecimalsDigits; + } + } + + /// @notice Returns the amount of tokens the contract can buy and the amount of STETH it will pay for it + /// This takes into account the current STETH balance this contract has + /// @return tokenAmount amount of tokens the contract can buy + /// @return ethAmount amount of STETH it will pay for the tokens + function tokenAmountNeededAndSTETHPayout() public view returns (uint256, uint256) { + uint256 tokenAmount = tokenAmountNeeded(); + uint256 ethAmount = stethAmountPerTokenAmount(tokenAmount); + uint256 ethAvailable = address(this).balance; + + if (ethAvailable >= ethAmount) { + return (tokenAmount, ethAmount); + } else { + // Tokens amount will be rounded down to avoid trying to buy more eth than available + tokenAmount = tokenAmountPerSTEthAmount(ethAvailable); + + // Recalculate eth amount because tokens amount are rounded down + ethAmount = stethAmountPerTokenAmount(tokenAmount); + + return (tokenAmount, ethAmount); + } + } + + /// @notice Returns the amount of tokens the contract expects in return for steth + /// @param stethAmount amount of STETH contract to be swapped + /// @return amount of tokens the contract will sell the ETH for + /// @dev result is rounded down + function tokenAmountPerSTEthAmount(uint256 stethAmount) public view returns (uint256) { + return (stethAmount * price() * paymentTokenDecimalsDigits) / 1e36; + } + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + ADMIN or OWNER TRANSACTIONS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + /// @notice Update `botDiscountBPs` + function setBotDiscountBPs(uint16 newBotDiscountBPs) external onlyAdminOrOwner { + // Admin is limited to min-max range, owner is not + if ( + admin == msg.sender && + (newBotDiscountBPs < minAdminBotDiscountBPs || newBotDiscountBPs > maxAdminBotDiscountBPs) + ) { + revert InvalidBotDiscountBPs(); + } + + emit BotDiscountBPsSet(botDiscountBPs, newBotDiscountBPs); + + botDiscountBPs = newBotDiscountBPs; + } + + /// @notice Update `baselinePaymentTokenAmount` + /// @param newBaselinePaymentTokenAmount the new `baselinePaymentTokenAmount` in token decimals. + function setBaselinePaymentTokenAmount(uint256 newBaselinePaymentTokenAmount) external onlyAdminOrOwner { + // Admin is limited to min-max range, owner is not + if ( + admin == msg.sender && + (newBaselinePaymentTokenAmount < minAdminBaselinePaymentTokenAmount || + newBaselinePaymentTokenAmount > maxAdminBaselinePaymentTokenAmount) + ) { + revert InvalidBaselinePaymentTokenAmount(); + } + + emit BaselinePaymentTokenAmountSet(baselinePaymentTokenAmount, newBaselinePaymentTokenAmount); + + baselinePaymentTokenAmount = newBaselinePaymentTokenAmount; + } + + /// @notice pause ETH buying + function pause() external onlyAdminOrOwner { + _pause(); + } + + /// @notice unpause ETH buying + function unpause() external onlyAdminOrOwner { + _unpause(); + } + + /// @notice set a new Admin + function setAdmin(address newAdmin) external onlyAdminOrOwner { + emit AdminSet(admin, newAdmin); + + admin = newAdmin; + } + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + OWNER TRANSACTIONS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + /// @notice Update minAdminBotDiscountBPs + function setMinAdminBotDiscountBPs(uint16 newMinAdminBotDiscountBPs) external onlyOwner { + emit MinAdminBotDiscountBPsSet(minAdminBotDiscountBPs, newMinAdminBotDiscountBPs); + + minAdminBotDiscountBPs = newMinAdminBotDiscountBPs; + } + + /// @notice Update maxAdminBotDiscountBPs + function setMaxAdminBotDiscountBPs(uint16 newMaxAdminBotDiscountBPs) external onlyOwner { + emit MaxAdminBotDiscountBPsSet(maxAdminBotDiscountBPs, newMaxAdminBotDiscountBPs); + + maxAdminBotDiscountBPs = newMaxAdminBotDiscountBPs; + } + + /// @notice Update minAdminBaselinePaymentTokenAmount + function setMinAdminBaselinePaymentTokenAmount(uint256 newMinAdminBaselinePaymentTokenAmount) external onlyOwner { + emit MinAdminBaselinePaymentTokenAmountSet( + minAdminBaselinePaymentTokenAmount, + newMinAdminBaselinePaymentTokenAmount + ); + + minAdminBaselinePaymentTokenAmount = newMinAdminBaselinePaymentTokenAmount; + } + + /// @notice Update maxAdminBaselinePaymentTokenAmount + function setMaxAdminBaselinePaymentTokenAmount(uint256 newMaxAdminBaselinePaymentTokenAmount) external onlyOwner { + emit MaxAdminBaselinePaymentTokenAmountSet( + maxAdminBaselinePaymentTokenAmount, + newMaxAdminBaselinePaymentTokenAmount + ); + + maxAdminBaselinePaymentTokenAmount = newMaxAdminBaselinePaymentTokenAmount; + } + + /// @notice Update priceFeed + function setPriceFeed(IPriceFeed newPriceFeed) external onlyOwner { + emit PriceFeedSet(address(priceFeed), address(newPriceFeed)); + + priceFeed = newPriceFeed; + } + + /// @notice Update `payer` + function setPayer(address newPayer) external onlyOwner { + emit PayerSet(address(payer), newPayer); + + payer = IPayer(newPayer); + } + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + INTERNAL FUNCTIONS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + function safeSendSTETH(address to, uint256 ethAmount) internal { + stETH.safeTransferFrom(treasury, to, ethAmount); + } +} diff --git a/test/STETHTokenBuyer.t.sol b/test/STETHTokenBuyer.t.sol new file mode 100644 index 0000000..4cbeb13 --- /dev/null +++ b/test/STETHTokenBuyer.t.sol @@ -0,0 +1,1021 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import 'forge-std/Test.sol'; +import { STETHTokenBuyer } from '../src/STETHTokenBuyer.sol'; +import { Payer } from '../src/Payer.sol'; +import { TestERC20 } from './helpers/TestERC20.sol'; +import { TestPriceFeed } from './helpers/TestPriceFeed.sol'; +import { STETHMaliciousBuyer } from './helpers/MaliciousBuyer.sol'; +import { IBuyETHCallback } from '../src/IBuyETHCallback.sol'; +import { STETHBuyerBot } from './helpers/STETHBuyerBot.sol'; + +contract STETHTokenBuyerTest is Test { + bytes constant STUB_CALLDATA = 'stub calldata'; + bytes constant OWNABLE_ERROR_STRING = 'Ownable: caller is not the owner'; + bytes4 constant ERROR_SELECTOR = 0x08c379a0; // See: https://docs.soliditylang.org/en/v0.8.16/control-structures.html?highlight=0x08c379a0 + + event SoldSTETH(address indexed to, uint256 ethOut, uint256 tokenIn); + event BotDiscountBPsSet(uint16 oldBPs, uint16 newBPs); + event BaselinePaymentTokenAmountSet(uint256 oldAmount, uint256 newAmount); + // event ETHWithdrawn(address indexed to, uint256 amount); + event MinAdminBotDiscountBPsSet(uint16 oldBPs, uint16 newBPs); + event MaxAdminBotDiscountBPsSet(uint16 oldBPs, uint16 newBPs); + event MinAdminBaselinePaymentTokenAmountSet(uint256 oldAmount, uint256 newAmount); + event MaxAdminBaselinePaymentTokenAmountSet(uint256 oldAmount, uint256 newAmount); + event PriceFeedSet(address oldFeed, address newFeed); + event PayerSet(address oldPayer, address newPayer); + event AdminSet(address oldAdmin, address newAdmin); + + STETHTokenBuyer buyer; + Payer payer; + TestERC20 paymentToken; + TestERC20 stETH; + TestPriceFeed priceFeed; + + uint256 baselinePaymentTokenAmount = 0; + uint16 botDiscountBPs = 0; + address owner = address(0x42); + address admin = address(0x43); + address treasury = makeAddr('treasury'); + address bot = address(0x99); + address user = address(0x1234); + address botOperator = address(0x4444); + STETHBuyerBot callbackBot; + + function setUp() public { + vm.label(owner, 'owner'); + vm.label(admin, 'admin'); + vm.label(bot, 'bot'); + vm.label(user, 'user'); + paymentToken = new TestERC20('Payment Token', 'PAY', 18); + stETH = new TestERC20('stETH', 'stETH', 18); + stETH.mint(treasury, 10000 ether); + priceFeed = new TestPriceFeed(); + payer = new Payer(owner, address(paymentToken)); + buyer = new STETHTokenBuyer({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: botDiscountBPs, + _minAdminBotDiscountBPs: 0, + _maxAdminBotDiscountBPs: 10_000, + _owner: owner, + _admin: admin, + _payer: address(payer), + _stETH: address(stETH), + _treasury: treasury + }); + callbackBot = new STETHBuyerBot(address(payer), address(paymentToken), STUB_CALLDATA, botOperator); + } + + function test_bpsUnder_10000() public { + uint16 bpsTooHigh = 10001; + + vm.expectRevert(STETHTokenBuyer.InvalidBotDiscountBPs.selector); + buyer = new STETHTokenBuyer({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: bpsTooHigh, + _minAdminBotDiscountBPs: 0, + _maxAdminBotDiscountBPs: 10_000, + _owner: owner, + _admin: admin, + _payer: address(payer), + _stETH: address(0), + _treasury: address(0) + }); + + vm.expectRevert(STETHTokenBuyer.InvalidBotDiscountBPs.selector); + buyer = new STETHTokenBuyer({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: botDiscountBPs, + _minAdminBotDiscountBPs: bpsTooHigh, + _maxAdminBotDiscountBPs: 10_000, + _owner: owner, + _admin: admin, + _payer: address(payer), + _stETH: address(0), + _treasury: address(0) + }); + + vm.expectRevert(STETHTokenBuyer.InvalidBotDiscountBPs.selector); + buyer = new STETHTokenBuyer({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: botDiscountBPs, + _minAdminBotDiscountBPs: 0, + _maxAdminBotDiscountBPs: bpsTooHigh, + _owner: owner, + _admin: admin, + _payer: address(payer), + _stETH: address(0), + _treasury: address(0) + }); + } + + function test_setPriceFeed_revertsForNonOwner() public { + TestPriceFeed newFeed = new TestPriceFeed(); + + vm.expectRevert(OWNABLE_ERROR_STRING); + buyer.setPriceFeed(newFeed); + } + + function test_setPriceFeed_worksForOwner() public { + TestPriceFeed newFeed = new TestPriceFeed(); + assertTrue(address(newFeed) != address(buyer.priceFeed())); + + vm.prank(owner); + buyer.setMaxAdminBotDiscountBPs(142); + + vm.expectEmit(true, true, true, true); + emit PriceFeedSet(address(buyer.priceFeed()), address(newFeed)); + vm.prank(owner); + buyer.setPriceFeed(newFeed); + + assertEq(address(buyer.priceFeed()), address(newFeed)); + } + + function test_tokenAmountNeeded_baselineAmountOnly() public { + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(100_000e18); + + assertEq(buyer.tokenAmountNeeded(), 100_000e18); + } + + function test_tokenAmountNeeded_debtOnly() public { + vm.prank(address(owner)); + payer.sendOrRegisterDebt(address(1), 42_000e18); + + assertEq(buyer.tokenAmountNeeded(), 42_000e18); + } + + function test_tokenAmountNeeded_paymentTokenBalanceOnly() public { + paymentToken.mint(address(payer), 42_000e18); + + assertEq(buyer.tokenAmountNeeded(), 0); + } + + function test_tokenAmountNeeded_baselineAndPaymentTokenBalance() public { + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(100_000e18); + paymentToken.mint(address(payer), 42_000e18); + + assertEq(buyer.tokenAmountNeeded(), 58_000e18); + } + + function test_tokenAmountNeeded_baselineAndPaymentTokenBalanceAndDebt() public { + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(100_000e18); + vm.prank(owner); + payer.sendOrRegisterDebt(address(1), 11_000e18); + + paymentToken.mint(address(payer), 42_000e18); + + assertEq(buyer.tokenAmountNeeded(), 69_000e18); + } + + function test_tokenAmountPerEthAmount() public { + priceFeed.setPrice(1358.37e18); + uint256 ethAmount = 1.333 ether; + uint256 tokenAmount = buyer.tokenAmountPerSTEthAmount(ethAmount); + + assertEq(tokenAmount, 1810.70721e18); + } + + function test_tokenAmountPerEthAmount_roundsDown() public { + uint256 ethAmount = 100000000000000000; // 0.1 ether + uint256 price = 111111111111111111111; // 111.111111111111111111 + + priceFeed.setPrice(price); + + uint256 tokenAmount = buyer.tokenAmountPerSTEthAmount(ethAmount); + uint256 ethAmount2 = buyer.stethAmountPerTokenAmount(tokenAmount); + + assertLt(ethAmount2, ethAmount); + } + + function test_tokenAmountNeededAndETHPayout_baselineAmountOnly() public { + vm.deal(address(buyer), 50 ether); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(100_000e18); + priceFeed.setPrice(2000e18); + + (uint256 tokenAmount, uint256 ethAmount) = buyer.tokenAmountNeededAndSTETHPayout(); + + assertEq(tokenAmount, 100_000e18); + assertEq(ethAmount, 50 ether); + } + + function test_tokenAmountNeededAndETHPayout_lowersTokensIfItBuysMoreEthThanAvailable() public { + paymentToken = new TestERC20('A', 'B', 6); + payer = new Payer(owner, address(paymentToken)); + + buyer = new STETHTokenBuyer({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: botDiscountBPs, + _minAdminBotDiscountBPs: 0, + _maxAdminBotDiscountBPs: 10_000, + _owner: owner, + _admin: admin, + _payer: address(payer), + _stETH: address(0), + _treasury: address(0) + }); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(100_000e6); + + vm.deal(address(buyer), 8 ether); + + priceFeed.setPrice(1350717518812290000000); + (uint256 tokenAmount, uint256 ethAmount) = buyer.tokenAmountNeededAndSTETHPayout(); + + uint256 ethAmount2 = buyer.stethAmountPerTokenAmount(tokenAmount); + + assertEq(ethAmount, ethAmount2); + } + + function test_tokenAmountNeededAndETHPayout_lowersTokensIfItBuysMoreEthThanAvailable_fuzz( + uint256 ethBalance, + uint256 price, + uint256 decimals, + uint256 tokensNeeded + ) public { + decimals = bound(decimals, 6, 18); + ethBalance = bound(ethBalance, 0, 1e12 ether); + price = bound(price, 1e18, 1e9 * 1e18); + tokensNeeded = bound(tokensNeeded, 0, (10_000_000 * 10) ^ decimals); + + paymentToken = new TestERC20('A', 'B', uint8(decimals)); + payer = new Payer(owner, address(paymentToken)); + + buyer = new STETHTokenBuyer({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: botDiscountBPs, + _minAdminBotDiscountBPs: 0, + _maxAdminBotDiscountBPs: 10_000, + _owner: owner, + _admin: admin, + _payer: address(payer), + _stETH: address(0), + _treasury: address(0) + }); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(tokensNeeded); + + vm.deal(address(buyer), ethBalance); + + priceFeed.setPrice(price); + (uint256 tokenAmount, uint256 ethAmount) = buyer.tokenAmountNeededAndSTETHPayout(); + + uint256 ethAmount2 = buyer.stethAmountPerTokenAmount(tokenAmount); + + assertEq(ethAmount, ethAmount2); + } + + function test_tokenAmountNeededAndETHPayout_lessEthAvailable() public { + vm.deal(address(buyer), 5 ether); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(100_000e18); + priceFeed.setPrice(2000e18); + + (uint256 tokenAmount, uint256 ethAmount) = buyer.tokenAmountNeededAndSTETHPayout(); + + assertEq(tokenAmount, 10_000e18); + assertEq(ethAmount, 5 ether); + } + + function test_price_botDiscountZero() public { + priceFeed.setPrice(1234e18); + + uint256 price = buyer.price(); + + assertEq(price, 1234e18); + } + + function test_price_botDiscount50BPs() public { + vm.prank(owner); + buyer.setBotDiscountBPs(50); + + priceFeed.setPrice(1700e18); + + uint256 price = buyer.price(); + + // 1700 * (1-0.005) + assertEq(price, 1691.5e18); + } + + function test_price_botDiscountHalfPrice() public { + vm.prank(owner); + buyer.setBotDiscountBPs(5_000); + + priceFeed.setPrice(4242e18); + + uint256 price = buyer.price(); + + assertEq(price, 2121e18); + } + + function test_buyETH_revertsWhenPaused() public { + vm.prank(admin); + buyer.pause(); + + vm.expectRevert('Pausable: paused'); + buyer.buySTETH(1234); + } + + function test_buyETH_botBuysExactBaselineAmount() public { + // Say ETH is worth $2000, then the oracle price denominated in ETH would be + // 1 / 2000 = 0.0005 + priceFeed.setPrice(2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether); + paymentToken.mint(bot, 2000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + + vm.startPrank(bot); + paymentToken.approve(address(buyer), 2000e18); + + vm.expectEmit(true, true, true, true); + emit SoldSTETH(bot, 1 ether, 2000e18); + buyer.buySTETH(2000e18); + + vm.stopPrank(); + + assertEq(stETH.balanceOf(bot), 1 ether); + assertEq(stETH.balanceOf(treasury), 9999 ether); + assertEq(paymentToken.balanceOf(address(payer)), 2000e18); + } + + function test_buyETH_paysBackDebt() public { + // user has debt of 2000 tokens + vm.prank(owner); + payer.sendOrRegisterDebt(user, 2000e18); + assertEq(payer.debtOf(user), 2000e18); + + // bot buys ETH for 2000 tokens + priceFeed.setPrice(2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether); + paymentToken.mint(bot, 2000e18); + vm.startPrank(bot); + paymentToken.approve(address(buyer), 2000e18); + buyer.buySTETH(2000e18); + vm.stopPrank(); + + // user has been paid + assertEq(paymentToken.balanceOf(user), 2000e18); + assertEq(payer.debtOf(user), 0); + } + + function test_buyETH_botCappedToBaselineAmount() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether); + paymentToken.mint(bot, 4000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + + vm.startPrank(bot); + paymentToken.approve(address(buyer), 4000e18); + + vm.expectEmit(true, true, true, true); + emit SoldSTETH(bot, 1 ether, 2000e18); + buyer.buySTETH(4000e18); + vm.stopPrank(); + + assertEq(stETH.balanceOf(bot), 1 ether); + assertEq(paymentToken.balanceOf(bot), 2000e18); + } + + function test_buyETH_revertsWhenContractHasInsufficientSTETHApproval() public { + priceFeed.setPrice(2000e18); + paymentToken.mint(bot, 2000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + assertEq(address(buyer).balance, 0); + + vm.prank(bot); + paymentToken.approve(address(buyer), 2000e18); + + vm.prank(bot); + vm.expectRevert('ERC20: insufficient allowance'); + buyer.buySTETH(2000e18); + } + + function test_buyETH_revertsWhenTreasuryHasInsufficientSTETH() public { + priceFeed.setPrice(2000e18); + paymentToken.mint(bot, 2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether); + // reduce treasury balance to 0.5 ether + vm.startPrank(treasury); + stETH.transfer(address(123), stETH.balanceOf(treasury) - 0.5 ether); + assertEq(stETH.balanceOf(treasury), 0.5 ether); + vm.stopPrank(); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + assertEq(address(buyer).balance, 0); + + vm.prank(bot); + paymentToken.approve(address(buyer), 2000e18); + + vm.prank(bot); + vm.expectRevert('ERC20: transfer amount exceeds balance'); + buyer.buySTETH(2000e18); + } + + function test_buyETH_revertsWhenTokenApprovalInsufficient() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether); + paymentToken.mint(bot, 2000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + + vm.prank(bot); + paymentToken.approve(address(buyer), 2000e18 - 1); + + vm.prank(bot); + vm.expectRevert('ERC20: insufficient allowance'); + buyer.buySTETH(2000e18); + } + + function test_buyETHWithCallback_revertsWhenPaused() public { + vm.prank(admin); + buyer.pause(); + + vm.expectRevert('Pausable: paused'); + vm.prank(botOperator); + buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + } + + function test_buyETHWithCallback_botBuysExactBaselineAmount() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether); + paymentToken.mint(address(callbackBot), 2000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + uint256 balanceBefore = stETH.balanceOf(address(callbackBot)); + + vm.expectEmit(true, true, true, true); + emit SoldSTETH(address(callbackBot), 1 ether, 2000e18); + + vm.prank(botOperator); + buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + + assertEq(stETH.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); + } + + function test_buyETHWithCallback_paysBackDebt() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether); + paymentToken.mint(address(callbackBot), 2000e18); + + vm.prank(owner); + payer.sendOrRegisterDebt(user, 2500e18); + + uint256 balanceBefore = stETH.balanceOf(address(callbackBot)); + + vm.prank(botOperator); + buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + + assertEq(stETH.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); + assertEq(paymentToken.balanceOf(user), 2000e18); + assertEq(paymentToken.balanceOf(address(payer)), 0); + assertEq(payer.debtOf(user), 500e18); + } + + function test_buyETHWithCallback_botCappedToBaselineAmount() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether); + paymentToken.mint(address(callbackBot), 4000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + uint256 balanceBefore = stETH.balanceOf(address(callbackBot)); + + vm.expectEmit(true, true, true, true); + emit SoldSTETH(address(callbackBot), 1 ether, 2000e18); + + vm.prank(botOperator); + buyer.buySTETH(4000e18, address(callbackBot), STUB_CALLDATA); + + assertEq(stETH.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); + assertEq(paymentToken.balanceOf(address(callbackBot)), 2000e18); + } + + function test_buyETHWithCallback_revertsWhenContractHasInsufficientSTETHApproval() public { + priceFeed.setPrice(2000e18); + paymentToken.mint(address(callbackBot), 4000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + // 2000 tokens at 0.0005 price = 1 ether + // setting the balance to the highest point where it should fail + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether - 1 wei); + + vm.expectRevert('ERC20: insufficient allowance'); + vm.prank(botOperator); + buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + } + + function test_buyETHWithCallback_revertsWhenTreasuryHasInsufficientSTETH() public { + priceFeed.setPrice(2000e18); + paymentToken.mint(address(callbackBot), 4000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether); + // reduce treasury balance to 0.5 ether + vm.startPrank(treasury); + stETH.transfer(address(123), stETH.balanceOf(treasury) - 0.5 ether); + assertEq(stETH.balanceOf(treasury), 0.5 ether); + vm.stopPrank(); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + + vm.expectRevert('ERC20: transfer amount exceeds balance'); + vm.prank(botOperator); + buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + } + + function test_buyETHWithCallback_revertsWhenTokenPaymentInsufficient() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether); + paymentToken.mint(address(callbackBot), 2000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + callbackBot.setTokenAmountOverride(2000e18 - 1); + callbackBot.setOverrideTokenAmount(true); + + vm.expectRevert( + abi.encodeWithSelector(STETHTokenBuyer.ReceivedInsufficientTokens.selector, 2000e18, 2000e18 - 1) + ); + vm.prank(botOperator); + buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + } + + function test_buyETHWithCallback_usesAllTokensToPayBackDebt() public { + vm.prank(owner); + payer.sendOrRegisterDebt(address(0x7777), 2000e18 + 10); + + priceFeed.setPrice(2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1 ether); + paymentToken.mint(address(callbackBot), 2000e18 + 10); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + callbackBot.setTokenAmountOverride(2000e18 + 10); + callbackBot.setOverrideTokenAmount(true); + + vm.prank(botOperator); + vm.expectEmit(true, true, true, true); + emit SoldSTETH(address(callbackBot), 1 ether, 2000e18 + 10); + buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + + assertEq(paymentToken.balanceOf(address(0x7777)), 2000e18 + 10); + } + + function test_buyETHWithCallback_maliciousBuyerCantReenter() public { + STETHMaliciousBuyer attacker = new STETHMaliciousBuyer(address(buyer), paymentToken); + priceFeed.setPrice(2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 10 ether); + paymentToken.mint(address(attacker), 2000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + + vm.expectRevert('ReentrancyGuard: reentrant call'); + attacker.reenterBuyWithCallback(2000e18); + } + + function test_buyETHWithCallback_maliciousBuyerCantReenterOtherBuyETHFunction() public { + STETHMaliciousBuyer attacker = new STETHMaliciousBuyer(address(buyer), paymentToken); + priceFeed.setPrice(2000e18); + vm.prank(treasury); + stETH.approve(address(buyer), 10 ether); + paymentToken.mint(address(attacker), 2000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + + vm.expectRevert('ReentrancyGuard: reentrant call'); + attacker.reenterBuyNoCallback(2000e18); + } + + function test_happyFlow_payingFullyInPaymentToken() public { + priceFeed.setPrice(100e18); + vm.prank(owner); + // 1% discount + buyer.setBotDiscountBPs(100); + + assertEq(buyer.price(), 99e18); + + // set buffer + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(99_990e18); + + // fund bot and buyer + paymentToken.mint(bot, 99_990e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1010 ether); + + // bots buy buffer + vm.startPrank(bot); + paymentToken.approve(address(buyer), 99_990e18); + + vm.expectEmit(true, true, true, true); + emit SoldSTETH(bot, 1010 ether, 99_990e18); + buyer.buySTETH(99_990e18); + vm.stopPrank(); + assertEq(paymentToken.balanceOf(bot), 0); + assertEq(stETH.balanceOf(bot), 1010 ether); + + // send or mint (42K) + vm.prank(owner); + payer.sendOrRegisterDebt(user, 42_000e18); + + // user gets sent that amount right away + assertEq(payer.debtOf(user), 0); + assertEq(paymentToken.balanceOf(user), 42_000e18); + + // fund bot and buyer again + paymentToken.mint(bot, 42_000e18); + + // 42000 / 99 = 424.242424242 + vm.prank(treasury); + stETH.approve(address(buyer), 424242424242424242424); + + // bots can top off what's missing (bots buy 42K) + vm.startPrank(bot); + paymentToken.approve(address(buyer), 42_000e18); + + vm.expectEmit(true, true, true, true); + emit SoldSTETH(bot, 424242424242424242424, 42_000e18); + buyer.buySTETH(42_000e18); + vm.stopPrank(); + assertEq(paymentToken.balanceOf(bot), 0); + assertEq(stETH.balanceOf(bot), 1010 ether + 424242424242424242424); + } + + function test_happyFlow_payingOverTheBuffer() public { + priceFeed.setPrice(100e18); + vm.prank(owner); + // 1% discount + buyer.setBotDiscountBPs(100); + + assertEq(buyer.price(), 99e18); + + // set buffer + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(99_990e18); + + // fund bot and buyer + paymentToken.mint(bot, 99_990e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1010 ether); + + // bots buy buffer + vm.startPrank(bot); + paymentToken.approve(address(buyer), 99_990e18); + + vm.expectEmit(true, true, true, true); + emit SoldSTETH(bot, 1010 ether, 99_990e18); + buyer.buySTETH(99_990e18); + vm.stopPrank(); + assertEq(paymentToken.balanceOf(bot), 0); + assertEq(stETH.balanceOf(bot), 1010 ether); + + // send or mint (141,990) + vm.prank(owner); + payer.sendOrRegisterDebt(user, 141_990e18); + assertEq(payer.debtOf(user), 42_000e18); + assertEq(paymentToken.balanceOf(user), 99_990e18); + + // fund bot and buyer again + paymentToken.mint(bot, 42_000e18); + + // 42000 / 99 = 424.242424242 + vm.prank(treasury); + stETH.approve(address(buyer), 424242424242424242424); + + // bots can top off what's missing (bots buy 42K) + vm.startPrank(bot); + paymentToken.approve(address(buyer), 42_000e18); + + vm.expectEmit(true, true, true, true); + emit SoldSTETH(bot, 424242424242424242424, 42_000e18); + buyer.buySTETH(42_000e18); + vm.stopPrank(); + assertEq(paymentToken.balanceOf(bot), 0); + assertEq(stETH.balanceOf(bot), 1010 ether + 424242424242424242424); + + // user's debt was paid + assertEq(payer.debtOf(user), 0); + assertEq(paymentToken.balanceOf(user), 141_990e18); + + // bots can top off what's missing (bots buy ~100K) + // fund bot and buyer again + paymentToken.mint(bot, 99_990e18); + vm.prank(treasury); + stETH.approve(address(buyer), 1010 ether); + vm.startPrank(bot); + paymentToken.approve(address(buyer), 99_990e18); + + vm.expectEmit(true, true, true, true); + emit SoldSTETH(bot, 1010 ether, 99_990e18); + buyer.buySTETH(99_990e18); + vm.stopPrank(); + assertEq(paymentToken.balanceOf(bot), 0); + assertEq(stETH.balanceOf(bot), 1010 ether + 424242424242424242424 + 1010 ether); + assertEq(paymentToken.balanceOf(address(payer)), 99_990e18); + } + + function test_setBaselinePaymentTokenAmount_adminCall_revertsGivenInputLessThanMin() public { + vm.prank(owner); + buyer.setMinAdminBaselinePaymentTokenAmount(10_000); + + vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.InvalidBaselinePaymentTokenAmount.selector)); + vm.prank(admin); + buyer.setBaselinePaymentTokenAmount(9999); + } + + function test_setBaselinePaymentTokenAmount_adminCall_revertsGivenInputGreaterThanMax() public { + vm.prank(owner); + buyer.setMaxAdminBaselinePaymentTokenAmount(10_000); + + vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.InvalidBaselinePaymentTokenAmount.selector)); + vm.prank(admin); + buyer.setBaselinePaymentTokenAmount(10_001); + } + + function test_setBaselinePaymentTokenAmount_adminCall_worksGivenValidInput() public { + vm.startPrank(owner); + buyer.setMinAdminBaselinePaymentTokenAmount(10_000); + buyer.setMaxAdminBaselinePaymentTokenAmount(100_000); + vm.stopPrank(); + vm.expectEmit(true, true, true, true); + emit BaselinePaymentTokenAmountSet(0, 50_000); + + vm.prank(admin); + buyer.setBaselinePaymentTokenAmount(50_000); + + assertEq(50_000, buyer.baselinePaymentTokenAmount()); + } + + function test_setBaselinePaymentTokenAmount_ownerCall_allowsSetGivenInputLessThanMin() public { + vm.prank(owner); + buyer.setMinAdminBaselinePaymentTokenAmount(10_000); + vm.expectEmit(true, true, true, true); + emit BaselinePaymentTokenAmountSet(0, 9999); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(9999); + + assertEq(9999, buyer.baselinePaymentTokenAmount()); + } + + function test_setBaselinePaymentTokenAmount_ownerCall_allowsSetGivenInputGreaterThanMax() public { + vm.prank(owner); + buyer.setMaxAdminBaselinePaymentTokenAmount(10_000); + vm.expectEmit(true, true, true, true); + emit BaselinePaymentTokenAmountSet(0, 10_001); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(10_001); + + assertEq(10_001, buyer.baselinePaymentTokenAmount()); + } + + function test_setBotDiscountBPs_adminCall_revertsGivenInputLessThanMin() public { + vm.prank(owner); + buyer.setMinAdminBotDiscountBPs(50); + + vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.InvalidBotDiscountBPs.selector)); + vm.prank(admin); + buyer.setBotDiscountBPs(49); + } + + function test_setBotDiscountBPs_adminCall_revertsGivenInputGreaterThanMax() public { + vm.prank(owner); + buyer.setMaxAdminBotDiscountBPs(100); + + vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.InvalidBotDiscountBPs.selector)); + vm.prank(admin); + buyer.setBotDiscountBPs(101); + } + + function test_setBotDiscountBPs_adminCall_worksGivenValidInput() public { + vm.prank(owner); + buyer.setBotDiscountBPs(74); + + vm.startPrank(owner); + buyer.setMinAdminBotDiscountBPs(50); + buyer.setMaxAdminBotDiscountBPs(100); + vm.stopPrank(); + + vm.expectEmit(true, true, true, true); + emit BotDiscountBPsSet(74, 75); + + vm.prank(admin); + buyer.setBotDiscountBPs(75); + + assertEq(75, buyer.botDiscountBPs()); + } + + function test_setBotDiscountBPs_ownerCall_allowsSetGivenInputLessThanMin() public { + vm.prank(owner); + buyer.setMinAdminBotDiscountBPs(50); + vm.expectEmit(true, true, true, true); + emit BotDiscountBPsSet(0, 49); + + vm.prank(owner); + buyer.setBotDiscountBPs(49); + + assertEq(49, buyer.botDiscountBPs()); + } + + function test_setBotDiscountBPs_ownerCall_allowsSetGivenInputGreaterThanMax() public { + vm.prank(owner); + buyer.setMaxAdminBotDiscountBPs(100); + vm.expectEmit(true, true, true, true); + emit BotDiscountBPsSet(0, 101); + + vm.prank(owner); + buyer.setBotDiscountBPs(101); + + assertEq(101, buyer.botDiscountBPs()); + } + + function test_setAdmin_worksForOwner() public { + address newAdmin = address(112233); + assertFalse(newAdmin == buyer.admin()); + vm.expectEmit(true, true, true, true); + emit AdminSet(buyer.admin(), newAdmin); + + vm.prank(owner); + buyer.setAdmin(newAdmin); + + assertEq(newAdmin, buyer.admin()); + } + + function test_setAdmin_worksForAdmin() public { + address newAdmin = address(112233); + assertFalse(newAdmin == buyer.admin()); + vm.expectEmit(true, true, true, true); + emit AdminSet(buyer.admin(), newAdmin); + + vm.prank(admin); + buyer.setAdmin(newAdmin); + + assertEq(newAdmin, buyer.admin()); + } + + function test_setAdmin_revertsForNonOwner() public { + vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.OnlyAdminOrOwner.selector)); + buyer.setAdmin(address(112233)); + } + + function test_pause_unpause_ownerCall_works() public { + vm.prank(owner); + buyer.pause(); + + assertTrue(buyer.paused()); + + vm.prank(owner); + buyer.unpause(); + + assertFalse(buyer.paused()); + } + + function test_pause_unpause_adminCall_works() public { + vm.prank(admin); + buyer.pause(); + + assertTrue(buyer.paused()); + + vm.prank(admin); + buyer.unpause(); + + assertFalse(buyer.paused()); + } + + function test_pause_unpause_revertForNonOwnerOrAdmin() public { + vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.OnlyAdminOrOwner.selector)); + buyer.pause(); + + vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.OnlyAdminOrOwner.selector)); + buyer.unpause(); + } + + function test_setPayer_worksForOwner() public { + address newPayer = address(112233); + assertFalse(newPayer == address(buyer.payer())); + vm.expectEmit(true, true, true, true); + emit PayerSet(address(buyer.payer()), newPayer); + + vm.prank(owner); + buyer.setPayer(newPayer); + + assertEq(newPayer, address(buyer.payer())); + } + + function test_setPayer_revertsForNonOwner() public { + vm.expectRevert(OWNABLE_ERROR_STRING); + buyer.setPayer(address(112233)); + } + + function test_setMinAdminBotDiscountBPs_worksForOwner() public { + vm.expectEmit(true, true, true, true); + emit MinAdminBotDiscountBPsSet(0, 42); + + vm.prank(owner); + buyer.setMinAdminBotDiscountBPs(42); + } + + function test_setMinAdminBotDiscountBPs_revertsForNonOwner() public { + vm.expectRevert(OWNABLE_ERROR_STRING); + buyer.setMinAdminBotDiscountBPs(42); + } + + function test_setMaxAdminBotDiscountBPs_worksForOwner() public { + vm.expectEmit(true, true, true, true); + emit MaxAdminBotDiscountBPsSet(10_000, 142); + + vm.prank(owner); + buyer.setMaxAdminBotDiscountBPs(142); + } + + function test_setMaxAdminBotDiscountBPs_revertsForNonOwner() public { + vm.expectRevert(OWNABLE_ERROR_STRING); + buyer.setMaxAdminBotDiscountBPs(142); + } + + function test_setMinAdminBaselinePaymentTokenAmount_worksForOwner() public { + vm.expectEmit(true, true, true, true); + emit MinAdminBaselinePaymentTokenAmountSet(0, 42); + + vm.prank(owner); + buyer.setMinAdminBaselinePaymentTokenAmount(42); + } + + function test_setMinAdminBaselinePaymentTokenAmount_revertsForNonOwner() public { + vm.expectRevert(OWNABLE_ERROR_STRING); + buyer.setMinAdminBaselinePaymentTokenAmount(42); + } + + function test_setMaxAdminBaselinePaymentTokenAmount_worksForOwner() public { + vm.expectEmit(true, true, true, true); + emit MaxAdminBaselinePaymentTokenAmountSet(10_000_000e18, 142); + + vm.prank(owner); + buyer.setMaxAdminBaselinePaymentTokenAmount(142); + } + + function test_setMaxAdminBaselinePaymentTokenAmount_revertsForNonOwner() public { + vm.expectRevert(OWNABLE_ERROR_STRING); + buyer.setMaxAdminBaselinePaymentTokenAmount(42); + } + + function test_ethNeeded() public { + priceFeed.setPrice(1400e18); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(1_000e18); + + uint256 ethNeeded = buyer.stethNeeded(100e18, 5000); + assertApproxEqAbs(ethNeeded, 1.178571429e18, 0.00001e18); + } + + function test_ethNeededIsZeroIfNothingNeeded() public { + priceFeed.setPrice(1400e18); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(1_000e18); + + vm.deal(address(buyer), 1.18 ether); + assertEq(buyer.stethNeeded(100e18, 5000), 0); + } +} diff --git a/test/helpers/MaliciousBuyer.sol b/test/helpers/MaliciousBuyer.sol index f7191ac..a42f50b 100644 --- a/test/helpers/MaliciousBuyer.sol +++ b/test/helpers/MaliciousBuyer.sol @@ -15,6 +15,16 @@ interface TokenBuyerLike { ) external; } +interface STETHTokenBuyerLike { + function buySTETH(uint256 tokenAmountWAD) external; + + function buySTETH( + uint256 tokenAmountWAD, + address to, + bytes calldata data + ) external; +} + contract MaliciousBuyer is IBuyETHCallback { TokenBuyerLike buyer; IERC20 token; @@ -66,3 +76,47 @@ contract MaliciousBuyer is IBuyETHCallback { } } } + +contract STETHMaliciousBuyer is IBuyETHCallback { + STETHTokenBuyerLike buyer; + IERC20 token; + bool calledTwice; + bool reenterWithCallback; + + constructor(address _buyer, IERC20 _token) { + buyer = STETHTokenBuyerLike(_buyer); + token = _token; + } + + function attack(uint256 tokenAmountWAD) public { + buyer.buySTETH(tokenAmountWAD); + } + + function reenterBuyWithCallback(uint256 tokenAmountWAD) public { + reenterWithCallback = true; + buyer.buySTETH(tokenAmountWAD, address(this), ''); + } + + function reenterBuyNoCallback(uint256 tokenAmountWAD) public { + reenterWithCallback = false; + buyer.buySTETH(tokenAmountWAD, address(this), ''); + } + + function buyETHCallback( + address, + uint256 amount, + bytes calldata + ) external payable { + if (reenterWithCallback) { + if (!calledTwice) { + calledTwice = true; + buyer.buySTETH(amount, address(this), ''); + } else { + token.transfer(address(buyer), amount); + } + } else { + token.approve(address(buyer), amount); + buyer.buySTETH(amount); + } + } +} diff --git a/test/helpers/STETHBuyerBot.sol b/test/helpers/STETHBuyerBot.sol new file mode 100644 index 0000000..d5578b6 --- /dev/null +++ b/test/helpers/STETHBuyerBot.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import 'forge-std/Test.sol'; +import { IBuyETHCallback } from '../../src/IBuyETHCallback.sol'; +import { IERC20 } from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; + +contract STETHBuyerBot is IBuyETHCallback, Test { + address immutable payer; + IERC20 immutable paymentToken; + + bool overrideTokenAmount; + uint256 tokenAmountOverride; + bytes dataToSend; + address operator; + + constructor( + address payer_, + address paymentToken_, + bytes memory dataToSend_, + address operator_ + ) { + payer = payer_; + paymentToken = IERC20(paymentToken_); + dataToSend = dataToSend_; + operator = operator_; + } + + function buyETHCallback( + address caller, + uint256 amount, + bytes memory data + ) external payable override { + assertEq(caller, operator); + assertEq(data, dataToSend); + + if (overrideTokenAmount) { + amount = tokenAmountOverride; + } + paymentToken.transfer(address(payer), amount); + } + + function setOverrideTokenAmount(bool overrideTokenAmount_) external { + overrideTokenAmount = overrideTokenAmount_; + } + + function setTokenAmountOverride(uint256 tokenAmountOverride_) external { + tokenAmountOverride = tokenAmountOverride_; + } +} From 10b3660f53deb1516af03bde5c223fc7b205ca3a Mon Sep 17 00:00:00 2001 From: davidbrai Date: Mon, 30 Dec 2024 14:47:29 +0100 Subject: [PATCH 2/6] change stethNeeded to return the amount of extra allowance needed --- src/STETHTokenBuyer.sol | 15 ++++++--------- test/STETHTokenBuyer.t.sol | 13 +++++++------ 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/STETHTokenBuyer.sol b/src/STETHTokenBuyer.sol index 97b9ed4..8df38f6 100644 --- a/src/STETHTokenBuyer.sol +++ b/src/STETHTokenBuyer.sol @@ -250,21 +250,18 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ */ - /// @notice Get how much STETH this contract needs in order to fund its current obligations plus `additionalTokens`, with - /// a safety buffer `bufferBPs` basis points. + /// @notice Get how much additional STETH allowance this contract needs in order to fund its current obligations plus `additionalTokens`. /// @param additionalTokens an additional amount of `paymentToken` liability to use in this STETH requirement calculation, in payment token decimals. - /// @param bufferBPs the number of basis points to add on top of the token liability price in STETH as a safety buffer, e.g. - /// if `bufferBPs` is 10K, the function will return twice the amount it needs according to price alone. - /// @return the amount of STETH needed - function stethNeeded(uint256 additionalTokens, uint256 bufferBPs) public view returns (uint256) { + /// @return the amount of additional STETH allowance needed + function stethNeeded(uint256 additionalTokens) public view returns (uint256) { uint256 tokenAmount = tokenAmountNeeded() + additionalTokens; uint256 ethCostOfTokens = stethAmountPerTokenAmount(tokenAmount); - uint256 ethCostWithBuffer = (ethCostOfTokens * (bufferBPs + 10_000)) / 10_000; + uint256 stETHAllowance = stETH.allowance(treasury, address(this)); - if (address(this).balance > ethCostWithBuffer) { + if (stETHAllowance > ethCostOfTokens) { return 0; } else { - return ethCostWithBuffer - address(this).balance; + return ethCostOfTokens - stETHAllowance; } } diff --git a/test/STETHTokenBuyer.t.sol b/test/STETHTokenBuyer.t.sol index 4cbeb13..8293889 100644 --- a/test/STETHTokenBuyer.t.sol +++ b/test/STETHTokenBuyer.t.sol @@ -999,23 +999,24 @@ contract STETHTokenBuyerTest is Test { buyer.setMaxAdminBaselinePaymentTokenAmount(42); } - function test_ethNeeded() public { + function test_stethNeeded() public { priceFeed.setPrice(1400e18); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(1_000e18); - uint256 ethNeeded = buyer.stethNeeded(100e18, 5000); - assertApproxEqAbs(ethNeeded, 1.178571429e18, 0.00001e18); + uint256 ethNeeded = buyer.stethNeeded(100e18); + assertApproxEqAbs(ethNeeded, 0.785714286e18, 0.00001e18); } - function test_ethNeededIsZeroIfNothingNeeded() public { + function test_stethNeededIsZeroIfNothingNeeded() public { priceFeed.setPrice(1400e18); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(1_000e18); - vm.deal(address(buyer), 1.18 ether); - assertEq(buyer.stethNeeded(100e18, 5000), 0); + vm.prank(treasury); + stETH.approve(address(buyer), 0.8 ether); + assertEq(buyer.stethNeeded(100e18), 0); } } From 92df5a9fd9c5da2743efddc41e15b98d2c9ad792 Mon Sep 17 00:00:00 2001 From: davidbrai Date: Mon, 30 Dec 2024 15:01:50 +0100 Subject: [PATCH 3/6] fix tokenAmountNeededAndSTETHPayout to take into account stETH allowance --- src/STETHTokenBuyer.sol | 18 +++++++++--------- test/STETHTokenBuyer.t.sol | 24 ++++++++++++++---------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/STETHTokenBuyer.sol b/src/STETHTokenBuyer.sol index 8df38f6..51777af 100644 --- a/src/STETHTokenBuyer.sol +++ b/src/STETHTokenBuyer.sol @@ -302,24 +302,24 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { } /// @notice Returns the amount of tokens the contract can buy and the amount of STETH it will pay for it - /// This takes into account the current STETH balance this contract has + /// This takes into account the current STETH allowance this contract has /// @return tokenAmount amount of tokens the contract can buy - /// @return ethAmount amount of STETH it will pay for the tokens + /// @return stethAmount amount of STETH it will pay for the tokens function tokenAmountNeededAndSTETHPayout() public view returns (uint256, uint256) { uint256 tokenAmount = tokenAmountNeeded(); - uint256 ethAmount = stethAmountPerTokenAmount(tokenAmount); - uint256 ethAvailable = address(this).balance; + uint256 stethAmount = stethAmountPerTokenAmount(tokenAmount); + uint256 stethAvailable = stETH.allowance(treasury, address(this)); - if (ethAvailable >= ethAmount) { - return (tokenAmount, ethAmount); + if (stethAvailable >= stethAmount) { + return (tokenAmount, stethAmount); } else { // Tokens amount will be rounded down to avoid trying to buy more eth than available - tokenAmount = tokenAmountPerSTEthAmount(ethAvailable); + tokenAmount = tokenAmountPerSTEthAmount(stethAvailable); // Recalculate eth amount because tokens amount are rounded down - ethAmount = stethAmountPerTokenAmount(tokenAmount); + stethAmount = stethAmountPerTokenAmount(tokenAmount); - return (tokenAmount, ethAmount); + return (tokenAmount, stethAmount); } } diff --git a/test/STETHTokenBuyer.t.sol b/test/STETHTokenBuyer.t.sol index 8293889..fd2e8f5 100644 --- a/test/STETHTokenBuyer.t.sol +++ b/test/STETHTokenBuyer.t.sol @@ -204,7 +204,8 @@ contract STETHTokenBuyerTest is Test { } function test_tokenAmountNeededAndETHPayout_baselineAmountOnly() public { - vm.deal(address(buyer), 50 ether); + vm.prank(treasury); + stETH.approve(address(buyer), 50 ether); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(100_000e18); @@ -231,14 +232,15 @@ contract STETHTokenBuyerTest is Test { _owner: owner, _admin: admin, _payer: address(payer), - _stETH: address(0), - _treasury: address(0) + _stETH: address(stETH), + _treasury: treasury }); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(100_000e6); - vm.deal(address(buyer), 8 ether); + vm.prank(treasury); + stETH.approve(address(buyer), 8 ether); priceFeed.setPrice(1350717518812290000000); (uint256 tokenAmount, uint256 ethAmount) = buyer.tokenAmountNeededAndSTETHPayout(); @@ -249,13 +251,13 @@ contract STETHTokenBuyerTest is Test { } function test_tokenAmountNeededAndETHPayout_lowersTokensIfItBuysMoreEthThanAvailable_fuzz( - uint256 ethBalance, + uint256 stethAllowance, uint256 price, uint256 decimals, uint256 tokensNeeded ) public { decimals = bound(decimals, 6, 18); - ethBalance = bound(ethBalance, 0, 1e12 ether); + stethAllowance = bound(stethAllowance, 0, 1e12 ether); price = bound(price, 1e18, 1e9 * 1e18); tokensNeeded = bound(tokensNeeded, 0, (10_000_000 * 10) ^ decimals); @@ -273,14 +275,15 @@ contract STETHTokenBuyerTest is Test { _owner: owner, _admin: admin, _payer: address(payer), - _stETH: address(0), - _treasury: address(0) + _stETH: address(stETH), + _treasury: treasury }); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(tokensNeeded); - vm.deal(address(buyer), ethBalance); + vm.prank(treasury); + stETH.approve(address(buyer), stethAllowance); priceFeed.setPrice(price); (uint256 tokenAmount, uint256 ethAmount) = buyer.tokenAmountNeededAndSTETHPayout(); @@ -291,7 +294,8 @@ contract STETHTokenBuyerTest is Test { } function test_tokenAmountNeededAndETHPayout_lessEthAvailable() public { - vm.deal(address(buyer), 5 ether); + vm.prank(treasury); + stETH.approve(address(buyer), 5 ether); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(100_000e18); From ce91fb30a727a0a1b91bc680bbe3b22a7a16d169 Mon Sep 17 00:00:00 2001 From: davidbrai Date: Tue, 31 Dec 2024 13:57:23 +0100 Subject: [PATCH 4/6] enable selling any erc20 token --- .vscode/settings.json | 4 +- src/ISwapTokensCallback.sol | 32 ++ src/{STETHTokenBuyer.sol => TokenBuyerV2.sol} | 163 +++++---- ...ETHTokenBuyer.t.sol => TokenBuyerV2.t.sol} | 336 ++++++++++-------- .../{STETHBuyerBot.sol => BuyerBot.sol} | 6 +- test/helpers/MaliciousBuyer.sol | 25 +- 6 files changed, 324 insertions(+), 242 deletions(-) create mode 100644 src/ISwapTokensCallback.sol rename src/{STETHTokenBuyer.sol => TokenBuyerV2.sol} (70%) rename test/{STETHTokenBuyer.t.sol => TokenBuyerV2.t.sol} (71%) rename test/helpers/{STETHBuyerBot.sol => BuyerBot.sol} (88%) diff --git a/.vscode/settings.json b/.vscode/settings.json index ade4ff2..d11ed8d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,5 @@ { - "solidity.formatter": "prettier" + "solidity.formatter": "prettier", + "solidity.packageDefaultDependenciesContractsDirectory": "src", + "solidity.packageDefaultDependenciesDirectory": "lib" } \ No newline at end of file diff --git a/src/ISwapTokensCallback.sol b/src/ISwapTokensCallback.sol new file mode 100644 index 0000000..60c47ba --- /dev/null +++ b/src/ISwapTokensCallback.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0 + +/// @title swapTokens Callback interface + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +pragma solidity ^0.8.17; + +interface ISwapTokensCallback { + /** + * @notice Called on the {to} in TokenBuyerV2#swapTokens, after sending it sellTokens in exchange for {amount} TokenBuyerV2#paymentToken. + * @param caller the `msg.sender` in TokenBuyerV2#swapTokens + * @param amount the TokenBuyerV2#paymentToken amount caller is buying sellTokens for + * @param data arbitrary data passed through by the caller via the TokenBuyerV2#swapTokens call + */ + function swapTokensCallback( + address caller, + uint256 amount, + bytes calldata data + ) external payable; +} diff --git a/src/STETHTokenBuyer.sol b/src/TokenBuyerV2.sol similarity index 70% rename from src/STETHTokenBuyer.sol rename to src/TokenBuyerV2.sol index 51777af..7d7ea9d 100644 --- a/src/STETHTokenBuyer.sol +++ b/src/TokenBuyerV2.sol @@ -22,15 +22,15 @@ import { SafeERC20 } from 'openzeppelin-contracts/contracts/token/ERC20/utils/Sa import { ReentrancyGuard } from 'openzeppelin-contracts/contracts/security/ReentrancyGuard.sol'; import { Math } from 'openzeppelin-contracts/contracts/utils/math/Math.sol'; import { IPriceFeed } from './IPriceFeed.sol'; -import { IBuyETHCallback } from './IBuyETHCallback.sol'; +import { ISwapTokensCallback } from './ISwapTokensCallback.sol'; import { IPayer } from './IPayer.sol'; -/// @title STETHTokenBuyer -/// @notice Buys ERC20 tokens for STETH at oracle prices +/// @title TokenBuyerV2 +/// @notice Buys a payment ERC20 token for another ERC20 at oracle prices /// It limits the amount of tokens it wants to buy using 2 factors: /// 1. The amount of debt registered in a `Payer` contract /// 2. A minimal "buffer" amount of tokens it wants to maintain -contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { +contract TokenBuyerV2 is Ownable, Pausable, ReentrancyGuard { using SafeERC20 for IERC20Metadata; /** @@ -39,8 +39,6 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ */ - error FailedSendingETH(bytes data); - error FailedWithdrawingETH(bytes data); error ReceivedInsufficientTokens(uint256 expected, uint256 actual); error OnlyAdminOrOwner(); error InvalidBotDiscountBPs(); @@ -52,10 +50,9 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ */ - event SoldSTETH(address indexed to, uint256 ethOut, uint256 tokenIn); + event SwappedTokens(address indexed to, uint256 sellTokenOut, uint256 paymentTokenIn); event BotDiscountBPsSet(uint16 oldBPs, uint16 newBPs); event BaselinePaymentTokenAmountSet(uint256 oldAmount, uint256 newAmount); - event ETHWithdrawn(address indexed to, uint256 amount); event MinAdminBotDiscountBPsSet(uint16 oldBPs, uint16 newBPs); event MaxAdminBotDiscountBPsSet(uint16 oldBPs, uint16 newBPs); event MinAdminBaselinePaymentTokenAmountSet(uint256 oldAmount, uint256 newAmount); @@ -72,13 +69,17 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { uint256 public constant MAX_BPS = 10_000; - /// @notice The ERC20 token the owner of this contract wants to exchange for ETH + /// @notice The ERC20 token the owner of this contract wants to exchange for the sellToken IERC20Metadata public immutable paymentToken; - IERC20Metadata public immutable stETH; + /// @notice The ERC20 token the contract will sell in exchange for paymentToken + IERC20Metadata public immutable sellToken; - /// @notice 10**paymentTokenDecimals, for the calculation for ETH price - uint256 public immutable paymentTokenDecimalsDigits; + /// @notice 1 unit of sellToken, e.g. 10^6 for USDC + uint256 public immutable sellTokenUnit; + + /// @notice 1 unit of paymentToken, e.g. 10^6 for USDC + uint256 public immutable paymentTokenUnit; /** ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ @@ -146,14 +147,14 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { address _owner, address _admin, address _payer, - address _stETH, + address _sellToken, address _treasury ) { payer = IPayer(_payer); address _paymentToken = address(payer.paymentToken()); paymentToken = IERC20Metadata(_paymentToken); - paymentTokenDecimalsDigits = 10**IERC20Metadata(_paymentToken).decimals(); + paymentTokenUnit = 10**IERC20Metadata(_paymentToken).decimals(); priceFeed = _priceFeed; baselinePaymentTokenAmount = _baselinePaymentTokenAmount; @@ -173,7 +174,8 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { _transferOwnership(_owner); admin = _admin; - stETH = IERC20Metadata(_stETH); + sellToken = IERC20Metadata(_sellToken); + sellTokenUnit = 10**IERC20Metadata(_sellToken).decimals(); treasury = _treasury; } @@ -183,13 +185,13 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ */ - /// @notice Buy STETH from this contract in exchange for `paymentToken` tokens. + /// @notice Buy `sellToken` from this contract in exchange for `paymentToken` tokens. /// The price is determined using `priceFeed` plus `botDiscountBPs` /// Immediately invokes `payer` to pay back outstanding debt /// @dev Caps `tokenAmount` by the amount of tokens the contract needs - /// @param tokenAmount the amount of ERC20 tokens msg.sender wishes to sell to this contract in exchange for ETH - function buySTETH(uint256 tokenAmount) external nonReentrant whenNotPaused { - uint256 amount = Math.min(tokenAmount, tokenAmountNeeded()); + /// @param paymentTokenAmount the amount of ERC20 tokens msg.sender wishes to sell to this contract + function swapTokens(uint256 paymentTokenAmount) external nonReentrant whenNotPaused { + uint256 amount = Math.min(paymentTokenAmount, paymentTokenAmountNeeded()); // Cache payer IPayer _payer = payer; @@ -201,36 +203,36 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { _payer.payBackDebt(amount); // Send msg.sender STETH - uint256 ethAmount = stethAmountPerTokenAmount(amount); - safeSendSTETH(msg.sender, ethAmount); + uint256 sellTokenAmount = sellTokenAmountPerPaymentTokenAmount(amount); + safeSendSellToken(msg.sender, sellTokenAmount); - emit SoldSTETH(msg.sender, ethAmount, amount); + emit SwappedTokens(msg.sender, sellTokenAmount, amount); } - /// @notice Buy ETH from this contract in exchange for `paymentToken` tokens. + /// @notice Buy sellToken tokens from this contract in exchange for `paymentToken` tokens. /// The price is determined using `priceFeed` plus `botDiscountBPs` /// Immediately invokes `payer` to pay back outstanding debt - /// @dev First sends ETH by calling a callback, and then checks it received tokens. - /// This allowed the caller to swap the ETH for tokens instead of holding tokens in advance - /// @param tokenAmount the amount of ERC20 tokens msg.sender wishes to sell to this contract in exchange for ETH - /// @param to the address to send ETH to by calling the callback function on it + /// @dev First sends sellToken by calling a callback, and then checks it received payment tokens. + /// This allowed the caller to swap the sellToken for tokens instead of holding tokens in advance. + /// @param paymentTokenAmount the amount of paymentToken tokens msg.sender wishes to sell to this contract in exchange for sellToken + /// @param to the address to send sellToken to by calling the callback function on it /// @param data arbitrary data passed through by the caller, usually used for callback verification - function buySTETH( - uint256 tokenAmount, + function swapTokens( + uint256 paymentTokenAmount, address to, bytes calldata data ) external nonReentrant whenNotPaused { - uint256 amount = Math.min(tokenAmount, tokenAmountNeeded()); + uint256 amount = Math.min(paymentTokenAmount, paymentTokenAmountNeeded()); IPayer _payer = payer; // Starting balance of `payer` uint256 balanceBefore = paymentToken.balanceOf(address(_payer)); - // Send ETH to `to` - uint256 ethAmount = stethAmountPerTokenAmount(amount); - safeSendSTETH(to, ethAmount); - IBuyETHCallback(to).buyETHCallback(msg.sender, amount, data); + // Send sellToken to `to` + uint256 sellTokenAmount = sellTokenAmountPerPaymentTokenAmount(amount); + safeSendSellToken(to, sellTokenAmount); + ISwapTokensCallback(to).swapTokensCallback(msg.sender, amount, data); // Check that `payers` balance increased by the expected amount uint256 tokensReceived = paymentToken.balanceOf(address(_payer)) - balanceBefore; @@ -241,7 +243,7 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { // Invoke `payer` to pay back outstanding debt _payer.payBackDebt(tokensReceived); - emit SoldSTETH(to, ethAmount, tokensReceived); + emit SwappedTokens(to, sellTokenAmount, tokensReceived); } /** @@ -250,24 +252,26 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ */ - /// @notice Get how much additional STETH allowance this contract needs in order to fund its current obligations plus `additionalTokens`. - /// @param additionalTokens an additional amount of `paymentToken` liability to use in this STETH requirement calculation, in payment token decimals. - /// @return the amount of additional STETH allowance needed - function stethNeeded(uint256 additionalTokens) public view returns (uint256) { - uint256 tokenAmount = tokenAmountNeeded() + additionalTokens; - uint256 ethCostOfTokens = stethAmountPerTokenAmount(tokenAmount); - uint256 stETHAllowance = stETH.allowance(treasury, address(this)); - - if (stETHAllowance > ethCostOfTokens) { - return 0; - } else { - return ethCostOfTokens - stETHAllowance; - } + /// @notice Get how much additional sellToken balance or allowance this contract needs in order to fund its current obligations plus `additionalTokens`. + /// @param additionalTokens an additional amount of `paymentToken` liability to use in this sellToken requirement calculation. + /// @return insufficientBalance the amount of additional sellToken the treasury needs + /// @return insufficientAllowance the amount of additional sellToken allowance this contract needs + function sellTokenNeeded(uint256 additionalTokens) + public + view + returns (uint256 insufficientBalance, uint256 insufficientAllowance) + { + uint256 paymentTokenAmount = paymentTokenAmountNeeded() + additionalTokens; + uint256 sellTokenAmount = sellTokenAmountPerPaymentTokenAmount(paymentTokenAmount); + uint256 sellTokenBalance = sellToken.balanceOf(treasury); + uint256 sellTokenAllowance = sellToken.allowance(treasury, address(this)); + insufficientBalance = sellTokenAmount > sellTokenBalance ? sellTokenAmount - sellTokenBalance : 0; + insufficientAllowance = sellTokenAmount > sellTokenAllowance ? sellTokenAmount - sellTokenAllowance : 0; } - /// @notice Returns the amount of tokens this contract is willing to exchange of ETH + /// @notice Returns the amount of payment tokens this contract is willing to swap /// @return amount of tokens - function tokenAmountNeeded() public view returns (uint256) { + function paymentTokenAmountNeeded() public view returns (uint256) { IPayer _payer = payer; uint256 _tokensAvailable = paymentToken.balanceOf(address(_payer)); uint256 totalDebt = _payer.totalDebt(); @@ -280,7 +284,7 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { } } - /// @notice Returns the ETH/`paymentToken` price this contract is willing to exchange ETH at, including the discount + /// @notice Returns the `sellToken`/`paymentToken` price this contract is willing to swapp at, including the discount /// @return The price, in 18 decimal format function price() public view returns (uint256) { unchecked { @@ -288,47 +292,50 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { } } - /// @notice Returns the amount of ETH this contract will send in exchange for `tokenAmount` tokens - /// @param tokenAmount the amount of tokens - /// @return amount of ETH the contract will sell for `tokenAmount` of tokens - function stethAmountPerTokenAmount(uint256 tokenAmount) public view returns (uint256) { + /// @notice Returns the amount of sellToken this contract will send in exchange for `tokenAmount` payment tokens + /// @param paymentTokenAmount the amount of paymentToken tokens + /// @return amount of sellToken the contract will sell for `tokenAmount` of payment tokens + function sellTokenAmountPerPaymentTokenAmount(uint256 paymentTokenAmount) public view returns (uint256) { unchecked { // Example: - // if tokenAmount == 3400000000 (3400 USDC) (6 decimals) + // if paymentTokenAmount == 3400000000 (3400 USDC) (6 decimals) // and price() == 1745910000000000000000 (1745.91) (18 decimals) // ((3400000000 * 1e36) / 1745910000000000000000) / 1e6 = 1.947408515e18 (3400/1745.91) - return ((tokenAmount * 1e36) / price()) / paymentTokenDecimalsDigits; + return ((paymentTokenAmount * 1e18 * sellTokenUnit) / price()) / paymentTokenUnit; } } - /// @notice Returns the amount of tokens the contract can buy and the amount of STETH it will pay for it - /// This takes into account the current STETH allowance this contract has - /// @return tokenAmount amount of tokens the contract can buy - /// @return stethAmount amount of STETH it will pay for the tokens - function tokenAmountNeededAndSTETHPayout() public view returns (uint256, uint256) { - uint256 tokenAmount = tokenAmountNeeded(); - uint256 stethAmount = stethAmountPerTokenAmount(tokenAmount); - uint256 stethAvailable = stETH.allowance(treasury, address(this)); - - if (stethAvailable >= stethAmount) { - return (tokenAmount, stethAmount); + /// @notice Returns the amount of payment tokens the contract can buy and the amount of sellToken it will pay for it + /// This takes into account the current sellToken allowance this contract has and the treasury balance + /// @return paymentTokenAmount amount of tokens the contract can buy + /// @return sellTokenAmount amount of STETH it will pay for the tokens + function paymentTokenAmountNeededAndSellTokenPayout() public view returns (uint256, uint256) { + uint256 paymentTokenAmount = paymentTokenAmountNeeded(); + uint256 sellTokenAmount = sellTokenAmountPerPaymentTokenAmount(paymentTokenAmount); + uint256 sellTokenAvailable = Math.min( + sellToken.balanceOf(treasury), + sellToken.allowance(treasury, address(this)) + ); + + if (sellTokenAvailable >= sellTokenAmount) { + return (paymentTokenAmount, sellTokenAmount); } else { // Tokens amount will be rounded down to avoid trying to buy more eth than available - tokenAmount = tokenAmountPerSTEthAmount(stethAvailable); + paymentTokenAmount = paymentTokenAmountPerSellTokenAmount(sellTokenAvailable); // Recalculate eth amount because tokens amount are rounded down - stethAmount = stethAmountPerTokenAmount(tokenAmount); + sellTokenAmount = sellTokenAmountPerPaymentTokenAmount(paymentTokenAmount); - return (tokenAmount, stethAmount); + return (paymentTokenAmount, sellTokenAmount); } } - /// @notice Returns the amount of tokens the contract expects in return for steth - /// @param stethAmount amount of STETH contract to be swapped - /// @return amount of tokens the contract will sell the ETH for + /// @notice Returns the amount of payment tokens the contract expects in return for sellToken + /// @param sellTokenAmount amount of sellToken to be swapped + /// @return amount of tokens the contract will swap sellToken for /// @dev result is rounded down - function tokenAmountPerSTEthAmount(uint256 stethAmount) public view returns (uint256) { - return (stethAmount * price() * paymentTokenDecimalsDigits) / 1e36; + function paymentTokenAmountPerSellTokenAmount(uint256 sellTokenAmount) public view returns (uint256) { + return (sellTokenAmount * price() * paymentTokenUnit) / (1e18 * sellTokenUnit); } /** @@ -446,7 +453,7 @@ contract STETHTokenBuyer is Ownable, Pausable, ReentrancyGuard { ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ */ - function safeSendSTETH(address to, uint256 ethAmount) internal { - stETH.safeTransferFrom(treasury, to, ethAmount); + function safeSendSellToken(address to, uint256 ethAmount) internal { + sellToken.safeTransferFrom(treasury, to, ethAmount); } } diff --git a/test/STETHTokenBuyer.t.sol b/test/TokenBuyerV2.t.sol similarity index 71% rename from test/STETHTokenBuyer.t.sol rename to test/TokenBuyerV2.t.sol index fd2e8f5..ca7f70c 100644 --- a/test/STETHTokenBuyer.t.sol +++ b/test/TokenBuyerV2.t.sol @@ -2,20 +2,20 @@ pragma solidity ^0.8.17; import 'forge-std/Test.sol'; -import { STETHTokenBuyer } from '../src/STETHTokenBuyer.sol'; +import { TokenBuyerV2 } from '../src/TokenBuyerV2.sol'; import { Payer } from '../src/Payer.sol'; import { TestERC20 } from './helpers/TestERC20.sol'; import { TestPriceFeed } from './helpers/TestPriceFeed.sol'; -import { STETHMaliciousBuyer } from './helpers/MaliciousBuyer.sol'; -import { IBuyETHCallback } from '../src/IBuyETHCallback.sol'; -import { STETHBuyerBot } from './helpers/STETHBuyerBot.sol'; +import { MaliciousBuyerV2 } from './helpers/MaliciousBuyer.sol'; +import { ISwapTokensCallback } from '../src/ISwapTokensCallback.sol'; +import { BuyerBot } from './helpers/BuyerBot.sol'; -contract STETHTokenBuyerTest is Test { +contract TokenBuyerV2Test is Test { bytes constant STUB_CALLDATA = 'stub calldata'; bytes constant OWNABLE_ERROR_STRING = 'Ownable: caller is not the owner'; bytes4 constant ERROR_SELECTOR = 0x08c379a0; // See: https://docs.soliditylang.org/en/v0.8.16/control-structures.html?highlight=0x08c379a0 - event SoldSTETH(address indexed to, uint256 ethOut, uint256 tokenIn); + event SwappedTokens(address indexed to, uint256 ethOut, uint256 tokenIn); event BotDiscountBPsSet(uint16 oldBPs, uint16 newBPs); event BaselinePaymentTokenAmountSet(uint256 oldAmount, uint256 newAmount); // event ETHWithdrawn(address indexed to, uint256 amount); @@ -27,10 +27,10 @@ contract STETHTokenBuyerTest is Test { event PayerSet(address oldPayer, address newPayer); event AdminSet(address oldAdmin, address newAdmin); - STETHTokenBuyer buyer; + TokenBuyerV2 buyer; Payer payer; TestERC20 paymentToken; - TestERC20 stETH; + TestERC20 sellToken; TestPriceFeed priceFeed; uint256 baselinePaymentTokenAmount = 0; @@ -41,7 +41,7 @@ contract STETHTokenBuyerTest is Test { address bot = address(0x99); address user = address(0x1234); address botOperator = address(0x4444); - STETHBuyerBot callbackBot; + BuyerBot callbackBot; function setUp() public { vm.label(owner, 'owner'); @@ -49,11 +49,11 @@ contract STETHTokenBuyerTest is Test { vm.label(bot, 'bot'); vm.label(user, 'user'); paymentToken = new TestERC20('Payment Token', 'PAY', 18); - stETH = new TestERC20('stETH', 'stETH', 18); - stETH.mint(treasury, 10000 ether); + sellToken = new TestERC20('sellToken', 'sellToken', 18); + sellToken.mint(treasury, 10000 ether); priceFeed = new TestPriceFeed(); payer = new Payer(owner, address(paymentToken)); - buyer = new STETHTokenBuyer({ + buyer = new TokenBuyerV2({ _priceFeed: priceFeed, _baselinePaymentTokenAmount: baselinePaymentTokenAmount, _minAdminBaselinePaymentTokenAmount: 0, @@ -64,17 +64,17 @@ contract STETHTokenBuyerTest is Test { _owner: owner, _admin: admin, _payer: address(payer), - _stETH: address(stETH), + _sellToken: address(sellToken), _treasury: treasury }); - callbackBot = new STETHBuyerBot(address(payer), address(paymentToken), STUB_CALLDATA, botOperator); + callbackBot = new BuyerBot(address(payer), address(paymentToken), STUB_CALLDATA, botOperator); } function test_bpsUnder_10000() public { uint16 bpsTooHigh = 10001; - vm.expectRevert(STETHTokenBuyer.InvalidBotDiscountBPs.selector); - buyer = new STETHTokenBuyer({ + vm.expectRevert(TokenBuyerV2.InvalidBotDiscountBPs.selector); + buyer = new TokenBuyerV2({ _priceFeed: priceFeed, _baselinePaymentTokenAmount: baselinePaymentTokenAmount, _minAdminBaselinePaymentTokenAmount: 0, @@ -85,12 +85,12 @@ contract STETHTokenBuyerTest is Test { _owner: owner, _admin: admin, _payer: address(payer), - _stETH: address(0), + _sellToken: address(0), _treasury: address(0) }); - vm.expectRevert(STETHTokenBuyer.InvalidBotDiscountBPs.selector); - buyer = new STETHTokenBuyer({ + vm.expectRevert(TokenBuyerV2.InvalidBotDiscountBPs.selector); + buyer = new TokenBuyerV2({ _priceFeed: priceFeed, _baselinePaymentTokenAmount: baselinePaymentTokenAmount, _minAdminBaselinePaymentTokenAmount: 0, @@ -101,12 +101,12 @@ contract STETHTokenBuyerTest is Test { _owner: owner, _admin: admin, _payer: address(payer), - _stETH: address(0), + _sellToken: address(0), _treasury: address(0) }); - vm.expectRevert(STETHTokenBuyer.InvalidBotDiscountBPs.selector); - buyer = new STETHTokenBuyer({ + vm.expectRevert(TokenBuyerV2.InvalidBotDiscountBPs.selector); + buyer = new TokenBuyerV2({ _priceFeed: priceFeed, _baselinePaymentTokenAmount: baselinePaymentTokenAmount, _minAdminBaselinePaymentTokenAmount: 0, @@ -117,7 +117,7 @@ contract STETHTokenBuyerTest is Test { _owner: owner, _admin: admin, _payer: address(payer), - _stETH: address(0), + _sellToken: address(0), _treasury: address(0) }); } @@ -148,20 +148,20 @@ contract STETHTokenBuyerTest is Test { vm.prank(owner); buyer.setBaselinePaymentTokenAmount(100_000e18); - assertEq(buyer.tokenAmountNeeded(), 100_000e18); + assertEq(buyer.paymentTokenAmountNeeded(), 100_000e18); } function test_tokenAmountNeeded_debtOnly() public { vm.prank(address(owner)); payer.sendOrRegisterDebt(address(1), 42_000e18); - assertEq(buyer.tokenAmountNeeded(), 42_000e18); + assertEq(buyer.paymentTokenAmountNeeded(), 42_000e18); } function test_tokenAmountNeeded_paymentTokenBalanceOnly() public { paymentToken.mint(address(payer), 42_000e18); - assertEq(buyer.tokenAmountNeeded(), 0); + assertEq(buyer.paymentTokenAmountNeeded(), 0); } function test_tokenAmountNeeded_baselineAndPaymentTokenBalance() public { @@ -169,7 +169,7 @@ contract STETHTokenBuyerTest is Test { buyer.setBaselinePaymentTokenAmount(100_000e18); paymentToken.mint(address(payer), 42_000e18); - assertEq(buyer.tokenAmountNeeded(), 58_000e18); + assertEq(buyer.paymentTokenAmountNeeded(), 58_000e18); } function test_tokenAmountNeeded_baselineAndPaymentTokenBalanceAndDebt() public { @@ -180,48 +180,48 @@ contract STETHTokenBuyerTest is Test { paymentToken.mint(address(payer), 42_000e18); - assertEq(buyer.tokenAmountNeeded(), 69_000e18); + assertEq(buyer.paymentTokenAmountNeeded(), 69_000e18); } - function test_tokenAmountPerEthAmount() public { + function test_tokenAmountPerSellTokenAmount() public { priceFeed.setPrice(1358.37e18); uint256 ethAmount = 1.333 ether; - uint256 tokenAmount = buyer.tokenAmountPerSTEthAmount(ethAmount); + uint256 tokenAmount = buyer.paymentTokenAmountPerSellTokenAmount(ethAmount); assertEq(tokenAmount, 1810.70721e18); } - function test_tokenAmountPerEthAmount_roundsDown() public { + function test_tokenAmountPerSellTokenAmount_roundsDown() public { uint256 ethAmount = 100000000000000000; // 0.1 ether uint256 price = 111111111111111111111; // 111.111111111111111111 priceFeed.setPrice(price); - uint256 tokenAmount = buyer.tokenAmountPerSTEthAmount(ethAmount); - uint256 ethAmount2 = buyer.stethAmountPerTokenAmount(tokenAmount); + uint256 tokenAmount = buyer.paymentTokenAmountPerSellTokenAmount(ethAmount); + uint256 ethAmount2 = buyer.sellTokenAmountPerPaymentTokenAmount(tokenAmount); assertLt(ethAmount2, ethAmount); } - function test_tokenAmountNeededAndETHPayout_baselineAmountOnly() public { + function test_tokenAmountNeededAndSellTokenPayout_baselineAmountOnly() public { vm.prank(treasury); - stETH.approve(address(buyer), 50 ether); + sellToken.approve(address(buyer), 50 ether); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(100_000e18); priceFeed.setPrice(2000e18); - (uint256 tokenAmount, uint256 ethAmount) = buyer.tokenAmountNeededAndSTETHPayout(); + (uint256 tokenAmount, uint256 ethAmount) = buyer.paymentTokenAmountNeededAndSellTokenPayout(); assertEq(tokenAmount, 100_000e18); assertEq(ethAmount, 50 ether); } - function test_tokenAmountNeededAndETHPayout_lowersTokensIfItBuysMoreEthThanAvailable() public { + function test_tokenAmountNeededAndSellTokenPayout_lowersTokensIfItBuysMoreEthThanAvailable() public { paymentToken = new TestERC20('A', 'B', 6); payer = new Payer(owner, address(paymentToken)); - buyer = new STETHTokenBuyer({ + buyer = new TokenBuyerV2({ _priceFeed: priceFeed, _baselinePaymentTokenAmount: baselinePaymentTokenAmount, _minAdminBaselinePaymentTokenAmount: 0, @@ -232,7 +232,7 @@ contract STETHTokenBuyerTest is Test { _owner: owner, _admin: admin, _payer: address(payer), - _stETH: address(stETH), + _sellToken: address(sellToken), _treasury: treasury }); @@ -240,31 +240,31 @@ contract STETHTokenBuyerTest is Test { buyer.setBaselinePaymentTokenAmount(100_000e6); vm.prank(treasury); - stETH.approve(address(buyer), 8 ether); + sellToken.approve(address(buyer), 8 ether); priceFeed.setPrice(1350717518812290000000); - (uint256 tokenAmount, uint256 ethAmount) = buyer.tokenAmountNeededAndSTETHPayout(); + (uint256 tokenAmount, uint256 ethAmount) = buyer.paymentTokenAmountNeededAndSellTokenPayout(); - uint256 ethAmount2 = buyer.stethAmountPerTokenAmount(tokenAmount); + uint256 ethAmount2 = buyer.sellTokenAmountPerPaymentTokenAmount(tokenAmount); assertEq(ethAmount, ethAmount2); } - function test_tokenAmountNeededAndETHPayout_lowersTokensIfItBuysMoreEthThanAvailable_fuzz( - uint256 stethAllowance, + function test_tokenAmountNeededAndSellTokenPayout_lowersTokensIfItBuysMoreEthThanAvailable_fuzz( + uint256 sellTokenAllowance, uint256 price, uint256 decimals, uint256 tokensNeeded ) public { decimals = bound(decimals, 6, 18); - stethAllowance = bound(stethAllowance, 0, 1e12 ether); + sellTokenAllowance = bound(sellTokenAllowance, 0, 1e12 ether); price = bound(price, 1e18, 1e9 * 1e18); tokensNeeded = bound(tokensNeeded, 0, (10_000_000 * 10) ^ decimals); paymentToken = new TestERC20('A', 'B', uint8(decimals)); payer = new Payer(owner, address(paymentToken)); - buyer = new STETHTokenBuyer({ + buyer = new TokenBuyerV2({ _priceFeed: priceFeed, _baselinePaymentTokenAmount: baselinePaymentTokenAmount, _minAdminBaselinePaymentTokenAmount: 0, @@ -275,7 +275,7 @@ contract STETHTokenBuyerTest is Test { _owner: owner, _admin: admin, _payer: address(payer), - _stETH: address(stETH), + _sellToken: address(sellToken), _treasury: treasury }); @@ -283,25 +283,44 @@ contract STETHTokenBuyerTest is Test { buyer.setBaselinePaymentTokenAmount(tokensNeeded); vm.prank(treasury); - stETH.approve(address(buyer), stethAllowance); + sellToken.approve(address(buyer), sellTokenAllowance); priceFeed.setPrice(price); - (uint256 tokenAmount, uint256 ethAmount) = buyer.tokenAmountNeededAndSTETHPayout(); + (uint256 tokenAmount, uint256 sellTokenAmount) = buyer.paymentTokenAmountNeededAndSellTokenPayout(); - uint256 ethAmount2 = buyer.stethAmountPerTokenAmount(tokenAmount); + uint256 sellTokenAmount2 = buyer.sellTokenAmountPerPaymentTokenAmount(tokenAmount); - assertEq(ethAmount, ethAmount2); + assertEq(sellTokenAmount, sellTokenAmount2); } - function test_tokenAmountNeededAndETHPayout_lessEthAvailable() public { + function test_tokenAmountNeededAndSellTokenPayout_lessSellTokenApproved() public { vm.prank(treasury); - stETH.approve(address(buyer), 5 ether); + sellToken.approve(address(buyer), 5 ether); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(100_000e18); priceFeed.setPrice(2000e18); - (uint256 tokenAmount, uint256 ethAmount) = buyer.tokenAmountNeededAndSTETHPayout(); + (uint256 tokenAmount, uint256 ethAmount) = buyer.paymentTokenAmountNeededAndSellTokenPayout(); + + assertEq(tokenAmount, 10_000e18); + assertEq(ethAmount, 5 ether); + } + + function test_tokenAmountNeededAndSellTokenPayout_lessSellTokenAvailable() public { + vm.prank(treasury); + sellToken.approve(address(buyer), 50 ether); + + // set sellToken balance of treasury to 5 ether + vm.startPrank(treasury); + sellToken.transfer(address(123), sellToken.balanceOf(treasury) - 5 ether); + vm.stopPrank(); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(100_000e18); + priceFeed.setPrice(2000e18); + + (uint256 tokenAmount, uint256 ethAmount) = buyer.paymentTokenAmountNeededAndSellTokenPayout(); assertEq(tokenAmount, 10_000e18); assertEq(ethAmount, 5 ether); @@ -343,7 +362,7 @@ contract STETHTokenBuyerTest is Test { buyer.pause(); vm.expectRevert('Pausable: paused'); - buyer.buySTETH(1234); + buyer.swapTokens(1234); } function test_buyETH_botBuysExactBaselineAmount() public { @@ -351,7 +370,7 @@ contract STETHTokenBuyerTest is Test { // 1 / 2000 = 0.0005 priceFeed.setPrice(2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 1 ether); + sellToken.approve(address(buyer), 1 ether); paymentToken.mint(bot, 2000e18); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(2000e18); @@ -360,13 +379,13 @@ contract STETHTokenBuyerTest is Test { paymentToken.approve(address(buyer), 2000e18); vm.expectEmit(true, true, true, true); - emit SoldSTETH(bot, 1 ether, 2000e18); - buyer.buySTETH(2000e18); + emit SwappedTokens(bot, 1 ether, 2000e18); + buyer.swapTokens(2000e18); vm.stopPrank(); - assertEq(stETH.balanceOf(bot), 1 ether); - assertEq(stETH.balanceOf(treasury), 9999 ether); + assertEq(sellToken.balanceOf(bot), 1 ether); + assertEq(sellToken.balanceOf(treasury), 9999 ether); assertEq(paymentToken.balanceOf(address(payer)), 2000e18); } @@ -379,11 +398,11 @@ contract STETHTokenBuyerTest is Test { // bot buys ETH for 2000 tokens priceFeed.setPrice(2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 1 ether); + sellToken.approve(address(buyer), 1 ether); paymentToken.mint(bot, 2000e18); vm.startPrank(bot); paymentToken.approve(address(buyer), 2000e18); - buyer.buySTETH(2000e18); + buyer.swapTokens(2000e18); vm.stopPrank(); // user has been paid @@ -394,7 +413,7 @@ contract STETHTokenBuyerTest is Test { function test_buyETH_botCappedToBaselineAmount() public { priceFeed.setPrice(2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 1 ether); + sellToken.approve(address(buyer), 1 ether); paymentToken.mint(bot, 4000e18); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(2000e18); @@ -403,15 +422,15 @@ contract STETHTokenBuyerTest is Test { paymentToken.approve(address(buyer), 4000e18); vm.expectEmit(true, true, true, true); - emit SoldSTETH(bot, 1 ether, 2000e18); - buyer.buySTETH(4000e18); + emit SwappedTokens(bot, 1 ether, 2000e18); + buyer.swapTokens(4000e18); vm.stopPrank(); - assertEq(stETH.balanceOf(bot), 1 ether); + assertEq(sellToken.balanceOf(bot), 1 ether); assertEq(paymentToken.balanceOf(bot), 2000e18); } - function test_buyETH_revertsWhenContractHasInsufficientSTETHApproval() public { + function test_buyETH_revertsWhenContractHasInsufficientSellTokenApproval() public { priceFeed.setPrice(2000e18); paymentToken.mint(bot, 2000e18); vm.prank(owner); @@ -423,18 +442,18 @@ contract STETHTokenBuyerTest is Test { vm.prank(bot); vm.expectRevert('ERC20: insufficient allowance'); - buyer.buySTETH(2000e18); + buyer.swapTokens(2000e18); } - function test_buyETH_revertsWhenTreasuryHasInsufficientSTETH() public { + function test_buyETH_revertsWhenTreasuryHasInsufficientSellToken() public { priceFeed.setPrice(2000e18); paymentToken.mint(bot, 2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 1 ether); + sellToken.approve(address(buyer), 1 ether); // reduce treasury balance to 0.5 ether vm.startPrank(treasury); - stETH.transfer(address(123), stETH.balanceOf(treasury) - 0.5 ether); - assertEq(stETH.balanceOf(treasury), 0.5 ether); + sellToken.transfer(address(123), sellToken.balanceOf(treasury) - 0.5 ether); + assertEq(sellToken.balanceOf(treasury), 0.5 ether); vm.stopPrank(); vm.prank(owner); @@ -446,13 +465,13 @@ contract STETHTokenBuyerTest is Test { vm.prank(bot); vm.expectRevert('ERC20: transfer amount exceeds balance'); - buyer.buySTETH(2000e18); + buyer.swapTokens(2000e18); } function test_buyETH_revertsWhenTokenApprovalInsufficient() public { priceFeed.setPrice(2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 1 ether); + sellToken.approve(address(buyer), 1 ether); paymentToken.mint(bot, 2000e18); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(2000e18); @@ -462,7 +481,7 @@ contract STETHTokenBuyerTest is Test { vm.prank(bot); vm.expectRevert('ERC20: insufficient allowance'); - buyer.buySTETH(2000e18); + buyer.swapTokens(2000e18); } function test_buyETHWithCallback_revertsWhenPaused() public { @@ -471,42 +490,42 @@ contract STETHTokenBuyerTest is Test { vm.expectRevert('Pausable: paused'); vm.prank(botOperator); - buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); } function test_buyETHWithCallback_botBuysExactBaselineAmount() public { priceFeed.setPrice(2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 1 ether); + sellToken.approve(address(buyer), 1 ether); paymentToken.mint(address(callbackBot), 2000e18); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(2000e18); - uint256 balanceBefore = stETH.balanceOf(address(callbackBot)); + uint256 balanceBefore = sellToken.balanceOf(address(callbackBot)); vm.expectEmit(true, true, true, true); - emit SoldSTETH(address(callbackBot), 1 ether, 2000e18); + emit SwappedTokens(address(callbackBot), 1 ether, 2000e18); vm.prank(botOperator); - buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); - assertEq(stETH.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); + assertEq(sellToken.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); } function test_buyETHWithCallback_paysBackDebt() public { priceFeed.setPrice(2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 1 ether); + sellToken.approve(address(buyer), 1 ether); paymentToken.mint(address(callbackBot), 2000e18); vm.prank(owner); payer.sendOrRegisterDebt(user, 2500e18); - uint256 balanceBefore = stETH.balanceOf(address(callbackBot)); + uint256 balanceBefore = sellToken.balanceOf(address(callbackBot)); vm.prank(botOperator); - buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); - assertEq(stETH.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); + assertEq(sellToken.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); assertEq(paymentToken.balanceOf(user), 2000e18); assertEq(paymentToken.balanceOf(address(payer)), 0); assertEq(payer.debtOf(user), 500e18); @@ -515,23 +534,23 @@ contract STETHTokenBuyerTest is Test { function test_buyETHWithCallback_botCappedToBaselineAmount() public { priceFeed.setPrice(2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 1 ether); + sellToken.approve(address(buyer), 1 ether); paymentToken.mint(address(callbackBot), 4000e18); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(2000e18); - uint256 balanceBefore = stETH.balanceOf(address(callbackBot)); + uint256 balanceBefore = sellToken.balanceOf(address(callbackBot)); vm.expectEmit(true, true, true, true); - emit SoldSTETH(address(callbackBot), 1 ether, 2000e18); + emit SwappedTokens(address(callbackBot), 1 ether, 2000e18); vm.prank(botOperator); - buyer.buySTETH(4000e18, address(callbackBot), STUB_CALLDATA); + buyer.swapTokens(4000e18, address(callbackBot), STUB_CALLDATA); - assertEq(stETH.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); + assertEq(sellToken.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); assertEq(paymentToken.balanceOf(address(callbackBot)), 2000e18); } - function test_buyETHWithCallback_revertsWhenContractHasInsufficientSTETHApproval() public { + function test_buyETHWithCallback_revertsWhenContractHasInsufficientSellTokenApproval() public { priceFeed.setPrice(2000e18); paymentToken.mint(address(callbackBot), 4000e18); vm.prank(owner); @@ -539,22 +558,22 @@ contract STETHTokenBuyerTest is Test { // 2000 tokens at 0.0005 price = 1 ether // setting the balance to the highest point where it should fail vm.prank(treasury); - stETH.approve(address(buyer), 1 ether - 1 wei); + sellToken.approve(address(buyer), 1 ether - 1 wei); vm.expectRevert('ERC20: insufficient allowance'); vm.prank(botOperator); - buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); } - function test_buyETHWithCallback_revertsWhenTreasuryHasInsufficientSTETH() public { + function test_buyETHWithCallback_revertsWhenTreasuryHasInsufficientSellToken() public { priceFeed.setPrice(2000e18); paymentToken.mint(address(callbackBot), 4000e18); vm.prank(treasury); - stETH.approve(address(buyer), 1 ether); + sellToken.approve(address(buyer), 1 ether); // reduce treasury balance to 0.5 ether vm.startPrank(treasury); - stETH.transfer(address(123), stETH.balanceOf(treasury) - 0.5 ether); - assertEq(stETH.balanceOf(treasury), 0.5 ether); + sellToken.transfer(address(123), sellToken.balanceOf(treasury) - 0.5 ether); + assertEq(sellToken.balanceOf(treasury), 0.5 ether); vm.stopPrank(); vm.prank(owner); @@ -562,24 +581,22 @@ contract STETHTokenBuyerTest is Test { vm.expectRevert('ERC20: transfer amount exceeds balance'); vm.prank(botOperator); - buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); } function test_buyETHWithCallback_revertsWhenTokenPaymentInsufficient() public { priceFeed.setPrice(2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 1 ether); + sellToken.approve(address(buyer), 1 ether); paymentToken.mint(address(callbackBot), 2000e18); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(2000e18); callbackBot.setTokenAmountOverride(2000e18 - 1); callbackBot.setOverrideTokenAmount(true); - vm.expectRevert( - abi.encodeWithSelector(STETHTokenBuyer.ReceivedInsufficientTokens.selector, 2000e18, 2000e18 - 1) - ); + vm.expectRevert(abi.encodeWithSelector(TokenBuyerV2.ReceivedInsufficientTokens.selector, 2000e18, 2000e18 - 1)); vm.prank(botOperator); - buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); } function test_buyETHWithCallback_usesAllTokensToPayBackDebt() public { @@ -588,7 +605,7 @@ contract STETHTokenBuyerTest is Test { priceFeed.setPrice(2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 1 ether); + sellToken.approve(address(buyer), 1 ether); paymentToken.mint(address(callbackBot), 2000e18 + 10); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(2000e18); @@ -597,17 +614,17 @@ contract STETHTokenBuyerTest is Test { vm.prank(botOperator); vm.expectEmit(true, true, true, true); - emit SoldSTETH(address(callbackBot), 1 ether, 2000e18 + 10); - buyer.buySTETH(2000e18, address(callbackBot), STUB_CALLDATA); + emit SwappedTokens(address(callbackBot), 1 ether, 2000e18 + 10); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); assertEq(paymentToken.balanceOf(address(0x7777)), 2000e18 + 10); } function test_buyETHWithCallback_maliciousBuyerCantReenter() public { - STETHMaliciousBuyer attacker = new STETHMaliciousBuyer(address(buyer), paymentToken); + MaliciousBuyerV2 attacker = new MaliciousBuyerV2(address(buyer), paymentToken); priceFeed.setPrice(2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 10 ether); + sellToken.approve(address(buyer), 10 ether); paymentToken.mint(address(attacker), 2000e18); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(2000e18); @@ -617,10 +634,10 @@ contract STETHTokenBuyerTest is Test { } function test_buyETHWithCallback_maliciousBuyerCantReenterOtherBuyETHFunction() public { - STETHMaliciousBuyer attacker = new STETHMaliciousBuyer(address(buyer), paymentToken); + MaliciousBuyerV2 attacker = new MaliciousBuyerV2(address(buyer), paymentToken); priceFeed.setPrice(2000e18); vm.prank(treasury); - stETH.approve(address(buyer), 10 ether); + sellToken.approve(address(buyer), 10 ether); paymentToken.mint(address(attacker), 2000e18); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(2000e18); @@ -644,18 +661,18 @@ contract STETHTokenBuyerTest is Test { // fund bot and buyer paymentToken.mint(bot, 99_990e18); vm.prank(treasury); - stETH.approve(address(buyer), 1010 ether); + sellToken.approve(address(buyer), 1010 ether); // bots buy buffer vm.startPrank(bot); paymentToken.approve(address(buyer), 99_990e18); vm.expectEmit(true, true, true, true); - emit SoldSTETH(bot, 1010 ether, 99_990e18); - buyer.buySTETH(99_990e18); + emit SwappedTokens(bot, 1010 ether, 99_990e18); + buyer.swapTokens(99_990e18); vm.stopPrank(); assertEq(paymentToken.balanceOf(bot), 0); - assertEq(stETH.balanceOf(bot), 1010 ether); + assertEq(sellToken.balanceOf(bot), 1010 ether); // send or mint (42K) vm.prank(owner); @@ -670,18 +687,18 @@ contract STETHTokenBuyerTest is Test { // 42000 / 99 = 424.242424242 vm.prank(treasury); - stETH.approve(address(buyer), 424242424242424242424); + sellToken.approve(address(buyer), 424242424242424242424); // bots can top off what's missing (bots buy 42K) vm.startPrank(bot); paymentToken.approve(address(buyer), 42_000e18); vm.expectEmit(true, true, true, true); - emit SoldSTETH(bot, 424242424242424242424, 42_000e18); - buyer.buySTETH(42_000e18); + emit SwappedTokens(bot, 424242424242424242424, 42_000e18); + buyer.swapTokens(42_000e18); vm.stopPrank(); assertEq(paymentToken.balanceOf(bot), 0); - assertEq(stETH.balanceOf(bot), 1010 ether + 424242424242424242424); + assertEq(sellToken.balanceOf(bot), 1010 ether + 424242424242424242424); } function test_happyFlow_payingOverTheBuffer() public { @@ -699,18 +716,18 @@ contract STETHTokenBuyerTest is Test { // fund bot and buyer paymentToken.mint(bot, 99_990e18); vm.prank(treasury); - stETH.approve(address(buyer), 1010 ether); + sellToken.approve(address(buyer), 1010 ether); // bots buy buffer vm.startPrank(bot); paymentToken.approve(address(buyer), 99_990e18); vm.expectEmit(true, true, true, true); - emit SoldSTETH(bot, 1010 ether, 99_990e18); - buyer.buySTETH(99_990e18); + emit SwappedTokens(bot, 1010 ether, 99_990e18); + buyer.swapTokens(99_990e18); vm.stopPrank(); assertEq(paymentToken.balanceOf(bot), 0); - assertEq(stETH.balanceOf(bot), 1010 ether); + assertEq(sellToken.balanceOf(bot), 1010 ether); // send or mint (141,990) vm.prank(owner); @@ -723,18 +740,18 @@ contract STETHTokenBuyerTest is Test { // 42000 / 99 = 424.242424242 vm.prank(treasury); - stETH.approve(address(buyer), 424242424242424242424); + sellToken.approve(address(buyer), 424242424242424242424); // bots can top off what's missing (bots buy 42K) vm.startPrank(bot); paymentToken.approve(address(buyer), 42_000e18); vm.expectEmit(true, true, true, true); - emit SoldSTETH(bot, 424242424242424242424, 42_000e18); - buyer.buySTETH(42_000e18); + emit SwappedTokens(bot, 424242424242424242424, 42_000e18); + buyer.swapTokens(42_000e18); vm.stopPrank(); assertEq(paymentToken.balanceOf(bot), 0); - assertEq(stETH.balanceOf(bot), 1010 ether + 424242424242424242424); + assertEq(sellToken.balanceOf(bot), 1010 ether + 424242424242424242424); // user's debt was paid assertEq(payer.debtOf(user), 0); @@ -744,16 +761,16 @@ contract STETHTokenBuyerTest is Test { // fund bot and buyer again paymentToken.mint(bot, 99_990e18); vm.prank(treasury); - stETH.approve(address(buyer), 1010 ether); + sellToken.approve(address(buyer), 1010 ether); vm.startPrank(bot); paymentToken.approve(address(buyer), 99_990e18); vm.expectEmit(true, true, true, true); - emit SoldSTETH(bot, 1010 ether, 99_990e18); - buyer.buySTETH(99_990e18); + emit SwappedTokens(bot, 1010 ether, 99_990e18); + buyer.swapTokens(99_990e18); vm.stopPrank(); assertEq(paymentToken.balanceOf(bot), 0); - assertEq(stETH.balanceOf(bot), 1010 ether + 424242424242424242424 + 1010 ether); + assertEq(sellToken.balanceOf(bot), 1010 ether + 424242424242424242424 + 1010 ether); assertEq(paymentToken.balanceOf(address(payer)), 99_990e18); } @@ -761,7 +778,7 @@ contract STETHTokenBuyerTest is Test { vm.prank(owner); buyer.setMinAdminBaselinePaymentTokenAmount(10_000); - vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.InvalidBaselinePaymentTokenAmount.selector)); + vm.expectRevert(abi.encodeWithSelector(TokenBuyerV2.InvalidBaselinePaymentTokenAmount.selector)); vm.prank(admin); buyer.setBaselinePaymentTokenAmount(9999); } @@ -770,7 +787,7 @@ contract STETHTokenBuyerTest is Test { vm.prank(owner); buyer.setMaxAdminBaselinePaymentTokenAmount(10_000); - vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.InvalidBaselinePaymentTokenAmount.selector)); + vm.expectRevert(abi.encodeWithSelector(TokenBuyerV2.InvalidBaselinePaymentTokenAmount.selector)); vm.prank(admin); buyer.setBaselinePaymentTokenAmount(10_001); } @@ -817,7 +834,7 @@ contract STETHTokenBuyerTest is Test { vm.prank(owner); buyer.setMinAdminBotDiscountBPs(50); - vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.InvalidBotDiscountBPs.selector)); + vm.expectRevert(abi.encodeWithSelector(TokenBuyerV2.InvalidBotDiscountBPs.selector)); vm.prank(admin); buyer.setBotDiscountBPs(49); } @@ -826,7 +843,7 @@ contract STETHTokenBuyerTest is Test { vm.prank(owner); buyer.setMaxAdminBotDiscountBPs(100); - vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.InvalidBotDiscountBPs.selector)); + vm.expectRevert(abi.encodeWithSelector(TokenBuyerV2.InvalidBotDiscountBPs.selector)); vm.prank(admin); buyer.setBotDiscountBPs(101); } @@ -898,7 +915,7 @@ contract STETHTokenBuyerTest is Test { } function test_setAdmin_revertsForNonOwner() public { - vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.OnlyAdminOrOwner.selector)); + vm.expectRevert(abi.encodeWithSelector(TokenBuyerV2.OnlyAdminOrOwner.selector)); buyer.setAdmin(address(112233)); } @@ -927,10 +944,10 @@ contract STETHTokenBuyerTest is Test { } function test_pause_unpause_revertForNonOwnerOrAdmin() public { - vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.OnlyAdminOrOwner.selector)); + vm.expectRevert(abi.encodeWithSelector(TokenBuyerV2.OnlyAdminOrOwner.selector)); buyer.pause(); - vm.expectRevert(abi.encodeWithSelector(STETHTokenBuyer.OnlyAdminOrOwner.selector)); + vm.expectRevert(abi.encodeWithSelector(TokenBuyerV2.OnlyAdminOrOwner.selector)); buyer.unpause(); } @@ -1003,24 +1020,47 @@ contract STETHTokenBuyerTest is Test { buyer.setMaxAdminBaselinePaymentTokenAmount(42); } - function test_stethNeeded() public { - priceFeed.setPrice(1400e18); + function test_sellTokenNeeded() public { + // set treasury sellToken balance to zero + vm.startPrank(treasury); + sellToken.transfer(address(123), sellToken.balanceOf(treasury)); + vm.stopPrank(); + // sellToken/paymentToken = 1000 + // assert(sellToken.balanceOf(treasury) >) + priceFeed.setPrice(1000e18); vm.prank(owner); buyer.setBaselinePaymentTokenAmount(1_000e18); + (uint256 insufficientBalance, uint256 insufficientAllowance) = buyer.sellTokenNeeded(100e18); - uint256 ethNeeded = buyer.stethNeeded(100e18); - assertApproxEqAbs(ethNeeded, 0.785714286e18, 0.00001e18); - } + assertApproxEqAbs(insufficientBalance, 1.1e18, 0.00001e18); + assertApproxEqAbs(insufficientAllowance, 1.1e18, 0.00001e18); - function test_stethNeededIsZeroIfNothingNeeded() public { - priceFeed.setPrice(1400e18); + // treasury has partial balance + sellToken.mint(treasury, 1.0e18); - vm.prank(owner); - buyer.setBaselinePaymentTokenAmount(1_000e18); + (insufficientBalance, insufficientAllowance) = buyer.sellTokenNeeded(100e18); + + assertApproxEqAbs(insufficientBalance, 0.1e18, 0.00001e18); + assertApproxEqAbs(insufficientAllowance, 1.1e18, 0.00001e18); + // partial allowance vm.prank(treasury); - stETH.approve(address(buyer), 0.8 ether); - assertEq(buyer.stethNeeded(100e18), 0); + sellToken.approve(address(buyer), 0.9e18); + + (insufficientBalance, insufficientAllowance) = buyer.sellTokenNeeded(100e18); + + assertApproxEqAbs(insufficientBalance, 0.1e18, 0.00001e18); + assertApproxEqAbs(insufficientAllowance, 0.2e18, 0.00001e18); + + // more than needed + sellToken.mint(treasury, 1.0e18); + vm.prank(treasury); + sellToken.approve(address(buyer), 1.9e18); + + (insufficientBalance, insufficientAllowance) = buyer.sellTokenNeeded(100e18); + + assertEq(insufficientBalance, 0); + assertEq(insufficientAllowance, 0); } } diff --git a/test/helpers/STETHBuyerBot.sol b/test/helpers/BuyerBot.sol similarity index 88% rename from test/helpers/STETHBuyerBot.sol rename to test/helpers/BuyerBot.sol index d5578b6..22d87f8 100644 --- a/test/helpers/STETHBuyerBot.sol +++ b/test/helpers/BuyerBot.sol @@ -2,10 +2,10 @@ pragma solidity ^0.8.17; import 'forge-std/Test.sol'; -import { IBuyETHCallback } from '../../src/IBuyETHCallback.sol'; +import { ISwapTokensCallback } from '../../src/ISwapTokensCallback.sol'; import { IERC20 } from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; -contract STETHBuyerBot is IBuyETHCallback, Test { +contract BuyerBot is ISwapTokensCallback, Test { address immutable payer; IERC20 immutable paymentToken; @@ -26,7 +26,7 @@ contract STETHBuyerBot is IBuyETHCallback, Test { operator = operator_; } - function buyETHCallback( + function swapTokensCallback( address caller, uint256 amount, bytes memory data diff --git a/test/helpers/MaliciousBuyer.sol b/test/helpers/MaliciousBuyer.sol index a42f50b..0193bb4 100644 --- a/test/helpers/MaliciousBuyer.sol +++ b/test/helpers/MaliciousBuyer.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.17; import { IERC20 } from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; import { IBuyETHCallback } from '../../src/IBuyETHCallback.sol'; +import { ISwapTokensCallback } from '../../src/ISwapTokensCallback.sol'; import 'forge-std/console.sol'; interface TokenBuyerLike { @@ -15,10 +16,10 @@ interface TokenBuyerLike { ) external; } -interface STETHTokenBuyerLike { - function buySTETH(uint256 tokenAmountWAD) external; +interface TokenBuyerV2Like { + function swapTokens(uint256 tokenAmountWAD) external; - function buySTETH( + function swapTokens( uint256 tokenAmountWAD, address to, bytes calldata data @@ -77,32 +78,32 @@ contract MaliciousBuyer is IBuyETHCallback { } } -contract STETHMaliciousBuyer is IBuyETHCallback { - STETHTokenBuyerLike buyer; +contract MaliciousBuyerV2 is ISwapTokensCallback { + TokenBuyerV2Like buyer; IERC20 token; bool calledTwice; bool reenterWithCallback; constructor(address _buyer, IERC20 _token) { - buyer = STETHTokenBuyerLike(_buyer); + buyer = TokenBuyerV2Like(_buyer); token = _token; } function attack(uint256 tokenAmountWAD) public { - buyer.buySTETH(tokenAmountWAD); + buyer.swapTokens(tokenAmountWAD); } function reenterBuyWithCallback(uint256 tokenAmountWAD) public { reenterWithCallback = true; - buyer.buySTETH(tokenAmountWAD, address(this), ''); + buyer.swapTokens(tokenAmountWAD, address(this), ''); } function reenterBuyNoCallback(uint256 tokenAmountWAD) public { reenterWithCallback = false; - buyer.buySTETH(tokenAmountWAD, address(this), ''); + buyer.swapTokens(tokenAmountWAD, address(this), ''); } - function buyETHCallback( + function swapTokensCallback( address, uint256 amount, bytes calldata @@ -110,13 +111,13 @@ contract STETHMaliciousBuyer is IBuyETHCallback { if (reenterWithCallback) { if (!calledTwice) { calledTwice = true; - buyer.buySTETH(amount, address(this), ''); + buyer.swapTokens(amount, address(this), ''); } else { token.transfer(address(buyer), amount); } } else { token.approve(address(buyer), amount); - buyer.buySTETH(amount); + buyer.swapTokens(amount); } } } From 2810a480b3cac7efb5550a6dafb9329d9343c43e Mon Sep 17 00:00:00 2001 From: davidbrai Date: Tue, 31 Dec 2024 13:59:23 +0100 Subject: [PATCH 5/6] fix ci --- .github/workflows/subgraph-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/subgraph-test.yml b/.github/workflows/subgraph-test.yml index 7c71132..f585c5f 100644 --- a/.github/workflows/subgraph-test.yml +++ b/.github/workflows/subgraph-test.yml @@ -5,7 +5,7 @@ on: [workflow_dispatch, pull_request, push] jobs: build_and_test: name: Build and Test - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 with: From b63ee8a20e1d037cf80d6ff2a0be5e52525a7ea3 Mon Sep 17 00:00:00 2001 From: davidbrai Date: Wed, 1 Jan 2025 10:23:25 +0100 Subject: [PATCH 6/6] minor fixes --- src/TokenBuyerV2.sol | 13 +++++++------ test/TokenBuyerV2.t.sol | 40 ++++++++++++++++++++-------------------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/TokenBuyerV2.sol b/src/TokenBuyerV2.sol index 7d7ea9d..f4eb8ee 100644 --- a/src/TokenBuyerV2.sol +++ b/src/TokenBuyerV2.sol @@ -212,8 +212,8 @@ contract TokenBuyerV2 is Ownable, Pausable, ReentrancyGuard { /// @notice Buy sellToken tokens from this contract in exchange for `paymentToken` tokens. /// The price is determined using `priceFeed` plus `botDiscountBPs` /// Immediately invokes `payer` to pay back outstanding debt - /// @dev First sends sellToken by calling a callback, and then checks it received payment tokens. - /// This allowed the caller to swap the sellToken for tokens instead of holding tokens in advance. + /// @dev First sends sellToken to `to`, then invokes the callback afterwhich it checks it received payment tokens. + /// This allows the caller to swap the sellToken for tokens instead of holding tokens in advance. /// @param paymentTokenAmount the amount of paymentToken tokens msg.sender wishes to sell to this contract in exchange for sellToken /// @param to the address to send sellToken to by calling the callback function on it /// @param data arbitrary data passed through by the caller, usually used for callback verification @@ -284,7 +284,7 @@ contract TokenBuyerV2 is Ownable, Pausable, ReentrancyGuard { } } - /// @notice Returns the `sellToken`/`paymentToken` price this contract is willing to swapp at, including the discount + /// @notice Returns the `sellToken`/`paymentToken` price this contract is willing to swap at, including the discount /// @return The price, in 18 decimal format function price() public view returns (uint256) { unchecked { @@ -298,9 +298,10 @@ contract TokenBuyerV2 is Ownable, Pausable, ReentrancyGuard { function sellTokenAmountPerPaymentTokenAmount(uint256 paymentTokenAmount) public view returns (uint256) { unchecked { // Example: + // if sellTokenUnit == 1e10 (10 decimals) // if paymentTokenAmount == 3400000000 (3400 USDC) (6 decimals) // and price() == 1745910000000000000000 (1745.91) (18 decimals) - // ((3400000000 * 1e36) / 1745910000000000000000) / 1e6 = 1.947408515e18 (3400/1745.91) + // ((3400000000 * 1e18 * 1e10) / 1745910000000000000000) / 1e6 = 1.947408515e10 (3400/1745.91) return ((paymentTokenAmount * 1e18 * sellTokenUnit) / price()) / paymentTokenUnit; } } @@ -453,7 +454,7 @@ contract TokenBuyerV2 is Ownable, Pausable, ReentrancyGuard { ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ */ - function safeSendSellToken(address to, uint256 ethAmount) internal { - sellToken.safeTransferFrom(treasury, to, ethAmount); + function safeSendSellToken(address to, uint256 amount) internal { + sellToken.safeTransferFrom(treasury, to, amount); } } diff --git a/test/TokenBuyerV2.t.sol b/test/TokenBuyerV2.t.sol index ca7f70c..3d5d595 100644 --- a/test/TokenBuyerV2.t.sol +++ b/test/TokenBuyerV2.t.sol @@ -211,10 +211,10 @@ contract TokenBuyerV2Test is Test { buyer.setBaselinePaymentTokenAmount(100_000e18); priceFeed.setPrice(2000e18); - (uint256 tokenAmount, uint256 ethAmount) = buyer.paymentTokenAmountNeededAndSellTokenPayout(); + (uint256 paymentTokenAmount, uint256 sellTokenAmount) = buyer.paymentTokenAmountNeededAndSellTokenPayout(); - assertEq(tokenAmount, 100_000e18); - assertEq(ethAmount, 50 ether); + assertEq(paymentTokenAmount, 100_000e18); + assertEq(sellTokenAmount, 50 ether); } function test_tokenAmountNeededAndSellTokenPayout_lowersTokensIfItBuysMoreEthThanAvailable() public { @@ -357,7 +357,7 @@ contract TokenBuyerV2Test is Test { assertEq(price, 2121e18); } - function test_buyETH_revertsWhenPaused() public { + function test_swapTokens_revertsWhenPaused() public { vm.prank(admin); buyer.pause(); @@ -365,7 +365,7 @@ contract TokenBuyerV2Test is Test { buyer.swapTokens(1234); } - function test_buyETH_botBuysExactBaselineAmount() public { + function test_swapTokens_botBuysExactBaselineAmount() public { // Say ETH is worth $2000, then the oracle price denominated in ETH would be // 1 / 2000 = 0.0005 priceFeed.setPrice(2000e18); @@ -389,7 +389,7 @@ contract TokenBuyerV2Test is Test { assertEq(paymentToken.balanceOf(address(payer)), 2000e18); } - function test_buyETH_paysBackDebt() public { + function test_swapTokens_paysBackDebt() public { // user has debt of 2000 tokens vm.prank(owner); payer.sendOrRegisterDebt(user, 2000e18); @@ -410,7 +410,7 @@ contract TokenBuyerV2Test is Test { assertEq(payer.debtOf(user), 0); } - function test_buyETH_botCappedToBaselineAmount() public { + function test_swapTokens_botCappedToBaselineAmount() public { priceFeed.setPrice(2000e18); vm.prank(treasury); sellToken.approve(address(buyer), 1 ether); @@ -430,7 +430,7 @@ contract TokenBuyerV2Test is Test { assertEq(paymentToken.balanceOf(bot), 2000e18); } - function test_buyETH_revertsWhenContractHasInsufficientSellTokenApproval() public { + function test_swapTokens_revertsWhenContractHasInsufficientSellTokenApproval() public { priceFeed.setPrice(2000e18); paymentToken.mint(bot, 2000e18); vm.prank(owner); @@ -445,7 +445,7 @@ contract TokenBuyerV2Test is Test { buyer.swapTokens(2000e18); } - function test_buyETH_revertsWhenTreasuryHasInsufficientSellToken() public { + function test_swapTokens_revertsWhenTreasuryHasInsufficientSellToken() public { priceFeed.setPrice(2000e18); paymentToken.mint(bot, 2000e18); vm.prank(treasury); @@ -468,7 +468,7 @@ contract TokenBuyerV2Test is Test { buyer.swapTokens(2000e18); } - function test_buyETH_revertsWhenTokenApprovalInsufficient() public { + function test_swapTokens_revertsWhenTokenApprovalInsufficient() public { priceFeed.setPrice(2000e18); vm.prank(treasury); sellToken.approve(address(buyer), 1 ether); @@ -484,7 +484,7 @@ contract TokenBuyerV2Test is Test { buyer.swapTokens(2000e18); } - function test_buyETHWithCallback_revertsWhenPaused() public { + function test_swapTokensWithCallback_revertsWhenPaused() public { vm.prank(admin); buyer.pause(); @@ -493,7 +493,7 @@ contract TokenBuyerV2Test is Test { buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); } - function test_buyETHWithCallback_botBuysExactBaselineAmount() public { + function test_swapTokensWithCallback_botBuysExactBaselineAmount() public { priceFeed.setPrice(2000e18); vm.prank(treasury); sellToken.approve(address(buyer), 1 ether); @@ -511,7 +511,7 @@ contract TokenBuyerV2Test is Test { assertEq(sellToken.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); } - function test_buyETHWithCallback_paysBackDebt() public { + function test_swapTokensWithCallback_paysBackDebt() public { priceFeed.setPrice(2000e18); vm.prank(treasury); sellToken.approve(address(buyer), 1 ether); @@ -531,7 +531,7 @@ contract TokenBuyerV2Test is Test { assertEq(payer.debtOf(user), 500e18); } - function test_buyETHWithCallback_botCappedToBaselineAmount() public { + function test_swapTokensWithCallback_botCappedToBaselineAmount() public { priceFeed.setPrice(2000e18); vm.prank(treasury); sellToken.approve(address(buyer), 1 ether); @@ -550,7 +550,7 @@ contract TokenBuyerV2Test is Test { assertEq(paymentToken.balanceOf(address(callbackBot)), 2000e18); } - function test_buyETHWithCallback_revertsWhenContractHasInsufficientSellTokenApproval() public { + function test_swapTokensWithCallback_revertsWhenContractHasInsufficientSellTokenApproval() public { priceFeed.setPrice(2000e18); paymentToken.mint(address(callbackBot), 4000e18); vm.prank(owner); @@ -565,7 +565,7 @@ contract TokenBuyerV2Test is Test { buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); } - function test_buyETHWithCallback_revertsWhenTreasuryHasInsufficientSellToken() public { + function test_swapTokensWithCallback_revertsWhenTreasuryHasInsufficientSellToken() public { priceFeed.setPrice(2000e18); paymentToken.mint(address(callbackBot), 4000e18); vm.prank(treasury); @@ -584,7 +584,7 @@ contract TokenBuyerV2Test is Test { buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); } - function test_buyETHWithCallback_revertsWhenTokenPaymentInsufficient() public { + function test_swapTokensWithCallback_revertsWhenTokenPaymentInsufficient() public { priceFeed.setPrice(2000e18); vm.prank(treasury); sellToken.approve(address(buyer), 1 ether); @@ -599,7 +599,7 @@ contract TokenBuyerV2Test is Test { buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); } - function test_buyETHWithCallback_usesAllTokensToPayBackDebt() public { + function test_swapTokensWithCallback_usesAllTokensToPayBackDebt() public { vm.prank(owner); payer.sendOrRegisterDebt(address(0x7777), 2000e18 + 10); @@ -620,7 +620,7 @@ contract TokenBuyerV2Test is Test { assertEq(paymentToken.balanceOf(address(0x7777)), 2000e18 + 10); } - function test_buyETHWithCallback_maliciousBuyerCantReenter() public { + function test_swapTokensWithCallback_maliciousBuyerCantReenter() public { MaliciousBuyerV2 attacker = new MaliciousBuyerV2(address(buyer), paymentToken); priceFeed.setPrice(2000e18); vm.prank(treasury); @@ -633,7 +633,7 @@ contract TokenBuyerV2Test is Test { attacker.reenterBuyWithCallback(2000e18); } - function test_buyETHWithCallback_maliciousBuyerCantReenterOtherBuyETHFunction() public { + function test_swapTokensWithCallback_maliciousBuyerCantReenterOtherBuyETHFunction() public { MaliciousBuyerV2 attacker = new MaliciousBuyerV2(address(buyer), paymentToken); priceFeed.setPrice(2000e18); vm.prank(treasury);