├── .flake8 ├── .github └── workflows │ ├── pypi.yml │ └── pytest.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── dump.py ├── read_hermes.py └── read_one_price_feed.py ├── pyproject.toml ├── pytest.ini ├── pythclient ├── __init__.py ├── config.py ├── exceptions.py ├── hermes.py ├── market_schedule.py ├── price_feeds.py ├── pythaccounts.py ├── pythclient.py ├── ratelimit.py ├── solana.py └── utils.py ├── setup.py └── tests ├── conftest.py ├── test_hermes.py ├── test_mapping_account.py ├── test_market_schedule.py ├── test_price_account.py ├── test_price_account_header.py ├── test_price_component.py ├── test_price_feeds.py ├── test_price_info.py ├── test_product_account.py ├── test_pyth_account.py ├── test_pyth_client.py ├── test_solana_account.py └── test_utils.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Seriously, we don't develop software on 80 character terminals anymore. 3 | max-line-length = 127 4 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-22.04 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | pip install setuptools wheel twine build 18 | - name: Build and publish 19 | env: 20 | TWINE_USERNAME: __token__ 21 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 22 | run: | 23 | python3 -m build 24 | python3 -m twine upload --repository pypi dist/* 25 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: pytest 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.9", "3.10", "3.11", "3.12"] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -e '.[testing]' 25 | 26 | - name: Lint with flake8 27 | run: | 28 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 29 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 30 | 31 | - name: Test with pytest 32 | run: | 33 | pytest 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | 5 | # VS Code 6 | .vscode/ 7 | 8 | # MacOS 9 | .DS_Store 10 | 11 | ### Python ### 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | *.py,cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | cover/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | db.sqlite3-journal 74 | 75 | # Flask stuff: 76 | instance/ 77 | .webassets-cache 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | 85 | # PyBuilder 86 | .pybuilder/ 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # IPython 93 | profile_default/ 94 | ipython_config.py 95 | 96 | # pyenv 97 | # For a library or package, you might want to ignore these files since the code is 98 | # intended to run in multiple environments; otherwise, check them in: 99 | # .python-version 100 | 101 | # pipenv 102 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 103 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 104 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 105 | # install all needed dependencies. 106 | #Pipfile.lock 107 | 108 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 109 | __pypackages__/ 110 | 111 | # Celery stuff 112 | celerybeat-schedule 113 | celerybeat.pid 114 | 115 | # SageMath parsed files 116 | *.sage.py 117 | 118 | # Environments 119 | .env 120 | .venv 121 | env/ 122 | venv/ 123 | ve/ 124 | ENV/ 125 | env.bak/ 126 | venv.bak/ 127 | 128 | # Spyder project settings 129 | .spyderproject 130 | .spyproject 131 | 132 | # Rope project settings 133 | .ropeproject 134 | 135 | # mkdocs documentation 136 | /site 137 | 138 | # mypy 139 | .mypy_cache/ 140 | .dmypy.json 141 | dmypy.json 142 | 143 | # Pyre type checker 144 | .pyre/ 145 | 146 | # pytype static type analyzer 147 | .pytype/ 148 | 149 | # Cython debug symbols 150 | cython_debug/ 151 | 152 | # End of https://www.toptal.com/developers/gitignore/api/python 153 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2021] [Pyth Data Foundation] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Pyth Client in Python 2 | ===================== 3 | 4 | [![pytest](https://github.com/pyth-network/pyth-client-py/actions/workflows/pytest.yml/badge.svg?branch=main)](https://github.com/pyth-network/pyth-client-py/actions/workflows/pytest.yml) 5 | 6 | A Python library to retrieve data from Pyth account structures off the Solana blockchain. 7 | 8 | **NOTE**: This library requires Python 3.7 or greater due to `from __future__ import annotations`. 9 | 10 | Usage 11 | -------------- 12 | 13 | Install the library: 14 | 15 | pip install pythclient 16 | 17 | You can then read the current Pyth price using the following: 18 | 19 | ```python 20 | from pythclient.pythclient import PythClient 21 | from pythclient.pythaccounts import PythPriceAccount 22 | from pythclient.utils import get_key 23 | 24 | solana_network="devnet" 25 | async with PythClient( 26 | first_mapping_account_key=get_key(solana_network, "mapping"), 27 | program_key=get_key(solana_network, "program") if use_program else None, 28 | ) as c: 29 | await c.refresh_all_prices() 30 | products = await c.get_products() 31 | for p in products: 32 | print(p.attrs) 33 | prices = await p.get_prices() 34 | for _, pr in prices.items(): 35 | print( 36 | pr.price_type, 37 | pr.aggregate_price_status, 38 | pr.aggregate_price, 39 | "p/m", 40 | pr.aggregate_price_confidence_interval, 41 | ) 42 | ``` 43 | 44 | This code snippet lists the products on pyth and the price for each product. Sample output is: 45 | 46 | ``` 47 | {'symbol': 'Crypto.ETH/USD', 'asset_type': 'Crypto', 'quote_currency': 'USD', 'description': 'ETH/USD', 'generic_symbol': 'ETHUSD', 'base': 'ETH'} 48 | PythPriceType.PRICE PythPriceStatus.TRADING 4390.286 p/m 2.4331 49 | {'symbol': 'Crypto.SOL/USD', 'asset_type': 'Crypto', 'quote_currency': 'USD', 'description': 'SOL/USD', 'generic_symbol': 'SOLUSD', 'base': 'SOL'} 50 | PythPriceType.PRICE PythPriceStatus.TRADING 192.27550000000002 p/m 0.0485 51 | {'symbol': 'Crypto.SRM/USD', 'asset_type': 'Crypto', 'quote_currency': 'USD', 'description': 'SRM/USD', 'generic_symbol': 'SRMUSD', 'base': 'SRM'} 52 | PythPriceType.PRICE PythPriceStatus.UNKNOWN None p/m None 53 | ... 54 | ``` 55 | 56 | The `examples` directory includes some example applications that demonstrate how to use this library: 57 | * `examples/dump.py` is a detailed usage example that demonstrates the asynchronous API to update prices using a websocket subscription or account polling. 58 | * `examples/read_one_price_feed.py` shows how to read the price of a single price feed using its account key. 59 | 60 | Developer Setup 61 | --------------- 62 | 63 | Using python 3.7 or newer, create, and activate a virtual environment: 64 | 65 | python3 -m venv ve 66 | . ve/bin/activate 67 | 68 | To install this library in editable mode with test dependencies: 69 | 70 | pip install -e '.[testing]' 71 | 72 | To run the unit tests: 73 | 74 | pytest 75 | 76 | If html based test coverage is more your jam: 77 | 78 | pytest --cov-report=html 79 | 80 | The coverage webpages will be in the `htmlcov` directory. 81 | 82 | 83 | Distribution 84 | ------------ 85 | 86 | To build the package for distribution to pypi, first install dependencies: 87 | 88 | `python3 -m pip install --upgrade twine build` 89 | 90 | Next, edit `setup.py` and bump the `version` field. 91 | Then build the package by running: 92 | 93 | ``` 94 | python3 -m build 95 | ``` 96 | 97 | This command will produce packages in the `dist/` directory. 98 | Upload these packages to pypi by running: 99 | 100 | ``` 101 | python3 -m twine upload --repository pypi dist/* 102 | ``` 103 | 104 | This command will require you to log in to a pypi account. 105 | -------------------------------------------------------------------------------- /examples/dump.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import annotations 4 | import asyncio 5 | import os 6 | import signal 7 | import sys 8 | from typing import List, Any 9 | 10 | from loguru import logger 11 | from pythclient.solana import PYTHNET_HTTP_ENDPOINT, PYTHNET_WS_ENDPOINT 12 | 13 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 14 | from pythclient.pythclient import PythClient # noqa 15 | from pythclient.ratelimit import RateLimit # noqa 16 | from pythclient.pythaccounts import PythPriceAccount # noqa 17 | from pythclient.utils import get_key # noqa 18 | 19 | logger.enable("pythclient") 20 | 21 | RateLimit.configure_default_ratelimit(overall_cps=9, method_cps=3, connection_cps=3) 22 | 23 | to_exit = False 24 | 25 | 26 | def set_to_exit(sig: Any, frame: Any): 27 | global to_exit 28 | to_exit = True 29 | 30 | 31 | signal.signal(signal.SIGINT, set_to_exit) 32 | 33 | 34 | async def main(): 35 | use_program = len(sys.argv) >= 2 and sys.argv[1] == "program" 36 | v2_first_mapping_account_key = get_key("pythnet", "mapping") 37 | v2_program_key = get_key("pythnet", "program") 38 | async with PythClient( 39 | first_mapping_account_key=v2_first_mapping_account_key, 40 | program_key=v2_program_key if use_program else None, 41 | solana_endpoint=PYTHNET_HTTP_ENDPOINT, # replace with the relevant cluster endpoints 42 | solana_ws_endpoint=PYTHNET_WS_ENDPOINT # replace with the relevant cluster endpoints 43 | ) as c: 44 | await c.refresh_all_prices() 45 | products = await c.get_products() 46 | all_prices: List[PythPriceAccount] = [] 47 | for p in products: 48 | print(p.key, p.attrs) 49 | prices = await p.get_prices() 50 | for _, pr in prices.items(): 51 | all_prices.append(pr) 52 | print( 53 | pr.key, 54 | pr.product_account_key, 55 | pr.price_type, 56 | pr.aggregate_price_status, 57 | pr.aggregate_price, 58 | "p/m", 59 | pr.aggregate_price_confidence_interval, 60 | ) 61 | 62 | ws = c.create_watch_session() 63 | await ws.connect() 64 | if use_program: 65 | print("Subscribing to program account") 66 | await ws.program_subscribe(v2_program_key, await c.get_all_accounts()) 67 | else: 68 | print("Subscribing to all prices") 69 | for account in all_prices: 70 | await ws.subscribe(account) 71 | print("Subscribed!") 72 | 73 | while True: 74 | if to_exit: 75 | break 76 | update_task = asyncio.create_task(ws.next_update()) 77 | while True: 78 | if to_exit: 79 | update_task.cancel() 80 | break 81 | done, _ = await asyncio.wait({update_task}, timeout=1) 82 | if update_task in done: 83 | pr = update_task.result() 84 | if isinstance(pr, PythPriceAccount): 85 | assert pr.product 86 | print( 87 | pr.product.symbol, 88 | pr.price_type, 89 | pr.aggregate_price_status, 90 | pr.aggregate_price, 91 | "p/m", 92 | pr.aggregate_price_confidence_interval, 93 | ) 94 | break 95 | 96 | print("Unsubscribing...") 97 | if use_program: 98 | await ws.program_unsubscribe(v2_program_key) 99 | else: 100 | for account in all_prices: 101 | await ws.unsubscribe(account) 102 | await ws.disconnect() 103 | print("Disconnected") 104 | 105 | 106 | asyncio.run(main()) 107 | -------------------------------------------------------------------------------- /examples/read_hermes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | 5 | from pythclient.hermes import HermesClient 6 | 7 | async def get_hermes_prices(): 8 | hermes_client = HermesClient([]) 9 | feed_ids = await hermes_client.get_price_feed_ids() 10 | feed_ids_rel = feed_ids[:2] 11 | version_http = 1 12 | version_ws = 1 13 | 14 | hermes_client.add_feed_ids(feed_ids_rel) 15 | 16 | prices_latest = await hermes_client.get_all_prices(version=version_http) 17 | 18 | for feed_id, price_feed in prices_latest.items(): 19 | print("Initial prices") 20 | print(f"Feed ID: {feed_id}, Price: {price_feed['price'].price}, Confidence: {price_feed['price'].conf}, Time: {price_feed['price'].publish_time}") 21 | 22 | print("Starting web socket...") 23 | ws_call = hermes_client.ws_pyth_prices(version=version_ws) 24 | ws_task = asyncio.create_task(ws_call) 25 | 26 | while True: 27 | await asyncio.sleep(5) 28 | if ws_task.done(): 29 | break 30 | print("Latest prices:") 31 | for feed_id, price_feed in hermes_client.prices_dict.items(): 32 | print(f"Feed ID: {feed_id}, Price: {price_feed['price'].price}, Confidence: {price_feed['price'].conf}, Time: {price_feed['price'].publish_time}") 33 | 34 | asyncio.run(get_hermes_prices()) 35 | -------------------------------------------------------------------------------- /examples/read_one_price_feed.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | 5 | from pythclient.pythaccounts import PythPriceAccount, PythPriceStatus 6 | from pythclient.solana import SolanaClient, SolanaPublicKey, PYTHNET_HTTP_ENDPOINT, PYTHNET_WS_ENDPOINT 7 | 8 | async def get_price(): 9 | # pythnet DOGE/USD price account key (available on pyth.network website) 10 | account_key = SolanaPublicKey("FsSM3s38PX9K7Dn6eGzuE29S2Dsk1Sss1baytTQdCaQj") 11 | solana_client = SolanaClient(endpoint=PYTHNET_HTTP_ENDPOINT, ws_endpoint=PYTHNET_WS_ENDPOINT) 12 | price: PythPriceAccount = PythPriceAccount(account_key, solana_client) 13 | 14 | await price.update() 15 | 16 | price_status = price.aggregate_price_status 17 | if price_status == PythPriceStatus.TRADING: 18 | # Sample output: "DOGE/USD is 0.141455 ± 7.4e-05" 19 | print("DOGE/USD is", price.aggregate_price, "±", price.aggregate_price_confidence_interval) 20 | else: 21 | print("Price is not valid now. Status is", price_status) 22 | 23 | await solana_client.close() 24 | 25 | asyncio.run(get_price()) 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | # Prevent SolanaClient from actually reaching out to devnet 4 | --disable-socket 5 | --allow-unix-socket 6 | --cov=pythclient 7 | --no-cov-on-fail 8 | asyncio_mode = auto 9 | -------------------------------------------------------------------------------- /pythclient/__init__.py: -------------------------------------------------------------------------------- 1 | from loguru import logger as _logger 2 | # as per Loguru documentation's recommendation for libraries 3 | _logger.disable(__name__) 4 | -------------------------------------------------------------------------------- /pythclient/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Library-wide settings. 3 | """ 4 | 5 | BACKOFF_MAX_VALUE = 16 6 | BACKOFF_MAX_TRIES = 8 7 | 8 | # The following getter functions are passed to the backoff decorators 9 | 10 | def get_backoff_max_value() -> int: 11 | return BACKOFF_MAX_VALUE 12 | 13 | def get_backoff_max_tries() -> int: 14 | return BACKOFF_MAX_TRIES 15 | -------------------------------------------------------------------------------- /pythclient/exceptions.py: -------------------------------------------------------------------------------- 1 | class RateLimitedException(Exception): 2 | """ 3 | Raised when the client is rate-limited. 4 | """ 5 | pass 6 | 7 | class SolanaException(Exception): 8 | """ 9 | Raised when the Solana API returns an error. 10 | """ 11 | pass 12 | 13 | class WebSocketClosedException(Exception): 14 | """ 15 | Raised when the WebSocket is closed while we are waiting for an update. 16 | """ 17 | 18 | class NotLoadedException(Exception): 19 | """ 20 | Raised when the child accounts are not yet loaded. 21 | """ 22 | 23 | class MissingAccountException(Exception): 24 | """ 25 | Raised when an account is needed but is missing. 26 | """ 27 | -------------------------------------------------------------------------------- /pythclient/hermes.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import TypedDict 3 | import httpx 4 | import os 5 | import json 6 | import websockets 7 | 8 | from .price_feeds import Price 9 | 10 | HERMES_ENDPOINT_HTTPS = "https://hermes.pyth.network/" 11 | HERMES_ENDPOINT_WSS = "wss://hermes.pyth.network/ws" 12 | 13 | 14 | class PriceFeed(TypedDict): 15 | feed_id: str 16 | price: Price 17 | ema_price: Price 18 | update_data: list[str] 19 | 20 | 21 | def parse_unsupported_version(version): 22 | if isinstance(version, int): 23 | raise ValueError("Version number {version} not supported") 24 | else: 25 | raise TypeError("Version must be an integer") 26 | 27 | 28 | class HermesClient: 29 | def __init__(self, feed_ids: list[str], endpoint=HERMES_ENDPOINT_HTTPS, ws_endpoint=HERMES_ENDPOINT_WSS, feed_batch_size=100): 30 | self.feed_ids = feed_ids 31 | self.pending_feed_ids = feed_ids 32 | self.prices_dict: dict[str, PriceFeed] = {} 33 | self.endpoint = endpoint 34 | self.ws_endpoint = ws_endpoint 35 | self.feed_batch_size = feed_batch_size # max number of feed IDs to query at once in https requests 36 | 37 | async def get_price_feed_ids(self) -> list[str]: 38 | """ 39 | Queries the Hermes https endpoint for a list of the IDs of all Pyth price feeds. 40 | """ 41 | 42 | url = os.path.join(self.endpoint, "api/price_feed_ids") 43 | 44 | async with httpx.AsyncClient() as client: 45 | data = (await client.get(url)).json() 46 | 47 | return data 48 | 49 | def add_feed_ids(self, feed_ids: list[str]): 50 | # convert feed_ids to a set to remove any duplicates from the input 51 | new_feed_ids_set = set(feed_ids) 52 | 53 | # update self.feed_ids; convert to set for union operation, then back to list 54 | self.feed_ids = list(set(self.feed_ids).union(new_feed_ids_set)) 55 | 56 | # update self.pending_feed_ids with only those IDs that are truly new 57 | self.pending_feed_ids = list(set(self.pending_feed_ids).union(new_feed_ids_set)) 58 | 59 | @staticmethod 60 | def extract_price_feed_v1(data: dict) -> PriceFeed: 61 | """ 62 | Extracts PriceFeed object from the v1 JSON response (individual price feed) from Hermes. 63 | """ 64 | price = Price.from_dict(data["price"]) 65 | ema_price = Price.from_dict(data["ema_price"]) 66 | update_data = data["vaa"] 67 | price_feed = { 68 | "feed_id": data["id"], 69 | "price": price, 70 | "ema_price": ema_price, 71 | "update_data": [update_data], 72 | } 73 | return price_feed 74 | 75 | @staticmethod 76 | def extract_price_feed_v2(data: dict) -> list[PriceFeed]: 77 | """ 78 | Extracts PriceFeed objects from the v2 JSON response (array of price feeds) from Hermes. 79 | """ 80 | update_data = data["binary"]["data"] 81 | 82 | price_feeds = [] 83 | 84 | for feed in data["parsed"]: 85 | price = Price.from_dict(feed["price"]) 86 | ema_price = Price.from_dict(feed["ema_price"]) 87 | price_feed = { 88 | "feed_id": feed["id"], 89 | "price": price, 90 | "ema_price": ema_price, 91 | "update_data": update_data, 92 | } 93 | price_feeds.append(price_feed) 94 | 95 | return price_feeds 96 | 97 | async def get_pyth_prices_latest(self, feedIds: list[str], version=2) -> list[PriceFeed]: 98 | """ 99 | Queries the Hermes https endpoint for the latest price feeds for a list of Pyth feed IDs. 100 | """ 101 | if version==1: 102 | url = os.path.join(self.endpoint, "api/latest_price_feeds") 103 | params = {"ids[]": feedIds, "binary": "true"} 104 | elif version==2: 105 | url = os.path.join(self.endpoint, "v2/updates/price/latest") 106 | params = {"ids[]": feedIds, "encoding": "base64", "parsed": "true"} 107 | else: 108 | parse_unsupported_version(version) 109 | 110 | async with httpx.AsyncClient() as client: 111 | data = (await client.get(url, params=params)).json() 112 | 113 | if version==1: 114 | results = [] 115 | for res in data: 116 | price_feed = self.extract_price_feed_v1(res) 117 | results.append(price_feed) 118 | elif version==2: 119 | results = self.extract_price_feed_v2(data) 120 | 121 | return results 122 | 123 | async def get_pyth_price_at_time(self, feed_id: str, timestamp: int, version=2) -> PriceFeed: 124 | """ 125 | Queries the Hermes https endpoint for the price feed for a Pyth feed ID at a given timestamp. 126 | """ 127 | if version==1: 128 | url = os.path.join(self.endpoint, "api/get_price_feed") 129 | params = {"id": feed_id, "publish_time": timestamp, "binary": "true"} 130 | elif version==2: 131 | url = os.path.join(self.endpoint, f"v2/updates/price/{timestamp}") 132 | params = {"ids[]": [feed_id], "encoding": "base64", "parsed": "true"} 133 | else: 134 | parse_unsupported_version(version) 135 | 136 | async with httpx.AsyncClient() as client: 137 | data = (await client.get(url, params=params)).json() 138 | 139 | if version==1: 140 | price_feed = self.extract_price_feed_v1(data) 141 | elif version==2: 142 | price_feed = self.extract_price_feed_v2(data)[0] 143 | 144 | return price_feed 145 | 146 | async def get_all_prices(self, version=2) -> dict[str, PriceFeed]: 147 | """ 148 | Queries the Hermes http endpoint for the latest price feeds for all feed IDs in the class object. 149 | 150 | There is a limit on the number of feed IDs that can be queried at once, so this function queries the feed IDs in batches. 151 | """ 152 | pyth_prices_latest = [] 153 | i = 0 154 | while len(self.feed_ids[i : i + self.feed_batch_size]) > 0: 155 | pyth_prices_latest += await self.get_pyth_prices_latest( 156 | self.feed_ids[i : i + self.feed_batch_size], 157 | version=version, 158 | ) 159 | i += self.feed_batch_size 160 | 161 | return dict([(feed['feed_id'], feed) for feed in pyth_prices_latest]) 162 | 163 | async def ws_pyth_prices(self, version=1): 164 | """ 165 | Opens a websocket connection to Hermes for latest prices for all feed IDs in the class object. 166 | """ 167 | if version != 1: 168 | parse_unsupported_version(version) 169 | 170 | async with websockets.connect(self.ws_endpoint) as ws: 171 | while True: 172 | # add new price feed ids to the ws subscription 173 | if len(self.pending_feed_ids) > 0: 174 | json_subscribe = { 175 | "ids": self.pending_feed_ids, 176 | "type": "subscribe", 177 | "verbose": True, 178 | "binary": True, 179 | } 180 | await ws.send(json.dumps(json_subscribe)) 181 | self.pending_feed_ids = [] 182 | 183 | msg = json.loads(await ws.recv()) 184 | if msg.get("type") == "response": 185 | if msg.get("status") != "success": 186 | raise Exception("Error in subscribing to websocket") 187 | try: 188 | if msg["type"] != "price_update": 189 | continue 190 | 191 | feed_id = msg["price_feed"]["id"] 192 | new_feed = msg["price_feed"] 193 | 194 | self.prices_dict[feed_id] = self.extract_price_feed_v1(new_feed) 195 | 196 | except Exception as e: 197 | raise Exception(f"Error in price_update message: {msg}") from e -------------------------------------------------------------------------------- /pythclient/market_schedule.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional, Tuple 2 | from datetime import datetime, timedelta 3 | from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 4 | 5 | # A time is a string of the form HHMM and 2400 is used to represent midnight 6 | Time = str 7 | 8 | # A time range is a tuple of two times, representing the start and end of a trading session. This 9 | # range is *inclusive of the start time and exclusive of the end time*. 10 | TimeRange = Tuple[Time, Time] 11 | 12 | # A daily schedule is a list of time ranges, representing the trading sessions for a given day 13 | DailySchedule = List[TimeRange] 14 | 15 | class MarketSchedule: 16 | def __init__(self, schedule_str: str): 17 | """ 18 | Parse a schedule string in Pyth format: "Timezone;WeeklySchedule;Holidays" 19 | """ 20 | 21 | parts = schedule_str.split(';') 22 | if len(parts) < 2: 23 | raise ValueError("Schedule must contain at least timezone and weekly schedule") 24 | 25 | self.timezone = self._validate_timezone(parts[0]) 26 | # Parse and validate weekly schedule - now returns parsed time ranges 27 | self.weekly_schedule = self._parse_weekly_schedule(parts[1]) 28 | self.holiday_schedule = self._parse_holidays(parts[2] if len(parts) > 2 else "") 29 | 30 | def _validate_timezone(self, timezone_str: str) -> ZoneInfo: 31 | try: 32 | return ZoneInfo(timezone_str) 33 | except ZoneInfoNotFoundError: 34 | raise ValueError(f"Invalid timezone: {timezone_str}") 35 | 36 | def _parse_weekly_schedule(self, schedule: str) -> List[DailySchedule]: 37 | """Parse the weekly schedule (Mon-Sun) into daily schedules""" 38 | days = schedule.split(',') 39 | if len(days) != 7: 40 | raise ValueError("Weekly schedule must contain exactly 7 days") 41 | 42 | weekly_schedule = [] 43 | for day_schedule in days: 44 | try: 45 | weekly_schedule.append(self._parse_daily_schedule(day_schedule)) 46 | except ValueError as e: 47 | raise ValueError(f"Invalid schedule format: {day_schedule}") from e 48 | 49 | return weekly_schedule 50 | 51 | def _parse_holidays(self, holidays: str) -> Dict[str, DailySchedule]: 52 | """Parse holiday overrides in format MMDD/Schedule""" 53 | if not holidays: 54 | return {} 55 | 56 | holiday_dict = {} 57 | for holiday in holidays.split(','): 58 | if not holiday: 59 | continue 60 | date_str, schedule = holiday.split('/') 61 | holiday_dict[date_str] = self._parse_daily_schedule(schedule) 62 | return holiday_dict 63 | 64 | def _parse_daily_schedule(self, schedule: str) -> DailySchedule: 65 | """Parse time ranges in format HHMM-HHMM or HHMM-HHMM&HHMM-HHMM""" 66 | if schedule == 'O': 67 | return [('0000', '2400')] 68 | elif schedule == 'C': 69 | return [] 70 | 71 | ranges = [] 72 | for time_range in schedule.split('&'): 73 | start, end = time_range.split('-') 74 | 75 | 76 | # Validate time format (HHMM) 77 | if not (len(start) == 4 and len(end) == 4 and 78 | start.isdigit() and end.isdigit() and 79 | 0 <= int(start[:2]) <= 23 and 0 <= int(start[2:]) <= 59 and 80 | ((0 <= int(end[:2]) <= 23 and 0 <= int(end[2:]) <= 59) or end == "2400")): 81 | raise ValueError(f"Invalid time format in schedule: {start}-{end}") 82 | 83 | ranges.append((start, end)) 84 | 85 | # Sort ranges by start time 86 | ranges.sort(key=lambda x: x[0]) 87 | 88 | return ranges 89 | 90 | def _get_time_ranges_for_date(self, dt: datetime) -> List[TimeRange]: 91 | """Helper function to get time ranges for a specific date, checking holiday schedule first""" 92 | date_str = dt.strftime("%m%d") 93 | if date_str in self.holiday_schedule: 94 | return self.holiday_schedule[date_str] 95 | return self.weekly_schedule[dt.weekday()] 96 | 97 | def is_market_open(self, dt: datetime) -> bool: 98 | """Check if the market is open at the given datetime""" 99 | # Convert to market timezone 100 | local_dt = dt.astimezone(self.timezone) 101 | time_ranges = self._get_time_ranges_for_date(local_dt) 102 | 103 | if not time_ranges: 104 | return False 105 | 106 | # Check current time against ranges 107 | current_time = local_dt.strftime("%H%M") 108 | return any(start <= current_time < end for start, end in time_ranges) 109 | 110 | def get_next_market_open(self, dt: datetime) -> Optional[datetime]: 111 | """Get the next market open datetime after the given datetime. If the market 112 | is open at the given datetime (even if just opens at the given time), 113 | returns the next open datetime. 114 | 115 | If the market is always open, returns None. The returned datetime is in 116 | the timezone of the input datetime.""" 117 | input_tz = dt.tzinfo 118 | current = dt.astimezone(self.timezone) 119 | 120 | # This flag is a invariant that indicates whether we're in the initial 121 | # trading session of the given datetime as we move forward in time. 122 | in_initial_trading_session = True 123 | 124 | # Look ahead up to 14 days 125 | for _ in range(14): 126 | time_ranges = self._get_time_ranges_for_date(current) 127 | 128 | current_time = current.strftime("%H%M") 129 | 130 | # Find the next open time after current_time 131 | next_open = None 132 | for start, end in time_ranges: 133 | # If the end time is before the current time, skip 134 | if end < current_time: 135 | continue 136 | 137 | # If we're in the middle of a trading session, look for next session 138 | # the complicated logic is to handle the distinction between 139 | # a trading session that continues past midnight and one that doesn't 140 | if start < current_time < end or (in_initial_trading_session and start <= current_time < end): 141 | continue 142 | 143 | # Reaching here means we're no longer in a trading session 144 | in_initial_trading_session = False 145 | 146 | # If this start time is after current time, this is the next open 147 | if current_time < start: 148 | next_open = start 149 | break 150 | 151 | if next_open is not None: 152 | # Found next opening time today 153 | hour, minute = int(next_open[:2]), int(next_open[2:]) 154 | result = current.replace(hour=hour, minute=minute, second=0, microsecond=0) 155 | return result.astimezone(input_tz) # Convert back to input timezone 156 | 157 | # Move to next day at midnight 158 | current = (current + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) 159 | 160 | # There is a potential edge case where the market immediately opens at midnight (when rolling over to the next day) 161 | # In this case, the new current time is the open time. 162 | if self.is_market_open(current) and not self.is_market_open(current - timedelta(minutes=1)): 163 | return current.astimezone(input_tz) 164 | 165 | return None 166 | 167 | def get_next_market_close(self, dt: datetime) -> Optional[datetime]: 168 | """Get the next market close datetime after the given datetime. If the 169 | market just closes at the given datetime, returns the next close datetime. 170 | 171 | If the market is always open, returns None. The returned datetime is in 172 | the timezone of the input datetime.""" 173 | input_tz = dt.tzinfo 174 | current = dt.astimezone(self.timezone) 175 | 176 | # Look ahead up to 14 days 177 | for _ in range(14): 178 | time_ranges = self._get_time_ranges_for_date(current) 179 | 180 | current_time = current.strftime("%H%M") 181 | 182 | # Find the next close time after current_time 183 | next_close = None 184 | for _, end in time_ranges: 185 | # If the end time is before (or equal to) the current time, skip 186 | if end <= current_time: 187 | continue 188 | 189 | # If we're in a trading session or a new one starts and the end time 190 | # doesn't roll over to the next day, this is the next close 191 | if current_time < end and end < "2400": 192 | next_close = end 193 | break 194 | 195 | if next_close is not None: 196 | # Found next closing time 197 | hour, minute = int(next_close[:2]), int(next_close[2:]) 198 | result = current.replace(hour=hour, minute=minute, second=0, microsecond=0) 199 | return result.astimezone(input_tz) # Convert back to input timezone 200 | 201 | # Move to next day at midnight 202 | current = (current + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) 203 | 204 | # There is a potential edge case where the market is not open at midnight (when rolling over to the next day) 205 | # In this case, the new current time is the close time. 206 | if not self.is_market_open(current) and self.is_market_open(current - timedelta(minutes=1)): 207 | return current.astimezone(input_tz) 208 | 209 | return None 210 | -------------------------------------------------------------------------------- /pythclient/pythaccounts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import List, Dict, Tuple, Optional, Any, ClassVar 3 | import base64 4 | from enum import Enum 5 | from dataclasses import dataclass, field 6 | import struct 7 | 8 | from loguru import logger 9 | 10 | from pythclient.market_schedule import MarketSchedule 11 | 12 | from . import exceptions 13 | from .solana import SolanaPublicKey, SolanaPublicKeyOrStr, SolanaClient, SolanaAccount 14 | 15 | 16 | _MAGIC = 0xA1B2C3D4 17 | _VERSION_1 = 1 18 | _VERSION_2 = 2 19 | _SUPPORTED_VERSIONS = set((_VERSION_1, _VERSION_2)) 20 | ACCOUNT_HEADER_BYTES = 16 # magic + version + type + size, u32 * 4 21 | _NULL_KEY_BYTES = b'\x00' * SolanaPublicKey.LENGTH 22 | DEFAULT_MAX_LATENCY = 25 23 | 24 | 25 | class PythAccountType(Enum): 26 | UNKNOWN = 0 27 | MAPPING = 1 28 | PRODUCT = 2 29 | PRICE = 3 30 | 31 | 32 | class PythPriceStatus(Enum): 33 | UNKNOWN = 0 34 | TRADING = 1 35 | HALTED = 2 36 | AUCTION = 3 37 | IGNORED = 4 38 | 39 | 40 | class PythPriceType(Enum): 41 | UNKNOWN = 0 42 | PRICE = 1 43 | 44 | 45 | # Join exponential moving average for EMA price and EMA confidence 46 | class EmaType(Enum): 47 | UNKNOWN = 0 48 | EMA_PRICE_VALUE = 1 49 | EMA_PRICE_NUMERATOR = 2 50 | EMA_PRICE_DENOMINATOR = 3 51 | EMA_CONFIDENCE_VALUE = 4 52 | EMA_CONFIDENCE_NUMERATOR = 5 53 | EMA_CONFIDENCE_DENOMINATOR = 6 54 | 55 | 56 | def _check_base64(format: str): 57 | # Solana should return base64 by default, but add a sanity check.. 58 | if format != "base64": 59 | raise Exception(f"unexpected data type from Solana: {format}") 60 | 61 | 62 | def _read_public_key_or_none(buffer: bytes, offset: int = 0) -> Optional[SolanaPublicKey]: 63 | buffer = buffer[offset:offset + SolanaPublicKey.LENGTH] 64 | if buffer == _NULL_KEY_BYTES: 65 | return None 66 | return SolanaPublicKey(buffer) 67 | 68 | 69 | def _read_attribute_string(buffer: bytes, offset: int) -> Tuple[Optional[str], int]: 70 | # attribute string format: 71 | # length (u8) 72 | # chars (char[length]) 73 | 74 | length = buffer[offset] 75 | if length == 0: 76 | return None, offset 77 | data_end = offset + 1 + length 78 | data = buffer[offset + 1:data_end] 79 | 80 | return data.decode('utf8', 'replace'), data_end 81 | 82 | 83 | def _parse_header(buffer: bytes, offset: int = 0, *, key: SolanaPublicKeyOrStr) -> Tuple[PythAccountType, int, int]: 84 | if len(buffer) - offset < ACCOUNT_HEADER_BYTES: 85 | raise ValueError("Pyth account data too short") 86 | 87 | # Pyth magic (u32) == MAGIC 88 | # version (u32) == VERSION_1 or 2 89 | # account type (u32) 90 | # account data size (u32) 91 | magic, version, type_, size = struct.unpack_from(" None: 114 | super().__init__(key, solana) 115 | 116 | def update_from(self, buffer: bytes, *, version: int, offset: int = 0) -> None: 117 | """ 118 | Update the data in this object from the Pyth account data in buffer at 119 | the given offset. 120 | 121 | This method must be overridden in subclasses. 122 | """ 123 | raise NotImplementedError("update_from should be overridden") 124 | 125 | def update_with_rpc_response(self, slot: int, value: Dict[str, Any]) -> None: 126 | """ 127 | Update the data in this object from the given JSON RPC response from the 128 | Solana node. 129 | """ 130 | super().update_with_rpc_response(slot, value) 131 | if "data" not in value: 132 | logger.error("invalid account data response from Solana for key {}: {}", self.key, value) 133 | raise ValueError(f"invalid account data response from Solana for key {self.key}: {value}") 134 | data_base64, data_format = value["data"] 135 | _check_base64(data_format) 136 | data = base64.b64decode(data_base64) 137 | type_, size, version = _parse_header(data, 0, key=self.key) 138 | class_ = _ACCOUNT_TYPE_TO_CLASS.get(type_, None) 139 | if class_ is not type(self): 140 | raise ValueError( 141 | f"wrong Pyth account type {type_} for {type(self)}") 142 | 143 | try: 144 | self.update_from(data[:size], version=version, offset=ACCOUNT_HEADER_BYTES) 145 | except Exception as e: 146 | logger.exception("error while parsing account", exception=e) 147 | 148 | 149 | class PythMappingAccount(PythAccount): 150 | """ 151 | Represents a mapping account, which simply contains a list of product 152 | accounts and a pointer (the public key) to the next mapping account. 153 | 154 | Attributes: 155 | entries (List[SolanaPublicKey]): a list of public keys of product 156 | accounts 157 | next_account_key (Optional[SolanaPublicKey]): the public key of the 158 | next mapping account, if any 159 | """ 160 | 161 | def __init__(self, key: SolanaPublicKeyOrStr, solana: SolanaClient) -> None: 162 | super().__init__(key, solana) 163 | self.entries: List[SolanaPublicKey] = [] 164 | self.next_account_key: Optional[SolanaPublicKey] = None 165 | 166 | def update_from(self, buffer: bytes, *, version: int, offset: int = 0) -> None: 167 | """ 168 | Update the data in this Mapping account from the given buffer. 169 | 170 | Structure: 171 | number of products (u32) 172 | unused (u32) 173 | next mapping account key (char[32]) 174 | """ 175 | fmt = " str: 197 | return f"PythMappingAccount ({self.key})" 198 | 199 | 200 | class PythProductAccount(PythAccount): 201 | """ 202 | Represents a product account, which contains metadata about the product 203 | (asset type, symbol, etc.) and a pointer (the public key) to the first price 204 | account. 205 | 206 | Attributes: 207 | first_price_account_key (SolanaPublicKey): the public key of the first price account (the price accounts form a linked list) 208 | attrs (dict): a dictionary of metadata attributes 209 | """ 210 | def __init__(self, key: SolanaPublicKey, solana: SolanaClient) -> None: 211 | super().__init__(key, solana) 212 | self._prices: Optional[Dict[PythPriceType, PythPriceAccount]] = None 213 | self.attrs: Dict[str, str] = {} 214 | self.first_price_account_key: Optional[SolanaPublicKey] = None 215 | 216 | @property 217 | def prices(self) -> Dict[PythPriceType, PythPriceAccount]: 218 | """ 219 | Gets the price accounts of this product. 220 | 221 | Raises NotLoadedException if they are not yet loaded. 222 | """ 223 | 224 | if self._prices is not None: 225 | return self._prices 226 | raise exceptions.NotLoadedException() 227 | 228 | @property 229 | def symbol(self) -> str: 230 | """ 231 | Gets this account's symbol, or 'Unknown' if there is no 'symbol' attribute. 232 | """ 233 | return self.attrs.get("symbol", "Unknown") 234 | 235 | @property 236 | def schedule(self) -> MarketSchedule: 237 | """ 238 | Gets the market schedule for this product. If the schedule is not set, returns an always open schedule. 239 | """ 240 | return MarketSchedule(self.attrs.get("schedule", "America/New_York;O,O,O,O,O,O,O;")) 241 | 242 | async def get_prices(self) -> Dict[PythPriceType, PythPriceAccount]: 243 | """ 244 | Gets the price accounts of this product. 245 | 246 | If they are not yet loaded, loads them. 247 | """ 248 | 249 | if self._prices is not None: 250 | return self._prices 251 | return await self.refresh_prices() 252 | 253 | async def refresh_prices(self) -> Dict[PythPriceType, PythPriceAccount]: 254 | """ 255 | Refreshes the price accounts of this product. 256 | """ 257 | 258 | prices: Dict[PythPriceType, PythPriceAccount] = {} 259 | key = self.first_price_account_key 260 | while key: 261 | price: PythPriceAccount = PythPriceAccount(key, self.solana, product=self) 262 | await price.update() 263 | prices[price.price_type] = price 264 | key = price.next_price_account_key 265 | self._prices = prices 266 | return prices 267 | 268 | async def check_price_changes( 269 | self, 270 | update_accounts: bool = True 271 | ) -> Tuple[List[PythPriceAccount], List[PythPriceAccount]]: 272 | """ 273 | Checks for changes to the list of price accounts of this product. 274 | 275 | Returns a tuple of a list of added accounts, and a list of removed accounts. 276 | """ 277 | 278 | if self._prices is None: 279 | prices = await self.refresh_prices() 280 | return list(prices.values()), [] 281 | old_prices = dict((price.key, price) for price in self._prices.values()) 282 | new_prices: Dict[PythPriceType, PythPriceAccount] = {} 283 | added_prices: List[PythPriceAccount] = [] 284 | if update_accounts: 285 | await self.solana.update_accounts([self, *old_prices.values()]) 286 | key = self.first_price_account_key 287 | while key: 288 | account = old_prices.pop(key, None) 289 | if account is None: 290 | account = PythPriceAccount(key, self.solana, product=self) 291 | await account.update() 292 | added_prices.append(account) 293 | new_prices[account.price_type] = account 294 | key = account.next_price_account_key 295 | 296 | self._prices = new_prices 297 | return added_prices, list(old_prices.values()) 298 | 299 | def use_price_accounts(self, new_prices: List[PythPriceAccount]) -> None: 300 | """ 301 | Use the price accounts provided in the list. 302 | 303 | The first price account must match the first_price_account_key of this product account, and the subsequent price 304 | accounts must match the next_price_account_key of the preceding price accounts. 305 | """ 306 | prices: Dict[PythPriceType, PythPriceAccount] = {} 307 | expected_key = self.first_price_account_key 308 | for price in new_prices: 309 | if price.key != expected_key: 310 | logger.error("expected price account {}, got {}", expected_key, price.key) 311 | raise ValueError(f"expected price account {expected_key}, got {price.key}") 312 | prices[price.price_type] = price 313 | expected_key = price.next_price_account_key 314 | if expected_key is not None: 315 | logger.error("expected price account {} but end of list reached", expected_key) 316 | raise ValueError("missing price account") 317 | self._prices = prices 318 | 319 | def update_from(self, buffer: bytes, *, version: int, offset: int = 0) -> None: 320 | """ 321 | Update the data in this product account from the given buffer. 322 | 323 | Structure: 324 | first price account key (char[32]) 325 | attributes 326 | { 327 | key (attribute string) 328 | value (attribute string) 329 | } 330 | repeat until end of data or key is empty 331 | """ 332 | 333 | first_price_account_key_bytes = buffer[offset:offset + 334 | SolanaPublicKey.LENGTH] 335 | attrs = {} 336 | 337 | offset += SolanaPublicKey.LENGTH 338 | buffer_len = len(buffer) 339 | while offset < buffer_len: 340 | key, offset = _read_attribute_string(buffer, offset) 341 | if key is None: 342 | break 343 | value, offset = _read_attribute_string(buffer, offset) 344 | attrs[key] = value 345 | 346 | self.first_price_account_key = SolanaPublicKey(first_price_account_key_bytes) 347 | if self.first_price_account_key == SolanaPublicKey.NULL_KEY: 348 | self.first_price_account_key = None 349 | self._prices = {} 350 | self.attrs: Dict[str, str] = attrs 351 | 352 | def __str__(self) -> str: 353 | return f"PythProductAccount {self.symbol} ({self.key})" 354 | 355 | def __repr__(self) -> str: 356 | return str(self) 357 | 358 | def __iter__(self): 359 | for key, val in self.__dict__.items(): 360 | if not key.startswith('_'): 361 | yield key, val 362 | 363 | 364 | @dataclass 365 | class PythPriceInfo: 366 | """ 367 | Contains price information. 368 | 369 | Attributes: 370 | raw_price (int): the raw price 371 | raw_confidence_interval (int): the raw confidence interval 372 | price (int): the price 373 | confidence_interval (int): the price confidence interval 374 | price_status (PythPriceStatus): the price status 375 | pub_slot (int): the slot time this price information was published 376 | exponent (int): the power-of-10 order of the price 377 | """ 378 | 379 | LENGTH: ClassVar[int] = 32 380 | 381 | raw_price: int 382 | raw_confidence_interval: int 383 | price_status: PythPriceStatus 384 | pub_slot: int 385 | exponent: int 386 | 387 | price: float = field(init=False) 388 | confidence_interval: float = field(init=False) 389 | 390 | def __post_init__(self): 391 | self.price = self.raw_price * (10 ** self.exponent) 392 | self.confidence_interval = self.raw_confidence_interval * \ 393 | (10 ** self.exponent) 394 | 395 | @staticmethod 396 | def deserialise(buffer: bytes, offset: int = 0, *, exponent: int) -> PythPriceInfo: 397 | """ 398 | Deserialise the data in the given buffer into a PythPriceInfo object. 399 | 400 | Structure: 401 | price (i64) 402 | confidence interval of price (u64) 403 | status (u32 PythPriceStatus) 404 | corporate action (u32, currently unused) 405 | slot (u64) 406 | """ 407 | # _ is corporate_action 408 | price, confidence_interval, price_status, _, pub_slot = struct.unpack_from( 409 | " str: 413 | return f"PythPriceInfo status {self.price_status} price {self.price}" 414 | 415 | def __repr__(self) -> str: 416 | return str(self) 417 | 418 | 419 | @dataclass 420 | class PythPriceComponent: 421 | """ 422 | Represents a price component. This is the individual prices each 423 | publisher sends in addition to their aggregate. 424 | 425 | Attributes: 426 | publisher_key (SolanaPublicKey): the public key of the publisher 427 | last_aggregate_price_info (PythPriceInfo): the price information from this 428 | publisher used in the last aggregate price 429 | latest_price_info (PythPriceInfo): the latest price information from this 430 | publisher 431 | exponent (int): the power-of-10 order for all the raw price information 432 | in this price component 433 | """ 434 | 435 | LENGTH: ClassVar[int] = SolanaPublicKey.LENGTH + 2 * PythPriceInfo.LENGTH 436 | 437 | publisher_key: SolanaPublicKey 438 | last_aggregate_price_info: PythPriceInfo 439 | latest_price_info: PythPriceInfo 440 | exponent: int 441 | 442 | @staticmethod 443 | def deserialise(buffer: bytes, offset: int = 0, *, exponent: int) -> Optional[PythPriceComponent]: 444 | """ 445 | Deserialise the data in the given buffer into a PythPriceComponent object. 446 | 447 | Structure: 448 | key of quoter (char[32]) 449 | contributing price to last aggregate (PythPriceInfo) 450 | latest contributing price (PythPriceInfo) 451 | """ 452 | key = _read_public_key_or_none(buffer, offset) 453 | if key is None: 454 | return None 455 | offset += SolanaPublicKey.LENGTH 456 | last_aggregate_price = PythPriceInfo.deserialise( 457 | buffer, offset, exponent=exponent) 458 | offset += PythPriceInfo.LENGTH 459 | latest_price = PythPriceInfo.deserialise(buffer, offset, exponent=exponent) 460 | return PythPriceComponent(key, last_aggregate_price, latest_price, exponent) 461 | 462 | 463 | class PythPriceAccount(PythAccount): 464 | """ 465 | Represents a price account, which contains price data of a particular type 466 | (price_type) for a product. 467 | 468 | Attributes: 469 | price_type (PythPriceType): the price type 470 | exponent (int): the power-of-10 order for all the raw price information 471 | in this price account 472 | last_slot (int): slot of last valid aggregate price 473 | information 474 | valid_slot (int): the slot of the current aggregate price 475 | product_account_key (SolanaPublicKey): the public key of the product account 476 | next_price_account_key (Optional[SolanaPublicKey]): the public key of the 477 | next price account in this product 478 | aggregator_key (SolanaPublicKey): the public key of the quoter who computed 479 | the last aggregate price 480 | aggregate_price_info (PythPriceInfo): the aggregate price information 481 | price_components (List[PythPriceComponent]): the price components that the 482 | aggregate price is composed of 483 | slot (int): the slot time when this account was last fetched 484 | product (Optional[PythProductAccount]): the product this price is for, if loaded 485 | max_latency (int): the maximum allowed slot difference for this feed 486 | """ 487 | 488 | def __init__(self, key: SolanaPublicKey, solana: SolanaClient, *, product: Optional[PythProductAccount] = None) -> None: 489 | super().__init__(key, solana) 490 | self.product = product 491 | self.price_type = PythPriceType.UNKNOWN 492 | self.exponent: Optional[int] = None 493 | self.num_components: int = 0 494 | self.last_slot: int = 0 495 | self.valid_slot: int = 0 496 | self.product_account_key: Optional[SolanaPublicKey] = None 497 | self.next_price_account_key: Optional[SolanaPublicKey] = None 498 | self.aggregate_price_info: Optional[PythPriceInfo] = None 499 | self.price_components: List[PythPriceComponent] = [] 500 | self.derivations: Dict[EmaType, int] = {} 501 | self.timestamp: int = 0 # unix timestamp in seconds 502 | self.min_publishers: Optional[int] = None 503 | self.prev_slot: int = 0 504 | self.prev_price: float = field(init=False) 505 | self.prev_conf: float = field(init=False) 506 | self.prev_timestamp: int = 0 # unix timestamp in seconds 507 | self.max_latency: int = 0 # maximum allowed slot difference for this feed 508 | 509 | @property 510 | def aggregate_price(self) -> Optional[float]: 511 | """ 512 | The aggregate price. Returns None if price is not currently available. 513 | If you need the price value regardless of availability use `aggregate_price_info.price` 514 | """ 515 | if self.aggregate_price_status == PythPriceStatus.TRADING: 516 | return self.aggregate_price_info.price 517 | else: 518 | return None 519 | 520 | @property 521 | def aggregate_price_confidence_interval(self) -> Optional[float]: 522 | """ 523 | The aggregate price confidence interval. Returns None if price is not currently available. 524 | If you need the confidence value regardless of availability use `aggregate_price_info.confidence_interval` 525 | """ 526 | if self.aggregate_price_status == PythPriceStatus.TRADING: 527 | return self.aggregate_price_info.confidence_interval 528 | else: 529 | return None 530 | 531 | @property 532 | def aggregate_price_status(self) -> Optional[PythPriceStatus]: 533 | """The aggregate price status.""" 534 | return self.get_aggregate_price_status_with_slot(self.slot) 535 | 536 | def get_aggregate_price_status_with_slot(self, slot: int) -> Optional[PythPriceStatus]: 537 | """ 538 | Gets the aggregate price status given a solana slot. 539 | You might consider using this function with the latest solana slot to make sure the price has not gone stale. 540 | """ 541 | if self.aggregate_price_info.price_status == PythPriceStatus.TRADING and \ 542 | slot - self.aggregate_price_info.pub_slot > self.max_latency: 543 | return PythPriceStatus.UNKNOWN 544 | 545 | return self.aggregate_price_info.price_status 546 | 547 | def update_from(self, buffer: bytes, *, version: int, offset: int = 0) -> None: 548 | """ 549 | Update the data in this price account from the given buffer. 550 | 551 | Structure: 552 | price type (u32 PythPriceType) 553 | exponent (i32) 554 | number of component prices (u32) 555 | (? unclear if this is supposed to match the number of 556 | PythPriceComponents below) 557 | unused (u32) 558 | currently accumulating price slot (u64) 559 | slot of current aggregate price (u64) 560 | derivations (u64[6] - array index corresponds to (DeriveType - 1) - v2 only) 561 | unused derivation values and minimum publishers (u64[2], i32[2], ) 562 | product account key (char[32]) 563 | next price account key (char[32]) 564 | account key of quoter who computed last aggregate price (char[32]) 565 | aggregate price info (PythPriceInfo) 566 | price components (PythPriceComponent[up to 16 (v1) / up to 32 (v2)]) 567 | """ 568 | if version == _VERSION_2: 569 | price_type, exponent, num_components = struct.unpack_from(" str: 632 | if self.product: 633 | return f"PythPriceAccount {self.product.symbol} {self.price_type} ({self.key})" 634 | else: 635 | return f"PythPriceAccount {self.price_type} ({self.key})" 636 | 637 | 638 | _ACCOUNT_TYPE_TO_CLASS = { 639 | PythAccountType.MAPPING: PythMappingAccount, 640 | PythAccountType.PRODUCT: PythProductAccount, 641 | PythAccountType.PRICE: PythPriceAccount 642 | } 643 | -------------------------------------------------------------------------------- /pythclient/pythclient.py: -------------------------------------------------------------------------------- 1 | """Python client for the Pyth oracle on the Solana blockchain.""" 2 | 3 | from __future__ import annotations 4 | from asyncio.futures import Future 5 | from typing import List, Optional, Union, Any, Dict, Coroutine, Tuple, Iterable 6 | from typing_extensions import Literal 7 | import asyncio 8 | 9 | import aiohttp 10 | import backoff 11 | from loguru import logger 12 | 13 | from .solana import SolanaAccount, SolanaClient, SolanaPublicKey, SOLANA_DEVNET_HTTP_ENDPOINT, SOLANA_DEVNET_WS_ENDPOINT, SolanaPublicKeyOrStr 14 | from .pythaccounts import PythAccount, PythMappingAccount, PythProductAccount, PythPriceAccount 15 | from . import exceptions, config, ratelimit 16 | 17 | 18 | class PythClient: 19 | def __init__(self, *, 20 | solana_client: Optional[SolanaClient] = None, 21 | solana_endpoint: str = SOLANA_DEVNET_HTTP_ENDPOINT, 22 | solana_ws_endpoint: str = SOLANA_DEVNET_WS_ENDPOINT, 23 | first_mapping_account_key: str, 24 | program_key: Optional[str] = None, 25 | aiohttp_client_session: Optional[aiohttp.ClientSession] = None) -> None: 26 | self._first_mapping_account_key = SolanaPublicKey(first_mapping_account_key) 27 | self._program_key = program_key and SolanaPublicKey(program_key) 28 | self.solana = solana_client or SolanaClient(endpoint=solana_endpoint, ws_endpoint=solana_ws_endpoint, client=aiohttp_client_session) 29 | self._products: Optional[List[PythProductAccount]] = None 30 | self._mapping_accounts: Optional[List[PythMappingAccount]] = None 31 | 32 | @property 33 | def solana_ratelimit(self) -> Union[ratelimit.RateLimit, Literal[False]]: 34 | return self.solana.ratelimit 35 | 36 | @property 37 | def products(self) -> List[PythProductAccount]: 38 | if self._products is not None: 39 | return self._products 40 | raise exceptions.NotLoadedException() 41 | 42 | async def get_products(self) -> List[PythProductAccount]: 43 | if self._products is not None: 44 | return self._products 45 | return await self.refresh_products() 46 | 47 | def refresh_products(self) -> Coroutine[Any, Any, List[PythProductAccount]]: 48 | return self._refresh_products() 49 | 50 | @backoff.on_exception( 51 | backoff.fibo, 52 | (aiohttp.ClientError, exceptions.RateLimitedException), 53 | max_tries=config.get_backoff_max_tries, 54 | max_value=config.get_backoff_max_value, 55 | ) 56 | async def refresh_all_prices(self) -> None: 57 | # do we have a program account key? 58 | if self._program_key: 59 | # use getProgramAccounts 60 | slot, account_json = await self._refresh_using_program() 61 | else: 62 | slot, account_json = None, None 63 | 64 | products = await self.get_products() 65 | tuples: List[Tuple[PythProductAccount, List[PythPriceAccount], PythPriceAccount]] = [ 66 | (product, [], PythPriceAccount(product.first_price_account_key, self.solana, product=product)) 67 | for product in products 68 | if product.first_price_account_key is not None 69 | ] 70 | 71 | while len(tuples) > 0: 72 | if account_json is not None: 73 | assert slot 74 | for _, _, price in tuples: 75 | p_data = account_json.get(str(price.key)) 76 | if p_data is None: 77 | raise exceptions.MissingAccountException(f"need account {price.key} but missing in getProgramAccount response") 78 | price.update_with_rpc_response(slot, p_data) 79 | else: 80 | await self.solana.update_accounts([price for _, _, price in tuples]) 81 | 82 | next_tuples: List[Tuple[PythProductAccount, List[PythPriceAccount], PythPriceAccount]] = [] 83 | for product, prices, price in tuples: 84 | prices.append(price) 85 | if price.next_price_account_key: 86 | next_tuples.append((product, prices, PythPriceAccount(price.next_price_account_key, self.solana, product=product))) 87 | else: 88 | product.use_price_accounts(prices) 89 | tuples = next_tuples 90 | 91 | @backoff.on_exception( 92 | backoff.fibo, 93 | (aiohttp.ClientError, exceptions.RateLimitedException), 94 | max_tries=config.get_backoff_max_tries, 95 | max_value=config.get_backoff_max_value, 96 | ) 97 | async def _refresh_using_program(self) -> Tuple[int, Dict[str, Any]]: 98 | # get all prices using getProgramAccounts 99 | assert self._program_key 100 | resp = await self.solana.get_program_accounts(self._program_key, with_context=True) 101 | slot: int = resp['context']['slot'] 102 | account_json: Dict[str, Any] = dict((account['pubkey'], account['account']) for account in resp['value']) 103 | 104 | if self._mapping_accounts is None: 105 | await self._refresh_mapping_accounts(account_json=account_json, slot=slot) 106 | if self._products is None: 107 | await self._refresh_products(account_json=account_json, slot=slot) 108 | 109 | return slot, account_json 110 | 111 | def create_watch_session(self): 112 | return WatchSession(self.solana) 113 | 114 | async def close(self): 115 | await self.solana.close() 116 | 117 | async def __aenter__(self): 118 | return self 119 | 120 | async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any): 121 | await self.close() 122 | 123 | @backoff.on_exception( 124 | backoff.fibo, 125 | (aiohttp.ClientError, exceptions.RateLimitedException), 126 | max_tries=config.get_backoff_max_tries, 127 | max_value=config.get_backoff_max_value, 128 | ) 129 | async def _refresh_products(self, *, update_accounts: bool = True, account_json: Optional[Dict[str, Any]] = None, slot: Optional[int] = None) -> List[PythProductAccount]: 130 | if update_accounts or not self._mapping_accounts: 131 | await self._refresh_mapping_accounts() 132 | assert self._mapping_accounts is not None 133 | 134 | product_account_keys: List[SolanaPublicKey] = [] 135 | for mapping_account in self._mapping_accounts: 136 | product_account_keys.extend(mapping_account.entries) 137 | 138 | existing_products = dict((product.key, product) for product in self._products) if self._products else {} 139 | 140 | products: List[PythProductAccount] = [] 141 | for k in product_account_keys: 142 | product = existing_products.get(k) or PythProductAccount(k, self.solana) 143 | if account_json is not None: 144 | p_data = account_json.get(str(k)) 145 | if p_data is None: 146 | raise exceptions.MissingAccountException(f"need account {k} but missing in getProgramAccount response") 147 | assert slot 148 | product.update_with_rpc_response(slot, p_data) 149 | products.append(product) 150 | if account_json is None and update_accounts: 151 | await self.solana.update_accounts(products) 152 | self._products = products 153 | return self._products 154 | 155 | async def get_mapping_accounts(self) -> List[PythMappingAccount]: 156 | if self._mapping_accounts is not None: 157 | return self._mapping_accounts 158 | return await self._refresh_mapping_accounts() 159 | 160 | async def get_all_accounts(self) -> List[PythAccount]: 161 | accounts: List[PythAccount] = [] 162 | accounts.extend(await self.get_mapping_accounts()) 163 | for product in await self.get_products(): 164 | accounts.append(product) 165 | accounts.extend((await product.get_prices()).values()) 166 | return accounts 167 | 168 | @backoff.on_exception( 169 | backoff.fibo, 170 | (aiohttp.ClientError, exceptions.RateLimitedException), 171 | max_tries=config.get_backoff_max_tries, 172 | max_value=config.get_backoff_max_value, 173 | ) 174 | async def _refresh_mapping_accounts(self, *, account_json: Optional[Dict[str, Any]] = None, slot: Optional[int] = None) -> List[PythMappingAccount]: 175 | key = self._first_mapping_account_key 176 | 177 | existing_mappings = dict((mapping.key, mapping) for mapping in self._mapping_accounts) if self._mapping_accounts else {} 178 | 179 | mapping_accounts: List[PythMappingAccount] = [] 180 | while key: 181 | m = existing_mappings.get(key) or PythMappingAccount(key, self.solana) 182 | if account_json is not None: 183 | m_data = account_json.get(str(key)) 184 | if m_data is None: 185 | raise exceptions.MissingAccountException(f"need account {key} but missing in getProgramAccount response") 186 | assert slot 187 | m.update_with_rpc_response(slot, m_data) 188 | else: 189 | await m.update() 190 | mapping_accounts.append(m) 191 | key = m.next_account_key 192 | self._mapping_accounts = mapping_accounts 193 | return self._mapping_accounts 194 | 195 | async def check_mapping_changes(self) -> Tuple[List[Union[PythMappingAccount, PythProductAccount]], List[Union[PythMappingAccount, PythProductAccount]]]: 196 | assert self._mapping_accounts 197 | assert self._products 198 | # collect old keys 199 | old_accounts = dict((product.key, product) for product in [*self._mapping_accounts, *self._products]) 200 | 201 | await self._refresh_mapping_accounts() 202 | 203 | # re-collect product accounts 204 | await self._refresh_products(update_accounts=False) 205 | 206 | new_accounts = dict((product.key, product) for product in [*self._mapping_accounts, *self._products]) 207 | added_keys = new_accounts.keys() - old_accounts.keys() 208 | removed_keys = old_accounts.keys() - new_accounts.keys() 209 | for new_key in added_keys: 210 | await new_accounts[new_key].update() 211 | 212 | return list(new_accounts[key] for key in added_keys), list(old_accounts[key] for key in removed_keys) 213 | 214 | 215 | def _WatchSession_reconnect_giveup(e: BaseException): 216 | return isinstance(e, asyncio.CancelledError) 217 | 218 | 219 | class WatchSession: 220 | def __init__(self, client: SolanaClient): 221 | self._client = client 222 | self._connected = False 223 | 224 | self._pending_sub: Dict[str, SolanaAccount] = {} 225 | self._subid_to_account: Dict[int, SolanaAccount] = {} 226 | self._accountkey_to_subid: Dict[str, int] = {} 227 | 228 | self._pending_program_sub: Dict[str, Dict[str, SolanaAccount]] = {} 229 | self._subid_to_program_accounts: Dict[int, Dict[str, SolanaAccount]] = {} 230 | self._programkey_to_subid: Dict[str, int] = {} 231 | self._request_id = 1 232 | self._reconnect_future: Optional[Future[Any]] = None 233 | 234 | def _next_subid(self) -> int: 235 | id = self._request_id 236 | self._request_id += 1 237 | return id 238 | 239 | @backoff.on_exception(backoff.fibo, 240 | Exception, 241 | max_tries=config.get_backoff_max_tries, 242 | max_value=config.get_backoff_max_value, 243 | giveup=_WatchSession_reconnect_giveup) 244 | async def reconnect(self): 245 | if self._reconnect_future: 246 | await self._reconnect_future 247 | return 248 | try: 249 | self._reconnect_future = asyncio.get_event_loop().create_future() 250 | logger.debug("reconnecting WebSocket...") 251 | await self.disconnect() 252 | await self.connect() 253 | 254 | resubscribe_accounts: List[SolanaAccount] = [] 255 | resubscribe_accounts.extend(self._pending_sub.values()) 256 | resubscribe_accounts.extend(self._subid_to_account.values()) 257 | logger.debug("connected, resubscribing to {} accounts...", len(resubscribe_accounts)) 258 | self._pending_sub = {} 259 | self._subid_to_account = {} 260 | self._accountkey_to_subid = {} 261 | for account in resubscribe_accounts: 262 | await self._subscribe(account, True) 263 | logger.debug("resubscribed") 264 | 265 | resubscribe_programs: List[Tuple[str, Dict[str, SolanaAccount]]] = [] 266 | resubscribe_programs.extend(self._pending_program_sub.items()) 267 | resubscribe_programs.extend((key, self._subid_to_program_accounts[subid]) for key, subid in self._programkey_to_subid.items()) 268 | logger.debug("connected, resubscribing to {} program accounts...", len(resubscribe_programs)) 269 | self._pending_program_sub = {} 270 | self._programkey_to_subid = {} 271 | self._subid_to_program_accounts = {} 272 | for key, accounts in resubscribe_programs: 273 | await self._program_subscribe(key, accounts.values(), True) 274 | logger.debug("resubscribed") 275 | finally: 276 | if self._reconnect_future: 277 | future, self._reconnect_future = self._reconnect_future, None 278 | future.set_result(None) 279 | 280 | async def connect(self): 281 | await self._client.ws_connect() 282 | 283 | async def disconnect(self): 284 | try: 285 | await self._client.ws_disconnect() 286 | except Exception as e: 287 | if isinstance(e, asyncio.CancelledError): 288 | raise 289 | logger.exception("exception while disconnecting WebSocket", exception=e) 290 | pass 291 | 292 | async def _subscribe(self, account: SolanaAccount, reconnecting: bool = False): 293 | try: 294 | keystr = str(account.key) 295 | if keystr in self._accountkey_to_subid: 296 | return 297 | logger.trace("subscribing to {}...", keystr) 298 | self._pending_sub[keystr] = account 299 | subid = await self._client.ws_account_subscribe(keystr) 300 | logger.trace("subscribed to {} with subid {}", keystr, subid) 301 | del self._pending_sub[keystr] 302 | self._accountkey_to_subid[keystr] = subid 303 | self._subid_to_account[subid] = account 304 | except Exception as e: 305 | if isinstance(e, asyncio.CancelledError): 306 | raise 307 | logger.exception("exception while subscribing to account", exception=e) 308 | if not reconnecting: 309 | await self.reconnect() 310 | 311 | def subscribe(self, account: SolanaAccount): 312 | return self._subscribe(account) 313 | 314 | async def unsubscribe(self, account: SolanaAccount): 315 | keystr = str(account.key) 316 | subid = self._accountkey_to_subid.pop(keystr, None) 317 | if subid is None: 318 | return 319 | del self._subid_to_account[subid] 320 | try: 321 | logger.trace("unsubscribing from {} with subid {}...", keystr, subid) 322 | await self._client.ws_account_unsubscribe(subid) 323 | logger.trace("unsubscribed from {}", keystr) 324 | except Exception as e: 325 | if isinstance(e, asyncio.CancelledError): 326 | raise 327 | logger.exception("exception while unsubscribing from account", exception=e) 328 | await self.reconnect() 329 | 330 | async def _program_subscribe(self, programkey: SolanaPublicKeyOrStr, accounts: Iterable[SolanaAccount], reconnecting: bool = False): 331 | try: 332 | keystr = str(programkey) 333 | if keystr in self._programkey_to_subid: 334 | return 335 | logger.trace("subscribing to program {} accounts...", keystr) 336 | 337 | accounts_dict = dict((str(account.key), account) for account in accounts) 338 | self._pending_program_sub[keystr] = accounts_dict 339 | subid = await self._client.ws_program_subscribe(keystr) 340 | logger.trace("subscribed to program {} with subid {}", keystr, subid) 341 | del self._pending_program_sub[keystr] 342 | self._programkey_to_subid[keystr] = subid 343 | self._subid_to_program_accounts[subid] = accounts_dict 344 | except Exception as e: 345 | if isinstance(e, asyncio.CancelledError): 346 | raise 347 | logger.exception("exception while subscribing to program", exception=e) 348 | if not reconnecting: 349 | await self.reconnect() 350 | 351 | def program_subscribe(self, programkey: SolanaPublicKeyOrStr, accounts: Iterable[SolanaAccount]): 352 | return self._program_subscribe(programkey, accounts) 353 | 354 | async def program_unsubscribe(self, programkey: SolanaPublicKeyOrStr): 355 | keystr = str(programkey) 356 | subid = self._programkey_to_subid.pop(keystr, None) 357 | if subid is None: 358 | return 359 | del self._subid_to_program_accounts[subid] 360 | try: 361 | logger.trace("unsubscribing from program {} with subid {}...", keystr, subid) 362 | await self._client.ws_program_unsubscribe(subid) 363 | logger.trace("unsubscribed from {}", keystr) 364 | except Exception as e: 365 | if isinstance(e, asyncio.CancelledError): 366 | raise 367 | logger.exception("exception while unsubscribing from program", exception=e) 368 | await self.reconnect() 369 | 370 | def update_program_accounts(self, programkey: SolanaPublicKeyOrStr, accounts: Iterable[SolanaAccount]): 371 | keystr = str(programkey) 372 | if keystr not in self._programkey_to_subid: 373 | raise ValueError(f"not subscribed to {keystr}") 374 | accounts_dict = dict((str(account.key), account) for account in accounts) 375 | self._subid_to_program_accounts[self._programkey_to_subid[keystr]] = accounts_dict 376 | 377 | async def next_update(self) -> SolanaAccount: 378 | while True: 379 | try: 380 | msg = await self._client.get_next_update() 381 | except asyncio.CancelledError: 382 | raise 383 | except exceptions.WebSocketClosedException as e: 384 | logger.warning(e.args[0]) 385 | await self.reconnect() 386 | continue 387 | except Exception as e: 388 | logger.exception("exception while retrieving update", exception=e) 389 | await self.reconnect() 390 | continue 391 | 392 | method = msg.get("method") 393 | if method == "accountNotification": 394 | subid = msg["params"]["subscription"] 395 | slot = msg["params"]["result"]["context"]["slot"] 396 | account_json = msg["params"]["result"]["value"] 397 | account = self._subid_to_account[subid] 398 | account.update_with_rpc_response(slot, account_json) 399 | return account 400 | elif method == "programNotification": 401 | subid = msg["params"]["subscription"] 402 | slot = msg["params"]["result"]["context"]["slot"] 403 | account_key = msg["params"]["result"]["value"]["pubkey"] 404 | account_json = msg["params"]["result"]["value"]["account"] 405 | account = self._subid_to_program_accounts[subid].get(account_key) 406 | if account: 407 | account.update_with_rpc_response(slot, account_json) 408 | return account 409 | else: 410 | logger.warning("got update for account {} from programSubscribe, but this account was never initialised", account_key) 411 | else: 412 | logger.debug("unknown method {} update from Solana: {}", method, msg) 413 | -------------------------------------------------------------------------------- /pythclient/ratelimit.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Union, Optional, Dict 3 | from typing_extensions import Literal 4 | import asyncio 5 | import datetime 6 | from urllib import parse as urlparse 7 | 8 | from loguru import logger 9 | 10 | RateLimitCPS = Union[float, Literal[False], None] 11 | RateLimitInterval = RateLimitCPS 12 | 13 | def _compute_sleep(now: datetime.datetime, interval: float, last_use: Optional[datetime.datetime] = None) -> float: 14 | if last_use is None or interval is None: 15 | return 0 16 | since_last_use = (now - last_use).total_seconds() 17 | if since_last_use >= interval: 18 | return 0 19 | return min(interval - since_last_use, interval) 20 | 21 | 22 | def _calculate_interval(cps_argument: RateLimitCPS) -> RateLimitInterval: 23 | if cps_argument is False or cps_argument is None: 24 | return cps_argument 25 | if cps_argument == 0: 26 | return None 27 | return 1 / cps_argument 28 | 29 | 30 | class RateLimit: 31 | _endpoint_ratelimit: Dict[str, RateLimit] = {} 32 | 33 | def __init__(self, *, 34 | overall_cps: RateLimitCPS = False, 35 | method_cps: RateLimitCPS = False, 36 | connection_cps: RateLimitCPS = False) -> None: 37 | """ 38 | Creates a new rate limit. 39 | 40 | Arguments are passed to configure; see configure for details. 41 | """ 42 | self._overall_interval: RateLimitInterval 43 | self._method_interval: RateLimitInterval 44 | self._connection_interval: RateLimitInterval 45 | self._method_last_invocation: Dict[str, datetime.datetime] = {} 46 | self._overall_last_invocation: Optional[datetime.datetime] = None 47 | self._last_connection: Optional[datetime.datetime] = None 48 | 49 | self.configure(overall_cps=overall_cps, method_cps=method_cps, connection_cps=connection_cps) 50 | 51 | def configure( 52 | self, 53 | *, 54 | overall_cps: RateLimitCPS = False, 55 | method_cps: RateLimitCPS = False, 56 | connection_cps: RateLimitCPS = False, 57 | ) -> None: 58 | """ 59 | Configure the rate limits. 60 | 61 | If an argument is specified as None or 0, then it is taken as no rate limit. 62 | 63 | If an argument is specified as False, then the rate limit is taken from 64 | the global default. 65 | 66 | Args: 67 | overall_cps: overall calls per second (applied across all methods) 68 | method_cps: per-method calls per second 69 | connection_cps: connections per second 70 | """ 71 | self._overall_interval = _calculate_interval(overall_cps) 72 | self._method_interval = _calculate_interval(method_cps) 73 | self._connection_interval = _calculate_interval(connection_cps) 74 | 75 | 76 | @staticmethod 77 | def _return_interval(instance_interval: RateLimitInterval, default_interval: RateLimitInterval) -> float: 78 | if instance_interval is None or instance_interval == 0: 79 | return 0 80 | elif instance_interval is False: 81 | return default_interval or 0 82 | return instance_interval 83 | 84 | def _get_overall_interval(self) -> float: 85 | return RateLimit._return_interval(self._overall_interval, _default_ratelimit._overall_interval) 86 | 87 | def _get_method_interval(self) -> float: 88 | return RateLimit._return_interval(self._method_interval, _default_ratelimit._method_interval) 89 | 90 | def _get_connection_interval(self) -> float: 91 | return RateLimit._return_interval(self._connection_interval, _default_ratelimit._connection_interval) 92 | 93 | async def apply_method(self, method: str, connection: bool = False) -> None: 94 | now = datetime.datetime.now() 95 | sleep_for = max( 96 | _compute_sleep( 97 | now, self._get_overall_interval(), self._overall_last_invocation 98 | ), 99 | _compute_sleep( 100 | now, 101 | self._get_method_interval(), 102 | self._method_last_invocation.get(method), 103 | ), 104 | ) 105 | if connection: 106 | sleep_for = max( 107 | sleep_for, 108 | _compute_sleep( 109 | now, self._get_connection_interval(), self._last_connection 110 | ), 111 | ) 112 | invoke_at = now + datetime.timedelta(seconds=sleep_for) 113 | self._method_last_invocation[method] = self._overall_last_invocation = invoke_at 114 | if connection: 115 | self._last_connection = invoke_at 116 | if sleep_for == 0: 117 | return 118 | logger.trace("sleeping {} s before method {}", sleep_for, method) 119 | await asyncio.sleep(sleep_for) 120 | 121 | async def apply_connection(self): 122 | now = datetime.datetime.now() 123 | sleep_for = _compute_sleep( 124 | now, self._get_connection_interval(), self._last_connection 125 | ) 126 | self._last_connection = now + datetime.timedelta(seconds=sleep_for) 127 | if sleep_for == 0: 128 | return 129 | logger.trace("sleeping {} s before connecting", sleep_for) 130 | await asyncio.sleep(sleep_for) 131 | 132 | @classmethod 133 | def get_endpoint_ratelimit(cls, endpoint: str) -> RateLimit: 134 | """ 135 | Returns the RateLimit object for a given host, given the endpoint URL. 136 | 137 | The RateLimit is shared per-host i.e. https://api.devnet.solana.com and 138 | wss://api.devnet.solana.com share a RateLimit. 139 | """ 140 | host = urlparse.urlsplit(endpoint)[1] 141 | if ':' in host: 142 | host = host.split(':')[0] 143 | ratelimit = cls._endpoint_ratelimit.get(host) 144 | if ratelimit is None: 145 | ratelimit = RateLimit() 146 | cls._endpoint_ratelimit[host] = ratelimit 147 | return ratelimit 148 | 149 | @classmethod 150 | def configure_endpoint_ratelimit(cls, endpoint: str, *, 151 | overall_cps: RateLimitCPS = False, 152 | method_cps: RateLimitCPS = False, 153 | connection_cps: RateLimitCPS = False): 154 | ratelimit = cls.get_endpoint_ratelimit(endpoint) 155 | ratelimit.configure(overall_cps=overall_cps, method_cps=method_cps, connection_cps=connection_cps) 156 | 157 | @staticmethod 158 | def configure_default_ratelimit(overall_cps: RateLimitCPS = False, 159 | method_cps: RateLimitCPS = False, 160 | connection_cps: RateLimitCPS = False): 161 | _default_ratelimit.configure(overall_cps=overall_cps, method_cps=method_cps, connection_cps=connection_cps) 162 | 163 | 164 | _default_ratelimit = RateLimit( 165 | overall_cps=None, method_cps=None, connection_cps=None 166 | ) 167 | -------------------------------------------------------------------------------- /pythclient/solana.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Union, Optional, Dict, List, Any, Sequence, cast 3 | from typing_extensions import Literal 4 | import asyncio 5 | import json 6 | 7 | import aiohttp 8 | import backoff 9 | import base58 10 | from loguru import logger 11 | 12 | from .exceptions import RateLimitedException, SolanaException, WebSocketClosedException 13 | from .ratelimit import RateLimit 14 | from . import config 15 | 16 | WS_PREFIX = "wss" 17 | HTTP_PREFIX = "https" 18 | 19 | DEVNET_ENDPOINT = "api.devnet.solana.com" 20 | TESTNET_ENDPOINT = "api.testnet.solana.com" 21 | MAINNET_ENDPOINT = "api.mainnet-beta.solana.com" 22 | PYTHNET_ENDPOINT = "pythnet.rpcpool.com" 23 | PYTHTEST_CROSSCHAIN_ENDPOINT = "api2.pythtest.pyth.network" 24 | PYTHTEST_CONFORMANCE_ENDPOINT = "api2.pythtest.pyth.network" 25 | 26 | SOLANA_DEVNET_WS_ENDPOINT = WS_PREFIX + "://" + DEVNET_ENDPOINT 27 | SOLANA_DEVNET_HTTP_ENDPOINT = HTTP_PREFIX + "://" + DEVNET_ENDPOINT 28 | 29 | SOLANA_TESTNET_WS_ENDPOINT = WS_PREFIX + "://" + TESTNET_ENDPOINT 30 | SOLANA_TESTNET_HTTP_ENDPOINT = HTTP_PREFIX + "://" + TESTNET_ENDPOINT 31 | 32 | SOLANA_MAINNET_WS_ENDPOINT = WS_PREFIX + "://" + MAINNET_ENDPOINT 33 | SOLANA_MAINNET_HTTP_ENDPOINT = HTTP_PREFIX + "://" + MAINNET_ENDPOINT 34 | 35 | PYTHNET_WS_ENDPOINT = WS_PREFIX + "://" + PYTHNET_ENDPOINT 36 | PYTHNET_HTTP_ENDPOINT = HTTP_PREFIX + "://" + PYTHNET_ENDPOINT 37 | 38 | PYTHTEST_CROSSCHAIN_WS_ENDPOINT = WS_PREFIX + "://" + PYTHTEST_CROSSCHAIN_ENDPOINT 39 | PYTHTEST_CROSSCHAIN_HTTP_ENDPOINT = HTTP_PREFIX + "://" + PYTHTEST_CROSSCHAIN_ENDPOINT 40 | 41 | PYTHTEST_CONFORMANCE_WS_ENDPOINT = WS_PREFIX + "://" + PYTHTEST_CONFORMANCE_ENDPOINT 42 | PYTHTEST_CONFORMANCE_HTTP_ENDPOINT = HTTP_PREFIX + "://" + PYTHTEST_CONFORMANCE_ENDPOINT 43 | 44 | class SolanaPublicKey: 45 | """ 46 | Represents a Solana public key. This class is meant to be immutable. 47 | """ 48 | 49 | LENGTH = 32 50 | NULL_KEY: SolanaPublicKey 51 | 52 | def __init__(self, key: Union[str, bytes]): 53 | """ 54 | Constructs a new SolanaPublicKey, either from a base58-encoded str or a raw 32-byte bytes. 55 | """ 56 | if isinstance(key, str): 57 | b58len = len(base58.b58decode(key)) 58 | if b58len != SolanaPublicKey.LENGTH: 59 | raise ValueError( 60 | f"invalid byte length of key, expected {SolanaPublicKey.LENGTH}, got {b58len}" 61 | ) 62 | self.key = key 63 | elif isinstance(key, bytes): # type: ignore # suppress unnecessaryIsInstance here 64 | if len(key) != SolanaPublicKey.LENGTH: 65 | raise ValueError( 66 | f"invalid byte length of key, expected {SolanaPublicKey.LENGTH}, got {len(key)}" 67 | ) 68 | self.key = base58.b58encode(key).decode("utf-8") 69 | else: 70 | raise ValueError(f"expected str or bytes for key, got {type(key)}") 71 | 72 | def __str__(self): 73 | return self.key 74 | 75 | def __repr__(self): 76 | return str(self) 77 | 78 | # __hash__ and __eq__ simply pass through to self.key (str). this is present 79 | # so that SolanaPublicKey can be used as a dictionary key and so that equality 80 | # works as expected (i.e. SolanaPublicKey("abc") == SolanaPublicKey("abc")) 81 | def __hash__(self): 82 | return self.key.__hash__() 83 | 84 | def __eq__(self, other: Any): 85 | return isinstance(other, SolanaPublicKey) and self.key.__eq__(other.key) 86 | 87 | 88 | SolanaPublicKeyOrStr = Union[SolanaPublicKey, str] 89 | SolanaPublicKey.NULL_KEY = SolanaPublicKey(b'\0' * 32) 90 | 91 | 92 | class SolanaAccount: 93 | """ 94 | Represents a Solana account. 95 | 96 | Attributes: 97 | key (SolanaPublicKey): the public key of this account 98 | slot (Optional[int]): the slot time when the data in this account was 99 | last updated 100 | solana (SolanaClient): the Solana RPC client 101 | lamports (Optional[int]): the account's balance, in lamports 102 | """ 103 | 104 | def __init__(self, key: SolanaPublicKeyOrStr, solana: SolanaClient) -> None: 105 | self.key: SolanaPublicKey = key if isinstance(key, SolanaPublicKey) else SolanaPublicKey(key) 106 | self.solana: SolanaClient = solana 107 | self.slot: Optional[int] = None 108 | self.lamports: Optional[int] = None 109 | 110 | def update_with_rpc_response(self, slot: int, value: Dict[str, Any]) -> None: 111 | """ 112 | Update the data in this object from the given JSON RPC response from the 113 | Solana node. 114 | """ 115 | self.slot = slot 116 | self.lamports: Optional[int] = value.get("lamports") 117 | 118 | async def update(self) -> None: 119 | """ 120 | Update the data in this object by retrieving new account data from the 121 | Solana blockchain. 122 | """ 123 | resp = await self.solana.get_account_info(self.key) 124 | value = resp.get("value") 125 | if value: 126 | try: 127 | self.update_with_rpc_response(resp["context"]["slot"], value) 128 | except Exception as ex: 129 | logger.exception("error while updating account {}", self.key, exception=ex) 130 | else: 131 | logger.warning("got null value from Solana getAccountInfo for {}; non-existent account? {}", self.key, resp) 132 | 133 | def __str__(self) -> str: 134 | return f"SolanaAccount ({self.key})" 135 | 136 | 137 | class SolanaCommitment: 138 | FINALIZED = "finalized" 139 | CONFIRMED = "confirmed" 140 | PROCESSED = "processed" 141 | 142 | 143 | class SolanaClient: 144 | def __init__( 145 | self, 146 | ratelimit: Union[Literal[False], RateLimit, None] = None, 147 | client: Optional[aiohttp.ClientSession] = None, 148 | *, 149 | endpoint: str = SOLANA_DEVNET_HTTP_ENDPOINT, 150 | ws_endpoint: str = SOLANA_DEVNET_WS_ENDPOINT 151 | ): 152 | """ 153 | Initialises a new Solana API client. 154 | 155 | Args: 156 | ratelimit (Union[False, RateLimit, None]): the rate limit; defaults 157 | to a global rate limit based on the endpoint; specify False to 158 | disable rate limiting 159 | client (aiohttp.ClientSession): the aiohttp ClientSession to use, 160 | will create one on first use otherwise 161 | endpoint (str): the URL to the HTTP endpoint; defaults to the Solana 162 | devnet endpoint 163 | ws_endpoint (str): the URL to the WebSocket endpoint; defaults to 164 | the Solana devnet endpoint 165 | """ 166 | 167 | # can't create one now as the ClientSession has to be created while in an 168 | # event loop, which we may not yet be in 169 | self._client = client 170 | self._is_own_client = False 171 | self.endpoint = endpoint 172 | self.ws_endpoint = ws_endpoint 173 | self.ratelimit: Union[RateLimit, Literal[False]] = ( 174 | RateLimit.get_endpoint_ratelimit(endpoint) 175 | if ratelimit is None 176 | else ratelimit 177 | ) 178 | self._next_id = 0 179 | self._ws: Optional[aiohttp.ClientWebSocketResponse] = None 180 | self._pending_updates: asyncio.Queue[Dict[str, Any]] = asyncio.Queue() 181 | 182 | def _get_next_id(self): 183 | id = self._next_id 184 | self._next_id += 1 185 | return id 186 | 187 | def _get_client(self) -> aiohttp.ClientSession: 188 | client = self._client 189 | if client is None: 190 | client = self._client = aiohttp.ClientSession() 191 | self._is_own_client = True 192 | return client 193 | 194 | async def close(self): 195 | """ 196 | Closes the underlying aiohttp ClientSession, if it was created by this 197 | SolanaClient. 198 | """ 199 | if self._is_own_client: 200 | await self._get_client().close() 201 | 202 | async def __aenter__(self): 203 | return self 204 | 205 | async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any): 206 | await self.close() 207 | 208 | async def update_accounts(self, accounts: Sequence[SolanaAccount]) -> None: 209 | # Solana's getMultipleAccounts RPC is limited to 100 accounts 210 | # Hence we have to split them into groups of 100 211 | # https://docs.solana.com/developing/clients/jsonrpc-api#getmultipleaccounts 212 | for grouped_accounts in [accounts[i:i+100] for i in range(0, len(accounts), 100)]: 213 | resp = await self.get_account_info([account.key for account in grouped_accounts]) 214 | slot = resp["context"]["slot"] 215 | values = resp["value"] 216 | for account, value in zip(grouped_accounts, values): 217 | if value is None: 218 | logger.warning("got null value from Solana getMultipleAccounts for {}; non-existent account?", account.key) 219 | continue 220 | try: 221 | account.update_with_rpc_response(slot, value) 222 | except Exception as ex: 223 | logger.exception("error while updating account {}", account.key, exception=ex) 224 | 225 | async def http_send(self, method: str, params: Optional[List[Any]] = None, *, return_error: bool = False) -> Any: 226 | if self.ratelimit: 227 | await self.ratelimit.apply_method(method, True) 228 | id = self._get_next_id() 229 | async with self._get_client().post( 230 | self.endpoint, json=_make_jsonrpc(id, method, params) 231 | ) as resp: 232 | if resp.status == 429: # rate-limited (429 Too Many Requests) 233 | raise RateLimitedException() 234 | data = await resp.json() 235 | if not isinstance(data, dict): 236 | raise SolanaException(f"got non-JSON-object {type(data)} from Solana") 237 | data = cast(Dict[str, Any], data) 238 | received_id: Any = data.get("id") 239 | if received_id != id: 240 | raise SolanaException( 241 | f"got response with ID {received_id} to request with ID {id}" 242 | ) 243 | error: Any = data.get("error") 244 | if error and not return_error: 245 | raise SolanaException( 246 | f"Solana RPC error: {error['code']} {error['message']}", error 247 | ) 248 | return error or data.get("result") 249 | 250 | async def get_account_info( 251 | self, 252 | key: Union[SolanaPublicKeyOrStr, Sequence[SolanaPublicKeyOrStr]], 253 | commitment: str = SolanaCommitment.CONFIRMED, 254 | encoding: str = "base64", 255 | ) -> Dict[str, Any]: 256 | if isinstance(key, Sequence) and not isinstance(key, str): 257 | return await self.http_send( 258 | "getMultipleAccounts", 259 | [[str(k) for k in key], {"commitment": commitment, "encoding": encoding}], 260 | ) 261 | else: 262 | return await self.http_send( 263 | "getAccountInfo", 264 | [str(key), {"commitment": commitment, "encoding": encoding}], 265 | ) 266 | 267 | async def get_program_accounts( 268 | self, 269 | key: SolanaPublicKeyOrStr, 270 | commitment: str = SolanaCommitment.CONFIRMED, 271 | encoding: str = "base64", 272 | with_context: bool = True 273 | ) -> Dict[str, Any]: 274 | return await self.http_send( 275 | "getProgramAccounts", 276 | [str(key), {"commitment": commitment, "encoding": encoding, "withContext": with_context}], 277 | ) 278 | 279 | async def get_balance( 280 | self, 281 | key: SolanaPublicKeyOrStr, 282 | commitment: str = SolanaCommitment.CONFIRMED 283 | ) -> Dict[str, Any]: 284 | return await self.http_send( 285 | "getBalance", 286 | [str(key), {"commitment": commitment}] 287 | ) 288 | 289 | async def get_block_time( 290 | self, 291 | slot: int 292 | ) -> Optional[int]: 293 | return await self.http_send( 294 | "getBlockTime", 295 | [slot] 296 | ) 297 | 298 | async def get_health(self) -> Union[Literal['ok'], Dict[str, Any]]: 299 | return await self.http_send("getHealth", return_error=True) 300 | 301 | async def get_cluster_nodes(self) -> List[Dict[str, Any]]: 302 | return await self.http_send("getClusterNodes") 303 | 304 | async def get_slot( 305 | self, 306 | commitment: str = SolanaCommitment.CONFIRMED, 307 | ) -> Union[int, Dict[str, Any]]: 308 | return await self.http_send( 309 | "getSlot", 310 | [{"commitment": commitment}] 311 | ) 312 | 313 | async def ws_account_subscribe( 314 | self, 315 | key: SolanaPublicKeyOrStr, 316 | commitment: str = SolanaCommitment.CONFIRMED, 317 | encoding: str = "base64", 318 | ): 319 | return await self.ws_send( 320 | "accountSubscribe", 321 | [str(key), {"commitment": commitment, "encoding": encoding}], 322 | ) 323 | 324 | async def ws_program_subscribe( 325 | self, 326 | key: SolanaPublicKeyOrStr, 327 | commitment: str = SolanaCommitment.CONFIRMED, 328 | encoding: str = "base64", 329 | ): 330 | return await self.ws_send( 331 | "programSubscribe", 332 | [str(key), {"commitment": commitment, "encoding": encoding}], 333 | ) 334 | 335 | async def ws_program_unsubscribe(self, subscription_id: int): 336 | return await self.ws_send( 337 | "programUnsubscribe", 338 | [subscription_id], 339 | ) 340 | 341 | async def ws_account_unsubscribe(self, subscription_id: int): 342 | return await self.ws_send( 343 | "accountUnsubscribe", 344 | [subscription_id], 345 | ) 346 | 347 | @property 348 | def ws_connected(self): 349 | return self._ws and not self._ws.closed 350 | 351 | @backoff.on_exception( 352 | backoff.fibo, 353 | aiohttp.ClientError, 354 | max_tries=config.get_backoff_max_tries, 355 | max_value=config.get_backoff_max_value, 356 | ) 357 | async def ws_connect(self): 358 | if self.ws_connected: 359 | return 360 | self._pending_updates = asyncio.Queue() 361 | logger.debug("connecting to Solana RPC via WebSocket {}...", self.ws_endpoint) 362 | self._ws = await self._get_client().ws_connect(self.ws_endpoint) 363 | logger.debug("connected to Solana RPC via WebSocket") 364 | 365 | async def ws_disconnect(self): 366 | if not self.ws_connected: 367 | return 368 | assert self._ws 369 | logger.debug("closing Solana RPC WebSocket connection...") 370 | await self._ws.close() 371 | logger.debug("closed Solana RPC WebSocket connection") 372 | self._ws = None 373 | 374 | async def ws_send(self, method: str, params: List[Any]): 375 | await self.ws_connect() 376 | if self.ratelimit: 377 | await self.ratelimit.apply_method(method, True) 378 | assert self._ws 379 | id = self._get_next_id() 380 | await self._ws.send_str(json.dumps(_make_jsonrpc(id, method, params))) 381 | return await self._ws_wait_response(id) 382 | 383 | async def _ws_wait_response(self, id: int): 384 | while True: 385 | msg = json.loads(await self._ws_receive_str()) 386 | if "method" in msg: 387 | self._pending_updates.put_nowait(msg) 388 | continue 389 | received_id = msg.get("id") 390 | if received_id != id: 391 | raise SolanaException( 392 | f"got response with ID {received_id} to request with ID {id}" 393 | ) 394 | error = msg.get("error") 395 | if error: 396 | raise SolanaException( 397 | f"Solana RPC error: {error['code']} {error['message']}", error 398 | ) 399 | return msg.get("result") 400 | 401 | async def _ws_receive_str(self) -> str: 402 | # aiohttp's receive_str throws a very cryptic error when the 403 | # connection is closed while we are waiting 404 | # handle that ourselves 405 | assert self._ws 406 | wsmsg = await self._ws.receive() 407 | wsmsgtype: aiohttp.WSMsgType = wsmsg.type # type: ignore # missing type information 408 | if wsmsgtype == aiohttp.WSMsgType.CLOSED or wsmsgtype == aiohttp.WSMsgType.CLOSING: 409 | logger.debug("WebSocket closed while waiting for message", wsmsg) 410 | raise WebSocketClosedException(f"WebSocket closed while waiting for update; close code was {self._ws.close_code}") 411 | elif wsmsgtype != aiohttp.WSMsgType.TEXT: 412 | raise SolanaException(f"Unexpected WebSocket message type {wsmsgtype} from Solana") 413 | return wsmsg.data # type: ignore # missing type information 414 | 415 | async def get_next_update(self) -> Dict[str, Any]: 416 | if not self._pending_updates.empty(): 417 | return self._pending_updates.get_nowait() 418 | msg = json.loads(await self._ws_receive_str()) 419 | if "method" not in msg: 420 | raise SolanaException(f"unexpected RPC response", msg) 421 | return msg 422 | 423 | 424 | def _make_jsonrpc(id: int, method: str, params: Optional[List[Any]]) -> Dict[str, Any]: 425 | r: Dict[str, Any] = {"jsonrpc": "2.0", "id": id, "method": method} 426 | if params: 427 | r["params"] = params 428 | return r 429 | -------------------------------------------------------------------------------- /pythclient/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | def get_key(network: str, type: str) -> Optional[str]: 5 | """ 6 | Get the program or mapping key. 7 | :param network: The network to get the key for. Either "mainnet", "devnet", "testnet", "pythnet", "pythtest-conformance", or "pythtest-crosschain". 8 | :param type: The type of key to get. Either "program" or "mapping". 9 | """ 10 | keys = { 11 | "pythnet": { 12 | "program": "FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH", 13 | "mapping": "AHtgzX45WTKfkPG53L6WYhGEXwQkN1BVknET3sVsLL8J", 14 | }, 15 | "mainnet": { 16 | "program": "FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH", 17 | "mapping": "AHtgzX45WTKfkPG53L6WYhGEXwQkN1BVknET3sVsLL8J", 18 | }, 19 | "devnet": { 20 | "program": "gSbePebfvPy7tRqimPoVecS2UsBvYv46ynrzWocc92s", 21 | "mapping": "BmA9Z6FjioHJPpjT39QazZyhDRUdZy2ezwx4GiDdE2u2", 22 | }, 23 | "testnet": { 24 | "program": "8tfDNiaEyrV6Q1U4DEXrEigs9DoDtkugzFbybENEbCDz", 25 | "mapping": "AFmdnt9ng1uVxqCmqwQJDAYC5cKTkw8gJKSM5PnzuF6z", 26 | }, 27 | "pythtest-conformance": { 28 | "program": "8tfDNiaEyrV6Q1U4DEXrEigs9DoDtkugzFbybENEbCDz", 29 | "mapping": "AFmdnt9ng1uVxqCmqwQJDAYC5cKTkw8gJKSM5PnzuF6z", 30 | }, 31 | "pythtest-crosschain": { 32 | "program": "gSbePebfvPy7tRqimPoVecS2UsBvYv46ynrzWocc92s", 33 | "mapping": "BmA9Z6FjioHJPpjT39QazZyhDRUdZy2ezwx4GiDdE2u2", 34 | }, 35 | } 36 | 37 | try: 38 | return keys[network][type] 39 | except KeyError: 40 | raise Exception(f"Unknown network or type: {network}, {type}") 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | requirements = ['aiodns', 'aiohttp>=3.7.4', 'backoff', 'base58', 'flake8', 'loguru', 'typing-extensions', 'pytz', 'pycryptodome', 'httpx', 'websockets'] 4 | 5 | with open('README.md', 'r', encoding='utf-8') as fh: 6 | long_description = fh.read() 7 | 8 | setup( 9 | name='pythclient', 10 | version='0.2.2', 11 | packages=['pythclient'], 12 | author='Pyth Developers', 13 | author_email='contact@pyth.network', 14 | description='A library to retrieve Pyth account structures off the Solana blockchain.', 15 | long_description=long_description, 16 | long_description_content_type='text/markdown', 17 | url='https://github.com/pyth-network/pyth-client-py', 18 | project_urls={ 19 | 'Bug Tracker': 'https://github.com/pyth-network/pyth-client-py/issues', 20 | }, 21 | classifiers=[ 22 | 'Programming Language :: Python :: 3', 23 | 'License :: OSI Approved :: Apache Software License', 24 | 'Operating System :: OS Independent', 25 | ], 26 | install_requires=requirements, 27 | extras_require={ 28 | 'testing': requirements + ['mock', 'pytest', 'pytest-cov', 'pytest-socket', 29 | 'pytest-mock', 'pytest-asyncio'], 30 | }, 31 | python_requires='>=3.9.0', 32 | ) 33 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pytest 3 | from _pytest.logging import caplog as _caplog 4 | from pythclient.solana import SolanaClient 5 | from loguru import logger 6 | 7 | 8 | @pytest.fixture 9 | def solana_client(): 10 | return SolanaClient( 11 | endpoint="https://example.com", 12 | ws_endpoint="wss://example.com", 13 | ) 14 | 15 | 16 | @pytest.fixture 17 | def caplog(_caplog): 18 | logger.enable("pythclient") 19 | 20 | class PropogateHandler(logging.Handler): 21 | def emit(self, record): 22 | logging.getLogger(record.name).handle(record) 23 | 24 | handler_id = logger.add(PropogateHandler(), format="{message}") 25 | yield _caplog 26 | logger.remove(handler_id) 27 | -------------------------------------------------------------------------------- /tests/test_hermes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pytest_mock import MockerFixture 4 | 5 | from mock import AsyncMock 6 | 7 | import httpx 8 | 9 | from pythclient.hermes import HermesClient, PriceFeed, parse_unsupported_version 10 | 11 | @pytest.fixture 12 | def feed_id(): 13 | return "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" 14 | 15 | @pytest.fixture 16 | def feed_ids(): 17 | return ["e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"] 18 | 19 | @pytest.fixture 20 | def hermes_client(feed_ids): 21 | return HermesClient(feed_ids) 22 | 23 | @pytest.fixture 24 | def data_v1(): 25 | return { 26 | "ema_price": { 27 | "conf": "509500001", 28 | "expo": -8, 29 | "price": "2920679499999", 30 | "publish_time": 1708363256 31 | }, 32 | "id": "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", 33 | "metadata": { 34 | "emitter_chain": 26, 35 | "prev_publish_time": 1708363256, 36 | "price_service_receive_time": 1708363256, 37 | "slot": 85480034 38 | }, 39 | "price": { 40 | "conf": "509500001", 41 | "expo": -8, 42 | "price": "2920679499999", 43 | "publish_time": 1708363256 44 | }, 45 | "vaa": "UE5BVQEAAAADuAEAAAADDQC1H7meY5fTed0FsykIb8dt+7nKpbuzfvU2DplDi+dcUl8MC+UIkS65+rkiq+zmNBxE2gaxkBkjdIicZ/fBo+X7AAEqp+WtlWb84np8jJfLpuQ2W+l5KXTigsdAhz5DyVgU3xs+EnaIZxBwcE7EKzjMam+V9rlRy0CGsiQ1kjqqLzfAAQLsoVO0Vu5gVmgc8XGQ7xYhoz36rsBgMjG+e3l/B01esQi/KzPuBf/Ar8Sg5aSEOvEU0muSDb+KIr6d8eEC+FtcAAPZEaBSt4ysXVL84LUcJemQD3SiG30kOfUpF8o7/wI2M2Jf/LyCsbKEQUyLtLbZqnJBSfZJR5AMsrnHDqngMLEGAAY4UDG9GCpRuPvg8hOlsrXuPP3zq7yVPqyG0SG+bNo8rEhP5b1vXlHdG4bZsutX47d5VZ6xnFROKudx3T3/fnWUAQgAU1+kUFc3e0ZZeX1dLRVEryNIVyxMQIcxWwdey+jlIAYowHRM0fJX3Scs80OnT/CERwh5LMlFyU1w578NqxW+AQl2E/9fxjgUTi8crOfDpwsUsmOWw0+Q5OUGhELv/2UZoHAjsaw9OinWUggKACo4SdpPlHYldoWF+J2yGWOW+F4iAQre4c+ocb6a9uSWOnTldFkioqhd9lhmV542+VonCvuy4Tu214NP+2UNd/4Kk3KJCf3iziQJrCBeLi1cLHdLUikgAQtvRFR/nepcF9legl+DywAkUHi5/1MNjlEQvlHyh2XbMiS85yu7/9LgM6Sr+0ukfZY5mSkOcvUkpHn+T+Nw/IrQAQ7lty5luvKUmBpI3ITxSmojJ1aJ0kj/dc0ZcQk+/qo0l0l3/eRLkYjw5j+MZKA8jEubrHzUCke98eSoj8l08+PGAA+DAKNtCwNZe4p6J1Ucod8Lo5RKFfA84CPLVyEzEPQFZ25U9grUK6ilF4GhEia/ndYXLBt3PGW3qa6CBBPM7rH3ABGAyYEtUwzB4CeVedA5o6cKpjRkIebqDNSOqltsr+w7kXdfFVtsK2FMGFZNt5rbpIR+ppztoJ6eOKHmKmi9nQ99ARKkTxRErOs9wJXNHaAuIRV38o1pxRrlQRzGsRuKBqxcQEpC8OPFpyKYcp6iD5l7cO/gRDTamLFyhiUBwKKMP07FAWTEJv8AAAAAABrhAfrtrFhR4yubI7X5QRqMK6xKrj7U3XuBHdGnLqSqcQAAAAAAGp0GAUFVV1YAAAAAAAUYUmIAACcQBsfKUtr4PgZbIXRxRESU79PjE4IBAFUA5i32yLSoX+GmfbRNwS3l2zMPesZrctxliv7fD0pBW0MAAAKqqMJFwAAAAAAqE/NX////+AAAAABkxCb7AAAAAGTEJvoAAAKqIcWxYAAAAAAlR5m4CP/mPsh1IezjYpDlJ4GRb5q4fTs2LjtyO6M0XgVimrIQ4kSh1qg7JKW4gbGkyRntVFR9JO/GNd3FPDit0BK6M+JzXh/h12YNCz9wxlZTvXrNtWNbzqT+91pvl5cphhSPMfAHyEzTPaGR9tKDy9KNu56pmhaY32d2vfEWQmKo22guegeR98oDxs67MmnUraco46a3zEnac2Bm80pasUgMO24=" 46 | } 47 | 48 | @pytest.fixture 49 | def data_v2(): 50 | return { 51 | "binary": { 52 | "encoding": "hex", 53 | "data": [ 54 | "504e41550100000003b801000000030d014016474bab1868acfe943cdcd3cf7a8b7ccfaf6f2a31870694d11c441505d0552a42f57df50093df73eca16fad7ae3d768b0dd0e64dbaf71579fd5d05c46a5f20002098e46154c00ee17e878295edaca5decd18f7a1e9a1f0576ca090219f350118d1a4a0cc94b853c8ae1d5064439e719c953e61450745cf10086c37ec93d878b610003edf89d49fe5bb035d3cab5f3960ca5c7be37073b6680afb0f154ec684990923330f6db1fced4680dcfce8664c9d757fe2e8ca84aec8950004371ab794979db7101068a0231af6701f5fbfe55ac7dd31d640dd17f2fa92a10450d7a6e5db03c7c1f90131452ed1e3290fbbf00bc8528f616e81771460b2c307e02db811a84545180620107ab6ea34d72541f44cf34c8e919b9ef336eef9774ee4cf3d5c7cc71f5f90e49d23a05878e2e278402aff8217df84f9ce3ae782c389b3230d09e9e66fada355d6600084018b5993c68c4d616a570925e63a7c82c5444aee9a0f6153bd724e0755d3086374c9cf4e6ec2f08ab9c914b4cd3868e250ad4f946219cc2af0a31936cd38147000a079d8fb93db9c82263556dfd768b6173da40d35ea4691d21de59cf207381b5a05cb693fd4a75cb2b190c0270f2ddc14335adca66bcd5a634bf316a4385e97250010bf6dfa12e7820c58514c74ec21029d5c11f98a586743b2da9d2e20d8d78b44bd3730af5c6428c1ad865cb9d94ee795d059b3b51bb1e7bc8f81d52e5db18167648010c8558ac8aefd43cf489bce260afaee270d36fd1a34923439261fc8220cb33f30521cfefebfe0d7cf21d3aaa60c9149f8ab085c90b0509ad2850efe01fc618ccec010d6bc67036011a75277ca476ca1f4d962ca0d861805a94c6353ad0ff6ae17263bc5401e7d7ee3f3010f77c6349ff264c4185b167f32108c7de9743f7a258c62d03000e63f823f4b8f2cb1d158aac8f7ba0e45227b6d99106831a50729825bf8b97969503f55bc33778ef6c21e696a99d304b72c9e5ca3941dd178a7fc5367aed7d0e00010f22ccd76becc94aec99ff0bb1fce128cb25644268c65ac8f2bf5fe357910942381e184a62e8a768d5be200e21e40a34047a6e5cd981d2380de7eb4aa46a15ce0a00127957a07e69f8af6f8752a09f24dde0d43277c80d3bc24f09a281e5e68878d0ea0445b356257e25e80422ddff2f799bb732eafdeee43fc14c21d4eda349a547010165d38df800000000001ae101faedac5851e32b9b23b5f9411a8c2bac4aae3ed4dd7b811dd1a72ea4aa7100000000027a3abd0141555756000000000007823fd000002710b939e515de35dd00cf7feaba6be6aed77c83e09901005500e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43000004b868a1a543000000009ea4861cfffffff80000000065d38df80000000065d38df7000004bcd90bec4000000000c41abcc80ab559eef775bd945c821d89ceba075f3c60f2dba713f2f7ed0d210ea03ee4bead9c9b6ffd8fff45f0826e6950c44a8a7e0eac9b5bc1f2bdf276965107fc612f72a05bd37ca85017dc13b01fa5d434887f33527d87c34f1caf4ed69501a6972959e7faf96a6bc43c0d08e2b1a095c50ef6609bf81b7661102f69acb46430115e301f1ebda0f008438e31564240e1cbc9092db20b73bfc8dd832b6467fd242f0043a167ccafbc0ba479d38be012ad1d75f35e2681754e78e1f10096a55f65512fe381238a67ffce0970" 55 | ] 56 | }, 57 | "parsed": [ 58 | { 59 | "id": "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", 60 | "price": { 61 | "price": "5190075917635", 62 | "conf": "2661582364", 63 | "expo": -8, 64 | "publish_time": 1708363256 65 | }, 66 | "ema_price": { 67 | "price": "5209141800000", 68 | "conf": "3290086600", 69 | "expo": -8, 70 | "publish_time": 1708363256 71 | }, 72 | "metadata": { 73 | "slot": 125976528, 74 | "proof_available_time": 1708363257, 75 | "prev_publish_time": 1708363255 76 | } 77 | } 78 | ] 79 | } 80 | 81 | @pytest.fixture 82 | def json_get_price_feed_ids(): 83 | return ["e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"] 84 | 85 | @pytest.fixture 86 | def json_get_pyth_prices_latest_v1(feed_ids: list[str]): 87 | return [{'id': feed_ids[0], 'price': {'price': '135077', 'conf': '69', 'expo': -5, 'publish_time': 1708529661}, 'ema_price': {'price': '135182', 'conf': '52', 'expo': -5, 'publish_time': 1708529661}, 'vaa': 'UE5BVQEAAAADuAEAAAADDQFOmUMU+Z4r00C4N2LLbib67MfjUQrRDE9JCMD2rRAbHw/wKlgC1RgVuom5U+XO3mTFBUySDPxVLdKZT2+kSPNPAQLUFqHSI4KJPqeD0kt+JojinHUrlMFPw6y/tq9Go/izICpUhJY0+RUoQQ1it5jJKhKIe0GOVCWDN3M8WZwjDNW1AQTpTokfNehR1p7GQdWOCoEiec0ARzKkAHYkIJAOaHGUOhXrvL2g2P/nXZzBK32a67vI2ia1e/sZN0rLclEobzDgAAbGveTKWcp9znmPAPAA1mGf+ZeJy7eXN/9vWrdEZc2wQ1cRO0eYAUcMMLAKNJ80lTKN7yxn0sekTUN1NSDdOSctAAdm8xF1ucpBgaOK7vo+OoXZa2nJ5vNxbUM99qvVxeg6sCMqwVD03/BD2VeuUxGGJUIJgbi6RViZoEahk2GcTE5kAQiwuLqaPQ8YJgYJ2ODBVZHXzTosLVWpxJQr1RwCvBpNKTiBFX6TMXJk6cWgKpi0siRFkMrMXuVApOMUbXtFGHmoAQoyfR9lYa/G8reoSK/NUaN2eNWgEpODlNlVRPSC1pe3YRG7BaGMyyOLc4dF3NDO/Mv8GERLRnq/5uk6hZmfMrdaAQsdCecWyLR6ajfuhZQXqXvSKYlkLum6WmWh1Rgytaiq3lXIQ0KzNxplogC7/uDxaICmwFMQIe5j+0vkQjK0YRtkAAwPqtN22f08AAFJ9vkLJ2EHNrzRqgpii3hGM4B+lqipvCT1Zwrrc4Akm2cT8jQvta08zHOGA9upZuyRefMKtvIrAQ0BoSlqog4qD/tABWgvxxCP0S7leutd1BtdcPkDMOyIaRt4HujJn4K4+7tqsukqkvdsm9kffRt2apxDLHpLttvCAQ4j24Btj0Z+U7soibfa/1asb/u+sDzydMZPiMEf6441IRaGdRHaeo9pCKzoaUc8xBcktFHsa7hZR8bE1xiYzCJzABB5St+Lvp/I2XXIjLB0TnscZRs/TDnRGl0N0UudLZ7BNgmtOCD8gLtvrnpSOegfeO+yEbHsAosn/CJVD1jgxuQuARLEIz8Z8yew/zDanqL+zjnR3GayU3ddKqP8AlGuxZjZNXEsQZP93ivcRgX14VyK+qxX9bHmsb/5+ljg3rNaokbyAWXWF/0AAAAAABrhAfrtrFhR4yubI7X5QRqMK6xKrj7U3XuBHdGnLqSqcQAAAAACfpqyAUFVV1YAAAAAAAeGqjMAACcQiDrN+zmqsRbApltVCbB2uiQTTXkBAFUAMRKwOkHJEO1EaFKqz2cRjLG+xnss0LmiFMWMwOqi7MoAAAAAAAIPpQAAAAAAAABF////+wAAAABl1hf9AAAAAGXWF/0AAAAAAAIQDgAAAAAAAAA0Crt2twYzlx9tlARo8EHFjGit6NU5uJVQez40nh22JSTHSjgQajW5RNeQbb732lp/y/9sv9HmENJElTPq3K22/AizzBGRk8LG9r40CWoY4KWiKxi9UqTvbO8hGd/Q6XyH+vrfu68/YWiqdx50GJkhYQS/nLfF9lgMMWf+svoFsyU8hbanvEjRht/xhRD30QKgQ0XqHBBRB5x1oagos+qIqYZY6Izt6SpS/Fj4p6qUrezAAEmVz83V+LurKg3kaurFWAzvwhnsQyop'}, {'id': feed_ids[1], 'price': {'price': '939182', 'conf': '1549', 'expo': -8, 'publish_time': 1708529661}, 'ema_price': {'price': '945274', 'conf': '2273', 'expo': -8, 'publish_time': 1708529661}, 'vaa': 'UE5BVQEAAAADuAEAAAADDQFOmUMU+Z4r00C4N2LLbib67MfjUQrRDE9JCMD2rRAbHw/wKlgC1RgVuom5U+XO3mTFBUySDPxVLdKZT2+kSPNPAQLUFqHSI4KJPqeD0kt+JojinHUrlMFPw6y/tq9Go/izICpUhJY0+RUoQQ1it5jJKhKIe0GOVCWDN3M8WZwjDNW1AQTpTokfNehR1p7GQdWOCoEiec0ARzKkAHYkIJAOaHGUOhXrvL2g2P/nXZzBK32a67vI2ia1e/sZN0rLclEobzDgAAbGveTKWcp9znmPAPAA1mGf+ZeJy7eXN/9vWrdEZc2wQ1cRO0eYAUcMMLAKNJ80lTKN7yxn0sekTUN1NSDdOSctAAdm8xF1ucpBgaOK7vo+OoXZa2nJ5vNxbUM99qvVxeg6sCMqwVD03/BD2VeuUxGGJUIJgbi6RViZoEahk2GcTE5kAQiwuLqaPQ8YJgYJ2ODBVZHXzTosLVWpxJQr1RwCvBpNKTiBFX6TMXJk6cWgKpi0siRFkMrMXuVApOMUbXtFGHmoAQoyfR9lYa/G8reoSK/NUaN2eNWgEpODlNlVRPSC1pe3YRG7BaGMyyOLc4dF3NDO/Mv8GERLRnq/5uk6hZmfMrdaAQsdCecWyLR6ajfuhZQXqXvSKYlkLum6WmWh1Rgytaiq3lXIQ0KzNxplogC7/uDxaICmwFMQIe5j+0vkQjK0YRtkAAwPqtN22f08AAFJ9vkLJ2EHNrzRqgpii3hGM4B+lqipvCT1Zwrrc4Akm2cT8jQvta08zHOGA9upZuyRefMKtvIrAQ0BoSlqog4qD/tABWgvxxCP0S7leutd1BtdcPkDMOyIaRt4HujJn4K4+7tqsukqkvdsm9kffRt2apxDLHpLttvCAQ4j24Btj0Z+U7soibfa/1asb/u+sDzydMZPiMEf6441IRaGdRHaeo9pCKzoaUc8xBcktFHsa7hZR8bE1xiYzCJzABB5St+Lvp/I2XXIjLB0TnscZRs/TDnRGl0N0UudLZ7BNgmtOCD8gLtvrnpSOegfeO+yEbHsAosn/CJVD1jgxuQuARLEIz8Z8yew/zDanqL+zjnR3GayU3ddKqP8AlGuxZjZNXEsQZP93ivcRgX14VyK+qxX9bHmsb/5+ljg3rNaokbyAWXWF/0AAAAAABrhAfrtrFhR4yubI7X5QRqMK6xKrj7U3XuBHdGnLqSqcQAAAAACfpqyAUFVV1YAAAAAAAeGqjMAACcQiDrN+zmqsRbApltVCbB2uiQTTXkBAFUASWAWJeGjQsH5DD/moDrgJRmRodduSA0nQVJMKQN74ooAAAAAAA5UrgAAAAAAAAYN////+AAAAABl1hf9AAAAAGXWF/0AAAAAAA5segAAAAAAAAjhCofSSh+aiBKjNaU+6ZbH8TlrF2uCFMh/5ts6kGbO5HTy0DjKjBftORAYo1rjK75a2IgA2CgrbhU7jgoohiwnjYZjFZjCdn5MN5g66N44JEVMsvQkw7xATLYHjjwTFxVNSlb0fK71JG8U3dYnQYukR2GGWekBqlOKSGf+svoFsyU8hbanvEjRht/xhRD30QKgQ0XqHBBRB5x1oagos+qIqYZY6Izt6SpS/Fj4p6qUrezAAEmVz83V+LurKg3kaurFWAzvwhnsQyop'}] 88 | 89 | @pytest.fixture 90 | def json_get_pyth_prices_latest_v2(feed_ids: list[str]): 91 | return {'binary': {'encoding': 'base64', 'data': ['UE5BVQEAAAADuAEAAAADDQL1JxtV1elNjzs2291jgXRpsSf4YCQcTk+AcnFSQuEebFASXELZ5Jy3QRqqkXuyoBmzgIueQyIC75FpTQVbmyfiAARvrKPM14V0I4OLOh1wvJxpJscvhlLXW1iuZN9E4WNm32x9o2uhwmQx66SdmcORpbeRKxqNwjfVyWwh7auDusBdAQbihmpCGqMRGclMSP62LojuT3plWmcFUUYbV+vMRqGk8WGm2WRqIcVtaqYu8gyAOqZt+Rq81Qh16wsie4JHV2PwAQeSMDuE525flbp0bVIie1a3r2KoMyfmclUSTPTqTM//JmrDQxIab+mupQtxy3oIcrAHOYF1H/lqHCYdPsSjDN5gAQh1gghNXT6IqNWcGItZSyxKJbhDS7yXDEJLNaG3OTPl1QITX3sSWXeW8dO5larV+lKAfXZPsWij8ouKtxwKqHMgAQo7RUY8HCLVkhnhITobBtlRT6LIvku6J3dFqz0ptccHuWPxBM5orE5OGZoURaUqyn1ulcaBDpT/boUAsEHF0Uo3AQtgJ4SgaMHn395DB7THFUTh3vwenWOZBNFUnXuUKc5coXPG9P5RbNZZX9K3ItQw/LVxzqtKCoBQzeAx0dn1rDOgAQwGmOQnLItBi1MCpex3iH0aBtNmh9+PKdoF7Ze00LndxERw2w9dYqwBDx2/rftzMUckmdtuZ7M0IeWW1syur4rPAA0lv+DjKWgG2bG3CrlqfsanyZSRV3DgpJyiyFeEGFHgHxDS6YQJEAP2K5f9MI+Flt5GysnYidZRZuNNEvnHte+eAA6a21Vt6wfm4q3mg+HJQX8QFrDjTyLuz+5129nSp5zygXYDk+QGQ/8fHRX0/d7GPWDRg03fJC03mVTYt2PUAP11AA9yMxX7o66nICcdqkR95dC9S6SizcRHmtPwYHpNuqsCECwRc+KHeBpQ2E01X0ZGXOZwywVQpMDP3LRNagFkO4KEARDnU3uQU9ya2XSF8RXWhxxDeGbrpCt4IiNaP01qLgVvBAv5pBGl1oAbBc+Y5zRjITNPZiGyWOZpYiMTLwZex0KsABL7NQ7UEXmnLF3RUFYg2uBdsXbHM36l/L7i2eBFNyrFsy0/+nBQZ5uZr9ddb/hG6zRHQnFUHTIm86KMvAhzcl0TAGXWFxkAAAAAABrhAfrtrFhR4yubI7X5QRqMK6xKrj7U3XuBHdGnLqSqcQAAAAACfpkqAUFVV1YAAAAAAAeGqKsAACcQZGdJ0D+VlqYUFSXWVFqyPSA0GDUCAFUAOdAg9gmC7Ykqu81KBqJ2qfm3v7zgAyBMEQtuSI9QLaMAAAAAFJV9tgAAAAAABNCT////+AAAAABl1hcZAAAAAGXWFxkAAAAAFJHyYgAAAAAABSiiCpNiTCHehLuPgQnVnm7re1I87s4Q5TZVJRrRsWTzDeLbOlWc8O8By8QDAHdDNV7aK912nrcDIAsm7Au7itRcL02FGe9iyoIzi8EneY72kweZJboq5RiYHcxvCx3V/FgsmDXj6w9j3ZiNF8KjAf0qIqA0tY0F4ykHz5KyzUpYmR9vXXIxCXe7XwsbuFmyqHpBvZh130jfjZKW/+WxxGo9hXJnbRLdlNSD867uAkeRFIvFR2TKZAtiGxu+zDlxU3TPm9edwwamNzEgAFUA0MojwcwAXgBMzx21v3autqSSGPQ9rD1LJ16S3hLe1NEAAAAAAmFuWwAAAAAAAJdu////+wAAAABl1hcZAAAAAGXWFxkAAAAAAmFfiAAAAAAAAJzWCsCXVf0bFBNSdDUSeMvsQVPDPaRYa0VX/CxnbDpJ4hGTipO9x6OVPpOzdoKJVX4YhZ85/RSXBYQezroKGjAC4j+ku/WDBwmax52ykn1wBQpBDED2A34ol64VYLnjEfVfl0Zvfv8khkB/Rg1NigYalj7zYNyDMGX85C7q04+Tk1/dYdR+zfwdxxo7qiB1CFEzBQUVe1qvD7TwOKhFYxllBKqXSh1CUPbYzv7QcIOTs0WfxIbHEQtiGxu+zDlxU3TPm9edwwamNzEg']}, 'parsed': [{'id': feed_ids[0], 'price': {'price': '345341366', 'conf': '315539', 'expo': -8, 'publish_time': 1708529433}, 'ema_price': {'price': '345109090', 'conf': '338082', 'expo': -8, 'publish_time': 1708529433}, 'metadata': {'slot': 126265515, 'proof_available_time': 1708529435, 'prev_publish_time': 1708529433}}, {'id': feed_ids[1], 'price': {'price': '39939675', 'conf': '38766', 'expo': -5, 'publish_time': 1708529433}, 'ema_price': {'price': '39935880', 'conf': '40150', 'expo': -5, 'publish_time': 1708529433}, 'metadata': {'slot': 126265515, 'proof_available_time': 1708529435, 'prev_publish_time': 1708529433}}]} 92 | 93 | @pytest.fixture 94 | def json_get_pyth_price_at_time_v1(feed_id: str): 95 | return {'id': feed_id, 'price': {'price': '135077', 'conf': '69', 'expo': -5, 'publish_time': 1708529661}, 'ema_price': {'price': '135182', 'conf': '52', 'expo': -5, 'publish_time': 1708529661}, 'vaa': 'UE5BVQEAAAADuAEAAAADDQFOmUMU+Z4r00C4N2LLbib67MfjUQrRDE9JCMD2rRAbHw/wKlgC1RgVuom5U+XO3mTFBUySDPxVLdKZT2+kSPNPAQLUFqHSI4KJPqeD0kt+JojinHUrlMFPw6y/tq9Go/izICpUhJY0+RUoQQ1it5jJKhKIe0GOVCWDN3M8WZwjDNW1AQTpTokfNehR1p7GQdWOCoEiec0ARzKkAHYkIJAOaHGUOhXrvL2g2P/nXZzBK32a67vI2ia1e/sZN0rLclEobzDgAAbGveTKWcp9znmPAPAA1mGf+ZeJy7eXN/9vWrdEZc2wQ1cRO0eYAUcMMLAKNJ80lTKN7yxn0sekTUN1NSDdOSctAAdm8xF1ucpBgaOK7vo+OoXZa2nJ5vNxbUM99qvVxeg6sCMqwVD03/BD2VeuUxGGJUIJgbi6RViZoEahk2GcTE5kAQiwuLqaPQ8YJgYJ2ODBVZHXzTosLVWpxJQr1RwCvBpNKTiBFX6TMXJk6cWgKpi0siRFkMrMXuVApOMUbXtFGHmoAQoyfR9lYa/G8reoSK/NUaN2eNWgEpODlNlVRPSC1pe3YRG7BaGMyyOLc4dF3NDO/Mv8GERLRnq/5uk6hZmfMrdaAQsdCecWyLR6ajfuhZQXqXvSKYlkLum6WmWh1Rgytaiq3lXIQ0KzNxplogC7/uDxaICmwFMQIe5j+0vkQjK0YRtkAAwPqtN22f08AAFJ9vkLJ2EHNrzRqgpii3hGM4B+lqipvCT1Zwrrc4Akm2cT8jQvta08zHOGA9upZuyRefMKtvIrAQ0BoSlqog4qD/tABWgvxxCP0S7leutd1BtdcPkDMOyIaRt4HujJn4K4+7tqsukqkvdsm9kffRt2apxDLHpLttvCAQ4j24Btj0Z+U7soibfa/1asb/u+sDzydMZPiMEf6441IRaGdRHaeo9pCKzoaUc8xBcktFHsa7hZR8bE1xiYzCJzABB5St+Lvp/I2XXIjLB0TnscZRs/TDnRGl0N0UudLZ7BNgmtOCD8gLtvrnpSOegfeO+yEbHsAosn/CJVD1jgxuQuARLEIz8Z8yew/zDanqL+zjnR3GayU3ddKqP8AlGuxZjZNXEsQZP93ivcRgX14VyK+qxX9bHmsb/5+ljg3rNaokbyAWXWF/0AAAAAABrhAfrtrFhR4yubI7X5QRqMK6xKrj7U3XuBHdGnLqSqcQAAAAACfpqyAUFVV1YAAAAAAAeGqjMAACcQiDrN+zmqsRbApltVCbB2uiQTTXkBAFUAMRKwOkHJEO1EaFKqz2cRjLG+xnss0LmiFMWMwOqi7MoAAAAAAAIPpQAAAAAAAABF////+wAAAABl1hf9AAAAAGXWF/0AAAAAAAIQDgAAAAAAAAA0Crt2twYzlx9tlARo8EHFjGit6NU5uJVQez40nh22JSTHSjgQajW5RNeQbb732lp/y/9sv9HmENJElTPq3K22/AizzBGRk8LG9r40CWoY4KWiKxi9UqTvbO8hGd/Q6XyH+vrfu68/YWiqdx50GJkhYQS/nLfF9lgMMWf+svoFsyU8hbanvEjRht/xhRD30QKgQ0XqHBBRB5x1oagos+qIqYZY6Izt6SpS/Fj4p6qUrezAAEmVz83V+LurKg3kaurFWAzvwhnsQyop'} 96 | 97 | @pytest.fixture 98 | def json_get_pyth_price_at_time_v2(feed_id: str): 99 | return {'binary': {'encoding': 'base64', 'data': ['UE5BVQEAAAADuAEAAAADDQL1JxtV1elNjzs2291jgXRpsSf4YCQcTk+AcnFSQuEebFASXELZ5Jy3QRqqkXuyoBmzgIueQyIC75FpTQVbmyfiAARvrKPM14V0I4OLOh1wvJxpJscvhlLXW1iuZN9E4WNm32x9o2uhwmQx66SdmcORpbeRKxqNwjfVyWwh7auDusBdAQbihmpCGqMRGclMSP62LojuT3plWmcFUUYbV+vMRqGk8WGm2WRqIcVtaqYu8gyAOqZt+Rq81Qh16wsie4JHV2PwAQeSMDuE525flbp0bVIie1a3r2KoMyfmclUSTPTqTM//JmrDQxIab+mupQtxy3oIcrAHOYF1H/lqHCYdPsSjDN5gAQh1gghNXT6IqNWcGItZSyxKJbhDS7yXDEJLNaG3OTPl1QITX3sSWXeW8dO5larV+lKAfXZPsWij8ouKtxwKqHMgAQo7RUY8HCLVkhnhITobBtlRT6LIvku6J3dFqz0ptccHuWPxBM5orE5OGZoURaUqyn1ulcaBDpT/boUAsEHF0Uo3AQtgJ4SgaMHn395DB7THFUTh3vwenWOZBNFUnXuUKc5coXPG9P5RbNZZX9K3ItQw/LVxzqtKCoBQzeAx0dn1rDOgAQwGmOQnLItBi1MCpex3iH0aBtNmh9+PKdoF7Ze00LndxERw2w9dYqwBDx2/rftzMUckmdtuZ7M0IeWW1syur4rPAA0lv+DjKWgG2bG3CrlqfsanyZSRV3DgpJyiyFeEGFHgHxDS6YQJEAP2K5f9MI+Flt5GysnYidZRZuNNEvnHte+eAA6a21Vt6wfm4q3mg+HJQX8QFrDjTyLuz+5129nSp5zygXYDk+QGQ/8fHRX0/d7GPWDRg03fJC03mVTYt2PUAP11AA9yMxX7o66nICcdqkR95dC9S6SizcRHmtPwYHpNuqsCECwRc+KHeBpQ2E01X0ZGXOZwywVQpMDP3LRNagFkO4KEARDnU3uQU9ya2XSF8RXWhxxDeGbrpCt4IiNaP01qLgVvBAv5pBGl1oAbBc+Y5zRjITNPZiGyWOZpYiMTLwZex0KsABL7NQ7UEXmnLF3RUFYg2uBdsXbHM36l/L7i2eBFNyrFsy0/+nBQZ5uZr9ddb/hG6zRHQnFUHTIm86KMvAhzcl0TAGXWFxkAAAAAABrhAfrtrFhR4yubI7X5QRqMK6xKrj7U3XuBHdGnLqSqcQAAAAACfpkqAUFVV1YAAAAAAAeGqKsAACcQZGdJ0D+VlqYUFSXWVFqyPSA0GDUCAFUAOdAg9gmC7Ykqu81KBqJ2qfm3v7zgAyBMEQtuSI9QLaMAAAAAFJV9tgAAAAAABNCT////+AAAAABl1hcZAAAAAGXWFxkAAAAAFJHyYgAAAAAABSiiCpNiTCHehLuPgQnVnm7re1I87s4Q5TZVJRrRsWTzDeLbOlWc8O8By8QDAHdDNV7aK912nrcDIAsm7Au7itRcL02FGe9iyoIzi8EneY72kweZJboq5RiYHcxvCx3V/FgsmDXj6w9j3ZiNF8KjAf0qIqA0tY0F4ykHz5KyzUpYmR9vXXIxCXe7XwsbuFmyqHpBvZh130jfjZKW/+WxxGo9hXJnbRLdlNSD867uAkeRFIvFR2TKZAtiGxu+zDlxU3TPm9edwwamNzEgAFUA0MojwcwAXgBMzx21v3autqSSGPQ9rD1LJ16S3hLe1NEAAAAAAmFuWwAAAAAAAJdu////+wAAAABl1hcZAAAAAGXWFxkAAAAAAmFfiAAAAAAAAJzWCsCXVf0bFBNSdDUSeMvsQVPDPaRYa0VX/CxnbDpJ4hGTipO9x6OVPpOzdoKJVX4YhZ85/RSXBYQezroKGjAC4j+ku/WDBwmax52ykn1wBQpBDED2A34ol64VYLnjEfVfl0Zvfv8khkB/Rg1NigYalj7zYNyDMGX85C7q04+Tk1/dYdR+zfwdxxo7qiB1CFEzBQUVe1qvD7TwOKhFYxllBKqXSh1CUPbYzv7QcIOTs0WfxIbHEQtiGxu+zDlxU3TPm9edwwamNzEg']}, 'parsed': [{'id': feed_id, 'price': {'price': '345341366', 'conf': '315539', 'expo': -8, 'publish_time': 1708529433}, 'ema_price': {'price': '345109090', 'conf': '338082', 'expo': -8, 'publish_time': 1708529433}, 'metadata': {'slot': 126265515, 'proof_available_time': 1708529435, 'prev_publish_time': 1708529433}}]} 100 | 101 | @pytest.fixture 102 | def mock_get_price_feed_ids(mocker: MockerFixture): 103 | async_mock = AsyncMock() 104 | mocker.patch('pythclient.hermes.HermesClient.get_price_feed_ids', side_effect=async_mock) 105 | return async_mock 106 | 107 | def test_parse_unsupported_version(): 108 | with pytest.raises(ValueError): 109 | parse_unsupported_version(3) 110 | with pytest.raises(TypeError): 111 | parse_unsupported_version("3") 112 | 113 | @pytest.mark.asyncio 114 | async def test_hermes_add_feed_ids(hermes_client: HermesClient, mock_get_price_feed_ids: AsyncMock): 115 | mock_get_price_feed_ids.return_value = ["ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"] 116 | 117 | feed_ids = await hermes_client.get_price_feed_ids() 118 | 119 | feed_ids_pre = hermes_client.feed_ids 120 | pending_feed_ids_pre = hermes_client.pending_feed_ids 121 | 122 | # Add feed_ids to the client in duplicate 123 | for _ in range(3): 124 | hermes_client.add_feed_ids(feed_ids) 125 | 126 | assert len(set(hermes_client.feed_ids)) == len(hermes_client.feed_ids) 127 | assert set(hermes_client.feed_ids) == set(feed_ids_pre + feed_ids) 128 | assert len(hermes_client.pending_feed_ids) == len(set(pending_feed_ids_pre + feed_ids)) 129 | 130 | def test_hermes_extract_price_feed_v1(hermes_client: HermesClient, data_v1: dict): 131 | price_feed = hermes_client.extract_price_feed_v1(data_v1) 132 | 133 | assert isinstance(price_feed, dict) 134 | assert set(price_feed.keys()) == set(PriceFeed.__annotations__.keys()) 135 | 136 | def test_hermes_extract_price_feed_v2(hermes_client: HermesClient, data_v2: dict): 137 | price_feeds = hermes_client.extract_price_feed_v2(data_v2) 138 | 139 | assert isinstance(price_feeds, list) 140 | assert isinstance(price_feeds[0], dict) 141 | assert set(price_feeds[0].keys()) == set(PriceFeed.__annotations__.keys()) 142 | 143 | @pytest.mark.asyncio 144 | async def test_get_price_feed_ids(hermes_client: HermesClient, json_get_price_feed_ids: list[str], mocker: AsyncMock): 145 | mocker.patch('httpx.AsyncClient.get', return_value = httpx.Response(200, json=json_get_price_feed_ids)) 146 | result = await hermes_client.get_price_feed_ids() 147 | 148 | assert result == json_get_price_feed_ids 149 | 150 | @pytest.mark.asyncio 151 | async def test_get_pyth_prices_latest_v1(hermes_client: HermesClient, feed_ids: list[str], json_get_pyth_prices_latest_v1: list[dict], mocker: AsyncMock): 152 | mocker.patch('httpx.AsyncClient.get', return_value = httpx.Response(200, json=json_get_pyth_prices_latest_v1)) 153 | result = await hermes_client.get_pyth_prices_latest(feed_ids, version=1) 154 | 155 | assert isinstance(result, list) 156 | assert len(result) == len(feed_ids) 157 | assert isinstance(result[0], dict) 158 | assert set(result[0].keys()) == set(PriceFeed.__annotations__.keys()) 159 | 160 | @pytest.mark.asyncio 161 | async def test_get_pyth_prices_latest_v2(hermes_client: HermesClient, feed_ids: list[str], json_get_pyth_prices_latest_v2: list[str], mocker: AsyncMock): 162 | mocker.patch('httpx.AsyncClient.get', return_value = httpx.Response(200, json=json_get_pyth_prices_latest_v2)) 163 | result = await hermes_client.get_pyth_prices_latest(feed_ids, version=2) 164 | 165 | assert isinstance(result, list) 166 | assert len(result) == len(feed_ids) 167 | assert isinstance(result[0], dict) 168 | assert set(result[0].keys()) == set(PriceFeed.__annotations__.keys()) 169 | 170 | @pytest.mark.asyncio 171 | async def test_get_pyth_prices_latest_v3(hermes_client: HermesClient, feed_ids: list[str], mocker: AsyncMock): 172 | mocker.patch('httpx.AsyncClient.get', return_value = httpx.Response(200, json=[])) 173 | with pytest.raises(ValueError): 174 | await hermes_client.get_pyth_prices_latest(feed_ids, version=3) 175 | 176 | @pytest.mark.asyncio 177 | async def test_get_pyth_price_at_time_v1(hermes_client: HermesClient, feed_id: str, json_get_pyth_price_at_time_v1: dict, mocker: AsyncMock): 178 | mocker.patch('httpx.AsyncClient.get', return_value = httpx.Response(200, json=json_get_pyth_price_at_time_v1)) 179 | result = await hermes_client.get_pyth_price_at_time(feed_id, 1708529661, version=1) 180 | 181 | assert isinstance(result, dict) 182 | assert set(result.keys()) == set(PriceFeed.__annotations__.keys()) 183 | 184 | @pytest.mark.asyncio 185 | async def test_get_pyth_price_at_time_v2(hermes_client: HermesClient, feed_id: str, json_get_pyth_price_at_time_v2: dict, mocker: AsyncMock): 186 | mocker.patch('httpx.AsyncClient.get', return_value = httpx.Response(200, json=json_get_pyth_price_at_time_v2)) 187 | result = await hermes_client.get_pyth_price_at_time(feed_id, 1708529661, version=2) 188 | 189 | assert isinstance(result, dict) 190 | assert set(result.keys()) == set(PriceFeed.__annotations__.keys()) 191 | 192 | @pytest.mark.asyncio 193 | async def test_get_pyth_price_at_time_v3(hermes_client: HermesClient, feed_id: str, mocker: AsyncMock): 194 | mocker.patch('httpx.AsyncClient.get', return_value = httpx.Response(200, json=[])) 195 | with pytest.raises(ValueError): 196 | await hermes_client.get_pyth_price_at_time(feed_id, 1708529661, version=3) 197 | 198 | @pytest.mark.asyncio 199 | async def test_get_all_prices_v1(hermes_client: HermesClient, json_get_pyth_prices_latest_v1: list[dict], mocker: AsyncMock): 200 | mocker.patch('httpx.AsyncClient.get', return_value = httpx.Response(200, json=json_get_pyth_prices_latest_v1)) 201 | result = await hermes_client.get_all_prices(version=1) 202 | 203 | assert isinstance(result, dict) 204 | assert set(result.keys()) == set(hermes_client.feed_ids) 205 | assert isinstance(result[hermes_client.feed_ids[0]], dict) 206 | assert set(result[hermes_client.feed_ids[0]].keys()) == set(PriceFeed.__annotations__.keys()) 207 | 208 | @pytest.mark.asyncio 209 | async def test_get_all_prices_v2(hermes_client: HermesClient, json_get_pyth_prices_latest_v2: list[dict], mocker: AsyncMock): 210 | mocker.patch('httpx.AsyncClient.get', return_value = httpx.Response(200, json=json_get_pyth_prices_latest_v2)) 211 | result = await hermes_client.get_all_prices(version=2) 212 | 213 | assert isinstance(result, dict) 214 | assert set(result.keys()) == set(hermes_client.feed_ids) 215 | assert isinstance(result[hermes_client.feed_ids[0]], dict) 216 | assert set(result[hermes_client.feed_ids[0]].keys()) == set(PriceFeed.__annotations__.keys()) -------------------------------------------------------------------------------- /tests/test_mapping_account.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import pytest 3 | 4 | from pythclient.pythaccounts import PythMappingAccount, _VERSION_2 5 | from pythclient.solana import SolanaPublicKey 6 | 7 | 8 | @pytest.fixture 9 | def mapping_account_bytes(): 10 | """ 11 | Put a debugger breakpoint in PythMappingAccount.update_from() at the top. 12 | Get the mapping account number of entries and 3 keys: 13 | 14 | fmt_size = struct.calcsize(fmt) 15 | intermediate_buffer = buffer[offset:offset + fmt_size + (SolanaPublicKey.LENGTH * 3)] 16 | 17 | Replace the num_keys bytes with 3: 18 | 19 | num_entries_bytes = int(3).to_bytes(4, 'little') 20 | product_account_bytes = num_entries_bytes + intermediate_buffer[struct.calcsize(" PythPriceAccount: 234 | return PythPriceAccount( 235 | key=SolanaPublicKey("5ALDzwcRJfSyGdGyhP3kP628aqBNHZzLuVww7o9kdspe"), 236 | solana=solana_client, 237 | ) 238 | 239 | def test_price_account_update_from(price_account_bytes: bytes, price_account: PythPriceAccount): 240 | price_account.update_from(buffer=price_account_bytes, version=2, offset=ACCOUNT_HEADER_BYTES) 241 | 242 | assert price_account.price_type == PythPriceType.PRICE 243 | assert price_account.exponent == -5 244 | assert price_account.num_components == 29 245 | assert len(price_account.price_components) == price_account.num_components 246 | assert price_account.last_slot == 197635583 247 | assert price_account.valid_slot == 197635582 248 | assert price_account.product_account_key == SolanaPublicKey( 249 | "3mkwqdkawySvAm1VjD4f2THN5mmXzb76fvft2hWpAANo" 250 | ) 251 | assert price_account.max_latency == 50 252 | assert price_account.next_price_account_key is None 253 | assert asdict(price_account.aggregate_price_info) == { 254 | "raw_price": 23623373, 255 | "raw_confidence_interval": 14454, 256 | "price_status": PythPriceStatus.TRADING, 257 | "pub_slot": 197635583, 258 | "exponent": -5, 259 | "price": 236.23373, 260 | "confidence_interval": 0.14454, 261 | } 262 | # Only assert the first element of the 19 price components 263 | assert asdict(price_account.price_components[0]) == { 264 | "publisher_key": SolanaPublicKey( 265 | "JTmFx5zX9mM94itfk2nQcJnQQDPjcv4UPD7SYj6xDCV" 266 | ), 267 | "last_aggregate_price_info": { 268 | "raw_price": 23622000, 269 | "raw_confidence_interval": 50363, 270 | "price_status": PythPriceStatus.TRADING, 271 | "pub_slot": 197635580, 272 | "exponent": -5, 273 | "price": 236.22000000000003, 274 | "confidence_interval": 0.50363, 275 | }, 276 | "latest_price_info": { 277 | "raw_price": 23622000, 278 | "raw_confidence_interval": 50363, 279 | "price_status": PythPriceStatus.TRADING, 280 | "pub_slot": 197635580, 281 | "exponent": -5, 282 | "price": 236.22000000000003, 283 | "confidence_interval": 0.50363, 284 | }, 285 | "exponent": -5, 286 | } 287 | assert price_account.min_publishers == 3 288 | 289 | 290 | def test_price_account_str( 291 | price_account_bytes: bytes, price_account: PythPriceAccount, solana_client: SolanaClient, 292 | ): 293 | expected_empty = "PythPriceAccount PythPriceType.UNKNOWN (5ALDzwcRJfSyGdGyhP3kP628aqBNHZzLuVww7o9kdspe)" 294 | assert str(price_account) == expected_empty 295 | 296 | expected = "PythPriceAccount PythPriceType.PRICE (5ALDzwcRJfSyGdGyhP3kP628aqBNHZzLuVww7o9kdspe)" 297 | price_account.update_from(buffer=price_account_bytes, version=2, offset=ACCOUNT_HEADER_BYTES) 298 | assert str(price_account) == expected 299 | 300 | price_account.product = PythProductAccount( 301 | key=SolanaPublicKey("5uKdRzB3FzdmwyCHrqSGq4u2URja617jqtKkM71BVrkw"), 302 | solana=solana_client, 303 | ) 304 | price_account.product.attrs = { 305 | "symbol": "FOO/BAR", 306 | } 307 | expected_with_product = ( 308 | "PythPriceAccount FOO/BAR PythPriceType.PRICE (5ALDzwcRJfSyGdGyhP3kP628aqBNHZzLuVww7o9kdspe)" 309 | ) 310 | assert str(price_account) == expected_with_product 311 | 312 | 313 | def test_price_account_agregate_conf_interval( 314 | price_account_bytes: bytes, price_account: PythPriceAccount, 315 | ): 316 | price_account.update_from(buffer=price_account_bytes, version=2, offset=ACCOUNT_HEADER_BYTES) 317 | price_account.slot = price_account.aggregate_price_info.pub_slot 318 | assert price_account.aggregate_price_confidence_interval == 0.14454 319 | 320 | 321 | def test_price_account_agregate_price( 322 | price_account_bytes: bytes, price_account: PythPriceAccount, 323 | ): 324 | price_account.update_from(buffer=price_account_bytes, version=2, offset=ACCOUNT_HEADER_BYTES) 325 | price_account.slot = price_account.aggregate_price_info.pub_slot 326 | assert price_account.aggregate_price == 236.23373 327 | 328 | def test_price_account_unknown_status( 329 | price_account_bytes: bytes, price_account: PythPriceAccount, 330 | ): 331 | price_account.update_from(buffer=price_account_bytes, version=2, offset=ACCOUNT_HEADER_BYTES) 332 | price_account.slot = price_account.aggregate_price_info.pub_slot 333 | price_account.aggregate_price_info.price_status = PythPriceStatus.UNKNOWN 334 | 335 | assert price_account.aggregate_price is None 336 | assert price_account.aggregate_price_confidence_interval is None 337 | 338 | def test_price_account_get_aggregate_price_status_still_trading( 339 | price_account_bytes: bytes, price_account: PythPriceAccount 340 | ): 341 | price_account.update_from(buffer=price_account_bytes, version=2, offset=ACCOUNT_HEADER_BYTES) 342 | price_account.slot = price_account.aggregate_price_info.pub_slot + 50 343 | 344 | price_status = price_account.aggregate_price_status 345 | assert price_status == PythPriceStatus.TRADING 346 | 347 | def test_price_account_get_aggregate_price_status_got_stale( 348 | price_account_bytes: bytes, price_account: PythPriceAccount 349 | ): 350 | price_account.update_from(buffer=price_account_bytes, version=2, offset=ACCOUNT_HEADER_BYTES) 351 | price_account.slot = price_account.aggregate_price_info.pub_slot + 50 + 1 352 | 353 | price_status = price_account.aggregate_price_status 354 | assert price_status == PythPriceStatus.UNKNOWN 355 | -------------------------------------------------------------------------------- /tests/test_price_account_header.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pythclient.pythaccounts import PythAccountType, _parse_header 4 | 5 | 6 | @pytest.fixture 7 | def valid_binary(): 8 | """ 9 | magic=0xA1B2C3D4 version=2, type=price, size=16 10 | """ 11 | return bytes([212, 195, 178, 161, 2, 0, 0, 0, 3, 0, 0, 0, 16, 0, 0, 0]) 12 | 13 | 14 | @pytest.fixture 15 | def valid_expected(): 16 | return PythAccountType.PRICE, 16, 2 17 | 18 | 19 | @pytest.fixture 20 | def bad_magic(): 21 | """ 22 | magic=0xDEADBEEF, version=2, type=price, size=16 23 | """ 24 | return bytes([239, 190, 173, 222, 2, 0, 0, 0, 3, 0, 0, 0, 16, 0, 0, 0]) 25 | 26 | 27 | @pytest.fixture 28 | def bad_magic_message(): 29 | return "Invalid Pyth account data header has wrong magic: expected a1b2c3d4, got deadbeef" 30 | 31 | 32 | @pytest.fixture 33 | def wrong_version(): 34 | """ 35 | magic=0xA1B2C3D4 version=42, type=price, size=16 36 | """ 37 | return bytes([212, 195, 178, 161, 42, 0, 0, 0, 3, 0, 0, 0, 16, 0, 0, 0]) 38 | 39 | 40 | @pytest.fixture 41 | def wrong_version_message(): 42 | return "Invalid Pyth account data has unsupported version 42" 43 | 44 | 45 | @pytest.fixture 46 | def wrong_size(): 47 | """ 48 | magic=0xA1B2C3D4 version=2, type=price, size=32 49 | """ 50 | return bytes([212, 195, 178, 161, 2, 0, 0, 0, 3, 0, 0, 0, 32, 0, 0, 0]) 51 | 52 | 53 | @pytest.fixture 54 | def wrong_size_message(): 55 | return "Invalid Pyth header says data is 32 bytes, but buffer only has 16 bytes" 56 | 57 | 58 | @pytest.fixture 59 | def too_short(): 60 | """ 61 | Totally bogus messge that is too short 62 | """ 63 | return bytes([1, 2, 3, 4]) 64 | 65 | 66 | @pytest.fixture 67 | def too_short_message(): 68 | return "Pyth account data too short" 69 | 70 | 71 | @pytest.mark.parametrize( 72 | "buffer_fixture_name", 73 | ["bad_magic", "wrong_version", "wrong_size", "too_short"], 74 | ) 75 | def test_header_parsing_errors(buffer_fixture_name, request): 76 | buffer = request.getfixturevalue(buffer_fixture_name) 77 | exc_message = request.getfixturevalue(f"{buffer_fixture_name}_message") 78 | 79 | with pytest.raises(ValueError, match=exc_message): 80 | _parse_header( 81 | buffer=buffer, 82 | offset=0, 83 | key="Invalid", 84 | ) 85 | 86 | 87 | def test_header_parsing_valid(valid_binary, valid_expected): 88 | actual = _parse_header( 89 | buffer=valid_binary, 90 | offset=0, 91 | key="Invalid", 92 | ) 93 | assert actual == valid_expected 94 | -------------------------------------------------------------------------------- /tests/test_price_component.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import base64 3 | 4 | from pythclient.solana import SolanaPublicKey 5 | from pythclient.pythaccounts import PythPriceComponent, PythPriceInfo, PythPriceStatus 6 | 7 | 8 | @pytest.fixture 9 | def price_component_bytes() -> bytes: 10 | return base64.b64decode(( 11 | b'92Z9u4180xchiUFKI8JrUh2MGcZFBFVV4+KOglaOZXjgewKnDgAAACDF+wAAAAAA' 12 | b'AQAAAAAAAADTsU8GAAAAAOB7AqcOAAAAIMX7AAAAAAABAAAAAAAAANSxTwYAAAAA' 13 | )) 14 | 15 | 16 | @pytest.fixture 17 | def price_component() -> PythPriceComponent: 18 | exponent = -8 19 | publisher_key = SolanaPublicKey("HekM1hBawXQu6wK6Ah1yw1YXXeMUDD2bfCHEzo25vnEB") 20 | last_aggregate_price = PythPriceInfo(**{ 21 | 'raw_price': 62931500000, 22 | 'raw_confidence_interval': 16500000, 23 | 'price_status': PythPriceStatus.TRADING, 24 | 'pub_slot': 105886163, 25 | 'exponent': exponent, 26 | }) 27 | latest_price = PythPriceInfo(**{ 28 | 'raw_price': 62931500000, 29 | 'raw_confidence_interval': 16500000, 30 | 'price_status': PythPriceStatus.TRADING, 31 | 'pub_slot': 105886164, 32 | 'exponent': exponent, 33 | }) 34 | return PythPriceComponent( 35 | publisher_key, 36 | last_aggregate_price_info=last_aggregate_price, 37 | latest_price_info=latest_price, 38 | exponent=exponent, 39 | ) 40 | 41 | 42 | def test_valid_deserialise(price_component: PythPriceComponent, price_component_bytes: bytes): 43 | actual = PythPriceComponent.deserialise(price_component_bytes, exponent=price_component.exponent) 44 | 45 | # To make pyright happy 46 | assert actual is not None 47 | 48 | # Make the actual assertions 49 | assert actual == price_component 50 | 51 | 52 | def test_deserialise_null_publisher_key(price_component: PythPriceComponent, price_component_bytes: bytes): 53 | # Zero out the solana key (the first 32 bytes of the buffer) 54 | bad_bytes = bytes(b'\x00' * SolanaPublicKey.LENGTH) + price_component_bytes[SolanaPublicKey.LENGTH:] 55 | actual = PythPriceComponent.deserialise(bad_bytes, exponent=price_component.exponent) 56 | assert actual is None 57 | -------------------------------------------------------------------------------- /tests/test_price_info.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import base64 3 | from pythclient.pythaccounts import PythPriceStatus, PythPriceInfo 4 | from dataclasses import asdict 5 | 6 | 7 | @pytest.fixture 8 | def price_info_trading(): 9 | return PythPriceInfo( 10 | raw_price=59609162000, 11 | raw_confidence_interval=43078500, 12 | price_status=PythPriceStatus.TRADING, 13 | pub_slot=105367617, 14 | exponent=-8, 15 | ) 16 | 17 | 18 | @pytest.fixture 19 | def price_info_trading_bytes(): 20 | return base64.b64decode(b'EKH74A0AAABkU5ECAAAAAAEAAAAAAAAAQchHBgAAAAA=') 21 | 22 | 23 | @pytest.fixture 24 | def price_info_ignored(): 25 | return PythPriceInfo( 26 | raw_price=59609162000, 27 | raw_confidence_interval=43078500, 28 | price_status=PythPriceStatus.IGNORED, 29 | pub_slot=105367617, 30 | exponent=-8, 31 | ) 32 | 33 | 34 | @pytest.fixture 35 | def price_info_ignored_bytes(): 36 | return base64.b64decode(b'EKH74A0AAABkU5ECAAAAAAQAAAAAAAAAQchHBgAAAAA=') 37 | 38 | @pytest.mark.parametrize( 39 | "raw_price,raw_confidence_interval,price_status,pub_slot,exponent,price,confidence_interval", 40 | [ 41 | ( 42 | 1234567890, 43 | 3.0, 44 | PythPriceStatus.TRADING, 45 | 12345, 46 | -8, 47 | 12.345678900000001, 48 | 3.0000000000000004e-08, 49 | ), 50 | (0, 0, PythPriceStatus.UNKNOWN, 100001, -9, 0.0, 0.0), 51 | ], 52 | ids=["price_status_trading", "price_status_unknown"], 53 | ) 54 | class TestPythPriceInfo: 55 | def test_price_info( 56 | self, 57 | raw_price, 58 | raw_confidence_interval, 59 | price_status, 60 | pub_slot, 61 | exponent, 62 | price, 63 | confidence_interval, 64 | ): 65 | actual = PythPriceInfo( 66 | raw_price=raw_price, 67 | raw_confidence_interval=raw_confidence_interval, 68 | price_status=price_status, 69 | pub_slot=pub_slot, 70 | exponent=exponent, 71 | ) 72 | for key, actual_value in asdict(actual).items(): 73 | assert actual_value == locals().get(key), f"'{key}' mismatch" 74 | 75 | def test_price_info_iter( 76 | self, 77 | raw_price, 78 | raw_confidence_interval, 79 | price_status, 80 | pub_slot, 81 | exponent, 82 | price, 83 | confidence_interval, 84 | ): 85 | actual = asdict( 86 | PythPriceInfo( 87 | raw_price=raw_price, 88 | raw_confidence_interval=raw_confidence_interval, 89 | price_status=price_status, 90 | pub_slot=pub_slot, 91 | exponent=exponent, 92 | ) 93 | ) 94 | expected = { 95 | "raw_price": raw_price, 96 | "raw_confidence_interval": raw_confidence_interval, 97 | "price_status": price_status, 98 | "pub_slot": pub_slot, 99 | "exponent": exponent, 100 | "price": price, 101 | "confidence_interval": confidence_interval, 102 | } 103 | assert actual == expected 104 | 105 | 106 | def test_price_info_deserialise_trading(price_info_trading, price_info_trading_bytes): 107 | actual = PythPriceInfo.deserialise( 108 | buffer=price_info_trading_bytes, 109 | offset=0, 110 | exponent=price_info_trading.exponent, 111 | ) 112 | assert asdict(actual) == asdict(price_info_trading) 113 | 114 | 115 | def test_price_info_deserialise_ignored(price_info_ignored, price_info_ignored_bytes): 116 | actual = PythPriceInfo.deserialise( 117 | buffer=price_info_ignored_bytes, 118 | offset=0, 119 | exponent=price_info_ignored.exponent, 120 | ) 121 | assert asdict(actual) == asdict(price_info_ignored) 122 | 123 | 124 | def test_price_info_str(price_info_trading): 125 | expected = "PythPriceInfo status PythPriceStatus.TRADING price 596.09162" 126 | assert str(price_info_trading) == expected 127 | assert repr(price_info_trading) == expected 128 | -------------------------------------------------------------------------------- /tests/test_product_account.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import base64 3 | import pytest 4 | from typing import Callable, Optional, Tuple 5 | 6 | import pythclient.pythaccounts 7 | from pythclient.exceptions import NotLoadedException 8 | from pythclient.solana import SolanaPublicKey, SolanaClient 9 | from pythclient.pythaccounts import ( 10 | _VERSION_2, 11 | PythProductAccount, 12 | _read_attribute_string, 13 | ) 14 | 15 | 16 | @pytest.fixture 17 | def product_account_bytes() -> bytes: 18 | # Manually split up base64 encoded str for readability 19 | return base64.b64decode(( 20 | b'PdK2NoakUOxykN86HgtYPASB9lE1Ht+nY285rtVc+KMKYXNzZXRfdHlwZQZDcnlw' 21 | b'dG8Gc3ltYm9sB0JDSC9VU0QHY291bnRyeQJVUw5xdW90ZV9jdXJyZW5jeQNVU0QL' 22 | b'ZGVzY3JpcHRpb24HQkNIL1VTRAV0ZW5vcgRTcG90DmdlbmVyaWNfc3ltYm9sBkJDSFVTRA==' 23 | )) 24 | 25 | 26 | @pytest.fixture 27 | def product_account(solana_client: SolanaClient): 28 | product_account = PythProductAccount( 29 | key=SolanaPublicKey("5uKdRzB3FzdmwyCHrqSGq4u2URja617jqtKkM71BVrkw"), 30 | solana=solana_client, 31 | ) 32 | product_account.attrs = { 33 | "asset_type": "Crypto", 34 | "symbol": "BCH/USD", 35 | "country": "US", 36 | "quote_currency": "USD", 37 | "description": "BCH/USD", 38 | "tenor": "Spot", 39 | "generic_symbol": "BCHUSD", 40 | } 41 | product_account.first_price_account_key = SolanaPublicKey( 42 | "5ALDzwcRJfSyGdGyhP3kP628aqBNHZzLuVww7o9kdspe", 43 | ) 44 | return product_account 45 | 46 | 47 | def test_product_account_update_from( 48 | product_account: PythProductAccount, product_account_bytes: bytes, solana_client: SolanaClient 49 | ): 50 | actual = PythProductAccount( 51 | key=product_account.key, 52 | solana=solana_client, 53 | ) 54 | assert actual.attrs == {} 55 | 56 | actual.update_from(buffer=product_account_bytes, version=_VERSION_2) 57 | 58 | assert dict(actual) == dict(product_account) 59 | 60 | 61 | def test_update_from_null_first_price_account_key( 62 | product_account: PythProductAccount, product_account_bytes: bytes, solana_client: SolanaClient 63 | ): 64 | actual = PythProductAccount( 65 | key=product_account.key, 66 | solana=solana_client, 67 | ) 68 | product_account.first_price_account_key = None 69 | 70 | # Zero out the first price account key 71 | bad_bytes = ( 72 | bytes(b"\x00" * SolanaPublicKey.LENGTH) 73 | + product_account_bytes[SolanaPublicKey.LENGTH:] 74 | ) 75 | 76 | actual.update_from(buffer=bad_bytes, version=_VERSION_2) 77 | 78 | assert dict(actual) == dict(product_account) 79 | 80 | 81 | def test_product_account_update_from_invalid_attr_key( 82 | product_account: PythProductAccount, product_account_bytes: bytes, solana_client: SolanaClient 83 | ): 84 | actual = PythProductAccount( 85 | key=product_account.key, 86 | solana=solana_client, 87 | ) 88 | 89 | def fake_read_attribute_string(buffer: bytes, offset: int) -> Tuple[Optional[str], str]: 90 | results = _read_attribute_string(buffer, offset) 91 | if results[0] == "generic_symbol": 92 | return (None, results[1]) 93 | return results 94 | 95 | with mock.patch.object(pythclient.pythaccounts, "_read_attribute_string") as mocked: 96 | mocked.side_effect = fake_read_attribute_string 97 | 98 | # Call PythProductAccount.update_from() 99 | actual.update_from(buffer=product_account_bytes, version=_VERSION_2) 100 | 101 | del product_account.attrs["generic_symbol"] 102 | 103 | assert dict(actual) == dict(product_account) 104 | 105 | 106 | @pytest.mark.parametrize("func", [str, repr]) 107 | def test_human_readable(func: Callable, product_account: PythProductAccount): 108 | expected = ( 109 | "PythProductAccount BCH/USD (5uKdRzB3FzdmwyCHrqSGq4u2URja617jqtKkM71BVrkw)" 110 | ) 111 | assert func(product_account) == expected 112 | 113 | 114 | def test_prices_property_not_loaded(product_account: PythProductAccount): 115 | with pytest.raises(NotLoadedException): 116 | product_account.prices 117 | 118 | 119 | def test_symbol_property(product_account): 120 | assert product_account.symbol == "BCH/USD" 121 | 122 | 123 | def test_symbol_property_unknown(product_account: PythProductAccount, solana_client: SolanaClient): 124 | actual = PythProductAccount( 125 | key=product_account.key, 126 | solana=solana_client, 127 | ) 128 | assert actual.symbol == "Unknown" 129 | -------------------------------------------------------------------------------- /tests/test_pyth_account.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from _pytest.logging import LogCaptureFixture 4 | 5 | from pythclient.solana import SolanaClient, SolanaPublicKey 6 | from pythclient.pythaccounts import PythAccount, PythAccountType, PythProductAccount 7 | 8 | 9 | @pytest.fixture 10 | def solana_pubkey() -> SolanaPublicKey: 11 | return SolanaPublicKey("AHtgzX45WTKfkPG53L6WYhGEXwQkN1BVknET3sVsLL8J") 12 | 13 | 14 | @pytest.fixture 15 | def product_account_pubkey() -> SolanaPublicKey: 16 | return SolanaPublicKey("89GseEmvNkzAMMEXcW9oTYzqRPXTsJ3BmNerXmgA1osV") 17 | 18 | 19 | @pytest.fixture 20 | def price_account_pubkey() -> SolanaPublicKey: 21 | return SolanaPublicKey("4EQrNZYk5KR1RnjyzbaaRbHsv8VqZWzSUtvx58wLsZbj") 22 | 23 | 24 | @pytest.fixture 25 | def product_account_b64() -> str: 26 | return ('1MOyoQIAAAACAAAAlwAAADAClHlZh5cpDjY4oXEsKb3iNn0OynlPd4sltaRy8ZLeBnN5bWJvbAdCQ0gv' 27 | 'VVNECmFzc2V0X3R5cGUGQ3J5cHRvDnF1b3RlX2N1cnJlbmN5A1VTRAtkZXNjcmlwdGlvbgdCQ0gvVVNE' 28 | 'DmdlbmVyaWNfc3ltYm9sBkJDSFVTRARiYXNlA0JDSA==') 29 | 30 | 31 | @pytest.fixture 32 | def pyth_account(solana_pubkey: SolanaPublicKey, solana_client: SolanaClient) -> PythAccount: 33 | return PythAccount( 34 | key=solana_pubkey, 35 | solana=solana_client, 36 | ) 37 | 38 | 39 | @ pytest.fixture 40 | def product_account(solana_client: SolanaClient, 41 | product_account_pubkey: SolanaPublicKey, 42 | price_account_pubkey: SolanaPublicKey) -> PythProductAccount: 43 | product_account = PythProductAccount( 44 | key=product_account_pubkey, 45 | solana=solana_client, 46 | ) 47 | product_account.slot = 96866599 48 | product_account.attrs = { 49 | 'asset_type': 'Crypto', 50 | 'symbol': 'BCH/USD', 51 | 'quote_currency': 'USD', 52 | 'description': 'BCH/USD', 53 | 'generic_symbol': 'BCHUSD', 54 | 'base': 'BCH' 55 | } 56 | product_account.first_price_account_key = price_account_pubkey 57 | return product_account 58 | 59 | 60 | def test_product_account_update_with_rpc_response_with_data( 61 | solana_client: SolanaClient, 62 | product_account: PythProductAccount, 63 | product_account_b64: str 64 | ) -> None: 65 | actual = PythProductAccount( 66 | key=product_account.key, 67 | solana=solana_client, 68 | ) 69 | assert actual.attrs == {} 70 | 71 | slot = 96866600 72 | value = { 73 | 'lamports': 1000000000, 74 | 'data': [ 75 | product_account_b64, 76 | 'base64' 77 | ] 78 | } 79 | 80 | actual.update_with_rpc_response(slot=slot, value=value) 81 | assert actual.slot == slot 82 | assert actual.lamports == value['lamports'] 83 | assert actual.attrs['symbol'] == product_account.attrs['symbol'] 84 | assert actual.attrs['description'] == product_account.attrs['description'] 85 | assert actual.attrs['generic_symbol'] == product_account.attrs['generic_symbol'] 86 | assert actual.attrs['base'] == product_account.attrs['base'] 87 | 88 | 89 | def test_pyth_account_update_with_rpc_response_wrong_type( 90 | pyth_account: PythAccount, 91 | caplog: LogCaptureFixture, 92 | product_account_b64: str 93 | ) -> None: 94 | slot = 96866600 95 | value = { 96 | 'lamports': 1000000000, 97 | 'data': [ 98 | product_account_b64, 99 | 'base64' 100 | ] 101 | } 102 | 103 | exc_message = f"wrong Pyth account type {PythAccountType.PRODUCT} for {type(pyth_account)}" 104 | with pytest.raises(ValueError, match=exc_message): 105 | pyth_account.update_with_rpc_response(slot=slot, value=value) 106 | 107 | 108 | def test_pyth_account_update_with_rpc_response_no_data( 109 | pyth_account: PythAccount, 110 | caplog: LogCaptureFixture 111 | ) -> None: 112 | slot = 106498726 113 | value = { 114 | "lamports": 1000000000 115 | } 116 | 117 | exc_message = f'invalid account data response from Solana for key {pyth_account.key}: {value}' 118 | with pytest.raises(ValueError, match=exc_message): 119 | pyth_account.update_with_rpc_response(slot=slot, value=value) 120 | assert exc_message in caplog.text 121 | -------------------------------------------------------------------------------- /tests/test_pyth_client.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Union, Sequence, List 2 | import pytest 3 | import base64 4 | from pythclient.exceptions import NotLoadedException 5 | from pythclient.pythaccounts import ( 6 | ACCOUNT_HEADER_BYTES, _VERSION_2, PythMappingAccount, PythPriceType, PythProductAccount, PythPriceAccount 7 | ) 8 | 9 | from pythclient.pythclient import PythClient, WatchSession 10 | from pythclient.solana import ( 11 | SolanaClient, 12 | SolanaCommitment, 13 | SolanaPublicKey, 14 | SolanaPublicKeyOrStr 15 | ) 16 | 17 | from pytest_mock import MockerFixture 18 | 19 | from mock import AsyncMock 20 | 21 | 22 | # Using constants instead of fixtures because: 23 | # 1) these values are not expected to be mutated 24 | # 2) these values are used in get_account_info_resp() and get_program_accounts_resp() 25 | # and so if they are passed in as fixtures, the functions will complain for the args 26 | # while mocking the respective functions 27 | V2_FIRST_MAPPING_ACCOUNT_KEY = 'BmA9Z6FjioHJPpjT39QazZyhDRUdZy2ezwx4GiDdE2u2' 28 | V2_PROGRAM_KEY = 'gSbePebfvPy7tRqimPoVecS2UsBvYv46ynrzWocc92s' 29 | 30 | BCH_PRODUCT_ACCOUNT_KEY = '89GseEmvNkzAMMEXcW9oTYzqRPXTsJ3BmNerXmgA1osV' 31 | BCH_PRICE_ACCOUNT_KEY = '4EQrNZYk5KR1RnjyzbaaRbHsv8VqZWzSUtvx58wLsZbj' 32 | 33 | MAPPING_ACCOUNT_B64_DATA = ('1MOyoQIAAAABAAAAWAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABqIGcc' 34 | 'Dj+MshnOP0blrglqTy/fk20r1NqJJfcAh9Ud2A==') 35 | PRODUCT_ACCOUNT_B64_DATA = ('1MOyoQIAAAACAAAAlwAAADAClHlZh5cpDjY4oXEsKb3iNn0OynlPd4sltaRy8ZLeBnN5bWJvbAdCQ0gv' 36 | 'VVNECmFzc2V0X3R5cGUGQ3J5cHRvDnF1b3RlX2N1cnJlbmN5A1VTRAtkZXNjcmlwdGlvbgdCQ0gvVVNE' 37 | 'DmdlbmVyaWNfc3ltYm9sBkJDSFVTRARiYXNlA0JDSA==') 38 | PRICE_ACCOUNT_B64_DATA = ('1MOyoQIAAAADAAAAEAsAAAEAAAD3////GwAAAAIAAAAfPsYFAAAAAB4+xgUAAAAA0B+GxYIAAAB/xYqq' 39 | 'AAAAADy4oy8BAAAAtPuFGgAAAAC8tR2HAAAAADy4oy8BAAAAAQAAAAAAAAAAAAAAAAAAAGogZxwOP4yy' 40 | 'Gc4/RuWuCWpPL9+TbSvU2okl9wCH1R3YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdPsYF' 41 | 'AAAAACB1G8yCAAAASJxHLgAAAAAvuzCkNjwQn8DZCsmCAAAASCynLwAAAAABAAAAAAAAAB8+xgUAAAAA' 42 | 'Qlxb88UapZ0T6mWzABhtX/lDiPrAaUMbsl4vmXpBgd4AI6GaggAAAICFtQ0AAAAAAQAAAAAAAAAdPsYF' 43 | 'AAAAAIATnJ2CAAAAAJW6CgAAAAABAAAAAAAAAB4+xgUAAAAAopQU2JDnE5ZYJwruatN5x2coYY19zVBC' 44 | 'tyZbiKhxYksAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 45 | 'AAAAAAAAAAAAAAAAkH6Erg47Ci94ZGUUYRxRICzgpNlfaIVeXC3eoge5K66z0OUXhQAAANMiBSEAAAAA' 46 | 'AQAAAAAAAABqYa8FAAAAALPQ5ReFAAAA0yIFIQAAAAABAAAAAAAAAGphrwUAAAAAJyCj9u7+AUmDcI1H' 47 | 'T9GvlDfStBYeQB5YYZZZsDQht+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 48 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHkXfq/XM16tovM/VgEWSsYzNZhi/J9PvVgRbUtqgcg8dWmr8' 49 | 'ggAAADjoaxIAAAAAAQAAAAAAAAAcPsYFAAAAAIn1Af2CAAAAi/1rEgAAAAABAAAAAAAAAB0+xgUAAAAA' 50 | 'E3YWCAU39ntOTBHgm48UMpFVs7DvUTFB/bbaq/vZeC4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 51 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAXp50gnYmYi6R4+I+QnSWi4H88VJKoIye' 52 | '5Uqoweh9l+UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 53 | 'AAAAAAAAAAAAAAAAZk/hVoPBHzoEnahGCoRrjFSg5bWEvDQW7clr70r1C8UAAAAAAAAAAAAAAAAAAAAA' 54 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAquLwEjnZE1h1qKp9' 55 | 'mpfiZ+fqJ1Rw3cLhX8nrx0VgKfEA599MkQAAAAB2sBAAAAAAAQAAAAAAAADCe1wFAAAAAADn30yRAAAA' 56 | 'AHawEAAAAAABAAAAAAAAAMJ7XAUAAAAANzRkq/Fg5DGQKTxjG3GJaaHanmhr9krIb1OThq282nAAAAAA' 57 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 58 | 'dkkNS7shgAYOa/R2+DNwiO1TuFMlP/ht6SdRU3x62T0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 59 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcTBWfR0upQv8cz+gPNHNt0GPnKBaObi9' 60 | 'LAKefxb5wtsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 61 | 'AAAAAAAAAAAAAAAA1FflJlHKD2zpeTMZ6awJhRePbFADBPK0iyu32DjydxMAAAAAAAAAAAAAAAAAAAAA' 62 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ4KPo2Gdpryu1okX' 63 | '3h18zpIX3scrrhIwY/97590vlj7AoFlfkAAAAMDXGTAAAAAAAQAAAAAAAACokB0FAAAAAMCgWV+QAAAA' 64 | 'wNcZMAAAAAABAAAAAAAAAKiQHQUAAAAAf7Bx65O+Q/eDp7AcK8Lw03LmNh99Mpfu5nsydLpn2MUAAAAA' 65 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 66 | 'GxE1Ex2rDAJyecg2P8ogqo9tPGKaDqZCf0+OHUfLz/EIHZd/hAAAALmp0SQAAAAAAQAAAAAAAADBxLcF' 67 | 'AAAAAAgdl3+EAAAAuanRJAAAAAABAAAAAAAAAMHEtwUAAAAAskWdp1YAT2k7uotwDoVkx9WSY7gay8uo' 68 | 'ykAsyQ6FrusAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 69 | 'AAAAAAAAAAAAAAAArq/eOgx1mU6Ixigj8cizk6fgfgXNTYnoHl9La1kz/CeAeCunjgAAAIDLeDEAAAAA' 70 | 'AQAAAAAAAABMtDcFAAAAAIB4K6eOAAAAgMt4MQAAAAABAAAAAAAAAEy0NwUAAAAA5v6vZs5/Kw4Sf3Gf' 71 | 'jLwRpg0NfHtESw5mRqECMnlwNLsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 72 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZFIJBu0qw/EJSssd8apY//Qv+Wl5hRi697NmiqraVk0AAAAA' 73 | 'AAAAAAAAAAAAAAAAAAAAAAAAAADhxFoFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOHEWgUAAAAA' 74 | 'jmNjneYqRRu77K9pGMTM31Dr7Hq6X3CAdoTOnMfxvKYAL4BmjwAAAIDR8AgAAAAAAQAAAAAAAAAch0YF' 75 | 'AAAAAAAvgGaPAAAAgNHwCAAAAAABAAAAAAAAAByHRgUAAAAAU8TtEyg6nKJ630rA85k7MfQylqJgcsND' 76 | '5LnQsZ7hpLurmVVTjwAAAADC6wsAAAAAAQAAAAAAAADMjEYFAAAAAKuZVVOPAAAAAMLrCwAAAAABAAAA' 77 | 'AAAAAMyMRgUAAAAAmNhsxmReIdbBhxZ8WjXWTYNiwcVJPqEt0UqCDDpH5XYTB71SjwAAAAAcTg4AAAAA' 78 | 'AQAAAAAAAADcjEYFAAAAABMHvVKPAAAAABxODgAAAAABAAAAAAAAANyMRgUAAAAATvf0GXTayRqQxVor' 79 | 'MaSVHlP7Byc52vz8WQF/b2TeHGsvK9hRjwAAAIDfFxAAAAAAAQAAAAAAAADcjEYFAAAAAC8r2FGPAAAA' 80 | 'gN8XEAAAAAABAAAAAAAAANyMRgUAAAAADcMZLVXjwPNi+/UPw5fcAP5p0QpTeiI4ES5mWhy7pWQAAAAA' 81 | 'AAAAAAAAAAAAAAAAAAAAAAAAAADcjEYFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANyMRgUAAAAA' 82 | 'YW9iNZroU1iQRzOa1Eib/co4u8Na3Sv0XL7nFtovGb8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 83 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASMRtW9UzBCNs7+OA+tYXWIAuPKUTI+uG' 84 | '9WgYebtjLGYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 85 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 86 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 87 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 88 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 89 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 90 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 91 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 92 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 93 | 'AAAAAAAAAAAAAAAA') 94 | 95 | 96 | def get_account_info_resp(key: Union[SolanaPublicKeyOrStr, Sequence[SolanaPublicKeyOrStr]]) -> Dict[str, Any]: 97 | b64_data = '' 98 | # mapping account 99 | if key == SolanaPublicKey(V2_FIRST_MAPPING_ACCOUNT_KEY): 100 | b64_data = MAPPING_ACCOUNT_B64_DATA 101 | # product account 102 | elif key == [SolanaPublicKey(BCH_PRODUCT_ACCOUNT_KEY)]: 103 | b64_data = PRODUCT_ACCOUNT_B64_DATA 104 | # price account 105 | elif key == SolanaPublicKey(BCH_PRICE_ACCOUNT_KEY): 106 | b64_data = PRICE_ACCOUNT_B64_DATA 107 | return { 108 | 'context': { 109 | 'slot': 96866599 110 | }, 111 | 'value': { 112 | 'data': [ 113 | b64_data, 114 | 'base64' 115 | ] 116 | } 117 | } 118 | 119 | 120 | def get_program_accounts_resp(key: SolanaPublicKeyOrStr, 121 | commitment: str = SolanaCommitment.CONFIRMED, 122 | encoding: str = "base64", 123 | with_context: bool = True) -> Dict[str, Any]: 124 | return { 125 | 'context': { 126 | 'slot': 96866599 127 | }, 128 | 'value': [ 129 | { 130 | 'account': { 131 | 'data': [ 132 | MAPPING_ACCOUNT_B64_DATA, 133 | 'base64' 134 | ], 135 | 'executable': False, 136 | 'lamports': 5143821440, 137 | 'owner': V2_PROGRAM_KEY, 138 | 'rentEpoch': 223 139 | }, 140 | 'pubkey': V2_FIRST_MAPPING_ACCOUNT_KEY 141 | }, 142 | { 143 | 'account': { 144 | 'data': [ 145 | PRODUCT_ACCOUNT_B64_DATA, 'base64' 146 | ], 147 | 'executable': False, 148 | 'lamports': 4351231, 149 | 'owner': V2_PROGRAM_KEY, 150 | 'rentEpoch': 224 151 | }, 152 | 'pubkey': BCH_PRODUCT_ACCOUNT_KEY 153 | }, 154 | { 155 | 'account': { 156 | 'data': [ 157 | PRICE_ACCOUNT_B64_DATA, 158 | 'base64' 159 | ], 160 | 'executable': False, 161 | 'lamports': 23942400, 162 | 'owner': V2_PROGRAM_KEY, 163 | 'rentEpoch': 224 164 | }, 165 | 'pubkey': BCH_PRICE_ACCOUNT_KEY 166 | } 167 | ] 168 | 169 | } 170 | 171 | 172 | @ pytest.fixture 173 | def pyth_client(solana_client: SolanaClient) -> PythClient: 174 | return PythClient( 175 | solana_client=solana_client, 176 | solana_endpoint="http://example.com", 177 | solana_ws_endpoint="ws://example.com", 178 | first_mapping_account_key=V2_FIRST_MAPPING_ACCOUNT_KEY, 179 | program_key=V2_PROGRAM_KEY 180 | ) 181 | 182 | 183 | @ pytest.fixture 184 | def pyth_client_no_program_key(solana_client: SolanaClient) -> PythClient: 185 | return PythClient( 186 | solana_client=solana_client, 187 | solana_endpoint="http://example.com", 188 | solana_ws_endpoint="ws://example.com", 189 | first_mapping_account_key=V2_FIRST_MAPPING_ACCOUNT_KEY 190 | ) 191 | 192 | 193 | @ pytest.fixture 194 | def watch_session(solana_client: SolanaClient) -> WatchSession: 195 | return WatchSession(solana_client) 196 | 197 | 198 | @ pytest.fixture 199 | def mapping_account(solana_client: SolanaClient) -> PythMappingAccount: 200 | mapping_account = PythMappingAccount( 201 | key=SolanaPublicKey(V2_FIRST_MAPPING_ACCOUNT_KEY), 202 | solana=solana_client 203 | ) 204 | return mapping_account 205 | 206 | 207 | @ pytest.fixture 208 | def mapping_account_entries() -> List[SolanaPublicKey]: 209 | return [ 210 | SolanaPublicKey(BCH_PRODUCT_ACCOUNT_KEY) 211 | ] 212 | 213 | 214 | @ pytest.fixture 215 | def product_account(solana_client: SolanaClient) -> PythProductAccount: 216 | product_account = PythProductAccount( 217 | key=SolanaPublicKey(BCH_PRODUCT_ACCOUNT_KEY), 218 | solana=solana_client, 219 | ) 220 | product_account.slot = 96866599 221 | product_account.attrs = { 222 | 'asset_type': 'Crypto', 223 | 'symbol': 'BCH/USD', 224 | 'quote_currency': 'USD', 225 | 'description': 'BCH/USD', 226 | 'generic_symbol': 'BCHUSD', 227 | 'base': 'BCH' 228 | } 229 | product_account.first_price_account_key = SolanaPublicKey( 230 | BCH_PRICE_ACCOUNT_KEY, 231 | ) 232 | return product_account 233 | 234 | 235 | @ pytest.fixture 236 | def product_account_bytes() -> bytes: 237 | return base64.b64decode(PRODUCT_ACCOUNT_B64_DATA)[ACCOUNT_HEADER_BYTES:] 238 | 239 | 240 | @ pytest.fixture 241 | def price_account(solana_client: SolanaClient) -> PythPriceAccount: 242 | return PythPriceAccount( 243 | key=SolanaPublicKey(BCH_PRICE_ACCOUNT_KEY), 244 | solana=solana_client, 245 | ) 246 | 247 | 248 | @ pytest.fixture 249 | def price_account_bytes() -> bytes: 250 | return base64.b64decode(PRICE_ACCOUNT_B64_DATA)[ACCOUNT_HEADER_BYTES:] 251 | 252 | 253 | @pytest.fixture() 254 | def mock_get_account_info(mocker: MockerFixture) -> AsyncMock: 255 | async_mock = AsyncMock(side_effect=get_account_info_resp) 256 | mocker.patch('pythclient.solana.SolanaClient.get_account_info', side_effect=async_mock) 257 | return async_mock 258 | 259 | 260 | @ pytest.fixture() 261 | def mock_get_program_accounts(mocker: MockerFixture) -> AsyncMock: 262 | async_mock = AsyncMock(side_effect=get_program_accounts_resp) 263 | mocker.patch('pythclient.solana.SolanaClient.get_program_accounts', side_effect=async_mock) 264 | return async_mock 265 | 266 | 267 | def test_products_property_not_loaded(pyth_client: PythClient) -> None: 268 | with pytest.raises(NotLoadedException): 269 | pyth_client.products 270 | 271 | 272 | @pytest.mark.asyncio 273 | async def test_get_products( 274 | pyth_client: PythClient, 275 | mock_get_account_info: AsyncMock, 276 | product_account: PythProductAccount 277 | ) -> None: 278 | products = await pyth_client.get_products() 279 | for i, _ in enumerate(products): 280 | assert products[i].key == product_account.key 281 | 282 | 283 | def test_get_ratelimit( 284 | pyth_client: PythClient, 285 | ) -> None: 286 | ratelimit = pyth_client.solana_ratelimit 287 | assert ratelimit._get_overall_interval() == 0 288 | assert ratelimit._get_method_interval() == 0 289 | assert ratelimit._get_connection_interval() == 0 290 | 291 | ratelimit.configure(overall_cps=1, method_cps=1, connection_cps=1) 292 | assert ratelimit._get_overall_interval() == 1.0 293 | assert ratelimit._get_method_interval() == 1.0 294 | assert ratelimit._get_connection_interval() == 1.0 295 | 296 | 297 | @pytest.mark.asyncio 298 | async def test_get_mapping_accounts( 299 | pyth_client: PythClient, 300 | mock_get_account_info: AsyncMock, 301 | mapping_account: PythMappingAccount 302 | ) -> None: 303 | mapping_accounts = await pyth_client.get_mapping_accounts() 304 | assert len(mapping_accounts) == 1 305 | assert mapping_accounts[0].key == mapping_account.key 306 | 307 | # call get_mapping_accounts again to test pyth_client returns self._mapping_accounts since 308 | # it has been populated due to previous call 309 | mapping_accounts = await pyth_client.get_mapping_accounts() 310 | assert len(mapping_accounts) == 1 311 | assert mapping_accounts[0].key == mapping_account.key 312 | 313 | 314 | @pytest.mark.asyncio 315 | async def test_get_all_accounts( 316 | pyth_client: PythClient, 317 | mock_get_account_info: AsyncMock, 318 | mapping_account: PythMappingAccount, 319 | mapping_account_entries: List[SolanaPublicKey], 320 | product_account: PythProductAccount, 321 | price_account: PythPriceAccount, 322 | product_account_bytes: bytes, 323 | price_account_bytes: bytes 324 | ) -> None: 325 | products = await pyth_client.get_products() 326 | for product in products: 327 | product.update_from(buffer=product_account_bytes, version=_VERSION_2) 328 | prices = await product.get_prices() 329 | for price in prices.values(): 330 | price.update_from(buffer=price_account_bytes, version=_VERSION_2) 331 | 332 | accounts = await pyth_client.get_all_accounts() 333 | assert accounts[0].key == mapping_account.key 334 | assert accounts[0].entries == mapping_account_entries 335 | assert dict(accounts[1]) == dict(product_account) 336 | assert accounts[2].key == price_account.key 337 | assert accounts[2].price_type == PythPriceType.PRICE 338 | assert accounts[2].exponent == -9 339 | assert accounts[2].num_components == 27 340 | assert len(accounts[2].price_components) == accounts[2].num_components 341 | assert accounts[2].last_slot == 96878111 342 | assert accounts[2].valid_slot == 96878110 343 | assert accounts[2].product_account_key == product_account.key 344 | assert accounts[2].next_price_account_key is None 345 | 346 | 347 | @ pytest.mark.asyncio 348 | async def test_refresh_all_prices( 349 | pyth_client: PythClient, 350 | mock_get_account_info: AsyncMock, 351 | mock_get_program_accounts: AsyncMock, 352 | product_account: PythProductAccount, 353 | price_account: PythPriceAccount 354 | ) -> None: 355 | await pyth_client.refresh_all_prices() 356 | for product in pyth_client.products: 357 | account = product.prices[PythPriceType.PRICE] 358 | assert account.key == price_account.key 359 | assert account.price_type == PythPriceType.PRICE 360 | assert account.exponent == -9 361 | assert account.num_components == 27 362 | assert len(account.price_components) == account.num_components 363 | assert account.last_slot == 96878111 364 | assert account.valid_slot == 96878110 365 | assert account.product_account_key == product_account.key 366 | assert account.next_price_account_key is None 367 | 368 | 369 | @ pytest.mark.asyncio 370 | async def test_refresh_all_prices_no_program_key( 371 | pyth_client_no_program_key: PythClient, 372 | mock_get_account_info: AsyncMock, 373 | ) -> None: 374 | await pyth_client_no_program_key.refresh_all_prices() 375 | for product in pyth_client_no_program_key.products: 376 | with pytest.raises(NotLoadedException): 377 | product.prices 378 | 379 | 380 | def test_create_watch_session( 381 | pyth_client: PythClient, 382 | watch_session: WatchSession 383 | ) -> None: 384 | ws = pyth_client.create_watch_session() 385 | assert isinstance(ws, WatchSession) 386 | -------------------------------------------------------------------------------- /tests/test_solana_account.py: -------------------------------------------------------------------------------- 1 | from _pytest.logging import LogCaptureFixture 2 | import pytest 3 | 4 | from pytest_mock import MockerFixture 5 | 6 | from mock import AsyncMock 7 | 8 | from pythclient.solana import SolanaAccount, SolanaPublicKey, SolanaClient 9 | 10 | 11 | @pytest.fixture 12 | def solana_pubkey() -> SolanaPublicKey: 13 | return SolanaPublicKey("AHtgzX45WTKfkPG53L6WYhGEXwQkN1BVknET3sVsLL8J") 14 | 15 | 16 | @pytest.fixture 17 | def solana_account(solana_pubkey: SolanaPublicKey, solana_client: SolanaClient) -> SolanaAccount: 18 | return SolanaAccount( 19 | key=solana_pubkey, 20 | solana=solana_client, 21 | ) 22 | 23 | 24 | @pytest.fixture() 25 | def mock_get_account_info(mocker: MockerFixture) -> AsyncMock: 26 | async_mock = AsyncMock() 27 | mocker.patch('pythclient.solana.SolanaClient.get_account_info', side_effect=async_mock) 28 | return async_mock 29 | 30 | 31 | def test_solana_account_update_with_rpc_response(solana_account: SolanaAccount) -> None: 32 | assert solana_account.slot is None 33 | assert solana_account.lamports is None 34 | 35 | slot = 106498726 36 | value = { 37 | "lamports": 1000000000 38 | } 39 | 40 | solana_account.update_with_rpc_response(slot=slot, value=value) 41 | 42 | assert solana_account.slot == slot 43 | assert solana_account.lamports == value["lamports"] 44 | 45 | 46 | @pytest.mark.asyncio 47 | async def test_solana_account_update_success(solana_account: SolanaAccount, 48 | mock_get_account_info: AsyncMock) -> None: 49 | 50 | mock_get_account_info.return_value = {'context': {'slot': 93752509}, 'value': {'lamports': 1000000001}} 51 | 52 | await solana_account.update() 53 | assert solana_account.slot == mock_get_account_info.return_value['context']['slot'] 54 | assert solana_account.lamports == mock_get_account_info.return_value['value']['lamports'] 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_solana_account_update_fail(solana_account: SolanaAccount, 59 | mock_get_account_info: AsyncMock, 60 | caplog: LogCaptureFixture, 61 | solana_pubkey: SolanaPublicKey) -> None: 62 | mock_get_account_info.return_value = {'value': {'lamports': 1000000001}} 63 | exc_message = f'error while updating account {solana_pubkey}' 64 | await solana_account.update() 65 | assert exc_message in caplog.text 66 | 67 | 68 | @pytest.mark.asyncio 69 | async def test_solana_account_update_null(solana_account: SolanaAccount, 70 | mock_get_account_info: AsyncMock, 71 | caplog: LogCaptureFixture, 72 | solana_pubkey: SolanaPublicKey,) -> None: 73 | mock_get_account_info.return_value = {'context': {'slot': 93752509}} 74 | exc_message = f'got null value from Solana getAccountInfo for {solana_pubkey}; ' \ 75 | + f'non-existent account? {mock_get_account_info.return_value}' 76 | await solana_account.update() 77 | assert exc_message in caplog.text 78 | 79 | 80 | def test_solana_account_str(solana_account: SolanaAccount) -> None: 81 | actual = str(solana_account) 82 | expected = f"SolanaAccount ({solana_account.key})" 83 | assert actual == expected 84 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pythclient.utils import get_key 4 | 5 | 6 | def test_utils_get_program_key() -> None: 7 | program_key = get_key("devnet", "program") 8 | assert program_key == "gSbePebfvPy7tRqimPoVecS2UsBvYv46ynrzWocc92s" 9 | 10 | 11 | def test_utils_get_mapping_key() -> None: 12 | mapping_key = get_key("devnet", "mapping") 13 | assert mapping_key == "BmA9Z6FjioHJPpjT39QazZyhDRUdZy2ezwx4GiDdE2u2" 14 | 15 | 16 | def test_utils_invalid_network() -> None: 17 | with pytest.raises(Exception) as e: 18 | get_key("testdevnet", "mapping") 19 | assert str(e.value) == "Unknown network or type: testdevnet, mapping" 20 | 21 | 22 | def test_utils_get_invalid_type() -> None: 23 | with pytest.raises(Exception) as e: 24 | get_key("devnet", "mappingprogram") 25 | assert str(e.value) == "Unknown network or type: devnet, mappingprogram" 26 | --------------------------------------------------------------------------------