Skip to content

手把手教你从0到1构建Uniswap V1:part1 #91

@MagicalBridge

Description

@MagicalBridge

Uniswap的不同版本

截止到2024年6月,Uniswap已经推出三个上线的生产版本。第四个版本目前还在开发阶段

Uniswap V1

第一个版本于2018年11月推出,Uniswap V1仅支持ERC-20代币与ETH之间的交易,这意味着任何ERC-20代币都可以通过ETH进行兑换,但是不能与其他的ERC-20代币进行交易;

如果用户需要交易两个ERC-20代币,则需要先将其中一个代币兑换为ETH, 然后再用ETH兑换另一个代币,这样会导致额外的交易费用和Gas费用。

Uniswap V1引入了恒定乘积做市商模型(AMM),这意味着流动性池中的代币价格是由其储备量决定的,支持无需许可的代币交换,但是,V1版本存在流动性分散和Gas费用高等问题。

Uniswap V2

于2020年5月推出,在V1版本的基础上进行了改进,增加了对ERC-20/ERC-20代币对的交易支持,并且支持了任何货币对之间的链式交换,提高了资金利用率。

Uniswap V3

于2021年5月推出,引入了“集中流动性”的概念,允许流动性提供者在特定价格范围内提供资金,从而显著提高了DEX的资本效率。此外,V3版本还允许做市商自行选择费用层级。

Uniswap V4

于2023年6月14日发布了代码草稿,目前尚未正式发布。V4版本的主要改进是引入了单例模式,将所有不同池子集中到一个单一的合约中,从而提高了Gas效率。

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

什么是Uniswap

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

底层来看,它是一种算法,允许创建池(或代币对),并为其注入流动性,以便用户利用这些流动性来交换代币。这种算法被称为自动做市商 (AMM) 或 自动流动性提供者 (ALP)。

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

做市商是为市场提供流动性(交易资产)的实体。流动性是交易得以进行的关键因素:如果您想卖东西,但没有人买,那么交易就无法进行。一些交易对具有高流动性(例如 BTC-USDT),而另一些则流动性较低甚至根本没有流动性(例如一些骗人的或可疑的山寨币)。

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

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

Uniswap 除了自动做市商的角色之外,还扮演着另一个重要角色 - 价格预言机 。价格预言机是从中心化交易所获取代币价格并提供给智能合约的服务 - 这些价格通常很难被操纵,因为中心化交易所的交易量通常非常大。然而,Uniswap 虽然没有那么大的交易量,仍然可以作为价格预言机发挥作用。

Uniswap 作为二级市场,会吸引套利者利用 Uniswap 和中心化交易所之间价格差异获利。这促使 Uniswap 池里的价格尽可能接近大型交易所的价格。如果没有适当的定价和储备平衡功能,这是无法实现的。

常数乘积做市商(CPMM)

自动做市商 (AMM) 是一个术语,用来涵盖各种去中心化交易所 (DEX) 的定价算法。最流行的 (也是最早被称为 AMM 的) 算法跟预测市场有关 - 预测市场允许人们通过预测价格走势来获利。Uniswap 和其他链上交易所使用的算法都是对这类算法的进一步发展。

常数乘积做市商(CPMM)是一种用于去中心化金融(DeFi)平台的自动化做市商(AMM)。它允许在无需传统订单簿的情况下进行加密货币对的自动化交易。

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

$$ x * y = k $$

其中 x 代表以太坊储备,y 代表代币储备(反之亦然),k 是常数。Uniswap 要求无论 x 或 y 的储备量是多少,k 必须保持不变。当您用以太坊交易代币时,您将以太坊存入合约并获得一定数量的代币作为回报。Uniswap 会确保每次交易后 k 保持不变(这并不完全正确,稍后我们会解释为什么)

智能合约开发

