Last active
November 7, 2021 20:42
-
-
Save chris-belcher/61b1abf9f60063aac495a2546876b3e2 to your computer and use it in GitHub Desktop.
bitcoin-blockchain-feed-bot
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#jsonrpc.py from https://github.com/JoinMarket-Org/joinmarket/blob/master/joinmarket/jsonrpc.py | |
#copyright # Copyright (C) 2013,2015 by Daniel Kraft <[email protected]> and phelix / blockchained.com | |
import base64 | |
import httplib | |
import json | |
class JsonRpcError(Exception): | |
def __init__(self, obj): | |
self.message = obj | |
class JsonRpcConnectionError(JsonRpcError): pass | |
class JsonRpc(object): | |
def __init__(self, host, port, user, password): | |
self.host = host | |
self.port = port | |
self.authstr = "%s:%s" % (user, password) | |
self.queryId = 1 | |
def queryHTTP(self, obj): | |
headers = {"User-Agent": "joinmarket", | |
"Content-Type": "application/json", | |
"Accept": "application/json"} | |
headers["Authorization"] = "Basic %s" % base64.b64encode(self.authstr) | |
body = json.dumps(obj) | |
try: | |
conn = httplib.HTTPConnection(self.host, self.port) | |
conn.request("POST", "", body, headers) | |
response = conn.getresponse() | |
if response.status == 401: | |
conn.close() | |
raise JsonRpcConnectionError( | |
"authentication for JSON-RPC failed") | |
# All of the codes below are 'fine' from a JSON-RPC point of view. | |
if response.status not in [200, 404, 500]: | |
conn.close() | |
raise JsonRpcConnectionError("unknown error in JSON-RPC") | |
data = response.read() | |
conn.close() | |
return json.loads(data) | |
except JsonRpcConnectionError as exc: | |
raise exc | |
except Exception as exc: | |
raise JsonRpcConnectionError("JSON-RPC connection failed. Err:" + | |
repr(exc)) | |
def call(self, method, params): | |
currentId = self.queryId | |
self.queryId += 1 | |
request = {"method": method, "params": params, "id": currentId} | |
response = self.queryHTTP(request) | |
if response["id"] != currentId: | |
raise JsonRpcConnectionError("invalid id returned by query") | |
if response["error"] is not None: | |
raise JsonRpcError(response["error"]) | |
return response["result"] | |
#irc bot code starts here | |
##single file, no dependencies | |
#by belcher | |
#Ares punished Alectryon by turning him into a rooster which never forgets | |
#to announce the arrival of the sun in the morning by its crowing. | |
import socket, time | |
from datetime import datetime | |
from getpass import getpass | |
#configuration stuff | |
nick = 'Alectryon' | |
hostport = ('irc.libera.chat', 6667) | |
nickserv_password = getpass('enter nickserv password for ' + nick + ': ') | |
channel = '#bitcoin-blocks' | |
#TODO :orwell.freenode.net 437 * Alectryon :Nick/channel is temporarily unavailable, then send /ns release <nick> <password> then wait then NICK <nick> | |
rpc = JsonRpc(host = 'localhost', | |
port = 8332, | |
user = 'bitcoinrpc', | |
password = '') | |
#TODO :Alectryon!~Alectryon@host NICK :Guest11162, for when you fuck up and dont identify in time | |
#TODO :barjavel.freenode.net 404 Guest878 #bitcoin-blocks :Cannot send to channel | |
check_for_new_block_interval = 1 | |
fee_estimate_output_interval = 60*20 | |
ping_interval_seconds = 120 | |
ping_timeout_seconds = 60*5 | |
old_bci = [rpc.call('getblockchaininfo', [])] | |
print 'bestblock = ' + old_bci[0]['bestblockhash'] | |
old_head = [rpc.call('getblockheader', [old_bci[0]['bestblockhash']])] | |
#''' | |
print("generating block times list") | |
block_times = [] | |
head = old_head[0] | |
for c in range(2016): | |
block_times.append(head['time']) | |
head = rpc.call('getblockheader', [head['previousblockhash']]) | |
block_times.reverse() | |
#''' | |
def get_miner_fee_stats(height, coinbase_tx, reward=None): | |
subsidy = 6.25 | |
halvening_height = 840000 | |
if height >= halvening_height: | |
subsidy = 3.125 | |
if reward is None: | |
reward = sum([tx_out['value'] for tx_out in coinbase_tx['vout']]) | |
fees = reward - subsidy | |
return fees, reward, subsidy, halvening_height | |
print("generating miner fee data list") | |
st = time.time() | |
head = old_head[0] | |
miner_fee_data = [] | |
#''' | |
for c in range(2016): | |
coinbase_txid = rpc.call("getblock", [head["hash"]])["tx"][0] | |
coinbase_tx = rpc.call("getrawtransaction", [coinbase_txid, True, head["hash"]]) | |
fees, reward, subsidy, halvening_height = get_miner_fee_stats(head["height"], coinbase_tx) | |
head = rpc.call("getblockheader", [head["previousblockhash"]]) | |
miner_fee_data.append((fees, reward)) | |
miner_fee_data.reverse() | |
#''' | |
print("miner fee data generated in " + str(time.time() - st) + "sec") | |
last_fee_estimate = [datetime.now()] | |
def strtimediff(s): | |
return "%02d:%02d" % (s/60, s%60) | |
def strfeerate(con, econ): | |
if con == econ: | |
return str(con*1e5) | |
else: | |
return str(con*1e5) + ', ' + str(econ*1e5) | |
def create_fee_rate_stats(): | |
st = time.time() | |
mempoolinfo = rpc.call("getmempoolinfo", []) | |
message = '\x0304MEMPOOL\x03' | |
message += " txes=" + str(mempoolinfo["size"]) | |
message += " size=" + ('%.3f' % (mempoolinfo["bytes"] / 1000000.0)) + " vMB" | |
message += " minfee=" + str(round(mempoolinfo["mempoolminfee"]*1e5, 4)) + "sat/vb" | |
message += " " | |
blocks = [(2, 'asap'), (6, 'hour'), (12, '2h'), (24, '4h'), (36, '6h'), (72, '12h'), (144, 'day'), (432, '3day'), (1008, 'week')] | |
data = [] | |
for b, t in blocks: | |
con = rpc.call('estimatesmartfee', [b, 'CONSERVATIVE']) | |
econ = rpc.call('estimatesmartfee', [b, 'ECONOMICAL']) | |
data.append((b, t, con['feerate'], econ['feerate'])) | |
message += 'FEE ESTIMATION' | |
message += ' fee rates: target-->(conservative, economical)sat/vbyte ' + ' '.join( | |
[t + '-->(' + strfeerate(con, econ) + ')' for b, t, con, econ in data]) | |
#blocks_index = [0, 6, 8] #asap, day, week | |
##typical tx 74a0b9a414f59dfe846473474b24c5d1d9d9c5110b0f54c8a22811f9eaa7a137 | |
#TYPICAL_VBYTES_KB = 168 | |
#message += ' typical tx(' + str(TYPICAL_VBYTES_KB) + ' vbytes): ' + ' '.join( | |
# [t + '-->' + '%.5f'%(con*TYPICAL_VBYTES_KB) + 'mbtc' for b, t, con, econ in [data[i] for i in blocks_index]]) | |
block_template = rpc.call("getblocktemplate", [{"rules": ["segwit"]}]) | |
message += " NEXT BLOCK" | |
fees, reward, subsidy, halvening_height = get_miner_fee_stats(block_template["height"], None, block_template["coinbasevalue"]/1e8) | |
message += ' fees=' + str(fees) + 'btc(' + ('%.2f%%)' % (100.0*fees / reward)) | |
message += " txes=" + str(len(block_template["transactions"])) | |
non_cpfp_template_txes = filter(lambda tx: len(tx["depends"]) == 0, block_template["transactions"]) | |
non_cpfp_template_txes = sorted(non_cpfp_template_txes, key=lambda x : -x["fee"]/x["weight"]) | |
message += " cpfp=" + str(len(block_template["transactions"]) - len(non_cpfp_template_txes)) | |
if len(non_cpfp_template_txes) > 0: | |
message += " feerates(sat/vbyte):" | |
first_tx = non_cpfp_template_txes[0] | |
message += " top=%.2f" % (first_tx["fee"] * 4.0 / first_tx["weight"],) | |
median_tx = non_cpfp_template_txes[len(non_cpfp_template_txes) // 2] | |
message += " median=%.2f" % (median_tx["fee"] * 4.0 / median_tx["weight"],) | |
last_tx = non_cpfp_template_txes[-1] | |
message += " last=%.2f" % (last_tx["fee"] * 4.0 / last_tx["weight"],) | |
print("fee rates = " + str([int(tx["fee"]*4 / tx["weight"]) for tx in non_cpfp_template_txes])) | |
#TODO maybe also cost per input/cost per output | |
et = time.time() | |
print 'mempool time taken = ' + str(et - st) + 'sec' | |
return message | |
def check_fee_estimate(sock): | |
#return ######comment this to stop fee estimate outputs | |
try: | |
if (datetime.now() - last_fee_estimate[0]).total_seconds() < fee_estimate_output_interval: | |
return | |
last_fee_estimate[0] = datetime.now() | |
message = create_fee_rate_stats() | |
print message | |
sock.sendall('PRIVMSG ' + channel + ' :' + message + '\r\n') | |
except JsonRpcError as e: | |
print repr(e) | |
def create_block_stats(blockhash): | |
st = time.time() | |
#TODO average fee rate, median fee rate, minimum fee rate | |
bci = rpc.call('getblockchaininfo', []) | |
head = rpc.call('getblockheader', [blockhash]) | |
MAX_WEIGHT = 4000000 #TODO lower this | |
block = rpc.call('getblock', [blockhash, 2]) | |
coinbase_tx = block['tx'][0] | |
fees, reward, subsidy, halvening_height = get_miner_fee_stats(block["height"], coinbase_tx) | |
if block_times[-1] != head["time"]: | |
print("updating block time and miner fee list") | |
block_times.pop(0) #remove oldest block | |
block_times.append(head['time']) #add new block | |
miner_fee_data.pop(0) | |
miner_fee_data.append((fees, reward)) | |
else: | |
print("not updating block time and miner fee list") | |
coinbase_str = coinbase_tx['vin'][0]['coinbase'] | |
readable_coinbase = ''.join([i for i in coinbase_str.decode('hex') | |
if i < '\x7f' and i > '\x19']) | |
windows = [6, 36, 144, 432, 1008, 2016] | |
#note the off-by-one error avoided here, 6 blocks have 5 intervals between them | |
intervals = [(block_times[-1] - block_times[-w])/(w-1) for w in windows] | |
fees_list, rewards_list = zip(*miner_fee_data) | |
until_retarget = -(bci['blocks'] % -2016) | |
utxos_produced = 0 | |
utxos_consumed = 0 | |
for tx in block['tx']: | |
utxos_produced += len(tx['vout']) | |
utxos_consumed += len(tx['vin']) | |
message = '\x0303BLOCK\x03' | |
message += ' hash=' + blockhash | |
#message += ' prevhash ' + head['previousblockhash'] | |
message += ' height=' + str(bci['blocks']) | |
message += (' ts=' + | |
datetime.fromtimestamp(head['time']).strftime("%Y-%m-%d %H:%M:%S")) | |
message += ' tx=' + str(len(block['tx'])) | |
message += ' outs=' + str(utxos_produced) + ' ins=' + str(utxos_consumed) | |
#message += ' out-in=' + '%+d'%(utxos_produced - utxos_consumed) | |
message += ' outs/tx=' + ('%.3f' % (1.0*utxos_produced / len(block['tx']))) + ' ins/tx=' + ('%.3f' % (1.0*utxos_consumed / len(block['tx']))) | |
message += ' fees=' + str(fees) + 'btc(' + ('%.2f%%)' % (100.0*fees / reward)) | |
#message += ' size=' + ('%.0f' % (block['size']/1000.0)) + 'kB' | |
message += ' weight=' + str(block['weight']) + '(%.0f%%)' % (100.0 * block['weight'] / MAX_WEIGHT) | |
message += ' interval=' + str(strtimediff(head['time'] - old_head[0]['time'])) | |
message += ' minermsg=' + readable_coinbase | |
message2 = 'median=' + datetime.fromtimestamp(bci['mediantime']).strftime("%Y-%m-%d %H:%M:%S") | |
#message2 += ' average-intervals(' + ', '.join([str(w) for w in windows]) + ')blocks=(' + ', '.join([str(strtimediff(d)) for d in intervals]) + ')' | |
message2 += ' avg (blocks, intervals, fee%) = (' + ', '.join([str(w) for w in windows]) + '), (' + ', '.join([str(strtimediff(d)) for d in intervals]) + '), (' + ', '.join(["%.2f%%" % (100.0*sum(fees_list[-w:])/sum(rewards_list[-w:])) for w in windows]) + ')' | |
message2 += ' retarget=' + str(until_retarget) + 'blocks(' + str(until_retarget/144) + 'days)' | |
#print 'diff=' + str(bci['difficulty']) + ' olddiff=' + str(old_bci[0]['difficulty']) | |
if bci['difficulty'] != old_bci[0]['difficulty']: | |
message2 += (' RETARGET! new difficulty=' + str(bci['difficulty']) + '(' + ('%+.1f%%' | |
% ((bci['difficulty'] - old_bci[0]['difficulty']) / | |
old_bci[0]['difficulty'] * 100)) + ')') | |
#bit2set = ((head["version"] >> 2) & 1) == 1 | |
#message2 += " bit2=" + str(bit2set) | |
until_halvening = halvening_height - bci["blocks"] | |
#message2 += " halvening=" + str(until_halvening) + "blocks(" + str(until_halvening/144) + "days) subsidy=" + str(subsidy) + "btc" | |
TAPROOT_ACTIVATION_HEIGHT = 709632 | |
until_taproot_activation = TAPROOT_ACTIVATION_HEIGHT - bci["blocks"] | |
message2 += " TaprootActivation=" + str(until_taproot_activation) + "blocks(" + str(until_taproot_activation/144) + "days) active=" + ("no" if until_taproot_activation > 0 else "YES!") | |
old_bci[0] = bci | |
old_head[0] = head | |
et = time.time() | |
print 'block stats = ' + str(et - st) + 'sec, msglength=' + str(len(message)) | |
return message, message2 | |
def check_new_block(sock): | |
try: | |
blockhash = rpc.call('getbestblockhash', []) | |
if blockhash == old_bci[0]['bestblockhash']: | |
return | |
last_fee_estimate[0] = datetime.fromtimestamp(0) | |
message, message2 = create_block_stats(blockhash) | |
print message | |
print message2 | |
sock.sendall('PRIVMSG ' + channel + ' :' + message + '\r\nPRIVMSG ' + | |
channel + ' :' + message2 + '\r\n') | |
check_fee_estimate(sock) | |
except JsonRpcError as e: | |
print repr(e) | |
def handle_irc_line(sock, line, chunks): | |
if len(chunks) < 2: | |
return | |
# sock.sendall('JOIN ' + channel + '\r\n') | |
#nuh_chunks = chunks[0].split('!') | |
#if nuh_chunks[0] == ':NickServ' and 'registered' in line and 'identify' in line: | |
#if chunks[1] == '376': ##end of modt | |
if line.startswith(':NickServ') and 'registered' in line and 'identify' in line: | |
print 'sending nickserv password' | |
time.sleep(4) #sleep because technically you need to send the password after nickserv asks for it | |
line = 'PRIVMSG NickServ :identify ' + nickserv_password + '\r\n' | |
#print(line) | |
sock.sendall(line) | |
if chunks[1] == '396': | |
#:sinisalo.freenode.net 396 beIcher unaffiliated/belcher :is now your hidden host (set by services.) | |
print 'joining channel' | |
sock.sendall('JOIN ' + channel + '\r\n') | |
elif chunks[1] == "404" or chunks[1] == "437": | |
#:hitchcock.freenode.net 404 Guest58403 #bitcoin-blocks :Cannot send to nick/channel | |
#:tolkien.freenode.net 437 * Alectryon :Nick/channel is temporarily unavailable | |
print 'releasing nickserv id' | |
sock.sendall('PRIVMSG NickServ :release ' + nick + ' ' + nickserv_password + '\r\n') | |
time.sleep(5) | |
sock.sendall('NICK ' + nick + '\r\n') | |
time.sleep(5) | |
sock.sendall('PRIVMSG NickServ :identify ' + nickserv_password + '\r\n') | |
print create_block_stats(old_bci[0]['bestblockhash']) | |
print create_fee_rate_stats() | |
time.sleep(3) | |
''' | |
import sys | |
sys.exit(0) | |
''' | |
while True: | |
try: | |
print 'connecting' | |
sock = socket.socket() | |
sock.connect(hostport) | |
print 'connected' | |
sock.settimeout(check_for_new_block_interval) | |
sock.sendall('USER ' + nick + ' b c :' + nick + '\r\n') | |
sock.sendall('NICK ' + nick + '\r\n') | |
recv_buffer = "" | |
last_ping = datetime.now() | |
waiting_for_pong = False | |
while True: | |
try: | |
#print 'reading' | |
recv_data = sock.recv(4096) | |
if not recv_data or len(recv_data) == 0: | |
raise EOFError() | |
recv_buffer += recv_data | |
lb = recv_data.find('\n') | |
if lb == -1: | |
continue | |
while lb != -1: | |
line = recv_buffer[:lb].rstrip() | |
recv_buffer = recv_buffer[lb + 1:] | |
lb = recv_buffer.find('\n') | |
#print str(datetime.now()) + ' ' + line | |
#print line | |
chunks = line.split(' ') | |
if chunks[0] == 'PING': | |
sock.sendall(line.replace('PING', 'PONG') + '\r\n') | |
elif len(chunks) > 1 and chunks[1] == 'PONG': | |
#print 'server replied to ping' | |
last_ping = datetime.now() | |
waiting_for_pong = False | |
else: | |
print(line) | |
handle_irc_line(sock, line, chunks) | |
except socket.timeout: | |
#print 'timed out' | |
check_new_block(sock) | |
check_fee_estimate(sock) | |
if waiting_for_pong: | |
if (datetime.now() - last_ping).total_seconds() < ping_timeout_seconds: | |
continue | |
print 'server ping timed out' | |
sock.close() | |
else: | |
if (datetime.now() - last_ping).total_seconds() < ping_interval_seconds: | |
continue | |
last_ping = datetime.now() | |
#print 'sending ping to server' | |
waiting_for_pong = True | |
sock.sendall('PING :hello world\r\n') | |
except (IOError, EOFError) as e: | |
print repr(e) | |
time.sleep(5) | |
finally: | |
try: | |
sock.close() | |
except IOError: | |
pass |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment