5. Fee
Đúng là như một chân trời mới được mở ra khi tìm hiểu về cách tính fee của UniswapV3. Lúc đầu mình cũng không quan tâm đến việc tính fee của UniswapV3 lắm, vì cơ bản cũng hiểu được sơ sơ về các tính toán và flow xung quang việc Swap và Liquidity thì coi như hiểu được core của nó rồi. Nhưng hôm nay mình có hứng nên ngồi xem lại để viết nốt về đoạn tính fee thì OMG luôn á, biết vậy viết sớm hơn.
5.1 Các biến liên quan đế việc tính fee.
Các biến global
Ở trong UniswapV3, có 2 biến là feeGrowthGlobal0X128
và feeGrowthGlobal1X129
để ghi lại lượng fee trên 1 liquidity unit
đã kiếm được từ tất cả lệnh swap trong suốt vòng đời của Pool, cho token0
và token1
, 2 biến này luôn tăng, chúng ta có thể nhìn vào dòng code sau để thấy điều đó.
Đây là cách mà Pool ghi lại fee thu được trong hàm swap().
Lưu ý: Mặc dù feeGrowthGlobal0X128
và feeGrowthGlobal1X128
luôn tăng sau mỗi swap, nhưng chúng không phản ánh số fee thực tế mà mỗi đơn vị liquidity trong Pool nhận được. Đây là chỉ số toàn cục, được dùng làm mốc để tính toán fee mà một người dùng cụ thể có thể nhận, tùy thuộc vào:
- họ cung cấp thanh khoản ở range nào,
- và thời điểm nào.
Các biến riêng của tick
Hãy chú ý vào phần chú thích của 2 biến đó: fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) only has relative meaning, not absolute — the value depends on when the tick is initialized
.
feeGrowthOutside{0,1}X128
thì không tăng đều và không mang ý nghĩa tuyệt đối.
Mỗi khi giá cross qua tick, giá trị này được cập nhật bằng phép trừ:
feeGrowthOutside = feeGrowthGlobalNow - feeGrowthOutside
Điều này giúp nó thể hiện fee ở phía bên kia của tick so với vị trí giá hiện tại, và bị "đảo chiều" mỗi lần tick bị cross. Vì vậy, giá trị feeGrowthOutside có thể tăng hoặc giảm.
5.2 Từ initialize đến việc tính fee
Khi một tick được initialize, các biến như feeGrowthOutside{0,1} sẽ được gán giá trị từ feeGrowthGlobal{0,1} tại thời điểm đó.
Uniswap quy ước rằng: mọi fee tích lũy trước thời điểm tick được khởi tạo đều nằm ở phía bên dưới tick (tức là fee phát sinh khi giá nằm ngoài range).
Do đó, feeGrowthOutside lúc initialize đóng vai trò là mốc tham chiếu để sau này tính toán fee đúng cho từng khoảng giá.
Trong bài viết này, mình tập trung vào trường hợp điển hình: cả tickLower và tickUpper đều đã được initialize trước thời điểm giá nằm trên cả hai tick (tức là tickLower, tickUpper < tickCurrent). Trường hợp này đơn giản nhưng đủ để hiểu được fee trong range được tính thế nào từ feeGrowthGlobal
và feeGrowthOutside
. Với các tình huống khác (ví dụ như giá đang nằm trong range khi tick được initialize), bạn hoàn toàn có thể suy luận từ nguyên lý trên để áp dụng tương tự.
Từ giờ, để đơn giản chúng ta chỉ cần xét việc tính fee của token0
cho ngắn gọn, vì với token1
cũng tương tự.
Bước 1: Tại thời điểm feeGrowthGlobal = 12, Alice lần đầu tiên thêm thanh khoản vào một price range nằm hoàn toàn bên dưới currentTick. Khi đó, cả lowerTick và upperTick đều được initialize với feeGrowthOutside = 12.
Đây là minh hoạ cho các vùng feeGrowthOutside
của lowerTick
và upperTick
, ở đây chúng ta ko cần xét đến vùng của feeGrowthGlobal
Làm sao chúng ta biết được nên vẽ vùng như thế nào, sao chúng ta lại biết 2 vùng đó đều nằm xuôi về phía bên trái , mời xem lại chú thích của code: fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) only has relative meaning, not absolute — the value depends on when the tick is initialized
.
Màu xanh là vùng feeGrowthOutside
của lowerTick
, màu đỏ là vùng feeGrowthOutside
của upperTick
. Bằng việc quan sát hình ta có thể suy luận được rằng ở trường hợp mà 2 vùng feeGrowthOutside
của 2 tick đều nằm về cùng một hướng so với currentTick
như thế này thì feeGrowthInside
dễ dàng được suy ra bằng cách lấy hiệu của 2 vùng đó (cái nào trừ cho cái nào thì tuỳ vào trường hợp). Do đó ở đây:
feeGrowthInside = upperTick.feeGrowthOutside - lowerTick.FeeGrowOutside = 12 - 12 = 0.
Điều này đúng bởi vì price range này vừa được add và chưa có lệnh swap nào được thực hiện trong price range này nên chưa thu được fee nào.
Bước 2: Sau khi Alice thêm thanh khoản, currentTick di chuyển vào vùng price range vừa được tạo (từ bên phải qua trái). Trong quá trình này:
- Trước khi vượt qua upperTick, feeGrowthGlobal đã tích lũy thêm 3 fee cho mỗi liquidity unit → đạt 15.
- Khi vượt qua upperTick, Uniswap cập nhật feeGrowthOutside tại upperTick bằng feeGrowthGlobal hiện tại (15) trừ đi giá trị feeGrowthOutside được gán lúc tick được initialize (12), ta có: feeGrowthOutside = 15 - 12 = 3.
- Giả sử price range này đã thu được 2 fee unit cho mỗi liquidity unit khi currentTick di chuyển từ upperTick vào giữa price range -> feeGrowthGlobal += 2 = 17.
Đây là minh hoạ cho các vùng feeGrowthOutside
của lowerTick
và upperTick
, và vùng feeGrowthGlobal
(màu xanh lá)
Lúc này ta thấy upperTick.feeGrowthOutside
đã bị đổi chiều do vị trí của currentTick
so với upperTick
đã thay đổi (xem lại chú thích của 2 biến trong struct Info). Và lúc này feeGrowthInside
có thể tính như sau
feeGrowthInside = feeGrowthGlobal - lowerTick.feeGrowthOutside - upperTick.feeGrowthOutside = 17 - 12 - 3 = 2
Bước 3: Tiếp đó currentTick tiếp tục giảm và di chuyển ra khỏi price range mà Alice đã tạo. Trong quá trình này:
- Trước khi vượt qua lowerTick, feeGrowthGlobal đã tích lũy thêm 2 fee cho mỗi liquidity unit → đạt 19.
- Khi vượt qua lowerTick, cần cập nhật feeGrowthOutside tại lowerTick bằng feeGrowthGlobal hiện tại (19) trừ đi giá trị feeGrowthOutside được gán lúc tick được initialize (12), ta có: feeGrowthOutside = 19 - 12 = 7.
Đây là minh hoạ cho các vùng feeGrowthOutside
của lowerTick và upperTick
Tương tự như ở bước 1, ở trường hợp này chúng ta có thể không cần quan tâm đến feeGrowthGlobal
là bao nhiêu, chỉ cần tính feeGrowthInside
như sau:
feeGrowthInside = lowerTick.feeGrowthOutside - upperTick.feeGrowthOutside = 7 - 3 = 4
Bước 4: Giả sử current sau khi thoát ra khỏi price range lại quay trở lại price range sau đó. Trong quá trình này:
- Toàn bộ quá trình đi ra khỏi price range và quay trở lại feeGrowthGlobal tích luỹ thêm được 5 fee cho mỗi đơn vị liquidity unit -> chạm 24
- Khi vượt qua lowerTick, cần cập nhật feeGrowthOutside tại lowerTick bằng feeGrowthGlobal hiện tại (24) trừ đi giá trị feeGrowthOutside trước đó (7), ta có: feeGrowthOutside = 24 - 7 = 17.
- Giả sử từ lúc bắt đầu quay trở lại price range này, feeGrowthGlobal đã thu thêm được 3 fee trên mỗi liquidity unit -> feeGrowthGlobal += 3 = 27.
Đây là minh hoạ cho các vùng feeGrowthOutside
của lowerTick
và upperTick
, và vùng của feeGrowthGlobal
Lúc này ta thấy lower.feeGrowthOutside
đã bị đổi chiều một lần nữa. Và lúc này feeGrowthInside có thể tính như sau:
feeGrowthInside = feeGrowthGlobal - lowerTick.feeGrowthOutside - upperTick.feeGrowthOutside = 27 - 17 - 3 = 7
-> Kết quả này chính xác bằng tổng fee ở bước này và fee đã thu được ở bước 3 (trên mỗi liquidity unit). Như vậy feeGrowthInside
có tính chất luôn tăng giống như feeGrowthGlobal
.
5.3 Triển khai một cách tổng quát hơn
Ở phần trên chúng ta đã hình dung được cách tính feeGrowthInside
trong các trường hợp, nhưng bây giờ hãy rút ra một cách tính tổng quát hơn.
Mọi trường hợp về vị trí tương đối của lowerTick
và upperTick
so với currentTick
đều có thể mô tả như sau:
Từ hình vẽ thì chúng ta có thể dễ dàng rút ra:
feeGrowthInside = feeGrowthGlobal - feeGrowthAbove - feeGrowthBelow
Quay lại xét các bước ở mục 5.2, ta sẽ thấy mọi trường hợp đều có thể đưa về dạng tổng quát này.
Bước 1
Ở đây, currentTick
đều nằm bên phải so với upperTick
và lowTick
, và từ hình vẽ chúng ta dễ dàng thấy được rằng:
feeGrowthAbove = feeGrowthGlobal - upperTick.feeGrowthOutside = 12 - 12 = 0
feeGrowthBelow = lowerTick.feeGrowthOutside = 12
=> feeGrowthInside = feeGrowthGlobal - feeGrowthAbove - feeGrowthBelow = 12 - 12 - 0 = 0
Bằng với kết quả mà chúng ta đã tính ở phần trên
Bước 2
Ở đây, currentTick
nằm giữa lowerTick
và upperTick
, và từ hình vẽ chúng ta dễ dàng thấy được rằng:
feeGrowthAbove = upperTick.feeGrowthOutside = 3
feeGrowthBelow = lowerTick.feeGrowthOutside = 12
=> feeGrowthInside = feeGrowthGlobal - feeGrowthAbove - feeGrowthBelow = 17 - 3 - 12 = 2
Bằng với kết quả mà chúng ta đã tính ở phần trên
Bước 3
Ở đây, currentTick
đều nằm phía bên trái của lowerTick
và upperTick
, chúng ta không cần phải biết sau khi ra khỏi price range, feeGrowthGlobal
đã tăng thêm bao nhiêu vì nó sẽ tự triệt tiêu trong phép tính, nhưng để đơn giản, mình cứ giả sử nó đã tăng thêm 2.5
feeGrowthGlobal += 2.5 = 19 + 2.5 = 21.5
feeGrowthAbove = upperTick.feeGrowthOutside = 3
feeGrowthBelow = feeGrowthGlobal - lowerTick.feeGrowthOutside = 21.5 - 7 = 14.5
=> feeGrowthInside = feeGrowthGloabl - feeGrowthAbove - feeGrowthBelow = 21.5 - 3 - 14.5 = 4
Bằng với kết quả mà chúng ta đã tính ở phần trên
Bước 4
Tương tự như bước 2
feeGrowthAbove = upperTick.feeGrowthOutside = 3
feeGrowthBelow = lowerTick.feeGrowthOutside = 17
=> feeGrowthInside = feeGrowthGlobal - feeGrowthAbove - feeGrowthBelow = 27 - 17 - 3 = 7
Bằng với kết quả mà chúng ta đã tính ở phần trên
Và cách xác định feeGrowthAbove
và feeGrowthBelow
này là những gì mà UniswapV3 đã thực sự làm trong code của mình, file Tick.sol
:
Và một điều đáng chú ý ở đây là, feeGrowthInside
có tính chất giống như feeGrowthGlobal
, là một giá trị luôn không giảm.
5.4 Cơ chế snapshot và cách rút fee
Như mình đã nói ở trên, các biến dạng feeGrowth{}
đều là giá trị của lượng fee kiếm được trên một liquidity unit
, và giá trị feeGrowthInside
được trả về từ hàm getFeeGrowthInside()
luôn là một giá trị luôn tăng nên UniswapV3 đã sử dụng một cách làm rất quen thuộc là snapshop
để ghi lại giá trị của feeGrowthInside
tại thời điểm người dùng cuối cùng tương tác (gọi là snapshot), rồi so sánh với giá trị hiện tại khi cập nhật, file Position.sol
feeGrowthInside{}
trả về từgetFeeGrowthInside()
là cộng dồn toàn bộ fee (per unit liquidity) thu được trong khoảng[lowerTick, upperTick]
tính đến hiện tại.feeGrowthInsideLast{}
trong structInfo
củaPosition
là snapshot cũ mà người dùng đã ghi lại lần cuối cùng.
=> Do đó, fee mà user kiếm được sẽ là:
tokensOwed = (feeGrowthInsideNow - feeGrowthInsideLast) * position_liquidity
Điều này giống với cách mà các hệ thống chia phần thưởng dùng share tokens, ví dụ, Compound, Aave, hoặc các hệ thống dùng accPerShare như:
rewards = (accRewardPerShare - userRewardDebt) * userShares
Đây là code:
Lượng fee mà user kiếm được sẽ được ghi nhận như một khoản nợ mà Pool đang nợ user trong position đó, chứ không chuyển trực tiếp cho user.
Okay, vậy là chúng ta đã hiểu cách mà UniswapV3 ghi nhận fee mà một position kiếm được, nhưng để trigger luồng tính fee và ghi nhận được nợ thì user nên gọi hàm nào?
Đó là hàm burn()
:
User có thể gọi hàm này với amount = 0
với mục đích chỉ để trigger việc tính fee, còn nếu user muốn rút liquidiity
luôn thì truyền amount > 0
. Lượng token0
và token1
thu về từ việc burn liquidity
cũng sẽ được ghi chung và tokensOwed{0/1}
cùng với fee kiếm được.
Sau đó user gọi hàm collect
để withdraw tokensOwed{0/1}
về ví mình