Skip to main content

Uniswap Protocol (V2)

· 14 min read

Uniswap là ứng dụng giao dịch phi tập trung phổ biến đầu tiên sử dụng Automated Market Maker (AMM) thay cho orderbook mà chúng ta thường thấy ở các sàn giao dịch tập trung hay chứng khoán truyền thống.

Orderbook là tập hợp các lệnh đặt mua và lệnh đặt bán từ tất cả người dùng muốn tham gia trao đổi 1 cặp tiền tệ hàng hóa, khi mà lệnh mua và lệnh bán khớp nhau thì giao dịch được thực hiện giữa 2 người.

Tuy nhiên để quản lý 1 orderbook với số lượng lớn các order tốn rất nhiều tài nguyên tính toán của máy tính. Trong bối cảnh Blockchain có khả năng tính toán xử lý giao dịch đầu tiên là Ethereum giai đoạn đầu thì việc xử lý lượng lớn các order update liên tục theo thời gian thực gần như là không thể.

Bởi vậy người ta nghĩ ra 1 cách là tạo ra 1 hồ thanh khoản (liquidity pool) bao gồm 1 số lượng nhất định của 1 cặp token (ví dụ 2 đồng token A và B). Người giao dịch nếu muốn đổi token A lấy token B thì chỉ việc bỏ 1 số lượng token A vào hồ thanh khoản và được trả về 1 số lượng token B có giá trị tương đương, và ngược lại.

Ưu và nhược điểm
  • Cách này có ưu điểm là người dùng có thể thực hiện trao đổi ngay lập tức từ pool mà không cần phải chờ khớp lệnh với 1 người khác, và hợp đồng thông minh được cài đặt sẵn trong pool có thể thực hiện thao tác này 1 cách nhanh chóng vì không phải quản lý 1 số lượng lớn tất cả order của người dùng cùng 1 lúc.

  • Tuy nhiên cách này cũng có nhược điểm là không thể đặt giá mua/bán ở 1 mức xác định mong muốn mà chỉ có thể giao dịch ngay ở mức giá hiện tại.

ERC20

Uniswap là giao thức AMM dành cho các đồng token ERC20, tức là smartcontract của các token này phải được lập trình theo 1 interface cố định IERC20.

interface IERC20 {
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}

Trong đó giao thức Uniswap sử dụng các hàm

  • transfer(address to, uint256 value): người gọi chuyển value token mình sở hữu cho địa chỉ ví to.
  • approve(address spender, uint256 value): người gọi cho phép spender quyền chuyển value token thuộc sở hữu của mình.
  • transferFrom(address from, address to, uint256 value): người gọi chuyển value token từ ví from sang ví to (cần được cấp quyền từ from bằng hàm approve).

Concepts

Giải thích một số khái niệm chính trong giao thức Uniswap V2.

Price và Swap

Tỉ giá giữa 2 đồng token được xác định bằng tỉ lệ số lượng của mỗi token có trong pool, đảm bảo công thức amount(A) * amount(B) = k với k là hằng số không đổi trước và sau khi swap.

Giả sử trong pool có 10 tokenA, 200 tokenB, có thể hiểu sơ bộ là 10 token A có giá trị tương đương 200 token B k = 10 * 200 = 2000. Giá ở phụ thuộc vào khối lượng muốn swap.

Ví dụ người dùng muốn swap 2 token A, bỏ vào pool 2 token A, 12 * amount(B2) = k = 2000.

Số token B trong pool sau khi swap amount(B2) = 2000 / 12 = 166.67.

Số token B nhận về sau khi swap amount(B1) - amount(B2) = 200 - 166.67 = 33.33.

Liquidity

Liquidity - hay thanh khoản là đại diện cho số lượng tokenA và tokenB có trong pool để cho phép giao dịch được diễn ra.

Hành động thêm số lượng token A và B vào pool để hỗ trợ giao dịch gọi là thêm thanh khoản (add liquidity).

