Simon Volpert minipos / master bch.py
master

Tree @master (Download .tar.gz)

bch.py @masterraw · history · blame

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
#!/usr/bin/env python3
# bch.py - A Bitcoin Cash utility library
# Author: Simon Volpert <simon@simonvolpert.com>
# This program is free software, released under the Apache License, Version 2.0. See the LICENSE file for more information

import urllib.request
import json
import random
import sys
import datetime
import logging
#optional import pycoin.key
#optional import cashaddr # Local library file

MAX_ERRORS = 10
TIMEOUT = 5
exchanges = [
	{
		'url': 'https://api.coinmarketcap.com/v2/ticker/1831/?convert={cur}',
		'price_key': 'data.quotes.{cur}.price',
	},
	{
		'url': 'https://api.coinbase.com/v2/exchange-rates?currency=BCH',
		'price_key': 'data.rates.{cur}',
	},
	{
		'url': 'https://apiv2.bitcoinaverage.com/indices/global/ticker/short?crypto=BCH&fiat={cur}',
		'price_key': 'BCH{cur}.last',
	},
	{
		# Extremely limited ticker set
		'url': 'https://api.kraken.com/0/public/Ticker?pair=BCH{cur}',
		'price_key': 'result.BCH{cur}.c.0',
	},
]
explorers = [
	{
		'url': 'https://cashexplorer.bitcoin.com/api/addr/{address}',
		'tx_url': 'https://cashexplorer.bitcoin.com/api/tx/{txid}',
		'balance_key': None,
		'confirmed_key': 'balance',
		'unconfirmed_key': 'unconfirmedBalance',
		'last_tx_key': 'transactions.0',
		'tx_time_key': 'time',
		'tx_inputs_key': 'vin',
		'tx_in_double_spend_key': 'doubleSpentTxID',
		'tx_outputs_key': 'vout',
		'tx_out_value_key': 'value',
		'tx_out_address_key': 'scriptPubKey.addresses.0',
		'tx_double_spend_key': None,
		'tx_fee_key': 'fees',
		'tx_size_key': 'size',
		'tx_confirmations_key': 'confirmations',
		'unit_satoshi': False,
		'prefixes': '13',
	},
	{
		'url': 'https://blockdozer.com/api/addr/{address}',
		'tx_url': 'https://blockdozer.com/api/tx/{txid}',
		'balance_key': None,
		'confirmed_key': 'balance',
		'unconfirmed_key': 'unconfirmedBalance',
		'last_tx_key': 'transactions.-1',
		'tx_time_key': 'time',
		'tx_inputs_key': 'vin',
		'tx_in_double_spend_key': 'doubleSpentTxID',
		'tx_outputs_key': 'vout',
		'tx_out_value_key': 'value',
		'tx_out_address_key': 'scriptPubKey.addresses.0',
		'tx_double_spend_key': None,
		'tx_fee_key': 'fees',
		'tx_size_key': 'size',
		'tx_confirmations_key': 'confirmations',
		'unit_satoshi': False,
		'prefixes': 'qp13',
	},
	{
		'url': 'https://bch-insight.bitpay.com/api/addr/{address}',
		'tx_url': 'https://bch-insight.bitpay.com/api/tx/{txid}',
		'balance_key': 'balance',
		'confirmed_key': None,
		'unconfirmed_key': 'unconfirmedBalance',
		'last_tx_key': 'transactions.0',
		'tx_time_key': 'time',
		'tx_inputs_key': 'vin',
		'tx_in_double_spend_key': 'doubleSpentTxID',
		'tx_outputs_key': 'vout',
		'tx_out_value_key': 'value',
		'tx_out_address_key': 'scriptPubKey.addresses.0',
		'tx_double_spend_key': None,
		'tx_fee_key': 'fees',
		'tx_size_key': 'size',
		'tx_confirmations_key': 'confirmations',
		'unit_satoshi': False,
		'prefixes': 'qp',
	},
	{
		'url': 'https://bch-chain.api.btc.com/v3/address/{address}',
		'tx_url': 'https://bch-chain.api.btc.com/v3/tx/{txid}',
		'balance_key': 'data.balance',
		'confirmed_key': None,
		'unconfirmed_key': 'data.unconfirmed_received',
		'last_tx_key': 'data.last_tx',
		'tx_time_key': 'data.created_at',
		'tx_inputs_key': 'data.inputs',
		'tx_in_double_spend_key': None,
		'tx_outputs_key': 'data.outputs',
		'tx_out_value_key': 'value',
		'tx_out_address_key': 'addresses.0',
		'tx_double_spend_key': 'data.is_double_spend',
		'tx_fee_key': 'data.fee',
		'tx_size_key': 'data.vsize',
		'tx_confirmations_key': 'data.confirmations',
		'unit_satoshi': True,
		'prefixes': '13',
	},
	{
		'url': 'https://bitcoincash.blockexplorer.com/api/addr/{address}',
		'tx_url': 'https://bitcoincash.blockexplorer.com/api/tx/{txid}',
		'balance_key': None,
		'confirmed_key': 'balance',
		'unconfirmed_key': 'unconfirmedBalance',
		'last_tx_key': 'transactions.0',
		'tx_time_key': 'time',
		'tx_inputs_key': 'vin',
		'tx_in_double_spend_key': 'doubleSpentTxID',
		'tx_outputs_key': 'vout',
		'tx_out_value_key': 'value',
		'tx_out_address_key': 'scriptPubKey.addresses.0',
		'tx_double_spend_key': None,
		'tx_fee_key': 'fees',
		'tx_size_key': 'size',
		'tx_confirmations_key': 'confirmations',
		'unit_satoshi': False,
		'prefixes': '13',
	},
	{
		'url': 'https://rest.bitbox.earth/v1/address/details/{address}',
		'tx_url': 'https://rest.bitbox.earth/v1/transaction/details/{txid}',
		'balance_key': None,
		'confirmed_key': 'balance',
		'unconfirmed_key': 'unconfirmedBalance',
		'last_tx_key': 'transactions.0',
		'tx_time_key': 'time',
		'tx_inputs_key': 'vin',
		'tx_in_double_spend_key': 'doubleSpentTxID',
		'tx_outputs_key': 'vout',
		'tx_out_value_key': 'value',
		'tx_out_address_key': 'scriptPubKey.addresses.0',
		'tx_double_spend_key': None,
		'tx_fee_key': 'fees',
		'tx_size_key': 'size',
		'tx_confirmations_key': 'confirmations',
		'unit_satoshi': False,
		'prefixes': 'qp13',
	},
]

