unspent.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. twuo: Tracking wallet unspent outputs class for the MMGen suite
  20. """
  21. import asyncio
  22. from collections import namedtuple
  23. from ..globalvars import g
  24. from ..color import red,yellow
  25. from ..util import (
  26. msg,
  27. die,
  28. capfirst,
  29. suf,
  30. fmt,
  31. keypress_confirm,
  32. line_input,
  33. base_proto_subclass
  34. )
  35. from ..base_obj import AsyncInit
  36. from ..objmethods import MMGenObject
  37. from ..obj import ImmutableAttr,ListItemAttr,MMGenListItem,TwComment,get_obj,HexStr,CoinTxID,MMGenList
  38. from ..addr import CoinAddr,MMGenID
  39. from ..rpc import rpc_init
  40. from .common import TwCommon,TwMMGenID,get_tw_label
  41. class TwUnspentOutputs(MMGenObject,TwCommon,metaclass=AsyncInit):
  42. def __new__(cls,proto,*args,**kwargs):
  43. return MMGenObject.__new__(base_proto_subclass(cls,proto,'tw','unspent'))
  44. txid_w = 64
  45. print_hdr_fs = '{a} (block #{b}, {c} UTC)\n{d}Sort order: {e}\n{f}\n\nTotal {g}: {h}\n'
  46. no_rpcdata_errmsg = f"""
  47. No spendable outputs found! Import addresses with balances into your
  48. watch-only wallet using 'mmgen-addrimport' and then re-run this program.
  49. """
  50. update_params_on_age_toggle = False
  51. class MMGenTwUnspentOutput(MMGenListItem):
  52. txid = ListItemAttr(CoinTxID)
  53. vout = ListItemAttr(int,typeconv=False)
  54. amt = ImmutableAttr(None)
  55. amt2 = ListItemAttr(None) # the ETH balance for token account
  56. label = ListItemAttr(TwComment,reassign_ok=True)
  57. twmmid = ImmutableAttr(TwMMGenID,include_proto=True)
  58. addr = ImmutableAttr(CoinAddr,include_proto=True)
  59. confs = ImmutableAttr(int,typeconv=False)
  60. date = ListItemAttr(int,typeconv=False,reassign_ok=True)
  61. scriptPubKey = ImmutableAttr(HexStr)
  62. skip = ListItemAttr(str,typeconv=False,reassign_ok=True)
  63. def __init__(self,proto,**kwargs):
  64. self.__dict__['proto'] = proto
  65. MMGenListItem.__init__(self,**kwargs)
  66. class conv_funcs:
  67. def amt(self,value):
  68. return self.proto.coin_amt(value)
  69. def amt2(self,value):
  70. return self.proto.coin_amt(value)
  71. async def __init__(self,proto,minconf=1,addrs=[]):
  72. self.proto = proto
  73. self.data = MMGenList()
  74. self.show_mmid = True
  75. self.minconf = minconf
  76. self.addrs = addrs
  77. self.rpc = await rpc_init(proto)
  78. self.min_cols = g.min_screen_width
  79. from .ctl import TrackingWallet
  80. self.wallet = await TrackingWallet(proto,mode='w')
  81. @property
  82. def total(self):
  83. return sum(i.amt for i in self.data)
  84. def gen_data(self,rpc_data,lbl_id):
  85. for o in rpc_data:
  86. if not lbl_id in o:
  87. continue # coinbase outputs have no account field
  88. l = get_tw_label(self.proto,o[lbl_id])
  89. if l:
  90. o.update({
  91. 'twmmid': l.mmid,
  92. 'label': l.comment or '',
  93. 'amt': self.proto.coin_amt(o['amount']),
  94. 'addr': CoinAddr(self.proto,o['address']),
  95. 'confs': o['confirmations']
  96. })
  97. yield self.MMGenTwUnspentOutput(
  98. self.proto,
  99. **{ k:v for k,v in o.items() if k in self.MMGenTwUnspentOutput.valid_attrs } )
  100. def set_column_params(self):
  101. data = self.data
  102. for i in data:
  103. i.skip = ''
  104. self.cols = self.get_term_columns(g.min_screen_width)
  105. # allow for 7-digit confirmation nums
  106. col1_w = max(3,len(str(len(data)))+1) # num + ')'
  107. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in data) or 12 # DEADBEEF:S:1
  108. max_acct_w = max(i.label.screen_width for i in data) + mmid_w + 1
  109. max_btcaddr_w = max(len(i.addr) for i in data)
  110. min_addr_w = self.cols - self.col_adj
  111. addr_w = min(max_btcaddr_w + (0,1+max_acct_w)[self.show_mmid],min_addr_w)
  112. acct_w = min(max_acct_w, max(24,addr_w-10))
  113. btaddr_w = addr_w - acct_w - 1
  114. label_w = acct_w - mmid_w - 1
  115. tx_w = min(self.txid_w,self.cols-addr_w-29-col1_w) # min=6 TODO
  116. txdots = ('','..')[tx_w < self.txid_w]
  117. self.column_params = namedtuple(
  118. 'column_params',
  119. ['col1_w','mmid_w','addr_w','btaddr_w','label_w','tx_w','txdots']
  120. )(col1_w, mmid_w, addr_w, btaddr_w, label_w, tx_w, txdots)
  121. def gen_display_output(self,c):
  122. fs = self.display_fs_fs.format( cw=c.col1_w, tw=c.tx_w )
  123. hdr_fs = self.display_hdr_fs_fs.format( cw=c.col1_w, tw=c.tx_w )
  124. yield hdr_fs.format(
  125. n = 'Num',
  126. t = 'TXid'.ljust(c.tx_w - 2) + ' Vout',
  127. a = 'Address'.ljust(c.addr_w),
  128. A = f'Amt({self.proto.dcoin})'.ljust(self.disp_prec+5),
  129. A2 = f' Amt({self.proto.coin})'.ljust(self.disp_prec+4),
  130. c = {
  131. 'confs': 'Confs',
  132. 'block': 'Block',
  133. 'days': 'Age(d)',
  134. 'date': 'Date',
  135. 'date_time': 'Date',
  136. }[self.age_fmt],
  137. ).rstrip()
  138. for n,i in enumerate(self.data):
  139. addr_dots = '|' + '.'*(c.addr_w-1)
  140. mmid_disp = MMGenID.fmtc(
  141. (
  142. '.'*c.mmid_w if i.skip == 'addr' else
  143. i.twmmid if i.twmmid.type == 'mmgen' else
  144. f'Non-{g.proj_name}'
  145. ),
  146. width = c.mmid_w,
  147. color = True )
  148. if self.show_mmid:
  149. addr_out = '{} {}{}'.format((
  150. type(i.addr).fmtc(addr_dots,width=c.btaddr_w,color=True) if i.skip == 'addr' else
  151. i.addr.fmt(width=c.btaddr_w,color=True)
  152. ),
  153. mmid_disp,
  154. (' ' + i.label.fmt(width=c.label_w,color=True)) if c.label_w > 0 else ''
  155. )
  156. else:
  157. addr_out = (
  158. type(i.addr).fmtc(addr_dots,width=c.addr_w,color=True) if i.skip=='addr' else
  159. i.addr.fmt(width=c.addr_w,color=True) )
  160. yield fs.format(
  161. n = str(n+1)+')',
  162. t = (
  163. '' if not i.txid else
  164. ' ' * (c.tx_w-4) + '|...' if i.skip == 'txid' else
  165. i.txid[:c.tx_w-len(c.txdots)] + c.txdots ),
  166. v = i.vout,
  167. a = addr_out,
  168. A = i.amt.fmt(color=True,prec=self.disp_prec),
  169. A2 = (i.amt2.fmt(color=True,prec=self.disp_prec) if i.amt2 is not None else ''),
  170. c = self.age_disp(i,self.age_fmt),
  171. ).rstrip()
  172. def gen_print_output(self,color,show_confs):
  173. addr_w = max(len(i.addr) for i in self.data)
  174. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in self.data) or 12 # DEADBEEF:S:1
  175. fs = self.print_fs_fs.format(
  176. tw = self.txid_w + 3,
  177. cf = '{c:<8} ' if show_confs else '',
  178. aw = self.proto.coin_amt.max_prec + 5 )
  179. yield fs.format(
  180. n = 'Num',
  181. t = 'Tx ID,Vout',
  182. a = 'Address'.ljust(addr_w),
  183. m = 'MMGen ID'.ljust(mmid_w),
  184. A = f'Amount({self.proto.dcoin})',
  185. A2 = f'Amount({self.proto.coin})',
  186. c = 'Confs', # skipped for eth
  187. b = 'Block', # skipped for eth
  188. D = 'Date',
  189. l = 'Label' )
  190. max_lbl_len = max([len(i.label) for i in self.data if i.label] or [2])
  191. for n,i in enumerate(self.data):
  192. yield fs.format(
  193. n = str(n+1) + ')',
  194. t = '{},{}'.format(
  195. ('|'+'.'*63 if i.skip == 'txid' and self.group else i.txid),
  196. i.vout ),
  197. a = (
  198. '|'+'.' * addr_w if i.skip == 'addr' and self.group else
  199. i.addr.fmt(color=color,width=addr_w) ),
  200. m = MMGenID.fmtc(
  201. (i.twmmid if i.twmmid.type == 'mmgen' else f'Non-{g.proj_name}'),
  202. width = mmid_w,
  203. color = color ),
  204. A = i.amt.fmt(color=color),
  205. A2 = ( i.amt2.fmt(color=color) if i.amt2 is not None else '' ),
  206. c = i.confs,
  207. b = self.rpc.blockcount - (i.confs - 1),
  208. D = self.age_disp(i,'date_time'),
  209. l = i.label.hl(color=color) if i.label else
  210. TwComment.fmtc(
  211. s = '',
  212. color = color,
  213. nullrepl = '-',
  214. width = max_lbl_len )
  215. ).rstrip()
  216. def display_total(self):
  217. msg('\nTotal unspent: {} {} ({} output{})'.format(
  218. self.total.hl(),
  219. self.proto.dcoin,
  220. len(self.data),
  221. suf(self.data) ))
  222. class item_action(TwCommon.item_action):
  223. async def a_balance_refresh(self,uo,idx):
  224. if not keypress_confirm(
  225. f'Refreshing tracking wallet {uo.item_desc} #{idx}. Is this what you want?'):
  226. return 'redo'
  227. await uo.wallet.get_balance( uo.data[idx-1].addr, force_rpc=True )
  228. await uo.get_data()
  229. uo.oneshot_msg = yellow(f'{uo.proto.dcoin} balance for account #{idx} refreshed\n\n')
  230. async def a_addr_delete(self,uo,idx):
  231. if not keypress_confirm(
  232. f'Removing {uo.item_desc} #{idx} from tracking wallet. Is this what you want?'):
  233. return 'redo'
  234. if await uo.wallet.remove_address( uo.data[idx-1].addr ):
  235. await uo.get_data()
  236. uo.oneshot_msg = yellow(f'{capfirst(uo.item_desc)} #{idx} removed\n\n')
  237. else:
  238. await asyncio.sleep(3)
  239. uo.oneshot_msg = red('Address could not be removed\n\n')
  240. async def a_lbl_add(self,uo,idx):
  241. async def do_lbl_add(lbl):
  242. e = uo.data[idx-1]
  243. if await uo.wallet.add_label( e.twmmid, lbl, addr=e.addr ):
  244. await uo.get_data()
  245. uo.oneshot_msg = yellow('Label {} {} #{}\n\n'.format(
  246. ('added to' if lbl else 'removed from'),
  247. uo.item_desc,
  248. idx ))
  249. else:
  250. await asyncio.sleep(3)
  251. uo.oneshot_msg = red('Label could not be added\n\n')
  252. cur_lbl = uo.data[idx-1].label
  253. msg('Current label: {}'.format(cur_lbl.hl() if cur_lbl else '(none)'))
  254. res = line_input(
  255. "Enter label text (or ENTER to return to main menu): ",
  256. insert_txt = cur_lbl )
  257. if res == cur_lbl:
  258. return None
  259. elif res == '':
  260. return (await do_lbl_add(res)) if keypress_confirm(
  261. f'Removing label for {uo.item_desc} #{idx}. Is this what you want?') else 'redo'
  262. else:
  263. return (await do_lbl_add(res)) if get_obj(TwComment,s=res) else 'redo'