LapTrinhBlockchain

Chia sẻ kiến thức về Lập Trình Blockchain

Cá nhân, Kiến thức Blockchain, Kiến thức lập trình, Lập trình Blockchain, Lập trình Smart Contract, Nâng cao Kiến thức, Phát triển bản thân

Hướng dẫn viết smart contract thực hiện tính toán và hoán đổi trực tiếp trên pool của Uniswap V4

Hướng dẫn viết smart contract thực hiện tính toán và hoán đổi trực tiếp trên pool của Uniswap V4

Hướng dẫn viết smart contract thực hiện tính toán và hoán đổi trực tiếp trên pool của Uniswap V4

Chia sẻ bài viết
5
(86)

Như chúng ta đã biết, trong Uniswap V4, có khá nhiều thay đổi so V2/V3, cụ thể là:

  • Trong V4 thì fee có thể động tùy thuộc vào Thanh khoản (Liquidity) và Độ biến động (Volatility) nên rất khó đoán định giá trị chính xác của lượng nhận được.
  • Trong V2/V3 thì 1 pool = 1 contract, nhưng trong V4 thì tất cả các pool nằm trong 1 singleton contract => Do đó Pool trong V4 được định danh bởi ID chứ không phải bởi địa chỉ của Pool.
  • Trong V2/V3 thì swap xảy ra trên Pool nhưng trong V4 thì swap xảy ra trên PoolManager
  • Trong V4 lại có Hooks cho phép thay đổi logic của swap => Nên khó có một công thức swap cố định

Với các Pool dùng Hooks thì việc tính output chính xác cực khó và chỉ có cách duy nhất là giả lập (Simulation) ở trạng thái hiện tại, gọi để lấy dữ liệu chính xác. Quá trình này gồm 2 việc, có thể sử dụng revm, geth, foundry, hardhard để thực hiện: “Fork state” và “Simulate transaction locally“. Việc Simulation này thực sự phức tạp và tốn nhiều tài nguyên.

Vì thế trong bài viết này chúng ta chỉ quan tâm tới các Pool dùng standard concentrated liquidity, tức là các Pool không có Hooks và fee không biến động, khi đó nó vẫn theo công thức của V3.

Viết contract thực hiện một lệnh swap thông thường

Hiểu về Kiến trúc cốt lõi của Uniswap V4

Trong V4, tất cả các pool đều nằm trong một hợp đồng duy nhất gọi là PoolManager, đây là kiến trúc Singleton. Nó giống như một “ngân hàng” khổng lồ nắm giữ số dư của tất cả các token cho tất cả các pool. Việc swap bây giờ chỉ là cập nhật số dư nội bộ trong PoolManager, không cần chuyển ERC-20 ra ngoài cho đến bước cuối cùng.

Thêm nữa, V4 sử dụng phương pháp “Kế toán tức thời” (Flash Accounting). PoolManager không cập nhật số dư ERC-20 ngay lập tức sau mỗi thao tác swap. Thay vào đó, nó sử dụng cơ chế kế toán tạm thời:

  1. Bạn gọi hàm unlock() trên PoolManager.
  2. PoolManager chuyển quyền điều khiển sang hợp đồng của bạn thông qua một callback.
  3. Trong callback, bạn thực hiện bao nhiêu thao tác tùy thích (swap, thêm thanh khoản, rút thanh khoản v.v.). Mỗi thao tác chỉ tạo ra một “nợ” (delta) giữa hợp đồng của bạn và PoolManager.
  4. Cuối cùng, trước khi callback kết thúc, hệ thống kiểm tra xem tất cả các khoản nợ có được thanh toán về 0 hay không. Nếu có bên nào còn nợ, giao dịch bị revert.

Cơ chế này cho phép bạn thực hiện swap nhiều bước trong một pool duy nhất hoặc giữa các pool khác nhau mà chỉ tốn phí chuyển token ERC-20 thực sự ở bước tất toán cuối cùng.

Luồng thực hiện swap trên Pool của Uniswap V4

Để thực hiện swap trong V4, chúng ta không tương tác trực tiếp với Pool mà phải tương tác với PoolManager. Để swap được chúng ta phải biết:

  • PoolManager: Địa chỉ của PoolManager
  • PoolKey: Thông tin của Pool gồm (token0, token1, fee, tickSpacing, hooks).
  • SwapParams: Thông số cho swap bao gồm (zeroForOne, amountSpecified, sqrtPriceLimitX96)

Luồng đầy đủ quá trình swap trên Pool V4 như sau:

  • B1: Gọi hàm poolManager.unlock(…) để khởi tạo một phiên giao dịch nội bộ. Khi hàm unlock() được gọi, poolManager sẽ gọi tới unlockCallback(…) của contract để chúng ta cài đặt các nghiệp vụ trong đó.
  • B2: Trong unlockCallback(…), gọi hàm poolManager.swap(…), hàm này sẽ trả về BalanceDelta chứa thông tin swap => Từ dữ liệu BalanceDelta chúng ta sẽ có được amount0amount1, kết hợp với zeroForOne ta sẽ xác định được amountInamountOut.
  • B3: Thực hiện tất toán giao dịch (Thanh toán nợ)
    Chúng ta cần thanh toán đủ tokenIn vào PoolManager và rút tokenOut về ví. Quá trình này gồm 2 bước nhỏ (Bạn thực hiện bước nào trước cũng được):
    • B4.1: Rút tokenOut về ví bằng cách gọi hàm:
      poolManager.take(tokenOut, sender, amountOut)
    • B4.2: Thanh toán đủ tokenIn cho PoolManager
      • Nếu tokenIn là token thường: Chúng ta sẽ gọi các hàm sau để hoàn thành công việc
        poolManager.sync(tokenIn)
        IERC20(tokenIn).transferFrom(sender, poolManager, amountIn)
        poolManager.settle()
      • Nếu tokenIn là ETH: Chúng ta sẽ gọi các hàm sau để hoàn thành công việc, thêm phần hoàn trả ETH nếu còn dư
        poolManager.sync(tokenIn)
        poolManager.settle{value: amountIn}()

        refundETH()

Mã nguồn thực hiện swap trên Pool của Uniswap V4

Bên dưới đây là mã nguồn thực hiện swap. Để cho code thực sự ngắn gọn và dễ đọc, tôi đã cố gắng tối giản chỉ lấy các interface thật sự cần thiết và đóng gọi lại trong 1 tệp để bạn có thể dễ dàng triển khai trên nhiều công cụ khác nhau. Nếu bạn muốn xem chi tiết các interface, bạn có thể xem tại UniswapV4-Core.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

type Currency is address;
type BalanceDelta is int256;
type BeforeSwapDelta is int256;
struct SwapParams {
    bool zeroForOne;
    int256 amountSpecified;                                 // The desired input amount if negative (exactIn), or the desired output amount if positive (exactOut)
    uint160 sqrtPriceLimitX96;
}
struct PoolKey {
    Currency currency0;
    Currency currency1;
    uint24 fee;
    int24 tickSpacing;
    IHooks hooks;
}

interface IERC20 {
    function transfer(address recipient, uint256 amount) external returns (bool);
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
}

interface IHooks {
    function beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata hookData) external returns (bytes4, BeforeSwapDelta, uint24);
    function afterSwap(address sender, PoolKey calldata key, SwapParams calldata params, BalanceDelta delta, bytes calldata hookData) external returns (bytes4, int128);
}

interface IPoolManager {
    function unlock(bytes calldata data) external returns (bytes memory);
    function swap(PoolKey memory key, SwapParams memory params, bytes calldata hookData) external returns (BalanceDelta swapDelta);
    function take(Currency currency, address to, uint256 amount) external;
    function sync(Currency currency) external;
    function settle() external payable returns (uint256 paid);
    function settleFor(address recipient) external payable returns (uint256 paid);
}

contract UniswapV4Swap {
    uint160 internal constant MIN_SQRT_RATIO = 4295128739;
    uint160 internal constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342;

    constructor() {
    }

    struct CallbackData {
        address sender;
        IPoolManager poolManager;
        PoolKey poolKey;
        bool zeroForOne;
        int256 amountSpecified;
    }

    receive() external payable {}
    fallback() external {}

    function swapExactInput(address poolManager, PoolKey calldata poolKey, bool zeroForOne, uint128 amountIn) external payable {
        swap(poolManager, poolKey, zeroForOne, amountIn, true);
    }

    function swapExactOutput(address poolManager, PoolKey calldata poolKey, bool zeroForOne, uint128 amountOut) external payable {
        swap(poolManager, poolKey, zeroForOne, amountOut, false);
    }

    function swap(address poolManager, PoolKey calldata poolKey, bool zeroForOne, uint128 amount, bool exactInput) internal {
        IPoolManager iPoolManager = IPoolManager(poolManager);
        int256 amountSpecified = int256(uint256(amount));
        if (exactInput) amountSpecified = -1*amountSpecified;
        CallbackData memory data = CallbackData({
            sender: msg.sender,
            poolManager: iPoolManager,
            poolKey: poolKey,
            zeroForOne: zeroForOne,
            amountSpecified: amountSpecified
        });
        iPoolManager.unlock(abi.encode(data));
    }

    function unlockCallback(bytes calldata rawData) external returns (bytes memory) {
        // Do swap
        CallbackData memory data = abi.decode(rawData, (CallbackData));
        require(msg.sender == address(data.poolManager), "Not pool manager");
        SwapParams memory swapParams = SwapParams({
            zeroForOne: data.zeroForOne,
            amountSpecified: data.amountSpecified,
            sqrtPriceLimitX96: (data.zeroForOne ? MIN_SQRT_RATIO + 1 : MAX_SQRT_RATIO - 1)
        });
        BalanceDelta delta = data.poolManager.swap(data.poolKey, swapParams, new bytes(0));
        (int128 amount0, int128 amount1) = parseAmounts(delta);
        if (data.zeroForOne) {
            // Receive currency1
            uint256 takeAmount = uint256(uint128(amount1));
            data.poolManager.take(data.poolKey.currency1, data.sender, takeAmount);

            // Pay currency0
            uint256 settleAmount = uint256(int256(-amount0));
            data.poolManager.sync(data.poolKey.currency0);
            if (Currency.unwrap(data.poolKey.currency0)==address(0)) {
                data.poolManager.settle{value: settleAmount}();
                refundETH(data.sender);
            } else {
                safeTransferFrom(Currency.unwrap(data.poolKey.currency0), data.sender, address(data.poolManager), settleAmount);
                data.poolManager.settle();
            }
        } else {
            // Receive currency0
            uint256 takeAmount = uint256(uint128(amount0));
            data.poolManager.take(data.poolKey.currency0, data.sender, takeAmount);

            // Pay currency1
            uint256 settleAmount = uint256(uint128(-amount1));
            data.poolManager.sync(data.poolKey.currency1);
            if (Currency.unwrap(data.poolKey.currency1)==address(0)) {
                data.poolManager.settle{value: settleAmount}();
                refundETH(data.sender);
            } else {
                safeTransferFrom(Currency.unwrap(data.poolKey.currency1), data.sender, address(data.poolManager), settleAmount);
                data.poolManager.settle();
            }
        }
        return abi.encode(delta);
    }

    function refundETH(address recipient) internal {
        uint256 balance = address(this).balance;
        if (balance > 0) {
            safeTransferETH(recipient, balance);
        }
    }

    function parseAmounts(BalanceDelta balanceDelta) internal pure returns (int128 amount0, int128 amount1) {
        assembly ("memory-safe") {
            amount0 := sar(128, balanceDelta)
            amount1 := signextend(15, balanceDelta)
        }
    }

    function safeTransferFrom(address token, address from, address to, uint value) internal {
        // bytes4(keccak256(bytes('transferFrom(address,address,uint)')));
        (bool success, bytes memory data) = token.call(abi.encodeWithSelector(0x23b872dd, from, to, value));
        require(success && (data.length == 0 || abi.decode(data, (bool))), 'Token tranfer failed');
        
    }

    function safeTransferETH(address to, uint value) internal {
        (bool success, ) = payable(to).call{value: value}(new bytes(0));
        require(success, 'ETH transfer failed');
    }
}

Trong code trên tôi public hai hàm: swapExactInput(…)swapExactOutput(…) để thực hiện swap theo kiểu chính xác token vào hay chính xác lượng token ra. Thực chất nó liên quan tới trường amountSpecified trong tham số SwapParams:

  • amountSpecified<0 => Chính xác lượng token vào
  • amountSpecified>0 => Chính xác lượng token ra

