원문 제목: 《호가 0%, 실행 12.8%: Uniswap v4 "독풀" 동적 수수료 함정 복기》
원문 저자: 왕쯔하오, BitsLab
2026년 8월 22일부터 23일까지, BNB Chain의 한 Uniswap v4 USDC/WBNB 동적 수수료 풀이 애그리게이터 호가에서 0% 수수료로 표시되었지만, 사용자의 실제 체결에는 12.8%의 LP fee가 부과되었다. 약 29시간 동안 이 풀은 21,086건이 체결되었고 누적 수수료는 131,888달러였으며, 이후 폐쇄되었다. 모든 피해 거래는 성공했다: 컨트랙트는 탈취되지 않았고, 출력은 사용자가 설정한 하한보다 높았으며, 어떠한 이상 신호도 없었다——차액은 조용히 수수료가 되었다.
이 글은 그중 한 건의 샘플 거래를 입구로 삼는다:
(0x7615dec2ce80506ec461a47739eb8533ac2bc7c605ca56c255964481b76363d0: 사용자가 5,926.90 USDT를 투입했으나 예상보다 757.43 USDT 적게 받음), 이 풀이 어떻게 「호가 0%, 실행 12.8%」를 달성했는지 해부한다: 수수료 함수의 3층 판정, 무력화된 슬리피지 보호, 다른 LP를 문 밖에 차단하는 화이트리스트. 모든 결론은 온체인 데이터와 로컬 통제 검증에 기반한다.
사용자가 애그리게이터 프런트엔드에서 스왑을 시작하면, 전체 링크는 이렇게 진행된다:

이 링크에는 두 가지 배경 사실이 있으며, 이후의 모든 층위 판정은 이 두 가지 위에 세워진다.
먼저 v4 측을 보자: v4는 Hook이 건별로 수수료를 오버라이드하는 것을 허용한다. PoolKey.fee = 0x800000은 동적 수수료 풀을 선언하며; Hook은 beforeSwap의 세 번째 반환값에 0x400000 | fee를 담아 전달한다. v4-core에서 이 반환값을 받는 코드(Pool.sol:303-305):

isOverride()는 오버라이드 플래그를 확인하고, removeOverrideFlagAndValidate()는 0x400000을 제거하고 MAX_LP_FEE = 1,000,000(100%)을 초과하지 않는지 검증한 후, 해당 수수료율로 이번 swap을 실행하고 Swap 이벤트에 기록한다. 즉, Hook이 무엇을 반환하든 사용자는 그것을 지불한다——출력이 여전히 amountOutMin보다 높기만 하면. 이는 정식 기능이며, 변동성이나 재고에 따라 수수료율을 조정하는 것은 정당한 용도다.
이 Hook 자체를 다시 보자: 두 가지 권한만 선언한다. v4는 권한을 컨트랙트 주소 하위 14비트에 인코딩하며(Hooks.sol:29-47), 배포 시 검증되고 사후 변경 불가하다. v4-core 측의 플래그 정의와 주소 디코딩을 대조해 읽으면:

온체인 실측 getHookPermissions() 반환값이 비트맵과 일치한다: 이 두 비트만 참이다.
Hook은 소스 코드가 없다. 이 글은 바이트코드에서 수수료 함수 전문을 디컴파일했다——다음은 이미 온체인에서 실행된 이 로직의 동작 분석이다:

다음으로 층별로 살펴보자. 처음 두 층은 모두 「이것이 시뮬레이션인가」에 답하고, 세 번째 층은 실제 거래가 얼마를 지불하는지 결정한다.
수수료 함수가 호출되면 먼저 tx.origin을 비교한다: 세 개의 상수 주소 중 하나와 같으면 바로 폴백 티어를 반환한다. 폴백 티어는 현재 0%로 설정되어 있다.
세 주소는 아무렇게나 선택된 것이 아니다. eth_call에서 from을 지정하지 않으면 트랜잭션의 origin은 제로 주소에 위치한다——애그리게이터 일괄 견적은 보통 지정하지 않는다. 로컬 포크에서 직접 검증:

첫 번째 층은 이 세 주소만 인식한다. 다음 층에서는 일반 주소를 지정한 후 Hook이 어떻게 구분하는지 보자.
tx.origin을 일반 주소로 바꿔 재시도해도 반환은 여전히 0%——첫 번째 층은 우회되었지만 수수료는 변하지 않았다. 이는 첫 번째 층이 유일한 판정이 아님을 의미하며, 두 번째 층을 찾아냈다: gasleft() * 10100 / 10000 >= gate.
애그리게이터 견적은 후보 경로가 시뮬레이션에서 gas 부족으로 실패하는 것을 방지하기 위해 매우 높은 gas를 주는 관행이 있다; 실제 거래가 Hook에 진입하기 전에 gas는 이미 Router, 승인, 선행 홉에 의해 소비된다. 동일한 코드, 세 가지 gas:

