Skip to content

Commit

Permalink
Merge pull request #235 from tradingstrategy-ai/analyze-univ3-multiho…
Browse files Browse the repository at this point in the history
…ps-trade

Support analyze multihops trade in Uniswap v3
  • Loading branch information
hieuh25 authored Nov 8, 2024
2 parents 918f133 + f0af7d3 commit 62bc5e8
Show file tree
Hide file tree
Showing 2 changed files with 152 additions and 34 deletions.
67 changes: 45 additions & 22 deletions eth_defi/uniswap_v3/analysis.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import TypedDict
from decimal import Decimal

from web3 import Web3
from web3.logs import DISCARD
Expand Down Expand Up @@ -107,36 +107,59 @@ def analyse_trade_by_receipt(
amount_in = input_args["amountIn"]
amount_out_min = input_args["amountOutMinimum"]

in_token_details = fetch_erc20_details(web3, path[0])
out_token_details = fetch_erc20_details(web3, path[-1])

# The tranasction logs are likely to contain several events like Transfer,
# Sync, etc. We are only interested in Swap events.
# See https://docs.uniswap.org/contracts/v3/reference/core/interfaces/pool/IUniswapV3PoolEvents#swap
swap_events = uniswap.PoolContract.events.Swap().process_receipt(tx_receipt, errors=DISCARD)

# NOTE: we are interested in the last swap event
# AttributeDict({'args': AttributeDict({'sender': '0x6D411e0A54382eD43F02410Ce1c7a7c122afA6E1', 'recipient': '0xC2c2C1C8871C189829d3CCD169010F430275BC70', 'amount0': -292184487391376249, 'amount1': 498353865, 'sqrtPriceX96': 3267615572280113943555521, 'liquidity': 41231056256176602, 'tick': -201931}), 'event': 'Swap', 'logIndex': 3, 'transactionIndex': 0, 'transactionHash': HexBytes('0xe7fff8231effe313010aed7d973fdbe75f58dc4a59c187b230e3fc101c58ec97'), 'address': '0x4529B3F2578Bf95c1604942fe1fCDeB93F1bb7b6', 'blockHash': HexBytes('0xe06feb724020c57c6a0392faf7db29fedf4246ce5126a5b743b2627b7dc69230'), 'blockNumber': 24})
event = swap_events[-1]
if len(swap_events) == 1:
event = swap_events[0]

props = event["args"]
amount0 = props["amount0"]
amount1 = props["amount1"]
tick = props["tick"]
props = event["args"]
amount0 = props["amount0"]
amount1 = props["amount1"]
tick = props["tick"]

pool_address = event["address"]
pool = fetch_pool_details(web3, pool_address)
# Depending on the path, the out token can pop up as amount0Out or amount1Out
# For complex swaps (unspported) we can have both
assert (amount0 > 0 and amount1 < 0) or (amount0 < 0 and amount1 > 0), "Unsupported swap type"

# Depending on the path, the out token can pop up as amount0Out or amount1Out
# For complex swaps (unspported) we can have both
assert (amount0 > 0 and amount1 < 0) or (amount0 < 0 and amount1 > 0), "Unsupported swap type"
if amount0 > 0:
amount_in = amount0
amount_out = amount1
else:
amount_in = amount1
amount_out = amount0

amount_out = amount0 if amount0 < 0 else amount1
assert amount_out < 0, "amount out should be negative for uniswap v3"
# NOTE: LP fee paid in token_in amount, not USD
pool = fetch_pool_details(web3, event["address"])
lp_fee_paid = float(amount_in * pool.fee / 10**in_token_details.decimals)
else:
first_event = swap_events[0]
if first_event["args"]["amount0"] > 0:
amount_in = first_event["args"]["amount0"]
else:
amount_in = first_event["args"]["amount1"]

in_token_details = fetch_erc20_details(web3, path[0])
out_token_details = fetch_erc20_details(web3, path[-1])
price = pool.convert_price_to_human(tick) # Return price of token0/token1
last_event = swap_events[-1]
if last_event["args"]["amount0"] > 0:
amount_out = last_event["args"]["amount1"]
else:
amount_out = last_event["args"]["amount0"]

# NOTE: with multiple hops, we make a temporary workaround that all pools in the path have similar fees
first_pool = fetch_pool_details(web3, first_event["address"])
lp_fee_paid = float(amount_in * first_pool.fee / 10**in_token_details.decimals) * len(swap_events)

assert amount_out < 0, "amount out should be negative for Uniswap v3 swap"

amount_out_cleaned = Decimal(abs(amount_out)) / Decimal(10**out_token_details.decimals)
amount_in_cleaned = Decimal(abs(amount_in)) / Decimal(10**in_token_details.decimals)

amount_in = amount0 if amount0 > 0 else amount1
lp_fee_paid = float(amount_in * pool.fee / 10**in_token_details.decimals)
price = amount_out_cleaned / amount_in_cleaned

return TradeSuccess(
gas_used,
Expand All @@ -148,7 +171,7 @@ def analyse_trade_by_receipt(
price,
in_token_details.decimals,
out_token_details.decimals,
token0=pool.token0,
token1=pool.token1,
token0=in_token_details,
token1=out_token_details,
lp_fee_paid=lp_fee_paid,
)
119 changes: 107 additions & 12 deletions tests/uniswap_v3/test_uniswap_v3_analysis.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Uniswap v3 slippage and trade success tests."""

from decimal import Decimal

import pytest
Expand Down Expand Up @@ -83,30 +84,40 @@ def usdc(web3, deployer) -> Contract:
return token


@pytest.fixture()
def dai(web3, deployer) -> Contract:
"""Mock DAI token.
Note that this token has 18 decimals instead of 6 of real USDC.
"""
token = create_token(web3, deployer, "DAI", "DAI", 100_000_000 * 10**18)
return token


@pytest.fixture()
def weth(uniswap_v3: UniswapV3Deployment) -> Contract:
"""Mock WETH token."""
return uniswap_v3.weth


@pytest.fixture()
def weth_usdc_fee() -> int:
def pool_trading_fee() -> int:
"""Get fee for weth_usdc trading pool on Uniswap v3 (fake)"""
return 3000


@pytest.fixture
def weth_usdc_pool(web3, deployer, uniswap_v3, weth, usdc, weth_usdc_fee) -> HexAddress:
def weth_usdc_pool(web3, deployer, uniswap_v3, weth, usdc, pool_trading_fee) -> HexAddress:
"""ETH-USDC pool with 1.7M liquidity."""
min_tick, max_tick = get_default_tick_range(weth_usdc_fee)
min_tick, max_tick = get_default_tick_range(pool_trading_fee)

pool_contract = deploy_pool(
web3,
deployer,
deployment=uniswap_v3,
token0=weth,
token1=usdc,
fee=weth_usdc_fee,
fee=pool_trading_fee,
)

add_liquidity(
Expand All @@ -122,6 +133,39 @@ def weth_usdc_pool(web3, deployer, uniswap_v3, weth, usdc, weth_usdc_fee) -> Hex
return pool_contract


@pytest.fixture
def weth_dai_pool(
web3: Web3,
deployer: str,
uniswap_v3: UniswapV3Deployment,
weth: Contract,
dai: Contract,
pool_trading_fee: int,
):
pool = deploy_pool(
web3,
deployer,
deployment=uniswap_v3,
token0=weth,
token1=dai,
fee=pool_trading_fee,
)

min_tick, max_tick = get_default_tick_range(pool_trading_fee)
add_liquidity(
web3,
deployer,
deployment=uniswap_v3,
pool=pool,
amount0=500 * 10**18,
amount1=850_000 * 10**18,
lower_tick=min_tick,
upper_tick=max_tick,
)

return pool


def test_analyse_by_receipt(
web3: Web3,
deployer: str,
Expand All @@ -130,9 +174,9 @@ def test_analyse_by_receipt(
weth: Contract,
usdc: Contract,
weth_usdc_pool: Contract,
weth_usdc_fee: int,
pool_trading_fee: int,
):
"""Analyse a Uniswap v3 trade by receipt."""
"""Analyse a Uniswap v3 multihop trade by receipt."""

# See if we can fix the Github CI random fails with this
reset_default_token_cache()
Expand All @@ -146,7 +190,7 @@ def test_analyse_by_receipt(

# Perform a swap USDC->WETH
path = [usdc.address, weth.address] # Path tell how the swap is routed
encoded_path = encode_path(path, [weth_usdc_fee])
encoded_path = encode_path(path, [pool_trading_fee])

tx_hash = router.functions.exactInput(
(
Expand All @@ -166,7 +210,7 @@ def test_analyse_by_receipt(
assert isinstance(analysis, TradeSuccess)
assert analysis.amount_out_decimals == 18
assert analysis.amount_in_decimals == 6
assert analysis.price == pytest.approx(Decimal(1699.9102484539058))
assert analysis.get_human_price(reverse_token_order=True) == pytest.approx(Decimal(1705.125346038114263963445989))
assert analysis.get_effective_gas_price_gwei() == 1
assert analysis.lp_fee_paid == pytest.approx(0.03)

Expand All @@ -177,7 +221,7 @@ def test_analyse_by_receipt(
reverse_path = [weth.address, usdc.address] # Path tell how the swap is routed
tx_hash = router.functions.exactInput(
(
encode_path(reverse_path, [weth_usdc_fee]),
encode_path(reverse_path, [pool_trading_fee]),
user_1,
FOREVER_DEADLINE,
all_weth_amount - 1000,
Expand All @@ -192,10 +236,61 @@ def test_analyse_by_receipt(
analysis = analyse_trade_by_receipt(web3, uniswap_v3, tx, tx_hash, receipt)

assert isinstance(analysis, TradeSuccess)
assert analysis.price == pytest.approx(Decimal(1699.9102484539058))
assert analysis.get_human_price(reverse_token_order=False) == pytest.approx(Decimal(1699.9102484539058))
assert analysis.get_human_price(reverse_token_order=True) == pytest.approx(Decimal(1 / 1699.9102484539058))
assert analysis.price == pytest.approx(Decimal(1694.90994))
assert analysis.get_human_price(reverse_token_order=False) == pytest.approx(Decimal(1694.90994))
assert analysis.get_human_price(reverse_token_order=True) == pytest.approx(Decimal(1 / 1694.90994))
assert analysis.get_effective_gas_price_gwei() == 1
assert analysis.amount_out_decimals == 6
assert analysis.amount_in_decimals == 18
assert analysis.lp_fee_paid == pytest.approx(1.7594014463335705e-05)


def test_analyse_multihop_trade(
web3: Web3,
deployer: str,
user_1,
uniswap_v3: UniswapV3Deployment,
weth: Contract,
usdc: Contract,
dai: Contract,
weth_usdc_pool: Contract,
weth_dai_pool: Contract,
pool_trading_fee: int,
):
"""Analyse a Uniswap v3 trade by receipt."""

# See if we can fix the Github CI random fails with this
reset_default_token_cache()

router = uniswap_v3.swap_router

# Give user_1 some cash to buy ETH and approve it on the router
usdc_amount_to_pay = 500 * 10**6
usdc.functions.transfer(user_1, usdc_amount_to_pay).transact({"from": deployer})
usdc.functions.approve(router.address, usdc_amount_to_pay).transact({"from": user_1})

# Perform a swap USDC->WETH
path = [usdc.address, weth.address, dai.address] # Path tell how the swap is routed
encoded_path = encode_path(path, [pool_trading_fee, pool_trading_fee])

tx_hash = router.functions.exactInput(
(
encoded_path,
user_1,
FOREVER_DEADLINE,
100 * 10**6,
0,
)
).transact({"from": user_1})

tx = web3.eth.get_transaction(tx_hash)
receipt = web3.eth.get_transaction_receipt(tx_hash)

# user_1 has less than 500 USDC left to loses in the LP fees
analysis = analyse_trade_by_receipt(web3, uniswap_v3, tx, tx_hash, receipt)
assert isinstance(analysis, TradeSuccess)
assert analysis.amount_out_decimals == 18
assert analysis.amount_in_decimals == 6
assert analysis.price == pytest.approx(Decimal(0.9938344933))
assert analysis.get_effective_gas_price_gwei() == 1
assert analysis.lp_fee_paid == pytest.approx(0.6)

0 comments on commit 62bc5e8

Please sign in to comment.