From 6c370dec4bae8f067b969f61e68a14087bab01a7 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Wed, 27 Jul 2022 16:16:42 +0000 Subject: [PATCH] new command: `mmnode-addrbal` Get balances for arbitrary addresses in the blockchain Testing: $ test/test.py -e regtest Example: # Top 10 Bitcoin addresses by balance (see https://blockchair.com/bitcoin/addresses) $ top10=' 34xp4vRoCGJym3xR7yCVPFHoCNxv4Twseo bc1qgdjqv0av3q56jvd82tkdjpy7gdp9ut8tlqmgrpmv24sq90ecnvqqjwvw97 1LQoWist8KkaUXSPKZHNvEyfrEkPHzSsCd 3LYJfcfHPXYJreMsASk2jkn69LWEYKzexb 3M219KR5vEneNb47ewrPfWyb5jQ2DjxRP6 bc1qazcm763858nkj2dj986etajv6wquslv8uxwczt 37XuVSEpWW4trkfmvWzegTHQt7BdktSKUs 1FeexV6bAHb8ybZjqQMjJrcCrHGW9sb6uF bc1qa5wkgaew2dkv56kfvj49j0av5nml45x9ek9hz6 3Kzh9qAqVWQhEsfQz7zEQL1EuSx5tyNLNS' # Compact summary: $ mmnode-addrbal --tabular $top10 # Full output: $ mmnode-addrbal $top10 --- cmds/mmnode-addrbal | 17 ++++ mmgen_node_tools/data/version | 2 +- mmgen_node_tools/main_addrbal.py | 170 +++++++++++++++++++++++++++++++ setup.cfg | 1 + test/test_py_d/ts_regtest.py | 60 +++++++++++ 5 files changed, 249 insertions(+), 1 deletion(-) create mode 100755 cmds/mmnode-addrbal create mode 100755 mmgen_node_tools/main_addrbal.py diff --git a/cmds/mmnode-addrbal b/cmds/mmnode-addrbal new file mode 100755 index 0000000..3a5bc68 --- /dev/null +++ b/cmds/mmnode-addrbal @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# +# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet +# Copyright (C)2013-2022 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen https://github.com/mmgen/mmgen-node-tools +# https://gitlab.com/mmgen/mmgen https://gitlab.com/mmgen/mmgen-node-tools + +""" +mmnode-addrbal: Get balances for arbitrary addresses in the blockchain +""" + +from mmgen.main import launch + +launch('addrbal',package='mmgen_node_tools') diff --git a/mmgen_node_tools/data/version b/mmgen_node_tools/data/version index 544b443..59afffe 100644 --- a/mmgen_node_tools/data/version +++ b/mmgen_node_tools/data/version @@ -1 +1 @@ -3.1.dev2 +3.1.dev3 diff --git a/mmgen_node_tools/main_addrbal.py b/mmgen_node_tools/main_addrbal.py new file mode 100755 index 0000000..c638ed6 --- /dev/null +++ b/mmgen_node_tools/main_addrbal.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# +# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet +# Copyright (C)2013-2022 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen https://github.com/mmgen/mmgen-node-tools +# https://gitlab.com/mmgen/mmgen https://gitlab.com/mmgen/mmgen-node-tools + +""" +mmnode-addrbal: Get balances for arbitrary addresses in the blockchain +""" + +from mmgen.common import * + +opts_data = { + 'text': { + 'desc': 'Get balances for arbitrary addresses in the blockchain', + 'usage': '[opts] address [address..]', + 'options': """ +-h, --help Print this help message +--, --longhelp Print help message for long options (common options) +-f, --first-block With tabular output, additionally display first block info +-t, --tabular Produce compact tabular output +""" + } +} + +def do_output(proto,addr_data,blk_hdrs): + + col1w = len(str(len(addr_data))) + indent = ' ' * (col1w + 2) + + for n,(addr,unspents) in enumerate(addr_data.items(),1): + Msg(f'\n{n:{col1w}}) Address: {addr.hl()}') + + if unspents: + heights = { u['height'] for u in unspents } + Msg('{}Balance: {}'.format( + indent, + proto.coin_amt(sum(u['amount'] for u in unspents)).hl2(unit=True,fs='{:,}') )), + Msg('{}{} unspent output{} in {} block{}'.format( + indent, + red(str(len(unspents))), + suf(unspents), + red(str(len(heights))), + suf(heights) )) + blk_w = len(str(unspents[-1]['height'])) + fs = '%s{:%s} {:19} {:64} {:4} {}' % (indent,max(5,blk_w)) + Msg(fs.format('Block','Date','TxID','Vout',' Amount')) + for u in unspents: + Msg(fs.format( + u['height'], + make_timestr( blk_hdrs[u['height']]['time'] ), + CoinTxID(u['txid']).hl(), + red(str(u['vout']).rjust(4)), + proto.coin_amt(u['amount']).fmt(color=True,fs='6.8') + )) + else: + Msg(f'{indent}No balance') + +def do_output_tabular(proto,addr_data,blk_hdrs): + + col1w = len(str(len(addr_data))) + 1 + max_addrw = max(len(addr) for addr in addr_data) + fb_heights = [str(unspents[0]['height']) if unspents else '' for unspents in addr_data.values()] + lb_heights = [str(unspents[-1]['height']) if unspents else '' for unspents in addr_data.values()] + fb_w = max(len(h) for h in fb_heights) + lb_w = max(len(h) for h in lb_heights) + + fs = ( + ' {n:>%s} {a} {u} {b:>%s} {t:19} {B:>%s} {T:19} {A}' % (col1w,max(5,fb_w),max(4,lb_w)) + if opt.first_block else + ' {n:>%s} {a} {u} {B:>%s} {T:19} {A}' % (col1w,max(4,lb_w)) ) + + Msg('\n' + fs.format( + n = '', + a = 'Address'.ljust(max_addrw), + u = 'UTXOs', + b = 'First', + t = 'Block', + B = 'Last', + T = 'Block', + A = ' Amount' )) + + for n,(addr,unspents) in enumerate(addr_data.items(),1): + if unspents: + Msg(fs.format( + n = str(n) + ')', + a = addr.fmt(width=max_addrw,color=True), + u = red(str(len(unspents)).rjust(5)), + b = unspents[0]['height'], + t = make_timestr( blk_hdrs[unspents[0]['height']]['time'] ), + B = unspents[-1]['height'], + T = make_timestr( blk_hdrs[unspents[-1]['height']]['time'] ), + A = proto.coin_amt(sum(u['amount'] for u in unspents)).fmt(color=True,fs='7.8') + )) + else: + Msg(fs.format( + n = str(n) + ')', + a = addr.fmt(width=max_addrw,color=True), + u = ' -', + b = '-', + t = '', + B = '-', + T = '', + A = ' -' )) + +async def main(req_addrs): + + from mmgen.protocol import init_proto_from_opts + proto = init_proto_from_opts(need_amt=True) + + from mmgen.addr import CoinAddr + addrs = [CoinAddr(proto,addr) for addr in req_addrs] + + from mmgen.rpc import rpc_init + rpc = await rpc_init(proto) + + height = await rpc.call('getblockcount') + Msg(f'{proto.coin} {proto.network.upper()} [height {height}]') + + from mmgen.base_proto.bitcoin.misc import scantxoutset + res = await scantxoutset( rpc, [f'addr({addr})' for addr in addrs] ) + + if not res['success']: + die(1,'UTXO scanning failed or was interrupted') + elif not res['unspents']: + msg('Address has no balance' if len(addrs) == 1 else + 'Addresses have no balances' ) + else: + addr_data = {k:[] for k in addrs} + + if 'desc' in res['unspents'][0]: + import re + for unspent in sorted(res['unspents'],key=lambda x: x['height']): + addr = re.match('addr\((.*?)\)',unspent['desc'])[1] + addr_data[addr].append(unspent) + else: + from mmgen.base_proto.bitcoin.tx.base import scriptPubKey2addr + for unspent in sorted(res['unspents'],key=lambda x: x['height']): + addr = scriptPubKey2addr( proto, unspent['scriptPubKey'] )[0] + addr_data[addr].append(unspent) + + good_addrs = len([v for v in addr_data.values() if v]) + + Msg('Total: {} in {} address{}'.format( + proto.coin_amt(res['total_amount']).hl2(unit=True,fs='{:,}'), + red(str(good_addrs)), + suf(good_addrs,'es') + )) + + blk_heights = {i['height'] for i in res['unspents']} + blk_hashes = await rpc.batch_call('getblockhash', [(h,) for h in blk_heights]) + blk_hdrs = await rpc.batch_call('getblockheader', [(H,) for H in blk_hashes]) + + (do_output_tabular if opt.tabular else do_output)( proto, addr_data, dict(zip(blk_heights,blk_hdrs)) ) + +cmd_args = opts.init(opts_data,init_opts={'rpc_backend':'aiohttp'}) + +if len(cmd_args) < 1: + die(1,'This command requires at least one coin address argument') + +from mmgen.obj import CoinTxID,Int + +try: + run_session(main(cmd_args)) +except KeyboardInterrupt: + sys.stderr.write('\n') diff --git a/setup.cfg b/setup.cfg index 638546a..3f804e3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,7 @@ packages = mmgen_node_tools.data scripts = + cmds/mmnode-addrbal cmds/mmnode-blocks-info cmds/mmnode-feeview cmds/mmnode-halving-calculator diff --git a/test/test_py_d/ts_regtest.py b/test/test_py_d/ts_regtest.py index 5f3ffaf..0cbc718 100755 --- a/test/test_py_d/ts_regtest.py +++ b/test/test_py_d/ts_regtest.py @@ -13,13 +13,25 @@ ts_regtest.py: Regtest tests for the test.py test suite """ import os +from mmgen.globalvars import g from mmgen.opts import opt from mmgen.util import die,gmsg +from mmgen.protocol import init_proto from ..include.common import * from .common import * from .ts_base import * +args1 = ['--bob'] +args2 = ['--bob','--rpc-backend=http'] + +def gen_addrs(proto,network,keys): + from mmgen.tool.api import tool_api + tool = tool_api() + tool.init_coin(proto.coin,'regtest') + tool.addrtype = proto.mmtypes[-1] + return [tool.privhex2addr('{:064x}'.format(key)) for key in keys] + class TestSuiteRegtest(TestSuiteBase): 'various operations via regtest mode' networks = ('btc','ltc','bch') @@ -30,6 +42,18 @@ class TestSuiteRegtest(TestSuiteBase): deterministic = False cmd_group = ( ('setup', 'regtest (Bob and Alice) mode setup'), + ('sendto1', 'sending funds to address #1 (1)'), + ('sendto2', 'sending funds to address #1 (2)'), + ('sendto3', 'sending funds to address #2'), + ('addrbal_single', 'getting address balance (single address)'), + ('addrbal_multiple', 'getting address balances (multiple addresses)'), + ('addrbal_multiple_tabular1', 'getting address balances (multiple addresses, tabular output)'), + ('addrbal_multiple_tabular2', 'getting address balances (multiple addresses, tabular, show first block)'), + ('addrbal_nobal1', 'getting address balances (no balance)'), + ('addrbal_nobal2', 'getting address balances (no balances)'), + ('addrbal_nobal3', 'getting address balances (one null balance)'), + ('addrbal_nobal3_tabular1', 'getting address balances (one null balance, tabular output)'), + ('addrbal_nobal3_tabular2', 'getting address balances (one null balance, tabular, show first block)'), ('stop', 'stopping regtest daemon'), ) @@ -39,6 +63,8 @@ class TestSuiteRegtest(TestSuiteBase): return if self.proto.testnet: die(2,'--testnet and --regtest options incompatible with regtest test suite') + self.proto = init_proto(self.proto.coin,network='regtest',need_amt=True) + self.addrs = gen_addrs(self.proto,'regtest',[1,2,3,4,5]) os.environ['MMGEN_TEST_SUITE_REGTEST'] = '1' @@ -55,6 +81,40 @@ class TestSuiteRegtest(TestSuiteBase): t.expect(s) return t + def sendto(self,addr,amt): + return self.spawn('mmgen-regtest',['send',addr,amt]) + + def sendto1(self): return self.sendto(self.addrs[0],'0.123') + def sendto2(self): return self.sendto(self.addrs[0],'0.234') + def sendto3(self): return self.sendto(self.addrs[1],'0.345') + + def addrbal_single(self): + return self.spawn('mmnode-addrbal',args2+[self.addrs[0]]) + + def addrbal_multiple(self): + return self.spawn('mmnode-addrbal',args2+[self.addrs[1],self.addrs[0]]) + + def addrbal_multiple_tabular1(self): + return self.spawn('mmnode-addrbal',args2+['--tabular',self.addrs[1],self.addrs[0]]) + + def addrbal_multiple_tabular2(self): + return self.spawn('mmnode-addrbal',args1+['--tabular','--first-block',self.addrs[1],self.addrs[0]]) + + def addrbal_nobal1(self): + return self.spawn('mmnode-addrbal',args2+[self.addrs[2]]) + + def addrbal_nobal2(self): + return self.spawn('mmnode-addrbal',args2+[self.addrs[2],self.addrs[3]]) + + def addrbal_nobal3(self): + return self.spawn('mmnode-addrbal',args2+[self.addrs[4],self.addrs[0],self.addrs[3]]) + + def addrbal_nobal3_tabular1(self): + return self.spawn('mmnode-addrbal',args2+['--tabular',self.addrs[4],self.addrs[0],self.addrs[3]]) + + def addrbal_nobal3_tabular2(self): + return self.spawn('mmnode-addrbal',args1+['--tabular','--first-block',self.addrs[4],self.addrs[0],self.addrs[3]]) + def stop(self): if opt.no_daemon_stop: self.spawn('',msg_only=True)