Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds test for blockheight for all endpoints #141

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bitcash/network/APIs/BitcoinDotComAPI.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def __init__(self, network_endpoint: str):
"address": "address/details/{}",
"raw-tx": "rawtransactions/sendRawTransaction",
"tx-details": "transaction/details/{}",
"block-height": "blockchain/getBlockCount",
}

@classmethod
Expand All @@ -52,6 +53,12 @@ def get_default_endpoints(cls, network):
def make_endpoint_url(self, path):
return self.network_endpoint + self.PATHS[path]

def get_blockheight(self, *args, **kwargs):
api_url = self.make_endpoint_url("block-height")
r = session.get(api_url, *args, **kwargs)
r.raise_for_status()
return r.json()

def get_balance(self, address, *args, **kwargs):
address = cashtokenaddress_to_address(address)
api_url = self.make_endpoint_url("address").format(address)
Expand Down
21 changes: 21 additions & 0 deletions bitcash/network/APIs/ChaingraphAPI.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ def send_request(self, json_request, *args, **kwargs):
def get_default_endpoints(cls, network):
return cls.DEFAULT_ENDPOINTS[network]

def get_blockheight(self, *args, **kwargs):
json_request = {
"query": """
query GetBlockheight($node: String!) {
block(
limit: 1
order_by: { height: desc }
where: { accepted_by: { node: { name: { _like: $node } } } }
) {
height
}
}
""",
"variables": {
"node": self.node_like,
},
}
json = self.send_request(json_request, *args, **kwargs)
blockheight = int(json["data"]["block"][0]["height"])
return blockheight

