Uniswap V4 지정가 주문 훅 part 2

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

Justin Gee
Tokamak Network
57 min readNov 15, 2023

--

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

첫 번째 포스팅에서 V3에서의 지정가 주문, V4 컨트랙트 구성도, V4 훅 플로우, 훅 컨트랙트 설정 및 CREATE2배포, 풀 생성 그리고 지정가 주문 넣기에 대해서 다뤘다.

이어서 지정가 주문을 처리하고 withdraw, 그리고 아직 다루지 않은 부분들을 이번 포스팅에서 다루겠다.

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

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

목차는 다음과 같다.

  • Swap & After Swap
  • Withdraw
  • Foundry 환경에서의 훅 컨트랙트 테스트 셋업
  • 지정가 주문 테스트
  • EIP-1153 : Transient Storage

Swap & After Swap

다음은 Swap & AfterSwap 동작에 대한 시퀀스 다이어그램이다.

Swap & After Swap, 시퀀스 다이어그램

이제 swap을 통해서 tick이 바뀌고, afterSwap 훅을 통해서 지정가 주문을 처리하는 부분을 다룰 것이다.

먼저 swap은 swap 전후에 훅 flag를 검사하는 것 외에 v3와 크게 달라진 부분이 없이 slot0을 업데이트 시켜준다. 물론 lock을 획득하고 delta 값을 0으로 만들어 주고 lock에서 벗어나게 된다.

v4-core/contracts/test/PoolSwapTest.sol에서 lockAcquired 함수를 볼 수 있다.

