Skip to content

Commit

Permalink
Tx generic signing & Tx module (the first take) (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
pbukva committed Jul 21, 2021
1 parent adab448 commit cc4698f
Show file tree
Hide file tree
Showing 15 changed files with 333 additions and 70 deletions.
16 changes: 8 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,31 +32,31 @@ SOURCE := $(RELATIVE_SOURCE:%=$(COSMOS_SDK_DIR)/%)
GENERATED := $(UNROOTED_SOURCE:%.proto=$(OUTPUT_FOLDER)/%.py)
PROTO_ROOT_DIRS := $(COSMOS_PROTO_RELATIVE_DIRS:%=$(COSMOS_SDK_DIR)/%)

GENERATED_DIRS := $(call unique,$(patsubst %/,$(OUTPUT_FOLDER)/%,$(UNROOTED_SOURCE)))
INIT_PY_FILES_TO_CREATE := $(GENERATED:%=%/__init__.py)
GENERATED_DIRS := $(call unique,$(foreach _,$(UNROOTED_SOURCE),$(dir $(_))))
INIT_PY_FILES_TO_CREATE := $(GENERATED_DIRS:%=$(OUTPUT_FOLDER)/%__init__.py)

COMPILE_PROTOBUFS_COMMAND := python -m grpc_tools.protoc $(PROTO_ROOT_DIRS:%=--proto_path=%) --python_out=$(OUTPUT_FOLDER) --grpc_python_out=$(OUTPUT_FOLDER) $(UNROOTED_SOURCE)


generate_proto_types: $(SOURCE) $(COSMOS_SDK_DIR)
generate_proto_types: $(COSMOS_SDK_DIR)
$(COMPILE_PROTOBUFS_COMMAND)

fetch_proto_schema_source: $(COSMOS_SDK_DIR)

generate_init_py_files: $(INIT_PY_FILES_TO_CREATE)

$(SOURCE)&: $(COSMOS_SDK_DIR)
$(SOURCE): $(COSMOS_SDK_DIR)

$(GENERATED)&: $(SOURCE)
$(GENERATED): $(SOURCE)
$(COMPILE_PROTOBUFS_COMMAND)

$(INIT_PY_FILES_TO_CREATE)&: $(GENERATED_DIRS)
touch $(INIT_PY_FILES_TO_CREATE)

$(GENERATED_DIRS)&: $(COSMOS_SDK_DIR)
$(GENERATED_DIRS): $(COSMOS_SDK_DIR)

$(COSMOS_SDK_DIR):
rm -rf $(COSMOS_SDK_DIR)
$(COSMOS_SDK_DIR): Makefile
rm -rfv $(COSMOS_SDK_DIR)
git clone --branch $(COSMOS_SDK_VERSION) --depth 1 --quiet --no-checkout --filter=blob:none https://github.com/fetchai/cosmos-sdk $(COSMOS_SDK_DIR)
cd $(COSMOS_SDK_DIR) && git checkout $(COSMOS_SDK_VERSION) -- $(COSMOS_PROTO_RELATIVE_DIRS)

Expand Down
9 changes: 3 additions & 6 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,12 @@ url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]
flake8 = "*"
black = "*"
mypy = "*"

[packages]
cosm = {editable = true, extras = ["dev", "test"], path = "."}
protobuf = "3.15.5"
grpcio = "1.38.1"
grpcio-tools = "1.38.1"

[requires]
python_version = "3.9"

