상세 컨텐츠

본문 제목

UniswapV3 Price Oracle

Blockchain/DeFi

by Yongari 2023. 5. 1. 20:57

본문

 

 

 

 

 

 

가격 오라클 #
저희가 탈중앙 거래소에 추가할 마지막 메커니즘은 가격 오라클입니다. 가격 오라클이 탈중앙 거래소에 필수적인 것은 아니지만(가격 오라클을 구현하지 않는 탈중앙 거래소가 있습니다), 이는 여전히 유니스왑의 중요한 기능이며 알아두면 흥미로운 부분입니다.

가격 오라클이란 무엇인가요? #
가격 오라클은 블록체인에 자산 가격을 제공하는 메커니즘입니다. 블록체인은 고립된 생태계이기 때문에 API를 통해 중앙화된 거래소에서 자산 가격을 가져오는 등 외부 데이터를 직접 쿼리할 수 있는 방법이 없습니다. 또 다른 어려운 문제는 데이터의 유효성과 신뢰성입니다. 거래소에서 가격을 가져올 때 그 가격이 진짜인지 어떻게 알 수 있을까요? 출처를 신뢰해야 합니다. 하지만 인터넷은 안전하지 않은 경우가 많으며, 때로는 가격이 조작되거나 DNS 레코드가 도용되거나 API 서버가 다운되는 등의 문제가 발생할 수 있습니다. 신뢰할 수 있고 정확한 온체인 가격을 제공하기 위해서는 이러한 모든 어려움을 해결해야 합니다.

위에서 언급한 문제를 해결하기 위한 첫 번째 해결책 중 하나가 바로 체인링크입니다. 체인링크는 탈중앙화된 오라클 네트워크를 운영하며, API를 통해 중앙화된 거래소로부터 자산 가격을 가져와 평균화하여 위변조 방지 방식으로 온체인에 제공합니다. 기본적으로 체인링크는 자산 가격이라는 하나의 상태 변수를 가진 컨트랙트 집합으로, 다른 컨트랙트나 사용자 누구나 읽을 수 있지만 오라클에 의해서만 쓰기가 가능합니다.

이것이 가격 오라클을 바라보는 한 가지 방법입니다. 또 다른 방법이 있습니다.

네이티브 온체인 거래소가 있다면 왜 외부에서 가격을 가져와야 할까요? 이것이 바로 유니스왑 가격 오라클이 작동하는 방식입니다. 차익거래와 높은 유동성 덕분에 유니스왑의 자산 가격은 중앙화된 거래소의 가격과 비슷합니다. 따라서 중앙화된 거래소를 자산 가격의 신뢰할 수 있는 출처로 사용하는 대신 유니스왑을 사용할 수 있으며, 온체인 데이터 전송과 관련된 문제를 해결할 필요가 없습니다(데이터 제공자를 신뢰할 필요도 없습니다).

 

 

유니스왑 가격 오라클 작동 방식 #
유니스왑은 단순히 이전의 모든 스왑 가격 기록을 보관합니다. 그게 다입니다. 그러나 유니스왑은 실제 가격을 추적하는 대신 풀 계약의 역사에서 매초 가격의 합계인 누적 가격을 추적합니다.

 

 

이 접근 방식을 사용하면  t1, t2 두 시점 사이의 시간 가중 평균 가격을 찾을 수 있습니다. 단순히 다음 지점 at1, at2에서 누적된 가격을 가져옵니다. 그리고 두 지점에서 하나를 빼고 두 지점 사이의 시간(초)으로 나눕니다:

 

이것이 유니스왑 V2에서 작동하는 방식입니다. V3에서는 약간 다릅니다. 누적된 가격은 현재 틱입니다. (이것은 가격의 log1.0001 값입니다.)

 

 

가격의 평균을 구하는 대신 기하평균을 구합니다: 

 

 

 

두 시점 사이의 시간 가중 기하 평균 가격을 구하려면 해당 시점에 누적된 값을 가져와서 다른 값에서 하나를 빼고 두 시점 사이의 시간(초)으로 나눈 다음 다음과 같이 계산합니다. 1.0001^x

 

 

유니스왑 V2는 과거 누적 가격을 저장하지 않았기 때문에 평균 가격을 계산할 때 과거 가격을 찾기 위해 타사 블록체인 데이터 인덱싱 서비스를 참조해야 했습니다. 반면, 유니스왑 V3는 최대 65,535개의 과거 누적 가격을 저장할 수 있어 과거 시간 가중 기하학적 가격을 훨씬 쉽게 계산할 수 있습니다.

 

가격 조작 완화 #
또 다른 중요한 주제는 가격 조작과 유니스왑에서 가격 조작을 완화하는 방법입니다.

이론적으로 풀의 가격을 유리하게 조작하는 것은 가능합니다. 예를 들어, 대량의 토큰을 구매해 가격을 올리고 유니스왑 가격 오라클을 사용하는 타사 탈중앙 금융 서비스에서 수익을 얻은 다음, 토큰을 실제 가격으로 다시 거래하는 것입니다. 이러한 공격을 완화하기 위해 유니스왑은 블록의 마지막 거래가 끝난 후 블록이 끝날 때 가격을 추적합니다. 이를 통해 블록 내 가격 조작의 가능성을 제거합니다.

기술적으로 유니스왑 오라클의 가격은 각 블록이 시작될 때 업데이트되며, 각 가격은 블록의 첫 번째 스왑 전에 계산됩니다.

 

가격 오라클 구현 #
자, 코딩을 시작하겠습니다.

관찰 및 카디널리티 #
먼저 Oracle 라이브러리 컨트랙트와 Observation 구조를 생성하는 것으로 시작하겠습니다:

 

// src/lib/Oracle.sol
library Oracle {
    struct Observation {
        uint32 timestamp;
        int56 tickCumulative;
        bool initialized;
    }
    ...
}

관측은 기록된 가격을 저장하는 슬롯입니다. 가격, 이 가격이 기록된 타임스탬프, 관측이 활성화될 때 참으로 설정되는 초기화 플래그를 저장합니다(모든 관측이 기본적으로 활성화되는 것은 아님). 풀 컨트랙트는 최대 65,535개의 관측을 저장할 수 있습니다:

 

// src/UniswapV3Pool.sol
contract UniswapV3Pool is IUniswapV3Pool {
    using Oracle for Oracle.Observation[65535];
    ...
    Oracle.Observation[65535] public observations;
}

그러나 이렇게 많은 옵저베이션 인스턴스를 저장하려면 많은 가스가 필요하므로(누군가가 각 인스턴스를 콘트랙트 스토리지에 기록하는 데 비용을 지불해야 함), 풀은 기본적으로 1개의 옵저베이션만 저장할 수 있으며, 새로운 가격이 기록될 때마다 덮어쓰게 됩니다. 활성화된 옵저버의 수, 즉 옵저버의 카디널리티는 비용을 지불할 의사가 있는 사람이 언제든지 늘릴 수 있습니다. 카디널리티를 관리하려면 몇 가지 추가 상태 변수가 필요합니다:

    ...
    struct Slot0 {
        // Current sqrt(P)
        uint160 sqrtPriceX96;
        // Current tick
        int24 tick;
        // Most recent observation index
        uint16 observationIndex;
        // Maximum number of observations
        uint16 observationCardinality;
        // Next maximum number of observations
        uint16 observationCardinalityNext;
    }
    ...

관찰 인덱스는 가장 최근 관찰의 인덱스를 추적합니다;
관찰 카디널리티는 활성화된 관찰의 수를 추적합니다;
observationCardinalityNext는 관측 배열이 확장할 수 있는 다음 카디널리티를 추적합니다.
관측값은 고정 길이 배열에 저장되며, 이 배열은 새 관측값이 저장될 때 확장되고 관찰CardinalityNext가 관찰Cardinality보다 크면(카디널리티가 확장될 수 있음을 신호) 확장됩니다. 배열을 확장할 수 없는 경우(다음 카디널리티 값이 현재 값과 같음), 가장 오래된 관측값을 덮어쓰게 됩니다. 즉, 관측값은 인덱스 0에 저장되고 다음 관측값은 인덱스 1에 저장되는 식으로 저장됩니다.

풀이 생성되면 관측값 카디널리티와 관측값 카디널리티 넥스트가 1로 설정됩니다:

