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: 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/TokenBuyerV2.sol b/src/TokenBuyerV2.sol new file mode 100644 index 0000000..f4eb8ee --- /dev/null +++ b/src/TokenBuyerV2.sol @@ -0,0 +1,460 @@ +// 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 { ISwapTokensCallback } from './ISwapTokensCallback.sol'; +import { IPayer } from './IPayer.sol'; + +/// @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 TokenBuyerV2 is Ownable, Pausable, ReentrancyGuard { + using SafeERC20 for IERC20Metadata; + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + ERRORS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + error ReceivedInsufficientTokens(uint256 expected, uint256 actual); + error OnlyAdminOrOwner(); + error InvalidBotDiscountBPs(); + error InvalidBaselinePaymentTokenAmount(); + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + EVENTS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + event SwappedTokens(address indexed to, uint256 sellTokenOut, uint256 paymentTokenIn); + event BotDiscountBPsSet(uint16 oldBPs, uint16 newBPs); + event BaselinePaymentTokenAmountSet(uint256 oldAmount, uint256 newAmount); + 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 the sellToken + IERC20Metadata public immutable paymentToken; + + /// @notice The ERC20 token the contract will sell in exchange for paymentToken + IERC20Metadata public immutable sellToken; + + /// @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; + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + 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 _sellToken, + address _treasury + ) { + payer = IPayer(_payer); + + address _paymentToken = address(payer.paymentToken()); + paymentToken = IERC20Metadata(_paymentToken); + paymentTokenUnit = 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; + sellToken = IERC20Metadata(_sellToken); + sellTokenUnit = 10**IERC20Metadata(_sellToken).decimals(); + treasury = _treasury; + } + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + EXTERNAL TRANSACTIONS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + /// @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 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; + + // 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 sellTokenAmount = sellTokenAmountPerPaymentTokenAmount(amount); + safeSendSellToken(msg.sender, sellTokenAmount); + + emit SwappedTokens(msg.sender, sellTokenAmount, amount); + } + + /// @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 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 + function swapTokens( + uint256 paymentTokenAmount, + address to, + bytes calldata data + ) external nonReentrant whenNotPaused { + uint256 amount = Math.min(paymentTokenAmount, paymentTokenAmountNeeded()); + + IPayer _payer = payer; + + // Starting balance of `payer` + uint256 balanceBefore = paymentToken.balanceOf(address(_payer)); + + // 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; + if (tokensReceived < amount) { + revert ReceivedInsufficientTokens(amount, tokensReceived); + } + + // Invoke `payer` to pay back outstanding debt + _payer.payBackDebt(tokensReceived); + + emit SwappedTokens(to, sellTokenAmount, tokensReceived); + } + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + VIEW FUNCTIONS + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + /// @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 payment tokens this contract is willing to swap + /// @return amount of tokens + function paymentTokenAmountNeeded() 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 `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 { + return (priceFeed.price() * (10_000 - botDiscountBPs)) / 10_000; + } + } + + /// @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 sellTokenUnit == 1e10 (10 decimals) + // if paymentTokenAmount == 3400000000 (3400 USDC) (6 decimals) + // and price() == 1745910000000000000000 (1745.91) (18 decimals) + // ((3400000000 * 1e18 * 1e10) / 1745910000000000000000) / 1e6 = 1.947408515e10 (3400/1745.91) + return ((paymentTokenAmount * 1e18 * sellTokenUnit) / price()) / paymentTokenUnit; + } + } + + /// @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 + paymentTokenAmount = paymentTokenAmountPerSellTokenAmount(sellTokenAvailable); + + // Recalculate eth amount because tokens amount are rounded down + sellTokenAmount = sellTokenAmountPerPaymentTokenAmount(paymentTokenAmount); + + return (paymentTokenAmount, sellTokenAmount); + } + } + + /// @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 paymentTokenAmountPerSellTokenAmount(uint256 sellTokenAmount) public view returns (uint256) { + return (sellTokenAmount * price() * paymentTokenUnit) / (1e18 * sellTokenUnit); + } + + /** + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + 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 safeSendSellToken(address to, uint256 amount) internal { + sellToken.safeTransferFrom(treasury, to, amount); + } +} diff --git a/test/TokenBuyerV2.t.sol b/test/TokenBuyerV2.t.sol new file mode 100644 index 0000000..3d5d595 --- /dev/null +++ b/test/TokenBuyerV2.t.sol @@ -0,0 +1,1066 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import 'forge-std/Test.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 { MaliciousBuyerV2 } from './helpers/MaliciousBuyer.sol'; +import { ISwapTokensCallback } from '../src/ISwapTokensCallback.sol'; +import { BuyerBot } from './helpers/BuyerBot.sol'; + +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 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); + 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); + + TokenBuyerV2 buyer; + Payer payer; + TestERC20 paymentToken; + TestERC20 sellToken; + 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); + BuyerBot 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); + sellToken = new TestERC20('sellToken', 'sellToken', 18); + sellToken.mint(treasury, 10000 ether); + priceFeed = new TestPriceFeed(); + payer = new Payer(owner, address(paymentToken)); + buyer = new TokenBuyerV2({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: botDiscountBPs, + _minAdminBotDiscountBPs: 0, + _maxAdminBotDiscountBPs: 10_000, + _owner: owner, + _admin: admin, + _payer: address(payer), + _sellToken: address(sellToken), + _treasury: treasury + }); + callbackBot = new BuyerBot(address(payer), address(paymentToken), STUB_CALLDATA, botOperator); + } + + function test_bpsUnder_10000() public { + uint16 bpsTooHigh = 10001; + + vm.expectRevert(TokenBuyerV2.InvalidBotDiscountBPs.selector); + buyer = new TokenBuyerV2({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: bpsTooHigh, + _minAdminBotDiscountBPs: 0, + _maxAdminBotDiscountBPs: 10_000, + _owner: owner, + _admin: admin, + _payer: address(payer), + _sellToken: address(0), + _treasury: address(0) + }); + + vm.expectRevert(TokenBuyerV2.InvalidBotDiscountBPs.selector); + buyer = new TokenBuyerV2({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: botDiscountBPs, + _minAdminBotDiscountBPs: bpsTooHigh, + _maxAdminBotDiscountBPs: 10_000, + _owner: owner, + _admin: admin, + _payer: address(payer), + _sellToken: address(0), + _treasury: address(0) + }); + + vm.expectRevert(TokenBuyerV2.InvalidBotDiscountBPs.selector); + buyer = new TokenBuyerV2({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: botDiscountBPs, + _minAdminBotDiscountBPs: 0, + _maxAdminBotDiscountBPs: bpsTooHigh, + _owner: owner, + _admin: admin, + _payer: address(payer), + _sellToken: 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.paymentTokenAmountNeeded(), 100_000e18); + } + + function test_tokenAmountNeeded_debtOnly() public { + vm.prank(address(owner)); + payer.sendOrRegisterDebt(address(1), 42_000e18); + + assertEq(buyer.paymentTokenAmountNeeded(), 42_000e18); + } + + function test_tokenAmountNeeded_paymentTokenBalanceOnly() public { + paymentToken.mint(address(payer), 42_000e18); + + assertEq(buyer.paymentTokenAmountNeeded(), 0); + } + + function test_tokenAmountNeeded_baselineAndPaymentTokenBalance() public { + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(100_000e18); + paymentToken.mint(address(payer), 42_000e18); + + assertEq(buyer.paymentTokenAmountNeeded(), 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.paymentTokenAmountNeeded(), 69_000e18); + } + + function test_tokenAmountPerSellTokenAmount() public { + priceFeed.setPrice(1358.37e18); + uint256 ethAmount = 1.333 ether; + uint256 tokenAmount = buyer.paymentTokenAmountPerSellTokenAmount(ethAmount); + + assertEq(tokenAmount, 1810.70721e18); + } + + function test_tokenAmountPerSellTokenAmount_roundsDown() public { + uint256 ethAmount = 100000000000000000; // 0.1 ether + uint256 price = 111111111111111111111; // 111.111111111111111111 + + priceFeed.setPrice(price); + + uint256 tokenAmount = buyer.paymentTokenAmountPerSellTokenAmount(ethAmount); + uint256 ethAmount2 = buyer.sellTokenAmountPerPaymentTokenAmount(tokenAmount); + + assertLt(ethAmount2, ethAmount); + } + + function test_tokenAmountNeededAndSellTokenPayout_baselineAmountOnly() public { + vm.prank(treasury); + sellToken.approve(address(buyer), 50 ether); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(100_000e18); + priceFeed.setPrice(2000e18); + + (uint256 paymentTokenAmount, uint256 sellTokenAmount) = buyer.paymentTokenAmountNeededAndSellTokenPayout(); + + assertEq(paymentTokenAmount, 100_000e18); + assertEq(sellTokenAmount, 50 ether); + } + + function test_tokenAmountNeededAndSellTokenPayout_lowersTokensIfItBuysMoreEthThanAvailable() public { + paymentToken = new TestERC20('A', 'B', 6); + payer = new Payer(owner, address(paymentToken)); + + buyer = new TokenBuyerV2({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: botDiscountBPs, + _minAdminBotDiscountBPs: 0, + _maxAdminBotDiscountBPs: 10_000, + _owner: owner, + _admin: admin, + _payer: address(payer), + _sellToken: address(sellToken), + _treasury: treasury + }); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(100_000e6); + + vm.prank(treasury); + sellToken.approve(address(buyer), 8 ether); + + priceFeed.setPrice(1350717518812290000000); + (uint256 tokenAmount, uint256 ethAmount) = buyer.paymentTokenAmountNeededAndSellTokenPayout(); + + uint256 ethAmount2 = buyer.sellTokenAmountPerPaymentTokenAmount(tokenAmount); + + assertEq(ethAmount, ethAmount2); + } + + function test_tokenAmountNeededAndSellTokenPayout_lowersTokensIfItBuysMoreEthThanAvailable_fuzz( + uint256 sellTokenAllowance, + uint256 price, + uint256 decimals, + uint256 tokensNeeded + ) public { + decimals = bound(decimals, 6, 18); + 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 TokenBuyerV2({ + _priceFeed: priceFeed, + _baselinePaymentTokenAmount: baselinePaymentTokenAmount, + _minAdminBaselinePaymentTokenAmount: 0, + _maxAdminBaselinePaymentTokenAmount: 10_000_000e18, + _botDiscountBPs: botDiscountBPs, + _minAdminBotDiscountBPs: 0, + _maxAdminBotDiscountBPs: 10_000, + _owner: owner, + _admin: admin, + _payer: address(payer), + _sellToken: address(sellToken), + _treasury: treasury + }); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(tokensNeeded); + + vm.prank(treasury); + sellToken.approve(address(buyer), sellTokenAllowance); + + priceFeed.setPrice(price); + (uint256 tokenAmount, uint256 sellTokenAmount) = buyer.paymentTokenAmountNeededAndSellTokenPayout(); + + uint256 sellTokenAmount2 = buyer.sellTokenAmountPerPaymentTokenAmount(tokenAmount); + + assertEq(sellTokenAmount, sellTokenAmount2); + } + + function test_tokenAmountNeededAndSellTokenPayout_lessSellTokenApproved() public { + vm.prank(treasury); + sellToken.approve(address(buyer), 5 ether); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(100_000e18); + priceFeed.setPrice(2000e18); + + (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); + } + + 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_swapTokens_revertsWhenPaused() public { + vm.prank(admin); + buyer.pause(); + + vm.expectRevert('Pausable: paused'); + buyer.swapTokens(1234); + } + + 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); + vm.prank(treasury); + sellToken.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 SwappedTokens(bot, 1 ether, 2000e18); + buyer.swapTokens(2000e18); + + vm.stopPrank(); + + assertEq(sellToken.balanceOf(bot), 1 ether); + assertEq(sellToken.balanceOf(treasury), 9999 ether); + assertEq(paymentToken.balanceOf(address(payer)), 2000e18); + } + + function test_swapTokens_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); + sellToken.approve(address(buyer), 1 ether); + paymentToken.mint(bot, 2000e18); + vm.startPrank(bot); + paymentToken.approve(address(buyer), 2000e18); + buyer.swapTokens(2000e18); + vm.stopPrank(); + + // user has been paid + assertEq(paymentToken.balanceOf(user), 2000e18); + assertEq(payer.debtOf(user), 0); + } + + function test_swapTokens_botCappedToBaselineAmount() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + sellToken.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 SwappedTokens(bot, 1 ether, 2000e18); + buyer.swapTokens(4000e18); + vm.stopPrank(); + + assertEq(sellToken.balanceOf(bot), 1 ether); + assertEq(paymentToken.balanceOf(bot), 2000e18); + } + + function test_swapTokens_revertsWhenContractHasInsufficientSellTokenApproval() 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.swapTokens(2000e18); + } + + function test_swapTokens_revertsWhenTreasuryHasInsufficientSellToken() public { + priceFeed.setPrice(2000e18); + paymentToken.mint(bot, 2000e18); + vm.prank(treasury); + sellToken.approve(address(buyer), 1 ether); + // reduce treasury balance to 0.5 ether + vm.startPrank(treasury); + sellToken.transfer(address(123), sellToken.balanceOf(treasury) - 0.5 ether); + assertEq(sellToken.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.swapTokens(2000e18); + } + + function test_swapTokens_revertsWhenTokenApprovalInsufficient() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + sellToken.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.swapTokens(2000e18); + } + + function test_swapTokensWithCallback_revertsWhenPaused() public { + vm.prank(admin); + buyer.pause(); + + vm.expectRevert('Pausable: paused'); + vm.prank(botOperator); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); + } + + function test_swapTokensWithCallback_botBuysExactBaselineAmount() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + sellToken.approve(address(buyer), 1 ether); + paymentToken.mint(address(callbackBot), 2000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + uint256 balanceBefore = sellToken.balanceOf(address(callbackBot)); + + vm.expectEmit(true, true, true, true); + emit SwappedTokens(address(callbackBot), 1 ether, 2000e18); + + vm.prank(botOperator); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); + + assertEq(sellToken.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); + } + + function test_swapTokensWithCallback_paysBackDebt() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + sellToken.approve(address(buyer), 1 ether); + paymentToken.mint(address(callbackBot), 2000e18); + + vm.prank(owner); + payer.sendOrRegisterDebt(user, 2500e18); + + uint256 balanceBefore = sellToken.balanceOf(address(callbackBot)); + + vm.prank(botOperator); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); + + assertEq(sellToken.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); + assertEq(paymentToken.balanceOf(user), 2000e18); + assertEq(paymentToken.balanceOf(address(payer)), 0); + assertEq(payer.debtOf(user), 500e18); + } + + function test_swapTokensWithCallback_botCappedToBaselineAmount() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + sellToken.approve(address(buyer), 1 ether); + paymentToken.mint(address(callbackBot), 4000e18); + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + uint256 balanceBefore = sellToken.balanceOf(address(callbackBot)); + + vm.expectEmit(true, true, true, true); + emit SwappedTokens(address(callbackBot), 1 ether, 2000e18); + + vm.prank(botOperator); + buyer.swapTokens(4000e18, address(callbackBot), STUB_CALLDATA); + + assertEq(sellToken.balanceOf(address(callbackBot)) - balanceBefore, 1 ether); + assertEq(paymentToken.balanceOf(address(callbackBot)), 2000e18); + } + + function test_swapTokensWithCallback_revertsWhenContractHasInsufficientSellTokenApproval() 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); + sellToken.approve(address(buyer), 1 ether - 1 wei); + + vm.expectRevert('ERC20: insufficient allowance'); + vm.prank(botOperator); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); + } + + function test_swapTokensWithCallback_revertsWhenTreasuryHasInsufficientSellToken() public { + priceFeed.setPrice(2000e18); + paymentToken.mint(address(callbackBot), 4000e18); + vm.prank(treasury); + sellToken.approve(address(buyer), 1 ether); + // reduce treasury balance to 0.5 ether + vm.startPrank(treasury); + sellToken.transfer(address(123), sellToken.balanceOf(treasury) - 0.5 ether); + assertEq(sellToken.balanceOf(treasury), 0.5 ether); + vm.stopPrank(); + + vm.prank(owner); + buyer.setBaselinePaymentTokenAmount(2000e18); + + vm.expectRevert('ERC20: transfer amount exceeds balance'); + vm.prank(botOperator); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); + } + + function test_swapTokensWithCallback_revertsWhenTokenPaymentInsufficient() public { + priceFeed.setPrice(2000e18); + vm.prank(treasury); + 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(TokenBuyerV2.ReceivedInsufficientTokens.selector, 2000e18, 2000e18 - 1)); + vm.prank(botOperator); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); + } + + function test_swapTokensWithCallback_usesAllTokensToPayBackDebt() public { + vm.prank(owner); + payer.sendOrRegisterDebt(address(0x7777), 2000e18 + 10); + + priceFeed.setPrice(2000e18); + vm.prank(treasury); + sellToken.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 SwappedTokens(address(callbackBot), 1 ether, 2000e18 + 10); + buyer.swapTokens(2000e18, address(callbackBot), STUB_CALLDATA); + + assertEq(paymentToken.balanceOf(address(0x7777)), 2000e18 + 10); + } + + function test_swapTokensWithCallback_maliciousBuyerCantReenter() public { + MaliciousBuyerV2 attacker = new MaliciousBuyerV2(address(buyer), paymentToken); + priceFeed.setPrice(2000e18); + vm.prank(treasury); + sellToken.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_swapTokensWithCallback_maliciousBuyerCantReenterOtherBuyETHFunction() public { + MaliciousBuyerV2 attacker = new MaliciousBuyerV2(address(buyer), paymentToken); + priceFeed.setPrice(2000e18); + vm.prank(treasury); + sellToken.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); + 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 SwappedTokens(bot, 1010 ether, 99_990e18); + buyer.swapTokens(99_990e18); + vm.stopPrank(); + assertEq(paymentToken.balanceOf(bot), 0); + assertEq(sellToken.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); + 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 SwappedTokens(bot, 424242424242424242424, 42_000e18); + buyer.swapTokens(42_000e18); + vm.stopPrank(); + assertEq(paymentToken.balanceOf(bot), 0); + assertEq(sellToken.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); + 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 SwappedTokens(bot, 1010 ether, 99_990e18); + buyer.swapTokens(99_990e18); + vm.stopPrank(); + assertEq(paymentToken.balanceOf(bot), 0); + assertEq(sellToken.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); + 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 SwappedTokens(bot, 424242424242424242424, 42_000e18); + buyer.swapTokens(42_000e18); + vm.stopPrank(); + assertEq(paymentToken.balanceOf(bot), 0); + assertEq(sellToken.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); + sellToken.approve(address(buyer), 1010 ether); + vm.startPrank(bot); + paymentToken.approve(address(buyer), 99_990e18); + + vm.expectEmit(true, true, true, true); + emit SwappedTokens(bot, 1010 ether, 99_990e18); + buyer.swapTokens(99_990e18); + vm.stopPrank(); + assertEq(paymentToken.balanceOf(bot), 0); + assertEq(sellToken.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(TokenBuyerV2.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(TokenBuyerV2.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(TokenBuyerV2.InvalidBotDiscountBPs.selector)); + vm.prank(admin); + buyer.setBotDiscountBPs(49); + } + + function test_setBotDiscountBPs_adminCall_revertsGivenInputGreaterThanMax() public { + vm.prank(owner); + buyer.setMaxAdminBotDiscountBPs(100); + + vm.expectRevert(abi.encodeWithSelector(TokenBuyerV2.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(TokenBuyerV2.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(TokenBuyerV2.OnlyAdminOrOwner.selector)); + buyer.pause(); + + vm.expectRevert(abi.encodeWithSelector(TokenBuyerV2.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_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); + + assertApproxEqAbs(insufficientBalance, 1.1e18, 0.00001e18); + assertApproxEqAbs(insufficientAllowance, 1.1e18, 0.00001e18); + + // treasury has partial balance + sellToken.mint(treasury, 1.0e18); + + (insufficientBalance, insufficientAllowance) = buyer.sellTokenNeeded(100e18); + + assertApproxEqAbs(insufficientBalance, 0.1e18, 0.00001e18); + assertApproxEqAbs(insufficientAllowance, 1.1e18, 0.00001e18); + + // partial allowance + vm.prank(treasury); + 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/BuyerBot.sol b/test/helpers/BuyerBot.sol new file mode 100644 index 0000000..22d87f8 --- /dev/null +++ b/test/helpers/BuyerBot.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import 'forge-std/Test.sol'; +import { ISwapTokensCallback } from '../../src/ISwapTokensCallback.sol'; +import { IERC20 } from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; + +contract BuyerBot is ISwapTokensCallback, 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 swapTokensCallback( + 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_; + } +} diff --git a/test/helpers/MaliciousBuyer.sol b/test/helpers/MaliciousBuyer.sol index f7191ac..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,6 +16,16 @@ interface TokenBuyerLike { ) external; } +interface TokenBuyerV2Like { + function swapTokens(uint256 tokenAmountWAD) external; + + function swapTokens( + uint256 tokenAmountWAD, + address to, + bytes calldata data + ) external; +} + contract MaliciousBuyer is IBuyETHCallback { TokenBuyerLike buyer; IERC20 token; @@ -66,3 +77,47 @@ contract MaliciousBuyer is IBuyETHCallback { } } } + +contract MaliciousBuyerV2 is ISwapTokensCallback { + TokenBuyerV2Like buyer; + IERC20 token; + bool calledTwice; + bool reenterWithCallback; + + constructor(address _buyer, IERC20 _token) { + buyer = TokenBuyerV2Like(_buyer); + token = _token; + } + + function attack(uint256 tokenAmountWAD) public { + buyer.swapTokens(tokenAmountWAD); + } + + function reenterBuyWithCallback(uint256 tokenAmountWAD) public { + reenterWithCallback = true; + buyer.swapTokens(tokenAmountWAD, address(this), ''); + } + + function reenterBuyNoCallback(uint256 tokenAmountWAD) public { + reenterWithCallback = false; + buyer.swapTokens(tokenAmountWAD, address(this), ''); + } + + function swapTokensCallback( + address, + uint256 amount, + bytes calldata + ) external payable { + if (reenterWithCallback) { + if (!calledTwice) { + calledTwice = true; + buyer.swapTokens(amount, address(this), ''); + } else { + token.transfer(address(buyer), amount); + } + } else { + token.approve(address(buyer), amount); + buyer.swapTokens(amount); + } + } +}