[pipenv]
allow_prereleases = true
10 changes: 8 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,15 @@
package_dir={'': 'src'},
packages=find_packages(where='src'),
python_requires='>=3.6, <4',
install_requires=['ecdsa', 'bech32', 'requests', 'google-api-python-client','mock','types-mock'],
install_requires=['ecdsa',
'bech32',
'requests',
'google-api-python-client',
'protobuf',
'grpcio',
'grpcio-tools'],
extras_require={
'dev': ['check-manifest'],
'dev': ['check-manifest', 'flake8', 'black', 'mypy'],
'test': ['coverage', 'pytest'],
},
project_urls={
Expand Down
2 changes: 1 addition & 1 deletion src/cosm/auth/rest_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from google.protobuf.json_format import Parse

import cosmos.crypto.secp256k1.keys_pb2 # noqa
from cosm.query.rest_client import QueryRestClient

from cosmos.auth.v1beta1.query_pb2 import (
Expand Down
9 changes: 7 additions & 2 deletions src/cosm/crypto/address.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ def __init__(
value: Union[str, bytes, PublicKey, "Address"],
prefix: Optional[str] = None,
):
if prefix is None:
prefix = DEFAULT_PREFIX

if isinstance(value, str):
_, data_base5 = bech32.bech32_decode(value)
if data_base5 is None:
Expand All @@ -37,15 +40,17 @@ def __init__(
raise RuntimeError("Incorrect address length")

self._address = value
self._display = _to_bech32(prefix or DEFAULT_PREFIX, self._address)
self._display = _to_bech32(prefix, self._address)

elif isinstance(value, PublicKey):
self._address = ripemd160(sha256(value.public_key_bytes))
self._display = _to_bech32(prefix or DEFAULT_PREFIX, self._address)
self._display = _to_bech32(prefix, self._address)

elif isinstance(value, Address):
self._address = value._address
self._display = value._display
else:
raise TypeError("Unexpected type of `value` parameter")

def __str__(self):
return self._display
Expand Down
15 changes: 15 additions & 0 deletions src/cosm/crypto/interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from abc import ABC, abstractmethod


class Signer(ABC):
@abstractmethod
def sign(
self, message: bytes, deterministic=False, canonicalise: bool = True
) -> bytes:
...

@abstractmethod
def sign_digest(
self, digest: bytes, deterministic=False, canonicalise: bool = True
) -> bytes:
...
3 changes: 2 additions & 1 deletion src/cosm/crypto/keypairs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import ecdsa
from ecdsa.curves import Curve
from ecdsa.util import sigencode_string_canonize, sigencode_string
from cosm.crypto.interface import Signer


class PublicKey:
Expand Down Expand Up @@ -60,7 +61,7 @@ def verify_digest(self, digest: bytes, signature: bytes):
return success


class PrivateKey(PublicKey):
class PrivateKey(PublicKey, Signer):
def __init__(self, private_key: Optional[bytes] = None):
if private_key is None:
self._signing_key = ecdsa.SigningKey.generate(
Expand Down
37 changes: 20 additions & 17 deletions src/cosm/query/rest_client.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
import urllib.error
import urllib.request
import requests


class QueryRestClient:
def __init__(self, rest_address: str):
self._session = requests.session()
self.rest_address = rest_address

def get(self, request: str) -> str:
url = self.rest_address + request

try:
with urllib.request.urlopen(url) as f:
response = f.read().decode("utf-8")
return response
except urllib.error.HTTPError as e:
raise RuntimeError(
f"HTTPError when sending a get request.\n Request: {request}\n Response: {e.code}, {str(e.read().decode('utf-8'))})"
)
except urllib.error.URLError as e:
def get(self, request: str) -> bytes:
response = self._session.get(url=self.rest_address + request)
if response.status_code != 200:
raise RuntimeError(
f"URLError when sending a get request.\n Request: {request}, Exception: {e})"
f"Error when sending a query request.\n Request: {request}\n Response: {response.status_code}, {str(response.content)})"
)
except Exception as e:
return response.content

def post(self, url_path, json_request: dict) -> bytes:
headers = {"Content-type": "application/json", "Accept": "application/json"}
response = self._session.post(
url=self.rest_address + url_path, json=json_request, headers=headers
)

if response.status_code != 200:
raise RuntimeError(
f"Exception during sending a get request.\n Request: {request}, Exception: {e})"
f"Error when sending a query request.\n Request: {json_request}\n Response: {response.status_code}, {str(response.content)})"
)
return response.content

def __del__(self):
self._session.close()
61 changes: 40 additions & 21 deletions src/cosm/query/tests.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,45 @@
from cosm.query.rest_client import QueryRestClient
from unittest import TestCase
import mock
from unittest.mock import Mock, patch
from requests import Session, Response


class QueryTests(TestCase):
@mock.patch("urllib.request.urlopen", autospec=True)
def test_get(self, mock_urlopen):
client = QueryRestClient("address")
client.get("/request")

self.assertEqual(mock_urlopen.call_args_list[0].args, ("address/request",))

def test_get_url_error(self):
client = QueryRestClient("http:https://127.0.0.1")
try:
client.get("/request")
except RuntimeError as e:
assert "URLError" in str(e)

def test_get_value_error(self):
client = QueryRestClient("address")
try:
client.get("/request")
except RuntimeError as e:
assert "unknown url type" in str(e)
@patch("requests.session", spec=Session)
def test_get_pass(self, session_mock):
rest_address = "some url"
client = QueryRestClient(rest_address)

session_mock.assert_called_once_with()
assert client.rest_address == rest_address

request_url_path = "my weird url path"
resp = Mock(spec=Response)
resp.status_code = 200
resp.content = "dfdffdss".encode(encoding="utf8")

session_mock.return_value.get.return_value = resp
client.get(request_url_path)
session_mock.return_value.get.assert_called_once_with(
url=rest_address + request_url_path
)

@patch("requests.session", spec=Session)
def test_get_error(self, session_mock):
rest_address = "some url"
client = QueryRestClient(rest_address)

session_mock.assert_called_once_with()
assert client.rest_address == rest_address

request_url_path = "my weird url path"
resp = Mock(spec=Response)
resp.status_code = 400
resp.content = "dfdffdss".encode(encoding="utf8")

session_mock.return_value.get.return_value = resp

with self.assertRaises(Exception) as context:
client.get(request_url_path)

self.assertTrue("Error when sending a query request" in str(context.exception))
4 changes: 4 additions & 0 deletions src/cosm/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ ignore_missing_imports = True

[mypy-cosm.*]
ignore_missing_imports = True


[mypy-grpc.*]
ignore_missing_imports = True
3 changes: 3 additions & 0 deletions src/cosm/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ def get(self, request: str) -> str:
"""Handle GET request."""
self.last_request = request
return self.content

def close(self):
pass
24 changes: 24 additions & 0 deletions src/cosm/tx/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from cosm.crypto.interface import Signer
from cosmos.tx.v1beta1.tx_pb2 import Tx, SignDoc


def sign_transaction(
tx: Tx,
signer: Signer,
chain_id: str,
account_number: int,
deterministic: bool = False,
):
sd = SignDoc()
sd.body_bytes = tx.body.SerializeToString()
sd.auth_info_bytes = tx.auth_info.SerializeToString()
sd.chain_id = chain_id
sd.account_number = account_number

data_for_signing = sd.SerializeToString()

# Generating deterministic signature:
signature = signer.sign(
data_for_signing, deterministic=deterministic, canonicalise=True
)
tx.signatures.extend([signature])
24 changes: 24 additions & 0 deletions src/cosm/tx/interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from abc import ABC, abstractmethod
import cosmos.tx.v1beta1.service_pb2 as svc


class RPCInterface(ABC):
# Simulate simulates executing a transaction for estimating gas usage.
@abstractmethod
def Simulate(self, request: svc.SimulateRequest) -> svc.SimulateResponse:
...

# GetTx fetches a tx by hash.
@abstractmethod
def GetTx(self, request: svc.GetTxRequest) -> svc.GetTxResponse:
...

# BroadcastTx broadcast transaction.
@abstractmethod
def BroadcastTx(self, request: svc.BroadcastTxRequest) -> svc.BroadcastTxResponse:
...

# GetTxsEvent fetches txs by event.
@abstractmethod
def GetTxsEvent(self, request: svc.GetTxsEventRequest) -> svc.GetTxsEventResponse:
...
50 changes: 50 additions & 0 deletions src/cosm/tx/rest_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from cosm.query.rest_client import QueryRestClient as RestClient
from cosm.tx.interface import RPCInterface

from cosmos.tx.v1beta1.service_pb2 import (
SimulateRequest,
SimulateResponse,
GetTxRequest,
GetTxResponse,
BroadcastTxRequest,
BroadcastTxResponse,
GetTxsEventRequest,
GetTxsEventResponse,
)
from google.protobuf.json_format import MessageToDict, Parse
from urllib.parse import urlencode


class TxRestClient(RPCInterface):
txs_url_path = "/cosmos/tx/v1beta1/txs"

def __init__(self, rest_client: RestClient):
self.rest_client = rest_client

# Simulate simulates executing a transaction for estimating gas usage.
def Simulate(self, request: SimulateRequest) -> SimulateResponse:
json_request = MessageToDict(request)
response = self.rest_client.post("/cosmos/tx/v1beta1/simulate", json_request)
return Parse(response, SimulateResponse())

# GetTx fetches a tx by hash.
def GetTx(self, request: GetTxRequest) -> GetTxResponse:
json_request = MessageToDict(request)
url_encoded_request = urlencode(json_request)
response = self.rest_client.get(
f"{self.txs_url_path}&{url_encoded_request}",
)
return Parse(response, GetTxResponse())

# BroadcastTx broadcast transaction.
def BroadcastTx(self, request: BroadcastTxRequest) -> BroadcastTxResponse:
json_request = MessageToDict(request)
response = self.rest_client.post(self.txs_url_path, json_request)
return Parse(response, BroadcastTxResponse())

# GetTxsEvent fetches txs by event.
def GetTxsEvent(self, request: GetTxsEventRequest) -> GetTxsEventResponse:
json_request = MessageToDict(request)
url_encoded_request = urlencode(json_request)
response = self.rest_client.get(f"{self.txs_url_path}&{url_encoded_request}")
return Parse(response, GetTxsEventResponse())
Loading

0 comments on commit cc4698f

Please sign in to comment.