// src/UniswapV3Pool.sol
contract UniswapV3Pool is IUniswapV3Pool {
    function initialize(uint160 sqrtPriceX96) public {
        ...

        (uint16 cardinality, uint16 cardinalityNext) = observations.initialize(
            _blockTimestamp()
        );

        slot0 = Slot0({
            sqrtPriceX96: sqrtPriceX96,
            tick: tick,
            observationIndex: 0,
            observationCardinality: cardinality,
            observationCardinalityNext: cardinalityNext
        });
    }
}

 

// src/lib/Oracle.sol
library Oracle {
    ...
    function initialize(Observation[65535] storage self, uint32 time)
        internal
        returns (uint16 cardinality, uint16 cardinalityNext)
    {
        self[0] = Observation({
            timestamp: time,
            tickCumulative: 0,
            initialized: true
        });

        cardinality = 1;
        cardinalityNext = 1;
    }
    ...
}

옵저베이션 쓰기 #
스왑 함수에서 현재 가격이 변경되면 관측값이 관측값 배열에 기록됩니다:

// src/UniswapV3Pool.sol
컨트랙트 UniswapV3Pool은 IUniswapV3Pool {
    함수 swap(...) public returns (...) {
        ...
        if (state.tick != slot0_.tick) {
            (
                uint16 observationIndex,
                uint16 관측 카디널리티
            ) = observations.write(
                    slot0_.observationIndex,
                    _blockTimestamp(),
                    slot0_.tick,
                    slot0_.관찰 카디널리티,
                    slot0_.observationCardinalityNext
                );
            
            (
                slot0.sqrtPriceX96,
                slot0.tick,
                slot0.observationIndex,
                슬롯0.관측카디널리티
            ) = (
                state.sqrtPriceX96,
                state.tick,
                관찰 인덱스,
                관측 카디널리티
            );
        }
        ...
    }
}

여기서 관찰되는 틱은 상태 틱이 아닌 슬롯0_.틱, 즉 스왑 전 가격이라는 점에 유의하세요! 다음 트랜잭션에서 새로운 가격으로 업데이트됩니다. 이것이 앞서 설명한 가격 조작 완화 기능입니다: 유니스왑은 블록의 첫 거래 전과 이전 블록의 마지막 거래 후 가격을 추적합니다.

또한 각 관측값은 _blockTimestamp(), 즉 현재 블록 타임스탬프로 식별된다는 점에 유의하시기 바랍니다. 즉, 현재 블록에 대한 관측이 이미 있는 경우 가격이 기록되지 않습니다. 현재 블록에 대한 관측이 없는 경우(즉, 블록의 첫 번째 스왑인 경우) 가격이 기록됩니다. 이는 가격 조작 완화 메커니즘의 일부입니다.

 