Ngoài ra, chúng ta nhớ khai báo dưới để contract có thể nhận được ETH:
receive() external payable {}
fallback() external {}
Hàm receive() để contract có thể nhận ETH thuần (Khi calldata rỗng), còn fallback() để nhận ETH khi calldata khác rỗng và không match với một function nào của contract.

Đây là phiên bản swap trên 1 pool, bạn có thể thử nâng cấp swap trên nhiều pool V4 liên tiếp nhau thử xem.

Triển khai và test thử

Tôi đã triển khai trên Unichain Sepolia (Testnet của Unichain) tại địa chỉ: 0xBD5eFAA6bEe4E2ED52EA00b91962F1C3EaeAb1F2

Bây giờ chúng ta sẽ test thử trên pool ETH/USDC: 0x1927686e9757bb312fc499e480536d466c788dcdc86a1b62c82643157f05b603
Chi tiết địa chỉ các contract xem mục Uniswap V4 trên Unichain Sepolia.

Đầu tiên chúng ta cần phải có thông tin PoolKey. Để lấy được thông tin PoolKey thì từ poolId 0x1927686e9757bb312fc499e480536d466c788dcdc86a1b62c82643157f05b603, ta bỏ 7 bytes ở cuối ta được 0x1927686e9757bb312fc499e480536d466c788dcdc86a1b62c8, dùng làm tham số truyền vào hàm poolKeys(…) của PositionManager ta được thông tin PoolKey như dưới:

{
  currency0 (address) : 0x0000000000000000000000000000000000000000
  currency1 (address) : 0x31d0220469e10c4E71834a79b1f276d740d3768F
  fee (uint24) : 3000
  tickSpacing (int24) : 60
  hooks (address) : 0x0000000000000000000000000000000000000000
}

Ngoài ra xem trong tài liệu của Uniswap V4, ta có:
PoolManager: 0x00b036b58a818b1bc34d502d3fe730db729e62ac

Bây giờ chúng ta gọi hàm swapExactInput(…) với các tham số như hình dưới:

Chi tiết các tham số khi gọi hàm gọi hàm swapExactInput(...)

Giao dịch thực hiện thành công: 0xe0ef5e467ca69543173f158677ac520a591ae520222838b4f2f49b5fe97dceff
Chúng ta đã hoán đổi 0.01 ETH để lấy 39.956914 USDC.

Hoán đổi thành công 0.01 ETH để lấy 39.956914 USDC.

Tiếp theo chúng ta gọi hàm swapExactOutput(…) với các tham số như hình dưới:

Tham số chi tiết khi gọi hàm swapExactOutput(...)

Giao dịch thực hiện thành công: 0xc95df968888fdf9b2bb8a9fb81f94dfc804c5f1f352cda6e7e0989f961bc1230
Theo như giao dịch chúng ta đã hoán đổi 0.00250299647185187 ETH để đổi lấy 10 USDC, và lượng dư ra 0.00749700352814813 ETH được hoàn trả lại:

Chi tiết giao dịch hoán đổi khi gọi hàm swapExactOutput(...)

Chúng ta cũng có thể thực hiện swap từ USDC sang ETH, nhưng trước khi thực hiện swap chúng ta phải thực hiện cấp quyền thủ công cho địa chỉ contract UniswapV4Swap được quyền sử dụng USDC từ ví của bạn. Đây là giao dịch cấp quyền của tôi: 0x94a88a88b9271605f279840b179f2b865775997095f52a58550f5209d8722ef1

Chi thiết các thông số của lệnh swap từ USDC sang ETH:

Chi tiết các tham số khi gọi hàm gọi hàm swapExactInput(...) khi swap từ USDC sang ETH

Giao dịch thực hiện thành công: 0x594412d1bb869fdba22e33844e7c11d7d5a003194817ff309429089e33e2ebef
Theo như giao dịch chúng ta đã hoán đổi 10 USDC để đổi lấy 0.002488001199515276 ETH:

Hoán đổi 10 USDC để đổi lấy 0.002488001199515276 ETH

