# 利用ftx免费提现swap代币

By [rbtree](https://paragraph.com/@rbtree) · 2022-10-16

---

这段时间出了一个XEN Crypto，把低迷了很长时间的以太坊gas再次拉高了。前几天偶然发现有人利用ftx提现刷XEN，主要就是部署一个智能合约，然后在receive函数里写自己想要执行的逻辑，这样一来，执行的gas费都是ftx为自己付。这个事情已经有人写过分析了，本文不再详述：

[https://mirror.xyz/x-explore.eth/M2BJgQJaj2JK0mAO9OecByja3tU7mKXbHR\_Agjs-MjA](https://mirror.xyz/x-explore.eth/M2BJgQJaj2JK0mAO9OecByja3tU7mKXbHR_Agjs-MjA)

不过我并不认同这属于“对ftx的攻击”，也不认为这是ftx的bug。和ftx每日的总提现相比，这点提现gas损失对ftx来说实在不算大。有人认为ftx应该把提现的gas做更严格的限制（目前的gas上限应该是500000），不过有些智能合约钱包，本来就是需要在receive函数里做一些事情的，如果严格限制了，可能会影响这部分用户的体验。如果这点损失能引诱更多人质押ftt来换取每日免费提现次数，或许是ftx愿意看到的。

我这几个月一直在玩stepn，虽然这个项目就像渣男一样把玩家耍得团团转，不过看在它还是有一点督促健身的作用，目前还留有一点仓位。

下图是eth链上，游戏代币gst的价格变化，妥妥的死亡下坠，看不到任何回头的希望。从这个价格来看，跑出gst应该立刻卖掉，不过gas费有时候还挺贵的，考虑到这种情况，可能隔几天卖一次更划算。不过如果你每天有ftx的免费提现次数，拿完全可以部署一个智能合约，用免费提现来触发兑换！

![](https://storage.googleapis.com/papyrus_images/d69600c7ea9ebb791c2d4852faca30940046fdf79e227f87b61a453af173dd16.png)

1 自己实现swap
----------

目前stepn代币的流动性主要在DOOAR，这是stepn项目自己的DEX，不过去看代码可以发现，evm链的代码几乎全部是照搬uniswapV2，只是手续费被DOOAR设置为1%，比uniswap高了很多。

[https://beta.dooar.com/swap?inputCurrency=ETH&outputCurrency=](https://beta.dooar.com/swap?inputCurrency=ETH&outputCurrency=)

[https://etherscan.io/address/0x53e0e51b5ed9202110d7ecd637a4581db8b9879f#code](https://etherscan.io/address/0x53e0e51b5ed9202110d7ecd637a4581db8b9879f#code)

[https://etherscan.io/address/0x1e895bfe59e3a5103e8b7da3897d1f2391476f3c#code](https://etherscan.io/address/0x1e895bfe59e3a5103e8b7da3897d1f2391476f3c#code)

如果我们采用UI界面交互，调用的是Router合约的swap。

    function swapTokensForExactTokens(
        uint amountOut,
        uint amountInMax,
        address[] calldata path,
        address to,
        uint deadline
    ) external virtual override ensure(deadline) returns (uint[] memory amounts) {
        amounts = DooarSwapV2Library.getAmountsIn(factory, amountOut, path);
        require(amounts[0] <= amountInMax, 'DooarSwapV2Router: EXCESSIVE_INPUT_AMOUNT');
        TransferHelper.safeTransferFrom(
            path[0], msg.sender, DooarSwapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
        );
        _swap(amounts, path, to);
    }
    
    function _swap(uint[] memory amounts, address[] memory path, address _to) internal virtual {
        for (uint i; i < path.length - 1; i++) {
            (address input, address output) = (path[i], path[i + 1]);
            (address token0,) = DooarSwapV2Library.sortTokens(input, output);
            uint amountOut = amounts[i + 1];
            (uint amount0Out, uint amount1Out) = input == token0 ? (uint(0), amountOut) : (amountOut, uint(0));
            address to = i < path.length - 2 ? DooarSwapV2Library.pairFor(factory, output, path[i + 2]) : _to;
            IDooarSwapV2Pair(DooarSwapV2Library.pairFor(factory, input, output)).swap(
                amount0Out, amount1Out, to, new bytes(0)
            );
        }
    }
    

咋一看似乎有些复杂，还包含循环，这是因为有时候没有直接的交易对，需要多步兑换，我们并没有多步兑换的需求，所以不用弄那么复杂。其实关键的就两步：

第一步是把需要换入的token转给Pair合约，第二步是调用Pair合约的swap函数，换出想要换出的代币。

    TransferHelper.safeTransferFrom(
        path[0], msg.sender, DooarSwapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
    );
    
    IDooarSwapV2Pair(DooarSwapV2Library.pairFor(factory, input, output)).swap(
        amount0Out, amount1Out, to, new bytes(0)
    );
    

在调用Pair合约的swap函数时，需要我们自己算出output的数额，代码里其实已经给出了计算的工具函数：

    function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
        uint amountInWithFee = amountIn * 99;
        uint numerator = amountInWithFee * reserveOut;
        uint denominator = reserveIn * 100 + amountInWithFee;
        amountOut = numerator / denominator;
    }
    

这里我们简单做个解释，我们知道Uniswap的核心原理是xy=k。

我们假设目前交易池中，两种资产的数目非别为reserveIn, reserveOut，而我想用amountIn的一种代币去换取amountOut个另一种代币。如果想保持xy=k成立，那么：

reserveIn \* reserveOut = (reserveIn + amountIn) \* (reserveOut - amountOut)

不过别忘了还有手续费，我们假定手续费比例为1-p，那么我们的amoutIn会被扣除为p\*amountIn。

这样上面的等式变为：

reserveIn \* reserveOut = (reserveIn + amountIn \* p) \* (reserveOut - amountOut)

反解出amountOut = p \* amountIn \* reserveOut/ (p \* amountIn + reserveIn)

其实这就是getAmountOut的计算方式，只不过因为solidity不支持小数，分子分母都乘上了100.

整理一下，我们可以写出如下代码：

    function swap() internal {
        uint amountIn = IERC20(gst).balanceOf(gstFrom);
        (uint256 gstReserve, uint256 usdcReserve, ) = IDooarSwapV2Pair(pool).getReserves();
        uint amountOut = getAmountOut(amountIn, gstReserve, usdcReserve);
        IERC20(gst).transferFrom(gstFrom, pool, amountIn);
        IDooarSwapV2Pair(pool).swap(0, amountOut, usdcTo, new bytes(0));
    }
    

我们要把对应的游戏账号里gst都发到Pair合约（这里需要提前用游戏账号做一次代币approve，以允许我们这个合约使用游戏账号里的gst代币），然后swap出usdc。这里还有一个好处，就是我们在swap的时候可以随意指定要把usdc发送到哪里。如果不想放到游戏账户了，完全可以指定为我们的其他钱包或者交易所账号的充值钱包。

我们在receive里调用这个函数就大功告成啦！

    receive() external payable {
        ethTo.call{value: address(this).balance}("");
        swap();
    }
    

2 滑点问题
------

我们在dex进行swap的时候，会有一个最大滑点设置。因为token价格是实时变动的，可能我们的交易被矿工打包的时候，价格和发送交易的时候已经有偏差了。这个时候，设置一个最大滑点能防止以我们不想接受的价格成交。

如果滑点设置太大，而我们交易的数额又很大的话，有可能被MEV攻击，造成损失。

[https://www.mev.wiki/attack-examples/sandwich-attack](https://www.mev.wiki/attack-examples/sandwich-attack)

![](https://storage.googleapis.com/papyrus_images/6d8e108e036d502072460146bc33479078fd5447fc79725f8a20f288a9b101c8.png)

那么，在这个例子中，我们如果来控制滑点呢？

我们可以利用提现的数额来指定最低成交价。例如目前的gst价格是0.11usdc，那么我们可以用0.000011eth来指定，成交的价格不得低于0.11。于是我们的代码可以进行如下修改：

在计算价格的时候，注意eth以及不同代币的decimal不一样，需要小心别出错。

    function swap(uint256 price) internal {
        uint amountIn = IERC20(gst).balanceOf(gstFrom);
        (uint256 gstReserve, uint256 usdcReserve, ) = IDooarSwapV2Pair(pool).getReserves();
        // 0.00001eth ~ 0.10usdc/gst
        uint amountOutMin = amountIn * price * (10000 * (10 ** usdcDecimal) / (10 ** gstDecimal)) / (10 ** 18);
        uint amountOut = getAmountOut(amountIn, gstReserve, usdcReserve);
        if (amountOut < amountOutMin) {
            revert INSUFFICIENT_OUTPUT_AMOUNT();
        }
        IERC20(gst).transferFrom(gstFrom, pool, amountIn);
        IDooarSwapV2Pair(pool).swap(0, amountOut, usdcTo, new bytes(0));
    }
    
    receive() external payable {
        ethTo.call{value: address(this).balance}("");
    }
    

实际操作中发现，ftx的最小提现额度是0.0035eth，所以可能需要做个特殊处理。此外，如果我们某天不想要这个功能了，或者发现合约有bug，我们想把它废掉，可以留个提现0.1ether以上自动销毁的功能。于是最终修改的代码如下：

    receive() external payable {
        if (msg.value >= 0.1 ether) {
            selfdestruct(payable(owner));
        } else {
            ethTo.call{value: address(this).balance}("");
            swap(msg.value - 0.0035 ether);
        }
    }
    

其实，0.0035ether的设定也有一定的保护作用，因为我们不能预判交易所提现所使用的钱包地址，所以receive函数是不可以进行白名单控制的，必定是所有人都可以触发。那么如果有人恶意发一个很小的数额，导致我们设定的最低兑换价格很低，从而从中套利呢？如果发送的数额损失的eth超过套利所得，那么就不会有人来攻击了，因为得不偿失。

3 三明治攻击模拟
---------

上一小节提到了设定最低价格的目的是防止被MEV套利，那么本小节我们研究一下什么情况下，套利者会有利可图。

本文的背景是，我们想用gst换usdc。假设我们兑换的数额足够大，且没有限制滑点，那么套利者可能抢在我们兑换之前，先把自己的gst换成usdc，在我们兑换完usdc之后，套利者再把gst换回来。

下面是一段简单的python代码。

    import numpy as np
    import matplotlib.pyplot as plt
    
    ratio = 0.99 # 1-费率 
    amountU0 = 763877 * 1e6  # 交易池初始usdc
    amountG0 = 6947776 * 1e8  # 交易池初始gst
    
    amountGtoSwap = 10000 * 1e8 # 用户准备兑换掉的gst
    
    # 计算攻击者拿x个gst进行套利，会盈利多少gst
    def f(x):
        x = x * 1e8
        amountU = amountU0
        amountG = amountG0
    
        amountUtemp = cal(amountG, amountU, x)
        amountU -= amountUtemp
        amountG += x
    
        out = cal(amountG, amountU, amountGtoSwap)
        amountU -= out
        amountG += amountGtoSwap
    
        out = cal(amountU, amountG, amountUtemp)
        return (out - x) / 1e8
    
    def cal(reserveIn, reserveOut, amountIn):
        return (ratio * amountIn * reserveOut) / (ratio * amountIn + reserveIn)
    
    
    x=np.arange(0, 10000000, 100)
    y=f(x)
    
    plt.plot(x,y)
    plt.show()
    

在运行之前，我们可以先定性分析一下。

如果不考虑费率的话，那么一定是攻击者拿来套利的gst越多，价格被便宜越厉害，用户损失越大，攻击者获益越多。但是因为费率的存在，套利的gst越多，攻击者本身也会花掉更多交易手续费，所以，并不是拿来套利的gst越多越好。

我们模拟一下，用户需要兑换10000gst

amountGtoSwap = 10000 \* 1e8

![](https://storage.googleapis.com/papyrus_images/3233377a48cb39437ce092084c2f058e35b40e7313e160a07bfe6d8fddca1742.png)

横轴是攻击者使用的gst数目，纵轴是攻击者盈利的gst。

单调递减，且盈利为负。看来此时，因为用户兑换的gst太少，获利还不足以弥补手续费，因此攻击者根本没有套利空间。

再模拟一下，用户需要兑换100000gst

amountGtoSwap = 10000 \* 1e8

![](https://storage.googleapis.com/papyrus_images/cd7f1190c3b50a736cf2190e340ec107fbfd991635965f3f3509f6730bee1ea3.png)

可以看出，此时攻击者的套利空间明显出来了，攻击者拿出大约3000000gst的时候，套利的数目最大。

看来，对于我们小额用户，不太需要考虑这种被套利的问题^\_^.

本文最终部署的合约如下：

[https://etherscan.io/address/0x924d8d3b280c5da7cf2b303d0cbc267a0d52cd34#code](https://etherscan.io/address/0x924d8d3b280c5da7cf2b303d0cbc267a0d52cd34#code)

虽然不会被套利，不过还是留下了第2节用提现额指定最小兑换比例的功能，毕竟在这个黑暗森林的一般的行业，保持危机意识还是有好处的～

---

*Originally published on [rbtree](https://paragraph.com/@rbtree/ftx-swap)*