Người dùng ban đầu (thường là người tạo ra pool hay cặp giao dịch) bỏ vào pool 1 số lượng token A và token B nhất định để xác lập tỉ giá ban đầu. Sau đó bất cứ người dùng nào khác cũng có quyền bỏ thêm vào pool 1 số lượng token A và B với tỉ lệ giống với tỉ lệ hiện tại trong pool.

Ngoài ra contract pool được cài đặt như là 1 đồng ERC20 token. Khi người dùng thêm thanh khoản số đồng pool-token này sẽ được mint ra tương ứng thể hiện quyền sở hữu đối với pool. Người dùng có quyền rút thanh khoản này tương đương tỉ lệ mà họ sở hữu. Khi mà tỉ lệ token trong pool thay đổi (giá thay đổi) thì tỉ lệ số token mà đại diện bởi lượng thanh khoản người dùng sở hữu cũng thay đổi theo.

Ví dụ ban đầu người dùng bỏ vào pool 10 token A, 1000 token B số token pool được mint ra và trả cho người dùng amount(poolAB) = 10 * 1000 = 10000 tương ứng 100% pool. Người dùng thứ 2 bỏ vào pool 2 token A, 200 token B, bằng 1/5 số hiện tại trong pool.

  • Pool ban đầu (A - B) = (12 - 1200), liquidity token 10000.
  • User2 thêm vào (A - B) = (2 - 200).
  • Dự trữ trong pool sau khi thêm (A - B) = (12 - 1200).
  • Số liquidity token mint ra và trả về cho user2 / trên tổng số 2000/12000.

Sau 1 thời gian giao dịch giả sử lượng dự trữ trong pool (A - B) = (48 - 300) (tokenA tăng 4 lần, tokenB giảm 4 lần, k không đổi). Lúc này user2 rút lượng liquidity mình nắm giữ 2000/12000 tương đương 1/6 pool, được trả về (A - B) = (8 - 50), so với lượng bỏ vào ban đầu (A - B) = (2 - 200) số token A tăng lên và B giảm đi, khi B tăng giá so với A.

Trong pool còn lại (A - B) = (40 - 250), đồng thời lượng liquidity token được burn đi còn lại 10000/10000 token đều do user1 nắm giữ.

Lúc này user1 có quyền rút toàn bộ thanh khoản còn lại (A - B) = (40 - 250) và nếu như vậy pool không còn thanh khoản để giao dịch được nữa khiến cho token mà người mua nắm giữ trở nên vô giá trị (scammer dùng cách này để rút hết thanh khoản hay còn gọi là rug-pull).

Cách thức hoạt động

Uniswap V2 bao gồm các contract sau

  • Factory: dùng để tạo ra và quản lý tất cả các pool/cặp giao dịch
  • Pool/Pair: mỗi cặp giao dịch được quản lý bởi 1 contract riêng cài đặt interface IERC20, cho phép thêm/rút thanh khoản và swap giữa 2 token.
  • Router: user không tương tác trực tiếp với Pool contract mà thông qua contract router gọi đến pool contract để thêm/rút thanh khoản, swap.

Factory

Interface đầy đủ contract factory V2 IUniswapV2Factory

Implementation UniswapV2Factory

interface IUniswapV2Factory {
event PairCreated(address indexed token0, address indexed token1, address pair, uint);

function getPair(address tokenA, address tokenB) external view returns (address pair);
function allPairs(uint) external view returns (address pair);
function allPairsLength() external view returns (uint);

function createPair(address tokenA, address tokenB) external returns (address pair);
}

Bất kỳ người dùng nào cũng có quyền gọi hàm createPair để tạo pool cho 2 token bất kỳ (contract factory deploy contract pool). Thông thường người dùng gọi trực tiếp hàm addLiquidity của router luôn và nếu pool chưa tồn tại router sẽ tự động gọi đến createPair của factory để tạo pool.

Pool/Pair

Interface IUniswapV2Pair

Implementation UniswapV2Pair

Trong đó có các hàm

1. Thêm liquidity