Lấy dữ liệu và thực hiện tính toán off-chain

Để tìm kiếm được các cơ hội Cyclic Trade chúng ta cần phải tính toán được số lượng nhận được bên ngoài chuỗi. Do đó chúng ta bắt buộc phải lấy được các dữ liệu cần thiết và thực hiện các tính toán off-chain.

Lấy dữ liệu xác định trạng thái hiện tại của Pool V4

Với Pool V4 mà không có hooks thì nó giống với Pool V3, nên chúng ta phải lấy được các dữ liệu tương tự như V3. Với Pool V3 chúng ta cần 2 thông tin cơ bản nhất đó là L√P, từ đó ta có thể tính được để có tính toán qua lại giữa amountIn <-> amountOut trong vùng tick hiện tại. Và hai thông tin cơ bản này được lấy từ hàm slot0()liquidity() của Pool.

Với Uniswap V4, do Pool được quản lý chung, không có Pool riêng để lấy dữ liệu trạng thái hiện tại của Pool mà bạn phải lấy trạng thái của pool thông qua hợp đồng StateView, trong đó sử dụng hai hàm tương tự đó là:
getLiquidity(poolId)
getSlot0(poolId)

Ví dụ hợp đồng StateView của Uniswap V4 trên Sepolia Unichain là: 0xc199f1072a74d4e905aba1a84d9a45e2546b6222. Bây giờ ta gọi thử để lấy thông tin cho pool ETH/USDC: 0x1927686e9757bb312fc499e480536d466c788dcdc86a1b62c82643157f05b603 ta được:

  • liquidity: 6567745307999030
  • sqrtPriceX96: 5015417344114601799662396
Lấy thông tin trạng thái hiện tại của Pool

Ngoài ra nếu bạn muốn tính thông tin thanh khoản ở các vùng tick tiếp theo thì bạn cần nhiều thông tin hơn thông qua các hàm của StateView:
getTickBitmap(poolId, tick)
getTickLiquidity(poolId, tick)

Thông tin trả về từ hàm getTickLiquidity()

Thực hiện tính toán off-chain

Với thông tin đã lấy được ở trên, ta hoàn toàn có thể thực hiện được tính toán tương tự như V3. Chi tiết tính toán bạn xem trong tệp SqrtPriceMath.sol, các hàm tính toán đều ở trong này:
getNextSqrtPriceFromInput()
getNextSqrtPriceFromOutput()
getAmount1Delta()
getAmount0Delta()

Giả sử ta cần thực hiện swap từ currency0 sang currency1, trong đó biết amountIn, ta cần tính amountOut. Ta thực hiện tính toán như sau:

  • B1: Gọi hàm getLiquidity(poolId) getSlot0(poolId) để lấy liquiditysqrtPrice
  • B2: Từ amountIn, ta phải trừ đi phí ta được amountInWithFee
  • B3: Chúng ta gọi hàm getNextSqrtPriceFromInput() ta được nextSqrtPrice
  • B4: Ta gọi hàm getAmount1Delta() ta được amountOut

Tương tự, nếu biết amountOut ta cũng có thể tính amountIn:

  • B1: Gọi hàm getLiquidity(poolId) getSlot0(poolId) để lấy liquiditysqrtPrice
  • B2: Từ amountOut, ta gọi hàm getNextSqrtPriceFromOutput() để được nextSqrtPrice
  • B3: Chúng ta gọi hàm getAmount0Delta() ta được amountInWithFee
  • B4: Ta cộng thêm phí vào thì ta được amountIn

Bạn đọc kỹ tệp SqrtPriceMath.sol và thực hiện chuyển đổi phần tính toán sang contract riêng của bạn, bạn sẽ hiểu rõ hơn cách tính toán. Từ bản tính toán đó, bạn có thể dễ dàng chuyển đổi sang các ngôn ngữ khác như NodeJs, Python, C/C++,…

Bài viết này có hữu ích với bạn?

Kích vào một biểu tượng ngôi sao để đánh giá bài viết!

Xếp hạng trung bình 5 / 5. Số phiếu: 86

Bài viết chưa có đánh giá! Hãy là người đầu tiên đánh giá bài viết này.

Trả lời

Giao diện bởi Anders Norén