Skip to content

Commit

Permalink
Aerial High Level API (#96)
Browse files Browse the repository at this point in the history
  • Loading branch information
ejfitzgerald authored Mar 11, 2022
1 parent 9b108fd commit 3cb23c5
Show file tree
Hide file tree
Showing 63 changed files with 2,353 additions and 4,361 deletions.
6 changes: 2 additions & 4 deletions .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,8 @@ jobs:
run: tox -e safety
- name: Static type check
run: tox -e mypy
- name: Pylint check
run: tox -e pylint
- name: Unused code check
run: tox -e vulture
# - name: Pylint check
# run: tox -e pylint
- name: License check
run: tox -e liccheck
- name: Copyright check
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
*.egg-info/
./build/
dist/
build/

# IDEs
.idea/

# Tox files
.tox
Pipfile.lock

.DS_Store
*/.DS_Store
Expand Down
1,381 changes: 0 additions & 1,381 deletions Pipfile.lock

This file was deleted.

22 changes: 9 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,28 +53,24 @@ To install the project use:

Below is a simple example for querying an account's balance and sending funds from one account to another using `RestClient`:

from cosmpy.clients.signing_cosmwasm_client import SigningCosmWasmClient
from cosmpy.common.rest_client import RestClient
from cosmpy.crypto.address import Address
from cosmpy.crypto.keypairs import PrivateKey
from cosmpy.protos.cosmos.base.v1beta1.coin_pb2 import Coin
from cosmpy.clients.ledger import CosmosLedger
from cosmpy.clients.crypto import CosmosCrypto, Coin

# Data
rest_endpoint_address = "http://the_rest_endpoint"
alice_private_key = PrivateKey(bytes.fromhex("<private_key_in_hex_format>"))
rest_node_address = "http://the_rest_endpoint"
alice_crypto = CosmosCrypto(private_key_str="<private_key_in_hex_format>"))
chain_id = "some_chain_id"
denom = "some_denomination"
bob_address = Address("some_address")
bob_address = "some_address"

channel = RestClient(rest_endpoint_address)
client = SigningCosmWasmClient(private_key, channel, chain_id)
ledger = CosmosLedger(chain_id=chain_id, rest_node_address=rest_endpoint_addres)

# Query Alice's Balance
res = client.get_balance(client.address, denom)
print(f"Alice's Balance: {res.balance.amount} {res.balance.denom}")
res = ledger.get_balance(alice_crypto.get_address(), denom)
print(f"Alice's Balance: {res} {denom}")

# Send 1 <denom> from Alice to Bob
client.send_tokens(bob_address, [Coin(amount="1", denom=denom)])
ledger.send_tokens(alice_crypto, bob_address, [Coin(amount="1", denom=denom)])

## Documentation

Expand Down
Binary file added contracts/simple.wasm
Binary file not shown.
2 changes: 0 additions & 2 deletions tests/integration/__init__.py → cosmpy/aerial/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,3 @@
# limitations under the License.
#
# ------------------------------------------------------------------------------

"""Module with PyCosm integration tests."""
281 changes: 281 additions & 0 deletions cosmpy/aerial/client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
# -*- coding: utf-8 -*-
# ------------------------------------------------------------------------------
#
# Copyright 2018-2021 Fetch.AI Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# ------------------------------------------------------------------------------

import re
import time
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional

import certifi
import grpc

