编码DeFi: Uniswap. Part 1

Photo by Clark Tibbs on Unsplash

编码DeFi: Uniswap. Part 1

Programming DeFi: Uniswap. Part 1

介绍

学习某样东西的最好方法就是教别人。学习某事的第二个最佳方法是自己动手。我决定将这两种方法结合起来,教我自己和你如何在以太坊(以及基于 EVM - 以太坊虚拟机的任何其他区块链)上编写 DeFi 服务。

我们的主要关注点将是这些服务如何运作,我们将尝试了解使它们成为现实的经济机制(并且它们都基于经济机制)。我们将找出、分解、学习并构建它们的核心机制。

然而,我们只会研究智能合约:为智能合约构建前端也是一项艰巨而有趣的任务,但这超出了本系列的范围。

让我们开始 Uniswap 之旅。

Uniswap 的不同版本

截至 2021 年 6 月,Uniswap 已推出三个版本。

第一个版本于 2018 年 11 月推出,仅允许以太币和代币之间的交换。链式交换也可以允许代币之间的交换。

V2 于 2020 年 3 月推出,它是 V1 的巨大改进,允许任何 ERC20 代币之间直接交换,以及任何货币对之间的链式交换。

V3 于 2021 年 5 月推出,显着提高了资本效率,这使得流动性提供者可以从池中移走更大一部分流动性,并仍然获得相同的奖励(或者将资本压缩在较小的价​​格范围内,并获得高达 4000 倍的利润) )。

在本系列中,我们将深入研究每个版本,并尝试从头开始构建每个版本的简化副本。

这篇博文特别关注 Uniswap V1,以尊重时间顺序并更好地了解以前的解决方案是如何改进的。

什么是 Uniswap?

简单来说,Uniswap 是一个去中心化交易所(DEX),旨在成为中心化交易所的替代品。它在以太坊区块链上运行并且完全自动化:没有管理员、经理或具有特权访问权限的用户。

在较低的杠杆上,它是一种算法,允许创建池或代币对,并为其填充流动性,以便用户使用这种流动性交换代币。这种算法被称为自动做市商或自动流动性提供者。

让我们更多地了解做市商。

做市商是向市场提供流动性(交易资产)的实体。流动性使交易成为可能:如果你想出售某种东西但没有人购买,那么就不会进行交易。有些交易对具有较高的流动性(例如 BTC-USDT),但有些交易对的流动性较低或根本没有流动性(例如一些诈骗或可疑的山寨币)。

DEX 必须拥有足够(或大量)的流动性才能发挥作用并作为中心化交易所的替代品。获得流动性的一种方法是 DEX 开发商将自己的资金(或投资者的资金)投入其中并成为做市商。然而,这不是一个现实的解决方案,因为考虑到 DEX 允许任何代币之间的交换,他们需要大量资金来为所有货币对提供足够的流动性。此外,这将使 DEX 中心化:作为唯一的做市商,开发商手中将拥有很大的权力。

更好的解决方案是允许任何人成为做市商,这就是 Uniswap 成为自动化做市商的原因:任何用户都可以将资金存入交易对(并从中受益)。

Uniswap 扮演的另一个重要角色是价格预言机。价格预言机是从中心化交易所获取代币价格并将其提供给智能合约的服务——此类价格通常很难操纵,因为中心化交易所的交易量通常非常大。然而,尽管 Uniswap 的交易量没有那么大,但它仍然可以充当价格预言机。

Uniswap 作为二级市场,吸引了套利者,他们通过 Uniswap 和中心化交易所之间的价格差异获利。这使得 Uniswap 池上的价格尽可能接近大型交易所的价格。如果没有适当的定价和储备平衡功能,这是不可能的。

恒定产品做市商

您可能已经听说过这个定义,让我们看看它的意思。

