Skip to content

Commit

Permalink
Merge pull request #1 from circuitdao/project
Browse files Browse the repository at this point in the history
Project
  • Loading branch information
nimcon authored Aug 22, 2023
2 parents 01912a8 + 166d1bb commit d201540
Show file tree
Hide file tree
Showing 16 changed files with 3,945 additions and 0 deletions.
21 changes: 21 additions & 0 deletions .gitignore
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
**/*~
**/#*#
4 changes: 4 additions & 0 deletions .pytest.ini
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
1 change: 1 addition & 0 deletions LICENCE
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
41 changes: 41 additions & 0 deletions README.md
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.
3 changes: 3 additions & 0 deletions chianode/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .rpcclient import RpcClient
from .mojoclient import MojoClient

36 changes: 36 additions & 0 deletions chianode/constants.py
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"
]
217 changes: 217 additions & 0 deletions chianode/mojoclient.py
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}")

Loading

0 comments on commit d201540

Please sign in to comment.