xmrwallet.py 17 KB

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