From 1b6652c38e7c68f95ae1c063bc988e600b908ace Mon Sep 17 00:00:00 2001 From: Unique-Divine Date: Sat, 8 Jul 2023 19:40:48 -0500 Subject: [PATCH 1/5] feat(jsonrpc): impement JSON-RPC 2.0 as a subpackage of nibiru --- nibiru/jsonrpc/jsonrpc.py | 210 +++++++++++++++++++++++++++++++++ nibiru/jsonrpc/jsonrpc_test.py | 100 ++++++++++++++++ nibiru/jsonrpc/rpc_error.py | 63 ++++++++++ nibiru/pytypes/jsonable.py | 2 +- 4 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 nibiru/jsonrpc/jsonrpc.py create mode 100644 nibiru/jsonrpc/jsonrpc_test.py create mode 100644 nibiru/jsonrpc/rpc_error.py diff --git a/nibiru/jsonrpc/jsonrpc.py b/nibiru/jsonrpc/jsonrpc.py new file mode 100644 index 00000000..33bebe2b --- /dev/null +++ b/nibiru/jsonrpc/jsonrpc.py @@ -0,0 +1,210 @@ +import dataclasses +from typing import Union, Optional, Set, Any, Dict + +from nibiru import pytypes +from nibiru.jsonrpc import rpc_error + + +def json_rpc_request_keys() -> Set[str]: + """Fields for a JSONRPCRequest. Must be one of: + ["method", "params", "jsonrpc", "id"] + """ + return set(["method", "params", "jsonrpc", "id"]) + + +def json_rpc_response_keys() -> Set[str]: + """Fields for a JSONRPCResponse. Must be one of: + ["result", "error", "jsonrpc", "id"] + """ + return set(["result", "error", "jsonrpc", "id"]) + + +JsonRPCID = Union[str, int] + + +@dataclasses.dataclass +class JsonRPCRequest: + method: str + params: pytypes.Jsonable = None + jsonrpc: str = "2.0" + id: Optional[JsonRPCID] = None + + def __post_init__(self): + self._validate_method(method=self.method) + self._validate_id(id=self.id) + + @staticmethod + def _validate_method(method: str): + if not isinstance(method, str): + raise ValueError("Method must be a string.") + if method.startswith("rpc."): + raise ValueError( + "Method names beginning with 'rpc.' are reserved.") + + @staticmethod + def _validate_id(id: Optional[Union[str, int]]): + id_ = id + if id_ is not None and not isinstance(id_, (str, int, type(None))): + raise ValueError("id must be a string, number, or None.") + if isinstance(id_, int) and id_ != int(id_): + raise ValueError( + "id as number should not contain fractional parts.") + + def to_dict(self) -> Dict[str, Any]: + request = {"jsonrpc": self.jsonrpc, "method": self.method} + if self.params is not None: + request["params"] = self.params + if self.id is not None: + request["id"] = self.id + return request + + @classmethod + def from_raw_dict(cls, raw: "RawJsonRPCRequest") -> "JsonRPCRequest": + # Make sure the raw data is a dictionary + if not isinstance(raw, dict): + raise TypeError(f"Expected dict, got {type(raw)}") + + # Check for the required fields + for field in ['jsonrpc', 'method']: + if field not in raw: + raise ValueError(f"Missing required field {field}") + + # Create a JsonRPCRequest object from the raw dictionary + jsonrpc = raw.get('jsonrpc') + method = raw.get('method') + params = raw.get('params', None) + id = raw.get('id', None) + return cls(method=method, params=params, id=id, jsonrpc=jsonrpc) + + +# from typing import TypedDict # not available in Python 3.7 +# class RawJsonRPCRequest(TypedDict): +class RawJsonRPCRequest(dict): + """Proxy for a 'TypedDict' representing a JSON RPC response. + + The 'JsonRPCRequest' type is defined according to the official + [JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification). + + Keys (ValueType): + method (str): A string containing the name of the method to be invoked. + Method names that begin with the word rpc followed by a period + character (U+002E or ASCII 46) are reserved for rpc-internal methods + and extensions and MUST NOT be used for anything else. + params (TODO): A structured value that holds the parameter values to be + used during the invocation of the method. This field MAY be omitted. + + jsonrpc (str): Specifies the version of the JSON-RPC protocol. + MUST be exactly "2.0". + + id (str): An identifier established by the Client that MUST contain a + String, Number, or NULL value if included. If it is not included, it + is assumed to be a notification. + 1. The value SHOULD normally not be Null: The use of Null as a value + for the id member in a Request object is discouraged, because this + specification uses a value of Null for Responses with an unknown + id. Also, because JSON-RPC 1.0 uses an id value of Null for + Notifications this could cause confusion in handling. + 2. The Numbers in the id SHOULD NOT contain fractional parts: + Fractional parts may be problematic, since many decimal fractions + cannot be represented exactly as binary fractions. + + Note that the Server MUST reply with the same value in the Response object + if included. This member is used to correlate the context between the two + objects. + """ + + +# from typing import TypedDict # not available in Python 3.7 +# class RawJsonRPCResponse(TypedDict): +class RawJsonRPCResponse(dict): + """Proxy for a 'TypedDict' representing a JSON RPC response. + + The 'JsonRPCResponse' type is defined according to the official + [JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification). + + Keys (ValueType): + result (TODO): ... + error (TODO): ... + jsonrpc (str): Should be "2.0". + id (str): block height at which the transaction was committed. + """ + + +@dataclasses.dataclass +class JsonRPCResponse: + """Generic JSON-RPC response as dictated by the official + [JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification). + + Args and Attributes: + id (JsonRPCID): + jsonrpc (str = "2.0"): + result (TODO, optional): Defaults to None. + error (TODO, optional): Defaults to None. + """ + + id: Optional[JsonRPCID] = None + jsonrpc: str = "2.0" + result: Any = None + error: Any = None + + def __post_init__(self): + self._validate(result=self.result, error=self.error) + + def _validate(self, result, error): + if result is not None and error is not None: + raise ValueError("Both result and error cannot be set.") + elif result is None and error is None: + raise ValueError("Either result or error must be set.") + elif result is not None: + self.result = result + self.error = None + elif error is not None: + if not isinstance(error, rpc_error.RPCError): + raise ValueError("Error must be an instance of RPCError.") + self.error = error.to_dict() + self.result = None + + def to_dict(self) -> RawJsonRPCResponse: + response: dict = {"jsonrpc": self.jsonrpc, "id": self.id} + if self.result is not None: + response["result"] = self.result + if self.error is not None: + response["error"] = self.error + return response + + @classmethod + def from_raw_dict(cls, raw: "RawJsonRPCResponse") -> "JsonRPCResponse": + # Make sure the raw data is a dictionary + if not isinstance(raw, dict): + raise TypeError(f"Expected dict, got {type(raw)}") + + # Check for the required fields + for field in ['jsonrpc', 'id']: + if field not in raw: + raise ValueError(f"Missing required field {field}") + + # Create a JsonRPCResponse object from the raw dictionary + jsonrpc = raw.get('jsonrpc') + id = raw.get('id') + result = raw.get('result', None) + error = raw.get('error', None) + + # Make sure either result or error is present + if result is None and error is None: + raise ValueError("Either result or error must be present.") + if result is not None and error is not None: + raise ValueError("Both result and error cannot be present.") + + # If an error is present, make sure it is an RPCError + if error is not None: + error = rpc_error.RPCError.from_dict(error) + + return cls(jsonrpc=jsonrpc, id=id, result=result, error=error) + + def __eq__(self, other) -> bool: + return all([ + self.id == other.id, + self.jsonrpc == other.jsonrpc, + self.result == other.result, + self.error == other.error, + ]) diff --git a/nibiru/jsonrpc/jsonrpc_test.py b/nibiru/jsonrpc/jsonrpc_test.py new file mode 100644 index 00000000..c142a375 --- /dev/null +++ b/nibiru/jsonrpc/jsonrpc_test.py @@ -0,0 +1,100 @@ +import json +import pytest + +from nibiru.jsonrpc import jsonrpc +from nibiru.jsonrpc import rpc_error + +from typing import Tuple, Dict, Callable, Union + + +def mock_rpc_method_subtract(params): + if isinstance(params, list): + return params[0] - params[1] + elif isinstance(params, dict): + return params["minuend"] - params["subtrahend"] + + +MOCK_METHODS: Dict[str, Callable] = {"subtract": mock_rpc_method_subtract} + + +def handle_request(request_json: str) -> jsonrpc.JsonRPCResponse: + try: + request_dict = json.loads(request_json) + except json.JSONDecodeError: + return jsonrpc.JsonRPCResponse( + error=rpc_error.ParseError(), + result=None, + ) + + try: + request = jsonrpc.JsonRPCRequest.from_raw_dict(request_dict) + except ValueError: + return jsonrpc.JsonRPCResponse( + error=rpc_error.InvalidRequestError(), + result=None, + id=request_dict.get("id") + ) + + method: Union[Callable, None] = MOCK_METHODS.get(request.method) + + if method is None: + return jsonrpc.JsonRPCResponse( + id=request.id, error=rpc_error.MethodNotFoundError() + ) + result = method(request.params) + return jsonrpc.JsonRPCResponse(id=request.id, result=result) + + +def rpc_call_test_case(req: str, resp: str) -> Tuple[str, str]: + assert isinstance(req, str) + assert isinstance(resp, str) + return (req, resp) + + +@pytest.mark.parametrize( + "request_json, response_json", + [ + rpc_call_test_case( + req='{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}', + resp='{"jsonrpc": "2.0", "result": 19, "id": 1}', + ), + rpc_call_test_case( + req='{"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}', + resp='{"jsonrpc": "2.0", "result": -19, "id": 2}', + ), + rpc_call_test_case( + req='{"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}', + resp='{"jsonrpc": "2.0", "result": 19, "id": 3}', + ), + rpc_call_test_case( + req='{"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4}', + resp='{"jsonrpc": "2.0", "result": 19, "id": 4}', + ), + rpc_call_test_case( + req='{"jsonrpc": "2.0", "method": "foobar", "id": "1"}', + resp='{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}', + ), + rpc_call_test_case( + req='{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]', + resp='{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}', + ), + rpc_call_test_case( + req='{"jsonrpc": "2.0", "method": 1, "params": "bar"}', + resp='{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}', + ), + ], +) +def test_rpc_calls(request_json: str, response_json: str): + got_resp: jsonrpc.JsonRPCResponse = handle_request(request_json) + want_resp = jsonrpc.JsonRPCResponse.from_raw_dict( + raw=json.loads(response_json), + ) + + # Manually check equals + assert got_resp.id == want_resp.id + assert got_resp.jsonrpc == want_resp.jsonrpc + assert got_resp.result == want_resp.result + assert got_resp.error == want_resp.error + + # Check with __eq__ method + assert got_resp == want_resp diff --git a/nibiru/jsonrpc/rpc_error.py b/nibiru/jsonrpc/rpc_error.py new file mode 100644 index 00000000..868fe97d --- /dev/null +++ b/nibiru/jsonrpc/rpc_error.py @@ -0,0 +1,63 @@ +class RPCError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + + def to_dict(self) -> dict: + error = {"code": self.code, "message": self.message} + if self.data is not None: + error["data"] = self.data + return error + + @classmethod + def from_dict(cls, d: dict) -> "RPCError": + if not isinstance(d, dict): + raise TypeError(f"Expected dict, got {type(d)}") + + # Check for required fields + for dict_key in ["code", "message"]: + if dict_key not in d: + raise ValueError(f"Missing required field {dict_key}") + + # Create an RPCError object from the dictionary. + code = d.get('code') + message = d.get('message') + data = d.get('data') + return cls(code=code, message=message, data=data) + + +class ParseError(RPCError): + def __init__(self, data=None): + super().__init__(-32700, "Parse error", data) + + +class InvalidRequestError(RPCError): + def __init__(self, data=None): + super().__init__(-32600, "Invalid Request", data) + + +class MethodNotFoundError(RPCError): + def __init__(self, data=None): + super().__init__(-32601, "Method not found", data) + + +class InvalidParamsError(RPCError): + def __init__(self, data=None): + super().__init__(-32602, "Invalid params", data) + + +class InternalError(RPCError): + def __init__(self, data=None): + super().__init__(-32603, "Internal error", data) + + +class ServerError(RPCError): + def __init__(self, code, data=None): + if -32099 <= code <= -32000: + super().__init__(code, "Server error", data) + else: + raise ValueError( + "Code for ServerError should be within range (incusive) " + + f"-32000 to -32099. Got code: {code}", + ) diff --git a/nibiru/pytypes/jsonable.py b/nibiru/pytypes/jsonable.py index c59fb0fe..a3fe3f02 100644 --- a/nibiru/pytypes/jsonable.py +++ b/nibiru/pytypes/jsonable.py @@ -29,7 +29,7 @@ def to_iso(dt: datetime) -> str: ) -def dict_to_jsonable(d: Mapping): +def dict_to_jsonable(d: Mapping) -> "Jsonable": """Recursively calls to_jsonable on each element of the given map.""" return {key: to_jsonable(val) for key, val in d.items()} From afbd3a4e2310d65373f2ca30b85c9e39da9662ad Mon Sep 17 00:00:00 2001 From: Unique-Divine Date: Sat, 8 Jul 2023 19:41:48 -0500 Subject: [PATCH 2/5] feat(tm_rpc): implement jsonrpc version of broadcast tx with tests --- nibiru/tm_rpc/broadcast.py | 86 +++++++++++++++++++++++++++++++++ nibiru/tm_rpc/broadcast_test.py | 69 ++++++++++++++++++++++++++ nibiru/tx.py | 12 +++-- 3 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 nibiru/tm_rpc/broadcast.py create mode 100644 nibiru/tm_rpc/broadcast_test.py diff --git a/nibiru/tm_rpc/broadcast.py b/nibiru/tm_rpc/broadcast.py new file mode 100644 index 00000000..62c85953 --- /dev/null +++ b/nibiru/tm_rpc/broadcast.py @@ -0,0 +1,86 @@ +import abc +import json +import dataclasses +from nibiru.jsonrpc import jsonrpc +from typing import Dict, Callable, Any, Optional, Tuple, Union + + +class TypedJsonRpcRequest(abc.ABC, jsonrpc.JsonRPCRequest): + + @abc.abstractmethod + def create(cls, *args) -> jsonrpc.JsonRPCRequest: ... + # TODO docs + + @classmethod + @abc.abstractmethod + def _validate_tm_rpc_request(*args): ... + # TODO docs + + +@dataclasses.dataclass +class BroadcastTxSync(jsonrpc.JsonRPCRequest): + """TODO docs + + Args: + req (Union[jsonrpc.JsonRPCRequest, str, dict]): An object that can be + parsed as a jsonrpc.JsonRPCRequest. + """ + + def __new__(cls, req: Union[jsonrpc.JsonRPCRequest, str, dict], + ) -> "BroadcastTxSync": + if isinstance(req, jsonrpc.JsonRPCRequest): + cls._validate_tm_rpc_request(json_rpc_req=req) + return req + elif isinstance(req, dict): + json_rpc_req: jsonrpc.JsonRPCRequest = \ + jsonrpc.JsonRPCRequest.from_raw_dict(raw=req) + return cls(req=json_rpc_req) + elif isinstance(req, str): + req_dict: dict = json.loads(req) + json_rpc_req: jsonrpc.JsonRPCRequest = ( + jsonrpc.JsonRPCRequest.from_raw_dict(raw=req_dict) + ) + return cls(req=json_rpc_req) + else: + raise TypeError( + 'expected request of type "jsonrpc.JsonRPCRequest", "str" or ' + + f'"dict": got type {type(req)}' + ) + + def create( + tx_raw_bytes: bytes, id=None, + ) -> jsonrpc.JsonRPCRequest: + return jsonrpc.JsonRPCRequest( + method="broadcast_tx_sync", + params=dict(tx=tx_raw_bytes), + id=id, + ) + + @classmethod + def _validate_tm_rpc_request(cls, json_rpc_req: jsonrpc.JsonRPCRequest): + if not isinstance(json_rpc_req.params, dict): + raise TypeError( + f"params field of {cls.__name__} must be a dict" + + f", not type {type(json_rpc_req.params)}", + ) + + tx_bytes = json_rpc_req.params.get("tx") + if not isinstance(tx_bytes, (str, bytes)): + raise TypeError( + f"request type {cls.__name__} must have " + + "params.tx of type Union[str, bytes]" + + f", not {type(tx_bytes).__name__}" + ) + + +CHAIN_JSON_RPC_METHODS: Dict[str, Callable[[Any], jsonrpc.JsonRPCRequest]] = dict( + broadcast_tx_sync=BroadcastTxSync.create, +) + + +# TODO test +def is_known_rpc_method(method: str) -> Tuple[bool, Optional[Callable]]: + method_fn = CHAIN_JSON_RPC_METHODS.get(method) + if method_fn is None: + return False, method_fn + return True, method_fn diff --git a/nibiru/tm_rpc/broadcast_test.py b/nibiru/tm_rpc/broadcast_test.py new file mode 100644 index 00000000..1c31e3e2 --- /dev/null +++ b/nibiru/tm_rpc/broadcast_test.py @@ -0,0 +1,69 @@ +import dataclasses +import json +import tests +from nibiru.tm_rpc import broadcast +from typing import Optional, List + + +# This class CANNOT include "Test" in its name because pytest will think it's +# supposed to be a test class. +@dataclasses.dataclass +class TC: + req_json: str + happy: bool + real_tx: bool = False + err_str: Optional[str] = None + + +cases: List[TC] = [ + TC(req_json="""{ + "jsonrpc": "2.0", + "id": 216135382217, + "method": "broadcast_tx_sync", + "params": { + "tx": "CokBCoYBChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEmYKK25pYmkxemFhdnZ6eGV6MGVsdW5kdG4zMnFuazlsa204a21jc3o0NGc3eGwSK25pYmkxYTRyNnhnNHBnNmtkdWZ6cTUydGs1NXZqc3hmYWhjcmF1c2pkYWcaCgoFdW5pYmkSATESbgpQCkYKHy9jb3Ntb3MuY3J5cHRvLnNlY3AyNTZrMS5QdWJLZXkSIwohAvzwBOriY8sVwEXrXf1gXanhT9imlfWeUWLQ8pMxrRsgEgQKAggBGAQSGgoSCgV1bmliaRIJNTY4NzUwMDAwEIDnheBUGkA88j0Oylm+2KqdT/RcxRm28Xe4G8inlGWYRyUbYz+6PQKdQXy3/2UDJ73zCSSBSxNVIUZ5xjufM6oC+6kfWsd3" + } +}""", happy=True, real_tx=True), + TC(req_json="""{ + "jsonrpc": "2.0", + "id": 381619358564, + "method": "broadcast_tx_sync", + "params": { + "tx": "CroDCmoKHi9uaWJpcnUucGVycC52Mi5Nc2dNYXJrZXRPcmRlchJICituaWJpMXphYXZ2enhlejBlbHVuZHRuMzJxbms5bGttOGttY3N6NDRnN3hsEgp1YnRjOnVudXNkGAEiBDEwMDAqAjEwMgEwCmYKHC9uaWJpcnUucGVycC52Mi5Nc2dBZGRNYXJnaW4SRgorbmliaTF6YWF2dnp4ZXowZWx1bmR0bjMycW5rOWxrbThrbWNzejQ0Zzd4bBIKdWJ0Yzp1bnVzZBoLCgV1bnVzZBICMjAKaAofL25pYmlydS5wZXJwLnYyLk1zZ1JlbW92ZU1hcmdpbhJFCituaWJpMXphYXZ2enhlejBlbHVuZHRuMzJxbms5bGttOGttY3N6NDRnN3hsEgp1YnRjOnVudXNkGgoKBXVudXNkEgE1CnoKHi9uaWJpcnUucGVycC52Mi5Nc2dNYXJrZXRPcmRlchJYCituaWJpMXphYXZ2enhlejBlbHVuZHRuMzJxbms5bGttOGttY3N6NDRnN3hsEgp1YnRjOnVudXNkGAIiAzIwMCoTNDAwMDAwMDAwMDAwMDAwMDAwMDIBMBJoClAKRgofL2Nvc21vcy5jcnlwdG8uc2VjcDI1NmsxLlB1YktleRIjCiEC/PAE6uJjyxXARetd/WBdqeFP2KaV9Z5RYtDykzGtGyASBAoCCAEYBRIUCg4KBXVuaWJpEgUxMDAwMBCAtRgaQIjSH+wlldDIfH4XjLPbTq8YIibaRSujNIba5UlpcpkZfQz7feVjtc8OxK+4PW3jGG75ZjJzDQ5ptQ//JbvpINM=" + } +} + """, happy=True, real_tx=True), + TC( + req_json=""" + { "jsonrpc": "2.0", "id": 42, "method": "foobarbat", "params": {}}""", + happy=False, + err_str="params.tx of type", + ), + TC( + req_json=""" + { "jsonrpc": "2.0", + "id": 42, + "method": + "foobarbat", + "params": { "tx": "mock_tx"} + }""", + happy=True, + ), +] + + +def test_init_BroadcastTxSync(): + + for tc in cases: + try: + tm_rpc_req = broadcast.BroadcastTxSync(req=json.loads(tc.req_json)) + + if not tc.happy: + raise RuntimeError("expected test case to raise error") + if tc.real_tx: + assert tm_rpc_req.params.get("tx") + # TODO feat: Build tx from string or bytes. + except BaseException as err: + if tc.err_str: + tests.raises(ok_errs=[tc.err_str], err=err) + assert not tc.happy, "expected test case to pass" diff --git a/nibiru/tx.py b/nibiru/tx.py index 7842bb6a..f11752fc 100644 --- a/nibiru/tx.py +++ b/nibiru/tx.py @@ -130,7 +130,8 @@ def execute_msgs( ) if address: address.decrease_sequence() - raise SimulationError(f"Failed to simulate transaction: {err}") from err + raise SimulationError( + f"Failed to simulate transaction: {err}") from err try: tx_resp: abci_type.TxResponse = self.execute_tx( @@ -466,12 +467,14 @@ def __generate_info(self, public_key: wallet.PublicKey = None) -> Tuple[str, str body_bytes = body.SerializeToString() mode_info = cosmos_tx_type.ModeInfo( - single=cosmos_tx_type.ModeInfo.Single(mode=tx_sign.SIGN_MODE_DIRECT) + single=cosmos_tx_type.ModeInfo.Single( + mode=tx_sign.SIGN_MODE_DIRECT) ) if public_key: any_public_key = any_pb2.Any() - any_public_key.Pack(public_key.to_public_key_proto(), type_url_prefix="") + any_public_key.Pack( + public_key.to_public_key_proto(), type_url_prefix="") signer_info = cosmos_tx_type.SignerInfo( mode_info=mode_info, sequence=self.sequence, public_key=any_public_key ) @@ -480,7 +483,8 @@ def __generate_info(self, public_key: wallet.PublicKey = None) -> Tuple[str, str mode_info=mode_info, sequence=self.sequence ) - auth_info = cosmos_tx_type.AuthInfo(signer_infos=[signer_info], fee=self.fee) + auth_info = cosmos_tx_type.AuthInfo( + signer_infos=[signer_info], fee=self.fee) auth_info_bytes = auth_info.SerializeToString() return body_bytes, auth_info_bytes From c97604bb4cfa1c608b1abd290604883b4da79347 Mon Sep 17 00:00:00 2001 From: Unique-Divine Date: Sat, 8 Jul 2023 23:06:39 -0500 Subject: [PATCH 3/5] chore(deps): add type stubs for the requests and urllib pkgs --- poetry.lock | 27 ++++++++++++++++++++++++++- pyproject.toml | 1 + scripts/fmt.sh | 6 ++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 scripts/fmt.sh diff --git a/poetry.lock b/poetry.lock index 094a5a72..c3b9df0c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1606,6 +1606,31 @@ files = [ {file = "types_protobuf-4.23.0.1-py3-none-any.whl", hash = "sha256:c926104f69ea62103846681b35b690d8d100ecf86c6cdda16c850a1313a272e4"}, ] +[[package]] +name = "types-requests" +version = "2.31.0.1" +description = "Typing stubs for requests" +optional = false +python-versions = "*" +files = [ + {file = "types-requests-2.31.0.1.tar.gz", hash = "sha256:3de667cffa123ce698591de0ad7db034a5317457a596eb0b4944e5a9d9e8d1ac"}, + {file = "types_requests-2.31.0.1-py3-none-any.whl", hash = "sha256:afb06ef8f25ba83d59a1d424bd7a5a939082f94b94e90ab5e6116bd2559deaa3"}, +] + +[package.dependencies] +types-urllib3 = "*" + +[[package]] +name = "types-urllib3" +version = "1.26.25.13" +description = "Typing stubs for urllib3" +optional = false +python-versions = "*" +files = [ + {file = "types-urllib3-1.26.25.13.tar.gz", hash = "sha256:3300538c9dc11dad32eae4827ac313f5d986b8b21494801f1bf97a1ac6c03ae5"}, + {file = "types_urllib3-1.26.25.13-py3-none-any.whl", hash = "sha256:5dbd1d2bef14efee43f5318b5d36d805a489f6600252bb53626d4bfafd95e27c"}, +] + [[package]] name = "typing-extensions" version = "4.7.1" @@ -1804,4 +1829,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "6c5e87a67bdef8756c42376ad9f94bc91a847bf6881b894b7c89d01a21e52ff4" +content-hash = "5a0999785316741a0f588ce00f72155393d1ef640afdd49714e8dc2a713568cb" diff --git a/pyproject.toml b/pyproject.toml index 080f6013..260ff380 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ pytest = "^7.1.3" black = "^22.10.0" pytest-cov = "^4.0.0" isort = "^5.12.0" +types-requests = "^2.31.0.1" [tool.black] line-length = 88 diff --git a/scripts/fmt.sh b/scripts/fmt.sh new file mode 100644 index 00000000..554249c2 --- /dev/null +++ b/scripts/fmt.sh @@ -0,0 +1,6 @@ +#!/bin/bash +poetry run black . +poetry run isort . +flake8 nibiru +flake8 tests +flake8 examples From f6d22dbd7d26b90564726b70173c06c42b0b3da8 Mon Sep 17 00:00:00 2001 From: Unique-Divine Date: Sat, 8 Jul 2023 23:07:46 -0500 Subject: [PATCH 4/5] test(json_rpc): test rpc block query --- nibiru/jsonrpc/jsonrpc.py | 61 ++++++++++++++++++++++++++------- nibiru/jsonrpc/jsonrpc_test.py | 33 +++++++++++++++--- nibiru/tm_rpc/broadcast.py | 32 +++++++++-------- nibiru/tm_rpc/broadcast_test.py | 19 +++++++--- nibiru/tx.py | 12 +++---- 5 files changed, 113 insertions(+), 44 deletions(-) diff --git a/nibiru/jsonrpc/jsonrpc.py b/nibiru/jsonrpc/jsonrpc.py index 33bebe2b..253bd871 100644 --- a/nibiru/jsonrpc/jsonrpc.py +++ b/nibiru/jsonrpc/jsonrpc.py @@ -1,5 +1,7 @@ import dataclasses -from typing import Union, Optional, Set, Any, Dict +from typing import Any, Dict, Optional, Set, Union + +import requests from nibiru import pytypes from nibiru.jsonrpc import rpc_error @@ -38,8 +40,7 @@ def _validate_method(method: str): if not isinstance(method, str): raise ValueError("Method must be a string.") if method.startswith("rpc."): - raise ValueError( - "Method names beginning with 'rpc.' are reserved.") + raise ValueError("Method names beginning with 'rpc.' are reserved.") @staticmethod def _validate_id(id: Optional[Union[str, int]]): @@ -47,8 +48,7 @@ def _validate_id(id: Optional[Union[str, int]]): if id_ is not None and not isinstance(id_, (str, int, type(None))): raise ValueError("id must be a string, number, or None.") if isinstance(id_, int) and id_ != int(id_): - raise ValueError( - "id as number should not contain fractional parts.") + raise ValueError("id as number should not contain fractional parts.") def to_dict(self) -> Dict[str, Any]: request = {"jsonrpc": self.jsonrpc, "method": self.method} @@ -66,8 +66,10 @@ def from_raw_dict(cls, raw: "RawJsonRPCRequest") -> "JsonRPCRequest": # Check for the required fields for field in ['jsonrpc', 'method']: - if field not in raw: + if field not in raw and field != "jsonrpc": raise ValueError(f"Missing required field {field}") + elif field == "jsonrpc": + raw["jsonrpc"] = cls.jsonrpc # Create a JsonRPCRequest object from the raw dictionary jsonrpc = raw.get('jsonrpc') @@ -202,9 +204,44 @@ def from_raw_dict(cls, raw: "RawJsonRPCResponse") -> "JsonRPCResponse": return cls(jsonrpc=jsonrpc, id=id, result=result, error=error) def __eq__(self, other) -> bool: - return all([ - self.id == other.id, - self.jsonrpc == other.jsonrpc, - self.result == other.result, - self.error == other.error, - ]) + return all( + [ + self.id == other.id, + self.jsonrpc == other.jsonrpc, + self.result == other.result, + self.error == other.error, + ] + ) + + +def do_json_rpc_request( + data: Union[JsonRPCRequest, RawJsonRPCRequest], + endpoint: str = "http://localhost:26657", + headers: Dict[str, str] = {"Content-Type": "application/json"}, +) -> JsonRPCResponse: + return JsonRPCResponse.from_raw_dict( + raw=do_json_rpc_request_raw( + data=data, + endpoint=endpoint, + headers=headers, + ) + ) + + +def do_json_rpc_request_raw( + data: Union[JsonRPCRequest, RawJsonRPCRequest], + endpoint: str = "http://localhost:26657", + headers: Dict[str, str] = {"Content-Type": "application/json"}, +) -> RawJsonRPCResponse: + if isinstance(data, dict): + data = JsonRPCRequest.from_raw_dict(data) + elif isinstance(data, JsonRPCRequest): + ... + # elif TODO: feat: add a fn that checks the attrs at runtime to + # assemble a valid JsonRPCRequest even if the class type is not dict + # or JSONRPCRequest + + resp: requests.Response = requests.post( + url=endpoint, json=data.to_dict(), headers=headers + ) + return resp.json() diff --git a/nibiru/jsonrpc/jsonrpc_test.py b/nibiru/jsonrpc/jsonrpc_test.py index c142a375..c8f93fc6 100644 --- a/nibiru/jsonrpc/jsonrpc_test.py +++ b/nibiru/jsonrpc/jsonrpc_test.py @@ -1,10 +1,9 @@ import json -import pytest +from typing import Callable, Dict, Tuple, Union -from nibiru.jsonrpc import jsonrpc -from nibiru.jsonrpc import rpc_error +import pytest -from typing import Tuple, Dict, Callable, Union +from nibiru.jsonrpc import jsonrpc, rpc_error def mock_rpc_method_subtract(params): @@ -32,7 +31,7 @@ def handle_request(request_json: str) -> jsonrpc.JsonRPCResponse: return jsonrpc.JsonRPCResponse( error=rpc_error.InvalidRequestError(), result=None, - id=request_dict.get("id") + id=request_dict.get("id"), ) method: Union[Callable, None] = MOCK_METHODS.get(request.method) @@ -98,3 +97,27 @@ def test_rpc_calls(request_json: str, response_json: str): # Check with __eq__ method assert got_resp == want_resp + + +def test_rpc_block_query(): + """ + Runs the example query JSON-RPC query from the Tendermint documentation: + The following exampl + + ```bash + curl --header "Content-Type: application/json" \ + --request POST \ + --data '{"method": "block" , "params": ["5"], "id": 1}' \ + localhost:26657 + ``` + + Ref: https://docs.tendermint.com/v0.37/rpc/#/jsonrpc-http:~:text=block%3Fheight%3D5-,JSONRPC,-/HTTP + """ + + jsonrpc_resp: jsonrpc.JsonRPCResponse = jsonrpc.do_json_rpc_request( + data=dict(method="block", params=["5"], id=1), + ) + assert isinstance(jsonrpc_resp, jsonrpc.JsonRPCResponse) + assert jsonrpc_resp.error is None + assert jsonrpc_resp.result + assert jsonrpc.JsonRPCResponse.from_raw_dict(raw=jsonrpc_resp.to_dict()) diff --git a/nibiru/tm_rpc/broadcast.py b/nibiru/tm_rpc/broadcast.py index 62c85953..c071d335 100644 --- a/nibiru/tm_rpc/broadcast.py +++ b/nibiru/tm_rpc/broadcast.py @@ -1,20 +1,20 @@ import abc -import json import dataclasses +import json +from typing import Any, Callable, Dict, Optional, Tuple, Union + from nibiru.jsonrpc import jsonrpc -from typing import Dict, Callable, Any, Optional, Tuple, Union class TypedJsonRpcRequest(abc.ABC, jsonrpc.JsonRPCRequest): - @abc.abstractmethod - def create(cls, *args) -> jsonrpc.JsonRPCRequest: ... - # TODO docs + def create(cls, *args) -> jsonrpc.JsonRPCRequest: + """TODO docs""" @classmethod @abc.abstractmethod - def _validate_tm_rpc_request(*args): ... - # TODO docs + def _validate_tm_rpc_request(*args): + """TODO docs""" @dataclasses.dataclass @@ -26,19 +26,22 @@ class BroadcastTxSync(jsonrpc.JsonRPCRequest): parsed as a jsonrpc.JsonRPCRequest. """ - def __new__(cls, req: Union[jsonrpc.JsonRPCRequest, str, dict], - ) -> "BroadcastTxSync": + def __new__( + cls, + req: Union[jsonrpc.JsonRPCRequest, str, dict], + ) -> "BroadcastTxSync": if isinstance(req, jsonrpc.JsonRPCRequest): cls._validate_tm_rpc_request(json_rpc_req=req) return req elif isinstance(req, dict): - json_rpc_req: jsonrpc.JsonRPCRequest = \ - jsonrpc.JsonRPCRequest.from_raw_dict(raw=req) + json_rpc_req: jsonrpc.JsonRPCRequest = jsonrpc.JsonRPCRequest.from_raw_dict( + raw=req + ) return cls(req=json_rpc_req) elif isinstance(req, str): req_dict: dict = json.loads(req) - json_rpc_req: jsonrpc.JsonRPCRequest = ( - jsonrpc.JsonRPCRequest.from_raw_dict(raw=req_dict) + json_rpc_req: jsonrpc.JsonRPCRequest = jsonrpc.JsonRPCRequest.from_raw_dict( + raw=req_dict ) return cls(req=json_rpc_req) else: @@ -48,7 +51,8 @@ def __new__(cls, req: Union[jsonrpc.JsonRPCRequest, str, dict], ) def create( - tx_raw_bytes: bytes, id=None, + tx_raw_bytes: bytes, + id=None, ) -> jsonrpc.JsonRPCRequest: return jsonrpc.JsonRPCRequest( method="broadcast_tx_sync", diff --git a/nibiru/tm_rpc/broadcast_test.py b/nibiru/tm_rpc/broadcast_test.py index 1c31e3e2..de737de8 100644 --- a/nibiru/tm_rpc/broadcast_test.py +++ b/nibiru/tm_rpc/broadcast_test.py @@ -1,8 +1,9 @@ import dataclasses import json +from typing import List, Optional + import tests from nibiru.tm_rpc import broadcast -from typing import Optional, List # This class CANNOT include "Test" in its name because pytest will think it's @@ -16,15 +17,20 @@ class TC: cases: List[TC] = [ - TC(req_json="""{ + TC( + req_json="""{ "jsonrpc": "2.0", "id": 216135382217, "method": "broadcast_tx_sync", "params": { "tx": "CokBCoYBChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEmYKK25pYmkxemFhdnZ6eGV6MGVsdW5kdG4zMnFuazlsa204a21jc3o0NGc3eGwSK25pYmkxYTRyNnhnNHBnNmtkdWZ6cTUydGs1NXZqc3hmYWhjcmF1c2pkYWcaCgoFdW5pYmkSATESbgpQCkYKHy9jb3Ntb3MuY3J5cHRvLnNlY3AyNTZrMS5QdWJLZXkSIwohAvzwBOriY8sVwEXrXf1gXanhT9imlfWeUWLQ8pMxrRsgEgQKAggBGAQSGgoSCgV1bmliaRIJNTY4NzUwMDAwEIDnheBUGkA88j0Oylm+2KqdT/RcxRm28Xe4G8inlGWYRyUbYz+6PQKdQXy3/2UDJ73zCSSBSxNVIUZ5xjufM6oC+6kfWsd3" } -}""", happy=True, real_tx=True), - TC(req_json="""{ +}""", + happy=True, + real_tx=True, + ), + TC( + req_json="""{ "jsonrpc": "2.0", "id": 381619358564, "method": "broadcast_tx_sync", @@ -32,7 +38,10 @@ class TC: "tx": "CroDCmoKHi9uaWJpcnUucGVycC52Mi5Nc2dNYXJrZXRPcmRlchJICituaWJpMXphYXZ2enhlejBlbHVuZHRuMzJxbms5bGttOGttY3N6NDRnN3hsEgp1YnRjOnVudXNkGAEiBDEwMDAqAjEwMgEwCmYKHC9uaWJpcnUucGVycC52Mi5Nc2dBZGRNYXJnaW4SRgorbmliaTF6YWF2dnp4ZXowZWx1bmR0bjMycW5rOWxrbThrbWNzejQ0Zzd4bBIKdWJ0Yzp1bnVzZBoLCgV1bnVzZBICMjAKaAofL25pYmlydS5wZXJwLnYyLk1zZ1JlbW92ZU1hcmdpbhJFCituaWJpMXphYXZ2enhlejBlbHVuZHRuMzJxbms5bGttOGttY3N6NDRnN3hsEgp1YnRjOnVudXNkGgoKBXVudXNkEgE1CnoKHi9uaWJpcnUucGVycC52Mi5Nc2dNYXJrZXRPcmRlchJYCituaWJpMXphYXZ2enhlejBlbHVuZHRuMzJxbms5bGttOGttY3N6NDRnN3hsEgp1YnRjOnVudXNkGAIiAzIwMCoTNDAwMDAwMDAwMDAwMDAwMDAwMDIBMBJoClAKRgofL2Nvc21vcy5jcnlwdG8uc2VjcDI1NmsxLlB1YktleRIjCiEC/PAE6uJjyxXARetd/WBdqeFP2KaV9Z5RYtDykzGtGyASBAoCCAEYBRIUCg4KBXVuaWJpEgUxMDAwMBCAtRgaQIjSH+wlldDIfH4XjLPbTq8YIibaRSujNIba5UlpcpkZfQz7feVjtc8OxK+4PW3jGG75ZjJzDQ5ptQ//JbvpINM=" } } - """, happy=True, real_tx=True), + """, + happy=True, + real_tx=True, + ), TC( req_json=""" { "jsonrpc": "2.0", "id": 42, "method": "foobarbat", "params": {}}""", diff --git a/nibiru/tx.py b/nibiru/tx.py index f11752fc..7842bb6a 100644 --- a/nibiru/tx.py +++ b/nibiru/tx.py @@ -130,8 +130,7 @@ def execute_msgs( ) if address: address.decrease_sequence() - raise SimulationError( - f"Failed to simulate transaction: {err}") from err + raise SimulationError(f"Failed to simulate transaction: {err}") from err try: tx_resp: abci_type.TxResponse = self.execute_tx( @@ -467,14 +466,12 @@ def __generate_info(self, public_key: wallet.PublicKey = None) -> Tuple[str, str body_bytes = body.SerializeToString() mode_info = cosmos_tx_type.ModeInfo( - single=cosmos_tx_type.ModeInfo.Single( - mode=tx_sign.SIGN_MODE_DIRECT) + single=cosmos_tx_type.ModeInfo.Single(mode=tx_sign.SIGN_MODE_DIRECT) ) if public_key: any_public_key = any_pb2.Any() - any_public_key.Pack( - public_key.to_public_key_proto(), type_url_prefix="") + any_public_key.Pack(public_key.to_public_key_proto(), type_url_prefix="") signer_info = cosmos_tx_type.SignerInfo( mode_info=mode_info, sequence=self.sequence, public_key=any_public_key ) @@ -483,8 +480,7 @@ def __generate_info(self, public_key: wallet.PublicKey = None) -> Tuple[str, str mode_info=mode_info, sequence=self.sequence ) - auth_info = cosmos_tx_type.AuthInfo( - signer_infos=[signer_info], fee=self.fee) + auth_info = cosmos_tx_type.AuthInfo(signer_infos=[signer_info], fee=self.fee) auth_info_bytes = auth_info.SerializeToString() return body_bytes, auth_info_bytes From d7597ab6108141821c1a2798dae1141317ccb9bb Mon Sep 17 00:00:00 2001 From: Unique-Divine Date: Sun, 9 Jul 2023 01:00:01 -0500 Subject: [PATCH 5/5] epic(tmrpc,tx): All passing tests for v0.21! --- nibiru/jsonrpc/__init__.py | 7 ++ nibiru/jsonrpc/jsonrpc.py | 9 +- nibiru/jsonrpc/jsonrpc_test.py | 2 +- nibiru/pytypes/common.py | 3 +- nibiru/tmrpc/__init__.py | 4 + nibiru/{tm_rpc => tmrpc}/broadcast.py | 16 +++- nibiru/{tm_rpc => tmrpc}/broadcast_test.py | 38 ++++++++- nibiru/tx.py | 95 ++++++++++++++++------ tests/__init__.py | 46 ++++++++++- tests/bank_test.py | 20 +++-- tests/conftest.py | 32 +++----- tests/perp_test.py | 42 ++++++---- tests/spot_test.py | 8 +- tests/staking_test.py | 14 ++-- tests/utils_test.py | 2 +- tests/websocket_test.py | 2 +- 16 files changed, 248 insertions(+), 92 deletions(-) create mode 100644 nibiru/jsonrpc/__init__.py create mode 100644 nibiru/tmrpc/__init__.py rename nibiru/{tm_rpc => tmrpc}/broadcast.py (84%) rename nibiru/{tm_rpc => tmrpc}/broadcast_test.py (72%) diff --git a/nibiru/jsonrpc/__init__.py b/nibiru/jsonrpc/__init__.py new file mode 100644 index 00000000..d5fe08cb --- /dev/null +++ b/nibiru/jsonrpc/__init__.py @@ -0,0 +1,7 @@ +from nibiru.jsonrpc.jsonrpc import JsonRPCID # noqa +from nibiru.jsonrpc.jsonrpc import JsonRPCRequest # noqa +from nibiru.jsonrpc.jsonrpc import JsonRPCResponse # noqa +from nibiru.jsonrpc.jsonrpc import do_jsonrpc_request # noqa +from nibiru.jsonrpc.jsonrpc import do_jsonrpc_request_raw # noqa +from nibiru.jsonrpc.jsonrpc import json_rpc_request_keys # noqa +from nibiru.jsonrpc.jsonrpc import json_rpc_response_keys # noqa diff --git a/nibiru/jsonrpc/jsonrpc.py b/nibiru/jsonrpc/jsonrpc.py index 253bd871..ed786de2 100644 --- a/nibiru/jsonrpc/jsonrpc.py +++ b/nibiru/jsonrpc/jsonrpc.py @@ -213,14 +213,17 @@ def __eq__(self, other) -> bool: ] ) + def ok(self) -> bool: + return all([self.error is None, self.result is not None]) -def do_json_rpc_request( + +def do_jsonrpc_request( data: Union[JsonRPCRequest, RawJsonRPCRequest], endpoint: str = "http://localhost:26657", headers: Dict[str, str] = {"Content-Type": "application/json"}, ) -> JsonRPCResponse: return JsonRPCResponse.from_raw_dict( - raw=do_json_rpc_request_raw( + raw=do_jsonrpc_request_raw( data=data, endpoint=endpoint, headers=headers, @@ -228,7 +231,7 @@ def do_json_rpc_request( ) -def do_json_rpc_request_raw( +def do_jsonrpc_request_raw( data: Union[JsonRPCRequest, RawJsonRPCRequest], endpoint: str = "http://localhost:26657", headers: Dict[str, str] = {"Content-Type": "application/json"}, diff --git a/nibiru/jsonrpc/jsonrpc_test.py b/nibiru/jsonrpc/jsonrpc_test.py index c8f93fc6..e44556f4 100644 --- a/nibiru/jsonrpc/jsonrpc_test.py +++ b/nibiru/jsonrpc/jsonrpc_test.py @@ -114,7 +114,7 @@ def test_rpc_block_query(): Ref: https://docs.tendermint.com/v0.37/rpc/#/jsonrpc-http:~:text=block%3Fheight%3D5-,JSONRPC,-/HTTP """ - jsonrpc_resp: jsonrpc.JsonRPCResponse = jsonrpc.do_json_rpc_request( + jsonrpc_resp: jsonrpc.JsonRPCResponse = jsonrpc.do_jsonrpc_request( data=dict(method="block", params=["5"], id=1), ) assert isinstance(jsonrpc_resp, jsonrpc.JsonRPCResponse) diff --git a/nibiru/pytypes/common.py b/nibiru/pytypes/common.py index d490f2f5..c58d4491 100644 --- a/nibiru/pytypes/common.py +++ b/nibiru/pytypes/common.py @@ -3,8 +3,7 @@ from enum import Enum from typing import List -# -from nibiru_proto.cosmos.base.v1beta1 import coin_pb2 as cosmos_base_coin_pb +from nibiru_proto.cosmos.base.v1beta1 import coin_pb2 as cosmos_base_coin_pb # noqa from nibiru_proto.nibiru.spot.v1.pool_pb2 import PoolType # noqa import nibiru diff --git a/nibiru/tmrpc/__init__.py b/nibiru/tmrpc/__init__.py new file mode 100644 index 00000000..84e6985c --- /dev/null +++ b/nibiru/tmrpc/__init__.py @@ -0,0 +1,4 @@ +from nibiru.tmrpc.broadcast import CHAIN_JSON_RPC_METHODS # noqa +from nibiru.tmrpc.broadcast import BroadcastTxSync # noqa +from nibiru.tmrpc.broadcast import TypedJsonRpcRequest # noqa +from nibiru.tmrpc.broadcast import is_known_rpc_method # noqa diff --git a/nibiru/tm_rpc/broadcast.py b/nibiru/tmrpc/broadcast.py similarity index 84% rename from nibiru/tm_rpc/broadcast.py rename to nibiru/tmrpc/broadcast.py index c071d335..351d8c31 100644 --- a/nibiru/tm_rpc/broadcast.py +++ b/nibiru/tmrpc/broadcast.py @@ -1,4 +1,5 @@ import abc +import base64 import dataclasses import json from typing import Any, Callable, Dict, Optional, Tuple, Union @@ -51,12 +52,23 @@ def __new__( ) def create( - tx_raw_bytes: bytes, + tx_raw_bytes: Union[bytes, str], id=None, ) -> jsonrpc.JsonRPCRequest: + tx_raw: str + if isinstance(tx_raw_bytes, bytes): + tx_raw = base64.b64encode(tx_raw_bytes).decode() + elif isinstance(tx_raw_bytes, str): + tx_raw = base64.b64decode(tx_raw_bytes).decode() + else: + raise TypeError( + "expected 'tx_raw_bytes' of type Union[str, bytes]" + + f", got type {type(tx_raw_bytes)}" + ) + return jsonrpc.JsonRPCRequest( method="broadcast_tx_sync", - params=dict(tx=tx_raw_bytes), + params=dict(tx=tx_raw), id=id, ) diff --git a/nibiru/tm_rpc/broadcast_test.py b/nibiru/tmrpc/broadcast_test.py similarity index 72% rename from nibiru/tm_rpc/broadcast_test.py rename to nibiru/tmrpc/broadcast_test.py index de737de8..27c0420c 100644 --- a/nibiru/tm_rpc/broadcast_test.py +++ b/nibiru/tmrpc/broadcast_test.py @@ -3,7 +3,10 @@ from typing import List, Optional import tests -from nibiru.tm_rpc import broadcast +from nibiru import Transaction, pytypes +from nibiru.jsonrpc import jsonrpc +from nibiru.msg import bank +from nibiru.tmrpc import broadcast # This class CANNOT include "Test" in its name because pytest will think it's @@ -76,3 +79,36 @@ def test_init_BroadcastTxSync(): if tc.err_str: tests.raises(ok_errs=[tc.err_str], err=err) assert not tc.happy, "expected test case to pass" + + +def test_do_BroadcastTxSync(): + sdk_val = tests.fixture_sdk_val() + sdk_other = tests.fixture_sdk_other() + assert sdk_val.tx.ensure_address_info() + assert sdk_val.tx.ensure_tx_config() + tx: Transaction + tx, _ = sdk_val.tx.build_tx( + msgs=[ + bank.MsgsBank.send( + sdk_val.address, + sdk_other.address, + [pytypes.Coin(7, "unibi"), pytypes.Coin(70, "unusd")], + ), + bank.MsgsBank.send( + sdk_val.address, + sdk_other.address, + [pytypes.Coin(15, "unibi"), pytypes.Coin(23, "unusd")], + ), + ] + ) + tx_raw_bytes: bytes = tx.raw_bytes + + jsonrpc_req: jsonrpc.JsonRPCRequest = broadcast.BroadcastTxSync.create( + tx_raw_bytes=tx_raw_bytes, + id=420, + ) + + jsonrpc_resp: jsonrpc.JsonRPCResponse = jsonrpc.do_jsonrpc_request( + data=jsonrpc_req, + ) + assert jsonrpc_resp.ok() diff --git a/nibiru/tx.py b/nibiru/tx.py index 7842bb6a..b01a6df5 100644 --- a/nibiru/tx.py +++ b/nibiru/tx.py @@ -1,18 +1,20 @@ """ Classes: + ExecuteTxResp: TODO docs TxClient: A client for building, simulating, and broadcasting transactions. Transaction: Transactions trigger state changes based on messages. Each message must be cryptographically signed before being broadcasted to the network. """ -import json +import dataclasses import logging import pprint from numbers import Number -from typing import Any, Iterable, List, Optional, Tuple, Union +from typing import Iterable, List, Optional, Tuple, Union from google.protobuf import any_pb2, message -from google.protobuf.json_format import MessageToDict + +# from google.protobuf.json_format import MessageToDict from nibiru_proto.cosmos.base.abci.v1beta1 import abci_pb2 as abci_type from nibiru_proto.cosmos.base.v1beta1 import coin_pb2 as cosmos_base_coin_pb from nibiru_proto.cosmos.base.v1beta1.coin_pb2 import Coin @@ -20,13 +22,20 @@ from nibiru_proto.cosmos.tx.v1beta1 import service_pb2 as tx_service from nibiru_proto.cosmos.tx.v1beta1 import tx_pb2 as cosmos_tx_type -from nibiru import exceptions +from nibiru import exceptions, jsonrpc from nibiru import pytypes as pt -from nibiru import wallet +from nibiru import tmrpc, wallet from nibiru.exceptions import SimulationError, TxError from nibiru.grpc_client import GrpcClient +@dataclasses.dataclass +class ExecuteTxResp: + code: Optional[int] + tx_hash: Optional[str] + log: str + + class TxClient: """ A client for building, simulating, and broadcasting transactions. @@ -64,7 +73,7 @@ def execute_msgs( msgs: Union[pt.PythonMsg, List[pt.PythonMsg]], sequence: Optional[int] = None, tx_config: Optional[pt.TxConfig] = None, - ) -> pt.RawSyncTxResp: + ) -> ExecuteTxResp: """ Broadcasts messages to a node in a single transaction. This function first simulates the corresponding transaction to estimate the amount of @@ -133,23 +142,37 @@ def execute_msgs( raise SimulationError(f"Failed to simulate transaction: {err}") from err try: - tx_resp: abci_type.TxResponse = self.execute_tx( - tx, gas_estimate, tx_config=tx_config + jsonrcp_resp: jsonrpc.jsonrpc.JsonRPCResponse = self.execute_tx( + tx=tx, + gas_estimate=gas_estimate, + tx_config=tx_config, + use_tmrpc=True, ) - tx_resp: dict[str, Any] = MessageToDict(tx_resp) - tx_hash: Union[str, None] = tx_resp.get("txhash") - assert tx_hash, f"null txhash on tx_resp: {tx_resp}" - tx_output: tx_service.GetTxResponse = self.client.tx_by_hash( - tx_hash=tx_hash + execute_resp = ExecuteTxResp( + code=jsonrcp_resp.result.get("code"), + tx_hash=jsonrcp_resp.result.get("hash"), + log=jsonrcp_resp.result.get("log"), ) - - if tx_output.get("tx_response").get("code") != 0: + if execute_resp.code != 0: address.decrease_sequence() - raise TxError(tx_output.raw_log) - breakpoint() - - tx_output["rawLog"] = json.loads(tx_output.get("rawLog", "{}")) - return pt.RawSyncTxResp(tx_output) + raise TxError(execute_resp.log) + return execute_resp + # ------------------------------------------------ + # gRPC version: TODO - add back as feature. + # ------------------------------------------------ + # tx_resp: dict[str, Any] = MessageToDict(tx_resp) + # tx_hash: Union[str, None] = tx_resp.get("txhash") + # assert tx_hash, f"null txhash on tx_resp: {tx_resp}" + # tx_output: tx_service.GetTxResponse = self.client.tx_by_hash( + # tx_hash=tx_hash + # ) + # + # if tx_output.get("tx_response").get("code") != 0: + # address.decrease_sequence() + # raise TxError(tx_output.raw_log) + # + # tx_output["rawLog"] = json.loads(tx_output.get("rawLog", "{}")) + # return pt.RawSyncTxResp(tx_output) except exceptions.ErrorQueryTx as err: logging.info("ErrorQueryTx") logging.error(err) @@ -163,8 +186,9 @@ def execute_tx( self, tx: "Transaction", gas_estimate: float, + use_tmrpc: bool = True, tx_config: pt.TxConfig = None, - ) -> abci_type.TxResponse: + ) -> Union[jsonrpc.jsonrpc.JsonRPCResponse, abci_type.TxResponse]: conf: pt.TxConfig = self.ensure_tx_config(new_tx_config=tx_config) def compute_gas_wanted() -> float: @@ -195,11 +219,30 @@ def compute_gas_wanted() -> float: .with_memo("") .with_timeout_height(self.client.timeout_height) ) - tx_raw_bytes = tx.get_signed_tx_data() + tx_raw_bytes: bytes = tx.get_signed_tx_data() + + if use_tmrpc: + return self._broadcast_tx_jsonrpc( + tx_raw_bytes=tx_raw_bytes, + ) + else: + return self._broadcast_tx_grpc( + tx_raw_bytes=tx_raw_bytes, tx_type=conf.broadcast_mode + ) + + def _broadcast_tx_jsonrpc( + self, + tx_raw_bytes: bytes, + tx_type: pt.TxBroadcastMode = pt.TxBroadcastMode.SYNC, + ) -> jsonrpc.jsonrpc.JsonRPCResponse: - return self._broadcast_tx(tx_raw_bytes, conf.broadcast_mode) + jsonrpc_req: jsonrpc.JsonRPCRequest = tmrpc.BroadcastTxSync.create( + tx_raw_bytes=tx_raw_bytes, + id=420, + ) + return jsonrpc.jsonrpc.do_jsonrpc_request(data=jsonrpc_req) - def _broadcast_tx( + def _broadcast_tx_grpc( self, tx_raw_bytes: bytes, tx_type: pt.TxBroadcastMode = pt.TxBroadcastMode.SYNC, @@ -400,6 +443,10 @@ def __repr__(self) -> str: ) return pprint.pformat(self_as_dict, indent=2) + @property + def raw_bytes(self) -> bytes: + return self.get_signed_tx_data() + @staticmethod def __convert_msgs(msgs: List[message.Message]) -> List[any_pb2.Any]: any_msgs: List[any_pb2.Any] = [] diff --git a/tests/__init__.py b/tests/__init__.py index 6e90cd4f..edbda750 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,14 +1,17 @@ """Tests package for the Nibiru Python SDK""" import logging +import os import pprint from typing import Iterable, List, Union import shutup -from nibiru import utils +import nibiru +from nibiru import pytypes, tx, utils shutup.please() + LOGGER: logging.Logger = logging.getLogger("test-logger") @@ -92,6 +95,20 @@ def transaction_must_succeed(tx_output: dict): assert isinstance(tx_output["rawLog"], list) +def broadcast_tx_must_succeed(res: tx.ExecuteTxResp): + """ + Ensure the output of a transaction have the fields required + and that the raw logs are properly parsed + + Args: + tx_output (dict): The output of a transaction in a dictionary + """ + + assert isinstance(res, tx.ExecuteTxResp) + assert res.code == 0 + assert res.tx_hash + + def raw_sync_tx_must_succeed(tx_output: dict): """ Ensure the output of a transaction have the fields required @@ -105,3 +122,30 @@ def raw_sync_tx_must_succeed(tx_output: dict): expected_keys = ["txhash", "rawLog"] dict_keys_must_match(tx_output, expected_keys) assert isinstance(tx_output["rawLog"], list) + + +TX_CONFIG_TEST: pytypes.TxConfig = pytypes.TxConfig( + broadcast_mode=pytypes.TxBroadcastMode.SYNC, + gas_multiplier=1.25, + gas_price=0.25, +) + + +def fixture_network() -> nibiru.Network: + return nibiru.Network.customnet() + + +def fixture_sdk_val() -> nibiru.Sdk: + return ( + nibiru.Sdk.authorize(key=os.getenv("VALIDATOR_MNEMONIC")) + .with_config(TX_CONFIG_TEST) + .with_network(fixture_network()) + ) + + +def fixture_sdk_other() -> nibiru.Sdk: + return ( + nibiru.Sdk.authorize() + .with_config(TX_CONFIG_TEST) + .with_network(fixture_network()) + ) diff --git a/tests/bank_test.py b/tests/bank_test.py index f0ae03de..9008bc49 100644 --- a/tests/bank_test.py +++ b/tests/bank_test.py @@ -26,10 +26,12 @@ def test_send_multiple_msgs(sdk_val: nibiru.Sdk, sdk_agent: nibiru.Sdk): ], ) - tests.LOGGER.info( - "nibid tx bank send - multiple msgs:\n" + tests.format_response(tx_output) - ) - tests.raw_sync_tx_must_succeed(tx_output) + # TODO deprecated + # tests.LOGGER.info( + # "nibid tx bank send - multiple msgs:\n" + + # tests.format_response(tx_output) + # ) + tests.broadcast_tx_must_succeed(tx_output) def test_send_single_msg(sdk_val: nibiru.Sdk, sdk_agent: nibiru.Sdk): @@ -47,7 +49,9 @@ def test_send_single_msg(sdk_val: nibiru.Sdk, sdk_agent: nibiru.Sdk): ] ) - tests.LOGGER.info( - "nibid tx bank send - single msgs:\n" + tests.format_response(tx_output) - ) - tests.raw_sync_tx_must_succeed(tx_output) + # TODO deprecated + # tests.LOGGER.info( + # "nibid tx bank send - single msgs:\n" + + # tests.format_response(tx_output) + # ) + tests.broadcast_tx_must_succeed(tx_output) diff --git a/tests/conftest.py b/tests/conftest.py index 1cf3b050..5c5f8f4e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,8 +16,8 @@ import dotenv import pytest +import tests from nibiru import Network, NetworkType, Sdk -from nibiru.pytypes import TxBroadcastMode, TxConfig def pytest_configure(config): @@ -80,8 +80,7 @@ def set_optional_globals(self): @pytest.fixture def network() -> Network: - chain: Network = Network.customnet() - return chain + return tests.fixture_network() # TODO test: Restore functionalty for settings the tests to run against ITN # or devnets for v0.21+ @@ -103,25 +102,20 @@ def network() -> Network: return chain -TX_CONFIG_TEST: TxConfig = TxConfig( - broadcast_mode=TxBroadcastMode.SYNC, - gas_multiplier=1.25, - gas_price=0.25, -) - - @pytest.fixture def sdk_val(network: Network) -> Sdk: - tx_config = TX_CONFIG_TEST - return ( - Sdk.authorize(pytest.VALIDATOR_MNEMONIC) - .with_config(tx_config) - .with_network(network) - ) + return tests.fixture_sdk_val() + # tx_config = tests.TX_CONFIG_TEST + # return ( + # Sdk.authorize(pytest.VALIDATOR_MNEMONIC) + # .with_config(tx_config) + # .with_network(network) + # ) @pytest.fixture def sdk_agent(network: Network) -> Sdk: - tx_config = TX_CONFIG_TEST - agent = Sdk.authorize().with_config(tx_config).with_network(network) - return agent + return tests.fixture_sdk_other() + # tx_config = tests.TX_CONFIG_TEST + # agent = Sdk.authorize().with_config(tx_config).with_network(network) + # return agent diff --git a/tests/perp_test.py b/tests/perp_test.py index b21daa8e..be412e61 100644 --- a/tests/perp_test.py +++ b/tests/perp_test.py @@ -33,11 +33,12 @@ def test_open_position(sdk_val: nibiru.Sdk): base_asset_amount_limit=0, ) ) - tests.LOGGER.info( - f"nibid tx perp open-position: {tests.format_response(tx_output)}" - ) - tests.raw_sync_tx_must_succeed(tx_output) + tests.broadcast_tx_must_succeed(tx_output) + # TODO deprecated + # tests.LOGGER.info( + # f"nibid tx perp open-position: {tests.format_response(tx_output)}" + # ) # tx_resp = pt.TxResp.from_raw(pt.RawTxResp(tx_output)) # assert "/nibiru.perp.v2.MsgMarketOrder" in tx_resp.rawLog[0].msgs # events_for_msg: List[str] = [ @@ -67,9 +68,10 @@ def test_perp_query_position(sdk_val: nibiru.Sdk): "margin_ratio", ], ) - tests.LOGGER.info( - f"nibid query perp trader-position: \n{tests.format_response(position_res)}" - ) + # TODO deprecated + # tests.LOGGER.info( + # f"nibid query perp trader-position: \n{tests.format_response(position_res)}" + # ) position = position_res["position"] assert position["margin"] assert position["open_notional"] @@ -113,9 +115,11 @@ def test_perp_add_margin(sdk_val: nibiru.Sdk): margin=pt.Coin(10, "unusd"), ), ) - tests.LOGGER.info( - f"nibid tx perp add-margin: \n{tests.format_response(tx_output)}" - ) + tests.broadcast_tx_must_succeed(res=tx_output) + # TODO deprecated + # tests.LOGGER.info( + # f"nibid tx perp add-margin: \n{tests.format_response(tx_output)}" + # ) except BaseException as err: ok_errors: List[str] = [ERRORS.collections_not_found, ERRORS.bad_debt] tests.raises(ok_errors, err) @@ -133,10 +137,11 @@ def test_perp_remove_margin(sdk_val: nibiru.Sdk): margin=pt.Coin(5, "unusd"), ) ) - tests.LOGGER.info( - f"nibid tx perp remove-margin: \n{tests.format_response(tx_output)}" - ) - tests.raw_sync_tx_must_succeed(tx_output) + # TODO deprecated + # tests.LOGGER.info( + # f"nibid tx perp remove-margin: \n{tests.format_response(tx_output)}" + # ) + tests.broadcast_tx_must_succeed(tx_output) # TODO test: verify the margin changes using the events except BaseException as err: ok_errors: List[str] = [ERRORS.collections_not_found, ERRORS.bad_debt] @@ -154,10 +159,11 @@ def test_perp_close_posititon(sdk_val: nibiru.Sdk): tx_output = sdk_val.tx.execute_msgs( Msg.perp.close_position(sender=sdk_val.address, pair=PAIR) ) - tests.LOGGER.info( - f"nibid tx perp close-position: \n{tests.format_response(tx_output)}" - ) - tests.raw_sync_tx_must_succeed(tx_output) + # TODO deprecated + # tests.LOGGER.info( + # f"nibid tx perp close-position: \n{tests.format_response(tx_output)}" + # ) + tests.broadcast_tx_must_succeed(tx_output) out = sdk_val.query.perp.position(trader=sdk_val.address, pair=PAIR) # Querying the position should raise an exception if it closed diff --git a/tests/spot_test.py b/tests/spot_test.py index 628f8cf1..ff52860f 100644 --- a/tests/spot_test.py +++ b/tests/spot_test.py @@ -40,7 +40,7 @@ def test_spot_create_pool(sdk_val: nibiru.Sdk): ) ) - tests.raw_sync_tx_must_succeed(tx_output) + tests.broadcast_tx_must_succeed(tx_output) except SimulationError as simulation_error: tests.raises( [SpotErrors.same_denom, SpotErrors.insufficient_funds], simulation_error @@ -143,7 +143,7 @@ def test_spot_join_pool(sdk_val: nibiru.Sdk, pool_ids: Dict[str, int]): ), ] ) - tests.raw_sync_tx_must_succeed(tx_output) + tests.broadcast_tx_must_succeed(tx_output) except BaseException as err: tests.raises(SpotErrors.no_pool_shares, err) @@ -176,7 +176,7 @@ def test_spot_swap(sdk_val: nibiru.Sdk, pool_ids: Dict[str, int]): ), ] ) - tests.raw_sync_tx_must_succeed(tx_output) + tests.broadcast_tx_must_succeed(tx_output) except BaseException as err: tests.raises(SpotErrors.swap_low_unusd_in_pool, err) @@ -199,7 +199,7 @@ def test_spot_exit_pool(sdk_val: nibiru.Sdk): for pool_token in pool_tokens ] ) - tests.raw_sync_tx_must_succeed(tx_output) + tests.broadcast_tx_must_succeed(tx_output) else: tests.LOGGER.info( "skipped test for 'nibid tx spot exit-pool' because\n" diff --git a/tests/staking_test.py b/tests/staking_test.py index 5286c564..7f534ce3 100644 --- a/tests/staking_test.py +++ b/tests/staking_test.py @@ -5,7 +5,7 @@ from nibiru.event_specs import EventCaptured, EventType from nibiru.exceptions import QueryError, SimulationError from nibiru.websocket import NibiruWebsocket -from tests import dict_keys_must_match, raw_sync_tx_must_succeed +from tests import broadcast_tx_must_succeed, dict_keys_must_match def get_validator_operator_address(sdk_val: Sdk): @@ -47,7 +47,7 @@ def test_query_vpool(sdk_val: Sdk): def test_query_delegation(sdk_val: Sdk): - raw_sync_tx_must_succeed(delegate(sdk_val)) + broadcast_tx_must_succeed(delegate(sdk_val)) query_resp = sdk_val.query.staking.delegation( sdk_val.address, get_validator_operator_address(sdk_val) ) @@ -61,7 +61,7 @@ def test_query_delegation(sdk_val: Sdk): def test_query_delegations(sdk_val: Sdk): - raw_sync_tx_must_succeed(delegate(sdk_val)) + broadcast_tx_must_succeed(delegate(sdk_val)) query_resp = sdk_val.query.staking.delegations(sdk_val.address) dict_keys_must_match( query_resp["delegation_responses"][0], @@ -73,7 +73,7 @@ def test_query_delegations(sdk_val: Sdk): def test_query_delegations_to(sdk_val: Sdk): - raw_sync_tx_must_succeed(delegate(sdk_val)) + broadcast_tx_must_succeed(delegate(sdk_val)) query_resp = sdk_val.query.staking.delegations_to( get_validator_operator_address(sdk_val) ) @@ -135,7 +135,7 @@ def test_redelegations(sdk_val: Sdk): def test_unbonding_delegation(sdk_val: Sdk): - raw_sync_tx_must_succeed(delegate(sdk_val)) + broadcast_tx_must_succeed(delegate(sdk_val)) try: undelegate(sdk_val) query_resp = sdk_val.query.staking.unbonding_delegation( @@ -155,7 +155,7 @@ def test_unbonding_delegation(sdk_val: Sdk): def test_unbonding_delegations(sdk_val: Sdk): - raw_sync_tx_must_succeed(delegate(sdk_val)) + broadcast_tx_must_succeed(delegate(sdk_val)) try: undelegate(sdk_val) except SimulationError as ex: @@ -173,7 +173,7 @@ def test_unbonding_delegations(sdk_val: Sdk): def test_unbonding_delegations_from(sdk_val: Sdk): - raw_sync_tx_must_succeed(delegate(sdk_val)) + broadcast_tx_must_succeed(delegate(sdk_val)) try: undelegate(sdk_val) except SimulationError as ex: diff --git a/tests/utils_test.py b/tests/utils_test.py index 1bb4de8c..33f2b78b 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -93,7 +93,7 @@ def test_get_block_messages(sdk_val: nibiru.Sdk, sdk_agent: nibiru.Sdk): [Coin(10000, "unibi"), Coin(100, "unusd")], ) ) - tests.raw_sync_tx_must_succeed(out) + tests.broadcast_tx_must_succeed(out) # tx_output = sdk_val.query.tx_by_hash(tx_hash=out["txhash"]) # height = int(tx_output["height"]) diff --git a/tests/websocket_test.py b/tests/websocket_test.py index 280f76e5..32a1f455 100644 --- a/tests/websocket_test.py +++ b/tests/websocket_test.py @@ -124,7 +124,7 @@ def test_websocket_tx_fail_queue(sdk_val: Sdk, network: Network): .with_chain_id(network.chain_id) .with_signer(sdk_val.tx.priv_key) ) - sdk_val.tx.execute_tx(tx, 300000) + sdk_val.tx.execute_tx_grpc(tx, 300000) time.sleep(3)