自动做市商是一个通用术语,包含不同的去中心化做市商算法。最受欢迎的市场(以及诞生该术语的市场)与预测市场有关 - 允许通过预测获利的市场。 Uniswap 和其他链上交易所是这些算法的延续。

Uniswap 的核心是恒定乘积函数:

x * y = k

其中, x 是以太储备, y 是代币储备(反之亦然), k 是常量。 Uniswap 要求无论 x 或 y 的储备有多少, k 保持不变。当您用以太币交易代币时,您将以太币存入合约并获得一定数量的代币作为回报。 Uniswap 确保每次交易后 k 保持不变(这不是真的,我们稍后会看到原因)。

这个公式还负责定价计算,我们很快就会看到如何进行。

智能合约开发

为了真正了解 Uniswap 的工作原理,我们将构建一个它的副本。我们将在 Solidity 中编写智能合约,并使用 HardHat 作为我们的开发环境。 HardHat 是一个非常好的工具,它极大地简化了智能合约的开发、测试和部署。强烈推荐!

如果您是智能合约开发的新手,我强烈建议您完成本课程(至少是基本路径)——这将对您有巨大的帮助!

设置项目

首先,创建一个空目录(我称之为 zuniswap ),将 cd 放入其中并安装 HardHat:

$ mkdir zuniswap && cd $_
$ yarn add -D hardhat

我们还需要一个代币合约,让我们使用 OpenZeppelin 提供的 ERC20 合约。

$ yarn add -D @openzeppelin/contracts

初始化 HardHat 项目并删除 contract 、 script 和 test 文件夹中的所有内容。

$ yarn hardhat
...follow the instructions...
$ rm ...
$ tree -a
.
├── .gitignore
├── contracts
├── hardhat.config.js
├── scripts
└── test

最后一步:我们将使用最新版本的 Solidity,在撰写本文时为 0.8.4。打开您的 hardhat.config.js 并更新其底部的 Solidity 版本。

代币合约

Uniswap V1 仅支持以太币交换。为了使它们成为可能,我们需要 ERC20 代币合约。我们来写吧!

// contracts/Token.sol
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Token is ERC20 {
  constructor(
    string memory name,
    string memory symbol,
    uint256 initialSupply
  ) ERC20(name, symbol) {
    _mint(msg.sender, initialSupply);
  }
}

这就是我们所需要的:我们扩展 OpenZeppelin 提供的 ERC20 合约并定义我们自己的构造函数,该构造函数允许我们设置代币名称、符号和初始供应量。构造函数还铸造 initialSupply 代币并将它们发送到代币创建者的地址。

现在,最有趣的部分开始了!

交换合约

Uniswap V1 只有两个合约:Factory 和 Exchange。

Factory 是一个注册合约,允许创建交易所并跟踪所有已部署的交易所,允许通过代币地址查找交易所地址,反之亦然。交换合约实际上定义了交换逻辑。每对(eth-token)都被部署为交换合约,并允许仅与一个代币交换以太币。

我们将构建 Exchange 合约并将 Factory 留给稍后的博客文章。

让我们创建一个新的空白合约:

// contracts/Exchange.sol
pragma solidity ^0.8.0;

contract Exchange {}

由于每个交易所只允许使用一种代币进行交换,因此我们需要将交易所与代币地址连接:

contract Exchange {
  address public tokenAddress;

  constructor(address _token) {
    require(_token != address(0), "invalid token address");

    tokenAddress = _token;
  }
}

代币地址是一个状态变量,可以从任何其他合约函数访问它。公开它可以让用户和开发人员阅读它并找出该交易所链接到的代币。在构造函数中,我们检查提供的令牌是否有效(不是零地址)并将其保存到状态变量中。

提供流动性

正如我们已经了解到的,流动性使交易成为可能。因此,我们需要一种方法来增加交易合约的流动性:

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Exchange {
    ...

    function addLiquidity(uint256 _tokenAmount) public payable {
        IERC20 token = IERC20(tokenAddress);
        token.transferFrom(msg.sender, address(this), _tokenAmount);
    }
}