# Initialize explorer and exchange list
random.seed()
random.shuffle(explorers)
for _server in explorers:
	# Using urllib.parse here would require an instruction similar to this:
	#     _server['name'] = '.'.join(urllib.parse.urlparse(_server['url'])[1].split('.')[-2:])
	# Which is neither any shorter, nor simpler, AND requires an additional import to work
	_server['name'] = '.'.join(_server['url'].split('/')[2].split('.')[-2:])
for _server in exchanges:
	_server['name'] = '.'.join(_server['url'].split('/')[2].split('.')[-2:])


def btc(amount):
	'''Return a native bitcoin amount representation'''
	result = ('%.8f' % float(amount)).rstrip('0.')
	if result == '':
		return '0'
	return result


def bits(amount):
	'''Return the amount represented in bits/cash'''
	amount = fiat(float(amount) * 1000000)
	if amount.endswith('.00'):
		amount = amount[:-3]
	return amount


def fiat(amount):
	'''Return the amount represented in a dollar/cent notation'''
	return ('%.2f' % float(amount))


def color(amount):
	'''Return the amount as colorized HTML'''
	# Inspired by Thomas Zander's proposal; Reference:
	# https://twitter.com/FloweeTheHub/status/996341710027403265
	# Only works for white or near-white background!
	wholes, sats = '{:.8f}'.format(float(amount)).split('.')
	mils = sats[0:3]
	bits = sats[3:6]
	sats = sats[6:8]
	whole_color = 'gray' if wholes == '0' else 'black'
	dot_color = 'lightgray' if mils == '000' and bits == '000' and sats == '00' else 'gray'
	mil_color = 'lightgray' if mils == '000' and bits == '000' and sats == '00' else 'green'
	bit_color = 'lightgray' if bits == '000' and sats == '00' else 'darkgreen'
	if sats == '00':
		sats = '&mdash;'
	result = '''<span style="color: {}">{}</span><span style="color: {}; padding-left: 0">.</span><span style="color: {}; padding-left: 0">{}</span><span style="color: {}; padding-left: 0.25em">{}</span><span style="color: gray; padding-left: 0.25em">{}</span>'''.format(whole_color, wholes, dot_color, mil_color, mils, bit_color, bits, sats)
	return result


def jsonload(url):
	'''Load a web page and return the resulting JSON object'''
	request = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
	with urllib.request.urlopen(request, timeout=TIMEOUT) as webpage:
		data = str(webpage.read(), 'UTF-8')
		data = json.loads(data)
	return data


def get_value(json_object, key_path):
	'''Get the value at the end of a dot-separated key path'''
	# Make sure the explorer did not return an error
	if 'err_no' in json_object:
		if json_object['err_no'] == 1:
			raise urllib.error.HTTPError(None, 404, 'Resource Not Found', None, None)
		elif json_object['err_no'] == 2:
			raise urllib.error.HTTPError(None, 400, 'Parameter Error', None, None)
	for k in key_path.split('.'):
		# Process integer indices
		try:
			k = int(k)
		except ValueError:
			pass
		# Expand the key
		try:
			json_object = json_object[k]
		except (TypeError, IndexError):
			# The key rightfully doesn't exist
			return False
	return json_object


def get_price(currency, exchange=exchanges[0]['name']):
	'''Get the current Bitcoin Cash price in the desired currency'''
	found = False
	for server in exchanges:
		if server['name'] == exchange:
			found = True
			break
	if not found:
		raise KeyError('{src} is not in list of exchanges'.format(src=exchange))
	data = jsonload(server['url'].format(cur=currency.upper(), cur_lower=currency.lower()))
	rate = float(get_value(data, server['price_key'].format(cur=currency.upper(), cur_lower=currency.lower())))
	if rate == 0.0:
		raise ValueError('Returned exchange rate is zero')
	return round(rate, 2)


def pick_explorer(server_name=None, address_prefix=None, ignore_errors=False):
	'''Advance the list of explorers until one that matches the requirements is found'''
	for __ in explorers:
		# Cycle to the next server
		server = explorers.pop(0)
		if server is None:
			raise StopIteration('Server list depleted')
		explorers.append(server)
		# Populate server error count if necessary
		if 'errors' not in server:
			logging.debug('Adding control fields to {} definition'.format(server['name']))
			server['errors'] = 0
			server['last_error'] = None
			server['last_data'] = None
		# Filter by server name
		if server_name is not None and server['name'] != server_name:
			continue
		# Filter by error rate
		if not ignore_errors and server['errors'] > MAX_ERRORS:
			logging.debug('Skipping {} based on error rates'.format(server['name']))
			continue
		# Filter by address prefix
		if address_prefix is not None and address_prefix not in server['prefixes']:
			logging.debug('Skipping {} due to unsupported address prefix'.format(server['name']))
			continue
		return server
	raise KeyError('No servers match the requirements')


class AddressInfo(object):
	'''A representation of a block explorer's idea of a bitcoin address state

Provided properties:
raw_data        (dict) the raw explorer JSON response
address         (str) the address in cash format
legacy_address  (str) the address in legacy format
confirmed       (float) the confirmed balance of the address
unconfirmed     (float) the unconfirmed balance of the address
'''

	def __init__(self, address, explorer=None, verify=False, ignore_errors=False):
		'''Keyword arguments:
address         (str) bitcoin_address or tuple(str xpub, int index)
explorer        (str) the name of a specific explorer to query
verify          (bool) the results should be verified with another explorer
ignore_errors   (str) don't skip explorers disabled for excessive errors
'''
		# Incompatible parameters
		if verify and explorer is not None:
			raise ValueError('The "verify" and "explorer" parameters are incompatible')
		# Generated address request
		xpub = None
		idx = None
		if type(address) is tuple:
			xpub, idx = address
		# Strip prefix
		elif address[0].lower() == 'b':
			address = address.split(':')[1]
		# Normalize case
		if address[0] in 'QP':
			address = address.lower()
		# Generate all address versions
		if address[0] in 'qp':
			self.address = address
			self.legacy_address = None
		elif address[0] in '13':
			self.address = None
			self.legacy_address = address
		try:
			if xpub is not None:
				self.address = generate_address(xpub, idx)
				self.legacy_address = generate_address(xpub, idx, False)
			elif self.address is None:
				self.address = convert_address(self.legacy_address)
			elif self.legacy_address is None:
				self.legacy_address = convert_address(self.address)
		except ImportError:
			if xpub is not None:
				raise
		# Add a temporary separator
		explorers.append(None)
		results = []
		# Figure out specific address type availability
		if self.address is not None and self.legacy_address is not None:
			prefixes = 'qp13'
		elif self.legacy_address is None:
			prefixes = 'qp'
		else:
			prefixes = '13'
		while explorers[0] is not None:
			# Query the next explorer
			if prefixes == 'qp13':
				server = pick_explorer(explorer, ignore_errors=ignore_errors)
			else:
				server = pick_explorer(explorer, address_prefix=prefixes[0], ignore_errors=ignore_errors)
			# Try to get balance
			try:
				# Get and cache the received data for possible future analysis
				logging.debug('Querying {}'.format(server['name']))
				if 'q' in server['prefixes']:
					json = jsonload(server['url'].format(address=self.address))
				else:
					json = jsonload(server['url'].format(address=self.legacy_address))
				server['last_data'] = self.raw_data = json
				# Conditional balance processing
				# TODO: This is a mighty convoluted way of doing it and needs rethinking
				if server['confirmed_key'] is not None and server['unconfirmed_key'] is not None:
					confirmed = float(get_value(json, server['confirmed_key']))
					unconfirmed = float(get_value(json, server['unconfirmed_key']))
				elif server['confirmed_key'] is not None and server['balance_key'] is not None:
					confirmed = float(get_value(json, server['confirmed_key']))
					balance = float(get_value(json, server['balance_key']))
					unconfirmed = balance - confirmed
				elif server['unconfirmed_key'] is not None and server['balance_key'] is not None:
					balance = float(get_value(json, server['balance_key']))
					unconfirmed = float(get_value(json, server['unconfirmed_key']))
					confirmed = balance - unconfirmed
				else:
					raise RuntimeError('Cannot figure out address balance')
				# Get the last txid
				try:
					txid = get_value(server['last_data'], server['last_tx_key'])
				except (KeyError, IndexError):
					txid = None
				if not txid:
					txid = None
			except KeyboardInterrupt:
				explorers.remove(None)
				raise
			except:
				server['errors'] += 1
				exception = sys.exc_info()[1]
				try:
					server['last_error'] = str(exception.reason)
				except AttributeError:
					server['last_error'] = str(exception)
				if server['errors'] > MAX_ERRORS:
					logging.error('Excessive errors from {server}, disabling. Last error: {error}'.format(server=server['name'], error=server['last_error']))
				continue
			# Convert balances to native units
			if server['unit_satoshi']:
				confirmed /= 100000000
				unconfirmed /= 100000000
			if server['errors'] > 0:
				server['errors'] -= 1
			data = (confirmed, unconfirmed, txid)
			if verify:
				if data not in results:
					results.append(data)
					continue
			results.append(data)
			break
		# If the end of the server list was reached without a single success, assume a network error
		explorers.remove(None)
		if results == []:
			for server in explorers:
				if server['errors'] > 0:
					server['errors'] -= 1
			raise ConnectionError('No results from any known block explorer')
		# Populate instance attributes
		self.confirmed, self.unconfirmed, self.last_txid = results[-1]


def get_balance(address, explorer=None, verify=False, ignore_errors=False):
	'''Get the current balance of an address from a block explorer
Takes the same arguments as AddressInfo()
Returns tuple(confirmed_balance, unconfirmed_balance)
'''
	addr = AddressInfo(address, explorer, verify, ignore_errors)
	return addr.confirmed, addr.unconfirmed


