Uniswap V4 지정가 주문 훅 part 1

스마트 컨트랙트 소스코드 레벨 분석

Justin Gee
Tokamak Network
45 min readNov 15, 2023

--

유니스왑 V4 로고(Source: Uniswap Labs Blog)

유니스왑 V4는 훅(hook)이란 것을 도입하여 유니스왑 풀들과의 상호작용 과정의 다양한 지점에서 커스텀 로직이 실행될 수 있도록 했다. 유니스왑 V4 공식 블로그에서는 동적 수수료, 온 체인 지정가 주문 그리고 대규모 거래를 작은 거래로 쪼개어 장기간에 걸쳐 처리되는 TWAMM(Time-weighted Automated Market Maker) 등을 훅의 예로 들고 있다. 유니스왑 랩스는 유니스왑을 단순한 DEX(Decentralized Exchange)가 아닌 맞춤형 플러그인과 프로세스를 만들 수 있는 금융 인프라로 보고 있다고 한다.

추후 타이탄 메인넷에 올라갈 TONDEX라는 프로젝트에서 유니스왑 V4의 훅을 사용하여 지정가 주문을 구현할 예정이다. 현재 유니스왑 깃허브에 드래프트 버전의 Core와 Periphery 레포지토리가 공개되어 있다. 테스트는 파운드리(Foundry) 프레임워크를 사용했다. 지정가 주문이 어떻게 온 체인으로 이루어지는지, V3와 어떻게 달라졌는지 드래프트 버전의 코드를 읽고 분석한 내용을 이 글에서 다루겠다.

*V3에 대한 이해도가 어느 정도 있어야 읽기 편하다.

영문 버전은 아래 링크에서 확인하실 수 있습니다:

목차는 다음과 같다.

  • V3에서의 지정가 주문
  • V4 컨트랙트 구성도
  • V4 훅 플로우
  • 훅 컨트랙트 설정 및 CREATE2 배포
  • 풀 생성
  • 지정가 주문 넣기

V3에서의 지정가 주문

코드를 보기 전에 유니스왑 V3에서 지정가 주문이 어떻게 이루어지는지 그림 예시를 통해서 알아보겠다.

(1) 현재 풀 상태

현재 tick이 85176이라고 가정하겠다. tick 이란 log_{sqrt(1.0001)}(sqrt(가격))과 같다. 이 그래프에서 높이는 유동성을 의미하며, current tick을 기준으로 왼쪽은 USDC 유동성으로 채워져있고, 오른쪽은 ETH 유동성으로 채워져있다.

이제 사용자가 ETH가 가격이 올라갈 것으로 예상한다. 정확히는 tick이 85680을 넘을 것이라고 예상하고 있다. 그럼 85620~85680의 tick 범위에 ETH로 유동성을 제공할 것이다. 다음 그림과 같아진다.

(2) 85620~85680 범위에 ETH로 유동성을 넣은 상태

여기서 85620은 85680–60을 통해서 구한 값이다. 왜 60을 뺐는지 알려면 tickSpacing이라는 것을 알고 있어야 한다. 현재 유니스왑 V3에서 유동성을 추가하는 페이지를 가보면 다음과 같은 Fee Tier를 볼 수 있을 것이다.

각각의 값은 해당 풀에서 거래할 때 발생하는 수수료를 나타낸다. 그리고 각 값마다 tickSpacing 이란 것이 매칭되어 고정되어 있다.

0.01% Fee Tier ⇒ 1 TickSpacing

0.05% Fee Tier ⇒ 10 TickSpacing

0.3% Fee Tier ⇒ 60 TickSpacing (예시)

1% Fee Tier ⇒ 200 TickSpacing

여기서 TickSpacing은 tick 사이의 거리를 설정하는 tick 간격이다. 이 거리가 넓을수록 가스 효율이 높아지고 정확성은 떨어진다. Tick을 tickSpacing의 배수 단위로 인덱싱하여, 해당 tick에 유동성이 있는지 없는지 검사한다. 그리고 정확성을 이유로 current tick의 값에는 적용되지 않는다.

0.05%~1% Fee Tier와 각각의 TickSpacing은 v3-core/contracts/UniswapV3Factory.sol에 하드코딩 되어 있으며, 0.01%는 유니스왑 거버넌스 투표를 통해서 결정되어 활성화되었다.

(3) 현재 tick이 85680까지 올라간 상태

위 그림을 보면 USDC를 팔고 ETH를 사 가는 스왑이 많이 일어났고, 현재 tick은 예상대로 85680에 도달했다. 그럼 아까 제공한 ETH 유동성이 USDC 유동성으로 바뀌게 되고, USDC를 돌려받을 수 있는 상태가 된다.

그럼 이때 유저(EOA)는 v3-periphery/contracts/NonfungiblePositionManganager 컨트랙트를 통해서 decreaseLiquidity와 collect 함수를 encode 해서 인자로 설정하고 Multicall 함수를 호출한다. 각각 무엇을 하는지 간단하게 설명하겠다.