function lockAcquired(bytes calldata rawData) external returns (bytes memory) {
require(msg.sender == address(manager));
CallbackData memory data = abi.decode(rawData, (CallbackData));

BalanceDelta delta = manager.swap(data.key, data.params, new bytes(0));
if (data.params.zeroForOne) {
if (delta.amount0() > 0) {
if (data.testSettings.settleUsingTransfer) {
if (data.key.currency0.isNative()) {
manager.settle{value: uint128(delta.amount0())}(data.key.currency0);
} else {
IERC20Minimal(Currency.unwrap(data.key.currency0)).transferFrom(
data.sender, address(manager), uint128(delta.amount0())
);
manager.settle(data.key.currency0);
}
} else {
// the received hook on this transfer will burn the tokens
manager.safeTransferFrom(
data.sender,
address(manager),
uint256(uint160(Currency.unwrap(data.key.currency0))),
uint128(delta.amount0()),
""
);
}
}
if (delta.amount1() < 0) {
if (data.testSettings.withdrawTokens) {
manager.take(data.key.currency1, data.sender, uint128(-delta.amount1()));
} else {
manager.mint(data.key.currency1, data.sender, uint128(-delta.amount1()));
}
}
} else {
...생략
}

poolManager의 swap 함수 역시 delta를 반환해 준다. 그리고 tokenIn의 델타 값은 poolManager 컨트랙트에서 양수가 될 것이고, 유동성을 제공할 때처럼 transferFrom과 settle을 통해서 delta 값을 0으로 만들어준다. tokenOut의 경우 델타값이 poolManager 컨트랙트에서 음수이고, take라는 함수를 통해서 delta 값을 0으로 만들어주게 된다.

transferFrom과 settle은 유동성을 제공할 때 다뤘으므로, take 함수를 한번 보겠다.

v4-core/contracts/PoolManager.sol

function take(Currency currency, address to, uint256 amount) external override noDelegateCall onlyByLocker {
_accountDelta(currency, amount.toInt128());
reservesOf[currency] -= amount;
currency.transfer(to, amount);
}

매우 간단하다. _accountDelta 함수를 통해서 델타값에 amount만큼을 더해 음수에서 0으로 만들어주고, reservesOf 변수를 업데이트해 주고 transfer 해준다.

그리고 PoolManager 컨트랙트의 swap 함수에는 다음과 같이 afterSwap flag를 체크하고 limitOrder 컨트랙트의 afterSwap 함수를 실행하게 된다.

v4-core/contracts/PoolManager.sol

function swap(
PoolKey memory key,
IPoolManager.SwapParams memory params,
bytes calldata hookData
) external override noDelegateCall onlyByLocker returns
...
if (key.hooks.shouldCallAfterSwap()) {
if (key.hooks.afterSwap(msg.sender, key, params, delta, hookData) != IHooks.afterSwap.selector) {
revert Hooks.InvalidHookResponse();
}
}

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

function afterSwap(
address,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
BalanceDelta,
bytes calldata
) external override poolManagerOnly returns (bytes4) {
(int24 tickLower, int24 lower, int24 upper) = _getCrossedTicks(key.toId(), key.tickSpacing);
if (lower > upper) return LimitOrder.afterSwap.selector;

// note that a zeroForOne swap means that the pool is actually gaining token0, so limit
// order fills are the opposite of swap fills, hence the inversion below
bool zeroForOne = !params.zeroForOne;
for (; lower <= upper; lower += key.tickSpacing) {
_fillEpoch(key, lower, zeroForOne);
}

setTickLowerLast(key.toId(), tickLower);
return LimitOrder.afterSwap.selector;
}

먼저 poolManagerOnly 모디파이어를 통해서, msg.sender가 poolManager 인지 검사한다.

afterSwap 함수를 보면, 이렇게 swap을 하는 사용자가 for 문을 돌면서 _fillEpoch 함수를 통해서 모든 지정가 주문을 처리하고 있다. for 문에서는 tickLowerLast에서부터, swap을 통해서 바뀐 새로운 tick까지 tickSpacing 만큼 더해가면서 for 문을 돌고 있다. 그만큼 swap 할 때 가스비가 더 든다는 것이다.

*초반에 tickSpacing과 fee 티어를 자유롭게 세팅할 수 있다고 언급했었다. tickSpacing을 어느 정도 큰 값으로 하면 for 문을 적게 돌 수 있으나, 지정가 주문의 tick 범위가 넓어져 UX가 안 좋아질 수 있다. fee를 낮게 책정한다면 그만큼 order router나 유니스왑X에서 이 풀이 경로에 잡혀 지정가 주문들이 빨리 처리될 수 있고, 높게 책정한다면 지정가 주문이 처리되는 유저들은 fee를 더해서 받으므로 결국 더 좋은 가격에 채결될 수 있다. 여러 가지 사항들을 고려해서 적당한 tickSpacing과 fee를 책정하는 것이 중요할 것이다.

v4-periphery/contracts/hooks/examples/LimitOrder.sol의 _fillEpoch함수를 보자.

function _fillEpoch(PoolKey calldata key, int24 lower, bool zeroForOne) internal {
Epoch epoch = getEpoch(key, lower, zeroForOne);
if (!epoch.equals(EPOCH_DEFAULT)) {
EpochInfo storage epochInfo = epochInfos[epoch];

epochInfo.filled = true;

(uint256 amount0, uint256 amount1) = abi.decode(
poolManager.lock(
abi.encodeCall(this.lockAcquiredFill, (key, lower, -int256(uint256(epochInfo.liquidityTotal))))
),
(uint256, uint256)
);
unchecked {
epochInfo.token0Total += amount0;
epochInfo.token1Total += amount1;
}
setEpoch(key, lower, zeroForOne, EPOCH_DEFAULT);
emit Fill(epoch, key, lower, zeroForOne);
}
}

위 함수를 보면 poolManager와 상호작용하기 위해 lock 함수에 encoding 한 lockAcquiredFill 함수를 인자로 넘기면서 호출하고 있다. 그럼 lockAcquiredFill 함수를 보자.

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

function lockAcquiredFill(PoolKey calldata key, int24 tickLower, int256 liquidityDelta)
external
selfOnly
returns (uint128 amount0, uint128 amount1)
{
BalanceDelta delta = poolManager.modifyPosition(
key,
IPoolManager.ModifyPositionParams({
tickLower: tickLower,
tickUpper: tickLower + key.tickSpacing,
liquidityDelta: liquidityDelta
}),
ZERO_BYTES
);
if (delta.amount0() < 0) poolManager.mint(key.currency0, address(this), amount0 = uint128(-delta.amount0()));
if (delta.amount1() < 0) poolManager.mint(key.currency1, address(this), amount1 = uint128(-delta.amount1()));
}

lockAcquiredFill 함수는 지정가 주문을 처리하는 함수이므로, 유저들이 넣은 유동성들을 풀에서 제거하게 된다. 이는 전편에서 다뤘던 “V3에서의 지정가 주문”의 decreaseLiquidity 함수와 유사하다. 즉 withdraw는 하지 않고 modifyPosition을 하게 된다.

그럼 poolManager 컨트랙트에서 유동성을 제거하는 token의 델타값은 음수가 되어있을 것이다. 이제 take 함수를 통해서 token을 돌려받고 델타값을 0으로 만들어주면 될까? 여기서는 그렇고 있지 않다. 즉 전편에서 다뤘던 “V3에서의 지정가 주문”의 collect를 수행하지 않는 것이다.

어떻게 lock에서 벗어날까? V4에서는 V3에서는 없었던 ERC1155를 활용한다. 아래 if 문을 보면 delta 값이 음수 면 자기 자신인 limitOrder 컨트랙트 주소에 해당 amount 만큼 ERC1155토큰을 민트 하게 된다.

v4-core/contracts/PoolManager.sol의 mint 함수를 보자.

/// @inheritdoc IPoolManager
function mint(Currency currency, address to, uint256 amount) external override noDelegateCall onlyByLocker {
_accountDelta(currency, amount.toInt128());
_mint(to, currency.toId(), amount, '');
}

이렇게 _accountDelta 함수를 통해서 amount만큼을 델타값에 더해서 델타를 0으로 만든다. 그리고 ERC1155를 mint 해주고 있다. ERC1155는 위와 같이 토큰 주소를 id 값으로 하여 같은 토큰을 여러 개 mint 할 수 있다. ERC721과는 다르게, 같은 id를 갖는 ERC1155 토큰끼리는 fungible 하다는 특성을 가지고 있다.

이렇게 토큰의 transfer 없이, poolManager가 훅 컨트랙트나 유저에게 빚진 값을 훅 컨트랙트나 유저가 바로 take 하지 않아도, ERC1155 토큰을 민트 하여 가지고 있음으로써 나중에 withdraw 할 수 있도록 보장을 받는 것이다. 토큰의 transfer 없이 poolManager와 유저 간 회계를 처리할 수 있다고 해서 ERC1155 Accounting이라고 유니스왑 V4 백서에도 명시되어 있다.

그리고 v4-periphery/contracts/hooks/examples/LimitOrder.sol에는 onERC1155Received 함수가 구현되어 있다.

function onERC1155Received(address, address, uint256, uint256, bytes calldata) external view returns (bytes4) {
if (msg.sender != address(poolManager)) revert NotPoolManagerToken();
return IERC1155Receiver.onERC1155Received.selector;
}

이렇게 EOA가 아닌 Contract는 ERC1155 토큰을 받기 위해서 onERC1155Received를 필수적으로 구현하고 있어야 한다. 표준에 의해서, onERC1155Received를 구현하지 않은 컨트랙트는 ERCC1155 토큰을 받을 수 없다.

그리고 위의 onERC1155Received 함수를 보면 msg.sender가 poolManager 인지 체크하는 과정을 통해서 악의적으로 ERC1155토큰을 다른 곳에서 구해서 poolManager의 토큰을 withdraw 할 수 없게 막고 있다.

Withdraw

다음은 Withdraw 동작에 대한 시퀀스 다이어그램이다.

Withdraw, 시퀀스 다이어그램

v4-periphery/contracts/hooks/examples/LimitOrder.sol의 withdraw 함수를 보자.

function withdraw(Epoch epoch, address to) external returns (uint256 amount0, uint256 amount1) {
EpochInfo storage epochInfo = epochInfos[epoch];
if (!epochInfo.filled) revert NotFilled();

uint128 liquidity = epochInfo.liquidity[msg.sender];
if (liquidity == 0) revert ZeroLiquidity();
delete epochInfo.liquidity[msg.sender];

uint256 token0Total = epochInfo.token0Total;
uint256 token1Total = epochInfo.token1Total;
uint128 liquidityTotal = epochInfo.liquidityTotal;

amount0 = FullMath.mulDiv(token0Total, liquidity, liquidityTotal);
amount1 = FullMath.mulDiv(token1Total, liquidity, liquidityTotal);

epochInfo.token0Total = token0Total - amount0;
epochInfo.token1Total = token1Total - amount1;
epochInfo.liquidityTotal = liquidityTotal - liquidity;
poolManager.lock(
abi.encodeCall(this.lockAcquiredWithdraw, (epochInfo.currency0, epochInfo.currency1, amount0, amount1, to))
);

emit Withdraw(msg.sender, epoch, liquidity);
}

위 함수를 보면 withdraw 하는 유저의 liquidity 값을 불러오고, 해당 liquidity를 liquidityTotal로 나누고 tokenTotal을 곱해서 비율에 맞는 만큼을 withdraw 하게 된다. 그리고 epochInfo 변수를 업데이트해 준다.

어느 때와 마찬가지로, poolManager 컨트랙트의 lock 함수를 호출하고, lockAcquiredWithdraw 함수를 통해서 poolManager 컨트랙트와 상호작용하게 된다.

v4-periphery/contracts/hooks/examples/LimitOrder.sol의 lockAcquiredWithdraw 함수를 보자.

function lockAcquiredWithdraw(
Currency currency0,
Currency currency1,
uint256 token0Amount,
uint256 token1Amount,
address to
) external selfOnly {
if (token0Amount > 0) {
poolManager.safeTransferFrom(
address(this), address(poolManager), uint256(uint160(Currency.unwrap(currency0))), token0Amount, ""
);
poolManager.take(currency0, to, token0Amount);
}
if (token1Amount > 0) {
poolManager.safeTransferFrom(
address(this), address(poolManager), uint256(uint160(Currency.unwrap(currency1))), token1Amount, ""
);
poolManager.take(currency1, to, token1Amount);
}
}

여기서 withdraw할 tokenAmount 만큼의 ERC1155토큰을 poolManager 컨트랙트에게 transfer 하고 take 함수를 호출한다.

swap에서 take 함수를 다뤘으니 기억할 것이다. poolManager에서 해당 token의 델타값을 음수로 만들어줘야 take 함수를 호출하여 withdraw 할 수 있다. ERC1155토큰을 poolManager 컨트랙트에게 transfer 하는 과정에서 해당 token의 델타값을 음수로 만들 수 있다. PoolManager도 컨트랙트이기 때문에 onERC1155Received를 구현해놨다. 해당 함수를 보자.

v4-core/contracts/PoolManager.sol

function onERC1155Received(address, address, uint256 id, uint256 value, bytes calldata) external returns (bytes4) {
if (msg.sender != address(this)) revert NotPoolManagerToken();
_burnAndAccount(CurrencyLibrary.fromId(id), value);
return IERC1155Receiver.onERC1155Received.selector;
}

function _burnAndAccount(Currency currency, uint256 amount) internal {
_burn(address(this), currency.toId(), amount);
_accountDelta(currency, -(amount.toInt128()));
}

PoolManager는 받은 ERC1155를 burn 시키고, _accountDelta 함수를 통해서 해당 토큰의 델타값을 음수로 만들고 nonzeroDeltaCount 1을 증가시킨다. 그리고 swap에서 다뤘던 take 함수를 통해서 withdraw 하고, 델타값과 nonzeroDeltaCount을 0으로 만들어줌으로써 lock에서 벗어나게 된다.

이렇게 UniswapV4의 전반적인 것을 분석했고 특히 지정가 주문에 대해서 다뤄봤다. 이제 실 데이터를 기반으로 테스트를 진행한다.

Foundry 환경에서의 훅 컨트랙트 테스트 셋업

먼저 유니스왑 V4 코드는 파운드리 프레임워크 환경에서 개발되었으며 테스트 코드 예시가 공개되어 있다. 먼저 훅 컨트랙트를 개발하기 위해 개발 환경을 셋팅하는 것을 다루겠다. 시작하기 전에 파운드리를 먼저 다운로드 받아야 한다. 다운로드는 이 Foundry Docs 가이드를 따라서 진행하면 된다.

그리고 디렉토리를 만들고 다음 명령어를 통해서 파운드리 환경을 세팅해 준다.

forge init .

만약에 다음과 같은 에러가 난다면…

Error: 
Cannot run `init` on a non-empty directory.

명령어 뒤에 — force를 붙여주자.

명령어 실행 후 폴더와 파일들을 살펴보면, src 폴더에 Counter.sol 파일과 test 폴더의 간단한 테스트 코드를 확인할 수 있다. 그리고 forge-std라는 라이브러리가 함께 다운로드 된다.

BaseHook 컨트랙트가 있는 v4-periphery 패키지를 다운로드하겠다.

forge install https://github.com/Uniswap/periphery-next

이 명령어는 v4-periphery의 깃허브 레포지토리를 lib 폴더로 클론 한다. 그리고 해당 컨트랙트를 가져다 쓰면 된다. 파운드리 환경에서는 컨트랙트 파일을 node_modules가 아닌 lib 폴더에서 import를 한다. BaseHook 컨트랙트를 import 해보자.

import {BaseHook} from "../lib/periphery-next/contracts/BaseHook.sol";

앞에 붙은 ../lib 와 /contracts를 생략하기 위해서 파운드리 환경에서는 remappings라는 것을 세팅해 준다. 다음 명령어를 실행해 보자.

forge remappings

결과

@ensdomains/=lib/periphery-next/lib/v4-core/node_modules/@ensdomains/
@openzeppelin/=lib/periphery-next/lib/openzeppelin-contracts/
@uniswap/v4-core/=lib/periphery-next/lib/v4-core/
ds-test/=lib/forge-std/lib/ds-test/src/
erc4626-tests/=lib/periphery-next/lib/openzeppelin-contracts/lib/erc4626-tests/
forge-gas-snapshot/=lib/periphery-next/lib/forge-gas-snapshot/src/
forge-std/=lib/forge-std/src/
hardhat/=lib/periphery-next/lib/v4-core/node_modules/hardhat/
openzeppelin-contracts/=lib/periphery-next/lib/openzeppelin-contracts/
openzeppelin/=lib/periphery-next/lib/openzeppelin-contracts/contracts/
periphery-next/=lib/periphery-next/contracts/
solmate/=lib/periphery-next/lib/solmate/src/
v4-core/=lib/periphery-next/lib/v4-core/contracts/

이렇게 파운드리는 lib 폴더를 참고해서 자동으로 remappings 들을 출력해 준다. 마지막에서 3번째 줄이 periphery-next는 lib/periphery-next/contracts와 같다는 내용이다. 루트 폴더에 remappings.txt 파일을 생성하고 콘솔 결과 내용을 붙여 넣는다. 그럼 다음과 같이 import 문을 수정할 수 있다.

import {BaseHook} from "periphery-next/BaseHook.sol";

이 환경에서 훅 컨트랙트를 개발하고, 테스트 코드를 작성하여 테스트해 볼 수 있다. 지정가 주문 훅 컨트랙트는 v4-periphery/hooks/examples/LimitOrder.sol을 참고하고 테스트 코드는 v4-periphery/test/LimitOrder.t.sol을 참고하면 된다.

Mocha 테스트 환경에서의 beforeEach가 파운드리에서는 setUp이다. setUp 함수는 각 테스트 케이스가 실행되기 전에 호출되는 함수다. setUp 코드는 다음과 같다.

function setUp() public {
initializeTokens();
token0 = TestERC20(Currency.unwrap(currency0));
token1 = TestERC20(Currency.unwrap(currency1));
manager = new PoolManager(500000);
...
}

먼저 PoolManager 컨트랙트를 생성할 때, 500000을 인자로 넘겨준다. 해당 값은 PoolManager가 상속하고 있는 Fees.sol의 controllerGasLimit라는 변수에 할당되어 초기화된다. controllerGasLimit은 Fees.sol의 _fetchPorotoclFees 함수에서 사용된다.

v4-core/contracts/Fees.sol

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 {
console.log('what!?');
}

_checkProtocolFee(protocolSwapFee);
_checkProtocolFee(protocolWithdrawFee);
}
}

