diff --git a/docs/examples.rst b/docs/examples.rst index 746f6556..021ff0ce 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,6 +1,196 @@ Examples ======== -No examples here yet! Why don't you contribute some? +This page shows common usage patterns for Uniswap v2 and v3. For v4 examples, +see the dedicated :doc:`v4` guide. -In the meantime, see the :ref:`Getting started` guide. +The code snippets here mirror the `test suite +`_, which +runs every example against a live mainnet fork using +`Anvil `_. + +.. contents:: Table of contents + :local: + :depth: 2 + +Uniswap v2 +---------- + +Initialization +`````````````` + +.. code:: python + + from uniswap import Uniswap + + ETH = "0x0000000000000000000000000000000000000000" + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F" + + uni = Uniswap( + address="0xYOUR_ADDRESS", + private_key="0xYOUR_PRIVATE_KEY", # or None for read-only + version=2, + provider="https://mainnet.infura.io/v3/YOUR_PROJECT_ID", + ) + +Getting prices +`````````````` + +.. code:: python + + ONE_ETH = 10**18 + + # How much USDC do I get for 1 ETH? + usdc_out = uni.get_price_input(ETH, USDC, ONE_ETH) + print(f"1 ETH → {usdc_out / 10**6:.2f} USDC") + + # How much ETH do I need to buy exactly 1000 USDC? + eth_needed = uni.get_price_output(ETH, USDC, 1000 * 10**6) + print(f"ETH needed for 1000 USDC: {eth_needed / ONE_ETH:.4f}") + +Making swaps +```````````` + +.. code:: python + + # Sell 0.1 ETH, receive USDC (exact input) + tx = uni.make_trade(ETH, USDC, ONE_ETH // 10) + + # Buy exactly 100 USDC, pay in ETH (exact output) + tx = uni.make_trade_output(ETH, USDC, 100 * 10**6) + + # Sell ETH → DAI with a custom recipient + tx = uni.make_trade(ETH, DAI, ONE_ETH // 10, recipient="0xSOME_OTHER_ADDRESS") + +Multi-hop swaps +``````````````` + +For pairs without a direct v2 pool, route through an intermediate token: + +.. code:: python + + WBTC = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" + + # ETH → WBTC (routed automatically through WETH by the v2 router) + wbtc_out = uni.get_price_input(ETH, WBTC, ONE_ETH // 10) + tx = uni.make_trade(ETH, WBTC, ONE_ETH // 10) + +Uniswap v3 +---------- + +v3 adds concentrated liquidity pools at multiple fee tiers. Always specify +``fee`` to select the pool — the right tier depends on the pair's volatility. + +Initialization +`````````````` + +.. code:: python + + from uniswap import Uniswap + + ETH = "0x0000000000000000000000000000000000000000" + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + WBTC = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" + DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F" + + uni = Uniswap( + address="0xYOUR_ADDRESS", + private_key="0xYOUR_PRIVATE_KEY", + version=3, + provider="https://mainnet.infura.io/v3/YOUR_PROJECT_ID", + ) + +Fee tiers +````````` + +Common v3 fee tiers: + +- ``500`` — 0.05% (stablecoin pairs, e.g. USDC/USDT) +- ``3000`` — 0.30% (most pairs, e.g. ETH/USDC) +- ``10000`` — 1.00% (exotic/volatile pairs) + +Getting prices +`````````````` + +.. code:: python + + ONE_ETH = 10**18 + + # Quote using the 0.30% ETH/USDC pool + usdc_out = uni.get_price_input(ETH, USDC, ONE_ETH, fee=3000) + print(f"1 ETH → {usdc_out / 10**6:.2f} USDC (0.30% pool)") + + # Compare with the 0.05% pool (better rate for large trades) + usdc_out_low = uni.get_price_input(ETH, USDC, ONE_ETH, fee=500) + print(f"1 ETH → {usdc_out_low / 10**6:.2f} USDC (0.05% pool)") + + # Exact output quote + eth_needed = uni.get_price_output(ETH, USDC, 1000 * 10**6, fee=500) + +Making swaps +```````````` + +.. code:: python + + # Sell 0.1 ETH for USDC via the 0.05% pool + tx = uni.make_trade(ETH, USDC, ONE_ETH // 10, fee=500) + + # Buy exactly 100 USDC, paying in ETH + tx = uni.make_trade_output(ETH, USDC, 100 * 10**6, fee=500) + +Multi-hop swaps +``````````````` + +The v3 client does not expose a multi-hop path parameter in ``make_trade``. +For pairs without a direct pool, execute two single-hop trades in sequence. +Wait for the first transaction and use the wallet's confirmed balance increase, +not its quote, as the second hop's input: + +.. code:: python + + DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F" + + # ETH → USDC (first hop, 0.05% pool) + usdc_before = uni.get_token_balance(USDC) + tx1 = uni.make_trade(ETH, USDC, ONE_ETH // 10, fee=500) + uni.w3.eth.wait_for_transaction_receipt(tx1) + usdc_received = uni.get_token_balance(USDC) - usdc_before + + # USDC → DAI (second hop, 0.01% stable pool) + tx2 = uni.make_trade(USDC, DAI, usdc_received, fee=100) + +Liquidity management (v3) +````````````````````````` + +.. code:: python + + from uniswap.util import default_tick_range + + ETH = "0x0000000000000000000000000000000000000000" + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + ONE_ETH = 10**18 + + # Get the pool contract instance + pool = uni.get_pool_instance(ETH, USDC, fee=500) + + # Sensible full-range tick bounds for this fee tier + tick_lower, tick_upper = default_tick_range(fee=500) + + # Mint a liquidity position (returns TxReceipt) + receipt = uni.mint_liquidity( + pool, + amount0=ONE_ETH // 10, + amount1=340 * 10**6, + tick_lower=tick_lower, + tick_upper=tick_upper, + deadline=2**64, + ) + assert receipt["status"] + + # Get your token IDs (ERC-721 NFTs representing positions) + positions = uni.get_liquidity_positions() + token_id = positions[0] + + # Close the position (collects fees + withdraws liquidity in one call) + receipt = uni.close_position(token_id, deadline=2**64) diff --git a/docs/index.rst b/docs/index.rst index a64479f3..76dfb2fe 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,8 +28,9 @@ A good place to start is the :doc:`getting-started` guide. :caption: Contents: getting-started + v4 api - cli + cli examples supported-deployments diff --git a/docs/v4.rst b/docs/v4.rst new file mode 100644 index 00000000..25937594 --- /dev/null +++ b/docs/v4.rst @@ -0,0 +1,409 @@ +Uniswap v4 +========== + +Uniswap v4 introduces a fundamentally different architecture from v2 and v3. +All pools live inside a single **PoolManager** singleton; positions are ERC-721 +tokens managed by the **PositionManager**; and every pool is uniquely identified +by a :class:`~uniswap.types.PoolKey` struct instead of a standalone contract address. + +.. contents:: Table of contents + :local: + :depth: 2 + +The PoolKey +----------- + +A :class:`~uniswap.types.PoolKey` is a five-field tuple that uniquely identifies a pool: + +.. code:: python + + from uniswap.types import PoolKey + from uniswap.constants import ETH_ADDRESS, ZERO_HOOK + + eth_usdc_pool = PoolKey( + currency0=ETH_ADDRESS, # "0x000...000" — ETH is the zero address in v4 + currency1="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", # USDC + fee=500, # 0.05% fee tier + tick_spacing=10, # tick spacing that matches the fee tier + hooks=ZERO_HOOK, # "0x000...000" means no hook contract + ) + +.. note:: + + In Uniswap v4, native ETH is represented by the **zero address** + (``0x000...000``), not WETH. The two currencies in a pool are always ordered + so that ``currency0 < currency1`` (lexicographic address ordering). + +Common fee tiers and their tick spacings: + ++------------+--------+--------------+ +| ``fee`` | Rate | Tick spacing | ++============+========+==============+ +| ``100`` | 0.01% | 1 | ++------------+--------+--------------+ +| ``500`` | 0.05% | 10 | ++------------+--------+--------------+ +| ``3000`` | 0.30% | 60 | ++------------+--------+--------------+ +| ``10000`` | 1.00% | 200 | ++------------+--------+--------------+ + +Connecting to Uniswap v4 +------------------------ + +Import :class:`~uniswap.Uniswap4` and provide a wallet address, private key, and +a mainnet Web3 provider. Omit ``private_key`` for read-only use. + +.. code:: python + + from uniswap import Uniswap4 + + address = "0xYOUR_ADDRESS" + private_key = "0xYOUR_PRIVATE_KEY" # or None for read-only + + uni = Uniswap4( + address=address, + private_key=private_key, + provider="https://mainnet.infura.io/v3/YOUR_PROJECT_ID", + ) + + # Or pass a Web3 instance directly: + from web3 import Web3 + w3 = Web3(Web3.HTTPProvider("https://mainnet.infura.io/v3/YOUR_PROJECT_ID")) + uni = Uniswap4(address=address, private_key=private_key, web3=w3) + +The ``PROVIDER`` environment variable is also accepted: + +.. code:: sh + + export PROVIDER="https://mainnet.infura.io/v3/YOUR_PROJECT_ID" + +Getting the spot price +---------------------- + +:func:`~uniswap.Uniswap4.get_token_token_spot_price` returns the current price +of ``token0`` denominated in ``token1``, derived from the pool's ``sqrtPriceX96`` +slot via the v4 StateView contract. + +.. code:: python + + from uniswap.constants import ETH_ADDRESS, ZERO_HOOK + + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + + # How many USDC per 1 ETH? + price = uni.get_token_token_spot_price( + token0=ETH_ADDRESS, + token1=USDC, + fee=500, + tick_spacing=10, + hooks=ZERO_HOOK, + ) + print(f"ETH/USDC spot price: {price:.2f}") # e.g. 3412.54 + + # Inverse: ETH price in USDC terms + price_inv = uni.get_token_token_spot_price(USDC, ETH_ADDRESS, fee=500, tick_spacing=10) + +Quoting a trade +--------------- + +The quote functions call the on-chain v4 Quoter contract and account for +liquidity depth and fees. Use them instead of the spot price whenever you need +an accurate estimate for a specific trade size. + +Exact input — how much output do I get? +``````````````````````````````````````` + +:func:`~uniswap.Uniswap4.get_price_input` returns the amount of ``token1`` +received for a fixed amount of ``token0``. + +.. code:: python + + from uniswap.constants import ETH_ADDRESS + + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + ONE_ETH = 10**18 + + # How much USDC do I get for 1 ETH? + usdc_out = uni.get_price_input( + token0=ETH_ADDRESS, + token1=USDC, + qty=ONE_ETH, + fee=500, + tick_spacing=10, + ) + print(f"1 ETH → {usdc_out / 10**6:.2f} USDC") + +For a 2-hop route (e.g. ETH → USDC → USDT), pass a ``route`` list of +:class:`~uniswap.types.PoolKey` objects instead of ``fee``/``tick_spacing``: + +.. code:: python + + from uniswap.types import PoolKey + from uniswap.constants import ETH_ADDRESS, ZERO_HOOK + + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + + eth_usdc = PoolKey(currency0=ETH_ADDRESS, currency1=USDC, fee=500, tick_spacing=10, hooks=ZERO_HOOK) + usdc_usdt = PoolKey(currency0=USDC, currency1=USDT, fee=100, tick_spacing=1, hooks=ZERO_HOOK) + + usdt_out = uni.get_price_input( + token0=ETH_ADDRESS, + token1=USDT, + qty=ONE_ETH, + route=[eth_usdc, usdc_usdt], + ) + +Exact output — how much input do I need? +````````````````````````````````````````` + +:func:`~uniswap.Uniswap4.get_price_output` returns the amount of ``token0`` +required to receive a fixed amount of ``token1``. + +.. code:: python + + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + + # How much ETH do I need to buy exactly 1000 USDC? + eth_needed = uni.get_price_output( + token0=ETH_ADDRESS, + token1=USDC, + qty=1000 * 10**6, # 1000 USDC in smallest unit + fee=500, + tick_spacing=10, + ) + print(f"ETH needed for 1000 USDC: {eth_needed / 10**18:.4f}") + +Estimating price impact +----------------------- + +:func:`~uniswap.Uniswap4.estimate_price_impact` compares the quoted price to +the pool spot price to estimate slippage. + +.. code:: python + + ONE_ETH = 10**18 + + impact = uni.estimate_price_impact( + token0=ETH_ADDRESS, + token1=USDC, + qty=ONE_ETH, + fee=500, + tick_spacing=10, + ) + print(f"Price impact: {impact:.4%}") # e.g. "Price impact: 0.0152%" + + if impact > 0.01: # warn above 1% + print("High price impact — consider splitting the trade.") + +Making a swap +------------- + +.. warning:: + + Always verify the expected output (via :func:`~uniswap.Uniswap4.get_price_input` + or :func:`~uniswap.Uniswap4.estimate_price_impact`) before executing a swap. + Low-liquidity pools can cause large, unexpected losses. + +:func:`~uniswap.Uniswap4.make_swap_input` executes a swap for a fixed input +amount. Pass the quoted output as ``qtycap``; the client applies its configured +``max_slippage`` tolerance when it builds the transaction. + +.. code:: python + + from uniswap.types import PoolKey + from uniswap.constants import ETH_ADDRESS, ZERO_HOOK + + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + ONE_ETH = 10**18 + SELL_AMOUNT = ONE_ETH // 10 # 0.1 ETH + + pool_key = PoolKey( + currency0=ETH_ADDRESS, + currency1=USDC, + fee=500, + tick_spacing=10, + hooks=ZERO_HOOK, + ) + + # Uniswap4 applies its configured max_slippage (1% by default) to this quote + expected_usdc = uni.get_price_input(ETH_ADDRESS, USDC, SELL_AMOUNT, fee=500, tick_spacing=10) + + tx_hash = uni.make_swap_input( + input_token=ETH_ADDRESS, + output_token=USDC, + qty=SELL_AMOUNT, + qtycap=expected_usdc, + swap_pool_key=pool_key, + ) + receipt = uni.w3.eth.wait_for_transaction_receipt(tx_hash) + print(f"Swap mined in block {receipt['blockNumber']}") + +:func:`~uniswap.Uniswap4.make_swap_output` buys an exact output amount. Pass the +quoted input as ``qtycap``; the client applies ``max_slippage`` to derive the +maximum input: + +.. code:: python + + # Buy exactly 100 USDC; max_slippage is applied to this quoted ETH input + expected_eth = uni.get_price_output(ETH_ADDRESS, USDC, 100 * 10**6, fee=500, tick_spacing=10) + + tx_hash = uni.make_swap_output( + input_token=ETH_ADDRESS, + output_token=USDC, + qty=100 * 10**6, + qtycap=expected_eth, + swap_pool_key=pool_key, + ) + +Discovering pools +----------------- + +:class:`~uniswap.util.V4pools` scans ``PoolManager Initialize`` events to find +all pools for a given pair of tokens. + +.. code:: python + + from web3 import Web3 + from uniswap.util import V4pools + from uniswap.constants import ETH_ADDRESS + + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + + w3 = Web3(Web3.HTTPProvider("https://mainnet.infura.io/v3/YOUR_PROJECT_ID")) + pools = V4pools(w3) + + # Scan the chain (slow on first run — save to disk for reuse) + pools.fetch_poolkey_data(chunk_size=2000) + pools.save_poolkeys_list("/tmp/v4_pools.json") + + # Load from disk on subsequent runs + pools.load_poolkeys_list("/tmp/v4_pools.json") + + # Get all ETH/USDC pools + eth_usdc_pools = pools.get_poolkeys_sublist(ETH_ADDRESS, USDC) + for pk in eth_usdc_pools: + print(f"fee={pk.fee}, tick_spacing={pk.tick_spacing}, hooks={pk.hooks}") + +Managing liquidity +------------------ + +Uniswap v4 positions are ERC-721 tokens managed by the PositionManager. + +Creating a pool +``````````````` + +New pools are created via :func:`~uniswap.Uniswap4.create_pool`, which sets the +initial price. The price is expressed as ``sqrtPriceX96`` (an integer). + +.. code:: python + + import math + from uniswap.types import PoolKey + from uniswap.constants import ETH_ADDRESS, ZERO_HOOK + + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + pool_key = PoolKey( + currency0=ETH_ADDRESS, currency1=USDC, fee=500, tick_spacing=10, hooks=ZERO_HOOK + ) + + # Encode an initial price of ~3400 USDC per ETH as sqrtPriceX96 + # price_ratio = token1_amount / token0_amount (in smallest units) + price_ratio = (3400 * 10**6) / 10**18 + sqrt_price_x96 = int(math.sqrt(price_ratio) * 2**96) + + tx = uni.create_pool(pool_key=pool_key, sqrt_price_x96=sqrt_price_x96) + uni.w3.eth.wait_for_transaction_receipt(tx) + +Minting a position +`````````````````` + +:func:`~uniswap.Uniswap4.mint_position` adds liquidity to a tick range and +returns an ERC-721 NFT representing the position. + +.. code:: python + + ONE_ETH = 10**18 + + tx = uni.mint_position( + pool_key=pool_key, + tick_lower=-887270, # near minimum tick + tick_upper=887270, # near maximum tick (full range) + liquidity=10**18, # amount of liquidity to provide + amount0=ONE_ETH // 10, # max ETH to spend + amount1=340 * 10**6, # max USDC to spend + ) + + # Retrieve the ERC-721 token ID(s) minted by this transaction + token_ids = uni.get_minted_token_id(tx.hex()) + token_id = token_ids[0] + print(f"Minted position with token ID: {token_id}") + +Reading position state +`````````````````````` + +.. code:: python + + from uniswap.types import PoolKey + + position = uni.get_position_info(token_id) + liquidity = uni.position_manager_get_position_liquidity(token_id) + print(f"Liquidity: {liquidity}") + + # Read the position value (amounts of token0/token1 at current price) + ETH_DECIMALS = 18 + USDC_DECIMALS = 6 + value = uni.get_position_value(token_id, ETH_DECIMALS, USDC_DECIMALS) + print(f"token0 amount: {value['amount0']}, token1 amount: {value['amount1']}") + +Collecting fees and removing liquidity +``````````````````````````````````````` + +.. code:: python + + from uniswap.types import PoolKey + + # Build the PoolKey from position info + position = uni.get_position_info(token_id) + pool_key = PoolKey( + position["currency0"], position["currency1"], + position["fee"], position["tickSpacing"], position["hooks"] + ) + + # Collect all accumulated fees + uni.collect_fees(pool_key=pool_key, token_id=token_id) + + # Remove all liquidity (burn requires the position to be fully empty) + liquidity = uni.position_manager_get_position_liquidity(token_id) + uni.decrease_liquidity( + pool_key=pool_key, + token_id=token_id, + liquidity=liquidity, + amount0_min=0, + amount1_min=0, + ) + + # Burn the position NFT once fully emptied + uni.burn_position(pool_key=pool_key, token_id=token_id, amount0_min=0, amount1_min=0) + +Reading pool state via StateView +--------------------------------- + +The StateView contract gives read-only access to pool state without making a +swap. + +.. code:: python + + from uniswap.constants import ETH_ADDRESS, ZERO_HOOK + + USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + + # Current pool price and tick + slot0 = uni.stateview_get_slot0(ETH_ADDRESS, USDC, fee=500, tick_spacing=10, hooks=ZERO_HOOK) + print(f"sqrtPriceX96: {slot0['sqrtPriceX96']}") + print(f"current tick: {slot0['tick']}") + + # Total in-range liquidity + liquidity = uni.stateview_get_liquidity(ETH_ADDRESS, USDC, fee=500, tick_spacing=10, hooks=ZERO_HOOK) + print(f"in-range liquidity: {liquidity}") diff --git a/tests/test_v4_examples.py b/tests/test_v4_examples.py new file mode 100644 index 00000000..0d0320cf --- /dev/null +++ b/tests/test_v4_examples.py @@ -0,0 +1,329 @@ +""" +Runnable examples for Uniswap v4. + +These tests serve as executable documentation: each test demonstrates one +common workflow against a live mainnet fork (Anvil). They run as part of the +``UNISWAP_VERSION=4`` CI matrix job, so every snippet is verified on every +commit. + +Run locally: + PROVIDER= UNISWAP_VERSION=4 pytest tests/test_v4_examples.py -v + +See docs/v4.rst for the prose version of these examples. +""" + +import os +import shutil +import subprocess +from dataclasses import dataclass +from time import sleep +from typing import Generator + +import pytest +from web3 import Web3 + +from uniswap import Uniswap4 +from uniswap.constants import ETH_ADDRESS, ZERO_HOOK +from uniswap.types import PoolKey +from uniswap.util import V4pools + +pytestmark = pytest.mark.skipif( + os.getenv("UNISWAP_VERSION") != "4", + reason="Run with UNISWAP_VERSION=4", +) + +# --------------------------------------------------------------------------- +# Well-known mainnet addresses used in examples +# --------------------------------------------------------------------------- + +USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +USDT_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + +# Canonical fee tiers and tick spacings +ETH_USDC_FEE = 500 +ETH_USDC_TICK_SPACING = 10 +USDC_USDT_FEE = 100 +USDC_USDT_TICK_SPACING = 1 + +ONE_ETH = 10**18 +ONE_USDC = 10**6 +ONE_USDT = 10**6 + +# PoolKey for ETH/USDC 0.05% +ETH_USDC_POOL = PoolKey( + currency0=ETH_ADDRESS, + currency1=USDC_ADDRESS, + fee=ETH_USDC_FEE, + tick_spacing=ETH_USDC_TICK_SPACING, + hooks=ZERO_HOOK, +) + +# PoolKey for USDC/USDT 0.01% +USDC_USDT_POOL = PoolKey( + currency0=USDC_ADDRESS, + currency1=USDT_ADDRESS, + fee=USDC_USDT_FEE, + tick_spacing=USDC_USDT_TICK_SPACING, + hooks=ZERO_HOOK, +) + + +# --------------------------------------------------------------------------- +# Fixtures — same pattern as test_uniswap4.py +# --------------------------------------------------------------------------- + + +@dataclass +class AnvilInstance: + provider: str + eth_address: str + eth_privkey: str + + +@pytest.fixture(scope="module") +def anvil() -> Generator[AnvilInstance, None, None]: + """Start an Anvil instance forked from mainnet.""" + if not shutil.which("anvil"): + raise Exception("anvil was not found in PATH") + if "PROVIDER" not in os.environ: + raise Exception( + "PROVIDER must be set to a mainnet RPC URL so Anvil can fork mainnet." + ) + + port = 10997 # different from test_uniswap4.py (10998) to allow parallel runs + p = subprocess.Popen( + f"anvil --port {port} --chain-id 1 --fork-url {os.environ['PROVIDER']}", + shell=True, + ) + # Anvil test account #2 (1000 ETH pre-funded) + eth_address = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" + eth_privkey = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" + sleep(3) + yield AnvilInstance(f"http://127.0.0.1:{port}", eth_address, eth_privkey) + p.kill() + p.wait() + + +@pytest.fixture(scope="module") +def web3(anvil: AnvilInstance) -> Web3: + return Web3(Web3.HTTPProvider(anvil.provider, request_kwargs={"timeout": 30})) + + +@pytest.fixture(scope="module") +def client(web3: Web3, anvil: AnvilInstance) -> Uniswap4: + return Uniswap4( + anvil.eth_address, + anvil.eth_privkey, + web3=web3, + gas_limit=500_000, + ) + + +@pytest.fixture(scope="module") +def pool_service(web3: Web3) -> V4pools: + return V4pools(web3) + + +# --------------------------------------------------------------------------- +# Example 1: Get the spot price +# --------------------------------------------------------------------------- + + +class TestExampleSpotPrice: + """Example: reading the current pool price from the StateView contract.""" + + def test_get_eth_usdc_spot_price(self, client: Uniswap4) -> None: + """ + get_token_token_spot_price returns the spot price of token0 in units + of token1. The result comes from the pool's sqrtPriceX96 slot without + executing any swap. + """ + price = client.get_token_token_spot_price( + token0=ETH_ADDRESS, + token1=USDC_ADDRESS, + fee=ETH_USDC_FEE, + tick_spacing=ETH_USDC_TICK_SPACING, + ) + # Sanity-check: ETH is worth more than $100 and less than $1M + assert 100 < price < 1_000_000, f"Unexpected ETH/USDC price: {price}" + + def test_spot_price_is_reciprocal(self, client: Uniswap4) -> None: + """The USDC/ETH price should be roughly 1/ETH_USDC price.""" + eth_usdc = client.get_token_token_spot_price( + ETH_ADDRESS, USDC_ADDRESS, ETH_USDC_FEE, ETH_USDC_TICK_SPACING + ) + usdc_eth = client.get_token_token_spot_price( + USDC_ADDRESS, ETH_ADDRESS, ETH_USDC_FEE, ETH_USDC_TICK_SPACING + ) + assert abs(usdc_eth - 1.0 / eth_usdc) / (1.0 / eth_usdc) < 0.001 + + +# --------------------------------------------------------------------------- +# Example 2: Quote a trade before executing it +# --------------------------------------------------------------------------- + + +class TestExampleQuote: + """Example: use the on-chain Quoter to get an accurate trade estimate.""" + + def test_quote_exact_input_single_hop(self, client: Uniswap4) -> None: + """ + get_price_input returns the USDC received for a fixed ETH input. + Use this before a swap to set the slippage cap (qtycap). + """ + usdc_out = client.get_price_input( + token0=ETH_ADDRESS, + token1=USDC_ADDRESS, + qty=ONE_ETH, + fee=ETH_USDC_FEE, + tick_spacing=ETH_USDC_TICK_SPACING, + ) + # Quote should be a positive integer (USDC in smallest unit) + assert usdc_out > 0 + usdc_human = usdc_out / ONE_USDC + assert 100 < usdc_human < 1_000_000, f"Unexpected USDC quote: {usdc_human}" + + def test_quote_exact_output_single_hop(self, client: Uniswap4) -> None: + """ + get_price_output returns the ETH required to buy an exact USDC amount. + """ + eth_needed = client.get_price_output( + token0=ETH_ADDRESS, + token1=USDC_ADDRESS, + qty=1000 * ONE_USDC, # buy 1000 USDC + fee=ETH_USDC_FEE, + tick_spacing=ETH_USDC_TICK_SPACING, + ) + assert eth_needed > 0 + assert eth_needed < ONE_ETH, "1000 USDC should cost less than 1 ETH" + + def test_quote_exact_input_two_hop(self, client: Uniswap4) -> None: + """ + Multi-hop quote: ETH → USDC → USDT via two pools. + Pass a list of PoolKey objects as ``route``. + """ + usdt_out = client.get_price_input( + token0=ETH_ADDRESS, + token1=USDT_ADDRESS, + qty=ONE_ETH // 10, # 0.1 ETH + route=[ETH_USDC_POOL, USDC_USDT_POOL], + ) + assert usdt_out > 0 + usdt_human = usdt_out / ONE_USDT + assert 10 < usdt_human < 100_000, f"Unexpected USDT quote: {usdt_human}" + + +# --------------------------------------------------------------------------- +# Example 3: Estimate price impact before swapping +# --------------------------------------------------------------------------- + + +class TestExamplePriceImpact: + """Example: check price impact to avoid trading into thin pools.""" + + def test_small_trade_has_low_impact(self, client: Uniswap4) -> None: + """ + A small ETH trade in a deep ETH/USDC pool should have near-zero impact. + estimate_price_impact returns a float: 0.01 = 1%. + """ + impact = client.estimate_price_impact( + token0=ETH_ADDRESS, + token1=USDC_ADDRESS, + qty=ONE_ETH // 100, # 0.01 ETH — tiny relative to pool depth + fee=ETH_USDC_FEE, + tick_spacing=ETH_USDC_TICK_SPACING, + ) + # Impact should be small (< 1%) for a tiny trade in a liquid pool + assert impact < 0.01, f"Expected low impact, got {impact:.4%}" + + def test_large_trade_has_higher_impact(self, client: Uniswap4) -> None: + """ + A larger trade should show measurably more price impact than a tiny one. + """ + small_impact = client.estimate_price_impact( + ETH_ADDRESS, + USDC_ADDRESS, + ONE_ETH // 100, + fee=ETH_USDC_FEE, + tick_spacing=ETH_USDC_TICK_SPACING, + ) + large_impact = client.estimate_price_impact( + ETH_ADDRESS, + USDC_ADDRESS, + 100 * ONE_ETH, + fee=ETH_USDC_FEE, + tick_spacing=ETH_USDC_TICK_SPACING, + ) + assert large_impact > small_impact + + +# --------------------------------------------------------------------------- +# Example 4: Read pool state via StateView +# --------------------------------------------------------------------------- + + +class TestExampleStateView: + """Example: read on-chain pool state without making a swap.""" + + def test_get_slot0(self, client: Uniswap4) -> None: + """ + stateview_get_slot0 returns the current sqrtPriceX96 and tick for a pool. + This is the cheapest way to read the current price. + """ + slot0 = client.stateview_get_slot0( + ETH_ADDRESS, + USDC_ADDRESS, + fee=ETH_USDC_FEE, + tick_spacing=ETH_USDC_TICK_SPACING, + hooks=ZERO_HOOK, + ) + assert slot0["sqrtPriceX96"] > 0 + # Tick is a signed integer; ETH/USDC tick should be negative + # (ETH address < USDC address, so token0=ETH, current price ~3400 USDC + # corresponds to a negative tick) + assert isinstance(slot0["tick"], int) + + def test_get_pool_liquidity(self, client: Uniswap4) -> None: + """ + stateview_get_liquidity returns total in-range liquidity for a pool. + A popular pool like ETH/USDC 0.05% should always have positive liquidity. + """ + liquidity = client.stateview_get_liquidity( + ETH_ADDRESS, + USDC_ADDRESS, + fee=ETH_USDC_FEE, + tick_spacing=ETH_USDC_TICK_SPACING, + hooks=ZERO_HOOK, + ) + assert liquidity > 0, "ETH/USDC 0.05% pool should have liquidity" + + +# --------------------------------------------------------------------------- +# Example 5: Discover pools for a token pair +# --------------------------------------------------------------------------- + + +class TestExamplePoolDiscovery: + """Example: filter a saved v4 pool index by token pair using V4pools.""" + + def test_find_eth_usdc_pools(self, pool_service: V4pools) -> None: + """ + Load a checked-in pool index, then filter it by token pair. + + ``fetch_poolkey_data`` is documented in ``docs/v4.rst`` but is not + suitable for the public CI RPC: scanning historical logs requires an + archive endpoint. The fixture is generated by that method and keeps + this example deterministic while exercising the filtering workflow. + """ + pool_data = os.path.join(os.path.dirname(__file__), "pool_list.test") + pool_service.load_poolkeys_list(pool_data) + + eth_usdc_pools = pool_service.get_poolkeys_sublist(ETH_ADDRESS, USDC_ADDRESS) + # There should be at least one ETH/USDC pool (the 0.05% one) + assert len(eth_usdc_pools) >= 1 + + # All returned keys should reference the correct tokens + for pk in eth_usdc_pools: + currencies = {pk.currency0.lower(), pk.currency1.lower()} + assert ETH_ADDRESS.lower() in currencies + assert USDC_ADDRESS.lower() in currencies