Uniswap Quoter 사용법 및 코드 분석 (feat. sandwich Attack 방지)
개발하게된 배경
저희 프로젝트 중 하나인 TONStater 프로젝트는 자신이 프로젝트를 런칭하고 싶다면 런칭을 쉽게 할 수 있게 도와주는 프로젝트 입니다. 프로젝트를 런칭할때 자신의 계획에 따라서 많은 Vault들을 만들게 됩니다. 그 중 프로젝트 오너가 가장 중요하다고 생각할 수 있는 Vault는 PublicSaleVault입니다.
PublicSaleVault는 자신의 토큰을 판매하고 TON을 받을 수 있게 됩니다. 이 판매 후 받은 TON을 판매자가 받기 위해선 그전에 몇가지 단계가 있는데 위의 그림에서 단계를 확인할 수 있습니다.
PublicSale의 과정을 간략히 설명드리면
첫번째 단계는 프로젝트 오너가 판매할 토큰을 PublicSaleVault에 분배하는 단계입니다.
두번째 단계는 유저가 PublicSaleVault의 세일기간동안 TON을 주고 토큰을 구매하는 단계입니다.
세번째 단계는 프로젝트 오너가 설정한 비율로 TON을 TOS로 swap 후 initialLiquidityVault로 보내는 단계입니다. 여기서 보내진 TOS는 초기 TOS-ProjectToken Pool을 만드는데 기여하게 됩니다.
네번째 단계는 남은 TON을 VestingFundVault에 Funding하는 단계입니다. 다섯번째 단계는 프로젝트 오너가 VestingFundVault에서 Vesting기간에 따라서 TON을 claim하는 단계입니다.
이 모든 단계에서는 앞선단계가 실행이 되지않거나 조건이 불충분하다면 뒤의 단계는 실행할 수 없습니다.
이런 단계들을 실행하는 과정에서 저희 프로젝트 궁극적 목표는 누구나 프로젝트를 런칭할 수 있고 누구나 vault의 일정에 따라 실행시킬수 있도록 하는 것 입니다. 특히 initialLiquidityVault로 TOS를 보내는 것은 TOS-ProjectToken Pool을 만드는 것을 지원하는 것으로 이 Pool을 사용할 프로젝트 토큰 구매자들에게 중요한 작업입니다. 그래서 TON을 TOS로 swap하여서 initialLiquidityVault로 TOS를 보내는 작업을 누구나 가능하게 하였는데 swap을 할때 가장 중요한 2가지는 amountIn(얼마큼 swap할 것인가)와 amountMinimumOut(얼만큼 받을 것인가)입니다.
보통 front에서 amountIn에 대한 amountMinimumOut을 계산하고 컨트랙트에서는 그 값을 받아서 swap이 진행됩니다. 하지만 만약 저희도 그렇게 개발이 된다면 저희의 서비스는 누구나 실행을 할 수 있기 때문에 저희가 서비스를 제공하는 front화면이 아닌 Contract에 직접 실행하는 방법이 가능해서 악의적으로 amountMinimumOut값을 입력하여서 실행하게 되면 프로젝트에 대해 손해를 누구나 쉽게 끼칠 수 있습니다.
그래서 저희는 Contract에서는 amountIn만 받고 Contract 내부적으로 amountMinimumOut값을 계산하여서 사용하게 되었습니다.
이 글에서는 sandwich attack이 어떻게 진행되는지 실제 tx를 통해 분석하고 front, Contract에서 Quoter를 사용하는 코드와 Quoter 과 swapRouter코드와의 차이점을 알아보도록 하겠습니다.
sandwich attack 분석
위의 그림을 보면 원래 순서는 Tx1 ~ Tx4까지 순서대로 신청이 되었는데 sandwich attack의 경우를 보면 Tx3위에 Tx3`, 아래에 Tx3``가 추가된 것을 볼 수 있습니다. 이렇게 Tx를 샌드위치처럼 감싸서 다른사람에게는 피해를 끼치고 자신은 이득을 얻는 공격을 sandwich attack이라고 합니다.
실제 있었던 sandwich attack tx를 통해서 어떻게 이득을 보는지 알아보겠습니다.
Tx1 : https://etherscan.io/tx/0x4ca1571ac3176e5961c573668810d8366f89ad8e330114b71119ae4b429662ba
Tx2 : https://etherscan.io/tx/0x2cd3e0d9793d3d0a9875dc1d35005676948096564a888dceb95902f1624a1acc
Tx3 : https://etherscan.io/tx/0x63483dc481ef3d6a13d3806a5a61b562bab7d5d02c2fb7560d1724a673739d2b
위의 트랜잭션 3개는 1개의 블록(14456531)에서 실행이 되었으며 실제 트랜잭션이 일어난 순서는 tx1, tx2, tx3 이지만 트랜잭션이 confirm이 된 순서는 tx2, tx1, tx3입니다. tx2을 보고 앞 뒤에 두 트랜잭션을 발생시켜서 사용자에겐 손실을 공격자는 이득을 발생시켰습니다.
먼저 공격자는 tx1에서 ETH → TOS로 변경하는 swap을 하였습니다. 총 199.909개의 ETH를 135,541.200WTON으로 변경하고 최종적으로 389,265.603TOS로 변경하였습니다. 이때 공격자는 1WTON : 2.87TOS의 비율로 변경을 하였습니다.
그다음 tx2에서는 7,205.081WTON을 15,679.464TOS로 변경하였습니다. 손실자는 1WTON : 2.17TOS의 비율로 변경한 것으로 원래라면 20,678.582TOS를 얻어야하는데 공격을 당하여 15,679.464TOS를 얻어 약 5,000개의 TOS의 손실이 일어났습니다.
마지막으로 tx3에서 TOS → ETH로 변경하는 swap을 하였습니다. 389,265.603TOS를 137,835.430WTON으로 변경하고 최종적으로 201.732ETH로 변경하였습니다. 이때의 비율은 1WTON : 2.82TOS로 최종 Pool의 비율은 다시 처음과 비슷하게 돌아가게됩니다. 공격자는 199.909ETH로 201.732ETH를 얻게되어서 총 1.823개의 ETH의 이득을 얻었습니다.
이 공격이 발생한 이유는 tx를 보낼때 amountOutMinimum값을 0으로 넣어서 손실자가 얼만큼의 TOS를 받아도 tx가 실행되기 때문에 발생하였습니다.
이런 공격을 방지하기 위해서 Quoter를 사용하여서 amountOutMinimum값을 구할 수 있습니다. 다음은 Quoter를 사용하여서 어떻게 구할 수 있는지 알아보겠습니다.
front에서 Quoter를 이용하여 amountMinimumOut값을 얻는 방법
위의 코드는 Quoter Contract를 이용하여 frontEnd에서 amountMinimumOut값을 얻는 코드입니다.
1~7번째 줄은 Quoter Contract를 쓰기 위해서 세팅하는 코드입니다. 여기서 5번째 줄은 uniswap의 Quoter주소를 넣어야합니다.
23~26번째 줄은 자신이 바꿀려고 하는 토큰 swap path를 만드는 과정입니다. 24번째 줄에서 자신이 swap하고 싶은 path길을 설정합니다. 저희의 경우 WTON → TOS로 swap을 하기 위해서 wton을 앞에 tos를 뒤에 설정하였습니다. 이는 exactAmountIn을 넣고 amountMinimumOut을 얻을때의 경우이고 만약 exactAmountOut을 넣고 amountInputMax값을 얻고 싶을때는 path를 반대로 넣어야합니다. 25번째 줄에서는 path길을 구성한 pool의 fee를 설정합니다. 0.05%라면 500, 0.3%라면 3000, 1%라면 10000을 설정하면 됩니다.
이 글에서는 exactAmountIn에 대한 amountMinimumOut을 얻는 예제만 다루게 되는데 다양한 path 예제와 exactAmountOut에 대한 amountInputMax값을 얻는 예제를 보고 싶은 경우에는 https://github.com/tokamak-network/tonswapper-contract/tree/main/test 링크의 테스트 코드에서 확인하실 수 있습니다.
28~31번째 줄은 최종적으로 Quoter Contract에 staticCall을 하여서 amountMinimumOut값을 얻을 수 있습니다. staticCall을 사용하였기 때문에 front에서는 따로 가스비를 지불하지않고 해당 값을 얻을 수 있습니다.
static call이란? 해당 contract의 storage를 변동시키지 않으며 다른 contract로 redirect되는 경우에도 storage 변경을 하지 않습니다. storage를 변경 시키지않기 때문에 가스비를 지불하지 않습니다.
Contract에서 Quoter를 이용하여 amountMinimumOut값을 얻는 방법
1~4번째 줄에서 Quoter주소, 토큰주소 그리고 pool의 fee까지 세팅을 한뒤 6~11번째 줄에서 contrract의 call기능으로 호출을 하였다. 최종적으로 call 이후 받은 result를 parseRevertReason 함수로 decode하여서 Contract에서 amountMinimumOut값을 얻을 수 있습니다.
front에서는 staticCall을 하여서 가스비가 들지 않았지만 contract에서는 call을 하여서 가스비가 더 소모될 것이 예상이 되었고 이를 전 후로 비교해보니 약 78,000정도의 가스비가 더 들어간 것을 확인 할 수 있었습니다. (call기능 추가 전 gasFee : 280630, 추가 후 gasFee : 358651)
여기서 이 글을 읽는 분들은 call을 직접하면 저희는 가격만 알고 싶은 것인데 실제로 swap을 하는 것이 아닌가 라는 의문을 가질 수 있습니다. 저 역시 이런 의문이 들었고 코드를 분석하면서 어떠한 방식으로 swap이 일어나지 않게 가격만 리턴할 수 있었는지 알 수 있었습니다.
Quoter 및 관련 Contract 코드 분석
Quoter Contract을 분석하기 위해서 Quoter Contract, SwapRouter Contract, UniswapV3Pool Contract를 이용하여서 분석하겠습니다.
위의 코드는 Quoter에서 가격을 가져올때 쓰는 함수와 swapRouter에서 swap을 할때 쓰는 함수입니다. 유저가 각각 Contract에 함수를 호출하게 되면 공통적으로 getPool(PoolAddress).swap함수를 call 합니다. (Quoter Contract의 11번째줄, swapRouter Contract의 15번째줄)
위의 코드는 UniswapV3Pool Contract의 swap함수를 간단히 보기위해서 전체코드가 아닌 일부분만 가져온 것입니다. 전체 코드를 확인하고 싶으시면 다음 링크에서 확인하실 수 있습니다. https://github.com/Uniswap/v3-core/blob/main/contracts/UniswapV3Pool.sol#L596-L788
UniswapV3Pool의 swap함수에서 14, 20번째 줄을 보시면 safeTransfer를 이용하여 토큰을 transfer하고 17, 23번째 줄에서 각 msg.sender의 uniswapV3SwapCallback함수를 호출하게 됩니다. 여기 코드까지만 보면Quoter에서 call을 할때도 safeTransfer코드가 실행이 되어서 실제 swap이 일어나는 것은 아닌가라는 의문이 들수있는데 다음 볼 uniswapV3SwapCallback함수에서 의문을 해결할 수 있었습니다.
uniswapV3SwapCallback함수를 호출하는 곳은 msg.sender가 되는데 만약 Quoter에서 swap함수를 Call하였다면 여기서 msg.sender는 Quoter Contract가 될 것이고 swapRouter에서 swap함수를 Call하였다면 msg.sender는 swapRouter Contract가 될 것입니다. 만약 delegateCall을 하게 되면 Quoter와 swapRouter에서 swap함수를 호출하였어도 msg.sender가 Quoter와 swapRouter가 아니고 다른 주소가 되기 때문에 7번째 줄에서 delegateCall을 호출할 수 없도록 delegateCall인지 noDelegateCall으로 체크합니다.
그럼 Quoter와 swapRouter에서의 uniswapV3swapCallback함수 코드를 각각 보겠습니다.
swapRouter의 uniswapV3swapCallback함수에서는 최종적으로 16, 25번째 줄에서 30번째 줄에 보이는 pay함수를 호출하여서 pay함수에서 토큰을 transfer하여 스왑을 마무리 해줍니다.
하지만 Quoter의 uniswapV3swapCallback함수에서는 18, 26번째 줄을 보면 어떠한 경우에도 revert를 하게 됩니다. revert를 하기전 얼만큼 받아야하는지(17번째줄) 또는 얼만큼 지불해야하는지(25번째줄)의 정보를 ptr에 넣어서 revert할때 정보를 보내게 됩니다.
기본적으로 아는 revert의 기능은 트랜잭션의 실행 전체를 취소시키는 것으로 이렇게 되면 저희 Contract에서의 트랜잭션도 취소되는 것인 아닌가? 라는 의문이 드시는 개발자들이 많으실 것이라고 예상됩니다. 하지만 solidity 0.5.0 버전 이후부터는 low-level 함수로 revert를 호출하게 되면 예외처리를 하고 실행을 이어나가게 되고 low-level 함수 콜도 리턴값을 받을 수 있게 되었습니다. 그래서 저희는 Contract Call을 통해서 revert할때 return된 값을 얻을 수 있게 되고 이것을 최종적으로 decode하여서 값을 쓸 수 있었습니다.
마치며
누구나 함수에 접근 가능한 상태에서 swap에 대한 sandwich Attack을 최소화하는 방법으로 Contract내에서 Quoter컨트랙트를 call하여 minimumAmountOut을 계산하여서 사용하였습니다. 이것 외에도 다른 Attack을 방지하기 위해서 함수에서는 2분간 TOS로 swap을 많이한 경우가 일어났는지, 너무 많은 priceImpact가 일어나진 않는지 이런 것들을 체크합니다. 이는 저희 github에서 확인하실 수 있습니다. 만약 누군가가 이 함수를 실행할 수 있는 권한이 있다면 그 누군가는 더 많은 이득을 취할 수 있는 방법이 생기게되어서 누구나 함수에 접근 가능하게 만들었고 누구나 함수에 접근가능하기에 가능한 공격들을 체크하며 방지하게 되었습니다. 이 글이 많은 개발자들에게 도움이 되었으면 바랍니다.