xmrwallet.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2023 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. xmrwallet.py - MoneroWalletOps class
  20. """
  21. import os,re,time,json
  22. from collections import namedtuple
  23. from .common import *
  24. from .objmethods import MMGenObject,Hilite,InitErrors
  25. from .obj import CoinTxID
  26. from .seed import SeedID
  27. from .protocol import init_proto
  28. from .proto.btc.common import b58a
  29. from .addr import CoinAddr,AddrIdx
  30. from .addrlist import KeyAddrList,AddrIdxList
  31. from .rpc import json_encoder
  32. from .proto.xmr.rpc import MoneroRPCClient,MoneroWalletRPCClient
  33. from .proto.xmr.daemon import MoneroWalletDaemon
  34. from .ui import keypress_confirm
  35. xmrwallet_uarg_info = (
  36. lambda e,hp: {
  37. 'daemon': e('HOST:PORT', hp),
  38. 'tx_relay_daemon': e('HOST:PORT[:PROXY_HOST:PROXY_PORT]', rf'({hp})(?::({hp}))?'),
  39. 'newaddr_spec': e('WALLET_NUM[:ACCOUNT][,"label text"]', rf'(\d+)(?::(\d+))?(?:,(.*))?'),
  40. 'transfer_spec': e('SOURCE_WALLET_NUM:ACCOUNT:ADDRESS,AMOUNT', rf'(\d+):(\d+):([{b58a}]+),([0-9.]+)'),
  41. 'sweep_spec': e('SOURCE_WALLET_NUM:ACCOUNT[,DEST_WALLET_NUM]', r'(\d+):(\d+)(?:,(\d+))?'),
  42. 'label_spec': e('WALLET_NUM:ACCOUNT:ADDRESS,"label text"', rf'(\d+):(\d+):(\d+),(.*)'),
  43. })(
  44. namedtuple('uarg_info_entry',['annot','pat']),
  45. r'(?:[^:]+):(?:\d+)'
  46. )
  47. class XMRWalletAddrSpec(str,Hilite,InitErrors,MMGenObject):
  48. color = 'cyan'
  49. width = 0
  50. trunc_ok = False
  51. min_len = 5 # 1:0:0
  52. max_len = 14 # 9999:9999:9999
  53. def __new__(cls,arg1,arg2=None,arg3=None):
  54. if type(arg1) == cls:
  55. return arg1
  56. try:
  57. if isinstance(arg1,str):
  58. me = str.__new__(cls,arg1)
  59. m = re.fullmatch( '({n}):({n}):({n}|None)'.format(n=r'[0-9]{1,4}'), arg1 )
  60. assert m is not None, f'{arg1!r}: invalid XMRWalletAddrSpec'
  61. for e in m.groups():
  62. if len(e) != 1 and e[0] == '0':
  63. die(2,f'{e}: leading zeroes not permitted in XMRWalletAddrSpec element')
  64. me.wallet = AddrIdx(m[1])
  65. me.account = int(m[2])
  66. me.account_address = None if m[3] == 'None' else int(m[3])
  67. else:
  68. me = str.__new__(cls,f'{arg1}:{arg2}:{arg3}')
  69. for arg in [arg1,arg2] + ([] if arg3 is None else [arg3]):
  70. assert isinstance(arg,int), f'{arg}: XMRWalletAddrSpec component not of type int'
  71. assert arg is None or arg <= 9999, f'{arg}: XMRWalletAddrSpec component greater than 9999'
  72. me.wallet = AddrIdx(arg1)
  73. me.account = arg2
  74. me.account_address = arg3
  75. return me
  76. except Exception as e:
  77. return cls.init_fail(e,me)
  78. class MoneroMMGenTX:
  79. class Base:
  80. def make_chksum(self,keys=None):
  81. res = json.dumps(
  82. dict( (k,v) for k,v in self.data._asdict().items() if (not keys or k in keys) ),
  83. cls = json_encoder
  84. )
  85. return make_chksum_6(res)
  86. @property
  87. def base_chksum(self):
  88. return self.make_chksum(
  89. ('op','create_time','network','seed_id','source','dest','amount')
  90. )
  91. @property
  92. def full_chksum(self):
  93. return self.make_chksum(set(self.data._fields) - {'metadata'})
  94. xmrwallet_tx_data = namedtuple('xmrwallet_tx_data',[
  95. 'op',
  96. 'create_time',
  97. 'sign_time',
  98. 'network',
  99. 'seed_id',
  100. 'source',
  101. 'dest',
  102. 'dest_address',
  103. 'txid',
  104. 'amount',
  105. 'fee',
  106. 'blob',
  107. 'metadata',
  108. ])
  109. def get_info(self,indent=''):
  110. d = self.data
  111. if d.dest:
  112. to_entry = f'\n{indent} To: ' + (
  113. 'Wallet {}, account {}, address {}'.format(
  114. d.dest.wallet.hl(),
  115. red(f'#{d.dest.account}'),
  116. red(f'#{d.dest.account_address}')
  117. )
  118. )
  119. fs = """
  120. Info for transaction {} [Seed ID: {}. Network: {}]:
  121. TxID: {}
  122. Signed: {} [{}]
  123. Type: {}
  124. From: Wallet {}, account {}{}
  125. Amount: {} XMR
  126. Fee: {} XMR
  127. Dest: {}
  128. """
  129. pmid = d.dest_address.parsed.payment_id
  130. if pmid:
  131. fs += ' Payment ID: {pmid}'
  132. from .util2 import format_elapsed_hr
  133. return fmt(fs,strip_char='\t',indent=indent).format(
  134. orange(self.base_chksum.upper()),
  135. d.seed_id.hl(),
  136. yellow(d.network.upper()),
  137. d.txid.hl(),
  138. make_timestr(d.sign_time),
  139. format_elapsed_hr(d.sign_time),
  140. blue(capfirst(d.op)),
  141. d.source.wallet.hl(),
  142. red(f'#{d.source.account}'),
  143. to_entry if d.dest else '',
  144. d.amount.hl(),
  145. d.fee.hl(),
  146. d.dest_address.hl(),
  147. pmid = pink(pmid.hex()) if pmid else None
  148. )
  149. def write(self,delete_metadata=False):
  150. dict_data = self.data._asdict()
  151. if delete_metadata:
  152. dict_data['metadata'] = None
  153. out = json.dumps(
  154. { 'MoneroMMGenTX': {
  155. 'base_chksum': self.base_chksum,
  156. 'full_chksum': self.full_chksum,
  157. 'data': dict_data,
  158. }
  159. },
  160. cls = json_encoder,
  161. )
  162. fn = '{}{}-XMR[{!s}]{}.sigtx'.format(
  163. self.base_chksum.upper(),
  164. (lambda s: f'-{s.upper()}' if s else '')(self.full_chksum),
  165. self.data.amount,
  166. (lambda s: '' if s == 'mainnet' else f'.{s}')(self.data.network),
  167. )
  168. from .fileutil import write_data_to_file
  169. write_data_to_file(fn,out,desc='MoneroMMGenTX data',ask_write=True,ask_write_default_yes=False)
  170. class NewSigned(Base):
  171. def __init__(self,*args,**kwargs):
  172. assert not args, 'Non-keyword args not permitted'
  173. d = namedtuple('kwargs_tuple',kwargs)(**kwargs)
  174. proto = init_proto( 'xmr', network=d.network, need_amt=True )
  175. now = int(time.time())
  176. self.data = self.xmrwallet_tx_data(
  177. op = d.op,
  178. create_time = now,
  179. sign_time = now,
  180. network = d.network,
  181. seed_id = SeedID(sid=d.seed_id),
  182. source = XMRWalletAddrSpec(d.source),
  183. dest = None if d.dest is None else XMRWalletAddrSpec(d.dest),
  184. dest_address = CoinAddr(proto,d.dest_address),
  185. txid = CoinTxID(d.txid),
  186. amount = proto.coin_amt(d.amount,from_unit='atomic'),
  187. fee = proto.coin_amt(d.fee,from_unit='atomic'),
  188. blob = d.blob,
  189. metadata = d.metadata,
  190. )
  191. class Signed(Base):
  192. def __init__(self,fn):
  193. from .fileutil import get_data_from_file
  194. self.fn = fn
  195. d_wrap = json.loads(get_data_from_file(fn))['MoneroMMGenTX']
  196. d = self.xmrwallet_tx_data(**d_wrap['data'])
  197. proto = init_proto( 'xmr', network=d.network, need_amt=True )
  198. self.data = self.xmrwallet_tx_data(
  199. op = d.op,
  200. create_time = d.create_time,
  201. sign_time = d.sign_time,
  202. network = d.network,
  203. seed_id = SeedID(sid=d.seed_id),
  204. source = XMRWalletAddrSpec(d.source),
  205. dest = None if d.dest is None else XMRWalletAddrSpec(d.dest),
  206. dest_address = CoinAddr(proto,d.dest_address),
  207. txid = CoinTxID(d.txid),
  208. amount = proto.coin_amt(d.amount),
  209. fee = proto.coin_amt(d.fee),
  210. blob = d.blob,
  211. metadata = d.metadata,
  212. )
  213. for k in ('base_chksum','full_chksum'):
  214. a = getattr(self,k)
  215. b = d_wrap[k]
  216. assert a == b, f'{k} mismatch: {a} != {b}'
  217. class MoneroWalletOps:
  218. ops = ('create','sync','list','new','transfer','sweep','relay','txview','label')
  219. opts = (
  220. 'wallet_dir',
  221. 'daemon',
  222. 'tx_relay_daemon',
  223. 'use_internal_keccak_module',
  224. 'hash_preset',
  225. 'restore_height',
  226. 'no_start_wallet_daemon',
  227. 'no_stop_wallet_daemon',
  228. 'no_relay',
  229. )
  230. pat_opts = ('daemon','tx_relay_daemon')
  231. class base(MMGenObject):
  232. opts = ('wallet_dir',)
  233. def __init__(self,uarg_tuple,uopt_tuple):
  234. def gen_classes():
  235. for cls in type(self).__mro__:
  236. yield cls
  237. if cls.__name__ == 'base':
  238. break
  239. classes = tuple(gen_classes())
  240. self.opts = tuple(set(opt for cls in classes for opt in cls.opts))
  241. if not hasattr(self,'stem'):
  242. self.stem = self.name
  243. global uarg, uopt, uarg_info, fmt_amt, hl_amt
  244. uarg = uarg_tuple
  245. uopt = uopt_tuple
  246. uarg_info = xmrwallet_uarg_info
  247. def fmt_amt(amt):
  248. return self.proto.coin_amt(amt,from_unit='atomic').fmt( iwidth=5, prec=12, color=True )
  249. def hl_amt(amt):
  250. return self.proto.coin_amt(amt,from_unit='atomic').hl()
  251. id_cur = None
  252. for cls in classes:
  253. if id(cls.check_uopts) != id_cur:
  254. cls.check_uopts(self)
  255. id_cur = id(cls.check_uopts)
  256. self.proto = init_proto( 'xmr', testnet=g.testnet, need_amt=True )
  257. def check_uopts(self):
  258. def check_pat_opt(name):
  259. val = getattr(uopt,name)
  260. if not re.fullmatch(uarg_info[name].pat,val,re.ASCII):
  261. die(1,'{!r}: invalid value for --{}: it must have format {!r}'.format(
  262. val,
  263. name.replace('_','-'),
  264. uarg_info[name].annot
  265. ))
  266. for opt in uopt._asdict():
  267. if getattr(uopt,opt) and not opt in self.opts:
  268. die(1,'Option --{} not supported for {!r} operation'.format(
  269. opt.replace('_','-'),
  270. uarg.op
  271. ))
  272. for opt in MoneroWalletOps.pat_opts:
  273. if getattr(uopt,opt):
  274. check_pat_opt(opt)
  275. def display_tx_relay_info(self,indent=''):
  276. m = re.fullmatch(uarg_info['tx_relay_daemon'].pat,uopt.tx_relay_daemon,re.ASCII)
  277. msg(fmt(f"""
  278. TX relay info:
  279. Host: {blue(m[1])}
  280. Proxy: {blue(m[2] or 'None')}
  281. """,strip_char='\t',indent=indent))
  282. def post_main(self):
  283. pass
  284. async def stop_wallet_daemon(self):
  285. pass
  286. class wallet(base):
  287. opts = (
  288. 'use_internal_keccak_module',
  289. 'hash_preset',
  290. 'daemon',
  291. 'no_start_wallet_daemon',
  292. 'no_stop_wallet_daemon',
  293. )
  294. wallet_exists = True
  295. def __init__(self,uarg_tuple,uopt_tuple):
  296. def wallet_exists(fn):
  297. try: os.stat(fn)
  298. except: return False
  299. else: return True
  300. def check_wallets():
  301. for d in self.addr_data:
  302. fn = self.get_wallet_fn(d)
  303. exists = wallet_exists(fn)
  304. if exists and not self.wallet_exists:
  305. die(1,f'Wallet {fn!r} already exists!')
  306. elif not exists and self.wallet_exists:
  307. die(1,f'Wallet {fn!r} not found!')
  308. super().__init__(uarg_tuple,uopt_tuple)
  309. self.kal = KeyAddrList(
  310. self.proto,
  311. uarg.infile,
  312. key_address_validity_check = True )
  313. self.create_addr_data()
  314. check_wallets()
  315. self.wd = MoneroWalletDaemon(
  316. proto = self.proto,
  317. wallet_dir = uopt.wallet_dir or '.',
  318. test_suite = g.test_suite,
  319. daemon_addr = uopt.daemon or None,
  320. )
  321. self.c = MoneroWalletRPCClient(daemon=self.wd,test_connection=False)
  322. if not uopt.no_start_wallet_daemon:
  323. async_run(self.c.restart_daemon())
  324. def create_addr_data(self):
  325. if uarg.wallets:
  326. idxs = AddrIdxList(uarg.wallets)
  327. self.addr_data = [d for d in self.kal.data if d.idx in idxs]
  328. if len(self.addr_data) != len(idxs):
  329. die(1,f'List {uarg.wallets!r} contains addresses not present in supplied key-address file')
  330. else:
  331. self.addr_data = self.kal.data
  332. async def stop_wallet_daemon(self):
  333. if not uopt.no_stop_wallet_daemon:
  334. await self.c.stop_daemon()
  335. def get_wallet_fn(self,d):
  336. return os.path.join(
  337. uopt.wallet_dir or '.','{}-{}-MoneroWallet{}{}'.format(
  338. self.kal.al_id.sid,
  339. d.idx,
  340. '.testnet' if g.testnet else '',
  341. '-α' if g.debug_utf8 else '' ))
  342. async def main(self):
  343. gmsg('\n{}ing {} wallet{}'.format(
  344. self.stem.capitalize(),
  345. len(self.addr_data),
  346. suf(self.addr_data) ))
  347. processed = 0
  348. for n,d in enumerate(self.addr_data): # [d.sec,d.addr,d.wallet_passwd,d.viewkey]
  349. fn = self.get_wallet_fn(d)
  350. gmsg('\n{}ing wallet {}/{} ({})'.format(
  351. self.stem.capitalize(),
  352. n+1,
  353. len(self.addr_data),
  354. os.path.basename(fn),
  355. ))
  356. processed += await self.process_wallet(
  357. d,
  358. fn,
  359. last = n == len(self.addr_data)-1 )
  360. gmsg(f'\n{processed} wallet{suf(processed)} {self.stem}ed')
  361. return processed
  362. class rpc:
  363. def __init__(self,parent,d):
  364. self.parent = parent
  365. self.c = parent.c
  366. self.d = d
  367. self.fn = parent.get_wallet_fn(d)
  368. def open_wallet(self,desc,refresh=True):
  369. gmsg_r(f'\n Opening {desc} wallet...')
  370. self.c.call( # returns {}
  371. 'open_wallet',
  372. filename=os.path.basename(self.fn),
  373. password=self.d.wallet_passwd )
  374. gmsg('done')
  375. if refresh:
  376. gmsg_r(f' Refreshing {desc} wallet...')
  377. ret = self.c.call('refresh')
  378. gmsg('done')
  379. if ret['received_money']:
  380. msg(' Wallet has received funds')
  381. def close_wallet(self,desc):
  382. gmsg_r(f'\n Closing {desc} wallet...')
  383. self.c.call('close_wallet')
  384. gmsg_r('done')
  385. async def stop_wallet(self,desc):
  386. msg(f'Stopping {self.c.daemon.desc} on port {self.c.daemon.bind_port}')
  387. gmsg_r(f'\n Stopping {desc} wallet...')
  388. await self.c.stop_daemon(quiet=True) # closes wallet
  389. gmsg_r('done')
  390. def print_accts(self,data,addrs_data,indent=' '):
  391. d = data['subaddress_accounts']
  392. msg('\n' + indent + f'Accounts of wallet {os.path.basename(self.fn)}:')
  393. fs = indent + ' {:6} {:18} {:<6} {:%s} {}' % max(len(e['label']) for e in d)
  394. msg(fs.format('Index ','Base Address','nAddrs','Label','Unlocked Balance'))
  395. for i,e in enumerate(d):
  396. msg(fs.format(
  397. str(e['account_index']),
  398. e['base_address'][:15] + '...',
  399. len(addrs_data[i]['addresses']),
  400. e['label'],
  401. fmt_amt(e['unlocked_balance']),
  402. ))
  403. def get_accts(self,print=True):
  404. data = self.c.call('get_accounts')
  405. addrs_data = [
  406. self.c.call('get_address',account_index=i)
  407. for i in range(len(data['subaddress_accounts']))
  408. ]
  409. if print:
  410. self.print_accts(data,addrs_data)
  411. return ( data, addrs_data )
  412. def create_acct(self,label=None):
  413. msg('\n Creating new account...')
  414. ret = self.c.call(
  415. 'create_account',
  416. label = label or 'Sweep from {}:{} [{}]'.format(
  417. self.parent.source.idx,
  418. self.parent.account,
  419. make_timestr() ))
  420. msg(' Index: {}'.format( pink(str(ret['account_index'])) ))
  421. msg(' Address: {}'.format( cyan(ret['address']) ))
  422. return (ret['account_index'], ret['address'])
  423. def get_last_acct(self,accts_data):
  424. msg('\n Getting last account...')
  425. ret = accts_data['subaddress_accounts'][-1]
  426. msg(' Index: {}'.format( pink(str(ret['account_index'])) ))
  427. msg(' Address: {}'.format( cyan(ret['base_address']) ))
  428. return (ret['account_index'], ret['base_address'])
  429. def print_addrs(self,accts_data,account):
  430. ret = self.c.call('get_address',account_index=account)
  431. d = ret['addresses']
  432. msg('\n Addresses of account #{} ({}):'.format(
  433. account,
  434. accts_data['subaddress_accounts'][account]['label']))
  435. fs = ' {:6} {:18} {:%s} {}' % max( [len(e['label']) for e in d], default=0 )
  436. msg(fs.format('Index ','Address','Label','Used'))
  437. for e in d:
  438. msg(fs.format(
  439. str(e['address_index']),
  440. e['address'][:15] + '...',
  441. e['label'],
  442. e['used']
  443. ))
  444. return ret
  445. def create_new_addr(self,account,label=None):
  446. msg_r('\n Creating new address: ')
  447. ret = self.c.call(
  448. 'create_address',
  449. account_index = account,
  450. label = label or f'Sweep from this account [{make_timestr()}]',
  451. )
  452. msg(cyan(ret['address']))
  453. return ret['address']
  454. def get_last_addr(self,account,display=True):
  455. if display:
  456. msg('\n Getting last address:')
  457. ret = self.c.call(
  458. 'get_address',
  459. account_index = account,
  460. )['addresses']
  461. addr = ret[-1]['address']
  462. if display:
  463. msg(' ' + cyan(addr))
  464. return ( addr, len(ret) - 1 )
  465. def set_label(self,account,address_idx,label):
  466. return self.c.call(
  467. 'label_address',
  468. index = { 'major': account, 'minor': address_idx },
  469. label = label
  470. )
  471. def make_transfer_tx(self,account,addr,amt):
  472. res = self.c.call(
  473. 'transfer',
  474. account_index = account,
  475. destinations = [{
  476. 'amount': amt.to_unit('atomic'),
  477. 'address': addr
  478. }],
  479. do_not_relay = True,
  480. get_tx_hex = True,
  481. get_tx_metadata = True
  482. )
  483. return MoneroMMGenTX.NewSigned(
  484. op = uarg.op,
  485. network = self.parent.proto.network,
  486. seed_id = self.parent.kal.al_id.sid,
  487. source = XMRWalletAddrSpec(self.parent.source.idx,self.parent.account,None),
  488. dest = None,
  489. dest_address = addr,
  490. txid = res['tx_hash'],
  491. amount = res['amount'],
  492. fee = res['fee'],
  493. blob = res['tx_blob'],
  494. metadata = res['tx_metadata'],
  495. )
  496. def make_sweep_tx(self,account,dest_acct,dest_addr_idx,addr):
  497. res = self.c.call(
  498. 'sweep_all',
  499. address = addr,
  500. account_index = account,
  501. do_not_relay = True,
  502. get_tx_hex = True,
  503. get_tx_metadata = True
  504. )
  505. if len(res['tx_hash_list']) > 1:
  506. die(3,'More than one TX required. Cannot perform this sweep')
  507. return MoneroMMGenTX.NewSigned(
  508. op = uarg.op,
  509. network = self.parent.proto.network,
  510. seed_id = self.parent.kal.al_id.sid,
  511. source = XMRWalletAddrSpec(self.parent.source.idx,self.parent.account,None),
  512. dest = XMRWalletAddrSpec(
  513. (self.parent.dest or self.parent.source).idx,
  514. dest_acct,
  515. dest_addr_idx),
  516. dest_address = addr,
  517. txid = res['tx_hash_list'][0],
  518. amount = res['amount_list'][0],
  519. fee = res['fee_list'][0],
  520. blob = res['tx_blob_list'][0],
  521. metadata = res['tx_metadata_list'][0],
  522. )
  523. def relay_tx(self,tx_hex):
  524. ret = self.c.call('relay_tx',hex=tx_hex)
  525. try:
  526. msg('\n Relayed {}'.format( CoinTxID(ret['tx_hash']).hl() ))
  527. except:
  528. msg(f'\n Server returned: {ret!s}')
  529. class create(wallet):
  530. name = 'create'
  531. stem = 'creat'
  532. wallet_exists = False
  533. opts = ('restore_height',)
  534. def check_uopts(self):
  535. if int(uopt.restore_height) < 0:
  536. die(1,f"{uopt.restore_height}: invalid value for --restore-height (less than zero)")
  537. async def process_wallet(self,d,fn,last):
  538. msg_r('') # for pexpect
  539. from .xmrseed import xmrseed
  540. ret = self.c.call(
  541. 'restore_deterministic_wallet',
  542. filename = os.path.basename(fn),
  543. password = d.wallet_passwd,
  544. seed = xmrseed().fromhex(d.sec.wif,tostr=True),
  545. restore_height = uopt.restore_height,
  546. language = 'English' )
  547. pp_msg(ret) if opt.debug else msg(' Address: {}'.format( ret['address'] ))
  548. return True
  549. class sync(wallet):
  550. name = 'sync'
  551. opts = ('rescan_blockchain',)
  552. def __init__(self,uarg_tuple,uopt_tuple):
  553. super().__init__(uarg_tuple,uopt_tuple)
  554. host,port = uopt.daemon.split(':') if uopt.daemon else ('localhost',self.wd.daemon_port)
  555. from .daemon import CoinDaemon
  556. self.dc = MoneroRPCClient(
  557. proto = self.proto,
  558. daemon = CoinDaemon('xmr'),
  559. host = host,
  560. port = int(port),
  561. user = None,
  562. passwd = None )
  563. self.accts_data = {}
  564. async def process_wallet(self,d,fn,last):
  565. chain_height = self.dc.call_raw('get_height')['height']
  566. msg(f' Chain height: {chain_height}')
  567. t_start = time.time()
  568. msg_r(' Opening wallet...')
  569. self.c.call(
  570. 'open_wallet',
  571. filename=os.path.basename(fn),
  572. password=d.wallet_passwd )
  573. msg('done')
  574. msg_r(' Getting wallet height (be patient, this could take a long time)...')
  575. wallet_height = self.c.call('get_height')['height']
  576. msg_r('\r' + ' '*68 + '\r')
  577. msg(f' Wallet height: {wallet_height} ')
  578. behind = chain_height - wallet_height
  579. if behind > 1000:
  580. msg_r(f' Wallet is {behind} blocks behind chain tip. Please be patient. Syncing...')
  581. ret = self.c.call('refresh')
  582. if behind > 1000:
  583. msg('done')
  584. if ret['received_money']:
  585. msg(' Wallet has received funds')
  586. for i in range(2):
  587. wallet_height = self.c.call('get_height')['height']
  588. if wallet_height >= chain_height:
  589. break
  590. ymsg(f' Wallet failed to sync (wallet height [{wallet_height}] < chain height [{chain_height}])')
  591. if i or not uopt.rescan_blockchain:
  592. break
  593. msg_r(' Rescanning blockchain, please be patient...')
  594. self.c.call('rescan_blockchain')
  595. self.c.call('refresh')
  596. msg('done')
  597. t_elapsed = int(time.time() - t_start)
  598. bn = os.path.basename(fn)
  599. a,b = self.rpc(self,d).get_accts(print=False)
  600. msg(' Balance: {} Unlocked balance: {}'.format(
  601. hl_amt(a['total_balance']),
  602. hl_amt(a['total_unlocked_balance']),
  603. ))
  604. self.accts_data[bn] = { 'accts': a, 'addrs': b }
  605. msg(f' Wallet height: {wallet_height}')
  606. msg(' Sync time: {:02}:{:02}'.format(
  607. t_elapsed // 60,
  608. t_elapsed % 60 ))
  609. if not last:
  610. self.c.call('close_wallet')
  611. return wallet_height >= chain_height
  612. def post_main(self):
  613. d = self.accts_data
  614. op = type(self).__name__
  615. for wnum,k in enumerate(d):
  616. if op == 'sync':
  617. self.rpc(self,self.addr_data[wnum]).print_accts( d[k]['accts'], d[k]['addrs'], indent='')
  618. elif op == 'list':
  619. fs = ' {:2} {} {} {}'
  620. msg('\n' + green(f'Wallet {k}:'))
  621. for acct_num,acct in enumerate(d[k]['addrs']):
  622. msg('\n Account #{} [{} {}]'.format(
  623. acct_num,
  624. self.proto.coin_amt(
  625. d[k]['accts']['subaddress_accounts'][acct_num]['unlocked_balance'],
  626. from_unit='atomic').hl(),
  627. self.proto.coin_amt.hlc('XMR')
  628. ))
  629. msg(fs.format('','Address'.ljust(95),'Used ','Label'))
  630. for addr in acct['addresses']:
  631. msg(fs.format(
  632. addr['address_index'],
  633. CoinAddr(self.proto,addr['address']).hl(),
  634. ( yellow('True ') if addr['used'] else green('False') ),
  635. pink(addr['label']) ))
  636. col1_w = max(map(len,d)) + 1
  637. fs = '{:%s} {} {}' % col1_w
  638. tbals = [0,0]
  639. msg('\n'+fs.format('Wallet','Balance ','Unlocked Balance'))
  640. for k in d:
  641. b = d[k]['accts']['total_balance']
  642. ub = d[k]['accts']['total_unlocked_balance']
  643. msg(fs.format( k + ':', fmt_amt(b), fmt_amt(ub) ))
  644. tbals[0] += b
  645. tbals[1] += ub
  646. msg(fs.format( '-'*col1_w, '-'*18, '-'*18 ))
  647. msg(fs.format( 'TOTAL:', fmt_amt(tbals[0]), fmt_amt(tbals[1]) ))
  648. class list(sync):
  649. pass
  650. class spec(wallet): # virtual class
  651. def create_addr_data(self):
  652. m = re.fullmatch(uarg_info[self.spec_id].pat,uarg.spec,re.ASCII)
  653. if not m:
  654. fs = "{!r}: invalid {!r} arg: for {} operation, it must have format {!r}"
  655. die(1,fs.format( uarg.spec, self.spec_id, self.name, uarg_info[self.spec_id].annot ))
  656. def gen():
  657. for i,k in self.spec_key:
  658. if m[i] == None:
  659. setattr(self,k,None)
  660. else:
  661. idx = int(m[i])
  662. try:
  663. res = [d for d in self.kal.data if d.idx == idx][0]
  664. except:
  665. die(1,'Supplied key-address file does not contain address {}:{}'.format(
  666. self.kal.al_id.sid,
  667. idx ))
  668. else:
  669. setattr(self,k,res)
  670. yield res
  671. self.addr_data = list(gen())
  672. self.account = None if m[2] is None else int(m[2])
  673. def strip_quotes(s):
  674. if s and s[0] in ("'",'"'):
  675. if s[-1] != s[0] or len(s) < 2:
  676. die(1,f'{s!r}: unbalanced quotes in label string!')
  677. return s[1:-1]
  678. else:
  679. return s # None or empty string
  680. if self.name == 'transfer':
  681. self.dest_addr = CoinAddr(self.proto,m[3])
  682. self.amount = self.proto.coin_amt(m[4])
  683. elif self.name == 'new':
  684. self.label = strip_quotes(m[3])
  685. elif self.name == 'label':
  686. self.address_idx = int(m[3])
  687. self.label = strip_quotes(m[4])
  688. class sweep(spec):
  689. name = 'sweep'
  690. spec_id = 'sweep_spec'
  691. spec_key = ( (1,'source'), (3,'dest') )
  692. opts = ('no_relay','tx_relay_daemon')
  693. def init_tx_relay_daemon(self):
  694. m = re.fullmatch(uarg_info['tx_relay_daemon'].pat,uopt.tx_relay_daemon,re.ASCII)
  695. wd2 = MoneroWalletDaemon(
  696. proto = self.proto,
  697. wallet_dir = uopt.wallet_dir or '.',
  698. test_suite = g.test_suite,
  699. daemon_addr = m[1],
  700. proxy = m[2] )
  701. if g.test_suite:
  702. wd2.usr_daemon_args = ['--daemon-ssl-allow-any-cert']
  703. wd2.start()
  704. self.c = MoneroWalletRPCClient(daemon=wd2)
  705. async def main(self):
  706. gmsg(f'\n{self.stem.capitalize()}ing account #{self.account} of wallet {self.source.idx}' + (
  707. f': {self.amount} XMR to {self.dest_addr}' if self.name == 'transfer'
  708. else ' to new address' if self.dest == None
  709. else f' to new account in wallet {self.dest.idx}' ))
  710. h = self.rpc(self,self.source)
  711. h.open_wallet('source')
  712. accts_data = h.get_accts()[0]
  713. max_acct = len(accts_data['subaddress_accounts']) - 1
  714. if self.account > max_acct:
  715. die(1,f'{self.account}: requested account index out of bounds (>{max_acct})')
  716. h.print_addrs(accts_data,self.account)
  717. if self.name == 'transfer':
  718. dest_addr = self.dest_addr
  719. elif self.dest == None:
  720. dest_acct = self.account
  721. if keypress_confirm(f'\nCreate new address for account #{self.account}?'):
  722. dest_addr_chk = h.create_new_addr(self.account)
  723. elif keypress_confirm(f'Sweep to last existing address of account #{self.account}?'):
  724. dest_addr_chk = None
  725. else:
  726. die(1,'Exiting at user request')
  727. dest_addr,dest_addr_idx = h.get_last_addr(self.account,display=not dest_addr_chk)
  728. assert dest_addr_chk in (None,dest_addr), 'dest_addr_chk1'
  729. h.print_addrs(accts_data,self.account)
  730. else:
  731. h.close_wallet('source')
  732. bn = os.path.basename(self.get_wallet_fn(self.dest))
  733. h2 = self.rpc(self,self.dest)
  734. h2.open_wallet('destination')
  735. accts_data = h2.get_accts()[0]
  736. if keypress_confirm(f'\nCreate new account for wallet {bn!r}?'):
  737. dest_acct,dest_addr = h2.create_acct()
  738. dest_addr_idx = 0
  739. h2.get_accts()
  740. elif keypress_confirm(f'Sweep to last existing account of wallet {bn!r}?'):
  741. dest_acct,dest_addr_chk = h2.get_last_acct(accts_data)
  742. dest_addr,dest_addr_idx = h2.get_last_addr(dest_acct,display=False)
  743. assert dest_addr_chk == dest_addr, 'dest_addr_chk2'
  744. else:
  745. die(1,'Exiting at user request')
  746. h2.close_wallet('destination')
  747. h.open_wallet('source',refresh=False)
  748. msg(f'\n Creating {self.name} transaction...')
  749. if self.name == 'transfer':
  750. new_tx = h.make_transfer_tx(self.account,dest_addr,self.amount)
  751. elif self.name == 'sweep':
  752. new_tx = h.make_sweep_tx(self.account,dest_acct,dest_addr_idx,dest_addr)
  753. msg('\n' + new_tx.get_info(indent=' '))
  754. if uopt.tx_relay_daemon:
  755. self.display_tx_relay_info(indent=' ')
  756. msg('Saving TX data to file')
  757. new_tx.write(delete_metadata=True)
  758. if uopt.no_relay:
  759. return True
  760. if keypress_confirm(f'Relay {self.name} transaction?'):
  761. w_desc = 'source'
  762. if uopt.tx_relay_daemon:
  763. await h.stop_wallet('source')
  764. msg('')
  765. self.init_tx_relay_daemon()
  766. h = self.rpc(self,self.source)
  767. w_desc = 'TX relay source'
  768. h.open_wallet(w_desc,refresh=False)
  769. msg_r(f'\n Relaying {self.name} transaction...')
  770. h.relay_tx(new_tx.data.metadata)
  771. gmsg('\nAll done')
  772. return True
  773. else:
  774. die(1,'\nExiting at user request')
  775. class transfer(sweep):
  776. name = 'transfer'
  777. stem = 'transferr'
  778. spec_id = 'transfer_spec'
  779. spec_key = ( (1,'source'), )
  780. class new(spec):
  781. name = 'new'
  782. spec_id = 'newaddr_spec'
  783. spec_key = ( (1,'source'), )
  784. async def main(self):
  785. h = self.rpc(self,self.source)
  786. h.open_wallet('Monero',refresh=True)
  787. label = '{a} [{b}]'.format(
  788. a = self.label or f"xmrwallet new {'account' if self.account == None else 'address'}",
  789. b = make_timestr() )
  790. if self.account == None:
  791. acct,addr = h.create_acct(label=label)
  792. else:
  793. msg_r('\n Account index: {}'.format( pink(str(self.account)) ))
  794. addr = h.create_new_addr(self.account,label=label)
  795. accts_data = h.get_accts()[0]
  796. if self.account != None:
  797. h.print_addrs(accts_data,self.account)
  798. # wallet must be left open: otherwise the 'stop_wallet' RPC call used to stop the daemon will fail
  799. if uopt.no_stop_wallet_daemon:
  800. h.close_wallet('Monero')
  801. msg('')
  802. class label(spec):
  803. name = 'label'
  804. spec_id = 'label_spec'
  805. spec_key = ( (1,'source'), )
  806. opts = ()
  807. async def main(self):
  808. gmsg('\n{} label for wallet {}, account #{}, address #{}'.format(
  809. 'Setting' if self.label else 'Removing',
  810. self.source.idx,
  811. self.account,
  812. self.address_idx
  813. ))
  814. h = self.rpc(self,self.source)
  815. h.open_wallet('source')
  816. accts_data = h.get_accts()[0]
  817. max_acct = len(accts_data['subaddress_accounts']) - 1
  818. if self.account > max_acct:
  819. die(1,f'{self.account}: requested account index out of bounds (>{max_acct})')
  820. ret = h.print_addrs(accts_data,self.account)
  821. if self.address_idx > len(ret['addresses']) - 1:
  822. die(1,'{}: requested address index out of bounds (>{})'.format(
  823. self.account,
  824. len(ret['addresses']) - 1 ))
  825. addr = ret['addresses'][self.address_idx]
  826. msg('\n {} {}\n {} {}\n {} {}'.format(
  827. 'Address: ',
  828. cyan(addr['address'][:15] + '...'),
  829. 'Existing label:',
  830. pink(addr['label']) if addr['label'] else '[none]',
  831. 'New label: ',
  832. pink(self.label) if self.label else '[none]' ))
  833. if addr['label'] == self.label:
  834. ymsg('\nLabel is unchanged, operation cancelled')
  835. elif keypress_confirm(' {} label?'.format('Set' if self.label else 'Remove')):
  836. h.set_label( self.account, self.address_idx, self.label )
  837. accts_data = h.get_accts(print=False)[0]
  838. ret = h.print_addrs(accts_data,self.account)
  839. new_label = ret['addresses'][self.address_idx]['label']
  840. if new_label != self.label:
  841. ymsg(f'Warning: new label {new_label!r} does not match requested value!')
  842. return False
  843. else:
  844. msg(cyan('\nLabel successfully {}'.format('set' if self.label else 'removed')))
  845. else:
  846. ymsg('\nOperation cancelled by user request')
  847. class relay(base):
  848. name = 'relay'
  849. opts = ('tx_relay_daemon',)
  850. def __init__(self,uarg_tuple,uopt_tuple):
  851. super().__init__(uarg_tuple,uopt_tuple)
  852. if uopt.tx_relay_daemon:
  853. m = re.fullmatch(uarg_info['tx_relay_daemon'].pat,uopt.tx_relay_daemon,re.ASCII)
  854. host,port = m[1].split(':')
  855. proxy = m[2]
  856. md = None
  857. else:
  858. from .daemon import CoinDaemon
  859. md = CoinDaemon('xmr',test_suite=g.test_suite)
  860. host,port = md.host,md.rpc_port
  861. proxy = None
  862. self.dc = MoneroRPCClient(
  863. proto = self.proto,
  864. daemon = md,
  865. host = host,
  866. port = int(port),
  867. user = None,
  868. passwd = None,
  869. test_connection = False, # relay is presumably a public node, so avoid extra connections
  870. proxy = proxy )
  871. self.tx = MoneroMMGenTX.Signed(uarg.infile)
  872. async def main(self):
  873. msg('\n' + self.tx.get_info())
  874. if uopt.tx_relay_daemon:
  875. self.display_tx_relay_info()
  876. if keypress_confirm('Relay transaction?'):
  877. res = self.dc.call_raw(
  878. 'send_raw_transaction',
  879. tx_as_hex = self.tx.data.blob
  880. )
  881. if res['status'] == 'OK':
  882. msg('Status: ' + green('OK'))
  883. if res['not_relayed']:
  884. ymsg('Transaction not relayed')
  885. return True
  886. else:
  887. die( 'RPCFailure', repr(res) )
  888. else:
  889. die(1,'Exiting at user request')
  890. class txview(base):
  891. name = 'txview'
  892. async def main(self):
  893. stdout_or_pager(
  894. '\n'.join(
  895. tx.get_info() for tx in
  896. sorted(
  897. (MoneroMMGenTX.Signed(fn) for fn in uarg.infile),
  898. key = lambda x: x.data.sign_time )
  899. ))