为了真正理解 Uniswap 的运作原理,我们将亲自构建一个模拟的版本。这里将使用 Solidity 编写智能合约,并使用 HardHat 作为我们的开发环境。HardHat 是一个非常棒的工具,可以大大简化智能合约的开发、测试和部署。

初始化开发环境

首先,创建一个空目录(我给它起名为 myUniswap ),将 cd 放入其中并安装 HardHat

mkdir myUniswap && 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.24。打开您的 hardhat.config.js 并更新其底部的 Solidity 版本。

代币合约

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

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

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 合约。ERC20 是以太坊上常用的代币标准,用于定义代币的基本功能,例如余额查询、转账和代币总供应量.
  • 我们定义了一个自定义的构造函数 (constructor)。构造函数会在合约部署时自动执行一次。
  • 这个自定义的构造函数允许我们设置三个参数:代币名称 (token name)、代币符号 (symbol) 和初始供应量 (initial supply)。
  • 在构造函数中,它还会铸造 (mint) 指定数量的代币 (initialSupply) 并将其发送到代币创建者的地址。

交换合约

Uniswap V1 只有两个合约:FactoryExchange

Exchange合约是用于进行ETH和ERC-20代币之间兑换的合约,(为了表述没有歧义,下面的涉及这个合约的地方都直接使用Exchange表示)

Factory是一个注册合约,用于创建Exchange合约并跟踪所有已部署的Exchange合约,使得可以通过代币地址找到Exchange合约地址,反之亦然。Exchange合约实际上定义了交换逻辑。每个(ETH-ERC20Token)对都部署为一个Exchange合约,并允许仅将以太币兑换为一个代币或从一个代币兑换以太币。

我们先来实现Exchange合约,Factory合约我们将在后面的文章中实现。

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

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

contract Exchange {}

由于每个Exchange合约只允许使用一种代币进行交换,因此我们需要将代币合约地址和Exchange合约地址绑定

contract Exchange {
  address public tokenAddress;

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

    tokenAddress = _token;
  }
}

Token地址是一个状态变量,它可以从任何其他合约函数访问。将它的可见性设置为public,在构造函数中,我们检查提供的token是否有效(不是零地址),并将其保存到状态变量中。

提供流动性

我们之前已经介绍过了,足够充分的流动性才能使交易成为可能。因此,我们需要一种方法向Exchange合约添加流动性:

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);
    }
}

默认情况下,合约无法接收以太币,我们可以给addLiquidity函数添加 payable 关键字来解决这个问题,该修饰符允许在函数中接收以太币:任何与函数调用一起发送的以太币都会添加到合约的余额中。

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

目前为止,addLiquidity 的实现并不完整,先不要着急,我将在后面的部分继续完善。

我们还需要添加一个返回Exchange合约代币余额的辅助函数。

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) });

        // 这里验证 exchange 合约的以太币余额是否等于100。
        expect(await getBalance(exchange.address)).to.equal(toWei(100));
        // 这里验证 exchange 合约的 token 储备是否等于 200 个 token。
        expect(await exchange.getReserve()).to.equal(toWei(200));
    });
});

让我来解释下上面这个测试用例的代码:

await token.approve(exchange.address, toWei(200));这里调用了 token 合约的 approve 函数,允许 exchange 合约在 token 合约中最多花费 200 个 token。toWei 是一个将数值转换为 Wei 单位的辅助函数(Wei 是以太坊中的最小单位)。

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

定价函数

现在让我们思考一下如何计算交易价格。通常情况下,人们会认为价格只和某种token的供应量有关。这么想其实是有道理的:

Exchange合约不和中心化的交易所或者任何的其他外部的价格预言机交互,所以它并不知道正确的价格,实际上Exchange合约本身就是一个价格预言机。它所知道的一切都是以太币和ERC20代币的储备,这也是我们计算价格的唯一信息。

