xmrwallet.py 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805
  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 re,time,json
  22. from collections import namedtuple
  23. from pathlib import PosixPath as Path
  24. from .objmethods import MMGenObject,Hilite,InitErrors
  25. from .obj import CoinTxID,Int
  26. from .color import red,yellow,green,blue,cyan,pink,orange
  27. from .util import (
  28. msg,
  29. msg_r,
  30. gmsg,
  31. bmsg,
  32. ymsg,
  33. rmsg,
  34. gmsg_r,
  35. pp_msg,
  36. die,
  37. fmt,
  38. suf,
  39. async_run,
  40. make_timestr,
  41. make_chksum_N,
  42. capfirst,
  43. )
  44. from .fileutil import get_data_from_file
  45. from .seed import SeedID
  46. from .protocol import init_proto
  47. from .proto.btc.common import b58a
  48. from .addr import CoinAddr,AddrIdx
  49. from .addrlist import KeyAddrList,ViewKeyAddrList,AddrIdxList
  50. from .rpc import json_encoder
  51. from .proto.xmr.rpc import MoneroRPCClient,MoneroWalletRPCClient
  52. from .proto.xmr.daemon import MoneroWalletDaemon
  53. from .ui import keypress_confirm
  54. from .autosign import get_autosign_obj
  55. xmrwallet_uargs = namedtuple('xmrwallet_uargs',[
  56. 'infile',
  57. 'wallets',
  58. 'spec',
  59. ])
  60. xmrwallet_uarg_info = (
  61. lambda e,hp: {
  62. 'daemon': e('HOST:PORT', hp),
  63. 'tx_relay_daemon': e('HOST:PORT[:PROXY_HOST:PROXY_PORT]', rf'({hp})(?::({hp}))?'),
  64. 'newaddr_spec': e('WALLET_NUM[:ACCOUNT][,"label text"]', rf'(\d+)(?::(\d+))?(?:,(.*))?'),
  65. 'transfer_spec': e('SOURCE_WALLET_NUM:ACCOUNT:ADDRESS,AMOUNT', rf'(\d+):(\d+):([{b58a}]+),([0-9.]+)'),
  66. 'sweep_spec': e('SOURCE_WALLET_NUM:ACCOUNT[,DEST_WALLET_NUM]', r'(\d+):(\d+)(?:,(\d+))?'),
  67. 'label_spec': e('WALLET_NUM:ACCOUNT:ADDRESS,"label text"', rf'(\d+):(\d+):(\d+),(.*)'),
  68. })(
  69. namedtuple('uarg_info_entry',['annot','pat']),
  70. r'(?:[^:]+):(?:\d+)'
  71. )
  72. class XMRWalletAddrSpec(str,Hilite,InitErrors,MMGenObject):
  73. color = 'cyan'
  74. width = 0
  75. trunc_ok = False
  76. min_len = 5 # 1:0:0
  77. max_len = 14 # 9999:9999:9999
  78. def __new__(cls,arg1,arg2=None,arg3=None):
  79. if type(arg1) == cls:
  80. return arg1
  81. try:
  82. if isinstance(arg1,str):
  83. me = str.__new__(cls,arg1)
  84. m = re.fullmatch( '({n}):({n}):({n}|None)'.format(n=r'[0-9]{1,4}'), arg1 )
  85. assert m is not None, f'{arg1!r}: invalid XMRWalletAddrSpec'
  86. for e in m.groups():
  87. if len(e) != 1 and e[0] == '0':
  88. die(2,f'{e}: leading zeroes not permitted in XMRWalletAddrSpec element')
  89. me.wallet = AddrIdx(m[1])
  90. me.account = int(m[2])
  91. me.account_address = None if m[3] == 'None' else int(m[3])
  92. else:
  93. me = str.__new__(cls,f'{arg1}:{arg2}:{arg3}')
  94. for arg in [arg1,arg2] + ([] if arg3 is None else [arg3]):
  95. assert isinstance(arg,int), f'{arg}: XMRWalletAddrSpec component not of type int'
  96. assert arg is None or arg <= 9999, f'{arg}: XMRWalletAddrSpec component greater than 9999'
  97. me.wallet = AddrIdx(arg1)
  98. me.account = arg2
  99. me.account_address = arg3
  100. return me
  101. except Exception as e:
  102. return cls.init_fail(e,me)
  103. def is_xmr_tx_file(cfg,fn):
  104. try:
  105. MoneroMMGenTX.Completed(cfg,fn)
  106. return True
  107. except Exception as e:
  108. if not 'MoneroMMGenTXFileParseError' in type(e).__name__:
  109. ymsg(f'\n{type(e).__name__}: {e}')
  110. return False
  111. class MoneroMMGenFile:
  112. def make_chksum(self,keys=None):
  113. res = json.dumps(
  114. dict( (k,v) for k,v in self.data._asdict().items() if (not keys or k in keys) ),
  115. cls = json_encoder
  116. )
  117. return make_chksum_N( res, rounds=1, nchars=self.chksum_nchars, upper=False )
  118. @property
  119. def base_chksum(self):
  120. return self.make_chksum(self.base_chksum_fields)
  121. @property
  122. def full_chksum(self):
  123. return self.make_chksum(self.full_chksum_fields) if self.full_chksum_fields else None
  124. def check_checksums(self,d_wrap):
  125. for k in ('base_chksum','full_chksum'):
  126. a = getattr(self,k)
  127. if a is not None:
  128. b = d_wrap[k]
  129. assert a == b, f'{k} mismatch: {a} != {b}'
  130. def make_wrapped_data(self,in_data):
  131. out = {
  132. 'base_chksum': self.base_chksum,
  133. 'full_chksum': self.full_chksum,
  134. 'data': in_data,
  135. } if self.full_chksum else {
  136. 'base_chksum': self.base_chksum,
  137. 'data': in_data,
  138. }
  139. return json.dumps(
  140. { self.data_label: out },
  141. cls = json_encoder,
  142. indent = 2,
  143. )
  144. def extract_data_from_file(self,cfg,fn):
  145. return json.loads( get_data_from_file( cfg, str(fn), self.desc ))[self.data_label]
  146. class MoneroMMGenTX:
  147. class Base(MoneroMMGenFile):
  148. data_label = 'MoneroMMGenTX'
  149. base_chksum_fields = ('op','create_time','network','seed_id','source','dest','amount')
  150. full_chksum_fields = ('op','create_time','network','seed_id','source','dest','amount','fee','blob')
  151. chksum_nchars = 6
  152. xmrwallet_tx_data = namedtuple('xmrwallet_tx_data',[
  153. 'op',
  154. 'create_time',
  155. 'sign_time',
  156. 'network',
  157. 'seed_id',
  158. 'source',
  159. 'dest',
  160. 'dest_address',
  161. 'txid',
  162. 'amount',
  163. 'fee',
  164. 'blob',
  165. 'metadata',
  166. 'unsigned_txset',
  167. 'signed_txset',
  168. 'complete',
  169. ])
  170. def __init__(self):
  171. self.name = type(self).__name__
  172. @property
  173. def src_wallet_idx(self):
  174. return int(self.data.source.split(':')[0])
  175. def get_info(self,indent=''):
  176. d = self.data
  177. if d.dest:
  178. to_entry = f'\n{indent} To: ' + (
  179. 'Wallet {}, account {}, address {}'.format(
  180. d.dest.wallet.hl(),
  181. red(f'#{d.dest.account}'),
  182. red(f'#{d.dest.account_address}')
  183. )
  184. )
  185. fs = """
  186. Info for transaction {a} [Seed ID: {b}. Network: {c}]:
  187. TxID: {d}
  188. Created: {e:19} [{f}]
  189. Signed: {g:19} [{h}]
  190. Type: {i}
  191. From: Wallet {j}, account {k}{l}
  192. Amount: {m} XMR
  193. Fee: {n} XMR
  194. Dest: {o}
  195. """
  196. pmid = d.dest_address.parsed.payment_id
  197. if pmid:
  198. fs += ' Payment ID: {pmid}'
  199. coldsign_status = (
  200. pink(' [cold signed{}]'.format(', submitted' if d.complete else ''))
  201. if d.signed_txset else '' )
  202. from .util2 import format_elapsed_hr
  203. return fmt(fs,strip_char='\t',indent=indent).format(
  204. a = orange(self.base_chksum.upper()),
  205. b = d.seed_id.hl(),
  206. c = yellow(d.network.upper()),
  207. d = d.txid.hl(),
  208. e = make_timestr(d.create_time),
  209. f = format_elapsed_hr(d.create_time),
  210. g = make_timestr(d.sign_time) if d.sign_time else '-',
  211. h = format_elapsed_hr(d.sign_time) if d.sign_time else '-',
  212. i = blue(capfirst(d.op)) + coldsign_status,
  213. j = d.source.wallet.hl(),
  214. k = red(f'#{d.source.account}'),
  215. l = to_entry if d.dest else '',
  216. m = d.amount.hl(),
  217. n = d.fee.hl(),
  218. o = d.dest_address.hl(),
  219. pmid = pink(pmid.hex()) if pmid else None
  220. )
  221. def write(self,delete_metadata=False,ask_write=True,ask_overwrite=True):
  222. dict_data = self.data._asdict()
  223. if delete_metadata:
  224. dict_data['metadata'] = None
  225. fn = '{a}{b}-XMR[{c!s}]{d}.{e}'.format(
  226. a = self.base_chksum.upper(),
  227. b = (lambda s: f'-{s.upper()}' if s else '')(self.full_chksum),
  228. c = self.data.amount,
  229. d = (lambda s: '' if s == 'mainnet' else f'.{s}')(self.data.network),
  230. e = self.ext
  231. )
  232. if self.cfg.autosign:
  233. fn = get_autosign_obj(self.cfg,'xmr').xmr_tx_dir / fn
  234. from .fileutil import write_data_to_file
  235. write_data_to_file(
  236. cfg = self.cfg,
  237. outfile = str(fn),
  238. data = self.make_wrapped_data(dict_data),
  239. desc = self.desc,
  240. ask_write = ask_write,
  241. ask_write_default_yes = not ask_write,
  242. ask_overwrite = ask_overwrite,
  243. ignore_opt_outdir = self.cfg.autosign )
  244. class New(Base):
  245. def __init__(self,*args,**kwargs):
  246. super().__init__()
  247. assert not args, 'Non-keyword args not permitted'
  248. if '_in_tx' in kwargs:
  249. in_data = kwargs.pop('_in_tx').data._asdict()
  250. in_data.update(kwargs)
  251. else:
  252. in_data = kwargs
  253. d = namedtuple('monero_tx_in_data_tuple',in_data)(**in_data)
  254. self.cfg = d.cfg
  255. proto = init_proto( self.cfg, 'xmr', network=d.network, need_amt=True )
  256. now = int(time.time())
  257. self.data = self.xmrwallet_tx_data(
  258. op = d.op,
  259. create_time = getattr(d,'create_time',now),
  260. sign_time = (getattr(d,'sign_time',None) or now) if self.signed else None,
  261. network = d.network,
  262. seed_id = SeedID(sid=d.seed_id),
  263. source = XMRWalletAddrSpec(d.source),
  264. dest = None if d.dest is None else XMRWalletAddrSpec(d.dest),
  265. dest_address = CoinAddr(proto,d.dest_address),
  266. txid = CoinTxID(d.txid),
  267. amount = proto.coin_amt(d.amount,from_unit='atomic'),
  268. fee = proto.coin_amt(d.fee,from_unit='atomic'),
  269. blob = d.blob,
  270. metadata = d.metadata,
  271. unsigned_txset = d.unsigned_txset,
  272. signed_txset = getattr(d,'signed_txset',None),
  273. complete = True if self.name == 'NewSigned' else getattr(d,'complete',False),
  274. )
  275. class NewUnsigned(New):
  276. desc = 'unsigned transaction'
  277. ext = 'rawtx'
  278. signed = False
  279. class NewSigned(New):
  280. desc = 'signed transaction'
  281. ext = 'sigtx'
  282. signed = True
  283. class NewColdSigned(NewSigned):
  284. pass
  285. class NewSubmitted(NewColdSigned):
  286. desc = 'submitted transaction'
  287. ext = 'subtx'
  288. class Completed(Base):
  289. desc = 'transaction'
  290. forbidden_fields = ()
  291. def __init__(self,cfg,fn):
  292. super().__init__()
  293. self.cfg = cfg
  294. self.fn = fn
  295. try:
  296. d_wrap = self.extract_data_from_file( cfg, fn )
  297. except Exception as e:
  298. die( 'MoneroMMGenTXFileParseError', f'{type(e).__name__}: {e}\nCould not load transaction file' )
  299. if not 'unsigned_txset' in d_wrap['data']: # backwards compat: use old checksum fields
  300. self.full_chksum_fields = (
  301. set(self.xmrwallet_tx_data._fields) -
  302. {'metadata','unsigned_txset','signed_txset','complete'} )
  303. for key in self.xmrwallet_tx_data._fields: # backwards compat: fill in missing fields
  304. if not key in d_wrap['data']:
  305. d_wrap['data'][key] = None
  306. d = self.xmrwallet_tx_data(**d_wrap['data'])
  307. if self.name != 'Completed':
  308. assert fn.name.endswith('.'+self.ext), 'TX filename {fn} has incorrect extension (not {self.ext!r})'
  309. assert getattr(d,self.req_field), f'{self.name} TX missing required field {self.req_field!r}'
  310. assert bool(d.sign_time)==self.signed,'{} has {}sign time!'.format(self.desc,'no 'if self.signed else'')
  311. for f in self.forbidden_fields:
  312. assert not getattr(d,f), f'{self.name} TX mismatch: contains forbidden field {f!r}'
  313. proto = init_proto( cfg, 'xmr', network=d.network, need_amt=True )
  314. self.data = self.xmrwallet_tx_data(
  315. op = d.op,
  316. create_time = d.create_time,
  317. sign_time = d.sign_time,
  318. network = d.network,
  319. seed_id = SeedID(sid=d.seed_id),
  320. source = XMRWalletAddrSpec(d.source),
  321. dest = None if d.dest is None else XMRWalletAddrSpec(d.dest),
  322. dest_address = CoinAddr(proto,d.dest_address),
  323. txid = CoinTxID(d.txid),
  324. amount = proto.coin_amt(d.amount),
  325. fee = proto.coin_amt(d.fee),
  326. blob = d.blob,
  327. metadata = d.metadata,
  328. unsigned_txset = d.unsigned_txset,
  329. signed_txset = d.signed_txset,
  330. complete = d.complete,
  331. )
  332. self.check_checksums(d_wrap)
  333. class Unsigned(Completed):
  334. desc = 'unsigned transaction'
  335. ext = 'rawtx'
  336. signed = False
  337. req_field = 'unsigned_txset'
  338. forbidden_fields = ('signed_txset',)
  339. class Signed(Completed):
  340. desc = 'signed transaction'
  341. ext = 'sigtx'
  342. signed = True
  343. req_field = 'blob'
  344. forbidden_fields = ('signed_txset','unsigned_txset')
  345. class ColdSigned(Signed):
  346. req_field = 'signed_txset'
  347. forbidden_fields = ()
  348. class Submitted(ColdSigned):
  349. desc = 'submitted transaction'
  350. ext = 'subtx'
  351. class MoneroWalletOutputsFile:
  352. class Base(MoneroMMGenFile):
  353. desc = 'wallet outputs'
  354. data_label = 'MoneroMMGenWalletOutputsFile'
  355. base_chksum_fields = ('seed_id','wallet_index','outputs_data_hex',)
  356. full_chksum_fields = ('seed_id','wallet_index','outputs_data_hex','signed_key_images')
  357. fn_fs = '{a}-outputs-{b}.{c}'
  358. ext_offset = 25 # len('-outputs-') + len(chksum) ({b})
  359. chksum_nchars = 16
  360. data_tuple = namedtuple('wallet_outputs_data',[
  361. 'seed_id',
  362. 'wallet_index',
  363. 'outputs_data_hex',
  364. 'signed_key_images',
  365. ])
  366. def __init__(self,cfg):
  367. self.name = type(self).__name__
  368. self.cfg = cfg
  369. def write(self,add_suf=''):
  370. from .fileutil import write_data_to_file
  371. write_data_to_file(
  372. cfg = self.cfg,
  373. outfile = str(self.get_outfile( self.cfg, self.wallet_fn )) + add_suf,
  374. data = self.make_wrapped_data(self.data._asdict()),
  375. desc = self.desc,
  376. ask_overwrite = False,
  377. ignore_opt_outdir = True )
  378. def get_outfile(self,cfg,wallet_fn):
  379. return (
  380. get_autosign_obj(cfg,'xmr').xmr_outputs_dir if cfg.autosign else
  381. wallet_fn.parent ) / self.fn_fs.format(
  382. a = wallet_fn.name,
  383. b = self.base_chksum,
  384. c = self.ext,
  385. )
  386. def get_wallet_fn(self,fn):
  387. assert fn.name.endswith(f'.{self.ext}'), (
  388. f'{type(self).__name__}: filename does not end with {"."+self.ext!r}'
  389. )
  390. return fn.parent / fn.name[:-(len(self.ext)+self.ext_offset+1)]
  391. def get_info(self,indent=''):
  392. if self.data.signed_key_images is not None:
  393. data = self.data.signed_key_images or []
  394. return f'{self.wallet_fn.name}: {len(data)} signed key image{suf(data)}'
  395. else:
  396. return f'{self.wallet_fn.name}: no key images'
  397. class New(Base):
  398. ext = 'raw'
  399. def __init__( self, parent, wallet_fn, data, wallet_idx=None ):
  400. super().__init__(parent.cfg)
  401. self.wallet_fn = wallet_fn
  402. init_data = dict.fromkeys(self.data_tuple._fields)
  403. init_data.update({
  404. 'seed_id': parent.kal.al_id.sid,
  405. 'wallet_index': wallet_idx or parent.get_idx_from_fn(wallet_fn),
  406. })
  407. init_data.update({k:v for k,v in data.items() if k in init_data})
  408. self.data = self.data_tuple(**init_data)
  409. class Completed(New):
  410. def __init__( self, parent, fn=None, wallet_fn=None ):
  411. def check_equal(desc,a,b):
  412. assert a == b, f'{desc} mismatch: {a} (from file) != {b} (from filename)'
  413. fn = fn or self.get_outfile( parent.cfg, wallet_fn )
  414. wallet_fn = wallet_fn or self.get_wallet_fn(fn)
  415. d_wrap = self.extract_data_from_file( parent.cfg, fn )
  416. data = d_wrap['data']
  417. check_equal( 'Seed ID', data['seed_id'], parent.kal.al_id.sid )
  418. wallet_idx = parent.get_idx_from_fn(wallet_fn)
  419. check_equal( 'Wallet index', data['wallet_index'], wallet_idx )
  420. super().__init__(
  421. parent = parent,
  422. wallet_fn = wallet_fn,
  423. data = data,
  424. wallet_idx = wallet_idx,
  425. )
  426. self.check_checksums(d_wrap)
  427. @classmethod
  428. def find_fn_from_wallet_fn(cls,cfg,wallet_fn,ret_on_no_match=False):
  429. path = get_autosign_obj(cfg,'xmr').xmr_outputs_dir or Path()
  430. pat = cls.fn_fs.format(
  431. a = wallet_fn.name,
  432. b = f'[0-9a-f]{{{cls.chksum_nchars}}}\\',
  433. c = cls.ext,
  434. )
  435. matches = [f for f in path.iterdir() if re.match(pat,f.name)]
  436. if not matches and ret_on_no_match:
  437. return None
  438. if not matches or len(matches) > 1:
  439. die(2,"{a} matching pattern {b!r} found in '{c}'!".format(
  440. a = 'No files' if not matches else 'More than one file',
  441. b = pat,
  442. c = path
  443. ))
  444. return matches[0]
  445. class Unsigned(Completed):
  446. pass
  447. class SignedNew(New):
  448. desc = 'signed key images'
  449. ext = 'sig'
  450. class Signed(Completed,SignedNew):
  451. pass
  452. class MoneroWalletDumpFile:
  453. class Base:
  454. desc = 'Monero wallet dump'
  455. data_label = 'MoneroMMGenWalletDumpFile'
  456. base_chksum_fields = ('seed_id','wallet_index','wallet_metadata')
  457. full_chksum_fields = None
  458. ext = 'dump'
  459. ext_offset = 0
  460. data_tuple = namedtuple('wallet_dump_data',[
  461. 'seed_id',
  462. 'wallet_index',
  463. 'wallet_metadata',
  464. ])
  465. def get_outfile(self,cfg,wallet_fn):
  466. return wallet_fn.parent / f'{wallet_fn.name}.{self.ext}'
  467. class New(Base,MoneroWalletOutputsFile.New):
  468. pass
  469. class Completed(Base,MoneroWalletOutputsFile.Completed):
  470. pass
  471. class MoneroWalletOps:
  472. ops = (
  473. 'create',
  474. 'create_offline',
  475. 'sync',
  476. 'list',
  477. 'new',
  478. 'transfer',
  479. 'sweep',
  480. 'relay',
  481. 'txview',
  482. 'label',
  483. 'sign',
  484. 'submit',
  485. 'dump',
  486. 'restore',
  487. 'export_outputs',
  488. 'import_key_images' )
  489. kafile_arg_ops = (
  490. 'create',
  491. 'sync',
  492. 'list',
  493. 'label',
  494. 'new',
  495. 'transfer',
  496. 'sweep',
  497. 'dump',
  498. 'restore' )
  499. opts = (
  500. 'wallet_dir',
  501. 'daemon',
  502. 'tx_relay_daemon',
  503. 'use_internal_keccak_module',
  504. 'hash_preset',
  505. 'restore_height',
  506. 'no_start_wallet_daemon',
  507. 'no_stop_wallet_daemon',
  508. 'no_relay',
  509. 'watch_only',
  510. 'autosign' )
  511. pat_opts = ('daemon','tx_relay_daemon')
  512. class base(MMGenObject):
  513. opts = ('wallet_dir',)
  514. trust_daemon = False
  515. def __init__(self,cfg,uarg_tuple):
  516. def gen_classes():
  517. for cls in type(self).__mro__:
  518. yield cls
  519. if cls.__name__ == 'base':
  520. break
  521. self.name = type(self).__name__
  522. self.cfg = cfg
  523. classes = tuple(gen_classes())
  524. self.opts = tuple(set(opt for cls in classes for opt in cls.opts))
  525. if not hasattr(self,'stem'):
  526. self.stem = self.name
  527. global uarg, uarg_info, fmt_amt, hl_amt
  528. uarg = uarg_tuple
  529. uarg_info = xmrwallet_uarg_info
  530. def fmt_amt(amt):
  531. return self.proto.coin_amt(amt,from_unit='atomic').fmt( iwidth=5, prec=12, color=True )
  532. def hl_amt(amt):
  533. return self.proto.coin_amt(amt,from_unit='atomic').hl()
  534. id_cur = None
  535. for cls in classes:
  536. if id(cls.check_uopts) != id_cur:
  537. cls.check_uopts(self)
  538. id_cur = id(cls.check_uopts)
  539. self.proto = init_proto( cfg, 'xmr', network=self.cfg.network, need_amt=True )
  540. def check_uopts(self):
  541. def check_pat_opt(name):
  542. val = getattr(self.cfg,name)
  543. if not re.fullmatch( uarg_info[name].pat, val, re.ASCII ):
  544. die(1,'{!r}: invalid value for --{}: it must have format {!r}'.format(
  545. val,
  546. name.replace('_','-'),
  547. uarg_info[name].annot
  548. ))
  549. for attr in self.cfg.__dict__:
  550. if attr in MoneroWalletOps.opts and not attr in self.opts:
  551. die(1,'Option --{} not supported for {!r} operation'.format(
  552. attr.replace('_','-'),
  553. self.name,
  554. ))
  555. for opt in MoneroWalletOps.pat_opts:
  556. if getattr(self.cfg,opt,None):
  557. check_pat_opt(opt)
  558. def parse_tx_relay_opt(self):
  559. return re.fullmatch(
  560. uarg_info['tx_relay_daemon'].pat,
  561. self.cfg.tx_relay_daemon,
  562. re.ASCII )
  563. def display_tx_relay_info(self,indent=''):
  564. m = self.parse_tx_relay_opt()
  565. msg(fmt(f"""
  566. TX relay info:
  567. Host: {blue(m[1])}
  568. Proxy: {blue(m[2] or 'None')}
  569. """,strip_char='\t',indent=indent))
  570. def post_main(self):
  571. pass
  572. async def stop_wallet_daemon(self):
  573. pass
  574. class wallet(base):
  575. opts = (
  576. 'use_internal_keccak_module',
  577. 'hash_preset',
  578. 'daemon',
  579. 'no_start_wallet_daemon',
  580. 'no_stop_wallet_daemon',
  581. 'autosign',
  582. 'watch_only',
  583. )
  584. wallet_exists = True
  585. start_daemon = True
  586. offline = False
  587. skip_wallet_check = False # for debugging
  588. def __init__(self,cfg,uarg_tuple):
  589. def wallet_exists(fn):
  590. try: fn.stat()
  591. except: return False
  592. else: return True
  593. def check_wallets():
  594. for d in self.addr_data:
  595. fn = self.get_wallet_fn(d)
  596. exists = wallet_exists(fn)
  597. if exists and not self.wallet_exists:
  598. die(1,f"Wallet '{fn}' already exists!")
  599. elif not exists and self.wallet_exists:
  600. die(1,f"Wallet '{fn}' not found!")
  601. super().__init__(cfg,uarg_tuple)
  602. if self.offline:
  603. from .wallet import Wallet
  604. self.seed_src = Wallet(
  605. cfg = cfg,
  606. fn = uarg.infile,
  607. ignore_in_fmt = True )
  608. gmsg('\nCreating ephemeral key-address list for offline wallets')
  609. self.kal = KeyAddrList(
  610. cfg = cfg,
  611. proto = self.proto,
  612. seed = self.seed_src.seed,
  613. addr_idxs = uarg.wallets,
  614. skip_chksum_msg = True )
  615. else:
  616. # with watch_only, make a second attempt to open the file as KeyAddrList:
  617. for first_try in (True,False):
  618. try:
  619. self.kal = (ViewKeyAddrList if (self.cfg.watch_only and first_try) else KeyAddrList)(
  620. cfg = cfg,
  621. proto = self.proto,
  622. addrfile = str(self.autosign_viewkey_addr_file) if self.cfg.autosign else uarg.infile,
  623. key_address_validity_check = True,
  624. skip_chksum_msg = True )
  625. break
  626. except:
  627. if first_try:
  628. msg(f"Attempting to open '{uarg.infile}' as key-address list")
  629. continue
  630. raise
  631. msg('')
  632. self.create_addr_data()
  633. if not self.skip_wallet_check:
  634. check_wallets()
  635. relay_opt = self.parse_tx_relay_opt() if self.name == 'submit' and self.cfg.tx_relay_daemon else None
  636. self.wd = MoneroWalletDaemon(
  637. cfg = self.cfg,
  638. proto = self.proto,
  639. wallet_dir = self.cfg.wallet_dir or '.',
  640. test_suite = self.cfg.test_suite,
  641. daemon_addr = relay_opt[1] if relay_opt else (self.cfg.daemon or None),
  642. trust_daemon = self.trust_daemon,
  643. )
  644. u = self.wd.usr_daemon_args = []
  645. if self.offline or (self.name in ('create','restore') and self.cfg.restore_height is None):
  646. u.append('--offline')
  647. if relay_opt:
  648. if self.cfg.test_suite:
  649. u.append('--daemon-ssl-allow-any-cert')
  650. if relay_opt[2]:
  651. u.append(f'--proxy={relay_opt[2]}')
  652. self.c = MoneroWalletRPCClient(
  653. cfg = self.cfg,
  654. daemon = self.wd,
  655. test_connection = False,
  656. )
  657. if self.start_daemon and not self.cfg.no_start_wallet_daemon:
  658. async_run(self.c.restart_daemon())
  659. @classmethod
  660. def get_idx_from_fn(cls,fn):
  661. return int( re.match(r'[0-9a-fA-F]{8}-(\d+)-Monero(WatchOnly)?Wallet.*',fn.name)[1] )
  662. def get_coin_daemon_rpc(self):
  663. host,port = self.cfg.daemon.split(':') if self.cfg.daemon else ('localhost',self.wd.daemon_port)
  664. from .daemon import CoinDaemon
  665. return MoneroRPCClient(
  666. cfg = self.cfg,
  667. proto = self.proto,
  668. daemon = CoinDaemon( self.cfg, 'xmr' ),
  669. host = host,
  670. port = int(port),
  671. user = None,
  672. passwd = None )
  673. @property
  674. def autosign_viewkey_addr_file(self):
  675. from .addrfile import ViewKeyAddrFile
  676. mpdir = get_autosign_obj(self.cfg,'xmr').xmr_dir
  677. flist = [f for f in mpdir.iterdir() if f.name.endswith(ViewKeyAddrFile.ext)]
  678. if len(flist) != 1:
  679. die(2,
  680. "{a} viewkey-address files found in autosign mountpoint directory '{b}'!\n".format(
  681. a = 'Multiple' if flist else 'No',
  682. b = mpdir
  683. )
  684. + 'Have you run ‘mmgen-autosign setup’ on your offline machine with the --xmrwallets option?'
  685. )
  686. else:
  687. return flist[0]
  688. def create_addr_data(self):
  689. if uarg.wallets:
  690. idxs = AddrIdxList(uarg.wallets)
  691. self.addr_data = [d for d in self.kal.data if d.idx in idxs]
  692. if len(self.addr_data) != len(idxs):
  693. die(1,f'List {uarg.wallets!r} contains addresses not present in supplied key-address file')
  694. else:
  695. self.addr_data = self.kal.data
  696. async def stop_wallet_daemon(self):
  697. if not self.cfg.no_stop_wallet_daemon:
  698. await self.c.stop_daemon()
  699. def get_wallet_fn(self,data,watch_only=None):
  700. if watch_only is None:
  701. watch_only = self.cfg.watch_only
  702. return Path(
  703. (self.cfg.wallet_dir or '.'),
  704. '{a}-{b}-Monero{c}Wallet{d}'.format(
  705. a = self.kal.al_id.sid,
  706. b = data.idx,
  707. c = 'WatchOnly' if watch_only else '',
  708. d = f'.{self.cfg.network}' if self.cfg.network != 'mainnet' else '')
  709. )
  710. @property
  711. def add_wallet_desc(self):
  712. return 'offline signing ' if self.offline else 'watch-only ' if self.cfg.watch_only else ''
  713. async def main(self):
  714. gmsg('\n{a}ing {b} {c}wallet{d}'.format(
  715. a = self.stem.capitalize(),
  716. b = len(self.addr_data),
  717. c = self.add_wallet_desc,
  718. d = suf(self.addr_data) ))
  719. processed = 0
  720. for n,d in enumerate(self.addr_data): # [d.sec,d.addr,d.wallet_passwd,d.viewkey]
  721. fn = self.get_wallet_fn(d)
  722. gmsg('\n{}ing wallet {}/{} ({})'.format(
  723. self.stem.capitalize(),
  724. n+1,
  725. len(self.addr_data),
  726. fn.name,
  727. ))
  728. processed += await self.process_wallet(
  729. d,
  730. fn,
  731. last = n == len(self.addr_data)-1 )
  732. gmsg(f'\n{processed} wallet{suf(processed)} {self.stem}ed')
  733. return processed
  734. def head_msg(self,wallet_idx,fn):
  735. gmsg('\n{a} {b}wallet #{c} ({d})'.format(
  736. a = self.action.capitalize(),
  737. b = self.add_wallet_desc,
  738. c = wallet_idx,
  739. d = fn.name
  740. ))
  741. class rpc:
  742. def __init__(self,parent,d):
  743. self.parent = parent
  744. self.cfg = parent.cfg
  745. self.c = parent.c
  746. self.d = d
  747. self.fn = parent.get_wallet_fn(d)
  748. self.new_tx_cls = (
  749. MoneroMMGenTX.NewUnsigned if self.cfg.watch_only else
  750. MoneroMMGenTX.NewSigned )
  751. def open_wallet(self,desc=None,refresh=True):
  752. add_desc = desc + ' ' if desc else self.parent.add_wallet_desc
  753. gmsg_r(f'\n Opening {add_desc}wallet...')
  754. self.c.call( # returns {}
  755. 'open_wallet',
  756. filename = self.fn.name,
  757. password = self.d.wallet_passwd )
  758. gmsg('done')
  759. if refresh:
  760. m = ' and contacting relay' if self.parent.name == 'submit' and self.cfg.tx_relay_daemon else ''
  761. gmsg_r(f' Refreshing {add_desc}wallet{m}...')
  762. ret = self.c.call('refresh')
  763. gmsg('done')
  764. if ret['received_money']:
  765. msg(' Wallet has received funds')
  766. def close_wallet(self,desc):
  767. gmsg_r(f'\n Closing {desc} wallet...')
  768. self.c.call('close_wallet')
  769. gmsg_r('done')
  770. async def stop_wallet(self,desc):
  771. msg(f'Stopping {self.c.daemon.desc} on port {self.c.daemon.bind_port}')
  772. gmsg_r(f'\n Stopping {desc} wallet...')
  773. await self.c.stop_daemon(quiet=True) # closes wallet
  774. gmsg_r('done')
  775. def print_accts(self,data,addrs_data,indent=' '):
  776. d = data['subaddress_accounts']
  777. msg('\n' + indent + f'Accounts of wallet {self.fn.name}:')
  778. fs = indent + ' {:6} {:18} {:<6} {:%s} {}' % max(len(e['label']) for e in d)
  779. msg(fs.format('Index ','Base Address','nAddrs','Label','Unlocked Balance'))
  780. for i,e in enumerate(d):
  781. msg(fs.format(
  782. str(e['account_index']),
  783. e['base_address'][:15] + '...',
  784. len(addrs_data[i]['addresses']),
  785. e['label'],
  786. fmt_amt(e['unlocked_balance']),
  787. ))
  788. def get_accts(self,print=True):
  789. data = self.c.call('get_accounts')
  790. addrs_data = [
  791. self.c.call('get_address',account_index=i)
  792. for i in range(len(data['subaddress_accounts']))
  793. ]
  794. if print:
  795. self.print_accts(data,addrs_data)
  796. return ( data, addrs_data )
  797. def create_acct(self,label=None):
  798. msg('\n Creating new account...')
  799. ret = self.c.call(
  800. 'create_account',
  801. label = label or 'Sweep from {}:{} [{}]'.format(
  802. self.parent.source.idx,
  803. self.parent.account,
  804. make_timestr() ))
  805. msg(' Index: {}'.format( pink(str(ret['account_index'])) ))
  806. msg(' Address: {}'.format( cyan(ret['address']) ))
  807. return (ret['account_index'], ret['address'])
  808. def get_last_acct(self,accts_data):
  809. msg('\n Getting last account...')
  810. ret = accts_data['subaddress_accounts'][-1]
  811. msg(' Index: {}'.format( pink(str(ret['account_index'])) ))
  812. msg(' Address: {}'.format( cyan(ret['base_address']) ))
  813. return (ret['account_index'], ret['base_address'])
  814. def print_addrs(self,accts_data,account):
  815. ret = self.c.call('get_address',account_index=account)
  816. d = ret['addresses']
  817. msg('\n Addresses of account #{} ({}):'.format(
  818. account,
  819. accts_data['subaddress_accounts'][account]['label']))
  820. fs = ' {:6} {:18} {:%s} {}' % max( [len(e['label']) for e in d], default=0 )
  821. msg(fs.format('Index ','Address','Label','Used'))
  822. for e in d:
  823. msg(fs.format(
  824. str(e['address_index']),
  825. e['address'][:15] + '...',
  826. e['label'],
  827. e['used']
  828. ))
  829. return ret
  830. def create_new_addr(self,account,label=None):
  831. msg_r('\n Creating new address: ')
  832. ret = self.c.call(
  833. 'create_address',
  834. account_index = account,
  835. label = label or f'Sweep from this account [{make_timestr()}]',
  836. )
  837. msg(cyan(ret['address']))
  838. return ret['address']
  839. def get_last_addr(self,account,display=True):
  840. if display:
  841. msg('\n Getting last address:')
  842. ret = self.c.call(
  843. 'get_address',
  844. account_index = account,
  845. )['addresses']
  846. addr = ret[-1]['address']
  847. if display:
  848. msg(' ' + cyan(addr))
  849. return ( addr, len(ret) - 1 )
  850. def set_label(self,account,address_idx,label):
  851. return self.c.call(
  852. 'label_address',
  853. index = { 'major': account, 'minor': address_idx },
  854. label = label
  855. )
  856. def make_transfer_tx(self,account,addr,amt):
  857. res = self.c.call(
  858. 'transfer',
  859. account_index = account,
  860. destinations = [{
  861. 'amount': amt.to_unit('atomic'),
  862. 'address': addr
  863. }],
  864. do_not_relay = True,
  865. get_tx_hex = True,
  866. get_tx_metadata = True
  867. )
  868. return self.new_tx_cls(
  869. cfg = self.cfg,
  870. op = self.parent.name,
  871. network = self.parent.proto.network,
  872. seed_id = self.parent.kal.al_id.sid,
  873. source = XMRWalletAddrSpec(self.parent.source.idx,self.parent.account,None),
  874. dest = None,
  875. dest_address = addr,
  876. txid = res['tx_hash'],
  877. amount = res['amount'],
  878. fee = res['fee'],
  879. blob = res['tx_blob'],
  880. metadata = res['tx_metadata'],
  881. unsigned_txset = res['unsigned_txset'] if self.cfg.watch_only else None,
  882. )
  883. def make_sweep_tx(self,account,dest_acct,dest_addr_idx,addr):
  884. res = self.c.call(
  885. 'sweep_all',
  886. address = addr,
  887. account_index = account,
  888. do_not_relay = True,
  889. get_tx_hex = True,
  890. get_tx_metadata = True
  891. )
  892. if len(res['tx_hash_list']) > 1:
  893. die(3,'More than one TX required. Cannot perform this sweep')
  894. return self.new_tx_cls(
  895. cfg = self.cfg,
  896. op = self.parent.name,
  897. network = self.parent.proto.network,
  898. seed_id = self.parent.kal.al_id.sid,
  899. source = XMRWalletAddrSpec(self.parent.source.idx,self.parent.account,None),
  900. dest = XMRWalletAddrSpec(
  901. (self.parent.dest or self.parent.source).idx,
  902. dest_acct,
  903. dest_addr_idx),
  904. dest_address = addr,
  905. txid = res['tx_hash_list'][0],
  906. amount = res['amount_list'][0],
  907. fee = res['fee_list'][0],
  908. blob = res['tx_blob_list'][0],
  909. metadata = res['tx_metadata_list'][0],
  910. unsigned_txset = res['unsigned_txset'] if self.cfg.watch_only else None,
  911. )
  912. def relay_tx(self,tx_hex):
  913. ret = self.c.call('relay_tx',hex=tx_hex)
  914. try:
  915. msg('\n Relayed {}'.format( CoinTxID(ret['tx_hash']).hl() ))
  916. except:
  917. msg(f'\n Server returned: {ret!s}')
  918. class create(wallet):
  919. stem = 'creat'
  920. wallet_exists = False
  921. opts = ('restore_height',)
  922. def check_uopts(self):
  923. if self.cfg.restore_height != 'current':
  924. if int(self.cfg.restore_height or 0) < 0:
  925. die(1,f'{self.cfg.restore_height}: invalid value for --restore-height (less than zero)')
  926. async def process_wallet(self,d,fn,last):
  927. msg_r('') # for pexpect
  928. if self.cfg.restore_height == 'current':
  929. restore_height = self.get_coin_daemon_rpc().call_raw('get_height')['height']
  930. else:
  931. restore_height = self.cfg.restore_height
  932. if self.cfg.watch_only:
  933. ret = self.c.call(
  934. 'generate_from_keys',
  935. filename = fn.name,
  936. password = d.wallet_passwd,
  937. address = d.addr,
  938. viewkey = d.viewkey,
  939. restore_height = restore_height )
  940. else:
  941. from .xmrseed import xmrseed
  942. ret = self.c.call(
  943. 'restore_deterministic_wallet',
  944. filename = fn.name,
  945. password = d.wallet_passwd,
  946. seed = xmrseed().fromhex(d.sec.wif,tostr=True),
  947. restore_height = restore_height,
  948. language = 'English' )
  949. pp_msg(ret) if self.cfg.debug else msg(' Address: {}'.format( ret['address'] ))
  950. return True
  951. class create_offline(create):
  952. offline = True
  953. def __init__(self,cfg,uarg_tuple):
  954. super().__init__(cfg,uarg_tuple)
  955. gmsg('\nCreating viewkey-address file for watch-only wallets')
  956. vkal = ViewKeyAddrList(
  957. cfg = self.cfg,
  958. proto = self.proto,
  959. addrfile = None,
  960. addr_idxs = uarg.wallets,
  961. seed = self.seed_src.seed,
  962. skip_chksum_msg = True )
  963. vkf = vkal.file
  964. # before writing viewkey-address file, shred any old ones in the directory:
  965. for f in Path(self.cfg.outdir or '.').iterdir():
  966. if f.name.endswith(vkf.ext):
  967. from .fileutil import shred_file
  968. msg(f"\nShredding old viewkey-address file '{f}'")
  969. shred_file( f, verbose=self.cfg.verbose )
  970. vkf.write() # write file to self.cfg.outdir
  971. class restore(create):
  972. def check_uopts(self):
  973. if self.cfg.restore_height is not None:
  974. die(1,f'--restore-height must be unset when running the ‘restore’ command')
  975. async def process_wallet(self,d,fn,last):
  976. def get_dump_data():
  977. def gen():
  978. for fn in [self.get_wallet_fn(d,watch_only=wo) for wo in (True,False)]:
  979. ret = fn.parent / (fn.name + '.dump')
  980. if ret.exists():
  981. yield ret
  982. dump_fns = tuple(gen())
  983. if not dump_fns:
  984. die(1,f"No suitable dump file found for '{fn}'")
  985. elif len(dump_fns) > 1:
  986. ymsg(f"Warning: more than one dump file found for '{fn}' - using the first!")
  987. return MoneroWalletDumpFile.Completed(
  988. parent = self,
  989. fn = dump_fns[0] ).data._asdict()['wallet_metadata']
  990. def restore_accounts():
  991. bmsg(' Restoring accounts:')
  992. for acct_idx,acct_data in enumerate(data[1:],1):
  993. msg(fs.format(acct_idx, 0, acct_data['address']))
  994. self.c.call('create_account')
  995. def restore_subaddresses():
  996. bmsg(' Restoring subaddresses:')
  997. for acct_idx,acct_data in enumerate(data):
  998. for addr_idx,addr_data in enumerate(acct_data['addresses'][1:],1):
  999. msg(fs.format(acct_idx, addr_idx, addr_data['address']))
  1000. ret = self.c.call( 'create_address', account_index=acct_idx )
  1001. def restore_labels():
  1002. bmsg(' Restoring labels:')
  1003. for acct_idx,acct_data in enumerate(data):
  1004. for addr_idx,addr_data in enumerate(acct_data['addresses']):
  1005. addr_data['used'] = False # do this so that restored data matches
  1006. msg(fs.format(acct_idx, addr_idx, addr_data['label']))
  1007. self.c.call(
  1008. 'label_address',
  1009. index = { 'major': acct_idx, 'minor': addr_idx },
  1010. label = addr_data['label'],
  1011. )
  1012. def make_format_str():
  1013. return ' acct {:O>%s}, addr {:O>%s} [{}]' % (
  1014. len(str( len(data) - 1 )),
  1015. len(str( max(len(acct_data['addresses']) for acct_data in data) - 1))
  1016. )
  1017. def check_restored_data():
  1018. restored_data = h.get_accts(print=False)[1]
  1019. if restored_data != data:
  1020. rmsg(f'Restored data does not match original dump! Dumping bad data.')
  1021. MoneroWalletDumpFile.New(
  1022. parent = self,
  1023. wallet_fn = fn,
  1024. data = {'wallet_metadata': restored_data} ).write(add_suf='.bad')
  1025. die(3,'Fatal error')
  1026. res = await super().process_wallet(d,fn,last)
  1027. h = self.rpc(self,d)
  1028. h.open_wallet('newly created')
  1029. msg('')
  1030. data = get_dump_data()
  1031. fs = make_format_str()
  1032. gmsg('\nRestoring accounts, subaddresses and labels from dump file:\n')
  1033. restore_accounts()
  1034. restore_subaddresses()
  1035. restore_labels()
  1036. check_restored_data()
  1037. return True
  1038. class sync(wallet):
  1039. opts = ('rescan_blockchain',)
  1040. def __init__(self,cfg,uarg_tuple):
  1041. super().__init__(cfg,uarg_tuple)
  1042. self.dc = self.get_coin_daemon_rpc()
  1043. self.accts_data = {}
  1044. async def process_wallet(self,d,fn,last):
  1045. chain_height = self.dc.call_raw('get_height')['height']
  1046. msg(f' Chain height: {chain_height}')
  1047. t_start = time.time()
  1048. msg_r(' Opening wallet...')
  1049. self.c.call(
  1050. 'open_wallet',
  1051. filename = fn.name,
  1052. password = d.wallet_passwd )
  1053. msg('done')
  1054. msg_r(' Getting wallet height (be patient, this could take a long time)...')
  1055. wallet_height = self.c.call('get_height')['height']
  1056. msg_r('\r' + ' '*68 + '\r')
  1057. msg(f' Wallet height: {wallet_height} ')
  1058. behind = chain_height - wallet_height
  1059. if behind > 1000:
  1060. msg_r(f' Wallet is {behind} blocks behind chain tip. Please be patient. Syncing...')
  1061. ret = self.c.call('refresh')
  1062. if behind > 1000:
  1063. msg('done')
  1064. if ret['received_money']:
  1065. msg(' Wallet has received funds')
  1066. for i in range(2):
  1067. wallet_height = self.c.call('get_height')['height']
  1068. if wallet_height >= chain_height:
  1069. break
  1070. ymsg(f' Wallet failed to sync (wallet height [{wallet_height}] < chain height [{chain_height}])')
  1071. if i or not self.cfg.rescan_blockchain:
  1072. break
  1073. msg_r(' Rescanning blockchain, please be patient...')
  1074. self.c.call('rescan_blockchain')
  1075. self.c.call('refresh')
  1076. msg('done')
  1077. t_elapsed = int(time.time() - t_start)
  1078. a,b = self.rpc(self,d).get_accts(print=False)
  1079. msg(' Balance: {} Unlocked balance: {}'.format(
  1080. hl_amt(a['total_balance']),
  1081. hl_amt(a['total_unlocked_balance']),
  1082. ))
  1083. self.accts_data[fn.name] = { 'accts': a, 'addrs': b }
  1084. msg(f' Wallet height: {wallet_height}')
  1085. msg(' Sync time: {:02}:{:02}'.format(
  1086. t_elapsed // 60,
  1087. t_elapsed % 60 ))
  1088. if not last:
  1089. self.c.call('close_wallet')
  1090. return wallet_height >= chain_height
  1091. def post_main(self):
  1092. d = self.accts_data
  1093. for wnum,k in enumerate(d):
  1094. if self.name == 'sync':
  1095. self.rpc(self,self.addr_data[wnum]).print_accts( d[k]['accts'], d[k]['addrs'], indent='')
  1096. elif self.name == 'list':
  1097. fs = ' {:2} {} {} {}'
  1098. msg('\n' + green(f'Wallet {k}:'))
  1099. for acct_num,acct in enumerate(d[k]['addrs']):
  1100. msg('\n Account #{} [{} {}]'.format(
  1101. acct_num,
  1102. self.proto.coin_amt(
  1103. d[k]['accts']['subaddress_accounts'][acct_num]['unlocked_balance'],
  1104. from_unit='atomic').hl(),
  1105. self.proto.coin_amt.hlc('XMR')
  1106. ))
  1107. msg(fs.format('','Address'.ljust(95),'Used ','Label'))
  1108. for addr in acct['addresses']:
  1109. msg(fs.format(
  1110. addr['address_index'],
  1111. CoinAddr(self.proto,addr['address']).hl(),
  1112. ( yellow('True ') if addr['used'] else green('False') ),
  1113. pink(addr['label']) ))
  1114. col1_w = max(map(len,d)) + 1
  1115. fs = '{:%s} {} {}' % col1_w
  1116. tbals = [0,0]
  1117. msg('\n'+fs.format('Wallet','Balance ','Unlocked Balance'))
  1118. for k in d:
  1119. b = d[k]['accts']['total_balance']
  1120. ub = d[k]['accts']['total_unlocked_balance']
  1121. msg(fs.format( k + ':', fmt_amt(b), fmt_amt(ub) ))
  1122. tbals[0] += b
  1123. tbals[1] += ub
  1124. msg(fs.format( '-'*col1_w, '-'*18, '-'*18 ))
  1125. msg(fs.format( 'TOTAL:', fmt_amt(tbals[0]), fmt_amt(tbals[1]) ))
  1126. class list(sync):
  1127. stem = 'sync'
  1128. class spec(wallet): # virtual class
  1129. def create_addr_data(self):
  1130. m = re.fullmatch(uarg_info[self.spec_id].pat,uarg.spec,re.ASCII)
  1131. if not m:
  1132. fs = "{!r}: invalid {!r} arg: for {} operation, it must have format {!r}"
  1133. die(1,fs.format( uarg.spec, self.spec_id, self.name, uarg_info[self.spec_id].annot ))
  1134. def gen():
  1135. for i,k in self.spec_key:
  1136. if m[i] == None:
  1137. setattr(self,k,None)
  1138. else:
  1139. idx = int(m[i])
  1140. try:
  1141. res = self.kal.entry(idx)
  1142. except:
  1143. die(1,'Supplied key-address file does not contain address {}:{}'.format(
  1144. self.kal.al_id.sid,
  1145. idx ))
  1146. else:
  1147. setattr(self,k,res)
  1148. yield res
  1149. self.addr_data = list(gen())
  1150. self.account = None if m[2] is None else int(m[2])
  1151. def strip_quotes(s):
  1152. if s and s[0] in ("'",'"'):
  1153. if s[-1] != s[0] or len(s) < 2:
  1154. die(1,f'{s!r}: unbalanced quotes in label string!')
  1155. return s[1:-1]
  1156. else:
  1157. return s # None or empty string
  1158. if self.name == 'transfer':
  1159. self.dest_addr = CoinAddr(self.proto,m[3])
  1160. self.amount = self.proto.coin_amt(m[4])
  1161. elif self.name == 'new':
  1162. self.label = strip_quotes(m[3])
  1163. elif self.name == 'label':
  1164. self.address_idx = int(m[3])
  1165. self.label = strip_quotes(m[4])
  1166. class sweep(spec):
  1167. spec_id = 'sweep_spec'
  1168. spec_key = ( (1,'source'), (3,'dest') )
  1169. opts = ('no_relay','tx_relay_daemon','watch_only')
  1170. def check_uopts(self):
  1171. if self.cfg.tx_relay_daemon and (self.cfg.no_relay or self.cfg.autosign):
  1172. die(1,'--tx-relay-daemon makes no sense in this context!')
  1173. def init_tx_relay_daemon(self):
  1174. m = self.parse_tx_relay_opt()
  1175. wd2 = MoneroWalletDaemon(
  1176. cfg = self.cfg,
  1177. proto = self.proto,
  1178. wallet_dir = self.cfg.wallet_dir or '.',
  1179. test_suite = self.cfg.test_suite,
  1180. daemon_addr = m[1],
  1181. proxy = m[2] )
  1182. if self.cfg.test_suite:
  1183. wd2.usr_daemon_args = ['--daemon-ssl-allow-any-cert']
  1184. wd2.start()
  1185. self.c = MoneroWalletRPCClient(
  1186. cfg = self.cfg,
  1187. daemon = wd2 )
  1188. async def main(self):
  1189. gmsg(f'\n{self.stem.capitalize()}ing account #{self.account} of wallet {self.source.idx}' + (
  1190. f': {self.amount} XMR to {self.dest_addr}' if self.name == 'transfer'
  1191. else ' to new address' if self.dest == None
  1192. else f' to new account in wallet {self.dest.idx}' ))
  1193. h = self.rpc(self,self.source)
  1194. h.open_wallet('source')
  1195. accts_data = h.get_accts()[0]
  1196. max_acct = len(accts_data['subaddress_accounts']) - 1
  1197. if self.account > max_acct:
  1198. die(1,f'{self.account}: requested account index out of bounds (>{max_acct})')
  1199. h.print_addrs(accts_data,self.account)
  1200. if self.name == 'transfer':
  1201. dest_addr = self.dest_addr
  1202. elif self.dest == None:
  1203. dest_acct = self.account
  1204. if keypress_confirm( self.cfg, f'\nCreate new address for account #{self.account}?' ):
  1205. dest_addr_chk = h.create_new_addr(self.account)
  1206. elif keypress_confirm( self.cfg, f'Sweep to last existing address of account #{self.account}?' ):
  1207. dest_addr_chk = None
  1208. else:
  1209. die(1,'Exiting at user request')
  1210. dest_addr,dest_addr_idx = h.get_last_addr(self.account,display=not dest_addr_chk)
  1211. assert dest_addr_chk in (None,dest_addr), 'dest_addr_chk1'
  1212. h.print_addrs(accts_data,self.account)
  1213. else:
  1214. h.close_wallet('source')
  1215. wf = self.get_wallet_fn(self.dest)
  1216. h2 = self.rpc(self,self.dest)
  1217. h2.open_wallet('destination')
  1218. accts_data = h2.get_accts()[0]
  1219. if keypress_confirm( self.cfg, f'\nCreate new account for wallet {wf.name!r}?' ):
  1220. dest_acct,dest_addr = h2.create_acct()
  1221. dest_addr_idx = 0
  1222. h2.get_accts()
  1223. elif keypress_confirm( self.cfg, f'Sweep to last existing account of wallet {wf.name!r}?' ):
  1224. dest_acct,dest_addr_chk = h2.get_last_acct(accts_data)
  1225. dest_addr,dest_addr_idx = h2.get_last_addr(dest_acct,display=False)
  1226. assert dest_addr_chk == dest_addr, 'dest_addr_chk2'
  1227. else:
  1228. die(1,'Exiting at user request')
  1229. h2.close_wallet('destination')
  1230. h.open_wallet('source',refresh=False)
  1231. msg(f'\n Creating {self.name} transaction...')
  1232. if self.name == 'transfer':
  1233. new_tx = h.make_transfer_tx(self.account,dest_addr,self.amount)
  1234. elif self.name == 'sweep':
  1235. new_tx = h.make_sweep_tx(self.account,dest_acct,dest_addr_idx,dest_addr)
  1236. msg('\n' + new_tx.get_info(indent=' '))
  1237. if self.cfg.tx_relay_daemon:
  1238. self.display_tx_relay_info(indent=' ')
  1239. msg('Saving TX data to file')
  1240. new_tx.write(delete_metadata=True)
  1241. if self.cfg.no_relay or self.cfg.autosign:
  1242. return True
  1243. if keypress_confirm( self.cfg, f'Relay {self.name} transaction?' ):
  1244. if self.cfg.tx_relay_daemon:
  1245. await h.stop_wallet('source')
  1246. msg('')
  1247. self.init_tx_relay_daemon()
  1248. h = self.rpc(self,self.source)
  1249. h.open_wallet('TX-relay-configured source',refresh=False)
  1250. msg_r(f'\n Relaying {self.name} transaction...')
  1251. h.relay_tx(new_tx.data.metadata)
  1252. gmsg('\nAll done')
  1253. return True
  1254. else:
  1255. die(1,'\nExiting at user request')
  1256. class transfer(sweep):
  1257. stem = 'transferr'
  1258. spec_id = 'transfer_spec'
  1259. spec_key = ( (1,'source'), )
  1260. class new(spec):
  1261. spec_id = 'newaddr_spec'
  1262. spec_key = ( (1,'source'), )
  1263. async def main(self):
  1264. h = self.rpc(self,self.source)
  1265. h.open_wallet('Monero',refresh=True)
  1266. label = '{a} [{b}]'.format(
  1267. a = self.label or f"xmrwallet new {'account' if self.account == None else 'address'}",
  1268. b = make_timestr() )
  1269. if self.account == None:
  1270. acct,addr = h.create_acct(label=label)
  1271. else:
  1272. msg_r('\n Account index: {}'.format( pink(str(self.account)) ))
  1273. addr = h.create_new_addr(self.account,label=label)
  1274. accts_data = h.get_accts()[0]
  1275. if self.account != None:
  1276. h.print_addrs(accts_data,self.account)
  1277. # wallet must be left open: otherwise the 'stop_wallet' RPC call used to stop the daemon will fail
  1278. if self.cfg.no_stop_wallet_daemon:
  1279. h.close_wallet('Monero')
  1280. msg('')
  1281. class label(spec):
  1282. spec_id = 'label_spec'
  1283. spec_key = ( (1,'source'), )
  1284. opts = ()
  1285. async def main(self):
  1286. gmsg('\n{} label for wallet {}, account #{}, address #{}'.format(
  1287. 'Setting' if self.label else 'Removing',
  1288. self.source.idx,
  1289. self.account,
  1290. self.address_idx
  1291. ))
  1292. h = self.rpc(self,self.source)
  1293. h.open_wallet('source')
  1294. accts_data = h.get_accts()[0]
  1295. max_acct = len(accts_data['subaddress_accounts']) - 1
  1296. if self.account > max_acct:
  1297. die(1,f'{self.account}: requested account index out of bounds (>{max_acct})')
  1298. ret = h.print_addrs(accts_data,self.account)
  1299. if self.address_idx > len(ret['addresses']) - 1:
  1300. die(1,'{}: requested address index out of bounds (>{})'.format(
  1301. self.account,
  1302. len(ret['addresses']) - 1 ))
  1303. addr = ret['addresses'][self.address_idx]
  1304. msg('\n {} {}\n {} {}\n {} {}'.format(
  1305. 'Address: ',
  1306. cyan(addr['address'][:15] + '...'),
  1307. 'Existing label:',
  1308. pink(addr['label']) if addr['label'] else '[none]',
  1309. 'New label: ',
  1310. pink(self.label) if self.label else '[none]' ))
  1311. if addr['label'] == self.label:
  1312. ymsg('\nLabel is unchanged, operation cancelled')
  1313. elif keypress_confirm( self.cfg, ' {} label?'.format('Set' if self.label else 'Remove') ):
  1314. h.set_label( self.account, self.address_idx, self.label )
  1315. accts_data = h.get_accts(print=False)[0]
  1316. ret = h.print_addrs(accts_data,self.account)
  1317. new_label = ret['addresses'][self.address_idx]['label']
  1318. if new_label != self.label:
  1319. ymsg(f'Warning: new label {new_label!r} does not match requested value!')
  1320. return False
  1321. else:
  1322. msg(cyan('\nLabel successfully {}'.format('set' if self.label else 'removed')))
  1323. else:
  1324. ymsg('\nOperation cancelled by user request')
  1325. class sign(wallet):
  1326. action = 'signing transaction with'
  1327. start_daemon = False
  1328. offline = True
  1329. async def main(self,fn):
  1330. await self.c.restart_daemon()
  1331. tx = MoneroMMGenTX.Unsigned( self.cfg, fn )
  1332. h = self.rpc(self,self.addr_data[0])
  1333. self.head_msg(tx.src_wallet_idx,h.fn)
  1334. h.open_wallet()
  1335. res = self.c.call(
  1336. 'sign_transfer',
  1337. unsigned_txset = tx.data.unsigned_txset,
  1338. export_raw = True,
  1339. get_tx_keys = True
  1340. )
  1341. new_tx = MoneroMMGenTX.NewColdSigned(
  1342. cfg = self.cfg,
  1343. txid = res['tx_hash_list'][0],
  1344. unsigned_txset = None,
  1345. signed_txset = res['signed_txset'],
  1346. _in_tx = tx,
  1347. )
  1348. await self.stop_wallet_daemon()
  1349. return new_tx
  1350. class submit(wallet):
  1351. action = 'submitting transaction with'
  1352. opts = ('tx_relay_daemon',)
  1353. def check_uopts(self):
  1354. if self.cfg.daemon:
  1355. die(1,f'--daemon is not supported for the ‘{self.name}’ operation. Use --tx-relay-daemon instead')
  1356. @property
  1357. def unsubmitted_tx_path(self):
  1358. from .autosign import Signable
  1359. t = Signable.xmr_transaction( get_autosign_obj(self.cfg,'xmr') )
  1360. if len(t.unsubmitted) != 1:
  1361. die('AutosignTXError', "{a} unsubmitted transaction{b} in '{c}'!".format(
  1362. a = 'More than one' if t.unsubmitted else 'No',
  1363. b = suf(t.unsubmitted),
  1364. c = t.parent.xmr_tx_dir,
  1365. ))
  1366. return t.unsubmitted[0]
  1367. async def main(self):
  1368. tx = MoneroMMGenTX.ColdSigned(
  1369. cfg = self.cfg,
  1370. fn = Path(uarg.infile) if uarg.infile else self.unsubmitted_tx_path )
  1371. h = self.rpc( self, self.kal.entry(tx.src_wallet_idx) )
  1372. self.head_msg(tx.src_wallet_idx,h.fn)
  1373. h.open_wallet()
  1374. msg('\n' + tx.get_info())
  1375. if self.cfg.tx_relay_daemon:
  1376. self.display_tx_relay_info()
  1377. if keypress_confirm( self.cfg, 'Submit transaction?' ):
  1378. res = self.c.call(
  1379. 'submit_transfer',
  1380. tx_data_hex = tx.data.signed_txset )
  1381. assert res['tx_hash_list'][0] == tx.data.txid, 'TxID mismatch in ‘submit_transfer’ result!'
  1382. else:
  1383. die(1,'Exiting at user request')
  1384. new_tx = MoneroMMGenTX.NewSubmitted(
  1385. cfg = self.cfg,
  1386. complete = True,
  1387. _in_tx = tx,
  1388. )
  1389. gmsg('\nOK')
  1390. new_tx.write(
  1391. ask_write = not self.cfg.autosign,
  1392. ask_overwrite = not self.cfg.autosign )
  1393. return new_tx
  1394. class dump(wallet):
  1395. async def process_wallet(self,d,fn,last):
  1396. h = self.rpc(self,d)
  1397. h.open_wallet('source')
  1398. acct_data,addr_data = h.get_accts(print=False)
  1399. msg('')
  1400. MoneroWalletDumpFile.New(
  1401. parent = self,
  1402. wallet_fn = fn,
  1403. data = {'wallet_metadata': addr_data} ).write()
  1404. return True
  1405. class export_outputs(wallet):
  1406. action = 'exporting outputs from'
  1407. stem = 'process'
  1408. opts = ('export_all',)
  1409. async def process_wallet(self,d,fn,last):
  1410. h = self.rpc(self,d)
  1411. h.open_wallet('source')
  1412. self.head_msg(d.idx,h.fn)
  1413. for ftype in ('Unsigned','Signed'):
  1414. old_fn = getattr(MoneroWalletOutputsFile,ftype).find_fn_from_wallet_fn(
  1415. cfg = self.cfg,
  1416. wallet_fn = fn,
  1417. ret_on_no_match = True )
  1418. if old_fn:
  1419. old_fn.unlink()
  1420. m = MoneroWalletOutputsFile.New(
  1421. parent = self,
  1422. wallet_fn = fn,
  1423. data = self.c.call('export_outputs', all=self.cfg.export_all ),
  1424. )
  1425. m.write()
  1426. return True
  1427. class export_key_images(wallet):
  1428. action = 'signing wallet outputs file with'
  1429. start_daemon = False
  1430. offline = True
  1431. async def main(self,fn,wallet_idx):
  1432. await self.c.restart_daemon()
  1433. h = self.rpc(self,self.addr_data[0])
  1434. self.head_msg(wallet_idx,fn)
  1435. h.open_wallet()
  1436. m = MoneroWalletOutputsFile.Unsigned(
  1437. parent = self,
  1438. fn = fn )
  1439. res = self.c.call(
  1440. 'import_outputs',
  1441. outputs_data_hex = m.data.outputs_data_hex )
  1442. idata = res['num_imported']
  1443. bmsg('\n {} output{} imported'.format( idata, suf(idata) ))
  1444. data = m.data._asdict()
  1445. data.update(self.c.call('export_key_images')) # for testing: all = True
  1446. m = MoneroWalletOutputsFile.SignedNew(
  1447. parent = self,
  1448. wallet_fn = m.get_wallet_fn(fn),
  1449. data = data )
  1450. idata = m.data.signed_key_images or []
  1451. bmsg(' {} key image{} signed'.format( len(idata), suf(idata) ))
  1452. await self.stop_wallet_daemon()
  1453. return m
  1454. class import_key_images(wallet):
  1455. action = 'importing key images into'
  1456. stem = 'process'
  1457. trust_daemon = True
  1458. async def process_wallet(self,d,fn,last):
  1459. h = self.rpc(self,d)
  1460. h.open_wallet()
  1461. self.head_msg(d.idx,h.fn)
  1462. m = MoneroWalletOutputsFile.Signed(
  1463. parent = self,
  1464. fn = MoneroWalletOutputsFile.Signed.find_fn_from_wallet_fn( self.cfg, fn ),
  1465. )
  1466. data = m.data.signed_key_images or []
  1467. bmsg('\n {} signed key image{} to import'.format( len(data), suf(data) ))
  1468. if data:
  1469. res = self.c.call( 'import_key_images', signed_key_images=data )
  1470. bmsg(f' Success: {res}')
  1471. return True
  1472. class relay(base):
  1473. opts = ('tx_relay_daemon',)
  1474. def __init__(self,cfg,uarg_tuple):
  1475. check_uopts = MoneroWalletOps.submit.check_uopts
  1476. super().__init__(cfg,uarg_tuple)
  1477. self.tx = MoneroMMGenTX.Signed( self.cfg, Path(uarg.infile) )
  1478. if self.cfg.tx_relay_daemon:
  1479. m = self.parse_tx_relay_opt()
  1480. host,port = m[1].split(':')
  1481. proxy = m[2]
  1482. md = None
  1483. else:
  1484. from .daemon import CoinDaemon
  1485. md = CoinDaemon( self.cfg, 'xmr', test_suite=self.cfg.test_suite )
  1486. host,port = md.host,md.rpc_port
  1487. proxy = None
  1488. self.dc = MoneroRPCClient(
  1489. cfg = self.cfg,
  1490. proto = self.proto,
  1491. daemon = md,
  1492. host = host,
  1493. port = int(port),
  1494. user = None,
  1495. passwd = None,
  1496. test_connection = False, # relay is presumably a public node, so avoid extra connections
  1497. proxy = proxy )
  1498. async def main(self):
  1499. msg('\n' + self.tx.get_info())
  1500. if self.cfg.tx_relay_daemon:
  1501. self.display_tx_relay_info()
  1502. if keypress_confirm( self.cfg, 'Relay transaction?' ):
  1503. res = self.dc.call_raw(
  1504. 'send_raw_transaction',
  1505. tx_as_hex = self.tx.data.blob
  1506. )
  1507. if res['status'] == 'OK':
  1508. msg('Status: ' + green('OK'))
  1509. if res['not_relayed']:
  1510. ymsg('Transaction not relayed')
  1511. return True
  1512. else:
  1513. die( 'RPCFailure', repr(res) )
  1514. else:
  1515. die(1,'Exiting at user request')
  1516. class txview(base):
  1517. async def main(self):
  1518. self.cfg._util.stdout_or_pager(
  1519. '\n'.join(
  1520. tx.get_info() for tx in
  1521. sorted(
  1522. (MoneroMMGenTX.Completed( self.cfg, Path(fn) ) for fn in uarg.infile),
  1523. key = lambda x: x.data.sign_time or x.data.create_time )
  1524. ))