def get_last_txid(address, explorer=None, verify=False, ignore_errors=False):
	'''Get the last tx associated with an address
Takes the same arguments as AddressInfo()
Returns str(txid)
'''
	addr = AddressInfo(address, explorer, verify, ignore_errors)
	return addr.last_txid


class TxNotFoundError(Exception):
	'''Raised when a requested txid is not known to any block explorer'''


class TxInfo(object):
	'''A representation of a block explorer's idea of a bitcoin transaction

Provided properties:
raw_data      (dict) the raw explorer JSON response
time          (datetime) the time this transaction was first seen or mined
outputs       (dict) a mapping of receiving addresses to receiving values
              both address formats are provided if possible
double_spend  (bool) whether or not this transaction has a competing transaction
size          (int) the size of the transaction in bytes
fee           (float) the transaction fee
fee_per_byte  (float) the transaction fee per byte
confirmations (int) the number of confirmations this transaction has

Will raise TxNotFoundError if the passed txid is not known to any explorer
'''

	def __init__(self, txid, explorer=None, ignore_errors=None):
		'''Keyword arguments:

txid          (str) the txid to look for
explorer      (str) the name of a specific explorer to query
ignore_errors (str) don't skip explorers disabled for excessive errors
'''
		# Add a temporary separator
		explorers.append(None)
		#tx_size = 10
		while explorers[0] is not None:
			# Query the next explorer
			try:
				server = pick_explorer(explorer, ignore_errors=ignore_errors)
			except StopIteration:
				break
			try:
				# Get and cache the received data for possible future analysis
				logging.debug('Querying {}'.format(server['name']))
				json = jsonload(server['tx_url'].format(txid=txid))
				server['last_data'] = self.raw_data = json
				# Figure out if the tx is a double spend
				if server['tx_double_spend_key'] is not None:
					self.double_spend = get_value(json, server['tx_double_spend_key'])
				else:
					self.double_spend = False
					for i, __ in enumerate(get_value(json, server['tx_inputs_key'])):
						#tx_size += 148
						try:
							if get_value(json, '.'.join([server['tx_inputs_key'], str(i), server['tx_in_double_spend_key']])) is not None:
								self.double_spend = True
						# Workaround for explorers that don't provide empty double spend keys
						except KeyError as k:
							if str(k).strip('\'') != server['tx_in_double_spend_key']:
								raise
				# Assemble list of output values
				self.outputs = {}
				for i, __ in enumerate(get_value(json, server['tx_outputs_key'])):
					#tx_size += 34
					try:
						addr = get_value(json, '.'.join([server['tx_outputs_key'], str(i), server['tx_out_address_key']]))
					except KeyError:
						# Most likely an OP_RETURN
						continue
					value = float(get_value(json, '.'.join([server['tx_outputs_key'], str(i), server['tx_out_value_key']])))
					if server['unit_satoshi']:
						value /= 100000000
					if addr[0] in 'bB':
						addr = addr.lower().split(':')[1]
					self.outputs[addr] = value
					# Provide both address formats if possible
					try:
						self.outputs[convert_address(addr)] = value
					except ImportError:
						pass
				# Figure out the tx size and fee
				self.fee = float(get_value(json, server['tx_fee_key']))
				#self.size = tx_size
				self.size = get_value(json, server['tx_size_key'])
				if server['unit_satoshi']:
					self.fee /= 100000000
				self.fee_per_byte = self.fee / self.size * 100000000
				self.time = datetime.datetime.fromtimestamp(get_value(json, server['tx_time_key']))
				self.confirmations = get_value(json, server['tx_confirmations_key'])
				break
			except KeyboardInterrupt:
				explorers.remove(None)
				raise
			except:
				exception = sys.exc_info()[1]
				if isinstance(exception, urllib.error.HTTPError) and exception.code == 404:
					continue
				server['errors'] += 1
				try:
					server['last_error'] = str(exception.reason)
				except AttributeError:
					server['last_error'] = str(exception)
				if server['errors'] > MAX_ERRORS:
					logging.error('Excessive errors from {server}, disabling. Last error: {error}'.format(server=server['name'], error=server['last_error']))
				continue
		try:
			explorers.remove(None)
		except ValueError:
			pass
		if self.__dict__ == {}:
			raise TxNotFoundError('No results from any known block explorer')


def get_tx_propagation(txid, threshold=100, callback=None, stop_on_double_spend=False, ignore_errors=False):
	'''Estimate a transaction's propagation across the Bitcoin Cash network
Returns a tuple consisting of:
  * The percentage of explorers that are aware of the txid;
  * The transaction's double spend status.

Keyword arguments:
txid       The txid to query
threshold  A percentage at which the propagation check is considered finished
callback   A function which will be called after every explorer query
           The function will be called with the perliminary results
stop_on_double_spend
           The check will be aborted as soon as a double spend is detected
'''
	sightings = 0
	double_spend = False
	num_servers = len(explorers)
	propagation = 0
	for server in explorers.copy():
		if not ignore_errors and 'errors' in server and server['errors'] > MAX_ERRORS:
			num_servers -= 1
			continue
		try:
			tx = TxInfo(txid, explorer=server['name'], ignore_errors=ignore_errors)
		except TxNotFoundError:
			continue
		except KeyboardInterrupt:
			raise
		except:
			exception = sys.exc_info()[1]
			try:
				error = exception.reason
			except AttributeError:
				error = exception
			logging.error('Could not fetch explorer data: {}'.format(error))
			continue
		if tx.double_spend:
			double_spend = True
		sightings += 1
		propagation = 100 * sightings / num_servers
		if callback is not None:
			callback(propagation, double_spend)
		if propagation >= threshold:
			break
		elif double_spend and stop_on_double_spend:
			break
	return propagation, double_spend


def generate_address(xpub, idx, cash=True):
	'''Generate a bitcoin cash or bitcoin legacy address from the extended public key at the given index'''
	# Optional dependencies if unused
	import pycoin.key
	import cashaddr
	subkey = pycoin.key.Key.from_text(xpub).subkey(0).subkey(idx)
	if cash:
		return cashaddr.encode('bitcoincash', 0, subkey.hash160())
	return subkey.address()


def validate_key(key):
	'''Check the validity of a key or an address'''
	# Optional dependencies if unused
	import pycoin.key
	import cashaddr
	if ':' in key:
		key = key.split(':')[1]
	if key[0] in '13x':
		try:
			pycoin.key.Key.from_text(key)
		except pycoin.encoding.EncodingError:
			return False
	elif key[0] in 'qpQP':
		try:
			cashaddr.decode('bitcoincash:' + key.lower())
		except ValueError:
			return False
	else:
		return False
	return True


def convert_address(address):
	'''Convert an address back and forth between cash and legacy formats'''
	# Optional dependencies if unused
	import pycoin.key
	import cashaddr
	if address[0] in 'bB':
		address = address.split(':')[1]
	if address[0] in '13':
		subkey = pycoin.key.Key.from_text(address)
		return cashaddr.encode('bitcoincash', 0, subkey.hash160())
	elif address[0] in 'qpQP':
		subkey = cashaddr.decode('bitcoincash:' + address.lower())[2]
		return pycoin.key.Key(hash160=subkey).address()
	else:
		raise ValueError('Unsupported address format')


if __name__ == '__main__':
	print('===== Known block explorers =====')
	for server in explorers:
		print(server['name'])
	try:
		cur = sys.argv[1].upper()
		print('\n===== Known exchange rate sources with {cur} support ====='.format(cur=cur))
	except IndexError:
		print('\n===== Known exchange rate sources =====')
	for server in exchanges:
		support = True
		try:
			get_price(cur, exchange=server['name'])
		except (KeyError, ValueError, urllib.error.HTTPError):
			error = sys.exc_info()[1]
			try:
				error = error.reason
			except AttributeError:
				pass
			if isinstance(error, KeyError):
				error = 'Key error: ' + str(error)
			print('{src} does not provide {cur} exchange rate: {error}'.format(src=server['name'], cur=cur, error=error))
			support = False
		except KeyboardInterrupt:
			sys.exit()
		except NameError:
			pass
		if support:
			print(server['name'])