这段时间非常有幸跟行业内人士聊了一会,算是第一次正式接触到区块链行业内从业者,很激动。整体感觉这个行业充满了变化,挑战和机遇,它会给年轻人机会,目前我正在考虑转行该行业,希望能够在三年后回首自己的选择时能够不后悔,不遗憾。
这段时间非常有幸跟行业内人士聊了一会,算是第一次正式接触到区块链行业内从业者,很激动。整体感觉这个行业充满了变化,挑战和机遇,它会给年轻人机会,目前我正在考虑转行该行业,希望能够在三年后回首自己的选择时能够不后悔,不遗憾。
uniswap是以太坊上的一个DAPP应用,后续的DEFI繁荣,出现各种各样的SWAP,其代码鼻祖就是Uniswap。
什么是AMM,价格如何移动?
AMM,全称Automated Market Makers,翻译过来是自动做市商。其基础模型来源于Vitalik于2017年发表的博客,讨论了“恒定乘机公式”,即每一个Uniswap Pair 中存有两种资产,并为这两种资产提供流动性。在为资产提供流动性时,保持两种Token储备的乘积不能减少的不变性。交易者须在交易中支付30个基点的费用,这些费用将用于流动性提供者。即保证$R_A * R_B \geq K$
下面我们将分别结合swap,mint,burn
来讨论AMM曲线的移动。
首先我们结合Uniswap中的简化版Swap代码来分析下价格移动
// this low-level function should be called from a contract which performs important safety checks
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
//拿到swap之前的tokenA和tokenB的余额
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
//拿到此时刻的tokenA和tokenB的余额
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
//乐观转账给address To
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
//通过余额的差值计算得到要交换的Token的数量
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
//扣除书续费,验证转账后的余额满足 X*Y >= K的要求
{ // scope for reserve{0,1}Adjusted, avoids stack too deep errors
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
}
//更新余额记录账本
_update(balance0, balance1, _reserve0, _reserve1);
}
可以看到,如下图要从A点移动到B点,通过调用Uniswap的swap函数,需要先转账给pair合约一定数量($X_1-X_0$)的tokenB,然后pair合约将对应数量的tokenA转账出去到目标地址。则转装出去的tokenA的数量为$Y_0-Y_1$, 在忽略手续费的情况下,此时的B点应该要落在曲线上。
function mint(address to) external lock returns (uint liquidity) {
//拿到调用mint前的tokenA和tokenB的余额
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
//拿到此时刻的TokenA和TokenB的余额
uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
//计算得到打进账户的两种Token的数量
uint amount0 = balance0.sub(_reserve0);
uint amount1 = balance1.sub(_reserve1);
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
if (_totalSupply == 0) {
//首次铸币
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
_mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
} else {
//非首次铸币
liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
}
//发送LP Token
_mint(to, liquidity);
// 更新账户余额
_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
}
可以看到,要让图中点A移动到点B,通过调用Uniswap中的Mint函数实现。如图中A点所示,其TokenX=2, TokenY=5000, 在保证$dy/dx$不变的情况下,即价格稳定的情况下,向Pair合约中,转账TokenX=1,TokenY=2500,此时系统的K值会由最初的10000增长为22500,点A移动到点B。即价格移动曲线也更新为最新的K=22500这条曲线。
// this low-level function should be called from a contract which performs important safety checks
function burn(address to) external lock returns (uint amount0, uint amount1) {
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
address _token0 = token0; // gas savings
address _token1 = token1; // gas savings
uint balance0 = IERC20(_token0).balanceOf(address(this));
uint balance1 = IERC20(_token1).balanceOf(address(this));
uint liquidity = balanceOf[address(this)];
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution
amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution
require(amount0 > 0 && amount1 > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED');
_burn(address(this), liquidity);
_safeTransfer(_token0, to, amount0);
_safeTransfer(_token1, to, amount1);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
emit Burn(msg.sender, amount0, amount1, to);
}
Burn方法是Mint方法的逆向,先向Pair合约中,转账一定量的LP token,然后通过Pair合约向外转账tokenA, tokenB. 使得上图中的B点回落到A点。
由于solidity中并不原生支持小数,Uniswap采用UQ112.112这种编码方式来存储价格信息。UQ112.112意味着该数值采取224位来编码值,前112位存放小数点前的值,其范围是$[0,2^{112}-1]$,后112位存放小数点后的值,其精度可以达到$\frac{1}{2^{112}}$.
library UQ112x112 {
uint224 constant Q112 = 2**112;
// 编码:将一个uint112的值编码为uint224
//0000000000000000000000000000000000000000000000000000000000000001 => 0x01 uint112
//00000000 => 前32位留空
//0000000000000000000000000001 => 整数部分
//0000000000000000000000000000 => 小数部分
function encode(uint112 y) internal pure returns (uint224 z) {
z = uint224(y) * Q112; // never overflows
}
// divide a UQ112x112 by a uint112, returning a UQ112x112
function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) {
z = x / uint224(y);
}
}
Uniswap选择UQ112.112的原因:因为UQ112.112可以被存储位uint224,这会留下32位的空闲Storage插槽位。再一个是每个Pair合约的reserve0和reserve1都是Uint112,同样会留下32位的空闲插槽位。特别的是pair合约的reserve连同最近一个区块的时间戳一起存储时,该时间戳会对$2^{32}$取余数称为uint32格式的数值。加之,尽管在任意给定时刻的价格都是UQ112.112格式,保证为uint224位,一段时间累积的价格并不是该格式。在储存插槽中末端的额外的32位可以用来储存累积的价格的溢出部分。这种设计意味着在每一个区块的第一笔交易中只需要额外的三次SSTORE操作(目前的开销是15000gas)。
选择UQ112.112的缺点:最末端的32位用来储存时间戳会溢出,事实上一个Uint32的时间戳溢出点是02/07/2106.
uint112 private reserve0; // uses single storage slot, accessible via getReserves
uint112 private reserve1; // uses single storage slot, accessible via getReserves
uint32 private blockTimestampLast; // uses single storage slot, accessible via getReserves
上述三个参数刚好占据一个slot,一个slot 32位,上面三个参数编码后就是14+14+4=32位
该slot中的数据排列是:
blockTime|Reserve1|Reserve0
//00000000 => blockTimestampLast
//0000000000000000000000000000 => reserve1
//0000000000000000000000000000 => reserve0
为什么可以看到合约中存在x*1000/1000
,目的是什么?
一方面是因为solidity中没有小数的概念,另一方面是为了保证数值精度
uint256 reward = (amountDeposited * 100) / totalDeposits;
与
uint256 rewardInWei = (amountDeposited * 100 * 10 ** 18) / totalDeposites;
如果amountDeposited=10000000, totalDeposits = 400+1000000:
reward=99
如果amountDeposited = 100, totalDeposits = 400 + 1000000
reward = 0 ether
rewardInWei = 99960015993602 wei = 0.00009996 ether
Uniswap V2添加了闪电兑功能,允许用户在付钱之前接受和使用Token资产,只要他们在同一笔原子交易中。闪电兑换功能在swap函数中实现:IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
也可以写成 address(to).callWithSigature("uniswapV2Call(address,uint256,uint256,bytes)", msg.sender, amount0Out, amount1Out, data)
. 当回调函数完成后,合于会检查新的账户余额并确认常数K满足要求(扣除手续费后的常数K). 如果合约没有足够的余额,则会回退整笔交易。
用户也可以用同一种Token来偿还给Pair合约,而不是必须完成swap交易。这事实上是允许任何人闪电贷任何一种储存在Pair中的资产,只需要支付0.3%的手续费即可。
// this low-level function should be called from a contract which performs important safety checks
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
//拿到swap之前的tokenA和tokenB的余额
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
//拿到此时刻的tokenA和tokenB的余额
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
//乐观转账给address To
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
//闪电兑-执行用户定义的回调合约,在乐观转账给address To和确保K值不减之间时调用
if(data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
//通过余额的差值计算得到要交换的Token的数量
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
//扣除书续费,验证转账后的余额满足 X*Y >= K的要求
{ // scope for reserve{0,1}Adjusted, avoids stack too deep errors
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
}
//更新余额记录账本
_update(balance0, balance1, _reserve0, _reserve1);
}
要理解LP token的供应值,就需要理解什么是LP token。
在Uniswap中,LP token在mint中被铸造出来,在burn中销毁。除开首次铸造,非首次铸造的LP token数量都是与LP token的当前总量成线性关系。LP token的价值来源于流动性的提供,即mint方法。LP token的增值逻辑是时间段($t_1$,$t_2$)间的swap交易手续费的累计。swap的交易手续费又可以表现为时间段($t_1$,$t_2$)对应的($\sqrt{k_1}$,$\sqrt{k_2}$)的增值。
首次铸币时,如何确定LP token的初始供应的数量呢?
$$ s{minted} = \sqrt{x{deposited}\cdot y_{deposited}}=\sqrt{k} $$
该公式确保了任意时刻的LP token的价值与初始供应的tokenA,tokenB的比例无关。例如,一个流动性提供者存入2 tokenA,200 tokenB,此时tokenA/tokenB的价格为100,则该流动性提供者会获得$\sqrt{2\cdot 200}=20$ LP token。该20份 LP token的价值就是2 tokenA, 200 tokenB, 以及此时间段间积累的手续费。如果一个流动性提供者最初存入的是2 tokenA, 800 tokenB, 此时tokenA/tokenB的价格为400,则该流动性提供者会获得$\sqrt{2\cdot 800}=40$ LP token。
非首次铸币时,LP token的供应量为:
$$ s{minted}=\frac{x{deposited}}{x{starting}}\cdot s{starting} $$
上述公式保证了LP token的价值永远不会低于$\sqrt{k}$.
然而,当我们对比mint部分代码时,会发现如下一行代码:
if (_totalSupply == 0) {
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
_mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
}
为什么在首次铸币时,要向address(0) 传送一部分LPtoken呢?
因为根据上述公式,存在如下的一种攻击模式,称为LP token操纵
sequenceDiagram
opt 首次铸币
攻击者->>Pair合约: tokenA.transfer(pair,1 wei)
攻击者->>Pair合约: tokenB.transfer(pair,1 wei)
攻击者->>Pair合约: pair.mint(msg.sender)
Pair合约 ->> 攻击者: LP token=1 wei
end
opt 二次铸币
攻击者->>Pair合约: tokenA.transfer(pair,10**22 wei)
攻击者->>Pair合约: tokenB.transfer(pair,10**22 wei)
攻击者->>Pair合约: pair.mint(msg.sender)
Pair合约 ->> 攻击者: LP token=10**22 wei
end
opt SYNC 同步余额
攻击者->>Pair合约: pair.sync()
end
opt 三次铸币
受害者->>Pair合约: tokenA.transfer(pair,10**22 wei)
受害者->>Pair合约: tokenB.transfer(pair,10**22 wei)
受害者->>Pair合约: pair.mint(msg.sender)
Pair合约 ->> 受害者: LP token=? wei
end
Uniswap支持0.05%的平台手续费,它可以在Factory合约中设置开或者关。如果被设置为收取平台手续费,则该平台手续费会转账给feeTo地址(该地址是在工厂合约中设置)。
如果设置了feeTo地址,Uniswap将会开始收取0.05%的平台手续费,其为流动性提供者收取的0.3%中的$\frac1{6}$, 即交易者仍然只会支付0.3%的手续费,其中$\frac5{6}$会支付给流动性提供者,剩余的$\frac1{6}$会支付给feeTo地址。如果每一笔交易都将其手续费的1/6实时打给feeTo地址会极度消耗gas。为了避免这种情况,累计的手续费只会在提供流动性和移除流动性时计算。合约会计算累计的手续费,并铸造新的LP token给到feeTo地址,在任何token被铸造或者销毁前进行。
function mint(address to) external lock returns (uint liquidity) {
//拿到调用mint前的tokenA和tokenB的余额
//拿到此时刻的TokenA和TokenB的余额
//计算得到打进账户的两种Token的数量
// 在实际创建任何LPtoken之前,先计算mintFee,即先把平台手续费对应的LP token发放了。
bool feeOn = _mintFee(_reserve0, _reserve1);
if (_totalSupply == 0) {
//首次铸币
} else {
//非首次铸币
}
//发送LP Token
// 更新账户余额
if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
}
function _mintFee(uint112 resserve0, uint112 reserve1) private returns (bool feeOn) {
//拿到工厂合约设置的feeTo地址
address feeTo = IUniswapFactory(factory).feeTo();
//根据feeTo地址判断是否开启收取平台手续费
feeOn = (feeTo == address(0));
//如果收取平台手续费
if (feeOn) {
if (KLast == 0) {
//判断是不是首次铸币,首次铸币不收取平台手续费,因为此时没有swap,压根就没有手续费
return;
} else {
//如果不是首次铸币,计算要发放的LP token的数量,转给feeTo地址
// s1 = totalSupply, k1 = KLast, k2 = reserve0 * reserve1
uint root_k1 = math.sqrt(KLast);
uint root_k2 = math.sqrt(uint256(reserve0).mul(uint256(reserve1)));
require(root_k2 > root_k1, "root_k2 <= root_k1");
uint numerator = root_k2.sub(root_k1);
uint denominator = root_k2.mul(5).add(root_k1);
uint amount = numerator.div(denominator).mul(totalSupply);
_mint(feeTo, amount);
}
} else if (KLast != 0) {
//如果不收取平台手续费,然而KLast不为0,则将其设置为0;
KLast = 0;
}
}
现在最大的问题是,如何计算发放给feeTo地址的LP token的数量。
首先要明确一点,手续费的收集体现在K值的增加上。由于每一笔swap都收取了0.3%的手续费,其会导致$\sqrt{k}$值缓慢增加。对比$t_1$和$t_2$时刻的$\sqrt{k_1}$和$\sqrt{k_2}$, 其差值即为$t_2$与$t_1$时刻间的手续费,则在任意时间段($t_1$,$t_2$)间,其手续费占当前时刻$t_2$的比重为:
$$ f_{1,2}=\frac{\sqrt{k_2}-\sqrt{k_1}}{\sqrt{k_2}} $$
此时,我们假设$t_2$时刻的LPtoken的总供应量为$s_1$,需要发放给feeTo地址的LP token的量为$s_m$, 由于LP token不能减发,且发送给feeTo地址的LPtoken在发送给流动性提供者的LPtoken之前发放,故需要满足如下等式:
$$ \frac{s_m}{s_m+s1} = \phi \times f{1,2} $$
其中$\phi$为平台手续费所占所有手续费的比例,此处为1/6
由方程式1,2得到,此时的发放给feeTo地址的平台手续费为:
$$ s_m=\frac{\sqrt{k_2}-\sqrt{k_1}}{(1/\phi-1)\cdot \sqrt{k_2} + \sqrt{k_1}}\times s_1 $$
带入$\phi=1/6$后得到:
$$ s_m=\frac{\sqrt{k_2}-\sqrt{k_1}}{5\cdot \sqrt{k_2} + \sqrt{k_1}}\times s_1 $$
由Uniswap Pair合约中铸造出的LP token天然的支持元交易。这意味着用户可以通过签名的方式授权转移LP token, 而不是必须由用户地址发起的以太坊上的交易。任何人可以通过调用permit函数提交他们的签名,付给gas费用,并同时进行多笔交易。
// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
constructor() public {
uint chainId;
assembly {
chainId := chainid
}
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
keccak256(bytes(name)),
keccak256(bytes('1')),
chainId,
address(this)
)
);
}
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
bytes32 digest = keccak256(
abi.encodePacked(
'\x19\x01',
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
)
);
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
_approve(owner, spender, value);
}
要理解Uniswap V2中提到的元交易部分,就需要理解EIP-712
旨在提高链外消息签名的可用性,以便在链上使用。我们看到越来越多的采用链外消息签名,因为它节省了gas,减少了区块链上的交易数量。当前签名的消息是向用户显示的不透明的hex字符串,与构成消息的项目几乎没有上下文。
EIP-712确定了编码数据及其结构,允许它显示给用户在签名时进行验证。以下是用户在签署 EIP712 消息时可以显示的内容的示例。
EIP-712 在EIP-191的基础上,进一步提出了一种结构化的签名信息,用于在前端(线下)签名时展示相应的信息,而不是一串难以理解的hex串。其在设计时,主要考虑到两方面特性:1.确保可确认性。如果不是确定性的,则哈希可能从签名到验证的瞬间而有所不同,导致签名被错误地拒绝 2. 可注射性。如果它不是注射的,那么我们的输入集中有两个不同的元素,它们对相同的值进行哈希值,导致签名对不同的不相关消息有效。
$$ \mathtt{T}\cup\mathtt{B^{8n}}\cup\mathtt{S} $$
对于bytes数据$B^{8n}$, 其编码方式遵循EIP-191, 即
$ encode(B^{8n})= $\x19"Ethereum Signed Message:\n"$ \ ||\ len(message)\ ||\ message $ 其中len(message)是没有0开头的Ascii编码值
$$
$$
对于结构化数据$S$, 其编码方式遵循EIP-712, 即
$$ encode(domainSeparator,message)= \ x19x01\ ||\ domainSeparator\ ||\ hashStruct(message) $$
domainSeparator是一个结构体,名为EIP712Domain:
struct EIP712Domain{
string name, //用户可读的域名,如DAPP的名字
string version, // 目前签名的域的版本号
uint256 chainId, // EIP-155中定义的chain ID, 如以太坊主网为1
address verifyingContract, // 用于验证签名的合约地址
bytes32 salt // 随机数
}
该结构体必须按照如上定义的字段和顺序定义,且如果有不需要的字段,可以跳过,但是不能改变该结构体内部顺序。
则对于DomainSeparator的编码方式应遵循如下编码方式:
$$ hashStruct(S)=keccak256(typeHash\ ||\ encodeData(S))\ typeHash=keccak256(encodeType(typeOf(S))) $$
对于上述结构体EIP712Domain来讲:
encodeType编码方式与函数选择器的编码方式不同,其包含了变量名和空格。
$$ encodeType=EIP712Domain(string name,string version,uint256 chainId,address verifyingContract) $$
对于encodeType编码,如果结构体中包含结构体,则只需要按照字母顺序将结构体依次编码即可
如针对结构体:
struct Transaction{
Person from,
Person to,
Asset tx
}
struct Asset{
address token,
uint256 amount
}
struct Person{
address wallet,
string name
}
编码后的encodeType为:
Transaction(Person from,Person to,Asset tx)Asset(address token,uint256 amount)Person(address wallet,string name)
encodeData编码要求所编码的每一位都占据32bytes,且编码的顺序要与encodeType中定义的顺序一致。针对string 或者 bytes 等动态类型,即长度不定的类型,其取值为 keccak256(string) 即内容的hash值。针对固定长度的类型,如bool 值其编码为Uint256, address 编码为uint160, bytes1~bytes32 编码为左对齐的bytes32,右侧补零。
故针对DomainSepeartor,其编码值为:因为每一项必须是32bytes,所以用abi.encode,而不用abi.encodePacked
abi.encode(
keccak(bytes(name)),
keccak(bytes(version)),
uint256(chianid),
uint256(uint160(verifyingContract))
);
结合起来,故知domainSeperator的值应为:
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), // encodeType
keccak256(bytes(name)), // encodeData-name
keccak256(bytes("1")), // encodeData-version
chianId, // encodeData-chainId
uint256(address(this)) // encodeData-address
)
);
$$ encode(domainSeparator,message)=\x19x01\ ||\ domainSeparator\ ||\ hashStruct(message) $$
根据上述公式,在uniswap中,我们需要明确需要编码的struct,即用户调用时显示在前端off-chain部分的信息:
struct Permit{
address owner,
address spender,
uint256 value,
uint256 nonce,
uint256 deadline
}
则该Permit的encodeType为:
encodeType(Permit) = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
则该Permit的encodeData为:因为每一项都必须是32bytes,所以使用abi.encode, 而不是用abi.encodePacked
abi.encode(
uint256(uint160(owner)),
uint256(uint160(spender)),
value,
nonce,
deadline
)
故该Permit的结构化签名信息应为:
bytes memory mssage = abi.encodePacked(
\x19\x01,
DOMAIN_SEPERATOR,
keccak256(
abi.encode(
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"),
uint256(uint160(owner)),
uint256(uint160(spender)),
value,
nonces[owner]++,
deadline
))
)
结合Uniswap中的元数据定义看,在Permit函数中,对上述消息Hash后,然后通过ecrecover
方法来验证address地址。当验证无误时,执行_approve
方法。
uniswap V2在工厂合约中,使用了create2关键字来创建合约。下面我们结合黄皮书,来进一步学习下create2与create
$$ a \equiv A(s, \boldsymbol{\sigma}[s]_{\mathrm{n}} - 1, \zeta, \mathbf{i}) $$
$$ A(s, n, \zeta, \mathbf{i}) \equiv \mathcal{B}_{96..255}\Big(\mathtt{KEC}\big(B(s, n, \zeta, \mathbf{i})\big)\Big) \ $$
$$ B(s, n, \zeta, \mathbf{i}) \equiv \begin{cases} \mathtt{RLP}\big(\;(s, n)\;\big) \text{if}\ \zeta = \varnothing \ (255) \cdot s \cdot \zeta \cdot \mathtt{KEC}(\mathbf{i}) \text{otherwise} \end{cases} $$
对于create关键字,其随机数$\zeta=\varnothing$, 则新生成的合约地址仅和msg.sender
和nonce
相关。其具体地址的值为经过RLP编码后的msg.sender
和 Nonce
的bytes,然后经过哈希后的最右侧160位。
如下:
msg.sender = 0xfefefefefefefefefefefefefefefefefefefefe
nonce = 1
(msg.sender, nonce) = [0xfefefefefefefefefefefefefefefefefefefefe,1]
RLP((sender,nonce)): 此为list,需按照list进行RLP编码
首先是msg.sender 的RLP编码: 0x94fefefefefefefefefefefefefefefefefefefefe (0x80+0x14)
然后是nonce 的RLP编码:01
所以(sender,nonce) 的RLP编码是: 0xD694fefefefefefefefefefefefefefefefefefefefe01 (0xc0+0x16)
则Keccak256(RLP((sender,nonce)))= 0x1eee6eebe7cda42d60b04fbbf862acff87608602aa2cb646f5d67560f5c3e9d7
则生成的地址为:0xf862acff87608602aa2cb646f5d67560f5c3e9d7 右侧160位
对于create2关键字,其随机数$\zeta \neq \varnothing$, 在uniswap v2中,该随机数定义为:$\zeta= keccak256(abi.encodePacked(token0, token1));$ 则create2地址应为:
bytes32 memory salt = keccak256(abi.encodePacked(token0,token1));
bytes memory init_code = type(UniswapV2Pair).creationCode;
B = abi.encodePacked(
hex'ff',
address(factory),
salt,
keccak256(init_code)
);
address pair = address(uint160(keccak256(B)));
那么为什么要选用create2这一opcode,而不是create呢?
原因在于create生成的地址与nonce相关,即与pair合约生成的顺序相关。我们希望pair合约的地址与该顺寻无关,故选用create2这一opcode。
整个Uniswap V2的预言机部分代码仅有4-5行,然而其却实现了时间平均价格,取代容易被操纵的瞬时价格,成为一个更加稳定的价格预言机。
下面我们将结合白皮书和代码部分来分析Uniswap V2中,该预言机是如何设计以及为什么需要这样设计
首先是价格,某时刻t的资产价格a/b 为此时刻的reserve_tokenA 与 reserve_tokenB的比值
$$ pt=\frac{r{t}^{a}}{r_t^{b}} $$
如果用瞬时价格作为预言机会出现什么问题?
瞬时价格容易被操纵,可以通过第一笔交易swap,让A/B的资产价格瞬时下跌,然后再第二笔交易中,利用基于此瞬时价格作为预言机的合约,比如一些清算服务等,去进行清算等,第三笔交易再将价格拉回原位。甚至此三笔交易可以在一个原子交易中完成。
三明治攻击
uniswap v2通过测量和记录每一个区块最开始的第一笔交易的价格。相较于同一个区块内的价格,该价格非常难以操纵。具体来说,Uniswap v2通过记录每个区块中有人与合约互动时的累积价格总和,来累积这个价格。
每个价格的权重是自上一个区块更新以来所经过的时间。这意味着,在任何给定的时间,累积器的值(在被更新后)都应该被加权。这意味着在任何特定时间(更新后),累积器的值应该是合约历史上每一秒钟的现货价格的总和。
$$ at=\sum{i=1}^{t}{p_i} $$
为了估计从时间$t_1$到$t_2$的时间加权平均价格,一个外部调用者可以在$t_1$时检查累积器的值,然后在$t_2$时再次检查,将该值减去第一个值,然后再除以所经过的秒数。(请注意合约本身并不存储这个累积器的历史值--调用者必须在周期开始时调用合约来读取和存储这个值)
$$ p_{t_1,t2}=\frac{\sum{i=t_1}^{t_2}{p_i}}{t_2-t1}=\frac{\sum{i=1}^{t_2}pi-\sum{i=1}^{t_1}{p_i}}{t_2-t1}=\frac{a{t2}-a{t_1}}{t_2-t_1} $$
下面我们结合代码来看下具体如何实现:
uint public price0CumulativeLast;
uint public price1CumulativeLast;
// 只在每个区块的最开始的第一笔交易处记录price,并且将价格按照时间权重进行累加得到a_t
function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
// 判断是否是该区块的第一笔交易,只要该block的timestamp与上一次记录的Block.timestamp不一样,就说明不是同一个块
uint32 timestamp = uint32(uint(block.timestamp)%(2**32));
if (timestamp > prev_timestamp) {
//如果是,计算出瞬时价格pi
//uint256 priceAtoB = uint256(_reserve0).div(uint256(_reserve1));
uint256 priceAtoB = uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)); // 得到的price是uint224位编码,头32位留空给溢出用,前112位为正数部分,后112位为小数部分
uint256 priceBtoA = uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1));
//按照时间权重累加到a_t上
price0CumulativeLast += (timestamp-prev_timestamp).mul(priceBtoA);
price1CumulativeLast += (timestamp-prev_timestamp).mul(priceAtoB);
}
prev_timestamp = timestamp;
}
最后得到的price1CumulativeLast
虽然是uint256
,但其实实质上仍然是UQ112.112
编码的数值。前32位留空给溢出,前112位为正数,后112位为小数。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!