먼저 protocolFeeController가 세팅되어 있는지를 체크한다. 오직 owner만 protocolFeeController를 어떤 주소로 세팅할 수 있으며, 거버넌스 투표를 통해서 정해질 것이다. 그리고 protocolSwapFee와 protocolWithdrawFee를 반환해 주는 protocolFeeController의 protocolFeesForPool 함수를 호출할 때, 가스를 controllerGasLimit으로 명시하여 그만큼 이상의 gas를 그 함수에서 못쓰게 만들고 있다.

EIP-150에 의해서 잔여 가스(gasLeft())의 63/64를 초과하여 할당하지 않도록 되어 있으므로, 이를 염두에 두고 controllerGasLimit을 설정해야 한다. 솔리디티 공식 문서에서 opcode의 가스 비용은 향후 변경될 수 있으므로 가스 값을 명시적으로 지정하는 것은 권장하고 있지 않지만, 이렇게 구현되어 있고 심지어 controllerGasLimit을 바꿀 수 있는 onlyOwner 함수도 찾아볼 수 없다. 만약에 opcode의 가스 비용이 향후 변경되어 더 이상 protocolFeeController의 protocolFeesForPool 함수를 호출할 수 없다면, protocolFeeController를 새로 세팅해 줘야 할 것이다.