默认情况下,合约无法接收以太币,这可以通过 payable 修饰符来修复,该修饰符允许在函数中接收以太币:任何与函数调用一起发送的以太币都会添加到合约的余额中。

存入代币是另一回事:由于代币余额存储在代币合约上,我们必须使用 transferFrom 函数(由 ERC20 标准定义)将代币从交易发送者的地址转移到合约。此外,交易发送者必须调用代币合约上的 approve 函数,以允许我们的交易合约获取他们的代币。

addLiquidity 的实现并不完整。我故意这样做是为了更多地关注定价功能。我们将在后面的部分填补空白。

我们还添加一个返回交易所代币余额的辅助函数:

function getReserve() public view returns (uint256) {
  return IERC20(tokenAddress).balanceOf(address(this));
}

我们现在可以测试 addLiquidity 以确保一切正确:

describe("addLiquidity", async () => {
  it("adds liquidity", async () => {
    await token.approve(exchange.address, toWei(200));
    await exchange.addLiquidity(toWei(200), { value: toWei(100) });

    expect(await getBalance(exchange.address)).to.equal(toWei(100));
    expect(await exchange.getReserve()).to.equal(toWei(200));
  });
});

首先,我们通过调用 approve 让交换合约花费 200 个代币。然后,我们调用 addLiquidity 存入 200 个代币(交易合约调用 transferFrom 来获取它们)和 100 个以太币,这些代币随函数调用一起发送。然后我们确保交易所确实收到了它们。

为了简洁起见,我在测试中省略了很多样板代码。如果有不清楚的地方,请检查完整的源代码

定价功能

现在,让我们考虑一下如何计算汇率。

人们可能会认为价格只是储备的关系,例如:

P_X = y/x

P_y = x/y

这是有道理的:交易合约不与中心化交易所或任何其他外部价格预言机交互,因此它们无法知道正确的价格。事实上,兑换合约就是一个价格预言机。他们所知道的一切都是以太币和代币储备,这是我们计算价格的唯一信息。

让我们坚持这个想法并构建一个定价函数:

function getPrice(uint256 inputReserve, uint256 outputReserve)
  public
  pure
  returns (uint256)
{
  require(inputReserve > 0 && outputReserve > 0, "invalid reserves");

  return inputReserve / outputReserve;
}

让我们测试一下:

describe("getPrice", async () => {
  it("returns correct prices", async () => {
    await token.approve(exchange.address, toWei(2000));
    await exchange.addLiquidity(toWei(2000), { value: toWei(1000) });

    const tokenReserve = await exchange.getReserve();
    const etherReserve = await getBalance(exchange.address);

    // ETH per token
    expect(
      (await exchange.getPrice(etherReserve, tokenReserve)).toString()
    ).to.eq("0.5");

    // token per ETH
    expect(await exchange.getPrice(tokenReserve, etherReserve)).to.eq(2);
  });
});

我们存入了 2000 个代币和 1000 个以太币,预计代币的价格为 0.5 个以太币,以太币的价格为 2 个代币。然而,测试失败了:它说我们用代币换取了 0 个以太币。这是为什么?

原因是 Solidity 支持仅进行舍入的整数除法。 0.5 的价格四舍五入为 0!让我们通过提高精度来解决这个问题:

function getPrice(uint256 inputReserve, uint256 outputReserve)
  public
  pure
  returns (uint256)
{
    ...

  return (inputReserve * 1000) / outputReserve;
}

更新后测试,会通过:

// ETH per token
expect(await exchange.getPrice(etherReserve, tokenReserve)).to.eq(500);

// token per ETH
expect(await exchange.getPrice(tokenReserve, etherReserve)).to.eq(2000);

所以,现在 1 个代币等于 0.5 个以太币,1 个以太币等于 2 个代币。

