diff --git a/bit/exceptions.py b/bit/exceptions.py index aa377e3..b03f0fb 100644 --- a/bit/exceptions.py +++ b/bit/exceptions.py @@ -4,3 +4,6 @@ class InsufficientFunds(Exception): class BitcoinNodeException(Exception): pass + +class ExcessiveAddress(Exception): + pass diff --git a/bit/network/services.py b/bit/network/services.py index f46bf7e..0d1f454 100644 --- a/bit/network/services.py +++ b/bit/network/services.py @@ -6,7 +6,9 @@ from bit.constants import BTC from bit.network import currency_to_satoshi from bit.network.meta import Unspent -from bit.exceptions import BitcoinNodeException +from bit.exceptions import BitcoinNodeException, ExcessiveAddress +from bit.transaction import address_to_scriptpubkey +from bit.utils import bytes_to_hex DEFAULT_TIMEOUT = 10 @@ -102,7 +104,412 @@ def __call__(self, *args): return responseJSON["result"] -class InsightAPI: +class BlockchairAPI: + MAIN_ENDPOINT = 'https://api.blockchair.com/bitcoin/' + MAIN_ADDRESS_API = MAIN_ENDPOINT + 'dashboards/address/{}' + MAIN_TX_PUSH_API = MAIN_ENDPOINT + 'dashboards/push/transaction' + MAIN_TX_API = MAIN_ENDPOINT + 'raw/transaction/{}' + TEST_ENDPOINT = 'https://api.blockchair.com/bitcoin/testnet/' + TEST_ADDRESS_API = TEST_ENDPOINT + 'dashboards/address/{}' + TEST_TX_PUSH_API = TEST_ENDPOINT + 'dashboards/push/transaction' + TEST_TX_API = TEST_ENDPOINT + 'raw/transaction/{}' + TX_PUSH_PARAM = 'data' + + @classmethod + def get_balance(cls, address): + r = requests.get(cls.MAIN_ADDRESS_API.format(address), timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + return r.json()['data'][address]['address']['balance'] + + @classmethod + def get_balance_testnet(cls, address): + r = requests.get(cls.TEST_ADDRESS_API.format(address), timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + return r.json()['data'][address]['address']['balance'] + + @classmethod + def get_transactions(cls, address): + endpoint = cls.MAIN_ADDRESS_API + + transactions = [] + offset = 0 + txs_per_page = 1000 + payload = {'offset': str(offset), 'limit': str(txs_per_page)} + + r = requests.get(endpoint.format(address), params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code == 404: # pragma: no cover + return [] + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() + response = response['data'][address] + total_txs = response['address']['transaction_count'] + + while total_txs > 0: + transactions.extend(tx for tx in response['transactions']) + + total_txs -= txs_per_page + offset += txs_per_page + payload['offset'] = str(offset) + r = requests.get(endpoint.format(address), params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json()['data'][address] + + return transactions + + @classmethod + def get_transactions_testnet(cls, address): + endpoint = cls.TEST_ADDRESS_API + + transactions = [] + offset = 0 + txs_per_page = 1000 + payload = {'offset': str(offset), 'limit': str(txs_per_page)} + + r = requests.get(endpoint.format(address), params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code == 404: # pragma: no cover + return [] + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() + response = response['data'][address] + total_txs = response['address']['transaction_count'] + + while total_txs > 0: + transactions.extend(tx for tx in response['transactions']) + + total_txs -= txs_per_page + offset += txs_per_page + payload['offset'] = str(offset) + r = requests.get(endpoint.format(address), params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json()['data'][address] + + return transactions + + @classmethod + def get_transaction_by_id(cls, txid): + r = requests.get(cls.MAIN_TX_API.format(txid), timeout=DEFAULT_TIMEOUT) + if r.status_code == 404: # pragma: no cover + return None + if r.status_code != 200: # pragma: no cover + raise ConnectionError + + response = r.json()['data'] + if not response: # pragma: no cover + return None + return response[txid]['raw_transaction'] + + @classmethod + def get_transaction_by_id_testnet(cls, txid): + r = requests.get(cls.TEST_TX_API.format(txid), timeout=DEFAULT_TIMEOUT) + if r.status_code == 404: # pragma: no cover + return None + if r.status_code != 200: # pragma: no cover + raise ConnectionError + + response = r.json()['data'] + if not response: # pragma: no cover + return None + return response[txid]['raw_transaction'] + + @classmethod + def get_unspent(cls, address): + endpoint = cls.MAIN_ADDRESS_API + + unspents = [] + offset = 0 + unspents_per_page = 1000 + payload = {'offset': str(offset), 'limit': str(unspents_per_page)} + + r = requests.get(endpoint.format(address), params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code == 404: # pragma: no cover + return None + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() + + block_height = response['context']['state'] + response = response['data'][address] + script_pubkey = response['address']['script_hex'] + total_unspents = response['address']['unspent_output_count'] + + while total_unspents > 0: + unspents.extend( + Unspent( + utxo['value'], + block_height - utxo['block_id'], + script_pubkey, + utxo['transaction_hash'], + utxo['index'], + ) + for utxo in response['utxo'] + ) + + total_unspents -= unspents_per_page + offset += unspents_per_page + payload['offset'] = str(offset) + r = requests.get(endpoint.format(address), params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json()['data'][address] + + return unspents + + @classmethod + def get_unspent_testnet(cls, address): + endpoint = cls.TEST_ADDRESS_API + + unspents = [] + offset = 0 + unspents_per_page = 1000 + payload = {'offset': str(offset), 'limit': unspents_per_page} + + r = requests.get(endpoint.format(address), params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code == 404: # pragma: no cover + return None + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() + + block_height = response['context']['state'] + response = response['data'][address] + script_pubkey = response['address']['script_hex'] + total_unspents = response['address']['unspent_output_count'] + + while total_unspents > 0: + unspents.extend( + Unspent( + utxo['value'], + block_height - utxo['block_id'], + script_pubkey, + utxo['transaction_hash'], + utxo['index'], + ) + for utxo in response['utxo'] + ) + + total_unspents -= unspents_per_page + offset += unspents_per_page + payload['offset'] = str(offset) + r = requests.get(endpoint.format(address), params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json()['data'][address] + + return unspents + + @classmethod + def broadcast_tx( + cls, tx_hex, + ): # pragma: no cover + r = requests.post(cls.MAIN_TX_PUSH_API, data={cls.TX_PUSH_PARAM: tx_hex}, timeout=DEFAULT_TIMEOUT) + return True if r.status_code == 200 else False + + @classmethod + def broadcast_tx_testnet(cls, tx_hex): # pragma: no cover + r = requests.post(cls.TEST_TX_PUSH_API, data={cls.TX_PUSH_PARAM: tx_hex}, timeout=DEFAULT_TIMEOUT) + return True if r.status_code == 200 else False + + +class BlockstreamAPI: + MAIN_ENDPOINT = 'https://blockstream.info/api/' + MAIN_ADDRESS_API = MAIN_ENDPOINT + 'address/{}' + MAIN_UNSPENT_API = MAIN_ADDRESS_API + '/utxo' + MAIN_TX_PUSH_API = MAIN_ENDPOINT + 'tx' + MAIN_TX_API = MAIN_ENDPOINT + 'tx/{}/hex' + TEST_ENDPOINT = 'https://blockstream.info/testnet/api/' + TEST_ADDRESS_API = TEST_ENDPOINT + 'address/{}' + TEST_UNSPENT_API = TEST_ADDRESS_API + '/utxo' + TEST_TX_PUSH_API = TEST_ENDPOINT + 'tx' + TEST_TX_API = TEST_ENDPOINT + 'tx/{}/hex' + TX_PUSH_PARAM = 'data' + + @classmethod + def get_balance(cls, address): + r = requests.get(cls.MAIN_ADDRESS_API.format(address), timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() + funded = response['chain_stats']['funded_txo_sum'] + response['mempool_stats']['funded_txo_sum'] + spent = response['chain_stats']['spent_txo_sum'] + response['mempool_stats']['spent_txo_sum'] + return funded - spent + + @classmethod + def get_balance_testnet(cls, address): + r = requests.get(cls.TEST_ADDRESS_API.format(address), timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() + funded = response['chain_stats']['funded_txo_sum'] + response['mempool_stats']['funded_txo_sum'] + spent = response['chain_stats']['spent_txo_sum'] + response['mempool_stats']['spent_txo_sum'] + return funded - spent + + @classmethod + def get_transactions( + cls, address, + ): + #! Blockstream returns at most 50 mempool (unconfirmed) transactions and ignores the rest + mempool_endpoint = cls.MAIN_ADDRESS_API + '/txs/mempool' + + endpoint = cls.MAIN_ADDRESS_API + '/txs/chain/{}' + + transactions = [] + + # Add mempool (unconfirmed) transactions + r = requests.get(mempool_endpoint.format(address), timeout=DEFAULT_TIMEOUT) + if r.status_code == 400: # pragma: no cover + return [] + elif r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() + unconfirmed = [tx['txid'] for tx in response] + + # It is safer to raise exception if API returns exactly 50 unconfirmed + # transactions, as there could be more that the API is unaware of. + if len(unconfirmed) == 50: # pragme: no cover + raise ExcessiveAddress + + r = requests.get(endpoint.format(address, ''), timeout=DEFAULT_TIMEOUT) + if r.status_code == 400: # pragma: no cover + return [] + elif r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() + + # The first 25 confirmed transactions are shown with no + # indication of the number of total transactions. + total_txs = len(response) + + while total_txs > 0: + transactions.extend(tx['txid'] for tx in response) + + response = requests.get(endpoint.format(address, transactions[-1]), timeout=DEFAULT_TIMEOUT).json() + total_txs = len(response) + + transactions.extend(unconfirmed) + + return transactions + + @classmethod + def get_transactions_testnet(cls, address): + endpoint = cls.TEST_ADDRESS_API + '/txs/chain/{}' + + transactions = [] + + r = requests.get(endpoint.format(address, ''), timeout=DEFAULT_TIMEOUT) + if r.status_code == 400: # pragma: no cover + return [] + elif r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() + + # The first 50 mempool and 25 confirmed transactions are shown with no + # indication of the number of total transactions. + total_txs = len(response) + + while total_txs > 0: + transactions.extend(tx['txid'] for tx in response) + + response = requests.get(endpoint.format(address, transactions[-1]), timeout=DEFAULT_TIMEOUT).json() + total_txs = len(response) + + return transactions + + @classmethod + def get_transaction_by_id(cls, txid): + r = requests.get(cls.MAIN_TX_API.format(txid), timeout=DEFAULT_TIMEOUT) + if r.status_code == 404: # pragma: no cover + return None + if r.status_code != 200: # pragma: no cover + raise ConnectionError + return r.text + + @classmethod + def get_transaction_by_id_testnet(cls, txid): + r = requests.get(cls.TEST_TX_API.format(txid), timeout=DEFAULT_TIMEOUT) + if r.status_code == 404: # pragma: no cover + return None + if r.status_code != 200: # pragma: no cover + raise ConnectionError + return r.text + + @classmethod + def get_unspent(cls, address): + # Get current block height: + r_block = requests.get(cls.MAIN_ENDPOINT + 'blocks/tip/height', timeout=DEFAULT_TIMEOUT) + if r_block.status_code != 200: # pragma: no cover + raise ConnectionError + block_height = int(r_block.text) + + r = requests.get(cls.MAIN_UNSPENT_API.format(address), timeout=DEFAULT_TIMEOUT) + + #! BlockstreamAPI blocks addresses with "too many" UTXOs. + if r.status_code == 400 and r.text == "Too many history entries": + raise ExcessiveAddress + elif r.status_code != 200: # pragma: no cover + raise ConnectionError + + script_pubkey = bytes_to_hex(address_to_scriptpubkey(address)) + + return sorted( + [ + Unspent( + tx["value"], + block_height - tx["status"]["block_height"] if tx["status"]["confirmed"] else 0, + script_pubkey, + tx["txid"], + tx["vout"], + ) + for tx in r.json() + ], + key=lambda u: u.confirmations, + ) + + @classmethod + def get_unspent_testnet(cls, address): + # Get current block height: + r_block = requests.get(cls.TEST_ENDPOINT + 'blocks/tip/height', timeout=DEFAULT_TIMEOUT) + if r_block.status_code != 200: # pragma: no cover + raise ConnectionError + block_height = int(r_block.text) + + r = requests.get(cls.TEST_UNSPENT_API.format(address), timeout=DEFAULT_TIMEOUT) + + if r.status_code == 400: # pragma: no cover + return [] + elif r.status_code != 200: # pragma: no cover + raise ConnectionError + + script_pubkey = bytes_to_hex(address_to_scriptpubkey(address)) + + return [ + Unspent( + tx["value"], + block_height - tx["status"]["block_height"] if tx["status"]["confirmed"] else 0, + script_pubkey, + tx["txid"], + tx["vout"], + ) + for tx in r.json() + ] + + @classmethod + def broadcast_tx(cls, tx_hex): # pragma: no cover + r = requests.post(cls.MAIN_TX_PUSH_API, data={cls.TX_PUSH_PARAM: tx_hex}, timeout=DEFAULT_TIMEOUT) + return True if r.status_code == 200 else False + + @classmethod + def broadcast_tx_testnet(cls, tx_hex): # pragma: no cover + r = requests.post(cls.TEST_TX_PUSH_API, data={cls.TX_PUSH_PARAM: tx_hex}, timeout=DEFAULT_TIMEOUT) + return True if r.status_code == 200 else False + + +class InsightAPI: # pragma: no cover MAIN_ENDPOINT = '' MAIN_ADDRESS_API = '' MAIN_BALANCE_API = '' @@ -128,7 +535,7 @@ def get_transactions(cls, address): @classmethod def get_transaction_by_id(cls, txid): r = requests.get(cls.MAIN_TX_API + txid, timeout=DEFAULT_TIMEOUT) - if r.status_code == 404: + if r.status_code == 404: # pragma: no cover return None if r.status_code != 200: # pragma: no cover raise ConnectionError @@ -156,70 +563,109 @@ def broadcast_tx(cls, tx_hex): # pragma: no cover return True if r.status_code == 200 else False -class BitpayAPI(InsightAPI): - MAIN_ENDPOINT = 'https://insight.bitpay.com/api/' - MAIN_ADDRESS_API = MAIN_ENDPOINT + 'addr/' - MAIN_BALANCE_API = MAIN_ADDRESS_API + '{}/balance' - MAIN_UNSPENT_API = MAIN_ADDRESS_API + '{}/utxo' +class BitcoreAPI(InsightAPI): + """ Insight API v8 """ + + MAIN_ENDPOINT = 'https://api.bitcore.io/api/BTC/mainnet/' + MAIN_ADDRESS_API = MAIN_ENDPOINT + 'address/{}' + MAIN_BALANCE_API = MAIN_ADDRESS_API + '/balance' + MAIN_UNSPENT_API = MAIN_ADDRESS_API + '/?unspent=true' MAIN_TX_PUSH_API = MAIN_ENDPOINT + 'tx/send' - MAIN_TX_API = MAIN_ENDPOINT + 'rawtx/' - TEST_ENDPOINT = 'https://test-insight.bitpay.com/api/' - TEST_ADDRESS_API = TEST_ENDPOINT + 'addr/' - TEST_BALANCE_API = TEST_ADDRESS_API + '{}/balance' - TEST_UNSPENT_API = TEST_ADDRESS_API + '{}/utxo' + MAIN_TX_API = MAIN_ENDPOINT + 'tx/{}' + MAIN_TX_AMOUNT_API = MAIN_TX_API + TEST_ENDPOINT = 'https://api.bitcore.io/api/BTC/testnet/' + TEST_ADDRESS_API = TEST_ENDPOINT + 'address/{}' + TEST_BALANCE_API = TEST_ADDRESS_API + '/balance' + TEST_UNSPENT_API = TEST_ADDRESS_API + '/?unspent=true' TEST_TX_PUSH_API = TEST_ENDPOINT + 'tx/send' - TEST_TX_API = TEST_ENDPOINT + 'rawtx/' - TX_PUSH_PARAM = 'rawtx' + TEST_TX_API = TEST_ENDPOINT + 'tx/{}' + TEST_TX_AMOUNT_API = TEST_TX_API + TX_PUSH_PARAM = 'rawTx' @classmethod - def get_balance_testnet(cls, address): - r = requests.get(cls.TEST_BALANCE_API.format(address), timeout=DEFAULT_TIMEOUT) + def get_unspent(cls, address): + endpoint = cls.MAIN_UNSPENT_API + "&limit=100" + + unspents = [] + + r = requests.get(endpoint.format(address), timeout=DEFAULT_TIMEOUT) if r.status_code != 200: # pragma: no cover raise ConnectionError - return r.json() + + response = r.json() + + while len(response) > 0: + unspents.extend( + Unspent( + currency_to_satoshi(tx['value'], 'satoshi'), + tx['confirmations'], + tx['script'], + tx['mintTxid'], + tx['mintIndex'], + ) + for tx in response + ) + response = requests.get( + endpoint.format(address) + "&since={}".format(response[-1]['_id']), timeout=DEFAULT_TIMEOUT + ).json() + + return unspents @classmethod - def get_transactions_testnet(cls, address): - r = requests.get(cls.TEST_ADDRESS_API + address, timeout=DEFAULT_TIMEOUT) - if r.status_code != 200: # pragma: no cover - raise ConnectionError - return r.json()['transactions'] + def get_balance(cls, address): + r = requests.get(cls.MAIN_BALANCE_API.format(address), timeout=DEFAULT_TIMEOUT) + r.raise_for_status() # pragma: no cover + return r.json()['balance'] @classmethod - def get_transaction_by_id_testnet(cls, txid): - r = requests.get(cls.TEST_TX_API + txid, timeout=DEFAULT_TIMEOUT) - if r.status_code == 404: - return None - if r.status_code != 200: # pragma: no cover - raise ConnectionError - return r.json()["rawtx"] + def get_balance_testnet(cls, address): + r = requests.get(cls.TEST_BALANCE_API.format(address), timeout=DEFAULT_TIMEOUT) + r.raise_for_status() # pragma: no cover + return r.json()['balance'] @classmethod def get_unspent_testnet(cls, address): - r = requests.get(cls.TEST_UNSPENT_API.format(address), timeout=DEFAULT_TIMEOUT) + endpoint = cls.TEST_UNSPENT_API + "&limit=100" + + unspents = [] + + r = requests.get(endpoint.format(address), timeout=DEFAULT_TIMEOUT) if r.status_code != 200: # pragma: no cover raise ConnectionError - return [ - Unspent( - currency_to_satoshi(tx['amount'], 'btc'), - tx['confirmations'], - tx['scriptPubKey'], - tx['txid'], - tx['vout'], + + response = r.json() + + while len(response) > 0: + unspents.extend( + Unspent( + currency_to_satoshi(tx['value'], 'satoshi'), + tx['confirmations'], + tx['script'], + tx['mintTxid'], + tx['mintIndex'], + ) + for tx in response ) - for tx in r.json() - ] + response = requests.get( + endpoint.format(address) + "&since={}".format(response[-1]['_id']), timeout=DEFAULT_TIMEOUT + ).json() + + return unspents @classmethod def broadcast_tx_testnet(cls, tx_hex): # pragma: no cover - r = requests.post(cls.TEST_TX_PUSH_API, data={cls.TX_PUSH_PARAM: tx_hex}, timeout=DEFAULT_TIMEOUT) + r = requests.post( + cls.TEST_TX_PUSH_API, + json={cls.TX_PUSH_PARAM: tx_hex, 'network': 'testnet', 'coin': 'BCH'}, + timeout=DEFAULT_TIMEOUT, + ) return True if r.status_code == 200 else False class BlockchainAPI: ENDPOINT = 'https://blockchain.info/' ADDRESS_API = ENDPOINT + 'address/{}?format=json' - UNSPENT_API = ENDPOINT + 'unspent?active=' + UNSPENT_API = ENDPOINT + 'unspent' TX_PUSH_API = ENDPOINT + 'pushtx' TX_API = ENDPOINT + 'rawtx/' TX_PUSH_PARAM = 'tx' @@ -237,10 +683,13 @@ def get_transactions(cls, address): transactions = [] offset = 0 - payload = {'offset': str(offset)} txs_per_page = 50 + payload = {'offset': str(offset)} - response = requests.get(endpoint.format(address), timeout=DEFAULT_TIMEOUT).json() + r = requests.get(endpoint.format(address), params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() total_txs = response['n_tx'] while total_txs > 0: @@ -256,7 +705,7 @@ def get_transactions(cls, address): @classmethod def get_transaction_by_id(cls, txid): r = requests.get(cls.TX_API + txid + '?limit=0&format=hex', timeout=DEFAULT_TIMEOUT) - if r.status_code == 500 and r.text == 'Transaction not found': + if r.status_code == 500 and r.text == 'Transaction not found': # pragma: no cover return None if r.status_code != 200: # pragma: no cover raise ConnectionError @@ -264,17 +713,30 @@ def get_transaction_by_id(cls, txid): @classmethod def get_unspent(cls, address): - r = requests.get(cls.UNSPENT_API + address, timeout=DEFAULT_TIMEOUT) + endpoint = cls.UNSPENT_API - if r.status_code == 500: + offset = 0 + utxos_per_page = 1000 + payload = {'active': address, 'offset': str(offset), 'limit': str(utxos_per_page)} + + r = requests.get(endpoint, params=payload, timeout=DEFAULT_TIMEOUT) + + if r.status_code == 500: # pragma: no cover return [] elif r.status_code != 200: # pragma: no cover raise ConnectionError - return [ + unspents = [ Unspent(tx['value'], tx['confirmations'], tx['script'], tx['tx_hash_big_endian'], tx['tx_output_n']) for tx in r.json()['unspent_outputs'] - ][::-1] + ] + + #! BlockchainAPI only supports up to 1000 UTXOs. + #! Raises an exception for addresses that may contain more UTXOs. + if len(unspents) == 1000: + raise ExcessiveAddress + + return unspents[::-1] @classmethod def broadcast_tx(cls, tx_hex): # pragma: no cover @@ -284,50 +746,62 @@ def broadcast_tx(cls, tx_hex): # pragma: no cover class SmartbitAPI: MAIN_ENDPOINT = 'https://api.smartbit.com.au/v1/blockchain/' - MAIN_ADDRESS_API = MAIN_ENDPOINT + 'address/' - MAIN_UNSPENT_API = MAIN_ADDRESS_API + '{}/unspent' + MAIN_ADDRESS_API = MAIN_ENDPOINT + 'address/{}' + MAIN_UNSPENT_API = MAIN_ADDRESS_API + '/unspent' MAIN_TX_PUSH_API = MAIN_ENDPOINT + 'pushtx' MAIN_TX_API = MAIN_ENDPOINT + 'tx/{}/hex' TEST_ENDPOINT = 'https://testnet-api.smartbit.com.au/v1/blockchain/' - TEST_ADDRESS_API = TEST_ENDPOINT + 'address/' - TEST_UNSPENT_API = TEST_ADDRESS_API + '{}/unspent' + TEST_ADDRESS_API = TEST_ENDPOINT + 'address/{}' + TEST_UNSPENT_API = TEST_ADDRESS_API + '/unspent' TEST_TX_PUSH_API = TEST_ENDPOINT + 'pushtx' TEST_TX_API = TEST_ENDPOINT + 'tx/{}/hex' TX_PUSH_PARAM = 'hex' @classmethod def get_balance(cls, address): - r = requests.get(cls.MAIN_ADDRESS_API + address + '?limit=1', timeout=DEFAULT_TIMEOUT) + r = requests.get(cls.MAIN_ADDRESS_API.format(address), params={'limit': '1'}, timeout=DEFAULT_TIMEOUT) if r.status_code != 200: # pragma: no cover raise ConnectionError return r.json()['address']['total']['balance_int'] @classmethod def get_balance_testnet(cls, address): - r = requests.get(cls.TEST_ADDRESS_API + address + '?limit=1', timeout=DEFAULT_TIMEOUT) + r = requests.get(cls.TEST_ADDRESS_API.format(address), params={'limit': '1'}, timeout=DEFAULT_TIMEOUT) if r.status_code != 200: # pragma: no cover raise ConnectionError return r.json()['address']['total']['balance_int'] @classmethod def get_transactions(cls, address): - r = requests.get(cls.MAIN_ADDRESS_API + address + '?limit=1000', timeout=DEFAULT_TIMEOUT) + txs_per_page = 1000 + payload = {'limit': str(txs_per_page)} + r = requests.get(cls.MAIN_ADDRESS_API.format(address), params=payload, timeout=DEFAULT_TIMEOUT) if r.status_code != 200: # pragma: no cover raise ConnectionError - data = r.json()['address'] + response = r.json()['address'] transactions = [] + next_link = None - if 'transactions' in data: - transactions.extend(t['txid'] for t in data['transactions']) + if 'transactions' in response: + transactions.extend(t['txid'] for t in response['transactions']) + next_link = response['transaction_paging']['next_link'] + + while next_link: + r = requests.get(next_link, timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json()['address'] + transactions.extend(t['txid'] for t in response['transactions']) + next_link = response['transaction_paging']['next_link'] return transactions @classmethod def get_transaction_by_id(cls, txid): r = requests.get(cls.MAIN_TX_API.format(txid) + '?limit=1000', timeout=DEFAULT_TIMEOUT) - if r.status_code == 400: + if r.status_code == 400: # pragma: no cover return None if r.status_code != 200: # pragma: no cover raise ConnectionError @@ -335,23 +809,35 @@ def get_transaction_by_id(cls, txid): @classmethod def get_transactions_testnet(cls, address): - r = requests.get(cls.TEST_ADDRESS_API + address + '?limit=1000', timeout=DEFAULT_TIMEOUT) + txs_per_page = 1000 + payload = {'limit': str(txs_per_page)} + r = requests.get(cls.TEST_ADDRESS_API.format(address), params=payload, timeout=DEFAULT_TIMEOUT) if r.status_code != 200: # pragma: no cover raise ConnectionError - data = r.json()['address'] + response = r.json()['address'] transactions = [] + next_link = None + + if 'transactions' in response: + transactions.extend(t['txid'] for t in response['transactions']) + next_link = response['transaction_paging']['next_link'] - if 'transactions' in data: - transactions.extend(t['txid'] for t in data['transactions']) + while next_link: + r = requests.get(next_link, params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json()['address'] + transactions.extend(t['txid'] for t in response['transactions']) + next_link = response['transaction_paging']['next_link'] return transactions @classmethod def get_transaction_by_id_testnet(cls, txid): r = requests.get(cls.TEST_TX_API.format(txid) + '?limit=1000', timeout=DEFAULT_TIMEOUT) - if r.status_code == 400: + if r.status_code == 400: # pragma: no cover return None if r.status_code != 200: # pragma: no cover raise ConnectionError @@ -359,35 +845,93 @@ def get_transaction_by_id_testnet(cls, txid): @classmethod def get_unspent(cls, address): - r = requests.get(cls.MAIN_UNSPENT_API.format(address) + '?limit=1000', timeout=DEFAULT_TIMEOUT) + txs_per_page = 1000 + payload = {'limit': str(txs_per_page)} + r = requests.get(cls.MAIN_UNSPENT_API.format(address), params=payload, timeout=DEFAULT_TIMEOUT) if r.status_code != 200: # pragma: no cover raise ConnectionError - return [ - Unspent( - currency_to_satoshi(tx['value'], 'btc'), - tx['confirmations'], - tx['script_pub_key']['hex'], - tx['txid'], - tx['n'], + + response = r.json() + + unspents = [] + next_link = None + + if 'unspent' in response: + unspents.extend( + Unspent( + currency_to_satoshi(tx['value'], 'btc'), + tx['confirmations'], + tx['script_pub_key']['hex'], + tx['txid'], + tx['n'], + ) + for tx in response['unspent'] ) - for tx in r.json()['unspent'] - ] + next_link = response['paging']['next_link'] + + while next_link: + r = requests.get(next_link, params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() + unspents.extend( + Unspent( + currency_to_satoshi(tx['value'], 'btc'), + tx['confirmations'], + tx['script_pub_key']['hex'], + tx['txid'], + tx['n'], + ) + for tx in response['unspent'] + ) + next_link = response['paging']['next_link'] + + return unspents @classmethod def get_unspent_testnet(cls, address): - r = requests.get(cls.TEST_UNSPENT_API.format(address) + '?limit=1000', timeout=DEFAULT_TIMEOUT) + txs_per_page = 1000 + payload = {'limit': str(txs_per_page)} + r = requests.get(cls.TEST_UNSPENT_API.format(address), params=payload, timeout=DEFAULT_TIMEOUT) if r.status_code != 200: # pragma: no cover raise ConnectionError - return [ - Unspent( - currency_to_satoshi(tx['value'], 'btc'), - tx['confirmations'], - tx['script_pub_key']['hex'], - tx['txid'], - tx['n'], + + response = r.json() + + unspents = [] + next_link = None + + if 'unspent' in response: + unspents.extend( + Unspent( + currency_to_satoshi(tx['value'], 'btc'), + tx['confirmations'], + tx['script_pub_key']['hex'], + tx['txid'], + tx['n'], + ) + for tx in response['unspent'] ) - for tx in r.json()['unspent'] - ] + next_link = response['paging']['next_link'] + + while next_link: + r = requests.get(next_link, params=payload, timeout=DEFAULT_TIMEOUT) + if r.status_code != 200: # pragma: no cover + raise ConnectionError + response = r.json() + unspents.extend( + Unspent( + currency_to_satoshi(tx['value'], 'btc'), + tx['confirmations'], + tx['script_pub_key']['hex'], + tx['txid'], + tx['n'], + ) + for tx in response['unspent'] + ) + next_link = response['paging']['next_link'] + + return unspents @classmethod def broadcast_tx(cls, tx_hex): # pragma: no cover @@ -406,34 +950,71 @@ class NetworkAPI: requests.exceptions.ConnectionError, requests.exceptions.Timeout, requests.exceptions.ReadTimeout, + ExcessiveAddress, ) - GET_BALANCE_MAIN = [BitpayAPI.get_balance, SmartbitAPI.get_balance, BlockchainAPI.get_balance] + GET_BALANCE_MAIN = [ + BlockchairAPI.get_balance, + BlockstreamAPI.get_balance, + BitcoreAPI.get_balance, + SmartbitAPI.get_balance, + BlockchainAPI.get_balance, + ] GET_TRANSACTIONS_MAIN = [ - BitpayAPI.get_transactions, # Limit 1000 + BlockchairAPI.get_transactions, # Limit 1000 + BlockstreamAPI.get_transactions, # Limit 1000 SmartbitAPI.get_transactions, # Limit 1000 - BlockchainAPI.get_transactions, - ] # No limit, requires multiple requests + BlockchainAPI.get_transactions, # No limit, requires multiple requests + ] GET_TRANSACTION_BY_ID_MAIN = [ - BitpayAPI.get_transaction_by_id, + BlockchairAPI.get_transaction_by_id, + BlockstreamAPI.get_transaction_by_id, SmartbitAPI.get_transaction_by_id, BlockchainAPI.get_transaction_by_id, ] GET_UNSPENT_MAIN = [ - BitpayAPI.get_unspent, # No limit + BlockchairAPI.get_unspent, + BitcoreAPI.get_unspent, # No limit SmartbitAPI.get_unspent, # Limit 1000 + BlockstreamAPI.get_unspent, BlockchainAPI.get_unspent, - ] # Limit 250 - BROADCAST_TX_MAIN = [BitpayAPI.broadcast_tx, SmartbitAPI.broadcast_tx, BlockchainAPI.broadcast_tx] # Limit 5/minute + ] + BROADCAST_TX_MAIN = [ + BlockchairAPI.broadcast_tx, + BlockstreamAPI.broadcast_tx, + BitcoreAPI.broadcast_tx, + SmartbitAPI.broadcast_tx, # Limit 5/minute + BlockchainAPI.broadcast_tx, + ] - GET_BALANCE_TEST = [BitpayAPI.get_balance_testnet, SmartbitAPI.get_balance_testnet] + GET_BALANCE_TEST = [ + BlockchairAPI.get_balance_testnet, + BlockstreamAPI.get_balance_testnet, + BitcoreAPI.get_balance_testnet, + SmartbitAPI.get_balance_testnet, + ] GET_TRANSACTIONS_TEST = [ - BitpayAPI.get_transactions_testnet, # Limit 1000 - SmartbitAPI.get_transactions_testnet, - ] # Limit 1000 - GET_TRANSACTION_BY_ID_TEST = [BitpayAPI.get_transaction_by_id_testnet, SmartbitAPI.get_transaction_by_id_testnet] - GET_UNSPENT_TEST = [BitpayAPI.get_unspent_testnet, SmartbitAPI.get_unspent_testnet] # No limit # Limit 1000 - BROADCAST_TX_TEST = [BitpayAPI.broadcast_tx_testnet, SmartbitAPI.broadcast_tx_testnet] # Limit 5/minute + BlockchairAPI.get_transactions_testnet, # Limit 1000 + BlockstreamAPI.get_transactions_testnet, + SmartbitAPI.get_transactions_testnet, # Limit 1000 + ] + GET_TRANSACTION_BY_ID_TEST = [ + BlockchairAPI.get_transaction_by_id_testnet, + BlockstreamAPI.get_transaction_by_id_testnet, + SmartbitAPI.get_transaction_by_id_testnet, + ] + GET_UNSPENT_TEST = [ + BlockchairAPI.get_unspent_testnet, + BitcoreAPI.get_unspent_testnet, # No limit + SmartbitAPI.get_unspent_testnet, # Limit 1000 + BlockstreamAPI.get_unspent_testnet, + ] + BROADCAST_TX_TEST = [ + BlockchairAPI.broadcast_tx_testnet, + BlockstreamAPI.broadcast_tx_testnet, + BitcoreAPI.broadcast_tx_testnet, + SmartbitAPI.broadcast_tx_testnet, # Limit 5/minute + ] @classmethod def connect_to_node(cls, user, password, host='localhost', port=8332, use_https=False, testnet=False): diff --git a/docs/source/dev/api.rst b/docs/source/dev/api.rst index 1c93f36..44ec7c8 100644 --- a/docs/source/dev/api.rst +++ b/docs/source/dev/api.rst @@ -95,3 +95,5 @@ Exceptions ---------- .. autoexception:: bit.exceptions.InsufficientFunds +.. autoexception:: bit.exceptions.BitcoinNodeException +.. autoexception:: bit.exceptions.ExcessiveAddress diff --git a/docs/source/guide/network.rst b/docs/source/guide/network.rst index c977814..fa62507 100644 --- a/docs/source/guide/network.rst +++ b/docs/source/guide/network.rst @@ -111,12 +111,11 @@ Services Bit communicates with the blockchain using trusted third-party APIs. Specifically, it can access: -- ``_ via :class:`~bit.network.services.BitpayAPI` -- ``_ via :class:`~bit.network.services.BlockchainAPI` +- ``_ via :class:`~bit.network.services.BlockchairAPI` +- ``_ via :class:`~bit.network.services.BlockstreamAPI` +- ``_ via :class:`~bit.network.services.BitcoreAPI` - ``_ via :class:`~bit.network.services.SmartbitAPI` - -Bit can alternatively use a remote Bitcoin node to interact with the blockchain, -see guide below. +- ``_ via :class:`~bit.network.services.BlockchainAPI` NetworkAPI ^^^^^^^^^^ @@ -124,9 +123,26 @@ NetworkAPI Private key network operations use :class:`~bit.network.NetworkAPI`. For each method, it polls a service and if an error occurs it tries another. +**Note:** + +:class:`~bit.network.services.BlockchainAPI` does only track up to 1000 unspent +transaction outputs (UTXOs) and will raise :class:`~bit.exceptions.ExcessiveAddress` +if polling unspents on an address with 1000 or more UTXOs. + +:class:`~bit.network.services.BlockstreamAPI` does only track up to 50 _unconfirmed_ +transactions and will raise :class:`~bit.exceptions.ExcessiveAddress` if polling +unspents on an address with 50 or more unconfirmed. It may occasionally also +raise :class:`~bit.exceptions.ExcessiveAddress` on an address with a history of +many (1000+) transactions. + +In those cases where the API may raise errors due to :class:`~bit.exceptions.ExcessiveAddress` +it is advised to use your own remote Bitcoin node to poll, see below. + Connect To Remote Node ---------------------- +Bit can alternatively use a remote Bitcoin node to interact with the blockchain. + See the dedicated guide under :ref:`remote node ` to configure Bit to use a remote Bitcoin node instead of relying on web APIs for blockchain interaction. diff --git a/tests/network/test_services.py b/tests/network/test_services.py index 668ed56..98422b6 100644 --- a/tests/network/test_services.py +++ b/tests/network/test_services.py @@ -8,18 +8,26 @@ from bit.network.services import ( RPCHost, RPCMethod, - BitpayAPI, - BlockchainAPI, NetworkAPI, + BitcoreAPI, + BlockchainAPI, SmartbitAPI, + BlockstreamAPI, + BlockchairAPI, set_service_timeout, ) -from tests.utils import catch_errors_raise_warnings, decorate_methods, raise_connection_error +from tests.utils import ( + catch_errors_raise_warnings, + decorate_methods, + raise_connection_error, + check_not_all_raise_errors, +) from bit.transaction import calc_txid MAIN_ADDRESS_USED1 = '1L2JsXHPMYuAa9ugvHGLwkdstCPUDemNCf' MAIN_ADDRESS_USED2 = '17SkEw2md5avVNyYgj6RiXuQKNwkXaxFyQ' +MAIN_ADDRESS_USED3 = '1CounterpartyXXXXXXXXXXXXXXXUWLpVr' MAIN_ADDRESS_UNUSED = '1DvnoW4vsXA1H9KDgNiMqY7iNkzC187ve1' TEST_ADDRESS_USED1 = 'n2eMqTT929pb1RDNuqEnxdaLau1rxy3efi' TEST_ADDRESS_USED2 = 'mmvP3mTe53qxHdPqXEvdu8WdC7GfQ2vmx5' @@ -30,6 +38,8 @@ TEST_TX_VALID = 'ff2b4641481f1ee553ba2c9f02f413a86f70240c35b5aee554f84e3efee93292' TX_INVALID = 'ff2b4641481f1ee553ba2c9f02f413a86f70240c35b5aee554f84e3efee93290' +set_service_timeout(30) + def all_items_common(seq): initial_set = set(seq[0]) @@ -188,7 +198,7 @@ def __call__(self, *args): class TestNetworkAPI: def test_get_balance_main_equal(self): - results = [call(MAIN_ADDRESS_USED2) for call in NetworkAPI.GET_BALANCE_MAIN] + results = check_not_all_raise_errors(NetworkAPI.GET_BALANCE_MAIN, NetworkAPI.IGNORED_ERRORS)(MAIN_ADDRESS_USED2) assert all(result == results[0] for result in results) def test_get_balance_main_failure(self): @@ -196,7 +206,7 @@ def test_get_balance_main_failure(self): MockBackend.get_balance(MAIN_ADDRESS_USED2) def test_get_balance_test_equal(self): - results = [call(TEST_ADDRESS_USED2) for call in NetworkAPI.GET_BALANCE_TEST] + results = check_not_all_raise_errors(NetworkAPI.GET_BALANCE_TEST, NetworkAPI.IGNORED_ERRORS)(TEST_ADDRESS_USED2) assert all(result == results[0] for result in results) def test_get_balance_test_failure(self): @@ -204,39 +214,47 @@ def test_get_balance_test_failure(self): MockBackend.get_balance_testnet(TEST_ADDRESS_USED2) def test_get_transactions_main_equal(self): - results = [call(MAIN_ADDRESS_USED1)[:100] for call in NetworkAPI.GET_TRANSACTIONS_MAIN] - assert all_items_common(results) + results = check_not_all_raise_errors(NetworkAPI.GET_TRANSACTIONS_MAIN, NetworkAPI.IGNORED_ERRORS)( + MAIN_ADDRESS_USED1 + ) + assert all_items_common([r[:100] for r in results]) def test_get_transactions_main_failure(self): with pytest.raises(ConnectionError): MockBackend.get_transactions(MAIN_ADDRESS_USED1) def test_get_transactions_test_equal(self): - results = [call(TEST_ADDRESS_USED2)[:100] for call in NetworkAPI.GET_TRANSACTIONS_TEST] - assert all_items_common(results) + results = check_not_all_raise_errors(NetworkAPI.GET_TRANSACTIONS_TEST, NetworkAPI.IGNORED_ERRORS)( + TEST_ADDRESS_USED2 + ) + assert all_items_common([r[:100] for r in results]) def test_get_transactions_test_failure(self): with pytest.raises(ConnectionError): MockBackend.get_transactions_testnet(TEST_ADDRESS_USED2) def test_get_transaction_by_id_main_equal(self): - results = [calc_txid(call(MAIN_TX_VALID)) for call in NetworkAPI.GET_TRANSACTION_BY_ID_MAIN] - assert all_items_equal(results) + results = check_not_all_raise_errors(NetworkAPI.GET_TRANSACTION_BY_ID_MAIN, NetworkAPI.IGNORED_ERRORS)( + MAIN_TX_VALID + ) + assert all_items_equal([calc_txid(r) for r in results]) def test_get_transaction_by_id_main_failure(self): with pytest.raises(ConnectionError): MockBackend.get_transaction_by_id(MAIN_TX_VALID) def test_get_transaction_by_id_test_equal(self): - results = [calc_txid(call(TEST_TX_VALID)) for call in NetworkAPI.GET_TRANSACTION_BY_ID_TEST] - assert all_items_equal(results) + results = check_not_all_raise_errors(NetworkAPI.GET_TRANSACTION_BY_ID_TEST, NetworkAPI.IGNORED_ERRORS)( + TEST_TX_VALID + ) + assert all_items_equal([calc_txid(r) for r in results]) def test_get_transaction_by_id_test_failure(self): with pytest.raises(ConnectionError): MockBackend.get_transaction_by_id_testnet(TEST_TX_VALID) def test_get_unspent_main_equal(self): - results = [call(MAIN_ADDRESS_USED2) for call in NetworkAPI.GET_UNSPENT_MAIN] + results = check_not_all_raise_errors(NetworkAPI.GET_UNSPENT_MAIN, NetworkAPI.IGNORED_ERRORS)(MAIN_ADDRESS_USED2) assert all_items_equal(results) def test_get_unspent_main_failure(self): @@ -244,7 +262,7 @@ def test_get_unspent_main_failure(self): MockBackend.get_unspent(MAIN_ADDRESS_USED1) def test_get_unspent_test_equal(self): - results = [call(TEST_ADDRESS_USED3) for call in NetworkAPI.GET_UNSPENT_TEST] + results = check_not_all_raise_errors(NetworkAPI.GET_UNSPENT_TEST, NetworkAPI.IGNORED_ERRORS)(TEST_ADDRESS_USED3) assert all_items_equal(results) def test_get_unspent_test_failure(self): @@ -359,102 +377,208 @@ class n(NetworkAPI): @decorate_methods(catch_errors_raise_warnings, NetworkAPI.IGNORED_ERRORS) -class TestBitpayAPI: +class TestBitcoreAPI: + def test_get_balance_return_type(self): + assert isinstance(BitcoreAPI.get_balance(MAIN_ADDRESS_USED1), int) + + def test_get_balance_main_used(self): + assert BitcoreAPI.get_balance(MAIN_ADDRESS_USED3) > 0 + + def test_get_balance_main_unused(self): + assert BitcoreAPI.get_balance(MAIN_ADDRESS_UNUSED) == 0 + + def test_get_balance_test_used(self): + assert BitcoreAPI.get_balance_testnet(TEST_ADDRESS_USED2) > 0 + + def test_get_balance_test_unused(self): + assert BitcoreAPI.get_balance_testnet(TEST_ADDRESS_UNUSED) == 0 + + def test_get_unspent_return_type(self): + assert iter(BitcoreAPI.get_unspent(MAIN_ADDRESS_USED1)) + + def test_get_unspent_main_used(self): + assert len(BitcoreAPI.get_unspent(MAIN_ADDRESS_USED3)) >= 2783 + + def test_get_unspent_main_unused(self): + assert len(BitcoreAPI.get_unspent(MAIN_ADDRESS_UNUSED)) == 0 + + def test_get_unspent_test_used(self): + assert len(BitcoreAPI.get_unspent_testnet(TEST_ADDRESS_USED2)) >= 196 + + def test_get_unspent_test_unused(self): + assert len(BitcoreAPI.get_unspent_testnet(TEST_ADDRESS_UNUSED)) == 0 + + +@decorate_methods(catch_errors_raise_warnings, NetworkAPI.IGNORED_ERRORS) +class TestBlockchainAPI: + def test_get_balance_return_type(self): + assert isinstance(BlockchainAPI.get_balance(MAIN_ADDRESS_USED1), int) + + def test_get_balance_used(self): + assert BlockchainAPI.get_balance(MAIN_ADDRESS_USED3) > 0 + + def test_get_balance_unused(self): + assert BlockchainAPI.get_balance(MAIN_ADDRESS_UNUSED) == 0 + + def test_get_transactions_return_type(self): + assert iter(BlockchainAPI.get_transactions(MAIN_ADDRESS_USED1)) + + def test_get_transactions_used(self): + assert len(BlockchainAPI.get_transactions(MAIN_ADDRESS_USED1)) >= 236 + + def test_get_transactions_unused(self): + assert len(BlockchainAPI.get_transactions(MAIN_ADDRESS_UNUSED)) == 0 + + def test_get_transaction_by_id_valid(self): + tx = BlockchainAPI.get_transaction_by_id(MAIN_TX_VALID) + assert calc_txid(tx) == MAIN_TX_VALID + + def test_get_transaction_by_id_invalid(self): + assert BlockchainAPI.get_transaction_by_id(TX_INVALID) == None + + def test_get_unspent_return_type(self): + assert iter(BlockchainAPI.get_unspent(MAIN_ADDRESS_USED1)) + + # ! BlockchainAPI only supports up to 1000 UTXOs + def test_get_unspent_main_used(self): + assert len(BlockchainAPI.get_unspent(MAIN_ADDRESS_USED2)) > 1 + + def test_get_unspent_main_used_too_many(self): + with pytest.raises(bit.exceptions.ExcessiveAddress): + BlockchainAPI.get_unspent(MAIN_ADDRESS_USED3) + + def test_get_unspent_main_unused(self): + assert len(BlockchainAPI.get_unspent(MAIN_ADDRESS_UNUSED)) == 0 + + +@decorate_methods(catch_errors_raise_warnings, NetworkAPI.IGNORED_ERRORS) +class TestBlockchairAPI: def test_get_balance_return_type(self): - assert isinstance(BitpayAPI.get_balance(MAIN_ADDRESS_USED1), int) + assert isinstance(BlockchairAPI.get_balance(MAIN_ADDRESS_USED1), int) def test_get_balance_main_used(self): - assert BitpayAPI.get_balance(MAIN_ADDRESS_USED1) > 0 + assert BlockchairAPI.get_balance(MAIN_ADDRESS_USED3) > 0 def test_get_balance_main_unused(self): - assert BitpayAPI.get_balance(MAIN_ADDRESS_UNUSED) == 0 + assert BlockchairAPI.get_balance(MAIN_ADDRESS_UNUSED) == 0 def test_get_balance_test_used(self): - assert BitpayAPI.get_balance_testnet(TEST_ADDRESS_USED2) > 0 + assert BlockchairAPI.get_balance_testnet(TEST_ADDRESS_USED2) > 0 def test_get_balance_test_unused(self): - assert BitpayAPI.get_balance_testnet(TEST_ADDRESS_UNUSED) == 0 + assert BlockchairAPI.get_balance_testnet(TEST_ADDRESS_UNUSED) == 0 def test_get_transactions_return_type(self): - assert iter(BitpayAPI.get_transactions(MAIN_ADDRESS_USED1)) + assert iter(BlockchairAPI.get_transactions(MAIN_ADDRESS_USED1)) def test_get_transactions_main_used(self): - assert len(BitpayAPI.get_transactions(MAIN_ADDRESS_USED1)) >= 218 + assert len(BlockchairAPI.get_transactions(MAIN_ADDRESS_USED1)) >= 236 def test_get_transactions_main_unused(self): - assert len(BitpayAPI.get_transactions(MAIN_ADDRESS_UNUSED)) == 0 + assert len(BlockchairAPI.get_transactions(MAIN_ADDRESS_UNUSED)) == 0 def test_get_transactions_test_used(self): - assert len(BitpayAPI.get_transactions_testnet(TEST_ADDRESS_USED2)) >= 444 + assert len(BlockchairAPI.get_transactions_testnet(TEST_ADDRESS_USED2)) >= 444 def test_get_transactions_test_unused(self): - assert len(BitpayAPI.get_transactions_testnet(TEST_ADDRESS_UNUSED)) == 0 + assert len(BlockchairAPI.get_transactions_testnet(TEST_ADDRESS_UNUSED)) == 0 def test_get_transaction_by_id_valid(self): - tx = BitpayAPI.get_transaction_by_id(MAIN_TX_VALID) + tx = BlockchairAPI.get_transaction_by_id(MAIN_TX_VALID) assert calc_txid(tx) == MAIN_TX_VALID def test_get_transaction_by_id_invalid(self): - assert BitpayAPI.get_transaction_by_id(TX_INVALID) == None + assert BlockchairAPI.get_transaction_by_id(TX_INVALID) == None def test_get_transaction_by_id_test_valid(self): - tx = BitpayAPI.get_transaction_by_id_testnet(TEST_TX_VALID) + tx = BlockchairAPI.get_transaction_by_id_testnet(TEST_TX_VALID) assert calc_txid(tx) == TEST_TX_VALID def test_get_transaction_by_id_test_invalid(self): - assert BitpayAPI.get_transaction_by_id_testnet(TX_INVALID) == None + assert BlockchairAPI.get_transaction_by_id_testnet(TX_INVALID) == None def test_get_unspent_return_type(self): - assert iter(BitpayAPI.get_unspent(MAIN_ADDRESS_USED1)) + assert iter(BlockchairAPI.get_unspent(MAIN_ADDRESS_USED1)) def test_get_unspent_main_used(self): - assert len(BitpayAPI.get_unspent(MAIN_ADDRESS_USED2)) >= 1 + assert len(BlockchairAPI.get_unspent(MAIN_ADDRESS_USED3)) >= 2783 def test_get_unspent_main_unused(self): - assert len(BitpayAPI.get_unspent(MAIN_ADDRESS_UNUSED)) == 0 + assert len(BlockchairAPI.get_unspent(MAIN_ADDRESS_UNUSED)) == 0 def test_get_unspent_test_used(self): - assert len(BitpayAPI.get_unspent_testnet(TEST_ADDRESS_USED2)) >= 194 + assert len(BlockchairAPI.get_unspent_testnet(TEST_ADDRESS_USED2)) >= 196 def test_get_unspent_test_unused(self): - assert len(BitpayAPI.get_unspent_testnet(TEST_ADDRESS_UNUSED)) == 0 + assert len(BlockchairAPI.get_unspent_testnet(TEST_ADDRESS_UNUSED)) == 0 @decorate_methods(catch_errors_raise_warnings, NetworkAPI.IGNORED_ERRORS) -class TestBlockchainAPI: +class TestBlockstreamAPI: def test_get_balance_return_type(self): - assert isinstance(BlockchainAPI.get_balance(MAIN_ADDRESS_USED1), int) + assert isinstance(BlockstreamAPI.get_balance(MAIN_ADDRESS_USED1), int) - def test_get_balance_used(self): - assert BlockchainAPI.get_balance(MAIN_ADDRESS_USED1) > 0 + def test_get_balance_main_used(self): + assert BlockstreamAPI.get_balance(MAIN_ADDRESS_USED3) > 0 - def test_get_balance_unused(self): - assert BlockchainAPI.get_balance(MAIN_ADDRESS_UNUSED) == 0 + def test_get_balance_main_unused(self): + assert BlockstreamAPI.get_balance(MAIN_ADDRESS_UNUSED) == 0 + + def test_get_balance_test_used(self): + assert BlockstreamAPI.get_balance_testnet(TEST_ADDRESS_USED2) > 0 + + def test_get_balance_test_unused(self): + assert BlockstreamAPI.get_balance_testnet(TEST_ADDRESS_UNUSED) == 0 def test_get_transactions_return_type(self): - assert iter(BlockchainAPI.get_transactions(MAIN_ADDRESS_USED1)) + assert iter(BlockstreamAPI.get_transactions(MAIN_ADDRESS_USED1)) - def test_get_transactions_used(self): - assert len(BlockchainAPI.get_transactions(MAIN_ADDRESS_USED1)) >= 218 + def test_get_transactions_main_used(self): + assert len(BlockstreamAPI.get_transactions(MAIN_ADDRESS_USED1)) >= 236 - def test_get_transactions_unused(self): - assert len(BlockchainAPI.get_transactions(MAIN_ADDRESS_UNUSED)) == 0 + def test_get_transactions_main_unused(self): + assert len(BlockstreamAPI.get_transactions(MAIN_ADDRESS_UNUSED)) == 0 + + def test_get_transactions_test_used(self): + assert len(BlockstreamAPI.get_transactions_testnet(TEST_ADDRESS_USED2)) >= 444 + + def test_get_transactions_test_unused(self): + assert len(BlockstreamAPI.get_transactions_testnet(TEST_ADDRESS_UNUSED)) == 0 def test_get_transaction_by_id_valid(self): - tx = BlockchainAPI.get_transaction_by_id(MAIN_TX_VALID) + tx = BlockstreamAPI.get_transaction_by_id(MAIN_TX_VALID) assert calc_txid(tx) == MAIN_TX_VALID def test_get_transaction_by_id_invalid(self): - assert BlockchainAPI.get_transaction_by_id(TX_INVALID) == None + assert BlockstreamAPI.get_transaction_by_id(TX_INVALID) == None + + def test_get_transaction_by_id_test_valid(self): + tx = BlockstreamAPI.get_transaction_by_id_testnet(TEST_TX_VALID) + assert calc_txid(tx) == TEST_TX_VALID + + def test_get_transaction_by_id_test_invalid(self): + assert BlockstreamAPI.get_transaction_by_id_testnet(TX_INVALID) == None def test_get_unspent_return_type(self): - assert iter(BlockchainAPI.get_unspent(MAIN_ADDRESS_USED1)) + assert iter(BlockstreamAPI.get_unspent(MAIN_ADDRESS_USED1)) + #! BlockstreamAPI blocks addresses with "too many history entries" for UTXOs. + #! Using an address with fewer UTXOs instead. def test_get_unspent_main_used(self): - assert len(BlockchainAPI.get_unspent(MAIN_ADDRESS_USED2)) >= 1 + assert len(BlockstreamAPI.get_unspent(MAIN_ADDRESS_USED2)) >= 1 + + def test_get_unspent_main_too_many(self): + with pytest.raises(bit.exceptions.ExcessiveAddress): + BlockstreamAPI.get_unspent(MAIN_ADDRESS_USED3) def test_get_unspent_main_unused(self): - assert len(BlockchainAPI.get_unspent(MAIN_ADDRESS_UNUSED)) == 0 + assert len(BlockstreamAPI.get_unspent(MAIN_ADDRESS_UNUSED)) == 0 + + def test_get_unspent_test_used(self): + assert len(BlockstreamAPI.get_unspent_testnet(TEST_ADDRESS_USED2)) >= 196 + + def test_get_unspent_test_unused(self): + assert len(BlockstreamAPI.get_unspent_testnet(TEST_ADDRESS_UNUSED)) == 0 @decorate_methods(catch_errors_raise_warnings, NetworkAPI.IGNORED_ERRORS) @@ -463,7 +587,7 @@ def test_get_balance_return_type(self): assert isinstance(SmartbitAPI.get_balance(MAIN_ADDRESS_USED1), int) def test_get_balance_main_used(self): - assert SmartbitAPI.get_balance(MAIN_ADDRESS_USED1) > 0 + assert SmartbitAPI.get_balance(MAIN_ADDRESS_USED3) > 0 def test_get_balance_main_unused(self): assert SmartbitAPI.get_balance(MAIN_ADDRESS_UNUSED) == 0 @@ -478,7 +602,7 @@ def test_get_transactions_return_type(self): assert iter(SmartbitAPI.get_transactions(MAIN_ADDRESS_USED1)) def test_get_transactions_main_used(self): - assert len(SmartbitAPI.get_transactions(MAIN_ADDRESS_USED1)) >= 218 + assert len(SmartbitAPI.get_transactions(MAIN_ADDRESS_USED1)) >= 236 def test_get_transactions_main_unused(self): assert len(SmartbitAPI.get_transactions(MAIN_ADDRESS_UNUSED)) == 0 @@ -507,13 +631,13 @@ def test_get_unspent_return_type(self): assert iter(SmartbitAPI.get_unspent(MAIN_ADDRESS_USED1)) def test_get_unspent_main_used(self): - assert len(SmartbitAPI.get_unspent(MAIN_ADDRESS_USED2)) >= 1 + assert len(SmartbitAPI.get_unspent(MAIN_ADDRESS_USED3)) >= 2783 def test_get_unspent_main_unused(self): assert len(SmartbitAPI.get_unspent(MAIN_ADDRESS_UNUSED)) == 0 def test_get_unspent_test_used(self): - assert len(SmartbitAPI.get_unspent_testnet(TEST_ADDRESS_USED2)) >= 194 + assert len(SmartbitAPI.get_unspent_testnet(TEST_ADDRESS_USED2)) >= 196 def test_get_unspent_test_unused(self): assert len(SmartbitAPI.get_unspent_testnet(TEST_ADDRESS_UNUSED)) == 0 diff --git a/tests/utils.py b/tests/utils.py index c48e2b7..5b49dca 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -22,7 +22,26 @@ def wrapper(*args, **kwargs): try: f(*args, **kwargs) except ignored_errors: - warnings.warn('Unreachable API from '.format(f.__name__), Warning) + warnings.warn('Unreachable API from {}'.format(f.__name__), Warning) assert True return wrapper + + +def check_not_all_raise_errors(fs, ignored_errors): # pragma: no cover + def wrapper(*args, **kwargs): + success = False + ret = [] + for f in fs: + try: + ret.append(f(*args, **kwargs)) + success = True + except ignored_errors: + continue + + if not success: + raise ConnectionError('All API calls to {} are unreachable'.format(fs[0].__name__)) + + return ret + + return wrapper