// src/lib/Oracle.sol
function write(
    Observation[65535] storage self,
    uint16 index,
    uint32 timestamp,
    int24 tick,
    uint16 cardinality,
    uint16 cardinalityNext
) internal returns (uint16 indexUpdated, uint16 cardinalityUpdated) {
    Observation memory last = self[index];

    if (last.timestamp == timestamp) return (index, cardinality);

    if (cardinalityNext > cardinality && index == (cardinality - 1)) {
        cardinalityUpdated = cardinalityNext;
    } else {
        cardinalityUpdated = cardinality;
    }

    indexUpdated = (index + 1) % cardinalityUpdated;
    self[indexUpdated] = transform(last, timestamp, tick);

여기서 현재 블록에 이미 관측이 있는 경우 관측이 건너뛰는 것을 볼 수 있습니다. 하지만 그러한 관측이 없는 경우 새 관측을 저장하고 가능한 경우 카디널리티를 확장하려고 시도합니다. 모듈로 연산자(%)는 관측 인덱스가 다음 범위 내에 유지되도록 합니다.[0,cardinality)에 도달하면 0으로 재설정됩니다.

이제 변환 함수를 살펴보겠습니다:

function transform(
    Observation memory last,
    uint32 timestamp,
    int24 tick
) internal pure returns (Observation memory) {
    uint56 delta = timestamp - last.timestamp;

    return
        Observation({
            timestamp: timestamp,
            tickCumulative: last.tickCumulative +
                int56(tick) *
                int56(delta),
            initialized: true
        });
}

여기서 계산하는 것은 누적 가격입니다. 현재 틱에 마지막 관측 이후 초 수를 곱한 후 마지막 누적 가격에 더합니다.

카디널리티 # 증가
이제 카디널리티가 어떻게 확장되는지 살펴봅시다.

누구나 언제든지 풀에 대한 관측값의 카디널리티를 늘리고 이에 필요한 가스 비용을 지불할 수 있습니다. 이를 위해 풀 컨트랙트에 새로운 공용 함수를 추가하겠습니다:

// src/UniswapV3Pool.sol
함수 increaseObservationCardinalityNext(
    uint16 관찰 카디널리티 다음
) public {
    uint16 observationCardinalityNextOld = slot0.observationCardinalityNext;
    uint16 observationCardinalityNextNew = observations.grow(
        observationCardinalityNextOld,
        observationCardinalityNext
    );

    if (observationCardinalityNextNew != observationCardinalityNextOld) {
        slot0.observationCardinalityNext = observationCardinalityNextNew;
        emit IncreaseObservationCardinalityNext(
            observationCardinalityNextOld,
            관측 카디널리티 넥스트 뉴
        );
    }
}

그리고 오라클에 새로운 기능이 추가되었습니다:

// src/lib/Oracle.sol
function grow(
    Observation[65535] storage self,
    uint16 current,
    uint16 next
) internal returns (uint16) {
    if (next <= current) return current;

    for (uint16 i = current; i < next; i++) {
        self[i].timestamp = 1;
    }

    return next;
}

각각의 타임스탬프 필드를 0이 아닌 값으로 설정하여 새로운 옵저베이션을 할당하고 있습니다. self는 저장소 변수로, 그 요소에 값을 할당하면 배열 카운터가 업데이트되고 그 값이 컨트랙트의 저장소에 기록된다는 점에 유의하세요.

관찰 사항 읽기 #3
드디어 이 장에서 가장 까다로운 부분인 옵저베이션 읽기에 도달했습니다. 계속 진행하기 전에 관측값이 어떻게 저장되는지 살펴보고 더 나은 그림을 그려보겠습니다.

관측값은 확장할 수 있는 고정 길이 배열에 저장됩니다:

 

스왑이 모든 블록에서 발생하는 것은 아니므로 모든 블록에 대해 관측값이 저장된다는 보장은 없습니다. 따라서 가격을 알 수 없는 블록이 있을 수 있으며, 이러한 관측이 누락되는 기간이 길어질 수 있습니다. 물론 오라클이 보고하는 가격에 차이가 생기는 것을 원치 않기 때문에 시간 가중 평균 가격(TWAP)을 사용하여 관측이 없는 기간의 가격을 평균화할 수 있습니다. TWAP을 사용하면 가격을 보간할 수 있습니다. 즉, 두 관측값 사이에 선을 그리면 선의 각 지점은 두 관측값 사이의 특정 타임스탬프에 해당하는 가격이 됩니다.

따라서 관측값을 읽는다는 것은 관측값 배열이 넘칠 수 있다는 점을 고려하여 타임스탬프별로 관측값을 찾고 누락된 관측값을 보간하는 것을 의미합니다(예: 가장 오래된 관측값이 배열의 가장 최근 관측값 뒤에 올 수 있음). (가스 절약을 위해) 타임스탬프별로 관측값을 색인하지 않으므로 효율적인 검색을 위해 이진 검색 알고리즘을 사용해야 합니다. 하지만 항상 그런 것은 아닙니다.

이를 더 작은 단계로 나누고 오라클에서 관찰 함수를 구현하는 것부터 시작해 보겠습니다:

function observe(
    Observation[65535] storage self,
    uint32 time,
    uint32[] memory secondsAgos,
    int24 tick,
    uint16 index,
    uint16 cardinality
) internal view returns (int56[] memory tickCumulatives) {
    tickCumulatives = new int56[](secondsAgos.length);

    for (uint256 i = 0; i < secondsAgos.length; i++) {
        tickCumulatives[i] = observeSingle(
            self,
            time,
            secondsAgos[i],
            tick,
            index,
            cardinality
        );
    }
}

이 함수는 현재 블록 타임스탬프, 가격을 구하려는 시점 목록(초 단위), 현재 틱, 관측 지수, 카디널리티를 받습니다.

관찰 단일 함수로 이동합니다:

function observeSingle(
    Observation[65535] storage self,
    uint32 time,
    uint32 secondsAgo,
    int24 tick,
    uint16 index,
    uint16 cardinality
) internal view returns (int56 tickCumulative) {
    if (secondsAgo == 0) {
        Observation memory last = self[index];
        if (last.timestamp != time) last = transform(last, time, tick);
        return last.tickCumulative;
    }
    ...
}

가장 최근 관측이 요청되면(0초 경과) 바로 반환할 수 있습니다. 현재 블록에 기록되지 않은 경우, 현재 블록과 현재 틱을 고려하도록 변환합니다.

더 오래된 시점이 요청되면 이진 검색 알고리즘으로 전환하기 전에 몇 가지 확인을 해야 합니다:

요청된 시점이 마지막 관측 시점인 경우, 가장 최근 관측 시점의 누적 가격을 반환할 수 있습니다;
요청된 시점이 마지막 관측 시점 이후인 경우, 마지막 관측 가격과 현재 가격을 알고 있는 상태에서 변환을 호출하여 이 시점의 누적 가격을 구할 수 있습니다;
요청된 시점이 마지막 관측 시점 이전인 경우 이진 검색을 사용해야 합니다.
세 번째 지점으로 바로 가보겠습니다:

function binarySearch(
    Observation[65535] storage self,
    uint32 time,
    uint32 target,
    uint16 index,
    uint16 cardinality
)
    private
    view
    returns (Observation memory beforeOrAt, Observation memory atOrAfter)
{
    ...

이 함수는 현재 블록 타임스탬프(시간), 요청된 가격대의 타임스탬프(목표), 현재 관측 지수 및 카디널리티를 받습니다. 요청된 시점이 위치한 두 관측 사이의 범위를 반환합니다.

이진 검색 알고리즘을 초기화하기 위해 경계를 설정합니다:

uint256 l = (index + 1) % cardinality; // oldest observation
uint256 r = l + cardinality - 1; // newest observation
uint256 i;

관찰 배열이 오버플로될 것으로 예상되므로 여기서 모듈로 연산자를 사용하고 있습니다.

그런 다음 무한 루프를 실행하여 범위의 중간 지점을 확인하고, 초기화되지 않은 경우(관측값이 없는 경우) 다음 지점으로 계속 진행합니다:

while (true) {
    i = (l + r) / 2;

    beforeOrAt = self[i % cardinality];

    if (!beforeOrAt.initialized) {
        l = i + 1;
        continue;
    }

    ...

포인트가 초기화되면 요청된 시점이 포함되기를 원하는 범위의 왼쪽 경계라고 부릅니다. 그리고 올바른 경계(atOrAfter)를 찾으려고 합니다:

  ...
    atOrAfter = self[(i + 1) % cardinality];

    bool targetAtOrAfter = lte(time, beforeOrAt.timestamp, target);

    if (targetAtOrAfter && lte(time, target, atOrAfter.timestamp))
        break;
    ...

경계를 찾았다면 경계를 반환합니다. 그렇지 않은 경우 검색을 계속합니다:

    ...
    if (!targetAtOrAfter) r = i - 1;
    else l = i + 1;
}

요청된 시점이 속한 관측 범위를 찾은 후에는 요청된 시점의 가격을 계산해야 합니다:

// function observeSingle() {
    ...
    uint56 observationTimeDelta = atOrAfter.timestamp -
        beforeOrAt.timestamp;
    uint56 targetDelta = target - beforeOrAt.timestamp;
    return
        beforeOrAt.tickCumulative +
        ((atOrAfter.tickCumulative - beforeOrAt.tickCumulative) /
            int56(observationTimeDelta)) *
        int56(targetDelta);
    ...

범위 내의 평균 변화율을 구하고 범위의 하한과 필요한 시점 사이에 경과한 시간(초)을 곱하기만 하면 됩니다. 이것이 앞서 설명한 보간입니다.

여기서 마지막으로 구현해야 할 것은 풀 컨트랙트에서 관측값을 읽고 반환하는 공용 함수입니다:

// src/UniswapV3Pool.sol
function observe(uint32[] calldata secondsAgos)
    public
    view
    returns (int56[] memory tickCumulatives)
{
    return
        observations.observe(
            _blockTimestamp(),
            secondsAgos,
            slot0.tick,
            slot0.observationIndex,
            slot0.observationCardinality
        );
}

관찰 내용 해석하기 #.
이제 관찰을 해석하는 방법을 살펴봅시다.

방금 추가한 관찰 함수는 누적된 가격의 배열을 반환하며, 이를 실제 가격으로 변환하는 방법을 알고 싶습니다. 관찰 함수의 테스트에서 이를 보여드리겠습니다.

테스트에서는 서로 다른 방향과 다른 블록에서 여러 스왑을 실행합니다:

function testObserve() public {
    ...
    pool.increaseObservationCardinalityNext(3);

    vm.warp(2);
    pool.swap(address(this), false, swapAmount, sqrtP(6000), extra);

    vm.warp(7);
    pool.swap(address(this), true, swapAmount2, sqrtP(4000), extra);

    vm.warp(20);
    pool.swap(address(this), false, swapAmount, sqrtP(6000), extra);
    ...

vm.warp는 파운드리에서 제공하는 치트 코드로, 지정된 타임스탬프가 있는 블록으로 전달합니다. 2, 7, 20 - 블록 타임스탬프입니다.

첫 번째 스왑은 타임스탬프 2가 있는 블록에서 이루어지고, 두 번째 스왑은 타임스탬프 7에서 이루어지며, 세 번째 스왑은 타임스탬프 20에서 이루어집니다. 이제 관측값을 읽을 수 있습니다:

   ...
    secondsAgos = new uint32[](4);
    secondsAgos[0] = 0;
    secondsAgos[1] = 13;
    secondsAgos[2] = 17;
    secondsAgos[3] = 18;

    int56[] memory tickCumulatives = pool.observe(secondsAgos);
    assertEq(tickCumulatives[0], 1607059);
    assertEq(tickCumulatives[1], 511146);
    assertEq(tickCumulatives[2], 170370);
    assertEq(tickCumulatives[3], 85176);
    ...

가장 먼저 관측된 가격은 풀이 배포될 때 설정된 초기 관측값인 0입니다. 그러나 카디널리티가 3으로 설정되어 있고 스왑을 3번 했으므로 마지막 관측값으로 덮어씌워졌습니다.
첫 번째 스왑 중에 틱 85176이 관찰되었는데, 이는 풀의 초기 가격이며 스왑 전 가격이 관찰되는 것을 의미합니다. 첫 번째 관측이 덮어씌워졌으므로 현재 가장 오래된 관측입니다.
다음으로 반환된 누적 가격은 170370으로 85176 + 85194입니다. 전자는 이전 누적 가격이고, 후자는 두 번째 스왑에서 관찰된 첫 번째 스왑 이후의 가격입니다.
다음으로 반환된 누적 가격은 511146으로 (511146 - 170370) / (17 - 13) = 85194이며, 이는 두 번째와 세 번째 스왑 사이의 누적 가격입니다.
마지막으로 가장 최근에 관측된 1607059는 (1607059 - 511146) / (20 - 7) = 84301이며, 이는 세 번째 스왑에서 관측된 두 번째 스왑 후 가격인 ~4581 USDC/ETH입니다.
요청된 시점은 스왑의 시점이 아니므로 보간이 필요한 예시입니다:

secondsAgos = new uint32[](5);
secondsAgos[0] = 0;
secondsAgos[1] = 5;
secondsAgos[2] = 10;
secondsAgos[3] = 15;
secondsAgos[4] = 18;

tickCumulatives = pool.observe(secondsAgos);
assertEq(tickCumulatives[0], 1607059);
assertEq(tickCumulatives[1], 1185554);
assertEq(tickCumulatives[2], 764049);
assertEq(tickCumulatives[3], 340758);
assertEq(tickCumulatives[4], 85176);

그 결과 가격이 나옵니다: 4581.03, 4581.03, 4747.6, 5008.91이며, 이는 요청된 구간 내의 평균 가격입니다.

Python에서 이러한 값을 계산하는 방법은 다음과 같습니다:

vals = [1607059, 1185554, 764049, 340758, 85176]
secs = [0, 5, 10, 15, 18]
[1.0001**((vals[i] - vals[i+1]) / (secs[i+1] - secs[i])) for i in range(len(vals)-1)]

 

출처: https://uniswapv3book.com/docs/milestone_5/price-oracle/

 

Price Oracle

Price Oracle # The final mechanism we’re going to add to our DEX is a price oracle. Even though it’s not essential to a DEX (there are DEXes that don’t implement a price oracle), it’s still an important feature of Uniswap and something that’s int

uniswapv3book.com

 

 

관련글 더보기