让我们根据这个思路去构建一个定价函数:

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 () => {
        // 批准exchange合约可以使用 2000 个代币
        await token.approve(exchange.address, toWei(2000));
        // 添加流动性,提供2000个代币和等值1000个以太币
        await exchange.addLiquidity(toWei(2000), { value: toWei(1000) });
        // 获取Exchange合约中代币的储备量
        const tokenReserve = await exchange.getReserve();
        // 获取Exchange合约中以太币的储备量
        const etherReserve = await getBalance(exchange.address);

        // 计算每个代币的以太币价格,并断言结果是 0.5
        expect(
                (await exchange.getPrice(etherReserve, tokenReserve)).toString()
        ).to.eq("0.5");

        // 计算每个以太币兑换代币价格,并断言结果是 2
        expect(await exchange.getPrice(tokenReserve, etherReserve)).to.eq(2);
    });
});

当我们执行上面的测试用例发现失败了,这是为什么呢? 原因是 Solidity 支持仅进行舍入的整数除法。 0.5 的价格四舍五入为 0!让我们通过提高精度来解决这个问题:

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

  return (inputReserve * 1000) / outputReserve;
}

相应的,测试用例的代码也需要修改:

// 每个代币的以太币价格,并断言结果是 500
expect(await exchange.getPrice(etherReserve, tokenReserve)).to.eq(500);

// 每个以太币的代币价格,并断言结果是 2000
expect(await exchange.getPrice(tokenReserve, etherReserve)).to.eq(2000);

一切看起来都是正常的,但是如果我们想将2000个代币全部兑换为ETH会发生什么呢?我们会将1000个以太币全部取走。

显然,定价功能出了问题:它导致兑换池子被抽干,这不是我们希望发生的事情。

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

Xnip2024-06-28_17-36-11.png

它跨越 x 和 y 轴,这意味着它允许其中任何一个轴为0,我们并不希望发生这样的事情。

正确的定价函数

我们在文章的开头已经介绍过了,Uniswap 是一个恒定乘积做市商,这意味着它基于恒定乘积公式:

$$ x*y= k $$

上面这个公式能够帮助我们写出更好的定价函数吗?让我们继续往下看。

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

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

其中 Δ𝑥 是我们要交易的以太币或代币数量,Δ𝑦是我们要交换的代币或以太币数量。有了这个公式,将公式展开,通过变换,我们现在可以得到 Δ𝑦 :

$$ Δy = \frac{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);
}

这是一个底层函数,因此将其设为私有。让我们创建两个函数来封装一下:

// 这个函数接受卖出的以太币数量 _ethSold 作为参数,并返回相应的代币数量。
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 个代币。这些金额非常接近之前定价函数产生的金额。然而,它们稍微小一些。这是为什么?

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

Xnip2024-06-28_17-38-07.png

双曲线永远不会穿过 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");
  });
});

正如上面测试用例所展示的,当我们尝试抽取全部流动性时,我们只得到了预期的一半。

交换函数

现在,我们准备写兑换函数了。

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 变量中)发送到getAmount函数并获得代币作为回报。请注意,我们需要从合约余额中减去 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 以太币作为交换。

总结:

本小节就到这里,我们还没有完成,但我们已经实现了很多功能。我们的Exchange合约可以接受用户的流动性,以防止耗尽的方式计算价格,并允许用户将 eth 与代币进行交换。但仍然缺少一些重要的部分:

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

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

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

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

5、工厂还没有实现。

我们将在后面的章节中完成这些!

参考链接:

1、Introduction to Smart Contracts 在开始开发智能合约之前需要学习大量有关智能合约、区块链和 EVM 的基本信息

2、Let’s run on-chain decentralized exchanges the way we run prediction markets, 这是 Vitalik Buterin 在 Reddit 上发表的一篇文章,他提议使用预测市场的机制来构建去中心化交易所。给出了使用恒定乘积公式的想法。

3、Uniswap V1 Documentation

4、Uniswap V1 Whitepaper

5、Constant Function Market Makers: DeFi’s “Zero to One” Innovation

6、Automated Market Making: Theory and Practice

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions