Skip to content

Commit

Permalink
Merge pull request #141 from yashasvi-ranawat/blockheight_check_for_APIs
Browse files Browse the repository at this point in the history
Adds test for blockheight for all endpoints
  • Loading branch information
merc1er authored May 5, 2024
2 parents a525e23 + 2cbbf30 commit aa93bd6
Show file tree
Hide file tree
Showing 10 changed files with 204 additions and 60 deletions.
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

0 comments on commit aa93bd6

Please sign in to comment.