uniswap_viewer.viewer_sync

viewer_sync.py - Synchronous interface for accessing Uniswap V3 pool data.

This module provides a synchronous viewer for querying Uniswap V3 pools using web3.py. It allows retrieving token prices, tick data, and computing tick-related values such as tick index and tick slots.

Main components:

  • Viewer: a class for interacting with a Uniswap V3 pool.
  • stream_new_blocks: generator function to yield new block numbers as they appear.
  • get_decimals: helper function to fetch token decimals from an ERC-20 contract.
  1"""
  2viewer_sync.py - Synchronous interface for accessing Uniswap V3 pool data.
  3
  4This module provides a synchronous viewer for querying Uniswap V3 pools using
  5web3.py. It allows retrieving token prices, tick data, and computing 
  6tick-related values such as tick index and tick slots.
  7
  8Main components:
  9- `Viewer`: a class for interacting with a Uniswap V3 pool.
 10- `stream_new_blocks`: generator function to yield new block numbers as they 
 11                       appear.
 12- `get_decimals`: helper function to fetch token decimals from an ERC-20 
 13                  contract.
 14"""
 15
 16import math
 17import time
 18import weakref
 19from typing import Optional, Dict, Any
 20from collections.abc import Generator
 21
 22from web3 import Web3
 23
 24from .utils import get_abi, TICKS_KEYS, prepare_token_address
 25
 26
 27class Viewer:
 28    """
 29    A synchronous Uniswap V3 pool viewer.
 30
 31    This class enables price and tick queries for a specific Uniswap V3 pool
 32    defined by a token pair and a fee tier.
 33    """
 34
 35    def __init__(self, w3: Web3, token0: str, token1: str, fee: int = 3000):
 36        """
 37        Initializes the Viewer instance.
 38
 39        Args:
 40            w3 (Web3): Web3 instance.
 41            token0 (str): Address of the first token (must be > token1).
 42            token1 (str): Address of the second token.
 43            fee (int, optional): Pool fee in basis points. Allowed values are 
 44                                 100 (0.01%), 500 (0.05%), 3000 (0.3%), 10000 
 45                                 (1%). Defaults to 3000 (0.3%).
 46
 47        Raises:
 48            AssertionError: If fee is invalid or token0 is not > token1 in bits.
 49        """
 50        # Prepare arguments
 51        token0 = prepare_token_address(token0)
 52        token1 = prepare_token_address(token1)
 53
 54        # Check arguments
 55        assert fee in (100, 500, 3000, 10000), \
 56            "Allowed fee must be in (100, 500, 3000, 10000) " \
 57            "that corresponds to 0.01%, 0.05%, 0.3%, 1%."
 58
 59        assert int(token0, 16) > int(token1, 16), \
 60            "token0 must go after token1 in bit representation."
 61
 62        # Attributes
 63        self._w3 = weakref.ref(w3)
 64        self._token0 = self._w3().to_checksum_address(token0)
 65        self._token1 = self._w3().to_checksum_address(token1)
 66        self._fee = fee
 67        self._decimals0 = None
 68        self._decimals1 = None
 69        self._pool_address = None
 70        self._pool_contract = None
 71
 72    def init(self):
 73        """
 74        Initializes the pool contract by resolving its address from the factory.
 75
 76        Must be called before using methods like `get_price` or `get_tick_data`.
 77
 78        Raises:
 79            AssertionError: If no pool exists for the provided tokens and fee.
 80        """
 81        self._decimals0 = get_decimals(self._w3(), self._token0)
 82        self._decimals1 = get_decimals(self._w3(), self._token1)
 83        self._pool_address = self._get_pool_address()
 84        self._pool_contract = self._w3().eth.contract(
 85            address=self._pool_address, abi=get_abi('pool-abi')
 86        )
 87
 88    def get_price(self, block_num: Optional[int] = None) -> float:
 89        """
 90        Returns the current pool price as a floating-point value.
 91
 92        The result is adjusted for token decimals.
 93
 94        Args:
 95            block_num (int, optional): Specific block number. If None, uses 
 96                                       latest.
 97
 98        Returns:
 99            float: The spot price of token0 denominated in token1.
100        """
101        self._check_initialized()
102        slot0 = self._pool_contract.functions.slot0().call(
103            block_identifier=block_num
104        )
105        sqrtPriceX96 = slot0[0]
106        price = (sqrtPriceX96 / (2**96)) ** 2
107        price *= 10 ** (self._decimals1 - self._decimals0)
108        return price
109
110    def get_tick_data(self, tick: int, 
111                      block_num: Optional[int] = None) -> Dict[str, Any]:
112        """
113        Fetches tick data for a given tick index from the pool.
114
115        Args:
116            tick (int): The tick index.
117            block_num (int, optional): Specific block number. If None, uses 
118                                       latest.
119
120        Returns:
121            dict: A dictionary of tick data fields, keyed by TICKS_KEYS.
122        """
123        self._check_initialized()
124        self._check_available_tick(tick)
125        tick_data = self._pool_contract.functions.ticks(tick).call(
126            block_identifier=block_num
127        )
128        return dict(zip(TICKS_KEYS, tick_data))
129
130    def calc_tick(self, price: float, normalize: bool = False) -> int:
131        """
132        Calculates the tick index corresponding to a given price.
133
134        Args:
135            price (float): Price of token0 in token1 units.
136            normalize (bool): Apply `tick_slot` in the end to get the tick slot. 
137                              Defaults to False.
138
139        Returns:
140            int: Closest tick index.
141        """
142        price *= 10 ** (self._decimals0 - self._decimals1)
143        tick = math.floor(math.log(price) / math.log(1.0001))
144        if normalize:
145            tick = self.tick_slot(tick)
146        return tick
147
148    def tick_spacing(self) -> int:
149        """
150        Returns the tick spacing for the pool based on the fee tier.
151
152        Returns:
153            int: Tick spacing (1, 10, 60, or 200).
154        """
155        return {
156            100: 1,
157            500: 10,
158            3000: 60,
159            10000: 200,
160        }[self._fee]
161
162    def tick_slot(self, tick: int) -> int:
163        """
164        Calculates the tick slot for a given tick.
165
166        A tick slot is the lower bound of the spacing interval that contains the 
167        tick.
168
169        Args:
170            tick (int): The tick index.
171
172        Returns:
173            int: The tick slot index.
174        """
175        return tick - tick % self.tick_spacing()
176
177    def _get_pool_address(self) -> str:
178        factory_contract = self._w3().eth.contract(
179            address='0x1F98431c8aD98523631AE4a59f267346ea31F984', 
180            abi=get_abi('factory-abi'),
181        )
182        pool_address = factory_contract.functions \
183            .getPool(self._token1, self._token0, self._fee).call()
184        assert pool_address != "0x0000000000000000000000000000000000000000", \
185            "Could not find pool for the tokens."
186        return pool_address
187
188    def _check_initialized(self):
189        assert self._pool_contract is not None, \
190            "Instance is not initialized, " \
191            "run `instance.init()` right after creation."
192
193    def _check_available_tick(self, tick):
194        spacing = self.tick_spacing()
195        assert tick % spacing == 0, \
196            f"Tick must be multiple of {spacing} due to fee = {self._fee}, " \
197            f"{tick} given. Please, use `tick = viewer.tick_slot(tick)` or " \
198            f"`tick = viewer.calc_tick(price, normalize=True)` to normalize."
199
200
201def stream_new_blocks(w3: Web3, timeout: int = 1) -> Generator[int]:
202    """
203    Yields new block numbers as they appear on-chain.
204
205    This generator polls the chain at regular intervals to detect new blocks
206    and yields all blocks that were mined since the last check.
207
208    Args:
209        w3 (Web3): An initialized Web3 instance.
210        timeout (int): Time delay in seconds between polling. Defaults to 1.
211
212    Yields:
213        int: Block numbers in increasing order.
214    """
215    block_num_prev = None
216    while True:
217        block_num = w3.eth.block_number
218        if block_num_prev is None:
219            yield block_num
220        else:
221            yield from range(block_num_prev + 1, block_num + 1)
222        block_num_prev = block_num
223        time.sleep(timeout)
224
225
226def get_decimals(w3: Web3, token: str) -> int:
227    """
228    Returns the number of decimals for a given ERC-20 token.
229
230    Args:
231        w3 (Web3): Web3 instance.
232        token (str): Ethereum address of the token.
233
234    Returns:
235        int: Number of decimal places used by the token.
236    """
237    token_contract = w3.eth.contract(address=token, abi=get_abi('erc20-abi'))
238    decimals = token_contract.functions.decimals().call()
239    return decimals
class Viewer:
 28class Viewer:
 29    """
 30    A synchronous Uniswap V3 pool viewer.
 31
 32    This class enables price and tick queries for a specific Uniswap V3 pool
 33    defined by a token pair and a fee tier.
 34    """
 35
 36    def __init__(self, w3: Web3, token0: str, token1: str, fee: int = 3000):
 37        """
 38        Initializes the Viewer instance.
 39
 40        Args:
 41            w3 (Web3): Web3 instance.
 42            token0 (str): Address of the first token (must be > token1).
 43            token1 (str): Address of the second token.
 44            fee (int, optional): Pool fee in basis points. Allowed values are 
 45                                 100 (0.01%), 500 (0.05%), 3000 (0.3%), 10000 
 46                                 (1%). Defaults to 3000 (0.3%).
 47
 48        Raises:
 49            AssertionError: If fee is invalid or token0 is not > token1 in bits.
 50        """
 51        # Prepare arguments
 52        token0 = prepare_token_address(token0)
 53        token1 = prepare_token_address(token1)
 54
 55        # Check arguments
 56        assert fee in (100, 500, 3000, 10000), \
 57            "Allowed fee must be in (100, 500, 3000, 10000) " \
 58            "that corresponds to 0.01%, 0.05%, 0.3%, 1%."
 59
 60        assert int(token0, 16) > int(token1, 16), \
 61            "token0 must go after token1 in bit representation."
 62
 63        # Attributes
 64        self._w3 = weakref.ref(w3)
 65        self._token0 = self._w3().to_checksum_address(token0)
 66        self._token1 = self._w3().to_checksum_address(token1)
 67        self._fee = fee
 68        self._decimals0 = None
 69        self._decimals1 = None
 70        self._pool_address = None
 71        self._pool_contract = None
 72
 73    def init(self):
 74        """
 75        Initializes the pool contract by resolving its address from the factory.
 76
 77        Must be called before using methods like `get_price` or `get_tick_data`.
 78
 79        Raises:
 80            AssertionError: If no pool exists for the provided tokens and fee.
 81        """
 82        self._decimals0 = get_decimals(self._w3(), self._token0)
 83        self._decimals1 = get_decimals(self._w3(), self._token1)
 84        self._pool_address = self._get_pool_address()
 85        self._pool_contract = self._w3().eth.contract(
 86            address=self._pool_address, abi=get_abi('pool-abi')
 87        )
 88
 89    def get_price(self, block_num: Optional[int] = None) -> float:
 90        """
 91        Returns the current pool price as a floating-point value.
 92
 93        The result is adjusted for token decimals.
 94
 95        Args:
 96            block_num (int, optional): Specific block number. If None, uses 
 97                                       latest.
 98
 99        Returns:
100            float: The spot price of token0 denominated in token1.
101        """
102        self._check_initialized()
103        slot0 = self._pool_contract.functions.slot0().call(
104            block_identifier=block_num
105        )
106        sqrtPriceX96 = slot0[0]
107        price = (sqrtPriceX96 / (2**96)) ** 2
108        price *= 10 ** (self._decimals1 - self._decimals0)
109        return price
110
111    def get_tick_data(self, tick: int, 
112                      block_num: Optional[int] = None) -> Dict[str, Any]:
113        """
114        Fetches tick data for a given tick index from the pool.
115
116        Args:
117            tick (int): The tick index.
118            block_num (int, optional): Specific block number. If None, uses 
119                                       latest.
120
121        Returns:
122            dict: A dictionary of tick data fields, keyed by TICKS_KEYS.
123        """
124        self._check_initialized()
125        self._check_available_tick(tick)
126        tick_data = self._pool_contract.functions.ticks(tick).call(
127            block_identifier=block_num
128        )
129        return dict(zip(TICKS_KEYS, tick_data))
130
131    def calc_tick(self, price: float, normalize: bool = False) -> int:
132        """
133        Calculates the tick index corresponding to a given price.
134
135        Args:
136            price (float): Price of token0 in token1 units.
137            normalize (bool): Apply `tick_slot` in the end to get the tick slot. 
138                              Defaults to False.
139
140        Returns:
141            int: Closest tick index.
142        """
143        price *= 10 ** (self._decimals0 - self._decimals1)
144        tick = math.floor(math.log(price) / math.log(1.0001))
145        if normalize:
146            tick = self.tick_slot(tick)
147        return tick
148
149    def tick_spacing(self) -> int:
150        """
151        Returns the tick spacing for the pool based on the fee tier.
152
153        Returns:
154            int: Tick spacing (1, 10, 60, or 200).
155        """
156        return {
157            100: 1,
158            500: 10,
159            3000: 60,
160            10000: 200,
161        }[self._fee]
162
163    def tick_slot(self, tick: int) -> int:
164        """
165        Calculates the tick slot for a given tick.
166
167        A tick slot is the lower bound of the spacing interval that contains the 
168        tick.
169
170        Args:
171            tick (int): The tick index.
172
173        Returns:
174            int: The tick slot index.
175        """
176        return tick - tick % self.tick_spacing()
177
178    def _get_pool_address(self) -> str:
179        factory_contract = self._w3().eth.contract(
180            address='0x1F98431c8aD98523631AE4a59f267346ea31F984', 
181            abi=get_abi('factory-abi'),
182        )
183        pool_address = factory_contract.functions \
184            .getPool(self._token1, self._token0, self._fee).call()
185        assert pool_address != "0x0000000000000000000000000000000000000000", \
186            "Could not find pool for the tokens."
187        return pool_address
188
189    def _check_initialized(self):
190        assert self._pool_contract is not None, \
191            "Instance is not initialized, " \
192            "run `instance.init()` right after creation."
193
194    def _check_available_tick(self, tick):
195        spacing = self.tick_spacing()
196        assert tick % spacing == 0, \
197            f"Tick must be multiple of {spacing} due to fee = {self._fee}, " \
198            f"{tick} given. Please, use `tick = viewer.tick_slot(tick)` or " \
199            f"`tick = viewer.calc_tick(price, normalize=True)` to normalize."

