티스토리 뷰
⛓️ 시리즈
2024.01.30 - [Solidity/DeFi] - [Uniswap] V2 Core - UniswapV2ERC20
2024.01.31 - [Solidity/DeFi] - [Uniswap] V2 Core - UniswapV2Factory
2024.01.31 - [Solidity/DeFi] - [Uniswap] V2 Core - UniswapV2Pair
2024.02.05 - [Solidity/DeFi] - [Uniswap] V2 Core 보충 자료 - 백서 읽기
2024.02.20 - [Solidity/DeFi] - [Uniswap] V2 Router
🦄 TWAP
Uniswap V2는 새로운 블록에서 최초로 컨트랙트가 호출되어 트랜잭션이 실행될 때 이전 블록에서의 마지막 가격 price0 또는 price1과 블록 사이의 시간 간격 timelapsed를 각각 곱하여 priceCumulative0와 priceCumulative1에 누적하여 더합니다.
priceCumulative0 += price0 * timelapsed;
priceCumulative1 += price1 * timelapsed;
이렇게 누적된 가격을 기반으로 특정 시간대의 평균 가격을 구할 수가 있습니다. 이를 TWAP(Time Weighted Average Price)이라고 합니다. TWAP은 공격자가 단기간에 토큰 가격에 큰 영향을 미치는 것을 방지함으로써 신뢰할 수 있는 가격 오라클을 제공하기 위해 V2에서 새롭게 도입되었습니다. 공격자가 구태여 가격을 쥐고 흔들고자 한다면 연속해서 여러 개의 블록을 생성해야 하는데 이는 사실상 불가능에 가깝습니다.
이번 게시글에서는 TWAP을 사용한 간단한 오라클 예제를 작성해보겠습니다.
🔮 SimpleOracle.sol
본 예제는 Uniswap v2-periphery 리포지토리에 있는 예제를 참고했습니다.
전체 코드
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "../interfaces/IUniswapV2Pair.sol";
import "../libraries/UniswapV2OracleLibrary.sol";
import "../libraries/FixedPoint.sol";
contract SimpleOracle {
error InvalidPeriod();
error InvalidToken();
using FixedPoint for *;
uint public constant PERIOD = 15 seconds;
IUniswapV2Pair public immutable pair;
address public immutable token0;
address public immutable token1;
uint public price0CumulativeLast;
uint public price1CumulativeLast;
uint32 public blockTimestampLast;
FixedPoint.uq112x112 public price0Average;
FixedPoint.uq112x112 public price1Average;
constructor(IUniswapV2Pair _pair) {
pair = _pair;
token0 = _pair.token0();
token1 = _pair.token1();
price0CumulativeLast = _pair.price0CumulativeLast();
price1CumulativeLast = _pair.price1CumulativeLast();
(, , blockTimestampLast) = _pair.getReserves();
}
function update() external {
(
uint price0Cumulative,
uint price1Cumulative,
uint32 blockTimestamp
) = UniswapV2OracleLibrary.currentCumulativePrices(address(pair));
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
if (timeElapsed < PERIOD) {
revert InvalidPeriod();
}
unchecked {
price0Average = FixedPoint.uq112x112(
uint224((price0Cumulative - price0CumulativeLast) / timeElapsed)
);
price1Average = FixedPoint.uq112x112(
uint224((price1Cumulative - price1CumulativeLast) / timeElapsed)
);
}
price0CumulativeLast = price0Cumulative;
price1CumulativeLast = price1Cumulative;
blockTimestampLast = blockTimestamp;
}
function consult(
address token,
uint amountIn
) external view returns (uint amountOut) {
if (token == token0) {
amountOut = price0Average.mul(amountIn).decode144();
} else if (token == token1) {
amountOut = price1Average.mul(amountIn).decode144();
} else {
revert InvalidToken();
}
}
}
포인트
1. 주기(PERIOD)적으로 update 함수를 호출하여 오라클 가격을 업데이트해 줘야 합니다. chainlink automation같은 도구를 사용하면 될 것 같습니다.
uint public constant PERIOD = 15 seconds;
function update() external {
(
uint price0Cumulative,
uint price1Cumulative,
uint32 blockTimestamp
) = UniswapV2OracleLibrary.currentCumulativePrices(address(pair));
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
if (timeElapsed < PERIOD) {
revert InvalidPeriod();
}
...
}
2. 페어 컨트랙트에 저장된 priceXCumulative 값은 오버플로우로 인해 오라클 컨트랙트에 기록된 priceXCumulativeLast 보다 작아질 수 있습니다. 그러나 두 값을 빼기 연산을 통해 구해지는 누적해서 더해진 값은 온전히 보존됩니다. 따라서 unchecked 블록을 사용해 오버플로우 체크를 건너뜁니다.
unchecked {
price0Average = FixedPoint.uq112x112(
uint224((price0Cumulative - price0CumulativeLast) / timeElapsed)
);
price1Average = FixedPoint.uq112x112(
uint224((price1Cumulative - price1CumulativeLast) / timeElapsed)
);
}
3. consult 함수는 토큰 컨트랙트의 주소와 입력 토큰 수량을 통해 쌍을 이루는 토큰의 출력 토큰 수량을 계산하여 반환합니다.
function consult(
address token,
uint amountIn
) external view returns (uint amountOut) {
if (token == token0) {
amountOut = price0Average.mul(amountIn).decode144();
} else if (token == token1) {
amountOut = price1Average.mul(amountIn).decode144();
} else {
revert InvalidToken();
}
}
🔮 SimpleOracleTest
오라클이 잘 동작하는지 테스트를 통해 알아보겠습니다.
전체 코드
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/interfaces/IUniswapV2Pair.sol";
import "../src/examples/SimpleOracle.sol";
import "../src/test/ERC20.sol";
import "../src/UniswapV2Router01.sol";
import "../src/UniswapV2Factory.sol";
import "../src/UniswapV2Pair.sol";
import "../src/libraries/UniswapV2Library.sol";
contract SimpleOracleTest is Test {
UniswapV2Router01 public router;
UniswapV2Factory public factory;
UniswapV2Pair public pair;
UniswapV2ERC20WithMint public token0;
UniswapV2ERC20WithMint public token1;
SimpleOracle public oracle;
uint public constant INIT_SUPPLY = 1000000e18;
function setUp() public {
token0 = new UniswapV2ERC20WithMint();
token1 = new UniswapV2ERC20WithMint();
(token0, token1) = token0 < token1
? (token0, token1)
: (token1, token0);
token0.mint(address(this), INIT_SUPPLY);
token1.mint(address(this), INIT_SUPPLY);
factory = new UniswapV2Factory(address(this));
router = new UniswapV2Router01(address(factory), address(0));
pair = UniswapV2Pair(
factory.createPair(address(token0), address(token1))
);
vm.label(address(token0), "token0");
vm.label(address(token1), "token1");
vm.label(address(factory), "factory");
vm.label(address(router), "router");
vm.label(address(pair), "pair");
token0.approve(address(router), type(uint256).max);
token1.approve(address(router), type(uint256).max);
(uint amountA, uint amountB, uint liquidity) = router.addLiquidity(
address(token0),
address(token1),
INIT_SUPPLY / 2,
INIT_SUPPLY / 2,
INIT_SUPPLY / 2,
INIT_SUPPLY / 2,
address(this),
block.timestamp
);
assertEq(amountA, INIT_SUPPLY / 2);
assertEq(amountB, INIT_SUPPLY / 2);
assertEq(liquidity, INIT_SUPPLY / 2 - 1000);
oracle = new SimpleOracle(IUniswapV2Pair(address(pair)));
vm.label(address(oracle), "oracle");
}
function test_update() public {
uint price0CumulativeLast = oracle.price0CumulativeLast();
uint price1CumulativeLast = oracle.price1CumulativeLast();
uint32 blockTimestampLast = oracle.blockTimestampLast();
assertEq(price0CumulativeLast, 0);
assertEq(price1CumulativeLast, 0);
assertEq(blockTimestampLast, block.timestamp);
uint amount1Out = oracle.consult(address(token0), 1000e18);
assertEq(amount1Out, 0);
vm.warp(block.timestamp + oracle.PERIOD());
vm.roll(block.number + 1);
address[] memory path = new address[](2);
path[0] = address(token0);
path[1] = address(token1);
router.swapExactTokensForTokens(
1000e18,
0,
path,
address(this),
block.timestamp + 1000
);
oracle.update();
amount1Out = oracle.consult(address(token0), 1000e18);
assertGt(amount1Out, 0);
vm.warp(block.timestamp + oracle.PERIOD());
vm.roll(block.number + 1);
router.swapExactTokensForTokens(
1000e18,
0,
path,
address(this),
block.timestamp + 1000
);
oracle.update();
uint newAmount1Out = oracle.consult(address(token0), 1000e18);
assertGt(amount1Out, newAmount1Out);
assertGt(newAmount1Out, 0);
}
}
초기 설정
- 두 개의 ERC-20 토큰 컨트랙트 token0과 token1을 생성하고 100만 개의 토큰을 공급합니다.
- Uniswap V2 팩토리, 라우터, 토큰 페어의 유동성 풀 컨트랙트를 차례로 생성합니다.
- 라우터에게 최대 수량의 토큰 사용을 허용하고 각각 50만 개의 토큰을 유동성 풀에 예치합니다.
- 앞서 생성한 페어 컨트랙트를 입력으로 오라클 컨트랙트를 생성합니다.
오라클 업데이트 테스트
- 오라클에서 누적 가격과 타임 스탬프를 가져옵니다. 이 값들은 오라클 컨트랙트를 생성할 때 초기화된 값들로, 이전에 어떠한 거래도 발생하지 않았기 때문에 각각 0, 0, 1이어야 합니다.
- 또한 consult 함수를 호출하더라도 계산된 평균 가격이 존재하지 않으므로 0이 반환됩니다.
- vm.warp()를 호출하여 오라클의 주기만큼 블록체인 상의 시간을 조정하고 vm.roll()을 호출하여 다음 블록 번호로 이동합니다.
- 새로운 블록에서 라우터 컨트랙트를 통해 token0울 token1과 스왑합니다. 블록에서 처음 발생하는 트랜잭션이므로 페어 컨트랙트의 누적 가격이 갱신됩니다.
- 오라클을 업데이트합니다. 페어 컨트랙트의 누적 가격이 갱신되었으므로 오라클의 누적 가격과 평균 가격이 새롭게 갱신됩니다.
- 갱신된 오라클을 통해 계산되는 토큰 가격은 0이 아니어야 합니다.
- 다시 한 번 vm.warp()를 호출하여 오라클의 주기만큼 블록체인 상의 시간을 조정하고 vm.roll()을 호출하여 다음 블록 번호로 이동합니다.
- 새로운 블록에서 token0을 token1과 스왑합니다. 블록에서 처음 발생하는 트랜잭션이므로 페어 컨트랙트의 누적 가격이 갱신됩니다.
- 오라클을 업데이트합니다. 오라클의 누적 가격과 평균 가격이 새롭게 갱신됩니다.
- 2번 연속으로 token0을 token1과 스왑했으므로 상대적으로 token0의 가격은 낮아지고 token1의 가격은 높아집니다. 따라서 consult 함수를 호출해 새롭게 계산된 token1의 가격은 이전에 계산된 token1의 가격보다 높아야만 합니다.(토큰 가격이 높아졌다는 것은 동일한 수량의 token0으로 스왑할 수 있는 token1의 수량이 줄어들었음을 의미)
$ forge test --mc SimpleOracleTest -vvvv
[⠃] Compiling...
No files changed, compilation skipped
Running 1 test for test/SimpleOracle.t.sol:SimpleOracleTest
[PASS] test_update() (gas: 288419)
Traces:
[288419] SimpleOracleTest::test_update()
├─ [2340] oracle::price0CumulativeLast() [staticcall]
│ └─ ← 0
├─ [2362] oracle::price1CumulativeLast() [staticcall]
│ └─ ← 0
├─ [2401] oracle::blockTimestampLast() [staticcall]
│ └─ ← 1
├─ [3006] oracle::consult(token0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 1000000000000000000000 [1e21]) [staticcall]
│ └─ ← 0
├─ [261] oracle::PERIOD() [staticcall]
│ └─ ← 15
├─ [0] VM::warp(16)
│ └─ ← ()
├─ [0] VM::roll(2)
│ └─ ← ()
├─ [124351] router::swapExactTokensForTokens(1000000000000000000000 [1e21], 0, [0x2e234DAe75C793f67A35089C9d99245E1C58470b, 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 1016)
│ ├─ [2765] factory::getPair(token0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], token1: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ └─ ← pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14]
│ ├─ [2526] pair::getReserves() [staticcall]
│ │ └─ ← 500000000000000000000000 [5e23], 500000000000000000000000 [5e23], 1
│ ├─ [765] factory::getPair(token0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], token1: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ └─ ← pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14]
│ ├─ [15093] token0::transferFrom(SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14], 1000000000000000000000 [1e21])
│ │ ├─ emit Transfer(from: SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], to: pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14], value: 1000000000000000000000 [1e21])
│ │ └─ ← true
│ ├─ [765] factory::getPair(token0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], token1: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ └─ ← pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14]
│ ├─ [84442] pair::swap(0, 995015938219190933279 [9.95e20], SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 0x)
│ │ ├─ [12730] token1::transfer(SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 995015938219190933279 [9.95e20])
│ │ │ ├─ emit Transfer(from: pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14], to: SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], value: 995015938219190933279 [9.95e20])
│ │ │ └─ ← true
│ │ ├─ [564] token0::balanceOf(pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14]) [staticcall]
│ │ │ └─ ← 501000000000000000000000 [5.01e23]
│ │ ├─ [564] token1::balanceOf(pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14]) [staticcall]
│ │ │ └─ ← 499004984061780809066721 [4.99e23]
│ │ ├─ emit Sync(reserve0: 501000000000000000000000 [5.01e23], reserve1: 499004984061780809066721 [4.99e23])
│ │ ├─ emit Swap(sender: router: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], amount0In: 1000000000000000000000 [1e21], amount1In: 0, amount0Out: 0, amount1Out: 995015938219190933279 [9.95e20], to: SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
│ │ └─ ← ()
│ └─ ← [1000000000000000000000 [1e21], 995015938219190933279 [9.95e20]]
├─ [89021] oracle::update()
│ ├─ [384] pair::price0CumulativeLast() [staticcall]
│ │ └─ ← 77884452878022414427957444938301440 [7.788e34]
│ ├─ [406] pair::price1CumulativeLast() [staticcall]
│ │ └─ ← 77884452878022414427957444938301440 [7.788e34]
│ ├─ [526] pair::getReserves() [staticcall]
│ │ └─ ← 501000000000000000000000 [5.01e23], 499004984061780809066721 [4.99e23], 16
│ └─ ← ()
├─ [1006] oracle::consult(token0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 1000000000000000000000 [1e21]) [staticcall]
│ └─ ← 1000000000000000000000 [1e21]
├─ [261] oracle::PERIOD() [staticcall]
│ └─ ← 15
├─ [0] VM::warp(31)
│ └─ ← ()
├─ [0] VM::roll(3)
│ └─ ← ()
├─ [36551] router::swapExactTokensForTokens(1000000000000000000000 [1e21], 0, [0x2e234DAe75C793f67A35089C9d99245E1C58470b, 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 1031)
│ ├─ [765] factory::getPair(token0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], token1: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ └─ ← pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14]
│ ├─ [526] pair::getReserves() [staticcall]
│ │ └─ ← 501000000000000000000000 [5.01e23], 499004984061780809066721 [4.99e23], 16
│ ├─ [765] factory::getPair(token0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], token1: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ └─ ← pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14]
│ ├─ [3493] token0::transferFrom(SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14], 1000000000000000000000 [1e21])
│ │ ├─ emit Transfer(from: SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], to: pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14], value: 1000000000000000000000 [1e21])
│ │ └─ ← true
│ ├─ [765] factory::getPair(token0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], token1: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ └─ ← pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14]
│ ├─ [19742] pair::swap(0, 991057653949317359744 [9.91e20], SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 0x)
│ │ ├─ [3130] token1::transfer(SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 991057653949317359744 [9.91e20])
│ │ │ ├─ emit Transfer(from: pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14], to: SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], value: 991057653949317359744 [9.91e20])
│ │ │ └─ ← true
│ │ ├─ [564] token0::balanceOf(pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14]) [staticcall]
│ │ │ └─ ← 502000000000000000000000 [5.02e23]
│ │ ├─ [564] token1::balanceOf(pair: [0x33036a2Af35b56B6e6DFD986D533fC748BC1Af14]) [staticcall]
│ │ │ └─ ← 498013926407831491706977 [4.98e23]
│ │ ├─ emit Sync(reserve0: 502000000000000000000000 [5.02e23], reserve1: 498013926407831491706977 [4.98e23])
│ │ ├─ emit Swap(sender: router: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], amount0In: 1000000000000000000000 [1e21], amount1In: 0, amount0Out: 0, amount1Out: 991057653949317359744 [9.91e20], to: SimpleOracleTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
│ │ └─ ← ()
│ └─ ← [1000000000000000000000 [1e21], 991057653949317359744 [9.91e20]]
├─ [4621] oracle::update()
│ ├─ [384] pair::price0CumulativeLast() [staticcall]
│ │ └─ ← 155458764588717211585217735613121800 [1.554e35]
│ ├─ [406] pair::price1CumulativeLast() [staticcall]
│ │ └─ ← 156080286864037727932528884088510485 [1.56e35]
│ ├─ [526] pair::getReserves() [staticcall]
│ │ └─ ← 502000000000000000000000 [5.02e23], 498013926407831491706977 [4.98e23], 31
│ └─ ← ()
├─ [1006] oracle::consult(token0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 1000000000000000000000 [1e21]) [staticcall]
│ └─ ← 996017932259043531071 [9.96e20]
└─ ← ()
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.38ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
'Solidity > DeFi' 카테고리의 다른 글
[Uniswap] V2 FlashSwap 예제 (1) | 2024.02.22 |
---|---|
[Uniswap] V2 Router (0) | 2024.02.20 |
[Uniswap] V2 Core 보충 자료 - 백서 읽기 (0) | 2024.02.05 |
[Uniswap] V2 Core - UniswapV2Pair (0) | 2024.01.31 |
[Uniswap] V2 Core - UniswapV2Factory (1) | 2024.01.31 |