-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from circuitdao/project
Project
- Loading branch information
Showing
16 changed files
with
3,945 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
## gitignore file ## | ||
|
||
# non-pytest test files | ||
check_*.py | ||
|
||
# distribution directory | ||
dist/ | ||
|
||
# environment variables | ||
.env | ||
|
||
# virtual environment | ||
venv/ | ||
activate | ||
|
||
# python cache | ||
**/__pycache__/ | ||
|
||
# temp files | ||
**/*~ | ||
**/#*# |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
# Settings for pytest. | ||
# asyncio_mode = auto makes adding the @pytest.mark.asyncio decorator to each test function unnecessary | ||
[pytest] | ||
asyncio_mode = auto |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
# About | ||
|
||
Python-chianode is a Python wrapper for [Chia blockchain](https://www.chia.net) full node APIs. | ||
|
||
The package supports the [official RPC API](https://docs.chia.net/full-node-rpc) for Chia nodes running on localhost, as well as the [Mojonode API](https://api.mojonode.com/docs). Calls are made asynchronously, and it is possible to receive streaming responses. | ||
|
||
Mojonode provides advanced REST calls not available via the official RPC interface, blockchain data via SQL query, and an event stream for blockchain and mempool events. Note that Mojonode does not implement all official RPCs. The ```get_routes``` endpoint returns a list of available endpoints. | ||
|
||
# Installation | ||
|
||
To install python-chianode, run | ||
|
||
```pip install python-chianode``` | ||
|
||
# Quick start | ||
|
||
Import and instantiate the Chia node client in your Python file as follows | ||
|
||
``` | ||
from chianode.mojoclient import MojoClient | ||
node = MojoClient() | ||
``` | ||
|
||
By default, both MojoClient and RpcClient connect to Mojonode. To connect to a local node only, do | ||
``` | ||
from chianode.rpcclient import RpcClient | ||
from chianode.constants import LOCALHOST | ||
node = RpcClient(base_url=LOCALHOST) | ||
``` | ||
|
||
To use Mojonode in conjunction with a local node, do | ||
``` | ||
from chianode.mojoclient import MojoClient | ||
from chianode.constants import LOCALHOST | ||
node = MojoClient(base_url=LOCALHOST) | ||
``` | ||
|
||
More detailed examples on how to use the wrapper can be found in ```example_rpc.py``` and ```example_events.py``` files. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
from .rpcclient import RpcClient | ||
from .mojoclient import MojoClient | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
NEWLINE = "\n" | ||
|
||
GET = "GET" | ||
POST = "POST" | ||
|
||
MAINNET = "mainnet" | ||
TESTNET10 = "testnet10" | ||
|
||
LOCALHOST = "https://localhost:8555/" | ||
MOJONODE = "https://api.mojonode.com/" | ||
|
||
MOJONODE_EVENT_OBJECTS = ["coin", "block", "transaction"] | ||
MOJONODE_PAGE_SIZE = 50 | ||
MOJONODE_MAX_HEIGHT_DIFF = 100 | ||
MOJONODE_RPC_ENDPOINTS = [ | ||
"/get_coin_record_by_name", | ||
"/get_coin_records_by_name", | ||
"/get_coin_records_by_parent_ids", | ||
"/get_coin_records_by_puzzle_hash", | ||
"/get_coin_records_by_puzzle_hashes", | ||
"/get_coin_records_by_hint", | ||
"/get_block_record_by_height", | ||
"/get_block_record", | ||
"/get_block_records", | ||
"/get_block", | ||
"/get_blocks", | ||
"/get_additions_and_removals", | ||
"/get_blockchain_state", | ||
"/get_puzzle_and_solution", | ||
"/get_block_spends", | ||
"/get_all_mempool_tx_ids", | ||
"/get_mempool_item_by_tx_id", | ||
"/get_initial_freeze_period", | ||
"/healthz", | ||
"/push_tx" | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,217 @@ | ||
import logging | ||
import httpx | ||
import uuid | ||
import json | ||
from datetime import datetime | ||
from .rpcclient import RpcClient | ||
from .constants import NEWLINE, GET, POST, MAINNET, LOCALHOST, MOJONODE, MOJONODE_RPC_ENDPOINTS | ||
|
||
|
||
logging.getLogger(__name__).addHandler(logging.NullHandler()) | ||
|
||
|
||
class MojoClient(RpcClient): | ||
|
||
def __init__(self, base_url=MOJONODE, network=MAINNET, mojo_timeout=10, rpc_timeout=5): # 5 second timeout is the httpx default | ||
"""Initialize a MojoClient instance. | ||
Keywork arguments: | ||
base_url -- the URL (exluding endpoint) for RPCs | ||
network -- the network to query | ||
mojo_timeout -- timeout in seconds for requests to Mojonode | ||
rpc_timeout -- timeout in seconds for RPCs | ||
""" | ||
|
||
if base_url == MOJONODE: rpc_timeout = mojo_timeout # Override RPC timeout if Mojonode used for RPC calls | ||
RpcClient.__init__(self, base_url=base_url, network=MAINNET, timeout=rpc_timeout) | ||
|
||
self.mojo_headers = {"accept": "application/json", "Content-Type": "application/json"} | ||
self.mojo_timeout = mojo_timeout | ||
|
||
self._streams = {} | ||
|
||
self.mojonode_nonrpc_endpoints = [ | ||
"/get_tx_by_name", | ||
"/get_uncurried_coin_spend", | ||
"/get_transactions_for_coin", | ||
"/get_query_schema", | ||
"/query", | ||
"/events", | ||
"/get_latest_singleton_spend" | ||
] | ||
|
||
if self.base_url == MOJONODE: | ||
self.mojoclient = self.client | ||
else: | ||
self.mojoclient = httpx.AsyncClient(base_url=MOJONODE, http2=True, timeout=self.mojo_timeout) | ||
|
||
|
||
async def _mojo_request(self, method, endpoint, params, no_network=False): | ||
"""Send a REST request to Mojonode. | ||
Keyword arguments: | ||
method -- a REST method (GET, POST, etc) | ||
endpoint -- URI endpoint to send request to | ||
params -- dict of request parameters | ||
no_network -- boolean indicating whether to add a network field to params | ||
""" | ||
|
||
url = MOJONODE + endpoint | ||
data = json.dumps(self._add_network_param(params, no_network)) | ||
|
||
if method == POST: | ||
logging.info(f"Sending POST request{NEWLINE} URL: {url}{NEWLINE} data: {data}") | ||
response = await self.mojoclient.post(url, content=data, headers=self.mojo_headers) | ||
else: | ||
raise ValueError(f"Unsupported REST method {method}") | ||
|
||
return response | ||
|
||
|
||
async def _mojo_request_no_network(self, method, endpoint, params): | ||
"""Send a REST request to Mojonode without specifying a network | ||
Keyword arguments: | ||
method -- a REST method (GET, POST, etc) | ||
endpoint -- URI endpoint to send request to | ||
params -- dict of request parameters | ||
""" | ||
|
||
return await self._mojo_request(method, endpoint, params, no_network=True) | ||
|
||
|
||
async def get_tx_by_name(self, tx_id): | ||
"""Transaction by transaction ID. | ||
Arguments: | ||
tx_id -- a spend bundle name | ||
Returns the transaction (spend bundle) corresponding to the transaction ID (spend bundle name). | ||
Since spend bundles are mempool objects, Mojonode may occasionally fail to record a spend, resulting in missing data. | ||
""" | ||
|
||
params = {"name": tx_id} | ||
return await self._mojo_request(POST, "get_tx_by_name", params) | ||
|
||
|
||
async def get_uncurried_coin_spend(self, coin_id): | ||
"""Uncurried coin spend for given coin ID.""" | ||
|
||
params = {"name": coin_id} | ||
return await self._mojo_request(POST, "get_uncurried_coin_spend", params) | ||
|
||
|
||
async def get_transactions_for_coin(self, coin_id): | ||
"""Transactions in which the specified coin was created and spent. | ||
Arguments: | ||
coin_id -- coin name (coin ID) as a byte-32 hex encoded string | ||
Returns the transaction IDs (spend bundle names) as 32-byte hex encoded stings of the spend bundles that created ('added_by') and spent ('removed_by') the coin. | ||
Since spend bundles are mempool objects, Mojonode may occasionally fail to record a spend, resulting in missing 'added_by' or 'removed_by' data. | ||
""" | ||
|
||
params = {"name": coin_id} | ||
return await self._mojo_request(POST, "get_transactions_for_coin", params) | ||
|
||
|
||
async def get_query_schema(self): | ||
"""Mojonode SQL database schema.""" | ||
|
||
return await self._mojo_request_no_network(POST, "get_query_schema", {}) | ||
|
||
|
||
async def query(self, query): | ||
"""Queries Mojonode SQL database for Chia blockchain data. | ||
Arguments: | ||
query -- a valid SQL query as a string | ||
""" | ||
|
||
params = {"query": query} | ||
return await self._mojo_request_no_network(POST, "query", params) | ||
|
||
|
||
async def get_latest_singleton_spend(self, address): | ||
"""Latest singleton spend for given address""" | ||
|
||
params = {"address": address} | ||
return await self._mojo_request(POST, "get_latest_singleton_spend", params) | ||
|
||
|
||
async def get_routes(self): | ||
"""Available endpoints""" | ||
|
||
if self.base_url == LOCALHOST: | ||
response = await self._request(POST, "get_routes", {}) | ||
endpoints = response.json()["routes"] + self.mojonode_nonrpc_endpoints | ||
elif self.base_url == MOJONODE: | ||
endpoints = MOJONODE_RPC_ENDPOINTS + self.mojonode_nonrpc_endpoints | ||
|
||
# Return available endpoints as HTTP response | ||
response_data = { | ||
"routes": endpoints, | ||
"success": True | ||
} | ||
headers = {"Content-Type": "application/json"} | ||
return httpx.Response(200, content=json.dumps(response_data).encode("utf-8"), headers=headers) | ||
|
||
|
||
async def close_stream(self, stream_id): | ||
"""Closes an event stream.""" | ||
|
||
if stream_id in self._streams.keys(): | ||
self._streams.pop(stream_id) | ||
else: | ||
raise ValueError(f"No stream with ID {stream_id} to close") | ||
|
||
|
||
async def events(self, for_object=None, from_ts="$", filters=""): | ||
"""Stream events. | ||
Mojonode disconnects event streams every 5 mins, so that the client needs to reconnect. | ||
Keyword arguments: | ||
for_object -- only stream events for specified object (coin, block, transaction). Streams all events if set to None | ||
from_ts -- only stream events from the given timestamp onwards. Note that timestamps are unique | ||
filters -- only stream events that pass the filter. See Mojonode documentation for details | ||
""" | ||
|
||
if for_object is not None: | ||
if not for_object in MOJONODE_EVENT_OBJECTS: raise ValueError(f"Unkown object specified ({object})") | ||
|
||
params = f"&from_ts={from_ts}" + f"&filters={filters}" | ||
if for_object is not None: endpoint = f"for_object={for_object}&" + params | ||
|
||
stream_id = str(uuid.uuid4()) | ||
self._streams[stream_id] = True | ||
yield stream_id | ||
|
||
while stream_id in self._streams.keys(): | ||
try: | ||
|
||
# Context manager for Mojonode event stream | ||
async with self.mojoclient.stream(GET, MOJONODE + "events?" + params, timeout=None) as response: | ||
|
||
logging.debug(f"Connected to stream ID {stream_id}") | ||
|
||
try: | ||
async for data in response.aiter_lines(): | ||
|
||
if stream_id in self._streams.keys(): | ||
|
||
if data.startswith('data: '): | ||
event = json.loads(data[6:]) | ||
yield event | ||
from_ts = event["ts"] | ||
else: | ||
# If stream no longer active, close it | ||
await response.aclose() | ||
logging.debug(f"Closed stream ID {stream_id}") | ||
break | ||
except Exception as e: | ||
logging.warning(f"Failed to read data from stream ID {stream_id}") | ||
|
||
except Exception as e: | ||
logging.warning(f"Failed to connect to stream ID {stream_id}") | ||
|
Oops, something went wrong.