A synchronous Uniswap V3 pool viewer.

This class enables price and tick queries for a specific Uniswap V3 pool defined by a token pair and a fee tier.

Viewer(w3: web3.main.Web3, token0: str, token1: str, fee: int = 3000)
36    def __init__(self, w3: Web3, token0: str, token1: str, fee: int = 3000):
37        """
38        Initializes the Viewer instance.
39
40        Args:
41            w3 (Web3): Web3 instance.
42            token0 (str): Address of the first token (must be > token1).
43            token1 (str): Address of the second token.
44            fee (int, optional): Pool fee in basis points. Allowed values are 
45                                 100 (0.01%), 500 (0.05%), 3000 (0.3%), 10000 
46                                 (1%). Defaults to 3000 (0.3%).
47
48        Raises:
49            AssertionError: If fee is invalid or token0 is not > token1 in bits.
50        """
51        # Prepare arguments
52        token0 = prepare_token_address(token0)
53        token1 = prepare_token_address(token1)
54
55        # Check arguments
56        assert fee in (100, 500, 3000, 10000), \
57            "Allowed fee must be in (100, 500, 3000, 10000) " \
58            "that corresponds to 0.01%, 0.05%, 0.3%, 1%."
59
60        assert int(token0, 16) > int(token1, 16), \
61            "token0 must go after token1 in bit representation."
62
63        # Attributes
64        self._w3 = weakref.ref(w3)
65        self._token0 = self._w3().to_checksum_address(token0)
66        self._token1 = self._w3().to_checksum_address(token1)
67        self._fee = fee
68        self._decimals0 = None
69        self._decimals1 = None
70        self._pool_address = None
71        self._pool_contract = None

Initializes the Viewer instance.

Args: w3 (Web3): Web3 instance. token0 (str): Address of the first token (must be > token1). token1 (str): Address of the second token. fee (int, optional): Pool fee in basis points. Allowed values are 100 (0.01%), 500 (0.05%), 3000 (0.3%), 10000 (1%). Defaults to 3000 (0.3%).

Raises: AssertionError: If fee is invalid or token0 is not > token1 in bits.

def init(self):
73    def init(self):
74        """
75        Initializes the pool contract by resolving its address from the factory.
76
77        Must be called before using methods like `get_price` or `get_tick_data`.
78
79        Raises:
80            AssertionError: If no pool exists for the provided tokens and fee.
81        """
82        self._decimals0 = get_decimals(self._w3(), self._token0)
83        self._decimals1 = get_decimals(self._w3(), self._token1)
84        self._pool_address = self._get_pool_address()
85        self._pool_contract = self._w3().eth.contract(
86            address=self._pool_address, abi=get_abi('pool-abi')
87        )

Initializes the pool contract by resolving its address from the factory.

Must be called before using methods like get_price or get_tick_data.

Raises: AssertionError: If no pool exists for the provided tokens and fee.

def get_price(self, block_num: Optional[int] = None) -> float:
 89    def get_price(self, block_num: Optional[int] = None) -> float:
 90        """
 91        Returns the current pool price as a floating-point value.
 92
 93        The result is adjusted for token decimals.
 94
 95        Args:
 96            block_num (int, optional): Specific block number. If None, uses 
 97                                       latest.
 98
 99        Returns:
100            float: The spot price of token0 denominated in token1.
101        """
102        self._check_initialized()
103        slot0 = self._pool_contract.functions.slot0().call(
104            block_identifier=block_num
105        )
106        sqrtPriceX96 = slot0[0]
107        price = (sqrtPriceX96 / (2**96)) ** 2
108        price *= 10 ** (self._decimals1 - self._decimals0)
109        return price

Returns the current pool price as a floating-point value.

The result is adjusted for token decimals.

Args: block_num (int, optional): Specific block number. If None, uses latest.

Returns: float: The spot price of token0 denominated in token1.

def get_tick_data(self, tick: int, block_num: Optional[int] = None) -> Dict[str, Any]:
111    def get_tick_data(self, tick: int, 
112                      block_num: Optional[int] = None) -> Dict[str, Any]:
113        """
114        Fetches tick data for a given tick index from the pool.
115
116        Args:
117            tick (int): The tick index.
118            block_num (int, optional): Specific block number. If None, uses 
119                                       latest.
120
121        Returns:
122            dict: A dictionary of tick data fields, keyed by TICKS_KEYS.
123        """
124        self._check_initialized()
125        self._check_available_tick(tick)
126        tick_data = self._pool_contract.functions.ticks(tick).call(
127            block_identifier=block_num
128        )
129        return dict(zip(TICKS_KEYS, tick_data))

Fetches tick data for a given tick index from the pool.

Args: tick (int): The tick index. block_num (int, optional): Specific block number. If None, uses latest.

Returns: dict: A dictionary of tick data fields, keyed by TICKS_KEYS.

def calc_tick(self, price: float, normalize: bool = False) -> int:
131    def calc_tick(self, price: float, normalize: bool = False) -> int:
132        """
133        Calculates the tick index corresponding to a given price.
134
135        Args:
136            price (float): Price of token0 in token1 units.
137            normalize (bool): Apply `tick_slot` in the end to get the tick slot. 
138                              Defaults to False.
139
140        Returns:
141            int: Closest tick index.
142        """
143        price *= 10 ** (self._decimals0 - self._decimals1)
144        tick = math.floor(math.log(price) / math.log(1.0001))
145        if normalize:
146            tick = self.tick_slot(tick)
147        return tick

Calculates the tick index corresponding to a given price.

Args: price (float): Price of token0 in token1 units. normalize (bool): Apply tick_slot in the end to get the tick slot. Defaults to False.

Returns: int: Closest tick index.

def tick_spacing(self) -> int:
149    def tick_spacing(self) -> int:
150        """
151        Returns the tick spacing for the pool based on the fee tier.
152
153        Returns:
154            int: Tick spacing (1, 10, 60, or 200).
155        """
156        return {
157            100: 1,
158            500: 10,
159            3000: 60,
160            10000: 200,
161        }[self._fee]

Returns the tick spacing for the pool based on the fee tier.

Returns: int: Tick spacing (1, 10, 60, or 200).

def tick_slot(self, tick: int) -> int:
163    def tick_slot(self, tick: int) -> int:
164        """
165        Calculates the tick slot for a given tick.
166
167        A tick slot is the lower bound of the spacing interval that contains the 
168        tick.
169
170        Args:
171            tick (int): The tick index.
172
173        Returns:
174            int: The tick slot index.
175        """
176        return tick - tick % self.tick_spacing()

Calculates the tick slot for a given tick.

A tick slot is the lower bound of the spacing interval that contains the tick.

Args: tick (int): The tick index.

Returns: int: The tick slot index.

def stream_new_blocks(w3: web3.main.Web3, timeout: int = 1) -> Generator[int]:
202def stream_new_blocks(w3: Web3, timeout: int = 1) -> Generator[int]:
203    """
204    Yields new block numbers as they appear on-chain.
205
206    This generator polls the chain at regular intervals to detect new blocks
207    and yields all blocks that were mined since the last check.
208
209    Args:
210        w3 (Web3): An initialized Web3 instance.
211        timeout (int): Time delay in seconds between polling. Defaults to 1.
212
213    Yields:
214        int: Block numbers in increasing order.
215    """
216    block_num_prev = None
217    while True:
218        block_num = w3.eth.block_number
219        if block_num_prev is None:
220            yield block_num
221        else:
222            yield from range(block_num_prev + 1, block_num + 1)
223        block_num_prev = block_num
224        time.sleep(timeout)

Yields new block numbers as they appear on-chain.

This generator polls the chain at regular intervals to detect new blocks and yields all blocks that were mined since the last check.

Args: w3 (Web3): An initialized Web3 instance. timeout (int): Time delay in seconds between polling. Defaults to 1.

Yields: int: Block numbers in increasing order.

def get_decimals(w3: web3.main.Web3, token: str) -> int:
227def get_decimals(w3: Web3, token: str) -> int:
228    """
229    Returns the number of decimals for a given ERC-20 token.
230
231    Args:
232        w3 (Web3): Web3 instance.
233        token (str): Ethereum address of the token.
234
235    Returns:
236        int: Number of decimal places used by the token.
237    """
238    token_contract = w3.eth.contract(address=token, abi=get_abi('erc20-abi'))
239    decimals = token_contract.functions.decimals().call()
240    return decimals

Returns the number of decimals for a given ERC-20 token.

Args: w3 (Web3): Web3 instance. token (str): Ethereum address of the token.

Returns: int: Number of decimal places used by the token.