一切看起来都是正确的,但如果我们将 2000 个代币换成以太币会发生什么?我们将获得 1000 个以太币,这就是我们合同上的全部内容!兑换会被耗尽!

显然,定价功能出了问题:它导致交易所耗尽,而这不是我们希望发生的事情。

原因是定价函数属于常数和公式,该公式将 k 定义为 x 和 y 的常数和。这个常和公式的函数是一条直线:

它跨越 x 和 y 轴,这意味着它允许其中任何一个轴为 0!我们绝对不希望这样。

正确的定价功能

让我们回想一下,Uniswap 是一个恒定乘积做市商,这意味着它基于恒定乘积公式:

x * y = k

这个公式能产生更好的定价函数吗?让我们来看看。

该公式指出,无论储备量( x 和 y )是多少, k 都保持不变。每笔交易都会增加以太币或代币的储备,并减少代币或以太币的储备——让我们将这个逻辑放入一个公式中:

(x+Δx)(y−Δy)=xy

其中 Δx 是我们要交易的以太币或代币数量 Δy ,我们要交换的代币或以太币数量。有了这个公式,我们现在可以找到 Δy :

Δy = yΔx / (x + Δx)

这看起来很有趣:该函数现在考虑输入量。让我们尝试对其进行编程,但请注意,我们现在处理的是金额,而不是价格。

function getAmount(
  uint256 inputAmount,
  uint256 inputReserve,
  uint256 outputReserve
) private pure returns (uint256) {
  require(inputReserve > 0 && outputReserve > 0, "invalid reserves");

  return (inputAmount * outputReserve) / (inputReserve + inputAmount);
}

这是一个低级函数,因此将其设为私有。让我们创建两个高级包装函数来简化计算:

function getTokenAmount(uint256 _ethSold) public view returns (uint256) {
  require(_ethSold > 0, "ethSold is too small");

  uint256 tokenReserve = getReserve();

  return getAmount(_ethSold, address(this).balance, tokenReserve);
}

function getEthAmount(uint256 _tokenSold) public view returns (uint256) {
  require(_tokenSold > 0, "tokenSold is too small");

  uint256 tokenReserve = getReserve();

  return getAmount(_tokenSold, tokenReserve, address(this).balance);
}

并测试它们:

describe("getTokenAmount", async () => {
  it("returns correct token amount", async () => {
    ... addLiquidity ...

    let tokensOut = await exchange.getTokenAmount(toWei(1));
    expect(fromWei(tokensOut)).to.equal("1.998001998001998001");
  });
});

describe("getEthAmount", async () => {
  it("returns correct eth amount", async () => {
    ... addLiquidity ...

    let ethOut = await exchange.getEthAmount(toWei(2));
    expect(fromWei(ethOut)).to.equal("0.999000999000999");
  });
});

所以,现在我们可以用 1.998 代币换 1 个以太币,用 0.999 以太币换 2 个代币。这些金额非常接近之前定价函数产生的金额。然而,它们稍微小一些。这是为什么?

我们基于价格计算的恒定乘积公式实际上是一条双曲线:

双曲线永远不会穿过 x 或 y ,因此储备量都不为 0。这使得储备量无限!

还有另一个有趣的含义:价格函数会导致价格滑点。相对于储备金而言,交易的代币数量越大,价格就越高。

这就是我们在测试中看到的情况:我们得到的结果略低于我们的预期。这可能被视为恒定产品做市商的一个缺点(因为每笔交易都有滑点),但这与保护资金池不被耗尽的机制相同。这也符合供求规律:相对于供给(储备),需求越高(你想要获得的产量越大),价格就越高(你获得的越少)。

让我们改进我们的测试,看看滑点如何影响价格:

describe("getTokenAmount", async () => {
  it("returns correct token amount", async () => {
    ... addLiquidity ...

    let tokensOut = await exchange.getTokenAmount(toWei(1));
    expect(fromWei(tokensOut)).to.equal("1.998001998001998001");

    tokensOut = await exchange.getTokenAmount(toWei(100));
    expect(fromWei(tokensOut)).to.equal("181.818181818181818181");

    tokensOut = await exchange.getTokenAmount(toWei(1000));
    expect(fromWei(tokensOut)).to.equal("1000.0");
  });
});

describe("getEthAmount", async () => {
  it("returns correct ether amount", async () => {
    ... addLiquidity ...

    let ethOut = await exchange.getEthAmount(toWei(2));
    expect(fromWei(ethOut)).to.equal("0.999000999000999");

    ethOut = await exchange.getEthAmount(toWei(100));
    expect(fromWei(ethOut)).to.equal("47.619047619047619047");

    ethOut = await exchange.getEthAmount(toWei(2000));
    expect(fromWei(ethOut)).to.equal("500.0");
  });
});

正如您所看到的,当我们尝试排空池中的水时,我们只得到了预期的一半。

最后要注意的是:我们最初的、基于准备金率的定价函数并没有错。事实上,当我们交易的代币数量与储备相比非常小时,这是正确的。但为了制造 AMM,我们需要更复杂的东西。

交换功能

现在,我们准备好实施交换了。

function ethToTokenSwap(uint256 _minTokens) public payable {
  uint256 tokenReserve = getReserve();
  uint256 tokensBought = getAmount(
    msg.value,
    address(this).balance - msg.value,
    tokenReserve
  );

  require(tokensBought >= _minTokens, "insufficient output amount");

  IERC20(tokenAddress).transfer(msg.sender, tokensBought);
}

将以太币交换为代币意味着将一定数量的以太币(存储在 msg.value 变量中)发送到应付合约函数并获得代币作为回报。请注意,我们需要从合约余额中减去 msg.value ,因为在调用该函数时,发送的以太币已经添加到其余额中。

这里另一个重要的变量是 __minTokens ——这是用户希望用以太币交换的最小代币数量。该金额在 UI 中计算,并且始终包含滑点容差;用户同意至少获得那么多但不能更少。这是一个非常重要的机制,可以保护用户免受抢先交易机器人的侵害,这些机器人试图拦截他们的交易并修改矿池余额以获取利润。

最后是今天的最后一段代码:

function tokenToEthSwap(uint256 _tokensSold, uint256 _minEth) public {
  uint256 tokenReserve = getReserve();
  uint256 ethBought = getAmount(
    _tokensSold,
    tokenReserve,
    address(this).balance
  );

  require(ethBought >= _minEth, "insufficient output amount");

  IERC20(tokenAddress).transferFrom(msg.sender, address(this), _tokensSold);
  payable(msg.sender).transfer(ethBought);
}

该函数基本上从用户的余额中转移 _tokensSold 代币,并向他们发送 ethBought 以太币作为交换。

结论

今天就这样!我们还没有完成,但我们做了很多。我们的交易合约可以接受用户的流动性,以防止耗尽的方式计算价格,并允许用户将 eth 与代币进行交换。这是很多,但仍然缺少一些重要的部分:

  1. 增加新的流动性可能会导致巨大的价格变化。

  2. 流动性提供者没有获得奖励;所有交换都是免费的。

  3. 没有办法消除流动性。

  4. 无法交换 ERC20 代币(链式交换)。

  5. 工厂仍未实施。

我们将在以后的部分中完成这些!

有用的链接

  1. 智能合约简介 在开始开发智能合约之前需要学习大量有关智能合约、区块链和 EVM 的基本信息。

  2. 让我们以运行预测市场的方式运行链上去中心化交易所,这是 Vitalik Buterin 在 Reddit 上发表的一篇文章,他提议使用预测市场的机制来构建去中心化交易所。这给出了使用恒定乘积公式的想法。

  3. Uniswap V1 文档

  4. Uniswap V1 白皮书

  5. 恒函数做市商:DeFi“从零到一”的创新

  6. 自动做市:理论与实践