function mint(address to) external lock returns (uint liquidity) {}
  • Người dùng chuyển 1 số tokenA và tokenB vào Pool.
  • Người dùng gọi hàm mint(address) với tham số truyền vào địa chỉ ví của mình.
  • Pool contract mint ra số liquidity token tương ứng lượng token A, B mới chuyển vào và trả cho người dùng.

2. Rút liquidity

function burn(address to) external lock returns (uint amount0, uint amount1) {}
  • Người dùng chuyển số liquidity token mà mình nắm giữ vào pool.
  • Người dùng gọi hàm burn(address to), truyền vào địa chỉ ví của mình.
  • Pool contract thực hiện đốt số liquidity token mà nó đang giữ, trả về số token A, B tương ứng cho người dùng.

3. Swap

function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {}
  • Giả sử muốn swap token A sang token B, người dùng chuyển 1 lượng token A vào pool.
  • Người dùng gọi hàm truyền vào
    • amount0Out: 0
    • amount1Out: lượng token B muốn nhận về tự tính toán, maximum phải thỏa mãn yêu cầu hằng số k nếu ko giao dịch sẽ thất bại
    • to: địa chỉ ví nhận
    • data: callback data trong trường hợp phía gọi hàm là 1 contract không phải user (router - sẽ giải thích ở phần tiếp theo)

Ta thấy trong tất cả 3 thao tác chính ở trên, giao dịch đều có 2 phần

  • (1) Người dùng chuyển token vào pool
  • (2) Gọi hàm trong pool contract

Trong trường hợp giao dịch (1) thành công, và giao dịch (2) thất bại hoặc chậm (network issue...), user khác có thể chen chân vào gọi (2) trước cả user ban đầu và chiếm lấy quyền swap, hay mint/burn liquidity mà ko phải nạp token vào do user ban đầu đã nạp thành công ở (1).

Bởi vậy các giao dịch này ko thể tách làm 2 mà phải hợp lại thành 1 giao dịch duy nhất để đảm bảo cả 2 thành công và không có giao dịch nào chen ngang, hoặc rollback cả 2 nếu thất bại. Đây là lúc chúng ta cần router contract.

Router

Router là contract trung gian mà người dùng tương tác trực tiếp để gọi đến pool contract nhằm thực hiện thêm/rút liquidity, swap.

Router01

Router02

1. Thêm liquidity

function addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) external override ensure(deadline) returns (uint amountA, uint amountB, uint liquidity) {}
  • (1) Người dùng cấp phép cho router contract quyền sử dụng tokenA, tokenB (gọi hàm approve trong contract ERC20 của A và B)
  • (2) Gọi hàm addLiquidity trong router contract, contract tính toán chuyển token vào và gọi hàm mint trong pool
    • amountADesired, amountBDesired: người dùng tự tính toán lượng token nạp vào cho tối ưu (bằng với tỉ lệ hiện tại trong pool)
    • amountAMin, amountBMin: số token tối thiểu nạp vào nếu ko đạt được mức mong muốn (sau khi tính toán sao cho lượng nạp vào bằng tỉ lệ hiện tại)
    • to: địa chỉ để nhận về liquidity token
    • deadline: thời hạn cuối cho giao dịch

2. Rút liquidity

function removeLiquidity(
address tokenA,
address tokenB,
uint liquidity,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) public override ensure(deadline) returns (uint amountA, uint amountB) {}
  • Người dùng cấp phép cho router quyền sử dụng liquidity token mà mình sở hữu và muốn rút
  • Người dùng gọi hàm removeLiquidity trong router contract, router chuyển token vào và gọi hàm burn trong pool
    • tokenA, tokenB: địa chỉ cặp token muốn rút liquidity
    • liquidity: lượng liquidity muốn rút
    • amountAMin, amountBMin: lượng token tối thiểu muốn nhận về
    • to: địa chỉ nhận token
    • deadline: thời hạn cuối cho giao dịch

3. Swap

Hàm swapExactTokensForTokens cho phép người dùng sử dụng chính xác số token bỏ vào

