利用ftx免费提现swap代币
October 16th, 2022

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

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

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

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

1 自己实现swap

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

如果我们采用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攻击,造成损失。

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

我们可以利用提现的数额来指定最低成交价。例如目前的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

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

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

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

amountGtoSwap = 10000 * 1e8

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

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

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

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

Subscribe to rbtree
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from rbtree

Skeleton

Skeleton

Skeleton