ts_xmrwallet.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  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. ts_xmrwallet.py: xmrwallet tests for the test.py test suite
  20. """
  21. import sys,os,atexit,asyncio
  22. from subprocess import run,PIPE
  23. from mmgen.globalvars import g
  24. from mmgen.opts import opt
  25. from mmgen.obj import MMGenRange,XMRAmt
  26. from mmgen.addr import KeyAddrList,AddrIdxList
  27. from ..include.common import *
  28. from .common import *
  29. from .ts_base import *
  30. class TestSuiteXMRWallet(TestSuiteBase):
  31. """
  32. Monero wallet operations
  33. """
  34. networks = ('xmr',)
  35. passthru_opts = ('coin',)
  36. tmpdir_nums = [29]
  37. dfl_random_txs = 3
  38. cmd_group = (
  39. ('gen_kafiles', 'generating key-address files'),
  40. ('create_wallets', 'creating Monero wallets'),
  41. ('set_dest_miner', 'opening miner wallet'),
  42. ('mine_blocks', 'mining blocks'),
  43. ('fund_alice', 'sending funds'),
  44. ('mine_blocks_tx', 'mining blocks'),
  45. ('sync_wallets', 'syncing all wallets'),
  46. ('sync_wallets_selected', 'syncing selected wallets'),
  47. ('sweep_to_address_proxy', 'sweeping to new address (via TX relay + proxy)'),
  48. ('mine_blocks', 'mining blocks'),
  49. ('sweep_to_account', 'sweeping to new account'),
  50. ('mine_blocks', 'mining blocks'),
  51. ('sweep_to_address_noproxy', 'sweeping to new address (via TX relay)'),
  52. ('mine_blocks', 'mining blocks'),
  53. ('transfer_to_miner_proxy', 'transferring funds to Miner (via TX relay + proxy)'),
  54. ('mine_blocks_extra', 'mining blocks'),
  55. ('sync_wallet_2', 'syncing Alice’s wallet #2'),
  56. ('transfer_to_miner_noproxy', 'transferring funds to Miner (via TX relay)'),
  57. ('mine_blocks', 'mining blocks'),
  58. )
  59. def __init__(self,trunner,cfgs,spawn):
  60. TestSuiteBase.__init__(self,trunner,cfgs,spawn)
  61. if trunner == None:
  62. return
  63. from mmgen.protocol import init_proto
  64. self.proto = init_proto('XMR',network='testnet')
  65. self.datadir_base = os.path.join('test','daemons','xmrtest')
  66. self.long_opts = ['--testnet=1', '--monero-wallet-rpc-password=passw0rd']
  67. self.init_users()
  68. self.init_daemon_args()
  69. for v in self.users.values():
  70. run(['mkdir','-p',v.udir])
  71. self.init_proxy()
  72. self.tx_relay_daemon_parm = 'localhost:{}'.format(self.users['bob'].md.rpc_port)
  73. self.tx_relay_daemon_proxy_parm = (
  74. self.tx_relay_daemon_parm + f':127.0.0.1:{self.socks_port}' # proxy must be IP, not 'localhost'
  75. if self.use_proxy else None )
  76. if not opt.no_daemon_autostart:
  77. self.start_daemons()
  78. self.start_wallet_daemons()
  79. if not opt.no_daemon_stop:
  80. atexit.register(self.stop_daemons)
  81. atexit.register(self.stop_wallet_daemons)
  82. self.balance = None
  83. # init methods
  84. def init_proxy(self):
  85. def port_in_use(port):
  86. import socket
  87. try: socket.create_connection(('localhost',port)).close()
  88. except: return False
  89. else: return True
  90. def start_proxy():
  91. if not opt.no_daemon_autostart:
  92. run(a+b2)
  93. omsg(f'SSH SOCKS server started, listening at localhost:{self.socks_port}')
  94. def kill_proxy():
  95. omsg(f'Killing SSH SOCKS server at localhost:{self.socks_port}')
  96. cmd = [ 'pkill', '-f', ' '.join(a + b2) ]
  97. run(cmd)
  98. self.use_proxy = False
  99. self.socks_port = 9060
  100. a = ['ssh','-x','-o','ExitOnForwardFailure=True','-D',f'localhost:{self.socks_port}']
  101. b0 = ['-o','PasswordAuthentication=False']
  102. b1 = ['localhost','true']
  103. b2 = ['-fN','-E','txrelay-proxy.debug','localhost']
  104. if port_in_use(self.socks_port):
  105. omsg(f'Port {self.socks_port} already in use. Assuming SSH SOCKS server is running')
  106. self.use_proxy = True
  107. else:
  108. cp = run(a+b0+b1,stdout=PIPE,stderr=PIPE)
  109. err = cp.stderr.decode()
  110. if err:
  111. omsg(err)
  112. if cp.returncode == 0:
  113. start_proxy()
  114. self.use_proxy = True
  115. elif 'onnection refused' in err:
  116. die(2,fmt("""
  117. The SSH daemon must be running and listening on localhost in order to test
  118. XMR TX relaying via SOCKS proxy. If sshd is not running, please start it.
  119. Otherwise, add the line 'ListenAddress 127.0.0.1' to your sshd_config, and
  120. then restart the daemon.
  121. """,indent=' '))
  122. elif 'ermission denied' in err:
  123. msg(fmt(f"""
  124. In order to test XMR TX relaying via SOCKS proxy, it’s desirable to enable
  125. SSH to localhost without a password, which is not currently supported by
  126. your configuration. Your possible courses of action:
  127. 1. Continue by answering 'y' at this prompt, and enter your system password
  128. at the following prompt;
  129. 2. Exit the test here, add your user SSH public key to your user
  130. 'authorized_keys' file, and restart the test; or
  131. 3. Exit the test here, start the SSH SOCKS proxy manually by entering the
  132. following command, and restart the test:
  133. {' '.join(a+b2)}
  134. """,indent=' ',strip_char='\t'))
  135. if keypress_confirm('Continue?'):
  136. start_proxy()
  137. self.use_proxy = True
  138. else:
  139. die(1,'Exiting at user request')
  140. else:
  141. die(2,fmt(f"""
  142. Please start the SSH SOCKS proxy by entering the following command:
  143. {' '.join(a+b2)}
  144. Then restart the test.
  145. """,indent=' '))
  146. if not opt.no_daemon_stop:
  147. atexit.register(kill_proxy)
  148. def init_users(self):
  149. from mmgen.daemon import CoinDaemon,MoneroWalletDaemon
  150. from mmgen.rpc import MoneroRPCClient,MoneroRPCClientRaw,MoneroWalletRPCClient
  151. self.users = {}
  152. n = self.tmpdir_nums[0]
  153. ud = namedtuple('user_data',[
  154. 'sid',
  155. 'mmwords',
  156. 'udir',
  157. 'datadir',
  158. 'kal_range',
  159. 'kafile',
  160. 'walletfile_fs',
  161. 'addrfile_fs',
  162. 'md',
  163. 'md_rpc',
  164. 'md_json_rpc',
  165. 'wd',
  166. 'wd_rpc',
  167. ])
  168. for user,sid,shift,kal_range in ( # kal_range must be None, a single digit, or a single hyphenated range
  169. ('miner', '98831F3A', 130, '1-2'),
  170. ('bob', '1378FC64', 140, None),
  171. ('alice', 'FE3C6545', 150, '1-4'),
  172. ):
  173. udir = os.path.join('test',f'tmp{n}',user)
  174. datadir = os.path.join(self.datadir_base,user)
  175. md = CoinDaemon(
  176. proto = self.proto,
  177. test_suite = True,
  178. port_shift = shift,
  179. opts = ['online'],
  180. datadir = datadir
  181. )
  182. md_rpc = MoneroRPCClientRaw(
  183. host = md.host,
  184. port = md.rpc_port,
  185. user = None,
  186. passwd = None,
  187. test_connection = False,
  188. )
  189. md_json_rpc = MoneroRPCClient(
  190. host = md.host,
  191. port = md.rpc_port,
  192. user = None,
  193. passwd = None,
  194. test_connection = False,
  195. )
  196. wd = MoneroWalletDaemon(
  197. user = 'foo',
  198. passwd = 'bar',
  199. wallet_dir = udir,
  200. test_suite = True,
  201. port_shift = shift,
  202. datadir = os.path.join('test','daemons'),
  203. daemon_addr = f'127.0.0.1:{md.rpc_port}',
  204. testnet = True
  205. )
  206. wd_rpc = MoneroWalletRPCClient(
  207. host = wd.host,
  208. port = wd.rpc_port,
  209. user = wd.user,
  210. passwd = wd.passwd,
  211. test_connection = False,
  212. )
  213. self.users[user] = ud(
  214. sid = sid,
  215. mmwords = f'test/ref/{sid}.mmwords',
  216. udir = udir,
  217. datadir = datadir,
  218. kal_range = kal_range,
  219. kafile = f'{udir}/{sid}-XMR-M[{kal_range}].testnet.akeys',
  220. walletfile_fs = f'{udir}/{sid}-{{}}-MoneroWallet.testnet',
  221. addrfile_fs = f'{udir}/{sid}-{{}}-MoneroWallet.testnet.address.txt',
  222. md = md,
  223. md_rpc = md_rpc,
  224. md_json_rpc = md_json_rpc,
  225. wd = wd,
  226. wd_rpc = wd_rpc,
  227. )
  228. def init_daemon_args(self):
  229. common_args = ['--p2p-bind-ip=127.0.0.1','--fixed-difficulty=1'] # ,'--rpc-ssl-allow-any-cert']
  230. for u in self.users:
  231. other_ports = [self.users[u2].md.p2p_port for u2 in self.users if u2 != u]
  232. node_args = [f'--add-exclusive-node=127.0.0.1:{p}' for p in other_ports]
  233. self.users[u].md.usr_coind_args = common_args + node_args
  234. # cmd_group methods
  235. def gen_kafiles(self):
  236. for user,data in self.users.items():
  237. if not data.kal_range:
  238. continue
  239. run(['mkdir','-p',data.udir])
  240. run(f'rm -f {data.kafile}',shell=True)
  241. t = self.spawn(
  242. 'mmgen-keygen', [
  243. '--testnet=1','-q', '--accept-defaults', '--coin=xmr',
  244. f'--outdir={data.udir}', data.mmwords, data.kal_range
  245. ],
  246. extra_desc = f'({capfirst(user)})' )
  247. t.read()
  248. t.ok()
  249. t.skip_ok = True
  250. return t
  251. def create_wallets(self):
  252. for user,data in self.users.items():
  253. if not data.kal_range:
  254. continue
  255. run('rm -f {}*'.format( data.walletfile_fs.format('*') ),shell=True)
  256. dir_opt = [f'--outdir={data.udir}']
  257. t = self.spawn(
  258. 'mmgen-xmrwallet',
  259. self.long_opts + dir_opt + [ 'create', data.kafile, data.kal_range ],
  260. extra_desc = f'({capfirst(user)})' )
  261. t.expect('Check key-to-address validity? (y/N): ','n')
  262. for i in MMGenRange(data.kal_range).items:
  263. t.expect('Address: ')
  264. t.read()
  265. t.ok()
  266. t.skip_ok = True
  267. return t
  268. async def set_dest_miner(self):
  269. self.do_msg()
  270. self.set_dest('miner',1,0,lambda x: x > 20,'unlocked balance > 20')
  271. await self.open_wallet_user('miner',1)
  272. return 'ok'
  273. async def fund_alice(self):
  274. self.do_msg()
  275. await self.transfer(
  276. 'miner',
  277. 1234567891234,
  278. read_from_file(self.users['alice'].addrfile_fs.format(1)),
  279. )
  280. self.set_dest('alice',1,0,lambda x: str(x) == '1.234567891234','unlocked balance == 1.234567891234')
  281. return 'ok'
  282. def sync_wallets_selected(self):
  283. return self.sync_wallets(wallets='1,3-4')
  284. def sync_wallet_2(self):
  285. return self.sync_wallets(wallets='2')
  286. def sync_wallets(self,wallets=None):
  287. data = self.users['alice']
  288. dir_opt = [f'--outdir={data.udir}']
  289. cmd_opts = [f'--daemon=localhost:{data.md.rpc_port}']
  290. t = self.spawn(
  291. 'mmgen-xmrwallet',
  292. self.long_opts + dir_opt + cmd_opts + [ 'sync', data.kafile ] + ([wallets] if wallets else []) )
  293. t.expect('Check key-to-address validity? (y/N): ','n')
  294. wlist = AddrIdxList(wallets) if wallets else MMGenRange(data.kal_range).items
  295. for n,wnum in enumerate(wlist):
  296. t.expect('Syncing wallet {}/{} ({})'.format(
  297. n+1,
  298. len(wlist),
  299. os.path.basename(data.walletfile_fs.format(wnum)),
  300. ))
  301. t.expect('Chain height: ')
  302. t.expect('Wallet height: ')
  303. t.expect('Balance: ')
  304. t.read()
  305. return t
  306. def do_op(self,op,user,spec,tx_relay_parm):
  307. data = self.users[user]
  308. dir_opt = [f'--outdir={data.udir}']
  309. cmd_opts = list_gen(
  310. [f'--daemon=localhost:{data.md.rpc_port}'],
  311. [f'--tx-relay-daemon={tx_relay_parm}', tx_relay_parm]
  312. )
  313. t = self.spawn(
  314. 'mmgen-xmrwallet',
  315. self.long_opts + dir_opt + cmd_opts + [ op, data.kafile, spec ],
  316. extra_desc = f'({capfirst(user)})' )
  317. t.expect('Check key-to-address validity? (y/N): ','n')
  318. if op == 'sweep':
  319. t.expect(
  320. 'Create new {} .* \(y/N\): '.format('account' if ',' in spec else 'address'),
  321. 'y', regex=True )
  322. t.expect(f'Relay {op} transaction? (y/N): ','y')
  323. t.read()
  324. return t
  325. def sweep_to_address_proxy(self):
  326. ret = self.do_op('sweep','alice','1:0',self.tx_relay_daemon_proxy_parm)
  327. self.set_dest('alice',1,0,lambda x: x > 1,'unlocked balance > 1')
  328. return ret
  329. def sweep_to_account(self):
  330. ret = self.do_op('sweep','alice','1:0,2',None)
  331. self.set_dest('alice',2,1,lambda x: x > 1,'unlocked balance > 1')
  332. return ret
  333. def sweep_to_address_noproxy(self):
  334. ret = self.do_op('sweep','alice','2:1',self.tx_relay_daemon_parm)
  335. self.set_dest('alice',2,1,lambda x: x > 1,'unlocked balance > 1')
  336. return ret
  337. def transfer_to_miner_proxy(self):
  338. addr = read_from_file(self.users['miner'].addrfile_fs.format(2))
  339. amt = '0.135'
  340. ret = self.do_op('transfer','alice',f'2:1:{addr},{amt}',self.tx_relay_daemon_proxy_parm)
  341. self.set_dest('miner',2,0,lambda x: str(x) == amt,f'unlocked balance == {amt}')
  342. return ret
  343. def transfer_to_miner_noproxy(self):
  344. addr = read_from_file(self.users['miner'].addrfile_fs.format(2))
  345. ret = self.do_op('transfer','alice',f'2:1:{addr},0.0995',self.tx_relay_daemon_parm)
  346. self.set_dest('miner',2,0,lambda x: str(x) == '0.2345','unlocked balance == 0.2345')
  347. return ret
  348. # wallet methods
  349. async def open_wallet_user(self,user,wnum):
  350. data = self.users[user]
  351. silence()
  352. kal = KeyAddrList(self.proto,data.kafile,skip_key_address_validity_check=True)
  353. end_silence()
  354. return await data.wd_rpc.call(
  355. 'open_wallet',
  356. filename = os.path.basename(data.walletfile_fs.format(wnum)),
  357. password = kal.entry(wnum).wallet_passwd )
  358. async def close_wallet_user(self,user):
  359. ret = await self.users[user].wd_rpc.call('close_wallet')
  360. return 'ok'
  361. # mining methods
  362. async def start_mining(self):
  363. data = self.users['miner']
  364. addr = read_from_file(data.addrfile_fs.format(1)) # mine to wallet #1, account 0
  365. for i in range(20):
  366. ret = await data.md_rpc.call(
  367. 'start_mining',
  368. do_background_mining = False, # run mining in background or foreground
  369. ignore_battery = True, # ignore battery state (on laptop)
  370. miner_address = addr, # account address to mine to
  371. threads_count = 3 ) # number of mining threads to run
  372. status = self.get_status(ret)
  373. if status == 'OK':
  374. return True
  375. elif status == 'BUSY':
  376. await asyncio.sleep(5)
  377. omsg('Daemon busy. Attempting to start mining...')
  378. else:
  379. die(2,f'Monerod returned status {status}')
  380. else:
  381. die(2,'Max retries exceeded')
  382. async def stop_mining(self):
  383. ret = await self.users['miner'].md_rpc.call('stop_mining')
  384. return self.get_status(ret)
  385. async def mine_blocks(self,random_txs=None,extra_blocks=None):
  386. """
  387. - open destination wallet
  388. - optionally create and broadcast random TXs
  389. - start mining
  390. - mine until funds appear in wallet
  391. - stop mining
  392. - close wallet
  393. """
  394. async def get_height():
  395. u = self.users['miner']
  396. for i in range(20):
  397. try:
  398. return (await u.md_json_rpc.call('get_last_block_header'))['block_header']['height']
  399. except Exception as e:
  400. if 'onnection refused' in str(e):
  401. omsg(f'{e}\nMonerod appears to have crashed. Attempting to restart...')
  402. await asyncio.sleep(5)
  403. u.md.restart()
  404. await asyncio.sleep(5)
  405. await self.start_mining()
  406. else:
  407. raise
  408. else:
  409. die(2,'Restart attempt limit exceeded')
  410. async def mine_extra_blocks():
  411. h_start = await get_height()
  412. imsg_r(f'[+{extra_blocks} blocks]: ')
  413. oqmsg_r('|')
  414. while True:
  415. await asyncio.sleep(2)
  416. h = await get_height()
  417. imsg_r(f'{h} ')
  418. oqmsg_r('+')
  419. if h - h_start > extra_blocks:
  420. break
  421. async def send_random_txs():
  422. from mmgen.tool import tool_api
  423. t = tool_api()
  424. t.init_coin('XMR','testnet')
  425. t.usr_randchars = 0
  426. imsg_r(f'Sending random transactions: ')
  427. for i in range(random_txs):
  428. await self.transfer(
  429. 'miner',
  430. 123456789,
  431. t.randpair()[1],
  432. )
  433. imsg_r(f'{i+1} ')
  434. oqmsg_r('+')
  435. await asyncio.sleep(0.5)
  436. imsg('')
  437. def print_balance(dest,ub):
  438. imsg('Total balance in {}’s wallet #{}, account {}: {}'.format(
  439. capfirst(dest.user),
  440. dest.wnum,
  441. dest.account,
  442. ub.hl()
  443. ))
  444. async def get_balance(dest):
  445. data = self.users[dest.user]
  446. await data.wd_rpc.call('refresh')
  447. ret = await data.wd_rpc.call('get_accounts')
  448. return XMRAmt(ret['subaddress_accounts'][dest.account]['unlocked_balance'],from_unit='atomic')
  449. self.do_msg(extra_desc=f'+{random_txs} random TXs' if random_txs else None)
  450. await self.open_wallet_user(self.dest.user,self.dest.wnum)
  451. if random_txs:
  452. await send_random_txs()
  453. await self.start_mining()
  454. h = await get_height()
  455. imsg_r(f'Chain height: {h} ')
  456. while True:
  457. ub = await get_balance(self.dest)
  458. if self.dest.test(ub):
  459. if extra_blocks:
  460. await mine_extra_blocks()
  461. imsg('')
  462. oqmsg_r('+')
  463. print_balance(self.dest,ub)
  464. break
  465. # else:
  466. # imsg(f'Test {self.dest.test_desc!r} failed')
  467. await asyncio.sleep(2)
  468. h = await get_height()
  469. imsg_r(f'{h} ')
  470. oqmsg_r('+')
  471. await self.stop_mining()
  472. if self.dest.user != 'miner':
  473. await self.close_wallet_user(self.dest.user)
  474. return 'ok'
  475. async def mine_blocks_tx(self):
  476. return await self.mine_blocks(random_txs=self.dfl_random_txs)
  477. async def mine_blocks_extra(self):
  478. return await self.mine_blocks(extra_blocks=100) # TODO: 100 is arbitrary value
  479. # util methods
  480. def get_status(self,ret):
  481. if ret['status'] != 'OK':
  482. imsg( 'RPC status: {}'.format(ret['status']) )
  483. return ret['status']
  484. def do_msg(self,extra_desc=None):
  485. self.spawn(
  486. '',
  487. msg_only = True,
  488. extra_desc = f'({extra_desc})' if extra_desc else None
  489. )
  490. def set_dest(self,user,wnum,account,test,test_desc):
  491. self.dest = namedtuple(
  492. 'dest_info',['user','wnum','account','test','test_desc'])(user,wnum,account,test,test_desc)
  493. async def transfer(self,user,amt,addr):
  494. return await self.users[user].wd_rpc.call('transfer',destinations=[{'amount':amt,'address':addr}])
  495. # daemon start/stop methods
  496. def start_daemons(self):
  497. self.stop_daemons()
  498. for v in self.users.values():
  499. run(['mkdir','-p',v.datadir])
  500. v.md.start()
  501. def stop_daemons(self):
  502. for v in self.users.values():
  503. if v.md.state != 'stopped':
  504. v.md.stop()
  505. run(['rm','-rf',self.datadir_base])
  506. def start_wallet_daemons(self):
  507. for v in self.users.values():
  508. if v.kal_range:
  509. v.wd.start()
  510. def stop_wallet_daemons(self):
  511. for v in self.users.values():
  512. if v.kal_range and v.wd.state != 'stopped':
  513. v.wd.stop()