from cosmpy.aerial.client.bank import create_bank_send_msg
from cosmpy.aerial.config import NetworkConfig
from cosmpy.aerial.exceptions import (
BroadcastError,
InsufficientFeesError,
NotFoundError,
OutOfGasError,
QueryTimeoutError,
)
from cosmpy.aerial.tx import SigningCfg, Transaction
from cosmpy.aerial.tx_helpers import MessageLog, SubmittedTx, TxResponse
from cosmpy.aerial.urls import Protocol, parse_url
from cosmpy.aerial.wallet import Wallet
from cosmpy.auth.rest_client import AuthRestClient
from cosmpy.bank.rest_client import BankRestClient
from cosmpy.common.rest_client import RestClient
from cosmpy.cosmwasm.rest_client import CosmWasmRestClient
from cosmpy.crypto.address import Address
from cosmpy.protos.cosmos.auth.v1beta1.auth_pb2 import BaseAccount
from cosmpy.protos.cosmos.auth.v1beta1.query_pb2 import QueryAccountRequest
from cosmpy.protos.cosmos.auth.v1beta1.query_pb2_grpc import QueryStub as AuthGrpcClient
from cosmpy.protos.cosmos.bank.v1beta1.query_pb2 import QueryBalanceRequest
from cosmpy.protos.cosmos.bank.v1beta1.query_pb2_grpc import QueryStub as BankGrpcClient
from cosmpy.protos.cosmos.staking.v1beta1.query_pb2_grpc import (
QueryStub as StakingGrpcClient,
)
from cosmpy.protos.cosmos.tx.v1beta1.service_pb2 import (
BroadcastMode,
BroadcastTxRequest,
GetTxRequest,
)
from cosmpy.protos.cosmos.tx.v1beta1.service_pb2_grpc import ServiceStub as TxGrpcClient
from cosmpy.protos.cosmwasm.wasm.v1.query_pb2_grpc import (
QueryStub as CosmWasmGrpcClient,
)
from cosmpy.staking.rest_client import StakingRestClient
from cosmpy.tx.rest_client import TxRestClient

DEFAULT_QUERY_TIMEOUT_SECS = 15
DEFAULT_QUERY_INTERVAL_SECS = 2


@dataclass
class Account:
address: Address
number: int
sequence: int


class LedgerClient:
def __init__(self, cfg: NetworkConfig):
cfg.validate()

self._network_config = cfg

parsed_url = parse_url(cfg.url)

if parsed_url.protocol == Protocol.GRPC:
if parsed_url.secure:
with open(certifi.where(), "rb") as f:
trusted_certs = f.read()
credentials = grpc.ssl_channel_credentials(
root_certificates=trusted_certs
)
grpc_client = grpc.secure_channel(parsed_url.host_and_port, credentials)
else:
grpc_client = grpc.insecure_channel(parsed_url.host_and_port)

self.wasm = CosmWasmGrpcClient(grpc_client)
self.auth = AuthGrpcClient(grpc_client)
self.txs = TxGrpcClient(grpc_client)
self.bank = BankGrpcClient(grpc_client)
self.staking = StakingGrpcClient(grpc_client)
else:
rest_client = RestClient(parsed_url.rest_url)

self.wasm = CosmWasmRestClient(rest_client) # type: ignore
self.auth = AuthRestClient(rest_client) # type: ignore
self.txs = TxRestClient(rest_client) # type: ignore
self.bank = BankRestClient(rest_client) # type: ignore
self.staking = StakingRestClient(rest_client) # type: ignore

@property
def network_config(self) -> NetworkConfig:
return self._network_config

def query_account(self, address: Address) -> Account:
request = QueryAccountRequest(address=str(address))
response = self.auth.Account(request)

account = BaseAccount()
if not response.account.Is(BaseAccount.DESCRIPTOR):
raise RuntimeError("Unexpected account type returned from query")
response.account.Unpack(account)

return Account(
address=address,
number=account.account_number,
sequence=account.sequence,
)

def query_bank_balance(self, address: Address, denom: Optional[str] = None) -> int:
denom = denom or self.network_config.fee_denomination

req = QueryBalanceRequest(
address=str(address),
denom=denom,
)

resp = self.bank.Balance(req)
assert resp.balance.denom == denom # sanity check

return int(resp.balance.amount)

def send_tokens(
self,
destination: Address,
amount: int,
denom: str,
sender: Wallet,
memo: Optional[str] = None,
gas_limit: Optional[int] = None,
) -> SubmittedTx:

# query the account information for the sender
account = self.query_account(sender.address())

# estimate the fee required for this transaction
gas_limit = (
gas_limit or 100000
) # TODO: Need to interface to the simulation engine
fee = self.estimate_fee_from_gas(gas_limit)

# build up the store transaction
tx = Transaction()
tx.add_message(
create_bank_send_msg(sender.address(), destination, amount, denom)
)
tx.seal(
SigningCfg.direct(sender.public_key(), account.sequence),
fee=fee,
gas_limit=gas_limit,
memo=memo,
)
tx.sign(sender.signer(), self.network_config.chain_id, account.number)
tx.complete()

# broadcast the store transaction
return self.broadcast_tx(tx)

def estimate_fee_from_gas(self, gas_limit: int):
return f"{gas_limit * self.network_config.fee_minimum_gas_price}{self.network_config.fee_denomination}"

