[SCH] Smart Contract Hacking 17편 - DeFi : Sniping bot

0xDave·2023년 6월 23일
0

Ethereum

목록 보기
110/112
post-thumbnail

요즘 같은 밈코인 장에 아주 적절한 예제를 만나서 너무 반갑다. 실제 스나이핑 봇에 비하면 허술하겠지만 그래도 만들다보면 얻는 게 많을 것 같다. 유니스왑 코드를 참고해서 아래 빈칸들을 채워보자.


pragma solidity 0.8.13;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/interfaces/IERC20.sol";

import "../interfaces/IUniswapV2.sol";

/**
 * @title Sniper
 * @author JohnnyTime (https://smartcontractshacking.com)
 */
contract Sniper is Ownable {

  // We are calculating `amountOut = amountOut * slippage / 1000.
  // So, if we set slippage to 1000, it means no slippage at all, because 1000 / 1000 = 1
  // Initally we try to purchase without slippage
  uint private constant INITIAL_SLIPPAGE = 1000; 
  // Every failed attemp we will increase the slippage by 0.3% (3 / 1000)
  uint private constant SLLIPAGE_INCREMENTS = 3; 

  IUniswapV2Factory private immutable factory;

  constructor(address _factory) {
    factory = IUniswapV2Factory(_factory);
  }

  /**
   * The main external snipe function that is being called by the contract owner.
   * Checks the the current reserves, determines the expected amountOut.
   * If amountOut >= `_absoluteMinAmountOut`, it will try to swap the tokens `_maxRetries` times
   * using the internal `_swapWithRetries` function.
   * @param _tokenIn the token address you want to sell
   * @param _tokenOut the token address you want to buy
   * @param _amountIn the amount of tokens you are sending in
   * @param _absoluteMinAmountOut the minimum amount of tokens you want out of the trade
   * @param _maxRetries In case the swap fails, it will try again _maxRetries times (with higher slippage tolerance every time)
   */
  function snipe(
    address _tokenIn, address _tokenOut, uint256 _amountIn,
    uint256 _absoluteMinAmountOut, uint8 _maxRetries)
    external onlyOwner {
    // TODO: Implement this function

    // TODO: Use the Factory to get the pair contract address, revert if pair doesn't exist
    // Note: might return error - if the pair is not created yet

    // TODO: Sort the tokens using the internal `_sortTokens` function

    uint256 amountOut;
    // NOTE: We're using block to avoid "stack too deep" error
    {
      // TODO: Get pair reserves, and match them with _tokenIn and _tokenOut
      
      // TODO: Get the expected amount out and revert if it's lower than the `_absoluteMinAmountOut`
      // NOTE: Use the internal _getAmountOut function
      
    }

    // TODO: Transfer the token to the pair contract

    // TODO: Set amount0Out and amount1Out, based on token0 & token1

    // TODO: Call internal _swapWithRetreis function with the relevant parameters

    // TODO: Transfer the sniped tokens to the owner

  }

  /**
   * Internal function that will try to swap the tokens using the pair contract
   * In case the swap failed, it will call itself again with higher slippage
   * and try again until the swap succeded or `_maxRetries`
   */
  function _swapWithRetries(
    address _pair, uint256 _amount0Out, uint256 _amount1Out, uint8 _maxRetries, uint8 _retryNo
    ) internal {
    // TODO: Implement this function

    // Our slippage tolerance. Every retry we will be willinig to pay 0.3% more for the tokens
    // The slippage will be calculated by `amountOut * slippage / 1000`, so
    // 0.3% = 997, 0.6% = 994, and so on..
    uint256 slippageTolerance;

    // TODO: Revert if we reached max retries

    // TODO: Set the slippage tolerance based on the _retryNo
    // TODO: Start from INITIAL_SLIPPAGE, then every retry we reduce SLLIPAGE_INCREMENTS

    // TODO: Apply the slippage to the amounts

    // TODO: Call the low-level pair swap() function with all the parameters
    // TODO: In case it failed, call _swapWithRetreis again (don't forget to increment _retryNo)

  }

  /**
   * Internal function to sort the tokens by their addresses
   * Exact same logic like in the Unsiwap Factory `createPair()` function.
   */
  function _sortTokens(address tokenA, address tokenB) internal pure returns (address token0, address token1) {
    // TODO: Implement tokens sorting functionality as in Uniswap V2 Factory `createPair` function
    
  }

  /**
   * Internal function to get the expected amount of tokens which we will receive based on given `amountIn` and pair reserves.
   * Exact same logic like in the Unsiwap Library `_getAmountOut()` function.
   */
  function _getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
      // TODO: Implement functionality as in Uniswap V2 Library `getAmountOut` function

  }
  
}

