DEX 견적 계산: V2 상수곱 vs V3 집중유동성
TECH INSIGHT / EXPLAINED
스왑 견적은 특정 수량·풀 상태·수수료 규칙에 대한 결과입니다. V2와 V3 모두 정밀하게 계산할 수 있지만 필요한 상태와 계산 방식이 다릅니다. 표시하기 전까지 토큰 수량은 정수 최소 단위로 유지합니다.
실제 프로젝트의 로컬 실행 화면
기존 프로젝트의 화면 또는 함수를 로컬에서 실행해 촬영했습니다. 개인정보 없는 샘플 입력을 사용했으며, 실행한 범위는 각 이미지 아래에 설명했습니다.
V2: 내림을 포함한 상수곱 계산
원본 Uniswap V2의 수수료 0.3%에 대해 아래 정수식은 라이브러리의 출력 내림 방식을 따릅니다. 다른 포크의 요율은 다를 수 있습니다. 준비금은 같은 블록에서 입력/출력 토큰 순서에 맞춰 읽어야 하며 수집·갱신 비용도 있습니다. 수수료가 풀에 남으면 원시 준비금의 곱이 늘 수 있으므로 k가 영원히 변하지 않는다는 설명은 단순화입니다.
def amount_out_v2(amount_in, reserve_in, reserve_out):
values = (amount_in, reserve_in, reserve_out)
if any(type(v) is not int or v <= 0 for v in values):
raise ValueError("positive integer base units required")
with_fee = amount_in * 997 # original Uniswap V2: 0.3%
return with_fee * reserve_out // (reserve_in * 1000 + with_fee)
unit = 10**6
print(amount_out_v2(unit, 1000*unit, 1000*unit))
print(amount_out_v2(500*unit, 1000*unit, 1000*unit))
# 996006 and 332665999 base units가격 충격은 견적 안에 이미 들어 있다
예시의 두 토큰이 모두 소수점6자리라면 입력1은 0.996006, 입력500은 332.665999를 받습니다. 큰 거래가 곡선을 따라 움직이면서 단위당 수령액이 줄어드는 것이 가격 충격입니다. 슬리피지는 보통 견적/예상 결과와 실제 체결의 차이를 뜻하므로 이미 계산한 가격 충격을 또 차감하지 않습니다.
V3는 구간별 유동성을 계산한다
V3에는 sqrtPriceX96, 활성 유동성, 초기화된 tick의 변화가 필요합니다. 경계를 넘으면 유동성과 단계별 정수 반올림을 적용합니다. 그림의 계단은 구간별 이용 가능한 유동성 변화를 표현한 것이며 가격 자체가 계단 함수라는 뜻이 아닙니다. 상태와 정확한 계산이 있으면 오프체인 재현도 가능하고 Quoter는 편리한 대안입니다.
Quoter 호출은 체결 보장이 아니다
원본 V3 Quoter는 swap/revert를 이용하며 일반 Solidity view 함수가 아닙니다. 고정 블록의 eth_call 또는 이에 맞는 제공자 호출을 사용합니다. 자동 생성 바인딩은 버전·ABI에 따라 달라져 일반적인 CallOpts 예제가 맞지 않을 수 있습니다. 수량·수수료 구간·풀·블록을 기록하고 실제 스왑에는 minOut과 기한을 둡니다. 포함 전 상태가 바뀌면 견적은 유효하지 않을 수 있습니다.
입력이 양수여도 견적이 0일 수 있다
두 준비금이 각각 1,000인 원시 정수 풀을 가정합시다. 원본 V2 수수료 식에서 입력 1은 출력 0, 입력 2는 출력 1, 입력 100은 출력 90입니다. 이는 소수점 표시 문제가 아니라 출력의 정수 내림 결과입니다. 라이브러리가 0을 계산했다는 사실과 pair가 그 수량의 스왑을 받아준다는 사실은 다릅니다. pair의 swap은 양쪽 출력이 모두 0인 요청을 거절합니다.
따라서 견적 단계가 수학적으로 성공했더라도 실행 계획은 별도로 최소 유효 수량을 검사해야 합니다. 0을 임의로 1로 올리면 수학적으로 근거 없는 지급 약속이 됩니다. 반대로 작은 주문의 실패를 풀에 유동성이 전혀 없다는 판정으로 바꾸어도 안 됩니다. 독립 Python 재현에서 위 세 입력의 결과를 확인했습니다.
정확한 출력을 원할 때는 입력을 반대로 계산한다
목표 출력 90을 받는 예시에서는 필요한 입력이 100이며, 99만 넣으면 출력은 89입니다. exact-input 식의 결과를 단순히 역수로 바꾸지 말고, 원본 V2 라이브러리의 나눗셈 후 1을 더하는 입력 계산을 따르세요. 다음 코드는 그 양수 정수 경계만 보여 줍니다.
def required_input(out, reserve_in, reserve_out):
values = (out, reserve_in, reserve_out)
if any(type(v) is not int or v <= 0 for v in values):
raise ValueError("positive integers required")
if out >= reserve_out:
raise ValueError("output exhausts reserve")
return reserve_in * out * 1000 // (
(reserve_out - out) * 997
) + 1
assert required_input(90, 1000, 1000) == 100
출력이 준비금과 같으면 분모가 0이므로 입력값 단계에서 거절합니다. Python은 큰 정수를 자동 확장하므로 이 예제만으로 Solidity의 정수 폭·오버플로 동작까지 검증했다고 말할 수는 없습니다. 토큰별 전송 차감이나 별도 프로토콜 수수료도 이 식의 범위 밖입니다.
같은 풀을 다시 방문하는 경로의 함정
A→B→A 경로가 같은 풀을 두 번 사용하면 두 번째 교환은 첫 교환 뒤의 상태를 읽어야 합니다. 원본 V3 Quoter는 각 풀 호출에서 revert로 변경을 되돌린 뒤 계산한 수량을 다음 단계로 넘깁니다. 따라서 같은 풀을 다시 방문하는 경로를 두 독립 견적으로 이어 붙이거나 단순 Quoter 반복으로 평가하면 실제 연속 실행과 상태가 달라질 수 있습니다.
Quoter 구현의 이 코드 흐름으로부터 수량 전달과 상태 연속성은 별개라는 결론을 얻을 수 있습니다. 경로 탐색기가 중복 풀을 허용한다면 변경된 상태를 이어 계산하는 별도 모형이 필요합니다. 이를 지원하지 않는 구현은 중복 풀 경로를 명시적으로 제외하는 편이 결과의 의미가 분명합니다.
한 단위의 차이도 원인을 분류한다
견적 차이가 나면 우선 입력 토큰 순서, 원시 수량, 블록 해시, 수수료 설정을 고정한 뒤 계산 단계를 비교합니다. V3에서는 다음 초기화 tick 직전·경계 도달·경계 통과를 별도 시험으로 두세요. SwapMath는 입력·출력·수수료에 서로 다른 정수 계산을 적용하므로 마지막 표시값만 반올림해 맞출 수 없습니다.
임의의 오차 허용률로 차이를 덮기보다 최초로 어긋난 단계와 원시 정수를 남기세요. 같은 블록의 데이터가 불완전하면 계산 오류와 구분해 재수집해야 합니다. 여기서 실행한 실험은 V2 산술 경계이며 V3 tick 시뮬레이터의 정확성을 검증한 것은 아닙니다. 회귀 사례에는 기대 수량만 저장하지 말고 계산에 사용한 전체 입력과 단계별 결과를 함께 보관해야 다음 버전에서 같은 차이를 재현할 수 있습니다.
공식 문서와 이어 읽기
내용·예제 검토: 2026-10-01
댓글
댓글 쓰기