v4-core/contracts/test/ProtocolFeeControllerTest.sol에서 protocolFeesForPool 함수 예시를 찾아볼 수 있다.

function protocolFeesForPool(PoolKey memory key) external view returns (uint8, uint8) {
return (swapFeeForPool[key.toId()], withdrawFeeForPool[key.toId()]);
}

이 함수는 가스가 500000이면 충분해 보이며, 현재 v4-periphery에서 모든 테스트 코드 예제에서 controllerGasLimit을 500000으로 세팅하고 있으므로, 이 값을 사용하겠다.

_fetchPorotoclFees 함수는 PoolManager 컨트랙트의 initialize 함수와 setProtocolFees 함수에서 사용된다. Protocol fee가 정해지기 전에 initialize된 pool들은 setProtocolFees 함수를 통해서 protocol fee를 세팅해 줄 수 있다.

v4-core/contracts/PoolManager.sol

function setProtocolFees(PoolKey memory key) external {
(uint8 newProtocolSwapFee, uint8 newProtocolWithdrawFee) = _fetchProtocolFees(key);
PoolId id = key.toId();
pools[id].setProtocolFees(newProtocolSwapFee, newProtocolWithdrawFee);
emit ProtocolFeeUpdated(id, newProtocolSwapFee, newProtocolWithdrawFee);
}

Pool들의 개수가 워낙 많아질 것이다 보니, 이렇게 아무나 가스비를 희생해서 실행할 수 있게끔 만들어 놨다.

setUp 함수를 이어서 완성해나가겠다.

전 편의 “훅 컨트랙트 설정 및 CREATE2 배포”에서 CREATE2를 사용하여 훅 컨트랙트의 앞 8비트를 특정 비트로 설정해야 훅 컨트랙트를 배포할 수 있다고 언급했다. 이렇게 하지 않고 Foundry 환경에서 테스트할 수 있는 방법이 있다.

