xmrwallet.py 34 KB

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