UniswapV4 LimitOrder Test Set Up (Draft Ver)

Justin Gee
31 min readAug 16, 2023

--

덱스를 개발할 때 지정가 주문을 구현할 필요성이 있어서, 한번 테스트해보면서 코드를 읽고 분석하려고 한다. 이번에는 Set up하는 과정 만을 다룰 것 이다. 개발환경은 Foundry다.

LimitOrder.t.sol

Contract TestLimitOrder is Test, Deployers {
using PoolIdLibrary for PoolKey;
uint160 constant SQRT_RATIO_10_1 = 250541448375047931186413801569; //-> sqrt(10) * 2^96
TestERC20 token0;
TestERC20 token1;
PoolManager manager;
LimitOrder limitOrder = LimitOrder(address(uint160(Hooks.AFTER_INITIALIZE_FLAG | Hooks.AFTER_SWAP_FLAG
PoolKey key;
PoolId id;

PoolSwapTest swapRouter;

function setUp() public {
token0 = new TestERC20(2**128); // 2**128 만큼 mint
token1 = new TestERC20(2**128); // 2**128 만큼 mint
manager = new PoolManager(500000); // 500,000이 PoolManager의 controllerGasLimit의 값으로 들어간다.
vm.record();
LimitOrderImplementation impl = new LimitOrderImplementation(manager, limitOrder);
...이어서

LimitOrder limitOrder = LimitOrder(address(uint160(Hooks.AFTER_INITIALIZE_FLAG | Hooks.AFTER_SWAP_FLAG

  • AFTER_INITIALIZE_FLAG ⇒ 1 << 158 ⇒ 2¹⁵⁸ => toHex ⇒ 4000000000000000000000000000000000000000 (0 39개)
  • AFTER_SWAP_FLAG ⇒ 1 << 154 ⇒ 2¹⁵⁴ 400000000000000000000000000000000000000 (0 38개)
  • 4000000000000000000000000000000000000000 | 400000000000000000000000000000000000000 =
    limitOrder address = 0x4400000000000000000000000000000000000000

이어서 LimitOrderImplementation 컨트랙트가 어떻게 생성되는지 보자.

LimitOrderImplementation.sol

contract LimitOrderImplementation is LimitOrder {
constructor(IPoolManager _poolManager, LimitOrder addressToEtech) LimitOrder(_poolManager) {
Hooks.validateHookAddress(addressToEtch, getHooksCalls());
}

// make this a no-op in testing
function validateHookAddress(BaseHook _this) internal pure override {}
}

LimitOrder 컨트랙트에 _poolmanager 주소를 constructor 인자로 넣어준다.

  • LimitOrder컨트랙트는 BaseHook을 상속받고 있다. BaseHook 컨트랙트의 constructor는 다음과 같다.

V4-periphery/BaseHook.sol

constructor(IPoolManager _poolManager) {
poolManager = _poolManager;
validateHookAddress(this);
}
...
// this function is virtual so that we can override it during testing, which allows us to deploy an implementation to any address and then etch the bytecode into the correct address
function validateHookAddress(BaseHook _this) internal pure virtual {
Hooks.validateHookAddress(_this, getHooksCalls());
}

여기서 validateHookAddress는 위에서 override하여 빈 함수를 실행시킨다. LimitOrderImplementation 컨트랙트를 보면 해당 함수는 아무것도 하지 않는다.

다시 LimitOrderImplementation 컨트랙트로 돌아와서 안에 addressToEtch를 인자로 주고 Hooks 라이브러리의 validateHookAddress함수를 호출 한다.

먼저 두 번째 인자로 getHookCalls() 함수를 호출한다.

LimitOrder.sol

function getHooksCalls() public pure override returns (Hooks.Calls memeory) {
return Hooks.Calls({
beforeInitialize: false,
afterInitialize: true,
beforeMoidfyPosition: false,
afterModifyPosition: false,
beforeSwap: false,
afterSwap: true,
beforeDonate: false,
afterDonate: false
  • 여기 변수들을 보았을때, pool컨트랙트의 initialize, modifyPosition, swap 그리고 donate가 일어나기 전 후에 Hook 컨트랙트의 함수가 발생할 수 있다는 것을 알 수 있다.
  • donate는 v3에서 볼 수 없는 것이다. 뭔지는 차차 알아가자.
  • 지정가 주문이 가능하려면, afterInitialize때 무엇을 설정하고, afterSwap때 지정가 주문이 이루어지는 것을 생각해 볼 수 있다.

v4-core/contracts/libraries/Hooks.sol

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

배포된 훅 주소가 제대로 되어서, 의도한 훅이 불리도록 보장하기 위한 함수.

  • 함수 인자 self = addressToEtch = 0x4400000000000000000000000000000000000000
  • shouldCall… 함수들을 살펴보면…
return uint256(uint160(address(self))) & BEFORE(or AFTER)_xxx_FLAG != 0

이렇게, 0x4400000000000000000000000000000000000000의 주소에서 각 자릿수의 &연산을 통해서 Hook이 설정되었는지 설정안되어있는지를 반환한다.

하나도 설정이 안되었다면 validateHookAddress는 revert 한다.

이제 제대로 컨트랙트가 만들어지므로 LimitOrder.t.sol으로 다시 돌아가자.

LimitOrder.t.sol

(, bytes32[] memory writes) = vm.accesses(address(impl));

이 주소에서 읽거나 썼던 슬롯을 가져올 수 있다.

vm.etch(address(limitOrder), address(impl).code);

주소 0x4400000000000000000000000000000000000000에 LimitOrderImplementation.sol 코드를 붙인다.

unchecked {
for (uint256 i = 0; i < writes.length; i ++ ) {
bytes32 slot = writes[i];
vm.store(address(limitOrder), slot, vm.load(address(impl), slot));
}
}

poolManager 주소를 write한 것을 limitOrder 주소에도 적용시켜준다. vm.load를 통해서 impl의 slot값에 해당하는 슬롯에 있는 값을 가져와 같은 슬롯에 저장하는 것 이다.

key = PoolKey(Currency.wrap(address(token0)), Currency.wrap(address(token1)), 3000, 60, limitOrder);
id = key.toId(); // PoolId.wrap(keccack256(abi.encode(poolKey)));

여기서 PoolKey는 다음과 같이 정의되어있다.

/// @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에서는, currency0, currency1 그리고 fee만으로 해당 풀의 키를 구성했다.
  • V4에서는 currency0, currency1 그리고 fee가 같더라도, tickSpacing과 hooks가 다른 여러개의 풀이 생성될 수 있다.
  • 그리고 tickSpacing을 uint24가 아닌, int24로 잡았다. (어떤 경우에 0이나 마이너스가 올 수 있다는 뜻일까?)
manager.initialize(key, SQRT_RATIO_1_1); // sqrt(1) * 2^96

v4-core/contracts/PoolManager.sol

function initialize(PoolKey memory key, uint160 sqrtPriceX96) external override returns(int24 tick) {
if (key.fee.isStaticFeeTooLarge()) revert FeeTooLarge();
...이어서
}

v4-core/contracts/libraries/FeeLibrary.sol

function isStaticFeeTooLarge(uint24 self) internal pure returns(bool) {
return self & STATIC_FEE_MASK >= 1000000;
}

먼저 key.fee 는 3000이다. binary로 표현하면 101110111000

STATIC_FEE_MASK는 uint24 public constant STATIC_FEE_MASK = 0x0FFFFF; 이다. 이것을 binary로 표현하면 0x0FFFFF ⇒ 0x000011111111111111111111

  • 101110111000 & 0x000011111111111111111111 = 0x101110111000 ⇒ 3000(decimal)
  • 즉 이 StaticFee는 TooLarge 하지 않다. Dynamic Fee가 뭔지는 다음에 알아보자.

v4-core/contracts/PoolManager.sol

...이어서
// see TickBitmap.sol for overflow conditions that can arise from tick spacing being too large
if (key.tickSpacing > MAX_TICK_SPACING) revert TickSpacingTooLarge(); // type(int16).max;
if (key.tickSpacing < MIN_TICK_SPACING) revert TickSpacingTooSmall(); // 1
if (!key.hooks.isValidHookAddress(key.fee)) revert Hooks.HookAddressNotValid(address(key.hooks));
...이어서
  • tickSpacing이 int24이고, type(int16).max보다 작은지, 1보다 큰지 검사한다. int24를 애초에 int16이나 uint뭐로 설정할 수 있었는데 왜 int24로 했는지 다음에 알아보자.
  • key.hooks.isValidHookAddress(key.fee) //3000

isValidHookAddress 함수를 보자.

/// @notice Ensures that the hook address includes at least one hook flag or dynamic fees, or is the 0 address
/// @param hook The hook to verify
function isValidHookAddress(IHooks hook, uint24 fee) internal pure returns (bool) {
//If there is no hook contract set, then fee cannot be dynamic and there cannot be a hook fee on swap or withdrawal.
return address(hook) == address(0)
? !fee.isDynamicFee() && !fee.hasHookSwapFee() && !fee.hasHookWithdrawFee() : (
uint160(address(hook)) >= AFTER_DONATE_FLAG || fee.isDynamicFee() || fee.hasHookSwapFee() || fee.hasHookWithdrawFee()
);
}

주석 내용

  • V4 버전의 pool들은 2가지의 경우를 가진다.

1 : 하나 이상의 hook이 설정되어있거나, dynamic fee를 사용한다.

2 : hook이 하나도 없다.

  • dynamic fee면 swap 이나 withdrawal에 hook fee라는 것을 적용시킬 수 있나보다.

hook은 0x4400000000000000000000000000000000000000

address(0)인 경우에는 dynamicFee, hasHookSwapFee, hasHookWithdrawFee가 전부 false 인지를 검사한다.

address(0)이 아닌경우에는, 위에 3가지 함수 중에 하나가 true이거나, hook이 AFTER_DONATE_FLAG보다 같거나 큰지 검사한다.

uint24 public constant DYNAMIC_FEE_FLAG = 0x800000; // 1000
uint24 public constant HOOK_SWAP_FEE_FLAG = 0x400000; // 0100
uint24 public constant HOOK_WITHDRAW_FEE_FLAG = 0x200000; // 0010
function isDynamicFee(uint24 self) internal pure returns(bool) {
return self & DYNAMIC_FEE_FLAG != 0;
}
function hasHookSwapFee(uint24 self) internal pure returns(bool) {
return self & HOOK_SWAP_FEE_FLAG != 0;
}
function hasHookWithdrawFee(uint24 self) internal pure returns(bool) {
return self & HOOK_WITHDRAW_FEE_FLAG != 0;
}
...

fee가 3000이었으므로 binary로 변환하면 101110111000 이다.

Dynamic_FEE_FLAG = 100000000000000000000000

HOOK_SWAP_FEE_FLAG = 010000000000000000000000

HOOK_WITHDRAW_FEE_FLAG = 001000000000000000000000

  • uint256 internal constant AFTER_DONATE_FLAG = 1 << 152;
  • 2¹⁵² to hex = 100000000000000000000000000000000000000 (0 38개)
  • 지금 테스트코드에서 hook은 0x4400000000000000000000000000000000000000 (0 37개) 이므로 hook이 더 크다, true다.

이어서 PoolManager 컨트랙트를 보자.

v4-core/contracts/PoolManager.sol

...이어서
if (key.hooks.shouldCallBeforeInitialize()) { //false
if (key.hooks.beforeInitialize(msg.sender, key, sqrtPriceX96) != IHook.beforeInitialize.selector) {
revert Hooks.InvalidHookResponse();
}
}

PoolId id = key.toId();
(uint8 protocolSwapFee, uint8 protocolWithdrawFee) = _fetchProtocolFees(key);
...이어

위에 if 문은 실행되지 않는다.

_fetchProtocolFees 함수를 보자. v4-core/contracts/Fees.sol (PoolManager가 상속하고 있음)

function _fetchProtocolFees(PoolKey memory key) internal view returns (uint8 protocolSwapFee, uint8 protocolWithdrawFee) {
if (address(protocolFeeController) != address(0)) {
//note that EIP-150 mandates that calls requesting more than 63/64ths of remaining gas will be allotted no more than this amount, so controllerGasLimit must be set with this in mind.
if (gasLeft() < controllerGasLimit) revert ProtocolFeeCannotBeFetched();
try protocolFeeController.protocolFeesForPool{gas: controllerGasLimit}(key) returns (uint8 updatedProtocolSwapFee, uint8 updatedProtocolWithdrawFee) {
protocolSwapFee = updatedProtocolSwapFee;
protocolWithdrawFee = updatedProtocolWithdrawFee;
} catch {}
_checkProtocolFee(protocolSwapFee);
_checkProtocolFee(protocolWithdrawFee);
}
}

protocolFeeController는 초기화해준적이 없다. 그러므로 address(0)이여서 함수는 실행되지 않는다.

  • setProtocolFeeController 함수를 통해서 설정할 수 있다.

주석 내용

  • EIP-150 — 요청하는 호출에 대해서는 잔여 가스의 63/64를 초과하여 할당하지 않도록 규정하고 있다. 그래서 이를 염두에 두고 controllerGasLimit을 설정해야 한다.
  • controllerGasLimit은 아까 500,000으로 설정했었다. gas에 대한 Test가 충분히 되고 설정해야 할 것 같다.

protocolFeeController의 protocolFeesForPool함수를 호출하면서 gas에 controllerGasLimit을 설정해주고 있다.

  • solidity docs를 참고해보면, Note that it is discouraged to specify gas values explicitly, since the gas costs of opcodes can change in the future.라고 되어있다. 옵코드의 가스 비용은 향후 변경될 수 있으므로, 가스 값을 명시적으로 지정하는 것은 권장하지 않고 있다.
  • 여기서 왜 가스리밋을 두는지는 다음에 알아보자.

_checkProtocolFee 함수를 보자.

function _checkProtocolFee(uint8 fee) internal pure {
if (fee != 0) {
uint8 fee0 = fee % 16;
uint8 fee1 = fee >> 4; // 나누기 16
// The fee is specified as a denominator so it cannot be LESS than the MIN_PROTOCOL_FEE_DENOMINATOR (unless it is 0);
if (
(fee0 != 0 && fee0 < MIN_PROTOCOL_FEE_DENOMINATOR || (fee1 != 0 && fee1 < MIN_PROTOCOL_FEE_DENOMINATOR)
) {
revert FeeTooLarge();
}
}
}
  • MIN_PROTOCOL_FEE_DENOMINATOR = 4 이다. uint8이므로 0,1,2,3 중에 하나다.

그리고 %16과 >>4를 통해서 각 fee를 불러오는 것을 보면 저장할 때는,
fee = fee0 + (fee1 << 4), 이렇게 저장하면 된다.

이어서 PoolManager 컨트랙트를 보자.

v4-core/contracts/PoolManager.sol

...이어서
(uint8 hookSwapFee, uint8 hookWithdrawFee) = _fetchHookFees(key);

_fetchHookFees 함수를 보자.

/// @notice There is no cap on the hook fee, but it is specified as a percentage taken on the amount after the protocol fee is applied, if there is a protocol fee.
function _fetchHookFees(PoolKey memory key) internal view returns (uint8 hookSwapFee, uint8 hookWithdrawFee) {
if (key.fee.hasHookSwapFee()) {
hookSwapFee = IHookFeeManager(address(key.hooks)).getHookSwapFee(key);
}
if (key.fee.hasHookWIthdrawFee()) {
hookWithdrawFee = IHookFeeMaanger(address(key.hooks)).getHookWithdrawFee(key);
}
}

먼저 key.fee는 uint24이다. Fees.sol에서 using FeeLibrary for uint24;을 통해서 .hasHookSwapFee 같은 함수 호출이 가능하다.

uint24 public constant HOOK_SWAP_FEE_FLAG = 0x400000; // 0100
uint24 public constant HOOK_WITHDRAW_FEE_FLAG = 0x200000; // 0010
function hasHookSwapFee(uint24 self) internal pure returns (bool) {
return self & HOOK_SWAP_FEE_FLAG != 0;
}
function hasHookWithdrawFee(uint24 self) internal pure returns (bool) {
return self & HOOK_WITHDRAW_FEE_FLAG != 0;
}
  • fee = 3000 ⇒ 101110111000
  • HOOK_SWAP_FEE_FLAG = 010000000000000000000000
  • HOOK_WITHDRAW_FEE_FLAG = 001000000000000000000000
  • 이므로 둘 다 false

v4-core/contracts/PoolManager.sol

tick = pools[id].initialize(sqrtPriceX96, protocolSwapFee, hooSwapFee, protocolWithdrawFee, hookWithdrawFee);

pools는 mapping(PoolId id => Pool.State) public pools; 이다.

  • Pool.State v4-core/contracts/libraries/Pool.sol에 구현되어 있다.
  • PoolManager의 using Pool for *; 이 라인을 통해서 initialize 함수가 호출이 가능하다.
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
});
}
  • getTickAtSqrtRatio은 V3와 같다. 0으로 계산된다.
  • 여기서 slot0이 V3와는 많이 다르다. 어떻게 다른지 한번 보자.

V3

struct Slot0 {
// the current price
uint160 sqrtPriceX96;
// the current tick
int24 tick;
// the most-recently updated index of the observations array
uint16 observationIndex;
// the current maximum number of observations that are being stored
uint16 observationCardinality;
// the next maximum number of observations to store, triggered in observations.write
uint16 observationCardinalityNext;
// the current protocol fee as a percentage of the swap fee taken on withdrawal
// represented as an integer denominator (1/x)%
uint8 feeProtocol;
// whether the pool is locked
bool unlocked;
}

V4

/// The uint8 fees variables are represented as integer denominators (1/x)
/// For swap fees, the upper 4 bits are the fee for trading 1 for 0, and the lower 4 are for 0 for 1 and are taken as a percentage of the lp swap fee.
/// For withdraw fees the upper 4 bits are the fee on amount1, and the lower 4 are for amount0 and are taken as a percentage of the principle amount of the underlying position.
/// swapFee: 1->0 | 0->1
/// withdrawFee: fee1 | fee0
struct Slot0 {
// the current price
uint160 sqrtPriceX96;
// the current tick
int24 tick;
uint8 protocolSwapFee;
uint8 protocolWithdrawFee;
uint8 hookSwapFee;
uint8 hookWithdrawFee;
}
  • 여기서 알수 있는 점은, 일단 V4에서는 오라클 기능이 default가 아니다. 그리고 protocolFee가 swapFee와 withdrawFee로 나뉜다. (V3에서는 swap과 flash에 protocolFee가 적용 되었었다. 물론 항상 protocolFee는 0이었다. 거버넌스 투표를 통해 바꿀 수 있는 것으로 알고 있다) 그리고 hook의 swap과 withdraw에도 fee가 있다. (이번 예제에서는 모두 0이다)

v4-core/contracts/PoolManager.sol

...이어서
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);
}

shouldCallAfterInitialize

function shouldCallAfterInitialize(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & AFTER_INITIALIZE_FLAG != 0;
}

0x4400000000000000000000000000000000000000 & 0x4000000000000000000000000000000000000000 ≠0이므로 true를 반환한다.

그리고 hooks의 afterInitialize(msg.sender, key, sqrtPriceX96, tick)을 인자로 넘겨주면서 함수를 호출한다.

  • msg.sender (PoolManager의 initialize 함수를 호출한 account)
  • tick : 0, sqrtPriceX96 : 79228162514264337593543950336

LimitOrder.sol

function afterInitialize(address, PoolKey calldata key, uint160, int24 tick) external override poolManagerOnly returns(bytes4) {
setTickLowerLast(key.toId(), getTickLower(tick, key.tickSpacing)); // 0
return LimitOrder.afterInitalize.selector;
}
...
//tick : 0, tickSpacing: 60
function getTickLower(int24 tick, int24 tickSpacing) private pure returns (int24) {
int24 compressed = tick / tickSpacing; // compressed = 0
if (tick < 0 && tick % tickSpacing != 0) compressed--; // round towards negative infinity
return compressed * tickSpacing; // 0 * 60 = 0
}
...
mapping(PoolId => int24) public tickLowerLasts;
...
function setTickLowerLast(PoolId poolId, int24 tickLower) private {
tickLowerLasts[poolId] = tickLower;
}
  • tickLowerLasts 라는 변수에 지금 initialize하고 있는 tick으로 초기화 해준다. 이때 그냥 tick이 아닌 tickSpacing으로 나눈 값이 저장된다. tickLower라고 하는 이유는 아마tickSpacing으로 나누면서 양수일 때는 나머지 값을 버리고, 음수일 때 compressed--;을 통해서 더 값이 적은 값으로 설정되기 때문인 것 같다. 다음에 다시 알아보자. 그리고 이 값이 나중에 AfterSwap 훅에서 사용되는 값이다.
  • LimitOrder.afterInitialize.selector를 반환해준다. 그리고 그 값이 PoolManager의 IHooks.afterInitialize.selector 값과 같은 값인지 비교한다.

LimitOrder.t.sol 마무리

...이어서
swapRouter = new PoolSwapTest(manager);

token0.approve(address(limitOrder), type(uint256).max);
token0.approve(address(limitOrder), type(uint256).max);
token0.approve(address(swapRouter), type(uint256).max);
token0.approve(address(swapRouter), type(uint256).max);

이렇게 테스트를 위해 PoolManager를 intialize하는 등의 set up 과정이 끝났다. 다음 편에 이어서 다루겠다.

--

--