这段时间出了一个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的免费提现次数,拿完全可以部署一个智能合约,用免费提现来触发兑换!
目前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();
}
我们在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超过套利所得,那么就不会有人来攻击了,因为得不偿失。
上一小节提到了设定最低价格的目的是防止被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节用提现额指定最小兑换比例的功能,毕竟在这个黑暗森林的一般的行业,保持危机意识还是有好处的~