먼저 LimitOrder 컨트랙트는 afterInitialize와 afterSwap의 true flag를 가진다.

AFTER_INITIALIZE_FLAG

⇒ 1 << 158 ⇒ 2¹⁵⁸

⇒ to hex ⇒ 4000000000000000000000000000000000000000 (0 39개)

AFTER_SWAP_FLAG

⇒ 1<<154 ⇒ 2¹⁵⁴

⇒ to hex ⇒ 400000000000000000000000000000000000000 (0 38개)

그러므로 LimitOrder 컨트랙트의 주소는 0x44로 시작하여야 하며, 테스트에서는 0x4400000000000000000000000000000000000000로 설정해도 아무 문제가 없다.

그래서 다음과 같이 테스트코드에서 LimitOrder 변수를 생성해준다.

LimitOrder limitOrder = LimitOrder(address(0x4400000000000000000000000000000000000000));

위에서는 new 키워드가 없으므로, 컨트랙트가 아니며 그냥 LimitOrder 컨트랙트 타입의 변수다.

그리고 validateHookAddress 함수를 override 하여 빈 함수로 만들고 LimitOrder 컨트랙트를 상속하고 있는 LimitOrderImplementation이라는 컨트랙트를 생성해 준다. 다음과 같이 말이다.

test/shared/implementation/LimitOrderImplementation.sol

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

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

이렇게 하는 이유는 기존의 LimitOrder 컨트랙트의 valdiateHookAddress 함수를, 오직 테스트 만을 위해 컨트랙트를 수정하지 않기 위함이다.

그리고 setUp 함수에서 다음과 같이 limitOrder 컨트랙트를 설정해 줄 수 있다.

