本篇文章,我们将向我们自己的UniswapV2源码中添加核心的功能——swapping。Uniswap创建的目的就是能够实现去中心化的代币交换,我们一起来看看它是如何完成的。我们会依然专注于核心的配对合约,我们还没开始构建可用的前端交互界面,也并不会进行价格计算。
欢迎回来,本篇文章,我们将向我们自己的UniswapV2源码中添加核心的功能——swapping。Uniswap创建的目的就是能够实现去中心化的代币交换,我们一起来看看它是如何完成的。我们会依然专注于核心的配对合约,我们还没开始构建可用的前端交互界面,也并不会进行价格计算。
此外,我们还将实现一个价格预言机:配对合约的设计允许我们仅仅用几行代码就能实现。
我们还会一起思考和讨论配对合约背后的一些设计细节和想法,这是我们在之前的文章中没有关注的。
到目前为止,我们已经具备了执行代币交换的一切条件,让我们思考下如何去实现它。
代币的交换意味着放弃一定数量的代币A来换取代币B。但是在这个过程中,我们需要某种中介来做一些事情:
当我们研究流动性供应时,我们了解了一些关于DEX定价的知识:流动性池子中的流动性数量决定了汇率。在Uniswap V1系列中,我们详细解释了恒定乘积公式是如何工作的以及成功交换的主要条件是什么。即:交换后的Token准备金的乘积必须等于或者大于交换前的准备金的乘积。无论流动性池子中的Token储备量是多少,恒定乘积必须保持不变,这是我们必须保证的唯一条件。
我们之前已经介绍过,配对合约是核心合约,这意味着它必须尽可能设计成一个基础合约和最小化。这会影响我们向合约发送代币的方式。有两种方式可以将代币转移给某人:
transfer
方法并传递接收者的地址和要发送的金额。approve
方法来允许其他用户或者合约将一定数量的代币转移到他们的地址。对方必须调用transferFrom
才能获取你的代币。你作为代币的所有者需要批准一定的金额;另一方支付实际转账费用。批准模式在以太坊的应用中很常见:dapp要求用户批准最大金额的支出,这样用户就不需要一次又一次的调用approve
方法(这会花费Gas),批准模式确实提升了用户体验。
我们开始写代码吧:
这个swap函数有三个入参,前两个入参代表的是代币的输出量,这是调用者希望用他的代币换取的数量。为什么要这样设计呢?因为我们并不想强制约束swap的方向:调用者可以指定其中一个或者两个,我们只需要执行必要的检查就可以了。
function swap(
uint256 amount0Out,
uint256 amount1Out,
address to
) public {
if (amount0Out == 0 && amount1Out == 0)
revert InsufficientOutputAmount();
...
接下来,我们需要确保有足够的Token储备发送给用户。
...
(uint112 reserve0_, uint112 reserve1_, ) = getReserves();
if (amount0Out > reserve0_ || amount1Out > reserve1_)
revert InsufficientLiquidity();
...
接下来,我们计算该合约的代币余额减去我们预计发送给调用者的金额。此时,我们预计调用者已经将他们想要交易的Token代币发送到该合约中。因此,其中一个或者两个代币的余额预计将大于相应的代币储备数量。
我们需要持续的边界和异常检查,我们预计该合约的Token代币余额和储备金不同(余额很快会保存到储备金中),并且我们需要确保他们的乘积等于或者大于或者等于当前储备金的乘积,如果满足这个要求,则:
...
if (balance0 * balance1 < uint256(reserve0_) * uint256(reserve1_))
revert InvalidK();
...
到这一步,可以安全的将代币转移给调用者并更新储备数量了,此时交换完成。
_update(balance0, balance1, reserve0_, reserve1_);
if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);
emit Swap(msg.sender, amount0Out, amount1Out, to);
}
提示:上面的这个实现并不完整:合约并没有收取交易费用,因此,流动性提供者无法从中获得收益,我们将在价格计算后完善这个部分。
对以太坊智能合约最常见的攻击形式之一就是重入攻击。 当合约在没有进行必要的检查或者更新状态的情况下进行外部调用时,很可能就会发生这种攻击。攻击者可以欺骗合约调用攻击者的合约。而攻击者的合约又会再次调用被攻击的合约(通常会调用多次)。因次。第二次调用(重新进入合约)利用了合约状态的错误更新,从而导致资金损失(这是攻击的主要目标)。
在配对合约中,swap
函数中有safeTransfer
这个方法的调用——合约会将代币发送给调用者,重入攻击正是对这种调用进行攻击的。 如果我们总是假定被调用的transfer
方法完全按照我们期望的方式执行,这种想法是很天真的。ERC20的代币标准并不是那么强制,开发人员可以根据需求定制任何想要做的事情。
防止重入攻击的常见方式有两种:
我们可以使用比较成熟的实现,比如 OpenZeppelin 合约中的 ReentrancyGuard
,UniswapV2 自己实现了一个,因为它并不难实现,主要的思想是在调用函数时候设置一个标志,设置了这个标志后这个函数就不允许被调用了;等到函数被调用完成之后这个标志将被取消。
这种机制不允许在调用函数时同一个用户重复调用该函数,但是也并不会影响其他的用户。每个用户之间行为都是独立的。
ReentrancyGuard
的实现原理如下:
ReentrancyGuard
定义了一个状态变量 _status
,用于表示函数调用的状态。这个变量可以有两个值:_NOT_ENTERED
和 _ENTERED
。默认情况下,合约处于 _NOT_ENTERED
状态。
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
在构造函数中,_status
被初始化为 _NOT_ENTERED
。
constructor() {
_status = _NOT_ENTERED;
}
关键在于 nonReentrant
修饰器。这个修饰器在函数执行之前会检查 _status
是否为 _NOT_ENTERED
,如果不是,就会抛出异常,阻止函数执行。执行函数之前将 _status
设置为 _ENTERED
,函数执行完毕后再将其设置回 _NOT_ENTERED
。
modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
在需要防止重入攻击的函数上,使用 nonReentrant
修饰器。
contract MyContract is ReentrancyGuard {
function safeFunction() external nonReentrant {
// function logic
}
}
当 safeFunction
被调用时,nonReentrant
修饰器会首先检查 _status
是否为 _NOT_ENTERED
,如果是,则继续执行并将 _status
设置为 _ENTERED
。这样,任何在函数执行期间的重入调用都会被阻止,因为 _status
不再是 _NOT_ENTERED
。函数执行完毕后,_status
会被恢复为 _NOT_ENTERED
,以便后续调用。
该模式在合约函数中强制执行严格的操作顺序:首先,进行所有必要的检查,从而确保该功能在正确的状态下工作。其次,函数根据其逻辑更新自身的状态。最后,该函数进行外部调用。(更新自身状态放在外部调用之前),这样的顺序保证了每次函数调用都是在函数状态最终确定且正确时进行的。不存在待处理的状态需要更新了。
我们的swap
实现容易遭受到攻击吗?可以欺骗它其中的储备资产发送给调用者吗?从理论上讲是的,因为它依赖于第三方合约(token合约),并且任何一个代币合约都可以为它提供错误的余...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!