#!/usr/bin/env python3 # # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution # Copyright (C)2013-2021 The MMGen Project # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU General Public License as published by the Free Software # Foundation, either version 3 of the License, or (at your option) any later # version. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more # details. # # You should have received a copy of the GNU General Public License along with # this program. If not, see . """ mmnode-blocks-info: Display information about a block or range of blocks """ import re from collections import namedtuple from time import strftime,gmtime from mmgen.common import * from mmgen.util import secs_to_hms from decimal import Decimal class local_vars: pass class BlocksInfo: total_bytes = 0 total_weight = 0 total_solve_time = 0 step = None bf = namedtuple('block_info_fields',['hdr1','hdr2','fs','bs_key','varname','deps','key']) # bs=getblockstats(), bh=getblockheader() # If 'bs_key' is set, it's included in self.bs_keys instead of 'key' fields = { 'block': bf('', 'Block', '{:<6}', None, 'height',[], None), 'hash': bf('', 'Hash', '{:<64}', None, 'H', [], None), 'date': bf('', 'Date', '{:<19}', None, 'df', [], None), 'interval': bf('Solve','Time ', '{:>8}', None, 'td', [], None), 'size': bf('', 'Size', '{:>7}', None, 'bs', [], 'total_size'), 'weight': bf('', 'Weight', '{:>7}', None, 'bs', [], 'total_weight'), 'utxo_inc': bf(' UTXO',' Incr', '{:>5}', None, 'bs', [], 'utxo_increase'), 'fee10': bf('10%', 'Fee', '{:>3}', 'feerate_percentiles','fp', ['bs'], 0), 'fee25': bf('25%', 'Fee', '{:>3}', 'feerate_percentiles','fp', ['bs'], 1), 'fee50': bf('50%', 'Fee', '{:>3}', 'feerate_percentiles','fp', ['bs'], 2), 'fee75': bf('75%', 'Fee', '{:>3}', 'feerate_percentiles','fp', ['bs'], 3), 'fee90': bf('90%', 'Fee', '{:>3}', 'feerate_percentiles','fp', ['bs'], 4), 'fee_avg': bf('Avg', 'Fee', '{:>3}', None, 'bs', [], 'avgfeerate'), 'fee_min': bf('Min', 'Fee', '{:>3}', None, 'bs', [], 'minfeerate'), 'fee_max': bf('Max', 'Fee', '{:>5}', None, 'bs', [], 'maxfeerate'), 'totalfee': bf('', 'Total Fee','{:>10}', 'totalfee', 'tf', ['bs'], None), 'outputs': bf('Out-', 'puts', '{:>5}', None, 'bs', [], 'outs'), 'inputs': bf('In- ', 'puts', '{:>5}', None, 'bs', [], 'ins'), 'version': bf('', 'Version', '{:8}', None, 'bh', [], 'versionHex'), 'nTx': bf('', ' nTx ', '{:>5}', None, 'bh', [], 'nTx'), 'subsidy': bf('Sub-', 'sidy', '{:5}', 'subsidy', 'su', ['bs'], None), 'difficulty':bf('Diffi-','culty', '{:8}', None, 'di', [], None), } dfl_fields = [ 'block', 'date', 'interval', 'subsidy', 'totalfee', 'size', 'weight', 'fee50', 'fee25', 'fee10', 'fee_avg', 'fee_min', 'version', ] fixed_fields = [ 'block', # until ≈ 09/01/2028 (block 1000000) 'hash', 'date', 'size', # until ≈ 6x block size increase 'weight', # until ≈ 2.5x block size increase 'version', 'subsidy', # until ≈ 01/04/2028 (increases by 1 digit per halving until 9th halving [max 10 digits]) 'difficulty', # until 1.00e+100 (i.e. never) ] # column width adjustment data: fs_lsqueeze = ['totalfee','inputs','outputs','nTx'] fs_rsqueeze = [] fs_groups = [ ('fee10','fee25','fee50','fee75','fee90','fee_avg','fee_min','fee_max'), ] fs_lsqueeze2 = ['interval'] funcs = { 'df': lambda self,loc: strftime('%Y-%m-%d %X',gmtime(self.t_cur)), 'td': lambda self,loc: ( '-{:02}:{:02}'.format(abs(self.t_diff)//60,abs(self.t_diff)%60) if self.t_diff < 0 else ' {:02}:{:02}'.format(self.t_diff//60,self.t_diff%60) ), 'tf': lambda self,loc: '{:.8f}'.format(loc.bs["totalfee"] * Decimal('0.00000001')), 'fp': lambda self,loc: loc.bs['feerate_percentiles'], 'su': lambda self,loc: str(loc.bs['subsidy'] * Decimal('0.00000001')).rstrip('0').rstrip('.'), 'di': lambda self,loc: '{:.2e}'.format(loc.bh['difficulty']), } def __init__(self): def get_fields(): if opt.fields: ufields = opt.fields.lstrip('+').split(',') for field in ufields: if field not in self.fields: die(1,f'{field!r}: unrecognized field') return self.dfl_fields + ufields if opt.fields[0] == '+' else ufields else: return self.dfl_fields def gen_fs(fnames): for i in range(len(fnames)): name = fnames[i] ls = (' ','')[name in self.fs_lsqueeze + self.fs_lsqueeze2] rs = (' ','')[name in self.fs_rsqueeze] if i < len(fnames) - 1 and fnames[i+1] in self.fs_lsqueeze2: rs = '' if i: for group in self.fs_groups: if name in group and fnames[i-1] in group: ls = '' break yield ls + self.fields[name].fs + rs self.block_list,self.first,self.last = self.parse_block_range(cmd_args) fnames = get_fields() self.fvals = list(self.fields[name] for name in fnames) self.fs = ''.join(gen_fs(fnames)).strip() self.deps = set(' '.join(v.varname + ' ' + ' '.join(v.deps) for v in self.fvals).split()) self.bs_keys = [(v.bs_key or v.key) for v in self.fvals if v.bs_key or v.varname == 'bs'] self.bs_keys.extend(['total_size','total_weight']) self.ufuncs = {v.varname:self.funcs[v.varname] for v in self.fvals if v.varname in self.funcs} if opt.miner_info: self.fs += ' {}' self.miner_pats = [re.compile(pat) for pat in ( rb'[\xe3\xe4\xe5][\^/](.*?)\xfa', rb'([a-zA-Z0-9&. -]+/Mined by [a-zA-Z0-9. ]+)', rb'\x08/(.*Mined by [a-zA-Z0-9. ]+)', rb'Mined by ([a-zA-Z0-9. ]+)', rb'[`]([_a-zA-Z0-9&. #/-]+)[/\xfa]', rb'[/^]([a-zA-Z0-9&. #/-]{5,})', rb'[/^]([_a-zA-Z0-9&. #/-]+)/', )] else: self.miner_pats = None def parse_block_range(self,args): def conv_blkspec(arg): if arg == 'cur': return c.blockcount elif is_int(arg): if int(arg) < 0: die(1,f'{arg}: block number must be non-negative') elif int(arg) > c.blockcount: die(1,f'{arg}: requested block greater than current chain tip!') else: return int(arg) else: die(1,f'{arg}: invalid block specifier') def parse_rangespec(arg): class RangeParser: def __init__(self,arg): self.arg = arg def parse(self,target): ret = getattr(self,'parse_'+target)() if debug: print(f'after parse({target}): {self.arg}') return ret def parse_from_tip(self): m = re.match(r'-([0-9]+)(.*)',self.arg) if m: self.arg = m[2] res = int(m[1]) assert res > 0, 'block count cannot be zero' assert res <= c.blockcount, f"'+{m[1]}': block count must be less than current chain height" return res def parse_range(self): m = re.match(r'([^+-]+)(-([^+-]+))*(.*)',self.arg) if m: if debug: print(m.groups()) self.arg = m[4] return ( conv_blkspec(m[1]), conv_blkspec(m[3]) if m[3] else None ) return (None,None) def parse_add(self): m = re.match(r'\+([0-9*]+)(.*)',self.arg) if m: self.arg = m[2] assert m[1].strip('*') == m[1], f"'+{m[1]}': malformed nBlocks specifier" assert len(m[1]) <= 30, f"'+{m[1]}': overly long nBlocks specifier" res = eval(m[1]) # m[1] is only digits plus '*', so safe assert res > 0, "'+0' not allowed" assert res <= c.blockcount, f"'+{m[1]}': nBlocks must be less than current chain height" return res debug = False range_spec = namedtuple('parsed_range_spec',['first','last','from_tip','nblocks','step']) p = RangeParser(arg) # parsing order must be preserved! from_tip = p.parse('from_tip') if p.arg.startswith('-'): opts.usage() first,last = p.parse('range') add1 = p.parse('add') add2 = p.parse('add') if p.arg or (from_tip and first): opts.usage() if last: nblocks,step = (None,add1) if add2: opts.usage() else: nblocks,step = (add1,add2) if debug: print(range_spec(first,last,from_tip,nblocks,step)) if from_tip: first = c.blockcount - from_tip if nblocks: if not first: first = c.blockcount - nblocks + 1 last = first + nblocks - 1 if not last: last = first if debug: print(range_spec(first,last,from_tip,nblocks,step)) first = conv_blkspec(first) last = conv_blkspec(last) if first > last: die(1,f'{first}-{last}: invalid block range') block_list = list(range(first,last+1,step)) if step else None return (block_list, first, last) # return (block_list,first,last) if not args: return (None,c.blockcount,c.blockcount) elif len(args) == 1: return parse_rangespec(args[0]) else: return ([conv_blkspec(a) for a in args],None,None) async def run(self): heights = self.block_list or range(self.first,self.last+1) hashes = await c.gathered_call('getblockhash',[(height,) for height in heights]) self.hdrs = await c.gathered_call('getblockheader',[(H,) for H in hashes]) async def init(count): h0 = ( self.hdrs[count] if heights[count] == 0 else await c.call('getblockheader',await c.call('getblockhash',heights[count]-1)) ) self.t_cur = h0['time'] if count == 0: self.first_prev_hdr = h0 if not self.block_list: await init(0) for n in range(len(heights)): if self.block_list: await init(n) await self.process_block(heights[n],hashes[n],self.hdrs[n]) async def process_block(self,height,H,hdr): loc = local_vars() loc.height = height loc.H = H loc.bh = hdr self.t_diff = hdr['time'] - self.t_cur self.t_cur = hdr['time'] self.total_solve_time += self.t_diff if 'bs' in self.deps: loc.bs = genesis_stats if height == 0 else await c.call('getblockstats',H,self.bs_keys) self.total_bytes += loc.bs['total_size'] self.total_weight += loc.bs['total_weight'] if opt.summary: return for varname,func in self.ufuncs.items(): setattr(loc,varname,func(self,loc)) if opt.miner_info: miner_info = '-' if height == 0 else await self.get_miner_string(H) def gen(): for v in self.fvals: if v.key is None: yield getattr(loc,v.varname) else: yield getattr(loc,v.varname)[v.key] if opt.miner_info: yield miner_info Msg(self.fs.format(*gen())) async def get_miner_string(self,H): tx0 = (await c.call('getblock',H))['tx'][0] bd = await c.call('getrawtransaction',tx0,1) if type(bd) == tuple: return '---' else: cb = bytes.fromhex(bd['vin'][0]['coinbase']) if opt.raw_miner_info: return repr(cb) else: for pat in self.miner_pats: m = pat.search(cb) if m: return ''.join(chr(b) for b in m[1] if 31 < b < 127).strip('^').strip('/').replace('/',' ') def print_header(self): hdr1 = [v.hdr1 for v in self.fvals] hdr2 = [v.hdr2 for v in self.fvals] if opt.miner_info: hdr1.append(' ') hdr2.append('Miner') if ''.join(hdr1).replace(' ',''): Msg(self.fs.format(*hdr1)) Msg(self.fs.format(*hdr2)) async def print_range_stats(self): # These figures don’t include the Genesis Block: elapsed = self.hdrs[-1]['time'] - self.first_prev_hdr['time'] nblocks = self.hdrs[-1]['height'] - self.first_prev_hdr['height'] Msg('Range: {}-{} ({} blocks [{}])'.format( self.hdrs[0]['height'], self.hdrs[-1]['height'], self.hdrs[-1]['height'] - self.hdrs[0]['height'] + 1, # includes Genesis Block secs_to_hms(elapsed) )) if elapsed: avg_bdi = int(elapsed / nblocks) if 'bs' in self.deps: total_blocks = len(self.hdrs) rate = (self.total_bytes / 10000) / (self.total_solve_time / 36) Msg_r(fmt(f""" Avg size: {self.total_bytes//total_blocks} bytes Avg weight: {self.total_weight//total_blocks} bytes MB/hr: {rate:0.4f} """)) Msg(f'Avg BDI: {avg_bdi/60:.2f} min') async def print_diff_stats(self): tip = c.blockcount # Only display stats if user-requested range ends with chain tip if self.last != tip: return cur_diff_disp = 'Cur difficulty: {:.2e}'.format(self.hdrs[-1]['difficulty']) rel = tip % 2016 if rel: rel_hdr = await c.call('getblockheader',await c.call('getblockhash',tip-rel)) tip_time = ( self.hdrs[-1]['time'] if self.hdrs[-1]['height'] == tip else (await c.call('getblockheader',await c.call('getblockhash',tip)))['time'] ) tdiff = tip_time - rel_hdr['time'] if tdiff: # if the 2 timestamps are equal (very unlikely), skip display to avoid div-by-zero error bdi = tdiff / rel adj_pct = ((600 / bdi) - 1) * 100 Msg_r(fmt(f""" Current height: {tip} Next diff adjust: {tip-rel+2016} (in {2016-rel} blocks [{((2016-rel)*bdi)/86400:.2f} days]) BDI (cur period): {bdi/60:.2f} min {cur_diff_disp} Est. diff adjust: {adj_pct:+.2f}% """)) else: Msg_r(fmt(f""" Current height: {tip} {cur_diff_disp} Next diff adjust: {tip-rel+2016} (in {2016-rel} blocks) """)) opts_data = { 'sets': [ ('raw_miner_info', True, 'miner_info', True), ('summary', True, 'raw_miner_info', False), ('summary', True, 'miner_info', False), ('hashes', True, 'fields', 'block,hash'), ('hashes', True, 'no_summary', True), ], 'text': { 'desc': 'Display information about a block or range of blocks', 'usage': '[opts] blocknum [blocknum ...] | blocknum-blocknum[+step] | [blocknum|-nBlocks]+nBlocks[+step]', 'usage2': [ '[opts] blocknum [blocknum ...]', '[opts] blocknum-blocknum[+step]', '[opts] [blocknum|-nBlocks]+nBlocks[+step]', ], 'options': """ -h, --help Print this help message --, --longhelp Print help message for long options (common options) -D, --no-diff-stats Omit difficulty adjustment stats from summary -H, --hashes Display only block numbers and hashes -m, --miner-info Display miner info in coinbase transaction -M, --raw-miner-info Display miner info in uninterpreted form -n, --no-header Don’t print the column header -o, --fields= Display the specified fields (comma-separated list) See AVAILABLE FIELDS below. If the first character is '+', fields are appended to the defaults. -s, --summary Print the summary only -S, --no-summary Don’t print the summary """, 'notes': """ If no block number is specified, the current block is assumed. The string 'cur' can be used in place of the current block number. If the requested range ends at the current chain tip, an estimate of the next difficulty adjustment is also displayed. The estimate is based on the average Block Discovery Interval from the beginning of the current 2016-block period. All fee fields except for 'totalfee' are in satoshis per virtual byte. AVAILABLE FIELDS: {f} EXAMPLES: # Display info for current block: {p} # Display info for the Genesis Block: {p} 0 # Display info for the last 20 blocks: {p} +20 # Display specified fields for blocks 165-190 {p} -o block,date,size,inputs,nTx 165-190 # Display info for 10 blocks beginning at block 600000: {p} 600000+10 # Display info for every 5th block of 50-block range beginning at 1000 # blocks from chain tip: {p} -- -1000+50+5 # Display info for block 152817, adding miner field: {p} --miner-info 152817 # Display specified fields for listed blocks: {p} -o block,date,hash 245798 170 624044 # Display every difficulty adjustment from Genesis Block to chain tip: {p} -o +difficulty 0-cur+2016 # Display roughly a block a day over the last two weeks. Note that # multiplication is allowed in the nBlocks spec: {p} +144*14+144 This program requires a txindex-enabled daemon for correct operation. """.format( f = fmt_list(BlocksInfo.fields,fmt='bare'), p = g.prog_name ) } } cmd_args = opts.init(opts_data) # 'getblockstats' RPC raises exception on Genesis Block, so provide our own stats: genesis_stats = { 'avgfee': 0, 'avgfeerate': 0, 'avgtxsize': 0, 'feerate_percentiles': [ 0, 0, 0, 0, 0 ], 'height': 0, 'ins': 0, 'maxfee': 0, 'maxfeerate': 0, 'maxtxsize': 0, 'medianfee': 0, 'mediantxsize': 0, 'minfee': 0, 'minfeerate': 0, 'mintxsize': 0, 'outs': 1, 'subsidy': 5000000000, 'swtotal_size': 0, 'swtotal_weight': 0, 'swtxs': 0, 'total_out': 0, 'total_size': 0, 'total_weight': 0, 'totalfee': 0, 'txs': 1, 'utxo_increase': 1, 'utxo_size_inc': 117 } async def main(): from mmgen.protocol import init_proto_from_opts proto = init_proto_from_opts() from mmgen.rpc import rpc_init global c c = await rpc_init(proto) m = BlocksInfo() if not (opt.summary or opt.no_header): m.print_header() await m.run() if m.last and not opt.no_summary: Msg('') await m.print_range_stats() if not opt.no_diff_stats: Msg('') await m.print_diff_stats() run_session(main())