snipe()


내가 쓴 답

  function snipe(
    address _tokenIn, address _tokenOut, uint256 _amountIn,
    uint256 _absoluteMinAmountOut, uint8 _maxRetries)
    external onlyOwner {
    // TODO: Implement this function

    // TODO: Use the Factory to get the pair contract address, revert if pair doesn't exist
    // Note: might return error - if the pair is not created yet
    address pair = factory.getPair(_tokenIn, _tokenOut);
    if (pair == address(0)) {
        revert();
    }
    // TODO: Sort the tokens using the internal `_sortTokens` function
    (address token0, address token1) = _sortTokens(_tokenIn, _tokenOut);

    uint256 amountOut;
    // NOTE: We're using block to avoid "stack too deep" error
    {
      // TODO: Get pair reserves, and match them with _tokenIn and _tokenOut
      (uint112 _reserve0, uint112 _reserve1,) = pair.getReserves();

      // TODO: Get the expected amount out and revert if it's lower than the `_absoluteMinAmountOut`
      // NOTE: Use the internal _getAmountOut function
      amountOut = _getAmountOut(_amountIn, _reserve0, _reserve1);
      if (amountOut < _absoluteMinAmountOut) {
            revert();
      }
    }

    // TODO: Transfer the token to the pair contract
    IERC20(_tokenIn).transferFrom(msg.sender, pair, _amountIn);

    // TODO: Set amount0Out and amount1Out, based on token0 & token1
    (uint256 amount0Out, uint256 amount1Out) = ??

    // TODO: Call internal _swapWithRetreis function with the relevant parameters
    _swapWithRetries(pair, amount0Out, amount1Out, _maxRetries, 0);

    // TODO: Transfer the sniped tokens to the owner
    IERC20(_tokenOut).transfer(msg.sender, amountOut);

  }

중간에 amount0Out, amount1Out을 어떻게 해야될 지 몰라서 비워놨다.


모범답안

function snipe(
        address _tokenIn,
        address _tokenOut,
        uint256 _amountIn,
        uint256 _absoluteMinAmountOut,
        uint8 _maxRetries
    ) external onlyOwner {
        // TODO: Implement this function

        // TODO: Use the Factory to get the pair contract address, revert if pair doesn't exist
        // Note: might return error - if the pair is not created yet
        address pairAddress = factory.getPair(_tokenIn, _tokenOut);
        require(pairAddress != address(0), "Pair doesnt exist");
        pair = IUniswapV2Pair(pairAddress);
        // TODO: Sort the tokens using the internal `_sortTokens` function
        (address token0, address token1) = _sortTokens(_tokenIn, _tokenOut);

        uint256 amountOut;
        // NOTE: We're using block to avoid "stack too deep" error
        {
            // TODO: Get pair reserves, and match them with _tokenIn and _tokenOut
            (uint256 reserve0, uint256 reserve1, ) = pair.getReserves();
            (uint256 reserveIn, uint256 reserveOut) = _tokenIn == token0 ? (reserve0, reserve1) : (reserve1, reserve0);

            // TODO: Get the expected amount out and revert if it's lower than the `_absoluteMinAmountOut`
            // NOTE: Use the internal _getAmountOut function
            amountOut = _getAmountOut(_amountIn, reserveIn, reserveOut);
            require(amountOut >= _absoluteMinAmountOut);
        }

        // TODO: Transfer the token to the pair contract
        IERC20(_tokenIn).transfer(address(pair), _amountIn);
        // TODO: Set amount0Out and amount1Out, based on token0 & token1
        (uint256 amount0Out, uint256 amount1Out) = _tokenIn == token0
            ? (uint256(0), amountOut)
            : (amountOut, uint256(0));
        // TODO: Call internal _swapWithRetries function with the relevant parameters

        _swapWithRetries(pairAddress, amount0Out, amount1Out, _maxRetries, 0);

        // TODO: Transfer the sniped tokens to the owner
        IERC20(_tokenOut).transfer(owner(), IERC20(_tokenOut).balanceOf(address(this)));
    }

수정해야 할 부분은 block 부분이다.

        // NOTE: We're using block to avoid "stack too deep" error
        {
            // TODO: Get pair reserves, and match them with _tokenIn and _tokenOut
            (uint256 reserve0, uint256 reserve1, ) = pair.getReserves();
            (uint256 reserveIn, uint256 reserveOut) = _tokenIn == token0 ? (reserve0, reserve1) : (reserve1, reserve0);

            // TODO: Get the expected amount out and revert if it's lower than the `_absoluteMinAmountOut`
            // NOTE: Use the internal _getAmountOut function
            amountOut = _getAmountOut(_amountIn, reserveIn, reserveOut);
            require(amountOut >= _absoluteMinAmountOut);
        }

getReserves()를 통해 reserve를 가져왔다고 해서 그걸로 끝나면 안 된다. 어떤 reserve가 페어에서 첫 번째로 사용될 지 정렬해줘야 한다. 이후에도 받는 토큰의 순서(amountOut을 앞에다 할 지 뒤에다 할 지)를 설정해줘야 한다


        IERC20(_tokenIn).transfer(address(pair), _amountIn);

나는 transferFrom을 이용해서 pair에 토큰을 보내게 했었는데 모범답안에서는 페어에 이미 토큰이 있다고 보고 그냥 transfer를 이용해 보내는 것 같다.


  	내가 쓴 답
    // TODO: Transfer the sniped tokens to the owner
    IERC20(_tokenOut).transfer(msg.sender, amountOut);
    모범 답안
    IERC20(_tokenOut).transfer(owner(), IERC20(_tokenOut).balanceOf(address(this)));

마지막 부분에서 나는 amountOut을 그냥 msg.sender에게 보내게 해놨는데, 이렇게 될 경우 스왑 이후의 받은 토큰의 양이 amountOut보다 적을 수 있다. 따라서 모범 답안처럼 스왑 이후에 컨트랙트가 가지고 있는 토큰의 양을 모두 보내는 방법이 훨씬 낫다.


_swapWithRetries()


내가 쓴 답

  function _swapWithRetries(
    address _pair, uint256 _amount0Out, uint256 _amount1Out, uint8 _maxRetries, uint8 _retryNo
    ) internal {
    // TODO: Implement this function

    // Our slippage tolerance. Every retry we will be willinig to pay 0.3% more for the tokens
    // The slippage will be calculated by `amountOut * slippage / 1000`, so
    // 0.3% = 997, 0.6% = 994, and so on..
    uint256 slippageTolerance;

    // TODO: Revert if we reached max retries
    if (_retryNo > _maxRetries) {
        revert();
    }

    // TODO: Set the slippage tolerance based on the _retryNo
    // TODO: Start from INITIAL_SLIPPAGE, then every retry we reduce SLLIPAGE_INCREMENTS
    uint256 adjustment = SLLIPAGE_INCREMENTS * _retryNo;
    slippageTolerance = (INITIAL_SLIPPAGE.sub(adjustment)) / 1000;

    // TODO: Apply the slippage to the amounts
    _amount0Out = _amount0Out * slippageTolerance;
    _amount1Out = _amount1Out * slippageTolerance;

    // TODO: Call the low-level pair swap() function with all the parameters
    // TODO: In case it failed, call _swapWithRetreis again (don't forget to increment _retryNo)
    (bool success, ) = _pair.swap(_amount0Out, _amount1Out, msg.sender, 0);
    if (success == false) {
        _retryNo++;
        _swapWithRetries(_pair, _amount0Out, _amount1Out, _maxRetries, _retryNo);
    }
  }

모범 답안

    function _swapWithRetries(
        address _pair,
        uint256 _amount0Out,
        uint256 _amount1Out,
        uint8 _maxRetries,
        uint8 _retryNo
    ) internal {
        // TODO: Implement this function

        // Our slippage tolerance. Every retry we will be willinig to pay 0.3% more for the tokens
        // The slippage will be calculated by `amountOut * slippage / 1000`, so
        // 0.3% = 997, 0.6% = 994, and so on..
        uint256 slippageTolerance;

        // TODO: Revert if we reached max retries
        require(_retryNo < _maxRetries, "Reached Max Retries");
        // TODO: Set the slippage tolerance based on the _retryNo
        // TODO: Start from INITIAL_SLIPPAGE, then every retry we reduce SLLIPAGE_INCREMENTS
        slippageTolerance = INITIAL_SLIPPAGE - (SLIPPAGE_INCREMENTS * _retryNo);

        // TODO: Apply the slippage to the amounts
        _amount0Out = (_amount0Out * slippageTolerance) / 1000;
        _amount1Out = (_amount1Out * slippageTolerance) / 1000;

        // TODO: Call the low-level pair swap() function with all the parameters
        // TODO: In case it failed, call _swapWithRetreis again (don't forget to increment _retryNo)
        try pair.swap(_amount0Out, _amount1Out, address(this), new bytes(0)) {} catch {
            _swapWithRetries(_pair, _amount0Out, _amount1Out, _maxRetries, _retryNo++);
        }
    }

일단 if문으로 revert를 시키는 것보다 모범 답안처럼 `require`문을 사용하는 게 훨씬 깔끔하다. 그 다음 코드에선 미리 1000으로 나눈 다음에 amount를 곱하는 방식을 사용했었는데 모범 답안을 보고 나니 먼저 amount를 곱하는 게 훨씬 안전한 것 같다.

마지막 부분에서 다시 swap을 할 때 모범 답안에서는 try - catch를 사용했다. 우선 이렇게도 할 수 있다는 것만 알아두자.


Internal function


내가 쓴 답

  function _sortTokens(address tokenA, address tokenB) internal pure returns (address token0, address token1) {
    // TODO: Implement tokens sorting functionality as in Uniswap V2 Factory `createPair` function
    require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
    (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
    require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
    require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); 
  }

  /**
   * Internal function to get the expected amount of tokens which we will receive based on given `amountIn` and pair reserves.
   * Exact same logic like in the Unsiwap Library `_getAmountOut()` function.
   */
  function _getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
    // TODO: Implement functionality as in Uniswap V2 Library `getAmountOut` function
    require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
    require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
    uint amountInWithFee = amountIn.mul(997);
    uint numerator = amountInWithFee.mul(reserveOut);
    uint denominator = reserveIn.mul(1000).add(amountInWithFee);
    amountOut = numerator / denominator;
  }

모범 답안

    function _sortTokens(address tokenA, address tokenB) internal pure returns (address token0, address token1) {
        // TODO: Implement tokens sorting functionality as in Uniswap V2 Factory `createPair` function
        require(tokenA != tokenB, "UniswapV2: IDENTICAL_ADDRESSES");
        (token0, token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
        require(token0 != address(0), "UniswapV2: ZERO_ADDRESS");
    }

    /**
     * Internal function to get the expected amount of tokens which we will receive based on given `amountIn` and pair reserves.
     * Exact same logic like in the Unsiwap Library `_getAmountOut()` function.
     */
    function _getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
        // TODO: Implement functionality as in Uniswap V2 Library `getAmountOut` function
        require(amountIn > 0, "UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT");
        require(reserveIn > 0 && reserveOut > 0, "UniswapV2Library: INSUFFICIENT_LIQUIDITY");
        uint amountInWithFee = amountIn * 997;
        uint numerator = amountInWithFee * reserveOut;
        uint denominator = reserveIn * 1000 + amountInWithFee;
        amountOut = numerator / denominator;

거의 비슷하다. 굳.


Task2


테스트 코드

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "forge-std/console.sol";
import "src/dex-2/Sniper.sol";
import {IUniswapV2Router02} from "src/interfaces/IUniswapV2.sol";
import "src/interfaces/IWETH9.sol";
import "src/utils/DummyERC20.sol";

/**
@dev run "forge test --fork-url $ETH_RPC_URL --fork-block-number 15969633 --match-contract DEX2 -vvv" 
*/
contract TestDEX2 is Test {
    address constant WETH_ADDRESS = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
    address constant UNISWAPV2_ROUTER_ADDRESS = address(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);
    address constant UNISWAPV2_FACTORY_ADDRESS = address(0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f);

    uint128 constant ETH_BALANCE = 300 ether;

    uint128 constant INITIAL_MINT = 80000 ether;
    uint128 constant INITIAL_LIQUIDITY = 10000 ether;
    uint128 constant ETH_IN_LIQUIDITY = 50 ether;

    uint128 constant ETH_TO_INVEST = 35 ether;
    uint128 constant MIN_AMOUNT_OUT = 1750 ether;

    address liquidityAdder = makeAddr("liquidityAdder");
    address user = makeAddr("user");

    IWETH9 weth = IWETH9(WETH_ADDRESS);
    IUniswapV2Router02 router;
    DummyERC20 preciousToken;
    Sniper sniper;

    function setUp() public {
        vm.label(WETH_ADDRESS, "WETH");
        vm.label(UNISWAPV2_ROUTER_ADDRESS, "UniswapV2Router02");
        vm.label(UNISWAPV2_FACTORY_ADDRESS, "UniswapV2Factory");

        // Set ETH balance
        vm.deal(liquidityAdder, ETH_BALANCE);
        vm.deal(user, ETH_BALANCE);

        vm.startPrank(liquidityAdder);

        // Deploy token
        preciousToken = new DummyERC20("PreciousToken", "PRECIOUS", INITIAL_MINT);

        // Load Uniswap Router contract
        router = IUniswapV2Router02(UNISWAPV2_ROUTER_ADDRESS);

        // Set the liquidity add operation deadline
        uint deadline = block.timestamp + 10000;

        // Deposit to WETH & approve router to spend tokens
        weth.deposit{value: ETH_IN_LIQUIDITY}();
        weth.approve(UNISWAPV2_ROUTER_ADDRESS, ETH_IN_LIQUIDITY);
        preciousToken.approve(UNISWAPV2_ROUTER_ADDRESS, INITIAL_LIQUIDITY);

        // Add the liquidity 10,000 PRECIOUS & 50 WETH
        router.addLiquidity(
            address(preciousToken),
            WETH_ADDRESS,
            INITIAL_LIQUIDITY,
            ETH_IN_LIQUIDITY,
            INITIAL_LIQUIDITY,
            ETH_IN_LIQUIDITY,
            liquidityAdder,
            deadline
        );

        vm.stopPrank();
    }

    function test_Attack() public {
        vm.startPrank(user);
        // TODO: Deploy your smart contract 'sniper`
        sniper = new Sniper(UNISWAPV2_FACTORY_ADDRESS);
        // TODO: Sniper the tokens using your snipe function
        // NOTE: Your rich friend is willing to invest 35 ETH in the project, and is willing to pay 0.02 WETH per PRECIOUS
        // Which is 4x time more expensive than the initial liquidity price.
        // You should retry 3 times to buy the token.
        // Make sure to deposit to WETH and send the tokens to the sniper contract in advance
        weth.deposit{value: ETH_TO_INVEST}();
        weth.transfer(address(sniper), ETH_TO_INVEST);
        sniper.snipe(address(weth), address(preciousToken), ETH_TO_INVEST, MIN_AMOUNT_OUT, 3);

        vm.stopPrank();

        /** SUCCESS CONDITIONS */

        // Bot was able to snipe at least 4,000 precious tokens
        // Bought at a price of ~0.00875 ETH per token (35 / 4000)
        uint preciousBalance = preciousToken.balanceOf(user);
        console.log("Sniped Balance");
        console.log(preciousBalance);
        assertEq(preciousBalance > 4000 ether, true);
    }
}

세팅하는 부분이 조금 길어서 그렇지 특이사항은 없다.

profile
Just BUIDL :)

0개의 댓글