v3-core/contracts/libraries/Position.sol을 보면 Info struct를 확인할 수 있다.

// info stored for each user's position
struct Info {
// the amount of liquidity owned by this position
uint128 liquidity;
// fee growth per unit of liquidity as of the last update to liquidity or fees owed
uint256 feeGrowthInside0LastX128;
uint256 feeGrowthInside1LastX128;
// the fees owed to the position owner in token0/token1
uint128 tokensOwed0;
uint128 tokensOwed1;
}

decreaseLiquidity

  • feeGrowthInsideLast와 liquidity 값을 현재 tick을 기준으로 새로 계산하여 tokensOwed 값을 업데이트한다. tokensOwed는 collectable amount이다.

collect

  • tokensOwed 만큼 claim 하는 함수이다.

하지만 사용자는 해당 풀의 tick이 변경되는 것을 계속 모니터링하다가 직접 decreaseLiquidity와 collect 함수를 호출해야 한다. 현재 스마트 컨트랙트는 다른 스마트 컨트랙트를 지켜보다가 혼자서 트랜잭션을 트리거 할 수 없는 구조이기 때문이다. 오프 체인 봇을 사용하는 경우도 있다.

하지만 이번 V4에서는 지정가 주문 훅을 사용하여 완전히 온체인에서 지정가 주문을 가능하게 만들었다.

V4 컨트랙트 구성도

유니스왑 V4는 V3와 유사하게 v4-corev4-periphery 레포지토리로 나누어 컨트랙트들을 관리한다.

전체적인 컨트랙트 구성도는 다음과 같다.

컨트랙트 구성도

V4에서는 V3와 다르게, PoolManager라는 컨트랙트가 v4-core 레포지토리에 존재하고, initialize, swap 그리고 modifyPosition 등의 로직들은 모두 v4-core/contracts/libraries/Pool.sol 라이브러리에 구현되어 있다. v4-core의 PoolManager 컨트랙트는 어떻게 보면 v3-periphery의 NonfungiblePositionManager 컨트랙트와 유사하지만, 모든 코어 로직에 사용되는 state들을 스토리지로 가지고 있다.

그리고 V4에서는 User-defined Value Types를 적극 활용하고 types 폴더에 각 타입을 정의해놨다. 예를 들어 각 토큰을 address가 아닌 Currency라는 User-defined Value Type을 정의하고 사용하고 있다.

v4-core/contracts/types/Currency.sol

type Currency is address;

using {greaterThan as >, lessThan as <, equals as ==} for Currency global;
function equals(Currency currency, Currency other) pure returns (bool) {
return Currency.unwrap(currency) == Currency.unwrap(other);
}
function greaterThan(Currency currency, Currency other) pure returns (bool) {
return Currency.unwrap(currency) > Currency.unwrap(other);
}
function lessThan(Currency currency, Currency other) pure returns (bool) {
return Currency.unwrap(currency) < Currency.unwrap(other);
}
/// @title CurrencyLibrary
/// @dev This library allows for transferring and holding native tokens and ERC20 tokens
library CurrencyLibrary {
using CurrencyLibrary for Currency;
...

이런 식으로 타입을 정의해 주면, 해당 타입에는 멤버 함수가 하나도 없다. 그래서 >, < 그리고 ==처럼 기본적인 연산자 사용이 불가능하여 위와 같이 equals, greaterThan 그리고 lessThan 함수를 구현하고, using for global 키워드를 사용하여 전역 scope에서 사용할 수 있도록 했다. 각 함수를 보면 unwrap을 통해서 Currency에서 address로 타입을 변환할 수 있고, wrap을 통해서 address에서 Currency로 타입을 변환할 수 있다.

v4-periphery를 보면 훅 컨트랙트들 밖에 없다. 이 훅 컨트랙트들은 모두 abstract 컨트랙트인 BaseHook을 상속하고 구현되어 있지 않은 함수를 오버라이드 하여 구현한다. 그리고 IPoolManager 인터페이스를 사용하여 PoolManager 컨트랙트와 상호작용한다. 아직 완성된 레포지토리가 아니며, V3처럼 positionManager 컨트랙트가 생길 수 있다.

V4 훅 플로우

훅 플로우(Source: 유니스왑 V4 백서)

위 그림은 스왑 전후로 커스텀 로직 훅을 실행하는 플로우를 보여준다. 스왑 전에 flag를 체크하여 True 면 beforeSwap 훅을 실행하고, 스왑 후에도 afterSwap flag를 체크하여 True 면 afterSwap 훅을 실행시키고 스왑을 끝내게 된다.

델타라는 값이 0이 되기만 하면 훅에서 정말 어떤 커스텀 로직이든 수행이 가능하다(뒤에서 델타가 무슨 값인지 살펴보겠다). 심지어 악의적인 훅도 개발되어 풀로 만들어질 수 있다. 참고로 V4에서는 각 풀마다 하나의 훅 컨트랙트를 가질 수 있다.

v4-core/contracts/types/PoolKey.sol의 PoolKey struct를 한번 보자.

/// @notice Returns the key for identifying a pool
struct PoolKey {
/// @notice The lower currency of the pool, sorted numerically
Currency currency0;
/// @notice The higher currency of the pool, sorted numerically
Currency currency1;
/// @notice The pool swap fee, capped at 1_000_000. The upper 4 bits determine if the hook sets any fees.
uint24 fee;
/// @notice Ticks that involve positions must be a multiple of tick spacing
int24 tickSpacing;
/// @notice The hooks of the pool
IHooks hooks;
}

V3에서는 token0, token1의 주소와 fee 이렇게 3가지 값을 가지고 풀들을 식별했다. 위에서 설명했듯이 총 4개의 Fee Tier가 있으며 TickSpacing과 각각 매칭되어 고정되어 있다. 그러므로 같은 token0과 token1을 가지고 최대 4개의 풀을 만들 수 있었다.

V4에서는 다르다. fee와 tickSpacing이 세트가 아니며, hooks라는 값이 추가되었다. 그러므로 총 5가지의 값을 가지고 풀들을 식별하며 셀 수 없을 정도로 많은 풀들이 만들어질 수 있다. 예를 들어 tickSpacing이 61로도 설정이 가능하다.

현재 smart-order-router는 다음 그림과 같이 스왑시에 최적의 경로를 찾아준다.

유니스왑 V3 스왑시 멀티 경로

하지만 V4로 업그레이드되면서 악의적인 훅을 가진 풀들도 존재할 수 있고, 무수히 많은 서로 다른 훅을 가진 풀들의 스왑 결과 값인 amountOut을 계산하기에는 너무나 비효율적일 것이다. 훅이 없는 풀들만 지원을 해주거나(그래도 너무 많다), 신뢰할 수 있는 기관에서 개발했고 Audit(감사)를 받은 훅이 있는 풀들만 지원해 주는 어떠한 정책이 생길 것으로 생각한다. 또한 UniswapX를 적극 활용할 것이라고 기대된다.

훅 컨트랙트 설정 및 CREATE2 배포

지정가 주문을 본격적으로 다루기 전에, 훅 컨트랙트에 어떤 flag를 어떻게 설정하는지 살펴보겠다.

위에서 언급했듯이 모든 훅 컨트랙트는 abstract 컨트랙트인 BaseHook을 상속하고 구현되어 있지 않은 함수를 오버라이드 하여 구현한다. 그중 하나의 함수가 getHooksCalls이다. 먼저 지정가 주문 컨트랙트에서 해당 함수를 보자.

v4-periphery/contracts/hooks/examples/LimitOrder.sol

function getHooksCalls() public pure override returns (Hooks.Calls memory) {
return Hooks.Calls({
beforeInitialize: false,
afterInitialize: true,
beforeModifyPosition: false,
afterModifyPosition: false,
beforeSwap: false,
afterSwap: true,
beforeDonate: false,
afterDonate: false
});
}

이 함수를 통해서, flag의 종류를 알 수 있다. “initialize”(풀 초기화), “modifyPosition”(포지션 업데이트), “swap” 그리고 “donate” 전후로 flag를 설정하여 커스텀 로직을 실행할 수 있다.

여기서 “donate”는 V3에는 없는 함수다. 해당 함수는 Pool 컨트랙트에 기부하여 feeGrowthGlobal(전역 수수료) 값을 올려, 해당 풀의 모든 유동성 제공자들이 혜택을 받을 수 있는 구조로 되어 있다. 이렇게 새로운 수수료 체계를 구축할 수 있다. V4에서는 무수히 많은 풀들이 생겨날 것이고, donate와 같은 함수를 활용하여 유동성 제공자들이 수수료를 많이 벌 수 있는 풀에 유동성이 자연스럽게 모일 것이다.

다시 위의 코드로 돌아와, afterInitialize와 afterSwap의 flag에만 true로 설정되어 있는 것을 확인할 수 있다. 그리고 BaseHook 컨트랙트의 생성자 함수를 보면 아래와 같이 validateHookAddress라는 함수가 있다.

v4-periphery/contracts/BaseHook.sol

constructor(IPoolManager _poolManager) {
poolManager = _poolManager;
validateHookAddress(this);
}

BaseHook 컨트랙트는 Hooks라는 라이브러리를 import 하고 있으며 validateHookAddress 함수는 Hooks 라이브러리에 구현되어 있다.

v4-core/contracts/libraries/Hooks.sol

uint256 internal constant BEFORE_INITIALIZE_FLAG = 1 << 159;
uint256 internal constant AFTER_INITIALIZE_FLAG = 1 << 158;
uint256 internal constant BEFORE_MODIFY_POSITION_FLAG = 1 << 157;
uint256 internal constant AFTER_MODIFY_POSITION_FLAG = 1 << 156;
uint256 internal constant BEFORE_SWAP_FLAG = 1 << 155;
uint256 internal constant AFTER_SWAP_FLAG = 1 << 154;
uint256 internal constant BEFORE_DONATE_FLAG = 1 << 153;
uint256 internal constant AFTER_DONATE_FLAG = 1 << 152;
...
function validateHookAddress(IHooks self, Calls memory calls) internal pure {
if (
calls.beforeInitialize != shouldCallBeforeInitialize(self)
|| calls.afterInitialize != shouldCallAfterInitialize(self)
|| calls.beforeModifyPosition != shouldCallBeforeModifyPosition(self)
|| calls.afterModifyPosition != shouldCallAfterModifyPosition(self)
|| calls.beforeSwap != shouldCallBeforeSwap(self) || calls.afterSwap != shouldCallAfterSwap(self)
|| calls.beforeDonate != shouldCallBeforeDonate(self) || calls.afterDonate != shouldCallAfterDonate(self)
) {
revert HookAddressNotValid(address(self));
}
}
...
function shouldCallBeforeInitialize(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & BEFORE_INITIALIZE_FLAG != 0;
}
function shouldCallAfterInitialize(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & AFTER_INITIALIZE_FLAG != 0;
}
function shouldCallBeforeModifyPosition(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & BEFORE_MODIFY_POSITION_FLAG != 0;
}
function shouldCallAfterModifyPosition(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & AFTER_MODIFY_POSITION_FLAG != 0;
}
function shouldCallBeforeSwap(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & BEFORE_SWAP_FLAG != 0;
}
function shouldCallAfterSwap(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & AFTER_SWAP_FLAG != 0;
}
function shouldCallBeforeDonate(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & BEFORE_DONATE_FLAG != 0;
}
function shouldCallAfterDonate(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & AFTER_DONATE_FLAG != 0;
}

위의 코드에서 shouldCall… 로 시작하는 함수는 매번 훅 flag를 체크할 때 사용되는 함수이므로 자주 보일 것이다.

자 validateHookAddress 함수의 첫 번째 인자인 self는 지정가 주문 컨트랙트의 주소이며, 두 번째 calls 인자는 위에서 살펴본 getHooksCalls 함수의 결괏값이다. getHookscalls 함수의 결괏값과 shouldCall… 로 시작하는 함수의 결괏값을 비교하여 다른 것이 하나라도 있다면 revert를 시키고 있다.

shouldCall… 로 시작하는 함수들을 보면 전부 훅 컨트랙트 주소에 & 연산을 하여, 특정 비트 자리가 1로 설정되어 있는지를 검사하고 있다. 위에서 afterInitialize와 afterSwap flag만을 설정했으므로, 주소 맨 뒤의 8비트 중에서, 1<<158 자리와 1<<154 자리만 1로 설정되어 있어야 하고 나머지는 0으로 설정되어 있어야 한다.

즉 주소를 이진수로 표현했을 때 제일 높은 8자리는, 01000100으로 설정되어 있어야 한다. 그리고 주소를 16진수로 나타냈을 때는 0x44..로 시작하는 주소가 될 것이다. 그러므로 임의의 주소로 배포하면 안 되고, CREATE2를 통해 8비트는 훅 flag에 맞는 값으로 설정해 줘야 한다.

CREATE2는 생성자 주소, 컨트랙트의 바이트코드, constructor 인자 값 그리고 salt 값을 가지고 미리 주소를 계산해 볼 수 있다. 그러므로 구현이 다 끝난 후에 생성자 주소, 바이트코드 그리고 constructor 인자 값은 고정해두고, 반복문을 돌면서 salt 값을 바꿔주어 01000100으로 시작하는 주소를 만들어 내야 한다(brute force). 마치 비트코인의 PoW처럼 말이다.

풀 생성

풀을 생성하는 과정에서 V3와 큰 차이가 있다. V3에서는 Factory 패턴을 사용하여 모든 풀들을 새로운 컨트랙트로 배포했지만 V4에서는 싱글톤 패턴을 사용한다.

V4 싱글톤 패턴(Source: Uniswap Labs Blog)

위의 그림은 ETH를 DAI로 스왑하는 과정에 경로가 여러 개일 때를 나타낸다. 왼쪽을 보면 transfer가 무려 6번이나 일어난다. 하지만 오른쪽의 싱글톤 패턴을 보면 transfer는 2번으로 끝이 난다. 이렇게 가스비를 크게 절약할 수 있다. 오른쪽 그림에서 각 토큰 페어는 컨트랙트가 아니라 v4-core/contracts/PoolManager 컨트랙트의 맵핑 변수인 pools로 관리된다. key 값은 다음과 같이 계산되어 진다.

v4-core/contracts/types/PoolId.sol

type PoolId is bytes32;
function toId(PoolKey memory poolKey) internal pure returns (PoolId) {
return PoolId.wrap(keccak256(abi.encode(poolKey)));
}

위에서 살펴본 PoolKey struct의 값들을 encode 하고 해쉬한다.

다음은 풀을 생성하는 동작에 대한 시퀀스 다이어그램이다.

풀 생성, 시퀀스 다이어그램

모든 코드를 라인 바이 라인으로 다루고 싶지만 글이 너무 길어질 수 있기 때문에, 해당 포스팅의 주제인 지정가 주문과 크게 관련 없는 부분은 생략할 것이다. PoolManager 컨트랙트의 initialize 함수를 보자.

v4-core/contracts/PoolManager.sol

function initialize(PoolKey memory key, uint160 sqrtPriceX96 ...
{
...
tick = pools[id].initialize(sqrtPriceX96, protocolSwapFee, hookSwapFee, protocolWithdrawFee, hookWithdrawFee);

if (key.hooks.shouldCallAfterInitialize()) {
if (
key.hooks.afterInitialize(msg.sender, key, sqrtPriceX96, tick)
!= IHooks.afterInitialize.selector
) {
revert Hooks.InvalidHookResponse();
}
}

emit Initialize(id, key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks);
}

v4-core/contracts/libraries/Pool.sol

function initialize(
State storage self,
uint160 sqrtPriceX96,
uint8 protocolSwapFee,
uint8 hookSwapFee,
uint8 protocolWithdrawFee,
uint8 hookWithdrawFee
) internal returns (int24 tick) {
if (self.slot0.sqrtPriceX96 != 0) revert PoolAlreadyInitialized();
tick = TickMath.getTickAtSqrtRatio(sqrtPriceX96);
self.slot0 = Slot0({
sqrtPriceX96: sqrtPriceX96,
tick: tick,
protocolSwapFee: protocolSwapFee,
hookSwapFee: hookSwapFee,
protocolWithdrawFee: protocolWithdrawFee,
hookWithdrawFee: hookWithdrawFee
});
}

먼저 Pool.sol 라이브러리에서 Slot0의 구조를 볼 수 있고, 아래 V3와는 필드들이 다른 것을 확인할 수 있다.

v3-core/contracts/UniswapV3Pool.sol

struct Slot0 {
uint160 sqrtPriceX96;
int24 tick;
uint16 observationIndex;
uint16 observationCardinality;
uint16 observationCardinalityNext;
uint8 feeProtocol;
bool unlocked;
}

V4에서는 observation 관련 변수들이 사라졌다. 즉 오라클 기능이 default가 아니다. V3에서는 오라클을 사용하지 않는 경우에도 포지션의 업데이트가 필요할 때 오라클 관련 변수들을 업데이트하느라 불필요하게 가스비가 낭비되었다. V4에서 오라클 기능이 필요하다면 훅으로 구현하여 풀을 배포하면 된다.

그리고 V3의 feeProtocol은 V4에서 protocolSwapFee와 protocolWithdrawFee로 나뉜다. 참고로 V3에서는 protocolFee가 항상 0이었고, 거버넌스 투표를 통해 바뀔 수 있다. V4에서 hookSwapFee와 hookWithdrawFee라는 변수가 추가되었다. 이는 swap이나 withdraw 시에 수수료를 매길 수 있다. 이는 훅 개발자가 이득을 취할 수 있는 부분이며, 사용자가 원하지 않으면 해당 풀에서 거래를 안 하면 된다.

tick과 sqrtPriceX96은 그대로지만 unlocked라는 변수가 V4에서는 없다. V4에서는 lock, 콜백 패턴 그리고 델타라는 값을 사용하고 뒤에서 자세히 다루겠다.

v4-core의 poolManager 컨트랙트의 initialize 함수를 보면, pool을 초기화한 후에 shouldCallAfterInitialize 함수를 통해서 flag를 체크하고 훅 컨트랙트의 afterInitialize 함수를 호출한다.

v4-periphery/contracts/hooks/examples/LimitOrder.sol

function afterInitialize(address, PoolKey calldata key, uint160, int24 tick, bytes calldata)
external
override
poolManagerOnly
returns (bytes4)
{
setTickLowerLast(key.toId(), getTickLower(tick, key.tickSpacing));
return LimitOrder.afterInitialize.selector;
}

함수는 정말 간단하다. 만약에 tickSpacing이 60이고 현재 tick이 80이라고 가정하자. 그럼 내림 연산을 하여 60이 tickLowerLast 값으로 저장된다. tickLowerLast라는 값은 뒤에서 지정가 주문이 체결될 때 사용되는 값으로, tick이 변경될 때 항상 업데이트되는 값이다.

그리고 모든 훅 함수는 마지막에 항상 selector를 반환하고 poolManager 컨트랙트는 hook의 response가 훅 함수의 selector 인지 항상 확인한다.

지정가 주문 넣기

다음은 지정가 주문 넣기 동작에 대한 시퀀스 다이어그램이다.

지정가 주문 넣기, 시퀀스 다이어그램

본격적으로 지정가 주문 넣는 것을 다루기 전에 먼저 지정가 주문 자료구조를 보겠다.

struct EpochInfo {
bool filled;
Currency currency0;
Currency currency1;
uint256 token0Total;
uint256 token1Total;
uint128 liquidityTotal;
mapping(address => uint128) liquidity;
}

mapping(bytes32 => Epoch) public epochs;
mapping(Epoch => EpochInfo) public epochInfos;
...
function getEpoch(PoolKey memory key, int24 tickLower, bool zeroForOne) public view returns (Epoch) {
return epochs[keccak256(abi.encode(key, tickLower, zeroForOne))];
}

struct 명을 보면 order나 limitOrder가 아닌 Epoch라는 단어를 쓰고 있다. Epoch이라는 단어는 블록체인에서 보통 간격이나 시간 단위의 뜻으로 사용된다. 여기서는 tickSpacing 만큼의 간격을 가지는 tick range(범위)를 뜻한다. 그래서 같은 범위에 여러 명의 사용자들이 지정가 주문을 넣을 수 있고, EpochInfo struct 안에 liquidity 매핑 변수로 각 사용자들의 유동성 값을 유지하고 있다. 그리고 같은 범위에 있는 지정가 주문들은 모두 한 번에 처리가 되는 구조다.

epochInfo의 key 값을 구하는 함수가 getEpoch이다. 여기서 zeroForOne은 방향, 즉 롱 포지션인지 숏 포지션인지를 판단하는 boolean 값이다. 이렇게 같은 동일한 키로 여러 사용자들의 지정가 주문이 있을 수 있다.

이제 지정가 주문을 넣는 place 함수를 살펴보겠다. 이 함수를 다루면서 콜백 패턴과 델타라는 값에 대해서 다룰 것이다.

v4-periphery/contracts/hooks/examples/LimitOrder.sol

function place(PoolKey calldata key, int24 tickLower, bool zeroForOne, uint128 liquidity)
external
onlyValidPools(key.hooks)
{
if (liquidity == 0) revert ZeroLiquidity();

poolManager.lock(
abi.encodeCall(this.lockAcquiredPlace, (key, tickLower, zeroForOne, int256(uint256(liquidity)), msg.sender))
);
...
}
...
function lockAcquiredPlace(
PoolKey calldata key,
int24 tickLower,
bool zeroForOne,
int256 liquidityDelta,
address owner
) external selfOnly {
BalanceDelta delta = poolManager.modifyPosition(
key,
IPoolManager.ModifyPositionParams({
tickLower: tickLower,
tickUpper: tickLower + key.tickSpacing,
liquidityDelta: liquidityDelta
}),
ZERO_BYTES
);

if (delta.amount0() > 0) {
if (delta.amount1() != 0) revert InRange();
if (!zeroForOne) revert CrossedRange();
// TODO use safeTransferFrom
IERC20Minimal(Currency.unwrap(key.currency0)).transferFrom(
owner, address(poolManager), uint256(uint128(delta.amount0()))
);
poolManager.settle(key.currency0);
} else {
if (delta.amount0() != 0) revert InRange();
if (zeroForOne) revert CrossedRange();
// TODO use safeTransferFrom
IERC20Minimal(Currency.unwrap(key.currency1)).transferFrom(
owner, address(poolManager), uint256(uint128(delta.amount1()))
);
poolManager.settle(key.currency1);
}
}

place 함수는 바로 poolManager 컨트랙트의 lock 함수를 호출하면서 lockAcquiredPlace 함수와 인자 값들을 인코딩 하여 lock 함수의 인자로 넘겨주고 있다.

참고로 abi.encodeCall은 function 포인터에 대한 호출을 인코딩한다. 결과는 흔히 사용하는 encodeWithSelector와 같지만 첫 번째 파라미터가 함수 selector가 아닌 function 포인터다.

PoolManager의 lock 함수에서 LimitOrder의 lockAcquired 함수로 콜백이 온다. 그리고 modifyPosition과 settle 함수 등을 통해서 poolManager 컨트랙트와 상호작용을 하게 된다.

V4에서 PoolManager 컨트랙트와 상호작용을 통해 state를 변경시키려면 다음과 같은 패턴을 가지게 된다.

콜백 패턴(Source: Exploring the Core Mechanism of UniswapV4)

이렇게 lock을 획득하고, poolManager와의 컨트랙트와의 상호작용을 끝내고 lock에서 벗어날 때 델타라는 값이 0이면 된다. 잠금 기간 동안 PoolManager가 콜백 컨트랙트에게 빚지거나, 콜백 컨트랙트가 PoolManager에게 빚져 갚아야 하는 값을 델타라고 한다. 즉 lock에서 벗어날 때는 서로 빚진 것이 없어야 트랜잭션이 성공한다.
PoolManager와 상호작용하는 함수들은, swap, modifyPosition, donate, take, settle 그리고 mint 함수들이 있다.

위의 lockAcquiredPlace 함수를 보면 poolManager 컨트랙트의 modifyPosition 함수는 델타라는 값을 반환한다. 참고로 V3와 다르게 V4에서는 modifyPosition이 private 함수가 아닌 external 함수로, 콜백 컨트랙트(locker)에서 바로 호출이 가능하다. 포지션 정보를 업데이트하고 서로 빚진 거를 갚기만 하면 되는 것이다.

poolManager에서 반환해 주는 델타라는 값은 다음과 같이 되어 있다.

function toBalanceDelta(int128 _amount0, int128 _amount1) pure returns (BalanceDelta balanceDelta) {
/// @solidity memory-safe-assembly
assembly {
balanceDelta :=
or(shl(128, _amount0), and(0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff, _amount1))
}
}

델타는 int256이며, 왼쪽 128비트는 amount0을 그리고 오른쪽 128비트는 amount1을 나타낸다. 그리고 amount0과 amount1함수로 각 값을 구할 수 있다.

function amount0(BalanceDelta balanceDelta) internal pure returns (int128 _amount0) {
/// @solidity memory-safe-assembly
assembly {
_amount0 := shr(128, balanceDelta)
}
}

function amount1(BalanceDelta balanceDelta) internal pure returns (int128 _amount1) {
/// @solidity memory-safe-assembly
assembly {
_amount1 := balanceDelta
}
}

여기서 amount0과 amount1은 한 토큰쌍의 각 amount다. 그리고 값이 양수이면 콜백 컨트랙트가 PoolManager에게 빚진 것이고, 음수이면 PoolManager가 콜백 컨트랙트에게 빚진 것이다.

다시 LimitOrder.sol의 lockAcquiredPlace 함수를 보면, amount0이나 amount1이 양수인지 체크하고, 양수인 경우에는 PoolManager에게 해당 토큰을 빚진 만큼 transfer 해주고 settle 함수를 호출하게 된다. modifyPosition, transfer 그리고 settle 함수를 통해서 PoolManager의 델타 값을 0에서 0으로 만들어주는 것이다. 이제 poolManager 안에서는 델타값을 어떻게 처리하는지 보겠다.

v4-core/contracts/PoolManager.sol

/// @inheritdoc IPoolManager
function lock(bytes calldata data) external override returns (bytes memory result) {
lockData.push(msg.sender);

// the caller does everything in this callback, including paying what they owe via calls to settle
result = ILockCallback(msg.sender).lockAcquired(data);
if (lockData.length == 1) {
if (lockData.nonzeroDeltaCount != 0) revert CurrencyNotSettled();
delete lockData;
} else {
lockData.pop();
}
}
...
function _accountDelta(Currency currency, int128 delta) internal {
if (delta == 0) return;

address locker = lockData.getActiveLock();
int256 current = currencyDelta[locker][currency];
int256 next = current + delta;

unchecked {
if (next == 0) {
lockData.nonzeroDeltaCount--;
} else if (current == 0) {
lockData.nonzeroDeltaCount++;
}
}

currencyDelta[locker][currency] = next;
}

이렇게 lock 함수를 통해서 lockData 변수에 콜백 컨트랙트 주소를 넣는다.

그리고 _accountDelta 함수는 modifyPosition이나 swap과 같은 함수를 통해서 currencyDelta 변수에 콜백 컨트랙트 주소와 토큰 주소를 key 값으로 하여 얼마를 빚졌는지에 대한 값을 업데이트한다. 그리고 해당 값이 0이 아니라면 nonzeroDeltaCount라는 변수를 증가시킨다. 만약에 next가 0으로 계산되어 빚진 값을 갚은 것이라면 nonzeroDeltaCount 변수를 감소시킨다.

lock 함수를 보면, 콜백 컨트랙트의 lockAcquired 함수가 끝나고 lock 함수가 끝날 때, lockData의 nonzeroDeltaCount가 0이 아니라면 revert 시키고 0이면 lock 함수는 성공하여 트랜잭션이 성공하게 된다.

다시 지정가 주문의 lockAcquiredPlace 함수로 돌아와, modifyPosition → transferFrom → settle 이렇게 3번 PoolManager와 상호작용하면서 델타 값이 변하는 과정을 정리하겠다.

(1) modifyPosition

  • 지정가 주문에서 modifyPosition은 currency0과 currency1 중에서 하나를 골라 한쪽으로 유동성을 제공하는 함수이다. 여기서는 currency0으로 유동성을 제공했다고 가정하겠다.
  • currency[<limitOrderAddress>][<currency0Address>] = +x (양수)
  • nonzeroDeltaCount는 1이 된다.

(2) transferFrom

  • currency0을 x 만큼 poolManager 컨트랙트에게 transfer

(3) settle

function settle(Currency currency) external payable override noDelegateCall onlyByLocker returns (uint256 paid) {
uint256 reservesBefore = reservesOf[currency];
reservesOf[currency] = currency.balanceOfSelf();
paid = reservesOf[currency] - reservesBefore;
// subtraction must be safe
_accountDelta(currency, -(paid.toInt128()));
}
  • 여기서 reservesOf[<currency0Address>]를 통해서 마지막으로 저장했던 값을 불러온다. 그리고 현재 poolManager의 currency0 balance 값을 불러와 업데이트한다.
  • 마지막으로 저장했던 값이랑 현재 업데이트된 값을 비교하여 차액인 x를 음수로 만들고 _accountDelta 함수에 인자로 넣어준다.
  • currency[<limitOrderAddress>][<currency0Address>] = +x -x = 0이 되고 nonzeroDeltaCount는 0이 되어 lock에서 벗어날 수 있다.

그리고 modifyPosition 함수는 delta를 계산하여 delta를 반환해 준다는 것, 그리고 여러 가지 수수료 정책을 고려하는 부분을 빼고는 V3와 큰 차이점이 없다. 쌓인 fee를 업데이트해 주는 방식과 tickBitmap을 사용하여 tick들을 초기화시켜주는 로직이 같다.

추가적으로, lockData 관련 함수들은 v4-core/contracts/libraries/LockDataLibrary.sol에 구현되어 있다.

library LockDataLibrary {
uint256 private constant OFFSET = uint256(keccak256("LockData"));

/// @dev Pushes a locker onto the end of the queue, and updates the sentinel storage slot.
function push(IPoolManager.LockData storage self, address locker) internal {
// read current value from the sentinel storage slot
uint128 length = self.length;
unchecked {
uint256 indexToWrite = OFFSET + length; // not in assembly because OFFSET is in the library scope
/// @solidity memory-safe-assembly
assembly {
// in the next storage slot, write the locker
sstore(indexToWrite, locker)
}
// update the sentinel storage slot
self.length = length + 1;
}
}
...

이 라이브러리는 큐(Queue)를 위한 커스텀 스토리지를 구현하고 있다. 마치 큐처럼 push와 pop 함수가 구현되어 있다. 그리고 uint256(keccak256(“LockData”))의 slot부터 locker의 address를 저장한다. 참고로 uint256(keccak256(“LockData”))의 값은 53391651520919458808831464411208423921832704174599079787929421271630035089670 이다.

그리고 v4-core/contracts/libraries/Pool.sol을 보면 TickInfo struct는 다음과 같다.

struct TickInfo {
uint128 liquidityGross;
int128 liquidityNet;
// fee growth per uint 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
uint256 feeGrowthOutside0x128;
uint256 feeGrowthOutside1x128;
}

여기서 liquidityGross와 liquidityNet 두 개를 합치면 256bits이므로 한 slot을 차지하게 된다. 그리고 assembly 블록에서 shift 연산을 통해서 각각의 값을 불러오고 업데이트를 시켜준다.

v4-core/contracts/libraries/Pool.sol에서 updateTick함수를 보면 알 수 있다.

function updateTick(State storage self, int24 tick, int128 liquidityDelta, bool upper)
internal
returns (bool flipped, uint128 liquidityGrossAfter)
{
TickInfo storage info = self.ticks[tick];

uint128 liquidityGrossBefore;
int128 liquidityNetBefore;
assembly {
// load first slot of info which contains liquidityGross and liquidityNet packed
// where the top 128 bits are liquidityNet and the bottom 128 bits are liquidityGross
let liquidity := sload(info.slot)
// slice off top 128 bits of liquidity (liquidityNet) to get just liquidityGross
liquidityGrossBefore := shr(128, shl(128, liquidity))
// shift right 128 bits to get just liquidityNet
liquidityNetBefore := shr(128, liquidity)
}
liquidityGrossAfter = liquidityDelta < 0
? liquidityGrossBefore - uint128(-liquidityDelta)
: liquidityGrossBefore + uint128(liquidityDelta);

flipped = (liquidityGrossAfter == 0) != (liquidityGrossBefore == 0);

if (liquidityGrossBefore == 0) {
// by convention, we assume that all growth before a tick was initialized happened _below_ the tick
if (tick <= self.slot0.tick) {
info.feeGrowthOutside0X128 = self.feeGrowthGlobal0X128;
info.feeGrowthOutside1X128 = self.feeGrowthGlobal1X128;
}
}
// when the lower (upper) tick is crossed left to right (right to left), liquidity must be added (removed)
int128 liquidityNet = upper ? liquidityNetBefore - liquidityDelta : liquidityNetBefore + liquidityDelta;
assembly {
// liquidityGrossAfter and liquidityNet are packed in the first slot of `info`
// So we can store them with a single sstore by packing them ourselves first
sstore(
info.slot,
// bitwise OR to pack liquidityGrossAfter and liquidityNet
or(
// liquidityGross is in the low bits, upper bits are already 0
liquidityGrossAfter,
// shift liquidityNet to take the upper bits and lower bits get filled with 0
shl(128, liquidityNet)
)
)
}
}

이런 식으로 storage 연산을 줄여, 가스비를 아낄 수 있다. liquidityGross와 liquidityNet은 V3와 똑같이 사용되는 값이다.

다음 포스팅에서 지정가 주문을 처리하고 withdraw, 그리고 추가적으로 다루지 않은 EIP-1153과 Foundry 환경에서 훅 컨트랙트를 테스트하는 것 등에 대해서 다루겠다.

Reference

포스팅을 작성하는 데에 도움을 주신 Aaron, Kevin, Zena 님에게 감사를 표합니다.

--

--