def wait_for_query_tx(
self,
tx_hash: str,
timeout: Optional[timedelta] = None,
internal: Optional[timedelta] = None,
) -> TxResponse:
timeout = timeout or timedelta(seconds=DEFAULT_QUERY_TIMEOUT_SECS)
internal = internal or timedelta(seconds=DEFAULT_QUERY_INTERVAL_SECS)

start = datetime.now()
while True:
try:
return self.query_tx(tx_hash)
except NotFoundError:
pass

delta = datetime.now() - start
if delta >= timeout:
raise QueryTimeoutError()

time.sleep(internal.total_seconds())

def query_tx(self, tx_hash: str) -> TxResponse:
req = GetTxRequest(hash=tx_hash)

try:
resp = self.txs.GetTx(req)
except grpc.RpcError as e:
details = e.details()
if "not found" in details:
raise NotFoundError()
else:
raise

# parse the transaction logs
logs = []
for log_data in resp.tx_response.logs:
events = {}
for event in log_data.events:
events[event.type] = {a.key: a.value for a in event.attributes}
logs.append(
MessageLog(
index=int(log_data.msg_index), log=log_data.msg_index, events=events
)
)

# parse the transaction events
events = {}
for event in resp.tx_response.events:
event_data = events.get(event.type, {})
for attribute in event.attributes:
event_data[attribute.key.decode()] = attribute.value.decode()
events[event.type] = event_data

return TxResponse(
hash=tx_hash,
height=int(resp.tx_response.height),
code=int(resp.tx_response.code),
gas_wanted=int(resp.tx_response.gas_wanted),
gas_used=int(resp.tx_response.gas_used),
raw_log=str(resp.tx_response.raw_log),
logs=logs,
events=events,
)

def broadcast_tx(self, tx: Transaction) -> SubmittedTx:
# create the broadcast request
broadcast_req = BroadcastTxRequest(
tx_bytes=tx.tx.SerializeToString(), mode=BroadcastMode.BROADCAST_MODE_SYNC
)

# broadcast the transaction
resp = self.txs.BroadcastTx(broadcast_req)
tx_digest = resp.tx_response.txhash

# process the response
if resp.tx_response.code:
if "out of gas" in resp.tx_response.raw_log:
match = re.search(
r"gasWanted:\s*(\d+).*?gasUsed:\s*(\d+)", resp.tx_response.raw_log
)
if match is not None:
gas_wanted = int(match.group(1))
gas_used = int(match.group(2))
else:
gas_wanted = -1
gas_used = -1

raise OutOfGasError(tx_digest, gas_wanted=gas_wanted, gas_used=gas_used)
elif "insufficient fees" in resp.tx_response.raw_log:
match = re.search(r"required:\s*(\d+\w+)", resp.tx_response.raw_log)
if match is not None:
required_fee = match.group(1)
else:
required_fee = f"more than {tx.fee}"
raise InsufficientFeesError(tx_digest, required_fee)
else:
raise BroadcastError(tx_digest, resp.tx_response.raw_log)

return SubmittedTx(self, tx_digest)
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@
#
# ------------------------------------------------------------------------------

"""REST example of query balance."""
from cosmpy.crypto.address import Address
from cosmpy.protos.cosmos.bank.v1beta1.tx_pb2 import MsgSend
from cosmpy.protos.cosmos.base.v1beta1.coin_pb2 import Coin

from cosmpy.bank.rest_client import BankRestClient, QueryBalanceRequest
from cosmpy.common.rest_client import RestClient

REST_URL = "http://127.0.0.1:1317"
ADDRESS = "fetch128r83uvcxns82535d3da5wmfvhc2e5mut922dw"
DENOM = "atestfet"
def create_bank_send_msg(
from_address: Address, to_address: Address, amount: int, denom: str
) -> MsgSend:
msg = MsgSend(
from_address=str(from_address),
to_address=str(to_address),
amount=[Coin(amount=str(amount), denom=denom)],
)

bank = BankRestClient(RestClient(REST_URL))
res = bank.Balance(QueryBalanceRequest(address=ADDRESS, denom=DENOM))
print(f"Balance of {ADDRESS} is {res.balance.amount} {res.balance.denom}")
return msg
Loading

0 comments on commit 3cb23c5

Please sign in to comment.