├── .gitignore ├── Containerfile ├── LICENSE ├── Makefile ├── README.md ├── dev-requirements.txt ├── requirements.txt ├── scripts └── run_regtest.sh ├── setup.cfg ├── setup.py ├── src └── bitcoinrpc │ ├── __init__.py │ ├── __version__.py │ ├── _exceptions.py │ ├── _spec.py │ ├── _types.py │ └── bitcoin_rpc.py ├── tests ├── __init__.py ├── bitcoin-regtest.conf ├── conftest.py ├── test_client.py ├── test_client_config.py └── test_connection.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # IDEs 2 | .idea 3 | .vscode 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | .venv-* 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | 137 | # Docker 138 | !.dockerignore 139 | volumes/ -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/debian:11.7-slim 2 | 3 | ARG BTC_VERSION=v24.1 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y \ 7 | build-essential \ 8 | libtool \ 9 | autotools-dev \ 10 | automake \ 11 | pkg-config \ 12 | bsdmainutils \ 13 | python3 \ 14 | libevent-dev \ 15 | libboost-dev \ 16 | libsqlite3-dev \ 17 | libminiupnpc-dev \ 18 | libnatpmp-dev \ 19 | libzmq3-dev \ 20 | systemtap-sdt-dev \ 21 | git && \ 22 | rm -rf /var/lib/apt/lists/* 23 | 24 | WORKDIR /opt 25 | 26 | RUN git clone --depth 1 --branch ${BTC_VERSION} https://github.com/bitcoin/bitcoin && \ 27 | cd bitcoin && \ 28 | ./autogen.sh && \ 29 | ./configure \ 30 | --enable-debug \ 31 | --enable-usdt \ 32 | --with-sqlite=yes \ 33 | --without-bdb \ 34 | --with-miniupnpc \ 35 | --with-natpmp \ 36 | --with-utils \ 37 | --with-daemon \ 38 | --with-gui=no && \ 39 | make -j$(nproc) && \ 40 | make install && \ 41 | cd .. && \ 42 | rm -rf bitcoin 43 | 44 | RUN useradd --user-group --create-home rpc 45 | 46 | USER rpc 47 | 48 | WORKDIR /home/rpc 49 | 50 | # Default data directory when running the bitcoin daemon on regtest 51 | RUN mkdir -p .bitcoin/regtest 52 | 53 | ENTRYPOINT ["/usr/local/bin/bitcoind", "-regtest", "-server"] 54 | 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Libor Martinek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | RM := rm -rf 2 | 3 | DIRS_TO_RM = .mypy_cache .pytest_cache .coverage .eggs build dist */*.egg-info 4 | DIRS_TO_CLEAN = src tests 5 | 6 | all: clean 7 | 8 | .PHONY: clean 9 | clean: 10 | @echo Cleaning crew has arrived! 11 | $(RM) $(DIRS_TO_RM) 12 | find $(DIRS_TO_CLEAN) -name '*.pyc' -type f -exec $(RM) '{}' + 13 | find $(DIRS_TO_CLEAN) -name '*.so' -type f -exec $(RM) '{}' + 14 | find $(DIRS_TO_CLEAN) -name '*.c' -type f -exec $(RM) '{}' + 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bitcoin-python-async-rpc 2 | Lightweight Bitcoin async JSON-RPC Python client. 3 | 4 | Serves as a tiny layer between an application and a Bitcoin daemon, its primary usage 5 | is querying the current state of Bitcoin blockchain, network stats, transactions... 6 | 7 | If you want complete Bitcoin experience in Python, consult 8 | [python-bitcoinlib](https://github.com/petertodd/python-bitcoinlib). 9 | 10 | ## Installation 11 | ```bash 12 | $ pip install bitcoinrpc 13 | ``` 14 | 15 | ## Supported methods 16 | Here is a list of supported methods, divided by their categories. Should you need 17 | method not implemented, wrap the call in `BitcoinRPC.acall(, ...)` coroutine. 18 | 19 | ### Blockchain 20 | 21 | | Method | Supported? | 22 | |------------|:----------------:| 23 | | `getbestblockhash` | ✔ | 24 | | `getblock` | ✔ | 25 | | `getblockchaininfo` | ✔ | 26 | | `getblockcount` | ✔ | 27 | | `getblockhash` | ✔ | 28 | | `getblockheader` | ✔ | 29 | | `getblockstats` | ✔ | 30 | | `getchaintips` | ✔ | 31 | | `getdifficulty` | ✔ | 32 | | `getmempoolinfo` | ✔ | 33 | | `getnetworkhashps` | ✔ | 34 | 35 | ### Mining 36 | 37 | | Method | Supported? | 38 | |------------|:----------------:| 39 | | `getmininginfo` | ✔ | 40 | 41 | ### Network 42 | 43 | | Method | Supported? | 44 | |------------|:----------------:| 45 | | `getconnectioncount` | ✔ | 46 | | `getnetworkinfo` | ✔ | 47 | 48 | ### Raw transactions 49 | 50 | | Method | Supported? | 51 | |------------|:----------------:| 52 | | `analyzepsbt` | ✔ | 53 | | `combinepsbt` | ✔ | 54 | | `decodepsbt` | ✔ | 55 | | `finalizepsbt` | ✔ | 56 | | `getrawtransaction` | ✔ | 57 | | `joinpsbts` | ✔ | 58 | | `utxoupdatepsbt` | ✔ | 59 | 60 | ### Wallet 61 | 62 | | Method | Supported? | 63 | |------------|:----------------:| 64 | | `walletprocesspsbt` | ✔ | 65 | 66 | ## Usage 67 | Minimal illustration (assuming Python 3.8+, where you can run `async` code in console) 68 | 69 | ``` 70 | $ python -m asyncio 71 | >>> import asyncio 72 | >>> 73 | >>> from bitcoinrpc import BitcoinRPC 74 | >>> rpc = BitcoinRPC.from_config("http://localhost:18443", ("rpc_user", "rpc_passwd")) 75 | >>> await rpc.getconnectioncount() 76 | 10 77 | >>> await rpc.aclose() # Clean-up resource 78 | ``` 79 | 80 | You can also use the `BitcoinRPC` as an asynchronous context manager, which does 81 | all the resource clean-up automatically, as the following example shows: 82 | 83 | ```python 84 | $ cat btc_rpc_minimal.py 85 | import asyncio 86 | 87 | from bitcoinrpc import BitcoinRPC 88 | 89 | 90 | async def main(): 91 | async with BitcoinRPC.from_config("http://localhost:18443", ("rpc_user", "rpc_password")) as rpc: 92 | print(await rpc.getconnectioncount()) 93 | 94 | 95 | if __name__ == "__main__": 96 | asyncio.run(main()) 97 | ``` 98 | 99 | Running this script yields: 100 | ``` 101 | $ python btc_rpc_minimal.py 102 | 10 103 | ``` 104 | 105 | If you want customize the underlying `httpx.AsyncClient`, you can instantiate the `BitcoinRPC` with one. 106 | Consider the following script, where the client is configured to log every HTTP request before it is sent 107 | out over the wire: 108 | 109 | ```python 110 | $ cat btc_custom_client.py 111 | import asyncio 112 | 113 | import httpx 114 | 115 | from bitcoinrpc import BitcoinRPC 116 | 117 | 118 | async def log_request(request: httpx.Request) -> None: 119 | print(request.content) 120 | 121 | 122 | async def main() -> None: 123 | client = httpx.AsyncClient(auth=("rpc_user", "rpc_password"), event_hooks={"request": [log_request]}) 124 | async with BitcoinRPC(url="http://localhost:18443", client=client) as rpc: 125 | print(await rpc.getconnectioncount()) 126 | 127 | 128 | if __name__ == "__main__": 129 | asyncio.run(main()) 130 | ``` 131 | 132 | Running this script yields: 133 | 134 | ``` 135 | $ python btc_custom_client.py 136 | b'{"jsonrpc":"2.0","id":1,"method":"getconnectioncount","params":[]}' 137 | 0 138 | ``` 139 | 140 | ## Testing 141 | 142 | A `Containerfile` is provided as a means to build an OCI image of a Bitcoin `regtest` node. 143 | Build the image (`podman` is used, but `docker` should be fine too): 144 | 145 | ``` 146 | $ podman build \ 147 | -f Containerfile \ 148 | --build-arg BTC_VERSION=v24.1 \ 149 | -t bitcoin-regtest:v24.1 \ 150 | -t bitcoin-regtest:latest \ 151 | . 152 | ``` 153 | 154 | and run it afterwards: 155 | 156 | ``` 157 | $ podman run \ 158 | --rm \ 159 | -it \ 160 | --mount=type=bind,src=./tests/bitcoin-regtest.conf,target=/home/rpc/.bitcoin/bitcoin.conf \ 161 | -p 127.0.0.1:18443:18443 \ 162 | --name bitcoin-regtest \ 163 | localhost/bitcoin-regtest:v24.1 164 | ``` 165 | 166 | which will expose the Bitcoin `regtest` node on port 18443, accesible from localhost only, with RPC user/password `rpc_user/rpc_password`. 167 | 168 | After you are done testing, stop the container via: 169 | 170 | ``` 171 | $ podman stop bitcoin-regtest 172 | ``` 173 | 174 | --- 175 | 176 | If you want to test against a different version of Bitcoin node, pass a different [tag](https://github.com/bitcoin/bitcoin/tags) in the build stage: 177 | 178 | ``` 179 | $ podman build \ 180 | -f Containerfile \ 181 | --build-arg BTC_VERSION=v25.0 \ 182 | -t bitcoin-regtest:v25.0 \ 183 | -t bitcoin-regtest:latest \ 184 | . 185 | ``` 186 | 187 | --- 188 | 189 | Different settings of the Bitcoin node may be passed via mounting your custom configuration file, or optionally as "arguments" to `podman run`: 190 | 191 | 192 | ``` 193 | $ podman run \ 194 | --rm \ 195 | -it \ 196 | --mount=type=bind,src=,target=/home/rpc/.bitcoin/bitcoin.conf \ 197 | -p 127.0.0.1:18443:18443 \ 198 | --name bitcoin-regtest \ 199 | localhost/bitcoin-regtest:v24.1 ... 200 | ``` 201 | 202 | --- 203 | 204 | Please, keep in mind that Bitcoin node compiled in the image is intended for testing & debugging purposes only! It may serve you as an inspiration for building 205 | your own, production-ready Bitcoin node, but its intended usage is testing! 206 | 207 | --- 208 | 209 | For testing this library, install `tox` (preferably, in a fresh virtual environment). 210 | 211 | Afterwards, coding-style is enforced by running: 212 | 213 | ``` 214 | (your-venv-with-tox) $ tox run -e linters 215 | ``` 216 | 217 | and tests corresponding are run (this example uses Python3.11) 218 | 219 | ``` 220 | (your-venv-with-tox) $ tox run -e py311 221 | ``` 222 | 223 | If you do not want to run tests marked as `"integration"`, which denote those requiring the bitcoin regtest node to run, you can filter them out by: 224 | 225 | ``` 226 | (your-venv-with-tox) $ tox run -e py311 -- -m 'not integration' 227 | ``` 228 | 229 | 230 | ## Changelog 231 | 232 | - **2024/02/12 - 0.7.0**: More robust handling of JSON-RPC 2.0 specification (thanks https://github.com/joxerx !) 233 | * **Breaking change**: change the handling of responses with non-2xx status codes in 'BitcoinRPC.acall'. 234 | Previously, said errors would be raised directly via the `httpx.Response.raise_for_status` method. 235 | Now, `httpx.Response.raise_for_status` is used only when the server 236 | returns an empty response, which may happen due to for example bad 237 | authentication. In all other cases, defer the decision whether RPC 238 | call was a success or a failure to the inspection of return JSON. 239 | - **2023/06/04 - 0.6.1**: Add RPC methods, mainly concerned with PSBTs 240 | - **2023/06/01 - 0.6.0**: 241 | * `BitcoinRPC` is now instantiated with a `httpx.AsyncClient` directly and an optional `counter` argument, which is a callable that may be used for distinguishing 242 | the JSON-RPC requests. Old-style instantiation, with `url` and optional user/password tuple, is kept within `BitcoinRPC.from_config` method. 243 | 244 | - **2021/12/28 - 0.5.0** change the signature of `BitcoinRPC` from `host, port, ...` to `url, ...`, delegating the creation of the node url to the caller. 245 | 246 | ## License 247 | MIT 248 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | tox -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | orjson>=3 2 | httpx<1 3 | typing_extensions>=4.0.0 4 | -------------------------------------------------------------------------------- /scripts/run_regtest.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker run \ 4 | --rm \ 5 | --network host \ 6 | --env RPCUSER="rpc_user" \ 7 | --env RPCPASSWORD="rpc_password" \ 8 | --mount type=volume,src=bitcoind-data,target=/bitcoin \ 9 | --name=bitcoind-node \ 10 | bitcoindevelopernetwork/bitcoind-regtest 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | ignore = D203, W503, E203, E231 4 | exclude = 5 | .tox, 6 | .git, 7 | __pycache__, 8 | build, 9 | dist, 10 | *.pyc, 11 | *.egg-info, 12 | .cache, 13 | .eggs 14 | 15 | [mypy] 16 | disallow_untyped_calls = True 17 | disallow_untyped_defs = True 18 | ignore_missing_imports = True 19 | no_implicit_optional = True 20 | 21 | [tool:pytest] 22 | addopts = --strict-markers -vv 23 | markers = 24 | integration: requires a running Bitcoin regtest node 25 | testpaths = tests 26 | 27 | [tool:isort] 28 | profile = black 29 | known_first_party = bitcoinrpc, tests 30 | known_third_party = httpx, orjson, typing_extensions, pytest 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import List 4 | 5 | from setuptools import setup 6 | 7 | 8 | def get_version(package: str) -> str: 9 | """ 10 | Extract package version, located in the `src/package/__version__.py`. 11 | """ 12 | version = Path("src", package, "__version__.py").read_text() 13 | pattern = r"__version__ = ['\"]([^'\"]+)['\"]" 14 | return re.match(pattern, version).group(1) # type: ignore 15 | 16 | 17 | def get_requirements(req_file: str) -> List[str]: 18 | """ 19 | Extract requirements from provided file. 20 | """ 21 | req_path = Path(req_file) 22 | requirements = req_path.read_text().split("\n") if req_path.exists() else [] 23 | return requirements 24 | 25 | 26 | def get_long_description(readme_file: str) -> str: 27 | """ 28 | Extract README from provided file. 29 | """ 30 | readme_path = Path(readme_file) 31 | long_description = ( 32 | readme_path.read_text(encoding="utf-8") if readme_path.exists() else "" 33 | ) 34 | return long_description 35 | 36 | 37 | setup( 38 | name="bitcoinrpc", 39 | python_requires=">=3.7", 40 | version=get_version("bitcoinrpc"), 41 | description="Lightweight Bitcoin JSON-RPC Python asynchronous client", 42 | long_description=get_long_description("README.md"), 43 | long_description_content_type="text/markdown", 44 | keywords="bitcoin async json-rpc", 45 | license="MIT", 46 | classifiers=[ 47 | "Development Status :: 4 - Beta", 48 | "Intended Audience :: Developers", 49 | "Programming Language :: Python", 50 | "Programming Language :: Python :: 3.7", 51 | "Programming Language :: Python :: 3.8", 52 | "Programming Language :: Python :: 3.9", 53 | "Programming Language :: Python :: 3.10", 54 | "Programming Language :: Python :: 3.11", 55 | "Programming Language :: Python :: 3 :: Only", 56 | "Topic :: Software Development :: Libraries :: Python Modules", 57 | ], 58 | url="https://github.com/bibajz/bitcoin-python-async-rpc", 59 | author="Libor Martinek", 60 | author_email="libasmartinek@protonmail.com", 61 | package_dir={"": "src"}, 62 | packages=["bitcoinrpc"], 63 | install_requires=get_requirements("requirements.txt"), 64 | ) 65 | -------------------------------------------------------------------------------- /src/bitcoinrpc/__init__.py: -------------------------------------------------------------------------------- 1 | from bitcoinrpc.__version__ import __version__ 2 | from bitcoinrpc._exceptions import RPCError 3 | from bitcoinrpc.bitcoin_rpc import BitcoinRPC 4 | 5 | __all__ = ( 6 | "__version__", 7 | "BitcoinRPC", 8 | "RPCError", 9 | ) 10 | -------------------------------------------------------------------------------- /src/bitcoinrpc/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.7.0" 2 | -------------------------------------------------------------------------------- /src/bitcoinrpc/_exceptions.py: -------------------------------------------------------------------------------- 1 | from bitcoinrpc._spec import Error, RequestId 2 | 3 | 4 | class RPCError(Exception): 5 | """ 6 | Enrich the `Error` - https://www.jsonrpc.org/specification#error_object 7 | with the `id` of the request that caused the error. 8 | """ 9 | 10 | def __init__(self, id: RequestId, error: Error) -> None: 11 | super().__init__(error["message"]) 12 | self.id = id 13 | self.error = error 14 | -------------------------------------------------------------------------------- /src/bitcoinrpc/_spec.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module containing definitions of objects (described as Python's Typed dictionaries) 3 | taken from JSON-RPC 2.0 specification. 4 | 5 | https://www.jsonrpc.org/specification 6 | """ 7 | 8 | from typing import List, Optional, Union 9 | 10 | from typing_extensions import NotRequired, TypedDict 11 | 12 | from bitcoinrpc._types import JSONType 13 | 14 | RequestId = Union[int, str, None] 15 | """ 16 | https://www.jsonrpc.org/specification#request_object 17 | 18 | According to the specification, must be String, Integer or Null. 19 | """ 20 | 21 | 22 | class Request(TypedDict): 23 | """ 24 | https://www.jsonrpc.org/specification#request_object 25 | 26 | `jsonrpc` member is always "2.0" 27 | """ 28 | 29 | jsonrpc: str 30 | id: RequestId 31 | method: str 32 | params: List[JSONType] 33 | 34 | 35 | class Error(TypedDict): 36 | """ 37 | https://www.jsonrpc.org/specification#error_object 38 | 39 | `data` member may not be present at all. 40 | """ 41 | 42 | code: int 43 | message: str 44 | data: NotRequired[JSONType] 45 | 46 | 47 | class _ResponseCommon(TypedDict): 48 | jsonrpc: str 49 | id: RequestId 50 | 51 | 52 | class ResponseSuccess(_ResponseCommon): 53 | """ 54 | https://www.jsonrpc.org/specification#response_object 55 | 56 | - `jsonrpc` member is always "2.0" 57 | - `result` member is always present and is a valid JSON 58 | - `error` should not be present by the specification, but at least Bitcoin Node 59 | as of version v26.0 always includes `error` member with value `NULL` on the 60 | success path. 61 | """ 62 | 63 | result: JSONType 64 | error: NotRequired[Optional[Error]] 65 | 66 | 67 | class ResponseError(_ResponseCommon): 68 | """ 69 | https://www.jsonrpc.org/specification#response_object 70 | 71 | - `jsonrpc` member is always "2.0" 72 | - `result` should not be present by the specification, but at least Bitcoin Node 73 | as of version v26.0 always includes `result` member with value `NULL` on the 74 | error path. 75 | - `error` member is always present and is a JSON of `Error` shape defined above. 76 | """ 77 | 78 | result: NotRequired[Optional[JSONType]] 79 | error: Error 80 | 81 | 82 | Response = Union[ResponseSuccess, ResponseError] 83 | -------------------------------------------------------------------------------- /src/bitcoinrpc/_types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, TypeVar, Union 2 | 3 | from typing_extensions import Literal, NotRequired, TypedDict 4 | 5 | JSONType = Union[None, bool, int, float, str, List["JSONType"], Dict[str, "JSONType"]] 6 | 7 | # Type aliases for 1-to-1 match of RPC method and its return type 8 | ConnectionCount = int 9 | Difficulty = float 10 | BestBlockHash = str 11 | BlockHash = str 12 | BlockCount = int 13 | NetworkHashps = float 14 | CombinePSBT = str 15 | JoinPSBTs = str 16 | UtxoUpdatePSBT = str 17 | 18 | 19 | class MempoolInfo(TypedDict): 20 | loaded: bool 21 | size: int 22 | bytes: int 23 | usage: int 24 | maxmempoool: int 25 | mempoolminfee: float 26 | mempoolmaxfee: float 27 | 28 | 29 | class _NetworkInfoNetworks(TypedDict): 30 | name: str 31 | limited: bool 32 | reachable: bool 33 | proxy: str 34 | proxy_randomize_credentials: bool 35 | 36 | 37 | class _NetworkInfoAddresses(TypedDict): 38 | address: str 39 | port: int 40 | score: float 41 | 42 | 43 | class NetworkInfo(TypedDict): 44 | version: int 45 | subversion: str 46 | protocolversion: str 47 | localservices: str 48 | localservicenames: List[str] 49 | localrelay: bool 50 | timeoffset: int 51 | networkactive: bool 52 | networks: List["_NetworkInfoNetworks"] 53 | relayfee: float 54 | incrementalfee: float 55 | localaddresses: List["_NetworkInfoAddresses"] 56 | warnings: str 57 | 58 | 59 | class BlockchainInfo(TypedDict): 60 | # TODO: Shares common items with MiningInfo 61 | chain: Literal["main", "test", "regtest"] 62 | blocks: int 63 | headers: int 64 | bestblockhash: str 65 | difficulty: float 66 | mediantime: int 67 | verificationprogress: float 68 | initialblockdownload: bool 69 | chainwork: str 70 | size_on_disk: int 71 | pruned: bool 72 | softforks: Dict[str, Any] # dictionary of the format {"bip-xxx": {...}} 73 | warnings: str 74 | 75 | 76 | class _ChainTipsDetail(TypedDict): 77 | height: int 78 | hash: str 79 | branchlen: int 80 | status: Literal["active", "valid-fork", "valid-headers", "headers-only", "invalid"] 81 | 82 | 83 | ChainTips = List["_ChainTipsDetail"] 84 | 85 | 86 | class _BlockHeader(TypedDict): 87 | """ 88 | Returned when verbose is set to `True`. Otherwise, `str` is returned 89 | """ 90 | 91 | hash: str 92 | confirmations: int 93 | height: int 94 | version: int 95 | versionHex: str 96 | merkleroot: str 97 | time: int 98 | mediantime: int 99 | nonce: int 100 | bits: str 101 | difficulty: float 102 | chainwork: str 103 | nTx: int 104 | previousblockhash: str 105 | nextblockhash: str 106 | 107 | 108 | BlockHeader = Union[str, "_BlockHeader"] 109 | 110 | 111 | class BlockStats(TypedDict): 112 | """ 113 | Returned dictionary will contain subset of the following, depending on filtering. 114 | """ 115 | 116 | avgfee: int 117 | avgfeerate: int 118 | avgtxsize: int 119 | blockhash: str 120 | feerate_percentiles: List[int] 121 | heigth: int 122 | ins: int 123 | maxfee: int 124 | maxfeerate: int 125 | maxtxsize: int 126 | medianfee: int 127 | mediantime: int 128 | mediantxsize: int 129 | minfee: int 130 | minfeerate: int 131 | mintxsize: int 132 | outs: int 133 | subsidy: int # Block reward in Satoshis 134 | swtotal_size: int 135 | swtotal_weight: int 136 | swtxs: int 137 | time: int 138 | total_out: int 139 | total_size: int 140 | total_weight: int 141 | totalfee: int 142 | txs: int 143 | utxo_increase: int 144 | utxo_size_inc: int 145 | 146 | 147 | class Block(_BlockHeader): 148 | strippedsize: int 149 | tx: List["RawTransaction"] 150 | 151 | 152 | class _RawTransaction(TypedDict): 153 | """Returned when verbose is set to `True`. Otherwise, `str` is returned""" 154 | 155 | txid: str 156 | hash: str 157 | version: int 158 | size: int 159 | vsize: int 160 | weight: int 161 | locktime: int 162 | vin: List[Dict[str, Any]] # TODO: Complete 163 | vout: List[Dict[str, Any]] # TODO: Complete 164 | hex: str 165 | blockhash: str 166 | confirmations: str 167 | time: int 168 | blocktime: int 169 | 170 | 171 | RawTransaction = Union[str, "_RawTransaction"] 172 | 173 | 174 | class MiningInfo(TypedDict): 175 | # TODO: Shares common items with BlockchainInfo 176 | blocks: int 177 | difficulty: float 178 | networkhashps: float 179 | pooledtx: int 180 | chain: Literal["main", "test", "regtest"] 181 | warnings: str 182 | 183 | 184 | class AnalyzePSBT(TypedDict): 185 | inputs: List[Dict[str, Any]] # TODO: Complete 186 | next: str 187 | 188 | fee: NotRequired[float] 189 | estimated_vsize: NotRequired[float] 190 | estimated_feerate: NotRequired[float] 191 | error: NotRequired[str] 192 | 193 | 194 | class DecodePSBT(TypedDict): 195 | tx: Dict[str, Any] 196 | unknown: Dict[str, Any] 197 | inputs: List[Dict[str, Any]] 198 | outputs: List[Dict[str, Any]] 199 | 200 | fee: NotRequired[float] 201 | 202 | 203 | class FinalizePSBT(TypedDict): 204 | psbt: str 205 | hex: str 206 | complete: bool 207 | 208 | 209 | class WalletProcessPSBT(TypedDict): 210 | psbt: str 211 | complete: bool 212 | 213 | 214 | BitcoinRPCResponse = TypeVar( 215 | "BitcoinRPCResponse", 216 | ConnectionCount, 217 | Difficulty, 218 | BestBlockHash, 219 | BlockHash, 220 | BlockCount, 221 | NetworkHashps, 222 | MempoolInfo, 223 | MiningInfo, 224 | BlockchainInfo, 225 | NetworkInfo, 226 | ChainTips, 227 | BlockHeader, 228 | BlockStats, 229 | Block, 230 | RawTransaction, 231 | CombinePSBT, 232 | JoinPSBTs, 233 | UtxoUpdatePSBT, 234 | AnalyzePSBT, 235 | DecodePSBT, 236 | FinalizePSBT, 237 | WalletProcessPSBT, 238 | ) 239 | -------------------------------------------------------------------------------- /src/bitcoinrpc/bitcoin_rpc.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from types import TracebackType 3 | from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union, cast 4 | 5 | import httpx 6 | import orjson 7 | from typing_extensions import Literal, Self 8 | 9 | from bitcoinrpc._exceptions import RPCError 10 | from bitcoinrpc._spec import RequestId, Response, ResponseError, ResponseSuccess 11 | from bitcoinrpc._types import ( 12 | AnalyzePSBT, 13 | BestBlockHash, 14 | BitcoinRPCResponse, 15 | Block, 16 | BlockchainInfo, 17 | BlockCount, 18 | BlockHash, 19 | BlockHeader, 20 | BlockStats, 21 | ChainTips, 22 | CombinePSBT, 23 | ConnectionCount, 24 | DecodePSBT, 25 | Difficulty, 26 | FinalizePSBT, 27 | JoinPSBTs, 28 | JSONType, 29 | MempoolInfo, 30 | MiningInfo, 31 | NetworkHashps, 32 | NetworkInfo, 33 | RawTransaction, 34 | UtxoUpdatePSBT, 35 | WalletProcessPSBT, 36 | ) 37 | 38 | 39 | # Neat trick found in asyncio library for task enumeration 40 | # https://github.com/python/cpython/blob/v3.12.2/Lib/asyncio/tasks.py#L33 41 | # 42 | # However, it's necessary to instantiate a new counter object for each instantiation of `BitcoinRPC` 43 | # object, the snippet from standard library is a global counter. 44 | def _next_request_id_factory() -> Callable[[], RequestId]: 45 | return itertools.count(1).__next__ 46 | 47 | 48 | class BitcoinRPC: 49 | __slots__ = ("_url", "_client", "_counter") 50 | """ 51 | Class representing a JSON-RPC client of a Bitcoin node. 52 | 53 | :param url: URL of the Bitcoin node. 54 | :param client: Underlying `httpx.AsyncClient`, which handles the requests issued. 55 | :param counter: Optional callable that serves as a generator for the "id" field within JSON-RPC requests. 56 | 57 | For list of all available commands, visit: 58 | https://developer.bitcoin.org/reference/rpc/index.html 59 | """ 60 | 61 | def __init__( 62 | self, 63 | url: str, 64 | client: httpx.AsyncClient, 65 | counter: Optional[Callable[[], RequestId]] = None, 66 | ) -> None: 67 | self._url = url 68 | self._client = client 69 | 70 | if counter is None: 71 | self._counter = _next_request_id_factory() 72 | else: 73 | self._counter = counter 74 | 75 | async def __aenter__(self) -> "BitcoinRPC": 76 | return self 77 | 78 | async def __aexit__( 79 | self, 80 | exc_type: Type[BaseException], 81 | exc_val: BaseException, 82 | exc_tb: TracebackType, 83 | ) -> None: 84 | await self.aclose() 85 | 86 | @classmethod 87 | def from_config( 88 | cls, 89 | url: str, 90 | auth: Optional[Tuple[str, str]], 91 | **options: Any, 92 | ) -> Self: 93 | """ 94 | Instantiate the `BitcoinRPC` client while also configuring the underlying `httpx.AsyncClient`. Additional 95 | options are passed directly as kwargs to `httpx.AsyncClient`, so it's your responsibility to conform to its 96 | interface. 97 | """ 98 | 99 | options = dict(options) 100 | headers = { 101 | "content-type": "application/json", 102 | **dict(options.pop("headers", {})), 103 | } 104 | return cls(url, client=httpx.AsyncClient(auth=auth, headers=headers, **options)) 105 | 106 | @property 107 | def url(self) -> str: 108 | return self._url 109 | 110 | @property 111 | def client(self) -> httpx.AsyncClient: 112 | return self._client 113 | 114 | async def aclose(self) -> None: 115 | await self.client.aclose() 116 | 117 | async def acall( 118 | self, 119 | method: str, 120 | params: List[JSONType], 121 | **kwargs: Any, 122 | ) -> BitcoinRPCResponse: 123 | """ 124 | Pass keyword arguments to directly modify the constructed request - 125 | see `httpx.Request`. 126 | """ 127 | response = await self.client.post( 128 | url=self.url, 129 | content=orjson.dumps( 130 | { 131 | "jsonrpc": "2.0", 132 | "id": self._counter(), 133 | "method": method, 134 | "params": params, 135 | } 136 | ), 137 | **kwargs, 138 | ) 139 | 140 | # Response may be empty, due to for example failed authentication. 141 | if response.content == b"": 142 | response.raise_for_status() 143 | 144 | content: Response = orjson.loads(response.content) 145 | # Based on the presence of "error" key in the deserialized dictionary 146 | # and its value, either: 147 | # - throw an `RPCError` exception containing the `id` of the request and 148 | # the data under "error" key, or 149 | # - if "error" key is not present or is `None`, meaning we are on the 150 | # success path, return the data under "result" key. 151 | # 152 | # Apologies for such ugly code, the `type: ignore` parts and `cast` are 153 | # present because `mypy` is unable to distinguish the constituents of the 154 | # `._spec.Response` union type based on the `content.get("error")`... 155 | if content.get("error") is not None: 156 | _error: ResponseError = content # type: ignore 157 | raise RPCError(id=content["id"], error=_error["error"]) 158 | else: 159 | _success: ResponseSuccess = content # type: ignore 160 | return cast(BitcoinRPCResponse, _success["result"]) 161 | 162 | async def getmempoolinfo(self) -> MempoolInfo: 163 | """https://developer.bitcoin.org/reference/rpc/getmempoolinfo.html""" 164 | return await self.acall("getmempoolinfo", []) 165 | 166 | async def getmininginfo(self) -> MiningInfo: 167 | """https://developer.bitcoin.org/reference/rpc/getmininginfo.html""" 168 | return await self.acall("getmininginfo", []) 169 | 170 | async def getnetworkinfo(self) -> NetworkInfo: 171 | """https://developer.bitcoin.org/reference/rpc/getnetworkinfo.html""" 172 | return await self.acall("getnetworkinfo", []) 173 | 174 | async def getblockchaininfo(self) -> BlockchainInfo: 175 | """https://developer.bitcoin.org/reference/rpc/getblockchaininfo.html""" 176 | return await self.acall("getblockchaininfo", []) 177 | 178 | async def getconnectioncount(self) -> ConnectionCount: 179 | """https://developer.bitcoin.org/reference/rpc/getconnectioncount.html""" 180 | return await self.acall("getconnectioncount", []) 181 | 182 | async def getchaintips(self) -> ChainTips: 183 | """https://developer.bitcoin.org/reference/rpc/getchaintips.html""" 184 | return await self.acall("getchaintips", []) 185 | 186 | async def getdifficulty(self) -> Difficulty: 187 | """https://developer.bitcoin.org/reference/rpc/getdifficulty.html""" 188 | return await self.acall("getdifficulty", []) 189 | 190 | async def getbestblockhash(self) -> BestBlockHash: 191 | """https://developer.bitcoin.org/reference/rpc/getbestblockhash.html""" 192 | return await self.acall("getbestblockhash", []) 193 | 194 | async def getblockhash(self, height: int) -> BlockHash: 195 | """https://developer.bitcoin.org/reference/rpc/getblockhash.html""" 196 | return await self.acall("getblockhash", [height]) 197 | 198 | async def getblockcount(self) -> BlockCount: 199 | """https://developer.bitcoin.org/reference/rpc/getblockcount.html""" 200 | return await self.acall("getblockcount", []) 201 | 202 | async def getblockheader( 203 | self, block_hash: str, verbose: bool = True 204 | ) -> BlockHeader: 205 | """https://developer.bitcoin.org/reference/rpc/getblockheader.html""" 206 | return await self.acall("getblockheader", [block_hash, verbose]) 207 | 208 | async def getblockstats( 209 | self, 210 | hash_or_height: Union[int, str], 211 | *keys: str, 212 | timeout: Optional[float] = 5.0, 213 | ) -> BlockStats: 214 | """ 215 | https://developer.bitcoin.org/reference/rpc/getblockstats.html 216 | 217 | Enter `keys` as positional arguments to return only the provided `keys` 218 | in the response. 219 | """ 220 | return await self.acall( 221 | "getblockstats", 222 | [hash_or_height, list(keys) or None], 223 | timeout=httpx.Timeout(timeout), 224 | ) 225 | 226 | async def getblock( 227 | self, 228 | block_hash: str, 229 | verbosity: Literal[0, 1, 2] = 1, 230 | timeout: Optional[float] = 5.0, 231 | ) -> Block: 232 | """ 233 | https://developer.bitcoin.org/reference/rpc/getblock.html 234 | 235 | :param verbosity: 0 for hex-encoded block data, 1 for block data with 236 | transactions list, 2 for block data with each transaction. 237 | """ 238 | return await self.acall( 239 | "getblock", [block_hash, verbosity], timeout=httpx.Timeout(timeout) 240 | ) 241 | 242 | async def getrawtransaction( 243 | self, 244 | txid: str, 245 | verbose: bool = True, 246 | block_hash: Optional[str] = None, 247 | timeout: Optional[float] = 5.0, 248 | ) -> RawTransaction: 249 | """ 250 | https://developer.bitcoin.org/reference/rpc/getrawtransactiono.html 251 | 252 | :param txid: If transaction is not in mempool, block_hash must also be provided. 253 | :param verbose: True for JSON, False for hex-encoded string 254 | :param block_hash: see ^txid 255 | :param timeout: If doing a lot of processing, no timeout may come in handy 256 | """ 257 | return await self.acall( 258 | "getrawtransaction", 259 | [txid, verbose, block_hash], 260 | timeout=httpx.Timeout(timeout), 261 | ) 262 | 263 | async def getnetworkhashps( 264 | self, 265 | nblocks: int = -1, 266 | height: Optional[int] = None, 267 | timeout: Optional[float] = 5.0, 268 | ) -> NetworkHashps: 269 | """ 270 | https://developer.bitcoin.org/reference/rpc/getnetworkhashps.html 271 | 272 | :param nblocks: -1 for estimated hash power since last difficulty change, 273 | otherwise as an average over last provided number of blocks 274 | :param height: If not provided, get estimated hash power for the latest block 275 | :param timeout: If doing a lot of processing, no timeout may come in handy 276 | """ 277 | return await self.acall( 278 | "getnetworkhashps", [nblocks, height], timeout=httpx.Timeout(timeout) 279 | ) 280 | 281 | async def analyzepsbt(self, psbt: str) -> AnalyzePSBT: 282 | """ 283 | https://developer.bitcoin.org/reference/rpc/analyzepsbt.html 284 | 285 | :param psbt: base64 string of a partially signed bitcoin transaction 286 | """ 287 | return await self.acall("analyzepsbt", [psbt]) 288 | 289 | async def combinepsbt(self, *psbts: str) -> CombinePSBT: 290 | """ 291 | https://developer.bitcoin.org/reference/rpc/combinepsbt.html 292 | 293 | :param psbts: base64 strings, each representing a partially signed bitcoin transaction 294 | """ 295 | return await self.acall("combinepsbt", list(psbts)) 296 | 297 | async def decodepsbt(self, psbt: str) -> DecodePSBT: 298 | """ 299 | https://developer.bitcoin.org/reference/rpc/decodepsbt.html 300 | 301 | :param psbt: base64 string of a partially signed bitcoin transaction 302 | """ 303 | return await self.acall("decodepsbt", [psbt]) 304 | 305 | async def finalizepsbt(self, psbt: str, extract: bool = True) -> FinalizePSBT: 306 | """ 307 | https://developer.bitcoin.org/reference/rpc/finalizepsbt.html 308 | 309 | :param psbt: base64 string of a partially signed bitcoin transaction 310 | :param extract: If set to true and the transaction is complete, return a hex-encoded network transaction 311 | """ 312 | return await self.acall("finalizepsbt", [psbt, extract]) 313 | 314 | async def joinpsbts(self, *psbts: str) -> JoinPSBTs: 315 | """ 316 | https://developer.bitcoin.org/reference/rpc/joinpsbts.html 317 | 318 | :param psbts: base64 strings, each representing a partially signed bitcoin transaction 319 | """ 320 | return await self.acall("joinpsbts", list(psbts)) 321 | 322 | async def utxoupdatepsbt( 323 | self, 324 | psbt: str, 325 | descriptors: Optional[List[Union[str, Dict[str, Union[int, str]]]]] = None, 326 | ) -> UtxoUpdatePSBT: 327 | """ 328 | https://developer.bitcoin.org/reference/rpc/utxoupdatepsbt.html 329 | 330 | :param psbt: base64 string of a partially signed bitcoin transaction 331 | :param extract: If set to true and the transaction is complete, return a hex-encoded network transaction 332 | """ 333 | if descriptors is not None: 334 | params = [psbt, descriptors] 335 | else: 336 | params = [psbt] 337 | return await self.acall("utxoupdatepsbt", params) # type: ignore 338 | 339 | async def walletprocesspsbt( 340 | self, 341 | psbt: str, 342 | sign: bool = True, 343 | sighashtype: str = "ALL", 344 | bip32_derivs: bool = True, 345 | ) -> WalletProcessPSBT: 346 | """ 347 | https://developer.bitcoin.org/reference/rpc/walletprocesspsbt.html 348 | 349 | :param psbt: base64 string of a partially signed bitcoin transaction 350 | :param sign: Sign the transaction too when updating 351 | :param sighashtype: signature hash type to sign, if it is not specified by PSBT. 352 | :param bip32_derivs: include bip32 derivation paths for pubkeys if known 353 | """ 354 | return await self.acall( 355 | "walletprocesspsbt", [psbt, sign, sighashtype, bip32_derivs] 356 | ) 357 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bibajz/bitcoin-python-async-rpc/40fc35c2ec4f2d03bfad76959c0cb00917509e7e/tests/__init__.py -------------------------------------------------------------------------------- /tests/bitcoin-regtest.conf: -------------------------------------------------------------------------------- 1 | [regtest] 2 | rpcallowip=127.0.0.1 3 | rpcallowip=10.0.0.0/8 4 | rpcallowip=192.168.0.0/16 5 | rpcbind=0.0.0.0:18443 6 | rpcuser=rpc_user 7 | rpcpassword=rpc_password 8 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from pathlib import Path 3 | from typing import Tuple 4 | 5 | import pytest 6 | from typing_extensions import TypedDict 7 | 8 | 9 | class _RpcConfig(TypedDict): 10 | url: str 11 | auth: Tuple[str, str] 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def rpc_config() -> _RpcConfig: 16 | """ 17 | Fixture of connection parameters usable by `BitcoinRPC.from_config`, kept in sync with bitcoin-regtest.conf 18 | file. 19 | """ 20 | 21 | # Multiple `rpcallowip` keys within the config file, therefore parsing cannot be strict. 22 | parser = configparser.ConfigParser(strict=False) 23 | with open(Path(__file__).parent / "bitcoin-regtest.conf", encoding="utf-8") as f: 24 | parser.read_file(f) 25 | 26 | conf = parser["regtest"] 27 | _, port = conf["rpcbind"].split(":") 28 | return { 29 | "url": f"http://localhost:{port}", 30 | "auth": (conf["rpcuser"], conf["rpcpassword"]), 31 | } 32 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import httpx 4 | import orjson 5 | import pytest 6 | 7 | from bitcoinrpc import BitcoinRPC 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_two_clients_dont_share_counter(rpc_config: t.Dict[str, t.Any]) -> None: 12 | logs: t.List[bytes] = [] 13 | 14 | def handler(_: httpx.Request) -> httpx.Response: 15 | """ 16 | Mock the JSON-RPC response so this test does not rely on a running Bitcoin Node 17 | """ 18 | return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": 0}) 19 | 20 | async def log_request(request: httpx.Request) -> None: 21 | """ 22 | Hook that stores the serialized request before it is sent over the wire. 23 | """ 24 | nonlocal logs 25 | logs.append(orjson.loads(request.content)) 26 | 27 | url, auth = rpc_config["url"], rpc_config["auth"] 28 | client1 = httpx.AsyncClient( 29 | auth=auth, 30 | event_hooks={"request": [log_request]}, 31 | transport=httpx.MockTransport(handler=handler), 32 | ) 33 | client2 = httpx.AsyncClient( 34 | auth=auth, 35 | event_hooks={"request": [log_request]}, 36 | transport=httpx.MockTransport(handler=handler), 37 | ) 38 | 39 | async with BitcoinRPC(url=url, client=client1) as rpc1: 40 | _ = await rpc1.getblockcount() 41 | 42 | async with BitcoinRPC(url=url, client=client2) as rpc2: 43 | _ = await rpc2.getblockcount() 44 | 45 | req1, req2 = logs 46 | assert req1 == req2 47 | -------------------------------------------------------------------------------- /tests/test_client_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test module exposing various errors which can occur when configuring `BitcoinRPC`. 3 | """ 4 | 5 | import pytest 6 | 7 | from bitcoinrpc import BitcoinRPC 8 | 9 | 10 | def test_unknown_httpx_option_provided_raises() -> None: 11 | """`httpx.AsyncClient` is strict about its init kwargs.""" 12 | with pytest.raises(TypeError): 13 | BitcoinRPC.from_config( 14 | "http://localhost:8332", 15 | ("rpc_user", "rpc_passwd"), 16 | unknown_httpx_kwarg="foo", 17 | ) 18 | 19 | 20 | def test_incorrect_httpx_option_provided_raises() -> None: 21 | """Existing option, but incorrectly used, results in `httpx.AsyncClient` error.""" 22 | with pytest.raises(AttributeError): 23 | BitcoinRPC.from_config( 24 | "http://localhost:8332", 25 | ("rpc_user", "rpc_passwd"), 26 | limits="Not like this.", 27 | ) 28 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import httpx 4 | import pytest 5 | 6 | from bitcoinrpc import BitcoinRPC, RPCError 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_connection_to_unknown_host( 11 | rpc_config: Dict[str, Any], 12 | unused_tcp_port: int, 13 | ) -> None: 14 | new_config = rpc_config.copy() 15 | new_config["url"] = f"http://localhost:{unused_tcp_port}" 16 | btc_rpc = BitcoinRPC.from_config(**new_config) 17 | 18 | with pytest.raises(httpx.ConnectError): 19 | await btc_rpc.getblockcount() 20 | 21 | 22 | @pytest.mark.integration 23 | @pytest.mark.asyncio 24 | async def test_connection_with_incorrect_auth(rpc_config: Dict[str, Any]) -> None: 25 | new_config = rpc_config.copy() 26 | new_config["auth"] = ("a", "b") 27 | btc_rpc = BitcoinRPC.from_config(**new_config) 28 | 29 | with pytest.raises(httpx.HTTPStatusError) as e: 30 | await btc_rpc.getblockcount() 31 | 32 | # 401 - Unauthorized 33 | assert e.value.response.status_code == 401 34 | 35 | 36 | @pytest.mark.integration 37 | @pytest.mark.asyncio 38 | async def test_connection_and_sample_rpc(rpc_config: Dict[str, Any]) -> None: 39 | btc_rpc = BitcoinRPC.from_config(**rpc_config) 40 | 41 | block_count = await btc_rpc.getblockcount() 42 | 43 | assert block_count >= 0 44 | 45 | 46 | @pytest.mark.integration 47 | @pytest.mark.asyncio 48 | async def test_connection_and_incorrect_rpc(rpc_config: Dict[str, Any]) -> None: 49 | btc_rpc = BitcoinRPC.from_config(**rpc_config) 50 | 51 | with pytest.raises(RPCError) as e: 52 | await btc_rpc.getblockheader("???") 53 | 54 | assert e.value.error == { 55 | "code": -8, 56 | "message": "hash must be of length 64 (not 3, for '???')", 57 | } 58 | 59 | 60 | @pytest.mark.integration 61 | @pytest.mark.asyncio 62 | async def test_connection_and_nonexistent_rpc(rpc_config: Dict[str, Any]) -> None: 63 | btc_rpc = BitcoinRPC.from_config(**rpc_config) 64 | 65 | with pytest.raises(RPCError) as e: 66 | await btc_rpc.acall("non-existent-method", []) 67 | 68 | assert e.value.error == {"code": -32601, "message": "Method not found"} 69 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion=4 3 | envlist = 4 | py{37,38,39,310,311} 5 | linters 6 | 7 | [testenv] 8 | deps = 9 | -r requirements.txt 10 | pytest 11 | pytest-asyncio 12 | commands = 13 | pytest -c setup.cfg {posargs} 14 | 15 | [testenv:linters] 16 | basepython = python3 17 | skip_install = true 18 | deps = 19 | {[testenv:black]deps} 20 | {[testenv:flake8]deps} 21 | {[testenv:mypy]deps} 22 | commands = 23 | {[testenv:black]commands} 24 | {[testenv:flake8]commands} 25 | {[testenv:mypy]commands} 26 | 27 | [testenv:flake8] 28 | basepython = python3 29 | skip_install = true 30 | deps = 31 | flake8 32 | commands = 33 | flake8 --version 34 | flake8 src/ tests/ setup.py 35 | 36 | [testenv:black] 37 | basepython = python3 38 | skip_install = true 39 | deps = 40 | black 41 | isort 42 | commands = 43 | isort --version 44 | isort src/ tests/ 45 | black --version 46 | black src/ tests/ setup.py 47 | 48 | [testenv:mypy] 49 | basepython = python3 50 | skip_install = true 51 | deps = 52 | -r requirements.txt 53 | mypy 54 | commands = 55 | mypy --version 56 | mypy src/ tests/ setup.py 57 | 58 | [testenv:build] 59 | basepython = python3 60 | skip_install = true 61 | allowlist_externals = 62 | make 63 | deps = 64 | wheel 65 | setuptools 66 | commands = 67 | make clean 68 | python setup.py -q sdist bdist_wheel 69 | 70 | [testenv:release] 71 | basepython = python3 72 | skip_install = true 73 | allowlist_externals = 74 | make 75 | deps = 76 | {[testenv:build]deps} 77 | twine 78 | commands = 79 | {[testenv:build]commands} 80 | twine upload --skip-existing dist/* 81 | --------------------------------------------------------------------------------