Web Developer DeFi
Web Developer DeFi
Share Dialog
Share Dialog

Subscribe to Smithereens

Subscribe to Smithereens
<100 subscribers
<100 subscribers
今天我们将核心功能 Token 交换添加到我们的 Uniswap V2。去中心化代币交换是 Uniswap 的创建目的,今天我们将看看它是如何完成的。我们仍在研究 Pair 合约,这意味着我们的实现都是较底层的,没有 Web 界面,我们甚至不会进行价格计算! 此外,我们将实现一个价格预言机:后续接入仅需要几行代码即可。 此外,我将解释对合约实现背后的一些细节和想法,这在上一部分中我没有足够的说明。让我们开始!
让我们想想我们将如何实现它。
交换意味着放弃一定数量的代币 A 以换取代币 B。但我们需要某种第三方:
提供实际汇率。
保证所有兑换均全额支付,即所有兑换均以正确汇率进行。
我们在研究流动性供应时了解了有关 DEX 定价的一些知识:它是定义汇率的池中的流动性数量。我详细解释了恒定乘积公式的工作原理以及成功交换的主要条件是什么。即:互换后的储备金乘积必须等于或大于互换前的乘积。
将代币转移给某人有两种方式:
通过调用 transfer 代币合约的方法并传递给接收方的地址和要发送的金额。
通过调用 approve 方法允许其他用户或合约将一定数量的代币授权到他们的地址,此后对方可以通过调用 transferFrom 来转移代币。
授权模式在以太坊应用中非常常见:dapp 要求用户批准最大金额的消费,这样用户就不需要一次又一次地调用授权(这需要 gas 费)。这提高了用户体验。但这里我们采用的是 transfer 的方式,代码如下:
该函数需要两个输出金额,每个代币一个。这些是调用者想用他们的代币换取的金额。为什么这样做呢?因为我们甚至不想强制执行交换的方向:调用者可以指定任何一个数额或两个数额,而我们只是进行必要的检查。
function swap(
uint256 amount0Out,
uint256 amount1Out,
address to
) public {
if (amount0Out == 0 && amount1Out == 0)
revert InsufficientOutputAmount();
...
接下来,我们需要确保有足够的储备发送给用户。
...
(uint112 reserve0_, uint112 reserve1_, ) = getReserves();
if (amount0Out > reserve0_ || amount1Out > reserve1_)
revert InsufficientLiquidity();
...
接下来,我们计算该合约的代币余额减去我们预计发送给调用者的金额。此时,预计调用者已将他们想要交易的代币发送到此合约。因此,预计其中一个或两个余额将大于相应的储备。
...
uint256 balance0 = IERC20(token0).balanceOf(address(this)) - amount0Out;
uint256 balance1 = IERC20(token1).balanceOf(address(this)) - amount1Out;
...
这是我们上面谈到的持续产品检查。我们预计该合约代币余额与其储备不同(余额将很快保存到储备中),我们需要确保它们的乘积等于或大于当前储备的乘积。如果满足此要求,则:
调用者正确计算了汇率(包括滑点)。
输出量是正确的。
转入合约的金额也是正确的
...
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);
}
请记住,此方案并不完整:你会发现合约不收取交易费用,因此流动性提供者无法从其资产中获得利润。我们将在实施价格计算后填补这一空白。
预言机的想法,将区块链与链下服务连接起来的桥梁,以便可以从智能合约中查询现实世界的数据,已经存在了很长一段时间。Chainlink 是最大的预言机网络之一,创建于 2017 年,如今,它是许多 DeFi 应用程序的关键部分。
Uniswap 虽然是一个链上应用程序,但也可以作为一个预言机。交易者他们通过最小化交易所之间的价格差异来赚钱。套利者使 Uniswap 的价格尽可能接近中心化交易所的价格,这也可以被视为将价格从中心化交易所馈送到区块链。为什么不利用这个事实将配对合约变成价格预言机呢?这就是 Uniswap V2 中所做的。
Uniswap V2 中价格预言机提供的这种价格称为时间加权平均价格,或 TWAP。它基本上允许在两个时刻之间获得平均价格。为了实现这一点,合约存储了累计价格:在每次交换之前,它计算当前边际价格(不包括费用),将它们乘以自上次交换以来经过的秒数,然后将该数字添加到前一个数字中。
那么价格的计算公式是什么呢?
对于预言机功能,Uniswap V2 使用边际价格,它不包括滑点和交换手续费,也不取决于交换的数量,由于solidity中并不原生支持小数,Uniswap采用UQ112.112这种编码方式来存储价格信息。UQ112.112意味着该数值采取224位来编码值,前112位存放小数点前的值,后112位存放小数点后的值,Uniswap 选择UQ112.112的原因:因为UQ112.112可以被存储位uint224,这会留下32位的空闲Storage插槽位。再一个是每个Pair合约的reserve0和reserve1都是Uint112,同样会留下32位的空闲插槽位。特别的是pair合约的reserve连同最近一个区块的时间戳一起存储时,该时间戳会对$2^{32}$取余数称为uint32格式的数值。加之,尽管在任意给定时刻的价格都是UQ112.112格式,保证为uint224位,一段时间累积的价格并不是该格式。在储存插槽中末端的额外的32位可以用来储存累积的价格的溢出部分。这种设计意味着在每一个区块的第一笔交易中只需要额外的三次SSTORE操作(目前的开销是15000gas) 在计算价格累计的时候我们需要一个变量,它将存储上次交换(或实际上是保留更新)时间戳。然后我们需要修改更新功能。
uint32 private blockTimestampLast;
function _update(
uint256 balance0,
uint256 balance1,
uint112 reserve0_,
uint112 reserve1_
) private {
...
unchecked {
uint32 timeElapsed = uint32(block.timestamp) - blockTimestampLast;
if (timeElapsed > 0 && reserve0_ > 0 && reserve1_ > 0) {
price0CumulativeLast +=
uint256(UQ112x112.encode(reserve1_).uqdiv(reserve0_)) *
timeElapsed;
price1CumulativeLast +=
uint256(UQ112x112.encode(reserve0_).uqdiv(reserve1_)) *
timeElapsed;
}
}
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = uint32(block.timestamp);
...
}
这是什么奇怪的 uint112 类型?为什么不使用 uint256?答案是:气体优化。
每个 EVM 操作都会消耗一定量的 gas。简单的运算,比如算术运算,消耗的 gas 很少,但有些运算会消耗大量的 gas。最昂贵的是 SSTORE ——为合约存储节省价值。它的对应物 ,SLOAD也 很昂贵。因此,如果智能合约开发人员尝试优化其合约的 gas 消耗,这对用户是有益的。使用 uint112 储备变量正是为了这个目的。
看看我们是如何布置变量的:
address public token0;
address public token1;
uint112 private reserve0;
uint112 private reserve1;
uint32 private blockTimestampLast;
uint256 public price0CumulativeLast;
uint256 public price1CumulativeLast;
变量的书写必须完全按照这个顺序进行。原因是每个状态变量对应一个存储槽,而 EVM 使用32字节的存储槽(每个存储槽正好是32字节)。当您读取状态变量值时,它会从该变量链接到的存储槽中读取。每个SLOAD调用一次读取 32 个字节,每个SSTORE调用一次写入 32 个字节。由于这些是昂贵的操作,我们真的希望减少存储读取和写入的数量。这就是适当布局状态变量可能会有所帮助的地方。
如果有几个连续的状态变量占用少于 32 个字节怎么办?EMV 打包小于 32 字节的相邻变量。
再看看我们的状态变量:
前两个是 address 变量。address 占用 20 个字节,两个地址占用 40 个字节,这意味着它们必须占用单独的存储槽。它们不能存放在一个插槽中,因为它们根本不适合。 两个 uint112 变量和一个变量 uint32 ——这看起来很有趣:112+112+32=256!这意味着它们可以放在一个存储槽中!这就是uint112选择保留的原因:保留变量总是一起读取,最好立即从存储中加载它们,而不是单独加载。这节省了一次 SLOAD 操作,而且由于储量被经常使用,这是巨大的气体节省。 两个uint256变量。这些无法打包,因为它们每个都占用一个完整的插槽。 同样重要的是,这两个 uint112 变量需要跟在一个占用一个完整插槽的变量之后——这可以确保它们中的第一个不会被打包在前一个插槽中。
function _safeTransfer(
address token,
address to,
uint256 value
) private {
(bool success, bytes memory data) = token.call(
abi.encodeWithSignature("transfer(address,uint256)", to, value)
);
if (!success || (data.length != 0 && !abi.decode(data, (bool))))
revert TransferFailed();
}
为什么不在 ERC20 接口上直接调用 transfer 方法?
在 pair 合约中,当进行代币转移时,我们总是想确定它们是否成功。根据 ERC20 的规定,转移方法必须返回一个布尔值:成功时为true;不成功时为fail。大多数代币都正确地实现了这一点,但有些代币却没有,它们只是什么都不返回。当然,我们不能检查代币合约的实现,也不能确定代币的转移确实已经完成,但我们至少可以检查转移的结果。而且,如果转移失败,我们也不想继续。
call这里是一个地址方法--这是一个低级别的函数,给我们提供了对合约调用的更细粒度的控制。在这个特定的例子中,它允许我们获得一个转移的结果,无论 transfer 方法是否返回一个结果。
今天我们将核心功能 Token 交换添加到我们的 Uniswap V2。去中心化代币交换是 Uniswap 的创建目的,今天我们将看看它是如何完成的。我们仍在研究 Pair 合约,这意味着我们的实现都是较底层的,没有 Web 界面,我们甚至不会进行价格计算! 此外,我们将实现一个价格预言机:后续接入仅需要几行代码即可。 此外,我将解释对合约实现背后的一些细节和想法,这在上一部分中我没有足够的说明。让我们开始!
让我们想想我们将如何实现它。
交换意味着放弃一定数量的代币 A 以换取代币 B。但我们需要某种第三方:
提供实际汇率。
保证所有兑换均全额支付,即所有兑换均以正确汇率进行。
我们在研究流动性供应时了解了有关 DEX 定价的一些知识:它是定义汇率的池中的流动性数量。我详细解释了恒定乘积公式的工作原理以及成功交换的主要条件是什么。即:互换后的储备金乘积必须等于或大于互换前的乘积。
将代币转移给某人有两种方式:
通过调用 transfer 代币合约的方法并传递给接收方的地址和要发送的金额。
通过调用 approve 方法允许其他用户或合约将一定数量的代币授权到他们的地址,此后对方可以通过调用 transferFrom 来转移代币。
授权模式在以太坊应用中非常常见:dapp 要求用户批准最大金额的消费,这样用户就不需要一次又一次地调用授权(这需要 gas 费)。这提高了用户体验。但这里我们采用的是 transfer 的方式,代码如下:
该函数需要两个输出金额,每个代币一个。这些是调用者想用他们的代币换取的金额。为什么这样做呢?因为我们甚至不想强制执行交换的方向:调用者可以指定任何一个数额或两个数额,而我们只是进行必要的检查。
function swap(
uint256 amount0Out,
uint256 amount1Out,
address to
) public {
if (amount0Out == 0 && amount1Out == 0)
revert InsufficientOutputAmount();
...
接下来,我们需要确保有足够的储备发送给用户。
...
(uint112 reserve0_, uint112 reserve1_, ) = getReserves();
if (amount0Out > reserve0_ || amount1Out > reserve1_)
revert InsufficientLiquidity();
...
接下来,我们计算该合约的代币余额减去我们预计发送给调用者的金额。此时,预计调用者已将他们想要交易的代币发送到此合约。因此,预计其中一个或两个余额将大于相应的储备。
...
uint256 balance0 = IERC20(token0).balanceOf(address(this)) - amount0Out;
uint256 balance1 = IERC20(token1).balanceOf(address(this)) - amount1Out;
...
这是我们上面谈到的持续产品检查。我们预计该合约代币余额与其储备不同(余额将很快保存到储备中),我们需要确保它们的乘积等于或大于当前储备的乘积。如果满足此要求,则:
调用者正确计算了汇率(包括滑点)。
输出量是正确的。
转入合约的金额也是正确的
...
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);
}
请记住,此方案并不完整:你会发现合约不收取交易费用,因此流动性提供者无法从其资产中获得利润。我们将在实施价格计算后填补这一空白。
预言机的想法,将区块链与链下服务连接起来的桥梁,以便可以从智能合约中查询现实世界的数据,已经存在了很长一段时间。Chainlink 是最大的预言机网络之一,创建于 2017 年,如今,它是许多 DeFi 应用程序的关键部分。
Uniswap 虽然是一个链上应用程序,但也可以作为一个预言机。交易者他们通过最小化交易所之间的价格差异来赚钱。套利者使 Uniswap 的价格尽可能接近中心化交易所的价格,这也可以被视为将价格从中心化交易所馈送到区块链。为什么不利用这个事实将配对合约变成价格预言机呢?这就是 Uniswap V2 中所做的。
Uniswap V2 中价格预言机提供的这种价格称为时间加权平均价格,或 TWAP。它基本上允许在两个时刻之间获得平均价格。为了实现这一点,合约存储了累计价格:在每次交换之前,它计算当前边际价格(不包括费用),将它们乘以自上次交换以来经过的秒数,然后将该数字添加到前一个数字中。
那么价格的计算公式是什么呢?
对于预言机功能,Uniswap V2 使用边际价格,它不包括滑点和交换手续费,也不取决于交换的数量,由于solidity中并不原生支持小数,Uniswap采用UQ112.112这种编码方式来存储价格信息。UQ112.112意味着该数值采取224位来编码值,前112位存放小数点前的值,后112位存放小数点后的值,Uniswap 选择UQ112.112的原因:因为UQ112.112可以被存储位uint224,这会留下32位的空闲Storage插槽位。再一个是每个Pair合约的reserve0和reserve1都是Uint112,同样会留下32位的空闲插槽位。特别的是pair合约的reserve连同最近一个区块的时间戳一起存储时,该时间戳会对$2^{32}$取余数称为uint32格式的数值。加之,尽管在任意给定时刻的价格都是UQ112.112格式,保证为uint224位,一段时间累积的价格并不是该格式。在储存插槽中末端的额外的32位可以用来储存累积的价格的溢出部分。这种设计意味着在每一个区块的第一笔交易中只需要额外的三次SSTORE操作(目前的开销是15000gas) 在计算价格累计的时候我们需要一个变量,它将存储上次交换(或实际上是保留更新)时间戳。然后我们需要修改更新功能。
uint32 private blockTimestampLast;
function _update(
uint256 balance0,
uint256 balance1,
uint112 reserve0_,
uint112 reserve1_
) private {
...
unchecked {
uint32 timeElapsed = uint32(block.timestamp) - blockTimestampLast;
if (timeElapsed > 0 && reserve0_ > 0 && reserve1_ > 0) {
price0CumulativeLast +=
uint256(UQ112x112.encode(reserve1_).uqdiv(reserve0_)) *
timeElapsed;
price1CumulativeLast +=
uint256(UQ112x112.encode(reserve0_).uqdiv(reserve1_)) *
timeElapsed;
}
}
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = uint32(block.timestamp);
...
}
这是什么奇怪的 uint112 类型?为什么不使用 uint256?答案是:气体优化。
每个 EVM 操作都会消耗一定量的 gas。简单的运算,比如算术运算,消耗的 gas 很少,但有些运算会消耗大量的 gas。最昂贵的是 SSTORE ——为合约存储节省价值。它的对应物 ,SLOAD也 很昂贵。因此,如果智能合约开发人员尝试优化其合约的 gas 消耗,这对用户是有益的。使用 uint112 储备变量正是为了这个目的。
看看我们是如何布置变量的:
address public token0;
address public token1;
uint112 private reserve0;
uint112 private reserve1;
uint32 private blockTimestampLast;
uint256 public price0CumulativeLast;
uint256 public price1CumulativeLast;
变量的书写必须完全按照这个顺序进行。原因是每个状态变量对应一个存储槽,而 EVM 使用32字节的存储槽(每个存储槽正好是32字节)。当您读取状态变量值时,它会从该变量链接到的存储槽中读取。每个SLOAD调用一次读取 32 个字节,每个SSTORE调用一次写入 32 个字节。由于这些是昂贵的操作,我们真的希望减少存储读取和写入的数量。这就是适当布局状态变量可能会有所帮助的地方。
如果有几个连续的状态变量占用少于 32 个字节怎么办?EMV 打包小于 32 字节的相邻变量。
再看看我们的状态变量:
前两个是 address 变量。address 占用 20 个字节,两个地址占用 40 个字节,这意味着它们必须占用单独的存储槽。它们不能存放在一个插槽中,因为它们根本不适合。 两个 uint112 变量和一个变量 uint32 ——这看起来很有趣:112+112+32=256!这意味着它们可以放在一个存储槽中!这就是uint112选择保留的原因:保留变量总是一起读取,最好立即从存储中加载它们,而不是单独加载。这节省了一次 SLOAD 操作,而且由于储量被经常使用,这是巨大的气体节省。 两个uint256变量。这些无法打包,因为它们每个都占用一个完整的插槽。 同样重要的是,这两个 uint112 变量需要跟在一个占用一个完整插槽的变量之后——这可以确保它们中的第一个不会被打包在前一个插槽中。
function _safeTransfer(
address token,
address to,
uint256 value
) private {
(bool success, bytes memory data) = token.call(
abi.encodeWithSignature("transfer(address,uint256)", to, value)
);
if (!success || (data.length != 0 && !abi.decode(data, (bool))))
revert TransferFailed();
}
为什么不在 ERC20 接口上直接调用 transfer 方法?
在 pair 合约中,当进行代币转移时,我们总是想确定它们是否成功。根据 ERC20 的规定,转移方法必须返回一个布尔值:成功时为true;不成功时为fail。大多数代币都正确地实现了这一点,但有些代币却没有,它们只是什么都不返回。当然,我们不能检查代币合约的实现,也不能确定代币的转移确实已经完成,但我们至少可以检查转移的结果。而且,如果转移失败,我们也不想继续。
call这里是一个地址方法--这是一个低级别的函数,给我们提供了对合约调用的更细粒度的控制。在这个特定的例子中,它允许我们获得一个转移的结果,无论 transfer 方法是否返回一个结果。
No activity yet