xmrwallet.py 32 KB

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