function setUp() public {
...
vm.record();
LimitOrderImplementation impl = new LimitOrderImplementation(manager, limitOrder);
(, bytes32[] memory writes) = vm.accesses(address(impl));
vm.etch(address(limitOrder), address(impl).code);
// for each storage key that was written during the hook implementation, copy the value over
unchecked {
for (uint256 i = 0; i < writes.length; i++) {
bytes32 slot = writes[i];
vm.store(address(limitOrder), slot, vm.load(address(impl), slot));
}
}
...

vm.record

  • 모든 스토리지 읽기 및 쓰기 기록을 시작하도록 지시하는 명령어다.
  • LimitOrderImplementation impl = new LimitOrderImplementation(manager, limitOrder), 이 라인에서 스토리지 쓰기를 진행한다.

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

  • writes 배열에, LimitOrderImplementation에서 쓰기를 한 모든 스토리지 슬롯을 가져올 수 있다.

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

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

for 문

  • LimitOrderImplementation를 배포하면서 스토리지 쓰기를 한(eg. poolManager 주소) 것을 limitOrder 주소에도 적용시켜준다. vm.load를 통해서 impl의 slot 값에 해당하는 슬롯에 있는 값을 가져와 같은 슬롯에 저장하는 것이다.

이렇게 Foundry 환경에서는 LimitOrder 컨트랙트를 생성하지 않고도, 어떤 주소에 코드를 붙이고 slot을 조작할 수 있다.

setUp 함수를 계속 보자.

function setUp() public {
...
key = PoolKey(currency0, currency1, 3000, 60, limitOrder);
id = key.toId();
manager.initialize(key, SQRT_RATIO_1_1, ZERO_BYTES);
...

Fee는 3000, tickSpacing은 60 그리고 훅은 LimitOrder 컨트랙트로 하는 key 값을 선언한다. 그리고 PoolManager 컨트랙트의 initialize 함수를 통해 해당 풀을 배포할 것이다.

풀을 배포할 때 인자로는 key 값과, SQRT_RATIO_1_1 그리고 ZERO_BYTES를 인자로 넘겨준다.

SQRT_RATIO_1_1은 79228162514264337593543950336이다.

  • 유니스왑에서 tick은 유한한 정수이며 양수 및 음수가 될 수 있다. 가격을 저장할 때 정수 부분에는 64비트를, 분수 부분에는 96비트를 사용하는 유리수인 Q64.96이라는 고정 소수점 숫자로 sqrt(가격)을 저장한다.
  • 예를 들어 token x의 reserve가 3 그리고 token y의 reserve가 5라고 가정하면, x의 가격을 sqrtPriceX96의 형태로 변환하는 과정은 다음과 같다.

=> sqrt(5/3)*2⁹⁶

테스트케이스에서는 최대한 간단하게 하기 위해 reserve가 1:1라고 가정하고, sqrt(1/1)*2⁹⁶을 가격으로 세팅할 것이다. 그리고 마지막 인자 값은 훅 flag를 체크하고, 콜백 함수로 콜백이 올 때 넘겨주는 값으로, 이 테스트케이스에서는 필요 없으므로 0으로 세팅한다.

PoolManager의 initialize는 전 편의 “풀 생성”에서 다뤘으므로 참고 바란다. PoolManager의 initialize 함수 호출 후 결과는 다음과 같다.

slot0 = Slot0({

sqrtPriceX96: 79228162514264337593543950336,

tick: 0,

protocolSwapFee: 0,

hookSwapFee: 0,

protocolWithdrawFee: 0,

hookWithdrawFee: 0

});

여기서 tick은 다음 식을 통해 구할 수 있다. tick = log(sqrtPriceX96 / 2⁹⁶)

=> log(79228162514264337593543950336 / 2⁹⁶) ⇒ 0

setUp 함수는 다음과 같이 마무리 된다.

function setUp() public {
...
swapRouter = new PoolSwapTest(manager);

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

SwapRouter는 현재 v4-core/contracts/test/PoolSwapTest.sol을 사용하면 된다. 지정가 주문을 넣기 위해서 미리 limitOrder와 swapRouter 컨트랙트를 approve 해주는 것으로 테스트 setUp은 끝난다.

지정가 주문 테스트

테스트 환경 setUp을 끝내고 이제 지정가 주문 테스트를 진행하겠다. 테스트 시나리오는 최대한 간단하게 했으며 v4-periphery/test/LimitOrder.t.sol을 참고했다. 지정가주문 ⇒ 스왑 ⇒ 출금 이렇게 테스트를 진행하겠다.

각각 시나리오의 코드는 자세히 다뤘으므로 여기서는 실 데이터를 기반으로 state가 어떻게 변하는지만 다룰 것이다. 참고로 poolManager에서는 각 풀마다 State struct로 state들을 관리한다.

v4-core/contracts/libraries/Pool.sol

struct State {
Slot0 slot0;
uint256 feeGrowthGlobal0X128;
uint256 feeGrowthGlobal1X128;
uint128 liquidity;
mapping(int24 => TickInfo) ticks;
mapping(int16 => uint256) tickBitmap;
mapping(bytes32 => Position.Info) positions;
}

지정가 주문

지정가 주문, 이 그림은 중간 과정을 완전히 생략한 요약본이니 아래 내용을 참고해 주시길 바랍니다
function testLimitOrder() public {
int24 tickLower = 0;
bool zeroForOne = true;
uint128 liquidity = 1000000;
limitOrder.place(key, tickLower, zeroForOne, liquidity);
...

(modifyPoistion ⇒ transferFrom ⇒ settle)

(1) modifyPoistion

— PoolManager —

tickLower가 0이고, zeroForOne이 true라면 tick 범위는 0~0+tickSpacing이 된다. 0~60 tick 범위에 1000000 만큼의 유동성을 제공할 것이다. 현재 tick이 0이므로, token0으로만 유동성을 제공하게 된다.

feeGrowthGlobal0x128 = 0
feeGrowthGlobal1x128 = 0
liquidity = 1000000

updateTick

  • ticks[0]: { liquidityGross: 1000000, liquidityNet:1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }
  • ticks[60]: { liquidityGross: 1000000, liquidityNet: -1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }

tickBitmap.flipTick

  • tickBitmap[0] = …00011 (2진수, 앞에 0 254개)
  • 여기서 [인덱스]는 word로 256비트 단위이고, 한 word 안에서 각 자릿수는 bitPos라고 한다. bitPos는 0부터 시작해 왼쪽으로 갈수록 255까지 증가한다.

position update

  • positions[keccak256(abi.encodePacked(지정가 주문 훅 address, 0, 60))] = { liquidity : 1000000, feeGrowthInside0LastX128: 0, feeGrowthInside1LastX128: 0 }

return delta.amount0 = 2996

  • 여기서 2996은 v4-core/contracts/libraries/SqrtPriceMath.sol의 getAmount0Delta 함수로 구해진 값이다.
  • amount0Delta = (Liquidity * 2⁹⁶) * (UpperSqrtPriceX96 — LowerSqrtPriceX96) / UpperSqrtPriceX96 / LowerSqrtPriceX96
  • = (1000000 * 79228162514264337593543950336) * (79466191966197645195421774833–79228162514264337593543950336 ) / 79466191966197645195421774833 / 79228162514264337593543950336
  • = 2996

(2) transferFrom

user가 poolManager에게 2996 token0을 transfer

(3) settle

reservesOf[token0] = 2996

델타 값 변화 (modifyPosition + transferFrom & settle)

modifyPosition

  • currencyDelta[지정가 주문 훅 address][token0Address] = 2996
  • nonzeroDeltaCount는 0에서 1로 증가

transferFrom & settle

  • currencyDelta[지정가 주문 훅 address][token0Address] = 2996–2996 = 0
  • nonzeroDeltaCount 1에서 0으로 감소

— LimitOrder —

EpochInfos[epochs[keccack256(abi.encode(key,0,true))]] = {

filled:false,

currency0,

currency1,

token0Total:0,

token1Total:0,

liquidityTotal:1000000,

liquidity[msg.sender]:1000000

}

이렇게 지정가 주문이 끝나면 다음과 같이 assertion 테스트를 진행할 수 있다.

function testLimitOrder() public {
...
assertTrue(
EpochLibrary.equals(
limitOrder.getEpoch(key, tickLower, zeroForOne),
Epoch.wrap(1)
)
);
assertEq(
manager.getLiquidity(
id,
address(limitOrder),
tickLower,
tickLower + 60
),
liquidity
);
...
}

참고로 epoch는 1부터 값을 저장한다.

Swap & SwapAfter

Swap, 이 그림은 중간 과정을 완전히 생략한 요약본이니 아래 내용을 참고해 주시길 바랍니다
function testLimitOrder() public {
...
swapRouter.swap(
key,
IPoolManager.SwapParams(
false,
1e18,
TickMath.getSqrtRatioAtTick(60)
),
PoolSwapTest.TestSettings(true, true)
);

swapRouter가 PoolManager에 lock을 건다.

SwapParams는 v4-core/contracts/interfaces/IPoolManager.sol에서 확인할 수 있으며 다음과 같다.

struct SwapParams {
bool zeroForOne;
int256 amountSpecified;
uint160 sqrtPriceLimitX96;
}

zeroForOne이 false이므로, token1을 token0으로 스왑하는 것이다. 그리고 amountSpecified는 1e18으로 설정했고, 현재 풀에는 2996 token0밖에 없다. sqrtPriceLimitX96을 getSqrtRatioAtTick(60)으로 설정했으므로 1e18을 전부 transfer 하지 않고(0~60 사이에 유동성이 1e18만큼 없으므로) 부분 swap이 이루어질 것이다.

TestSettings는 v4-core/contracts/test/PoolSwapTest.sol에서 확인할 수 있다.

struct TestSettings {
bool withdrawTokens;
bool settleUsingTransfer;
}

withdrawTokens를 true로 설정하면, take 하지 않고 ERC1155를 mint 한다. false는 그 반대다.

settleUsingTransfer를 true로 설정하면, token transferFrom과 settle을 하게 되고, false로 설정하면 ERC1155를 PoolManager에게 transfer 한다.

이 테스트에서는 둘 다 true로 설정한다.

— PoolManager —

tickBitmap.nextInitializedTickWithinOneWord

v4-core/contracts/libraries/TickBitmap.sol을 보면

function nextInitializedTickWithinOneWord(
mapping(int16 => uint256) storage self,
int24 tick,
int24 tickSpacing,
bool lte
) internal view returns (int24 next, bool initialized) {
unchecked {
int24 compressed = tick / tickSpacing;
...
(int16 wordPos, uint8 bitPos) = position(compressed + 1);
// all the 1s at or to the left of the bitPos
uint256 mask = ~((1 << bitPos) - 1);
uint256 masked = self[wordPos] & mask;

...
function position(int24 tick) private pure returns (int16 wordPos, uint8 bitPos) {
unchecked {
wordPos = int16(tick >> 8);
bitPos = uint8(int8(tick % 256));
}
}

이렇게 마스크 연산을 통해서 다음 초기화된 tick을 찾는다. zeroForOne이 true로 token0의 가격이 올라가기 때문에, 더 큰 tick을 찾아야 한다.

현재 tick: 0, word:0, bitPos:1

  • comprssed는 0/60 = 0이다. 그리고 compressed에 1을 더하여 wordPos는 0, bitPos는 1이 된다. 여기서 1을 더하는 이유는 더 큰 bitPos 값을 찾을 때 현재 bitPos 값을 제외한다.

현재 tickBitmap 값: tickBitmap[0] = …00011 (2진수, 앞에 0 254개)

  • zeroForOne이 true이기 때문에 현재 1bitPos를 기준으로 자신을 포함한 왼쪽에서 initialized된 tick을 찾는다. 이때 마스크는 ~((1 << bitPos) — 1) ⇒ 111….1110 이 되고 wordPos & mask 연산을 통해 같은 word 안에 1 bitPos가 초기화되어있는 것을 바로 찾을 수 있다. 1 bitPos에 60 tickSpacing 을 곱해줌으로써 tickNext 값은 60이 된다.

computeSwapStep

  • amountIn = RoundingUp(Liquidity * (UpperSqrtPriceX96 — LowerSqrtPriceX96) / 2⁹⁶)
    => 1000000 * (79466191966197645195421774833–79228162514264337593543950336) / 2⁹⁶ = 3005
  • amountOut = (Liquidity * 2⁹⁶) * (UpperSqrtPriceX96 — LowerSqrtPriceX96) / UpperSqrtPriceX96 / LowerSqrtPriceX96
    => (1000000 * 2⁹⁶) * (79466191966197645195421774833–79228162514264337593543950336) / 79466191966197645195421774833 / 79228162514264337593543950336 = 2995
  • feeAmount = RoundingUp(amountIn * 3000 / (1000000–3000)) = 10
  • feeGrowthGlobal1X128 = feeAmount * 2¹²⁸ / liquidity = 3402823669209384634633746074317682
  • ticks[0]: { liquidityGross: 1000000, liquidityNet:1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }
  • ticks[60]: { liquidityGross: 1000000, liquidityNet: -1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }
    ⇒ ticks[60]: { liquidityGross: 1000000, liquidityNet: -1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 3402823669209384634633746074317682 }

델타 값 변화 (swap)

  • currencyDelta[swapRouter address][token0Address] = -2995
  • currencyDelta[swapRouter address][token1Address] = 3015
  • nonzeroDeltaCount 0에서 2로 증가

AfterSwap 훅 함수 호출

AfterSwap, 이 그림은 중간 과정을 완전히 생략한 요약본이니 아래 내용을 참고해 주시길 바랍니다

LimitOrder 컨트랙트가 PoolManager에 lock을 건다.

lockAcquiredFilled의 modifyPosition을 통해서 0~60 tick 범위에 EpochInfos[epochs[keccack256(abi.encode(key,0,true))]].liquidityTotal = 1000000 만큼 유동성을 제거한다.

— PoolManager —

feeGrowthGlobal0x128 = 0

feeGrowthGlobal1x128 = 0 ⇒ 3402823669209384634633746074317682

liquidity = 1000000 ⇒ 0

updateTick

  • ticks[0]: { liquidityGross: 1000000, liquidityNet:1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }

⇒ ticks[0]: { liquidityGross: 0, liquidityNet:0, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }

  • ticks[60]: { liquidityGross: 1000000, liquidityNet: -1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }

⇒ ticks[60]: { liquidityGross: 0, liquidityNet: 0, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 3402823669209384634633746074317682 }

tickBitmap.flipTick

  • tickBitmap[0] = …00011 (2진수, 앞에 0 254개) ⇒ …00000 (0 256개)

position update

  • positions[keccak256(abi.encodePacked(지정가 주문 훅 address, 0, 60))] = { liquidity : 1000000, feeGrowthInside0LastX128: 0, feeGrowthInside1LastX128: 0 }

⇒ positions[keccak256(abi.encodePacked(지정가 주문 훅 address, 0, 60))] = { liquidity : 0, feeGrowthInside0LastX128: 0, feeGrowthInside1LastX128: 3402823669209384634633746074317682 }

feesOwed = 9

  • feesOwed1 = (feeGrowthInside1x128 — _self.feeGrowthInside1LastX128) * liquidity / 2¹²⁸
  • = (3402823669209384634633746074317682–0) * 1000000 / 340282366920938463463374607431768211456 = 9

return delta.amount1 = -3004 + (-9) = -3013

  • delta.amount1 = -(Liquidity * (UpperSqrtPriceX96 — LowerSqrtPriceX96) / 2⁹⁶) + (-feesOwed)
  • = -(1000000 * (79466191966197645195421774833–79228162514264337593543950336 ) / 79228162514264337593543950336) + (-9) = -3013

— LimitOrder —

ERC1155 mint

  • LimitOrder 주소로 3013만큼 ERC1155 mint

델타 값 변화 (modifyPosition + mint ERC1155)

modifyPosition

  • currencyDelta[지정가 주문 훅 address][token1Address] = -3013
  • nonzeroDeltaCount는 2에서 3로 증가

mint ERC1155

  • currencyDelta[지정가 주문 훅 address][token0Address] = -3013 + 3013 = 0
  • nonzeroDeltaCount 3에서 2으로 감소

EpochInfos[epochs[keccack256(abi.encode(key,0,true))]] = {

filled:false, ⇒ true

currency0,

currency1,

token0Total:0,

token1Total: 0 ⇒ 3013,

liquidityTotal:1000000,

liquidity[msg.sender]:1000000

}

Swap 마무리

take token0

  • reservesOf[token0] = 2996–2995 = 1
  • currencyDelta[swapRouter address][token0Address] = -2995 + 2995 = 0
  • nonzeroDeltaCount 2에서 1로 감소
  • transfer to swapper

transfer token1 to PoolManager

  • reservesOf[token1] = 3015

settle token1

  • currencyDelta[swapRouter address][token1Address] = 3015–3015 = 0
  • nonzeroDeltaCount 1에서 0으로 감소

이렇게 스왑이 끝나면 다음과 같이 assertion 테스트를 진행할 수 있다.

function testLimitOrder() public {
...
assertEq(limitOrder.getTickLowerLast(id), 60);
(, int24 tick, , , , ) = manager.getSlot0(id);
assertEq(tick, 60);

(
bool filled,
,
,
uint256 token0Total,
uint256 token1Total,

) = limitOrder.epochInfos(Epoch.wrap(1));
assertTrue(filled);
assertEq(token0Total, 0);
assertEq(token1Total, 3013);
assertEq(
manager.getLiquidity(
id,
address(limitOrder),
tickLower,
tickLower + 60
),
0
);
...
}

출금

출금, 이 그림은 중간 과정을 완전히 생략한 요약본이니 아래 내용을 참고해 주시길 바랍니다
function testLimitOrder() public {
...
limitOrder.withdraw(Epoch.wrap(1), <받을 주소>);
...

첫 번째 인자로는 epcoh 인덱스 그리고 두 번째 인자는 받을 주소를 넘겨주면 된다.

EpochInfos[epochs[keccack256(abi.encode(key,0,true))]] = {

filled:false, ⇒ true

currency0,

currency1,

token0Total:0,

token1Total: 0 ⇒ 3013 ⇒ 3013–3013 = 0

liquidityTotal:1000000 ⇒ 1000000–1000000 = 0

liquidity[msg.sender]:1000000 ⇒ delete liquidity[msg.sender] ⇒ 0

}

ERC1155 safeTransferFrom

  • LimitOrder 컨트랙트가 가지고 있는 3013개의 ERC1155토큰을 PoolManager에게 전송한다.
  • PoolManager는 받은 ERC1155토큰을 모두 burn 시킨다.
  • currencyDelta[지정가 주문 훅 address][token0Address] = -3013
  • nonzeroDeltaCount 0에서 1로 증가

Take

  • reservesOf[token1] = 3015–3013 = 2
  • nonzeroDeltaCount 1에서 0으로 감소
  • <받을 주소>에게 3013 token1 전송

이렇게 출금이 끝나면 다음과 같이 assertion 테스트를 진행할 수 있고 이렇게 마무리 한다.

function testLimitOrder() public {
...
(, , , token0Total, token1Total, ) = limitOrder.epochInfos(
Epoch.wrap(1)
);

assertEq(token0Total, 0);
assertEq(token1Total, 0);
}

유니스왑 V4에서 아직 다루지 않은 부분이 많다. 그중에서 EIP-1153 Transient Storage에 대해서 간단히 다루고 마무리를 짓겠다.

EIP-1153 : Transient Storage

2024년 1월에 있을 이더리움 “Dencun”의 업그레이드 범위에 EIP-1153이 포함되었고, 유니스왑 V4 백서에서도 Transient Storage를 사용하여 가스비를 줄일 것이라고 명시하고 있다. 해당 내용에 대해서 간단하게 다루겠다.

Source: Pascal Marco Caversaccio

위의 그림처럼 기존의 calldata, memory 그리고 storage 외에 Transient Storage라는 것을 도입한다. Transient Storage는 memory처럼 해당 트랜잭션이 끝날 때 휘발되고, storage(world state)처럼 함수의 scope를 벗어나 전역 scope에서 읽기와 쓰기가 가능하다.

읽기와 쓰기인 새로운 opcode “tload”와 “tstore”가 새로 추가가 될 예정이며, “sload”와 “sstore”보다 95%나 싼 가스비를 소모한다. 이는 다음과 같은 상황에서 엄청 유용하게 쓰일 수 있다.

바로 유니스왑 V4에서 자주 쓰이는 델타 값이다. 델타는 결국 값 0에서 시작해서 0으로 끝난다. 그러므로 트랜잭션이 끝날 때 휘발되어도 아무 문제가 없다. 그리고 해당 값은 하나의 함수 내에서만 쓰이는 값이 아니라 여러 함수에서 쓰는 값이므로, world state처럼 전역 scope를 가져야 한다. 그러므로 델타값은 Transient Storage에 저장하여 사용하면 딱이다. 이렇게 불필요한 storage 연산들을 없애 엄청난 가스비를 절약할 수 있다.

*추후 TONDEX 프로젝트에서 유니스왑V3를 V4로 마이그레이션하는 작업을 하게 될 것이다. 지정가 주문뿐만 아니라 훅 컨트랙트를 사용하여 유동성 제공자들에게 혜택이 많이 갈 수 있는 새로운 수수료 정책을 도입할 것이며, 새로운 디파이 상품에 대한 아이디어를 모색하고 개발해 나갈 것이다.

이것으로 유니스왑V4 지정가 주문 아티클을 마무리합니다. 감사합니다.

Reference

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

--

--