분기점은 실측 결과 16.85M–16.9M gas 사이에 위치한다. 30M과 2.59M의 차이가 바로 "견적"과 "실행"의 차이다.
앞의 두 계층을 모두 통과한 실제 거래는 세 번째 계층으로 진입한다: 블록 환경 지문을 해시한 후 10,000으로 나눈 나머지를 구하고, 그 결과가 속한 구간에 따라 해당 수수료율 구간을 반환한다. 현재 설정은 8% / 10% / 10%의 세 구간이며, 빗나가면 0%로 폴백한다.
이 계층에는 주목할 만한 두 가지 설계가 있다. 지문에는 calldata의 세 필드가 혼입되어 있다——애그리게이터 견적의 calldata와 실제 라우팅의 calldata는 원래부터 다르므로, 지문도 자연히 달라진다. 또한 거래별 의사난수는 수수료율에 분포를 부여하여, 몇 건만 보고는 규칙을 알아채기 어렵고 단순한 규칙 열거로는 막을 수 없다.
전체 21,086건의 온체인 이벤트에서 19개의 역사적 수수료율 구간(6.8%–28%)이 나타났으며, 이는 수수료율 구간이 줄곧 관리자 함수에 의해 필요에 따라 재구성되어 왔음을 보여준다. 샘플 거래가 체결될 당시 적용된 것은 12.8%였다.

이로써 「독 풀」에 대한 정의를 내릴 수 있다——세 가지 조건을 동시에 충족하는 v4 풀: PoolKey.fee가 동적 수수료율 플래그를 지님; 수수료율 결정이 공개 시장 상태가 아닌 실행 환경 신호(gas, origin, 블록 환경)를 읽음; 견적 시뮬레이션과 온체인 실행의 수수료율에 체계적 분기가 발생하며, 수혜자는 독 풀 배포자임. 경계는 수수료율이 무엇을 읽는가에 있다: 어떤 호출자에게든 동일한 수수료율을 반환하는 것은 프로그래머블 시장 설계이고, 「누가 가격을 묻는가」에 따라 다른 수수료율을 반환하는 것은 라우팅 시스템에 대한 기만이다.
여기까지 수수료율 분기의 메커니즘은 완전해졌다. 그러나 수수료율이 징수된 후에도 거래가 왜 성공할 수 있는지는 다음 문제이다.
먼저 이 거래의 전체 경로와 금액을 살펴보자:

해당 Swap 이벤트는 적용된 수수료율을 fee 필드에 기록한다: 128000. v4의 수수료율 단위는 1,000,000을 100%로 하므로, 128000은 12.8%이다. 이 거래의 손실은 두 가지 관점에서 측정할 수 있다: 풀 관점에서 보면 수수료율은 WBNB 입력에 대해 징수된다; 전체 경로 관점에서 보면 스테이블코인 입출금 차액은 757.43 USDT로, 12.7794%의 손실이며, 여기에는 선행 홉 수수료와 페그 이탈 편차도 포함된다. 두 숫자의 차이는 0.02 퍼센트 포인트에 불과하며, 이는 손실의 주요 원인이 바로 이 풀의 LP fee임을 보여준다.
amountOutMin은 최종 출력 하한만 검증할 뿐, 중간의 각 홉에서 얼마의 수수료가 징수되었는지는 보지 않는다:

12.8%의 수수료율이 전체 20.32%의 버퍼 안에 들어오므로, 출력은 여전히 하한선보다 높다——거래 성공, 롤백 없음. 주류 스테이블코인 경로의 일반적인 기본값은 0.1%–1%이며, 이 경로는 20배 이상의 여유를 제공한다. 사용자는 넓은 버퍼가 "더 안정적"이라고 생각하지만, 실제로는 경로상의 모든 컨트랙트를 상대방의 자율에 맡기는 것이다. 이 계산을 그림으로 그리면:

화이트리스트 로직은 beforeAddLiquidity에 있으며, 디컴파일 결과는 다음과 같다:

유동성 추가는 세 개의 관문을 통과해야 한다: 먼저 모든 v4 Hook에 있는 PoolManager 호출 검사, 그다음 반드시 공식 PositionManager를 거쳐야 하며, 마지막으로 포지션 보유자의 화이트리스트를 확인한다. 목록에 없는 주소는 여기서 컨트랙트에 의해 롤백되며, 다른 LP는 들어올 수 없고, 수수료 수익은 희석되지 않고 전부 배포자에게 귀속된다. 목록 자체는 관리자 함수(셀렉터 0xc4452e52)에 의해 주소별로 유지된다.
21,086건의 거래를 함께 보면, 분포는 명확한 패턴을 보여준다: 0% 구간 6,946건, 거래액 12,947,751달러, 건당 1,864달러; 수수료 구간 14,140건, 거래액 1,120,106달러, 건당 79달러. 대액 거래는 0% 구간에 집중되고, 수수료는 소액 거래에 집중된다. 대액 0%의 가장 합리적인 해석은 배포자가 높은 가스로 자기 거래를 하는 것이다: 높은 가스와 애그리게이터 견적이 동일한 판정에 부합하며, 자기 거래의 수수료 비용은 거의 0에 가깝다. 조작된 거래량은 풀을 시세 사이트 순위에 올린다(스냅샷은 24시간 거래량 약 1,082만 달러, 16,671건을 보여줌), 애그리게이터의 눈에는 이것이 깊이가 좋고 수수료율이 낮은 풀이다.
판정 3에서 19개의 과거 수수료 구간을 언급했으며, 이들은 디컴파일된 수수료 구성 함수(셀렉터 0x4d909a45, 공개 시그니처 미등록)에서 나온다:

네 개의 수수료 구간과 임계값이 전체적으로 패킹되어 keccak(poolId, 2)라는 저장 슬롯에 기록된다. 온체인 실측에서 해당 슬롯 판독값은 0x0138800186a00186a0이며, 함수의 패킹 형식과 구간별로 대응한다. 수수료 구간은 여기서 언제든 조정 가능한 파라미터이며, 배포 시 고정된 것이 아니다.
이 풀은 08-22 06:57에 생성되었고, 07:13에 첫 스왑이 발생했으며, 21:39가 본문의 샘플 거래입니다. 08-23 12:25에 마지막 체결이 있었고 이후 등록이 해제되었습니다. 전체 활성 기간은 약 29시간이며, 총 거래량은 14,067,857달러, 수수료 수입은 131,888달러입니다(배포자 비용 미차감). 재검토 시점에 등록 플래그는 다시 활성화 상태로 돌아와 있었습니다——풀은 여전히 존재하며 언제든 다시 활성화할 수 있습니다.
전체 공격 체인을 연결하면 다음과 같습니다:

전체 체인의 인과관계는 위 그림에 모두 담겨 있으며, 세 가지 조건이 모두 충족되어야 합니다: 시뮬레이션이 처음 두 계층을 통과할 것, 실제 거래가 세 번째 계층에 도달할 것, 버퍼가 수수료율보다 클 것. 어느 하나라도 성립하지 않으면 이 수법은 무효화됩니다.
첫 번째 지점은 애그리게이터의 견적 방식입니다. 근본 원인은 시뮬레이션과 실행이 같은 경로를 거치지 않는다는 것입니다: 견적은 이상화된 풀 수준 조회를 사용하며 제로 주소와 높은 가스가 설정되고, 실제 거래는 실제 신원과 이미 소모된 가스를 가지고 진행됩니다. Hook이 바로 이 차이를 읽어낼 수 있어 수수료율이 분기됩니다. 대응 방법은 시뮬레이션을 실행에 가깝게 만드는 것입니다——실제로 전송할 Router calldata, 실제 from/to, 온체인 거래에 근접한 가스로 시뮬레이션하고, 체결 후 Swap 이벤트를 디코딩하여 fee를 검증하며, 견적과 맞지 않으면 가중치를 낮추거나 라우팅에서 제외합니다.
두 번째 지점은 사용자의 슬리피지 설정입니다. 근본 원인은 min_out이 너무 느슨하게 설정된 것입니다: 20.32%의 버퍼가 12.8%의 수수료율을 완전히 삼켜버려 거래가 정상적으로 성공합니다. 주요 스테이블코인 경로에서는 0.1%–1%면 일상 사용에 충분하니, 거래发起 전에 이 숫자를 확인하십시오; 지갑과 프런트엔드가 기본값을 더 엄격하게 설정하면 사용자의 대부분의 위험을 차단할 수 있습니다.
세 번째 지점은 라우팅准入입니다. 근본 원인은 모든 동적 수수료 풀이 견적 경쟁에 직접 참여할 수 있다는 것입니다: 오픈소스도 없고 감사 기록도 없는 Hook이라도刷出来的 거래량으로 추천 경로에 진입할 수 있습니다. 신뢰할 수 있는 이력이 없는 동적 수수료 풀은 기본적으로 라우팅에 포함하지 않거나 현저히 가중치를 낮추는 것이 가장 안전한 처리입니다.
·샘플 거래:
https://bscscan.com/tx/0x7615dec2ce80506ec461a47739eb8533ac2bc7c605ca56c255964481b76363d0(블록 117497524)
·풀 초기화 트랜잭션:
https://bscscan.com/tx/0x8fa72ef72d77b61f715dc9ce90548249ab045c6d78716078cd5ab425bbe69a05
·PoolManager:
0x28e2ea090877bf75740558f6bfb36a5ffee9e9df
·Pool ID:
0x36e5540e9dedc02229fe8a82aa5b10c0bf07d1fa74e4f2ffe0efd00fa1a36aea
·Hook:
0xd111b3ddd92e627f1864520c770e913ec04e0880(소스 코드 없음, 의사 코드는 본 문서에서 독립적으로 디컴파일; 관리자 0x08b03e1a5444d469f4dc954e74d3f662c94a6b13)
·체결 및 수수료: 해당 풀의 전체 21,086건 Swap 이벤트를 건별 누적 집계(eth_getLogs), WBNB는 동일 거래 실행 가격으로 환산; 수익은 총액 기준이며 배포자 비용은 미공제
BlockBeats 공식 커뮤니티에 참여하세요:
Telegram 구독 그룹:https://t.me/theblockbeats
Telegram 토론 그룹:https://t.me/BlockBeats_App
Twitter 공식 계정:https://twitter.com/BlockBeatsAsia