def get_balance(self, address, *args, **kwargs):
json_request = {
"query": """
Expand Down
9 changes: 9 additions & 0 deletions bitcash/network/APIs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ def get_default_endpoints(self, network):
:rtype: ``list`` of ``str``
"""

@abstractmethod
def get_blockheight(self, *args, **kwargs):
"""
Return the block height.

:returns: Blockheight
:rtype: ``int``
"""

@abstractmethod
def get_balance(self, address, *args, **kwargs):
"""
Expand Down
46 changes: 6 additions & 40 deletions bitcash/network/rates.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
from collections import OrderedDict
from decimal import ROUND_DOWN
from functools import wraps
from time import time

import requests
from bitcash.network.http import session
from bitcash.utils import Decimal
from bitcash.utils import Decimal, time_cache

DEFAULT_CACHE_TIME = 60

Expand Down Expand Up @@ -651,42 +649,9 @@ def currency_to_satoshi(amount, currency):
return int(satoshis * Decimal(amount))


class CachedRate:
__slots__ = ("satoshis", "last_update")

def __init__(self, satoshis, last_update):
self.satoshis = satoshis
self.last_update = last_update


def currency_to_satoshi_local_cache(f):
start_time = time()

cached_rates = dict(
[(currency, CachedRate(None, start_time)) for currency in EXCHANGE_RATES.keys()]
)

@wraps(f)
def wrapper(amount, currency):
now = time()

cached_rate = cached_rates[currency]

if (
not cached_rate.satoshis
or now - cached_rate.last_update > DEFAULT_CACHE_TIME
):
cached_rate.satoshis = EXCHANGE_RATES[currency]()
cached_rate.last_update = now

return int(cached_rate.satoshis * Decimal(amount))

return wrapper


@currency_to_satoshi_local_cache
def currency_to_satoshi_local_cached():
pass # pragma: no cover
@time_cache(max_age=DEFAULT_CACHE_TIME, cache_size=len(EXCHANGE_RATES))
def _currency_to_satoshi_cached(currency):
return EXCHANGE_RATES[currency]()


def currency_to_satoshi_cached(amount, currency):
Expand All @@ -700,7 +665,8 @@ def currency_to_satoshi_cached(amount, currency):
:type currency: ``str``
:rtype: ``int``
"""
return currency_to_satoshi_local_cached(amount, currency)
satoshis = _currency_to_satoshi_cached(currency)
return int(satoshis * Decimal(amount))


def satoshi_to_currency(num, currency):
Expand Down
56 changes: 48 additions & 8 deletions bitcash/network/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Import supported endpoint APIs
from bitcash.network.APIs.BitcoinDotComAPI import BitcoinDotComAPI
from bitcash.network.APIs.ChaingraphAPI import ChaingraphAPI
from bitcash.utils import time_cache

# Dictionary of supported endpoint APIs
ENDPOINT_ENV_VARIABLES = {
Expand All @@ -14,6 +15,9 @@
# Default API call total time timeout
DEFAULT_TIMEOUT = 5

# Default sanitized endpoint, based on blockheigt, cache timeout
DEFAULT_SANITIZED_ENDPOINTS_CACHE_TIME = 300

BCH_TO_SAT_MULTIPLIER = 100000000

NETWORKS = {"mainnet", "testnet", "regtest"}
Expand Down Expand Up @@ -103,6 +107,42 @@ def get_endpoints_for(network):
else:
endpoints.append(ENDPOINT_ENV_VARIABLES[endpoint](each))

return tuple(endpoints)


@time_cache(max_age=DEFAULT_SANITIZED_ENDPOINTS_CACHE_TIME, cache_size=len(NETWORKS))
def get_sanitized_endpoints_for(network="mainnet"):
"""Gets endpoints sanitized by their blockheights.
Solves the problem when an endpoint is stuck on an older block.

:param network: network in ["mainnet", "testnet", "regtest"].
"""
endpoints = get_endpoints_for(network)

endpoints_blockheight = [0 for _ in range(len(endpoints))]

for i, endpoint in enumerate(endpoints):
try:
endpoints_blockheight[i] = endpoint.get_blockheight(timeout=DEFAULT_TIMEOUT)
except NetworkAPI.IGNORED_ERRORS: # pragma: no cover
pass

if sum(endpoints_blockheight) == 0:
raise ConnectionError("All APIs are unreachable.") # pragma: no cover

# remove unreachable or un-synced endpoints
highest_blockheight = max(endpoints_blockheight)
pop_indices = []
for i in range(len(endpoints)):
if endpoints_blockheight[i] != highest_blockheight:
pop_indices.append(i)

if pop_indices:
endpoints = list(endpoints)
for i in sorted(pop_indices, reverse=True):
endpoints.pop(i)
endpoints = tuple(endpoints)

return endpoints


Expand Down Expand Up @@ -131,7 +171,7 @@ def get_balance(cls, address, network="mainnet"):
:raises ConnectionError: If all API services fail.
:rtype: ``int``
"""
for endpoint in get_endpoints_for(network):
for endpoint in get_sanitized_endpoints_for(network):
try:
return endpoint.get_balance(address, timeout=DEFAULT_TIMEOUT)
except cls.IGNORED_ERRORS: # pragma: no cover
Expand All @@ -148,7 +188,7 @@ def get_transactions(cls, address, network="mainnet"):
:raises ConnectionError: If all API services fail.
:rtype: ``list`` of ``str``
"""
for endpoint in get_endpoints_for(network):
for endpoint in get_sanitized_endpoints_for(network):
try:
return endpoint.get_transactions(address, timeout=DEFAULT_TIMEOUT)
except cls.IGNORED_ERRORS: # pragma: no cover
Expand All @@ -166,7 +206,7 @@ def get_transaction(cls, txid, network="mainnet"):
:rtype: ``Transaction``
"""

for endpoint in get_endpoints_for(network):
for endpoint in get_sanitized_endpoints_for(network):
try:
return endpoint.get_transaction(txid, timeout=DEFAULT_TIMEOUT)
except cls.IGNORED_ERRORS: # pragma: no cover
Expand All @@ -186,7 +226,7 @@ def get_tx_amount(cls, txid, txindex, network="mainnet"):
:rtype: ``Decimal``
"""

for endpoint in get_endpoints_for(network):
for endpoint in get_sanitized_endpoints_for(network):
try:
return endpoint.get_tx_amount(txid, txindex, timeout=DEFAULT_TIMEOUT)
except cls.IGNORED_ERRORS: # pragma: no cover
Expand All @@ -204,7 +244,7 @@ def get_unspent(cls, address, network="mainnet"):
:rtype: ``list`` of :class:`~bitcash.network.meta.Unspent`
"""

for endpoint in get_endpoints_for(network):
for endpoint in get_sanitized_endpoints_for(network):
try:
return endpoint.get_unspent(address, timeout=DEFAULT_TIMEOUT)
except cls.IGNORED_ERRORS: # pragma: no cover
Expand All @@ -222,7 +262,7 @@ def get_raw_transaction(cls, txid, network="mainnet"):
:rtype: ``Transaction``
"""

for endpoint in get_endpoints_for(network):
for endpoint in get_sanitized_endpoints_for(network):
try:
return endpoint.get_raw_transaction(txid, timeout=DEFAULT_TIMEOUT)
except cls.IGNORED_ERRORS: # pragma: no cover
Expand All @@ -240,7 +280,7 @@ def broadcast_tx(cls, tx_hex, network="mainnet"): # pragma: no cover
"""
success = None

for endpoint in get_endpoints_for(network):
for endpoint in get_sanitized_endpoints_for(network):
_ = [end[0] for end in ChaingraphAPI.get_default_endpoints(network)]
if endpoint in _ and network == "mainnet":
# Default chaingraph endpoints do not indicate failed broadcast
Expand All @@ -256,7 +296,7 @@ def broadcast_tx(cls, tx_hex, network="mainnet"): # pragma: no cover

if not success:
raise ConnectionError(
"Transaction broadcast failed, or " "Unspents were already used."
"Transaction broadcast failed, or Unspents were already used."
)

raise ConnectionError("All APIs are unreachable.")
36 changes: 36 additions & 0 deletions bitcash/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import decimal
import functools
import time
from binascii import hexlify


Expand Down Expand Up @@ -68,3 +70,37 @@ def varint_to_int(val):
if start_byte == b"\xfd":
return int.from_bytes(val.read(2), "little")
return int.from_bytes(start_byte, "little")


def time_cache(max_age: int, cache_size: int = 32):
"""
Timed cache decorator to store a value until time-to-live

:param max_age: Time, in seconds, untill when the value is invalidated.
:param cache_size: Size of LRU cache.
"""

class ReturnValue:
def __init__(self, value, expiry):
self.value = value
self.expiry = expiry

def _decorator(fn):
@functools.lru_cache(maxsize=cache_size)
def cache_fn(*args, **kwargs):
value = fn(*args, **kwargs)
expiry = time.monotonic() + max_age
return ReturnValue(value, expiry)

@functools.wraps(fn)
def _wrapped(*args, **kwargs):
return_value = cache_fn(*args, **kwargs)
if return_value.expiry < time.monotonic():
# update the reference to the cache
return_value.value = fn(*args, **kwargs)
return_value.expiry = time.monotonic() + max_age
return return_value.value

return _wrapped

return _decorator
6 changes: 6 additions & 0 deletions tests/network/APIs/test_BitcoinDotComAPI.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ def setup_method(self):
self.monkeypatch = MonkeyPatch()
self.api = BitcoinDotComAPI("https://dummy.com/v2/")

def test_get_blockheight(self):
return_json = 800_000
self.monkeypatch.setattr(_bapi, "session", DummySession(return_json))
blockheight = self.api.get_blockheight()
assert blockheight == 800_000

def test_get_balance(self):
return_json = {
"balanceSat": 2500,
Expand Down
12 changes: 12 additions & 0 deletions tests/network/APIs/test_ChaingraphAPI.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ def setup_method(self):
self.monkeypatch = MonkeyPatch()
self.api = ChaingraphAPI("https://dummy.com/v1/graphql")

def test_get_blockheight(self):
return_json = {
"data": {
"block": [
{"height": "123456"},
]
}
}
self.monkeypatch.setattr(_capi, "session", DummySession(return_json))
blockheight = self.api.get_blockheight()
assert blockheight == 123456

def test_get_balance(self):
return_json = {
"data": {
Expand Down
Loading