function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external override ensure(deadline) returns (uint[] memory amounts) {}
  • Người dùng cấp phép cho router sử dụng token đầu tiên trong path (path[0])
  • Người dùng gọi hàm swapExactTokensForTokens trong router, router tính toán chuyển token vào và gọi swap qua nhiều pool
    • amountIn: số token chính xác muốn sử dụng để swap (token đầu tiên trong path)
    • amountOutMin: lượng token tối thiểu muốn nhận về (token cuối cùng trong path)
    • path: đường dẫn để swap từ token đầu tiên để lấy về token cuối cùng, vd path=[tokenA, tokenB, tokenC] => swap qua 2 pool [tokenA/tokenB, tokenB/tokenC]
    • to: địa chỉ nhận
    • deadline: thời hạn cuối cho giao dịch

Hàm swapTokensForExactTokens cho phép người dùng xác định chính xác số token muốn nhận về

function swapTokensForExactTokens(
uint amountOut,
uint amountInMax,
address[] calldata path,
address to,
uint deadline
) external override ensure(deadline) returns (uint[] memory amounts) {}
  • Người dùng cấp phép cho router sử dụng token đầu tiên trong path (path[0])
  • Người dùng gọi hàm swapTokensForExactTokens, router tính toán lượng token đầu vào, chuyển token và gọi swap qua nhiều pool
    • amountOut: lượng token chính xác muốn nhận về (token cuối cùng trong path)
    • amountInMax: lượng token tối đa muốn sử dụng để swap (token đầu tiên trong path)
    • path: đường dẫn để swap token như trên
    • to: địa chỉ nhận
    • deadline: thời hạn cuối cho giao dịch
Nguyên tắc chung

Trong 3 thao tác chính ở trên ta thấy cũng có 2 bước cần thực thi

  • (1) Cấp quyền sử dụng token cho router contract
  • (2) Gọi hàm trong router contract
    • Router chuyển token của người dùng vào trong pool
    • Router gọi hàm tương ứng trong pool, pool trả token về cho người dùng

Ở đây (1)(2) là 2 giao dịch độc lập không cần đi liền với nhau, ở (2) là đã gộp 2 bước đã nêu khi muốn swap token trực tiếp từ pool thành 1 giao dịch duy nhất. Ngoài ra việc thiết kế contract router độc lập với contract pool cho phép logic router được sử dụng lại với nhiều pool khác nhau, cũng như là có thể swap qua nhiều pool trong cùng 1 giao dịch.

WETH

Các pool đa phần là cặp của đồng token ERC20 với coin native ETH, trong trường hợp này người ta tạo ra 1 đồng ERC20 WETH đại diện cho ETH, để việc swap được diễn ra thuận tiện như giữa 2 đồng ERC20 thông thường. Contract WETH ngoài interface ERC20 được cài đặt các hàm

function deposit() external payable {}
function depositTo(address to) external payable {}
function withdraw(uint256 value) external {}
function withdrawTo(address payable to, uint256 value) external {}

Dựa trên contract WETH này thì router cung cấp thêm 1 số hàm để làm việc trực tiếp với native ETH luôn tiện hơn cho người dùng, thay vì approve token WETH người dùng chỉ cần trả native coin ETH khi gọi hàm (sử dụng payable)

function addLiquidityETH(...) external override payable ensure(deadline) returns (...) {}
function swapExactETHForTokens(...) external override payable ensure(deadline) returns (uint[] memory amounts) {}

Router cũng hỗ trợ người dùng rút trực tiếp về đồng native ETH.

function removeLiquidityETH(...) public override ensure(deadline) returns (...) {}
function swapTokensForExactETH(...) external override ensure(deadline) returns (uint[] memory amounts) {}

Trong bài này chúng ta đã tìm hiểu các concept và cách thức hoạt động cơ bản của Uniswap V2, ở V3 sẽ có các tính năng mở rộng và phức tạp hơn tương đối mình sẽ trình bày ở bài viết sau.