xmrwallet.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2021 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
  22. from collections import namedtuple
  23. from .common import *
  24. from .addr import KeyAddrList,AddrIdxList
  25. from .rpc import MoneroRPCClientRaw, MoneroWalletRPCClient
  26. from .daemon import MoneroWalletDaemon
  27. xmrwallet_uarg_info = (
  28. lambda e,hp: {
  29. 'daemon': e('HOST:PORT', hp),
  30. 'tx_relay_daemon': e('HOST:PORT[:PROXY_HOST:PROXY_PORT]', r'({p})(?::({p}))?'.format(p=hp)),
  31. 'wallets_sweep': e('SOURCE_WALLET_NUM:ACCOUNT[,DEST_WALLET_NUM]', r'(\d+):(\d+)(?:,(\d+))?'),
  32. })(
  33. namedtuple('uarg_info_entry',['annot','pat']),
  34. r'(?:[^:]+):(?:\d+)'
  35. )
  36. class MoneroWalletOps:
  37. ops = ('create','sync','sweep')
  38. class base:
  39. class rpc:
  40. def __init__(self,parent,d):
  41. self.parent = parent
  42. self.c = parent.c
  43. self.d = d
  44. self.fn = parent.get_wallet_fn(d)
  45. async def open_wallet(self,desc):
  46. gmsg_r(f'\n Opening {desc} wallet...')
  47. ret = await self.c.call( # returns {}
  48. 'open_wallet',
  49. filename=os.path.basename(self.fn),
  50. password=self.d.wallet_passwd )
  51. gmsg('done')
  52. async def close_wallet(self,desc):
  53. gmsg_r(f'\n Closing {desc} wallet...')
  54. await self.c.call('close_wallet')
  55. gmsg_r('done')
  56. def print_accts(self,data,addrs_data,indent=' '):
  57. d = data['subaddress_accounts']
  58. msg('\n' + indent + f'Accounts of wallet {os.path.basename(self.fn)}:')
  59. fs = indent + ' {:6} {:18} {:<6} {:%s} {}' % max(len(e['label']) for e in d)
  60. msg(fs.format('Index ','Base Address','nAddrs','Label','Balance'))
  61. for i,e in enumerate(d):
  62. msg(fs.format(
  63. str(e['account_index']),
  64. e['base_address'][:15] + '...',
  65. len(addrs_data[i]['addresses']),
  66. e['label'],
  67. fmt_amt(e['balance']),
  68. ))
  69. async def get_accts(self,print=True):
  70. data = await self.c.call('get_accounts')
  71. addrs_data = [
  72. await self.c.call('get_address',account_index=i)
  73. for i in range(len(data['subaddress_accounts']))
  74. ]
  75. if print:
  76. self.print_accts(data,addrs_data)
  77. return ( data, addrs_data )
  78. async def create_acct(self):
  79. msg('\n Creating new account...')
  80. ret = await self.c.call(
  81. 'create_account',
  82. label = f'Sweep from {self.parent.source.idx}:{self.parent.account}'
  83. )
  84. msg(' Index: {}'.format( pink(str(ret['account_index'])) ))
  85. msg(' Address: {}'.format( cyan(ret['address']) ))
  86. return ret['address']
  87. def get_last_acct(self,accts_data):
  88. msg('\n Getting last account...')
  89. data = accts_data['subaddress_accounts'][-1]
  90. msg(' Index: {}'.format( pink(str(data['account_index'])) ))
  91. msg(' Address: {}'.format( cyan(data['base_address']) ))
  92. return data['base_address']
  93. async def get_addrs(self,accts_data,account):
  94. ret = await self.c.call('get_address',account_index=account)
  95. d = ret['addresses']
  96. msg('\n Addresses of account #{} ({}):'.format(
  97. account,
  98. accts_data['subaddress_accounts'][account]['label']))
  99. fs = ' {:6} {:18} {:%s} {}' % max(len(e['label']) for e in d)
  100. msg(fs.format('Index ','Address','Label','Used'))
  101. for e in d:
  102. msg(fs.format(
  103. str(e['address_index']),
  104. e['address'][:15] + '...',
  105. e['label'],
  106. e['used']
  107. ))
  108. return ret
  109. async def create_new_addr(self,account):
  110. msg_r('\n Creating new address: ')
  111. ret = await self.c.call(
  112. 'create_address',
  113. account_index = account,
  114. label = 'Sweep from this account',
  115. )
  116. msg(cyan(ret['address']))
  117. return ret['address']
  118. async def get_last_addr(self,account):
  119. msg('\n Getting last address:')
  120. ret = (await self.c.call(
  121. 'get_address',
  122. account_index = account,
  123. ))['addresses'][-1]['address']
  124. msg(' ' + cyan(ret))
  125. return ret
  126. def display_tx_relay_info(self):
  127. msg('\n TX relay host: {}\n Proxy: {}'.format(
  128. blue(self.parent.wd2.daemon_addr),
  129. blue(self.parent.wd2.proxy)
  130. ))
  131. def display_sweep_tx(self,data):
  132. from .obj import CoinTxID
  133. msg(' TxID: {}\n Amount: {}\n Fee: {}'.format(
  134. CoinTxID(data['tx_hash_list'][0]).hl(),
  135. hl_amt(data['amount_list'][0]),
  136. hl_amt(data['fee_list'][0]),
  137. ))
  138. async def make_sweep_tx(self,account,addr):
  139. ret = await self.c.call(
  140. 'sweep_all',
  141. address = addr,
  142. account_index = account,
  143. do_not_relay = True,
  144. get_tx_metadata = True
  145. )
  146. self.display_sweep_tx(ret)
  147. return ret
  148. def display_txid(self,data):
  149. from .obj import CoinTxID
  150. msg('\n Relayed {}'.format( CoinTxID(data['tx_hash']).hl() ))
  151. async def relay_sweep_tx(self,tx_hex):
  152. ret = await self.c.call('relay_tx',hex=tx_hex)
  153. try:
  154. self.display_txid(ret)
  155. except:
  156. msg('\n'+str(ret))
  157. wallet_exists = True
  158. tx_relay = False
  159. def check_uargs(self):
  160. def check_host_arg(name):
  161. val = getattr(uarg,name)
  162. if not re.fullmatch(uarg_info[name].pat,val,re.ASCII):
  163. die(1,'{!r}: invalid {!r} parameter: it must have format {!r}'.format(
  164. val, name, uarg_info[name].annot ))
  165. if uarg.op != 'create' and uarg.restore_height != 0:
  166. die(1,"'restore_height' arg is supported only for create operation")
  167. if uarg.restore_height < 0:
  168. die(1,f"{uarg.restore_height}: invalid 'restore_height' arg (<0)")
  169. if uarg.daemon:
  170. check_host_arg('daemon')
  171. if uarg.tx_relay_daemon:
  172. if not self.tx_relay:
  173. die(1,f"'tx_relay_daemon' arg is not recognized for operation {uarg.op!r}")
  174. check_host_arg('tx_relay_daemon')
  175. def __init__(self,uarg_tuple):
  176. def wallet_exists(fn):
  177. try: os.stat(fn)
  178. except: return False
  179. else: return True
  180. def check_wallets():
  181. for d in self.addr_data:
  182. fn = self.get_wallet_fn(d)
  183. exists = wallet_exists(fn)
  184. if exists and not self.wallet_exists:
  185. die(1,f'Wallet {fn!r} already exists!')
  186. elif not exists and self.wallet_exists:
  187. die(1,f'Wallet {fn!r} not found!')
  188. global uarg, uarg_info, fmt_amt, hl_amt
  189. uarg = uarg_tuple
  190. uarg_info = xmrwallet_uarg_info
  191. from .obj import XMRAmt
  192. def fmt_amt(amt):
  193. return XMRAmt(amt,from_unit='min_coin_unit').fmt(fs='5.12',color=True)
  194. def hl_amt(amt):
  195. return XMRAmt(amt,from_unit='min_coin_unit').hl()
  196. self.check_uargs()
  197. from .protocol import init_proto
  198. self.kal = KeyAddrList(init_proto('xmr',network='mainnet'),uarg.xmr_keyaddrfile)
  199. self.create_addr_data()
  200. check_wallets()
  201. self.wd = MoneroWalletDaemon(
  202. wallet_dir = opt.outdir or '.',
  203. test_suite = g.test_suite,
  204. daemon_addr = uarg.daemon or None,
  205. )
  206. if uarg.start_wallet_daemon:
  207. self.wd.restart()
  208. self.c = MoneroWalletRPCClient(
  209. host = self.wd.host,
  210. port = self.wd.rpc_port,
  211. user = self.wd.user,
  212. passwd = self.wd.passwd
  213. )
  214. self.post_init()
  215. def create_addr_data(self):
  216. if uarg.wallets:
  217. idxs = AddrIdxList(uarg.wallets)
  218. self.addr_data = [d for d in self.kal.data if d.idx in idxs]
  219. if len(self.addr_data) != len(idxs):
  220. die(1,f'List {uarg.wallets!r} contains addresses not present in supplied key-address file')
  221. else:
  222. self.addr_data = self.kal.data
  223. def stop_daemons(self):
  224. if uarg.stop_wallet_daemon:
  225. self.wd.stop()
  226. if uarg.tx_relay_daemon:
  227. self.wd2.stop()
  228. def post_init(self): pass
  229. def post_process(self): pass
  230. def get_wallet_fn(self,d):
  231. return os.path.join(
  232. opt.outdir or '.','{}-{}-MoneroWallet{}'.format(
  233. self.kal.al_id.sid,
  234. d.idx,
  235. '-α' if g.debug_utf8 else ''))
  236. async def process_wallets(self):
  237. gmsg('\n{}ing {} wallet{}'.format(self.desc,len(self.addr_data),suf(self.addr_data)))
  238. processed = 0
  239. for n,d in enumerate(self.addr_data): # [d.sec,d.addr,d.wallet_passwd,d.viewkey]
  240. fn = self.get_wallet_fn(d)
  241. gmsg('\n{}ing wallet {}/{} ({})'.format(
  242. self.desc,
  243. n+1,
  244. len(self.addr_data),
  245. os.path.basename(fn),
  246. ))
  247. processed += await self.run(d,fn)
  248. gmsg('\n{} wallet{} {}'.format(processed,suf(processed),self.past))
  249. return processed
  250. class create(base):
  251. name = 'create'
  252. desc = 'Creat'
  253. past = 'created'
  254. wallet_exists = False
  255. async def run(self,d,fn):
  256. from .baseconv import baseconv
  257. ret = await self.c.call(
  258. 'restore_deterministic_wallet',
  259. filename = os.path.basename(fn),
  260. password = d.wallet_passwd,
  261. seed = baseconv.fromhex(d.sec,'xmrseed',tostr=True),
  262. restore_height = uarg.restore_height,
  263. language = 'English' )
  264. pp_msg(ret) if opt.debug else msg(' Address: {}'.format(ret['address']))
  265. return True
  266. class sync(base):
  267. name = 'sync'
  268. desc = 'Sync'
  269. past = 'synced'
  270. async def run(self,d,fn):
  271. chain_height = (await self.dc.call('get_height'))['height']
  272. msg(f' Chain height: {chain_height}')
  273. import time
  274. t_start = time.time()
  275. msg_r(' Opening wallet...')
  276. await self.c.call(
  277. 'open_wallet',
  278. filename=os.path.basename(fn),
  279. password=d.wallet_passwd )
  280. msg('done')
  281. msg_r(' Getting wallet height (be patient, this could take a long time)...')
  282. wallet_height = (await self.c.call('get_height'))['height']
  283. msg_r('\r' + ' '*68 + '\r')
  284. msg(f' Wallet height: {wallet_height} ')
  285. behind = chain_height - wallet_height
  286. if behind > 1000:
  287. msg_r(f' Wallet is {behind} blocks behind chain tip. Please be patient. Syncing...')
  288. ret = await self.c.call('refresh')
  289. if behind > 1000:
  290. msg('done')
  291. if ret['received_money']:
  292. msg(' Wallet has received funds')
  293. t_elapsed = int(time.time() - t_start)
  294. bn = os.path.basename(fn)
  295. a,b = await self.rpc(self,d).get_accts(print=False)
  296. msg(' Balance: {} Unlocked balance: {}'.format(
  297. hl_amt(a['total_balance']),
  298. hl_amt(a['total_unlocked_balance']),
  299. ))
  300. self.accts_data[bn] = { 'accts': a, 'addrs': b }
  301. msg(' Wallet height: {}'.format( (await self.c.call('get_height'))['height'] ))
  302. msg(' Sync time: {:02}:{:02}'.format( t_elapsed//60, t_elapsed%60 ))
  303. await self.c.call('close_wallet')
  304. return True
  305. def post_init(self):
  306. host,port = uarg.daemon.split(':') if uarg.daemon else ('localhost',self.wd.daemon_port)
  307. self.dc = MoneroRPCClientRaw(host=host, port=int(port), user=None, passwd=None)
  308. self.accts_data = {}
  309. def post_process(self):
  310. d = self.accts_data
  311. for n,k in enumerate(d):
  312. ad = self.addr_data[n]
  313. self.rpc(self,ad).print_accts( d[k]['accts'], d[k]['addrs'], indent='')
  314. col1_w = max(map(len,d)) + 1
  315. fs = '{:%s} {} {}' % col1_w
  316. tbals = [0,0]
  317. msg('\n'+fs.format('Wallet','Balance ','Unlocked Balance'))
  318. for k in d:
  319. b = d[k]['accts']['total_balance']
  320. ub = d[k]['accts']['total_unlocked_balance']
  321. msg(fs.format( k + ':', fmt_amt(b), fmt_amt(ub) ))
  322. tbals[0] += b
  323. tbals[1] += ub
  324. msg(fs.format( '-'*col1_w, '-'*18, '-'*18 ))
  325. msg(fs.format( 'TOTAL:', fmt_amt(tbals[0]), fmt_amt(tbals[1]) ))
  326. class sweep(base):
  327. name = 'sweep'
  328. desc = 'Sweep'
  329. past = 'swept'
  330. tx_relay = True
  331. def create_addr_data(self):
  332. m = re.fullmatch(uarg_info['wallets_sweep'].pat,uarg.wallets,re.ASCII)
  333. if not m:
  334. fs = "{!r}: invalid 'wallets' arg: for sweep operation, it must have format {!r}"
  335. die(1,fs.format( uarg.wallets, uarg_info['wallets_sweep'].annot ))
  336. def gen():
  337. for i,k in ( (1,'source'), (3,'dest') ):
  338. if m[i] == None:
  339. setattr(self,k,None)
  340. else:
  341. idx = int(m[i])
  342. try:
  343. res = [d for d in self.kal.data if d.idx == idx][0]
  344. except:
  345. die(1,'Supplied key-address file does not contain address {}:{}'.format(
  346. self.kal.al_id.sid,
  347. idx ))
  348. else:
  349. setattr(self,k,res)
  350. yield res
  351. self.addr_data = list(gen())
  352. self.account = int(m[2])
  353. def post_init(self):
  354. if uarg.tx_relay_daemon:
  355. m = re.fullmatch(uarg_info['tx_relay_daemon'].pat,uarg.tx_relay_daemon,re.ASCII)
  356. self.wd2 = MoneroWalletDaemon(
  357. wallet_dir = opt.outdir or '.',
  358. test_suite = g.test_suite,
  359. daemon_addr = m[1],
  360. proxy = m[2],
  361. port_shift = 16,
  362. )
  363. if uarg.start_wallet_daemon:
  364. self.wd2.restart()
  365. self.c2 = MoneroWalletRPCClient(
  366. host = self.wd2.host,
  367. port = self.wd2.rpc_port,
  368. user = self.wd2.user,
  369. passwd = self.wd2.passwd
  370. )
  371. async def process_wallets(self):
  372. gmsg(f'\nSweeping account #{self.account} of wallet {self.source.idx}' + (
  373. ' to new address' if self.dest is None else
  374. f' to new account in wallet {self.dest.idx}' ))
  375. h = self.rpc(self,self.source)
  376. await h.open_wallet('source')
  377. accts_data = (await h.get_accts())[0]
  378. max_acct = len(accts_data['subaddress_accounts']) - 1
  379. if self.account > max_acct:
  380. die(1,f'{self.account}: requested account index out of bounds (>{max_acct})')
  381. await h.get_addrs(accts_data,self.account)
  382. if self.dest == None:
  383. if keypress_confirm(f'\nCreate new address for account #{self.account}?'):
  384. new_addr = await h.create_new_addr(self.account)
  385. elif keypress_confirm(f'Sweep to last existing address of account #{self.account}?'):
  386. new_addr = await h.get_last_addr(self.account)
  387. else:
  388. die(1,'Exiting at user request')
  389. await h.get_addrs(accts_data,self.account)
  390. else:
  391. await h.close_wallet('source')
  392. bn = os.path.basename(self.get_wallet_fn(self.dest))
  393. h2 = self.rpc(self,self.dest)
  394. await h2.open_wallet('destination')
  395. accts_data = (await h2.get_accts())[0]
  396. if keypress_confirm(f'\nCreate new account for wallet {bn!r}?'):
  397. new_addr = await h2.create_acct()
  398. await h2.get_accts()
  399. elif keypress_confirm(f'Sweep to last existing account of wallet {bn!r}?'):
  400. new_addr = h2.get_last_acct(accts_data)
  401. else:
  402. die(1,'Exiting at user request')
  403. await h2.close_wallet('destination')
  404. await h.open_wallet('source')
  405. msg('\n Creating sweep transaction: balance of wallet {}, account #{} => {}'.format(
  406. self.source.idx,
  407. self.account,
  408. cyan(new_addr),
  409. ))
  410. sweep_tx = await h.make_sweep_tx(self.account,new_addr)
  411. if keypress_confirm('Relay sweep transaction?'):
  412. w_desc = 'source'
  413. if uarg.tx_relay_daemon:
  414. await h.close_wallet('source')
  415. self.c = self.c2
  416. h = self.rpc(self,self.source)
  417. w_desc = 'TX relay source'
  418. await h.open_wallet(w_desc)
  419. h.display_tx_relay_info()
  420. msg_r(f' Relaying sweep transaction...')
  421. await h.relay_sweep_tx( sweep_tx['tx_metadata_list'][0] )
  422. await h.close_wallet(w_desc)
  423. gmsg('\n\nAll done')
  424. else:
  425. await h.close_wallet('source')
  426. die(1,'\nExiting at user request')
  427. return True