ts_xmrwallet.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  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. )
  54. def __init__(self,trunner,cfgs,spawn):
  55. TestSuiteBase.__init__(self,trunner,cfgs,spawn)
  56. if trunner == None:
  57. return
  58. from mmgen.protocol import init_proto
  59. self.proto = init_proto('XMR',network='testnet')
  60. self.datadir_base = os.path.join('test','daemons','xmrtest')
  61. self.tool_args = ['--testnet=1', '--monero-wallet-rpc-password=passw0rd']
  62. self.init_users()
  63. self.init_daemon_args()
  64. for v in self.users.values():
  65. run(['mkdir','-p',v.udir])
  66. if not opt.no_daemon_autostart:
  67. self.start_daemons()
  68. self.start_wallet_daemons()
  69. if not opt.no_daemon_stop:
  70. atexit.register(self.stop_daemons)
  71. atexit.register(self.stop_wallet_daemons)
  72. self.init_proxy()
  73. self.balance = None
  74. # init methods
  75. def init_proxy(self):
  76. def kill_proxy():
  77. omsg(f'Killing SSH SOCKS server at localhost:{self.socks_port}')
  78. cmd = [ 'pkill', '-f', ' '.join(a + b2) ]
  79. run(cmd)
  80. self.use_proxy = False
  81. self.socks_port = 9060
  82. a = ['ssh','-x','-o','ExitOnForwardFailure=True','-D',f'localhost:{self.socks_port}']
  83. b1 = ['localhost','true']
  84. b2 = ['-fN','-E','txrelay-proxy.debug','localhost']
  85. cp = run(a+b1,stdout=PIPE,stderr=PIPE)
  86. if cp.returncode == 0:
  87. if not opt.no_daemon_autostart:
  88. run(a+b2)
  89. omsg(f'SSH SOCKS server started, listening at localhost:{self.socks_port}')
  90. self.use_proxy = True
  91. elif b'already in use' in cp.stderr:
  92. omsg(f'Port {self.socks_port} already in use. Assuming SSH SOCKS server is running')
  93. self.use_proxy = True
  94. else:
  95. m1 = 'Unable to start command {!r}\n'.format(' '.join(a + b))
  96. m2 = 'Will not test proxied TX relay daemon'
  97. omsg(cp.stderr.decode())
  98. omsg(yellow(m1+m2))
  99. if not opt.no_daemon_stop:
  100. atexit.register(kill_proxy)
  101. def init_users(self):
  102. from mmgen.daemon import CoinDaemon,MoneroWalletDaemon
  103. from mmgen.rpc import MoneroRPCClient,MoneroRPCClientRaw,MoneroWalletRPCClient
  104. self.users = {}
  105. n = self.tmpdir_nums[0]
  106. ud = namedtuple('user_data',[
  107. 'sid',
  108. 'mmwords',
  109. 'udir',
  110. 'datadir',
  111. 'kal_range',
  112. 'kafile',
  113. 'walletfile_fs',
  114. 'addrfile_fs',
  115. 'md',
  116. 'md_rpc',
  117. 'md_json_rpc',
  118. 'wd',
  119. 'wd_rpc',
  120. ])
  121. for user,sid,shift,kal_range in ( # kal_range must be None, a single digit, or a single hyphenated range
  122. ('miner', '98831F3A', 130, '1'),
  123. ('bob', '1378FC64', 140, None),
  124. ('alice', 'FE3C6545', 150, '1-4'),
  125. ):
  126. udir = os.path.join('test',f'tmp{n}',user)
  127. datadir = os.path.join(self.datadir_base,user)
  128. md = CoinDaemon(
  129. proto = self.proto,
  130. test_suite = True,
  131. port_shift = shift,
  132. opts = ['online'],
  133. datadir = datadir
  134. )
  135. md_rpc = MoneroRPCClientRaw(
  136. host = md.host,
  137. port = md.rpc_port,
  138. user = None,
  139. passwd = None,
  140. test_connection = False,
  141. )
  142. md_json_rpc = MoneroRPCClient(
  143. host = md.host,
  144. port = md.rpc_port,
  145. user = None,
  146. passwd = None,
  147. test_connection = False,
  148. )
  149. wd = MoneroWalletDaemon(
  150. user = 'foo',
  151. passwd = 'bar',
  152. wallet_dir = udir,
  153. test_suite = True,
  154. port_shift = shift,
  155. datadir = os.path.join('test','daemons'),
  156. daemon_addr = f'127.0.0.1:{md.rpc_port}',
  157. testnet = True
  158. )
  159. wd_rpc = MoneroWalletRPCClient(
  160. host = wd.host,
  161. port = wd.rpc_port,
  162. user = wd.user,
  163. passwd = wd.passwd,
  164. test_connection = False,
  165. )
  166. self.users[user] = ud(
  167. sid = sid,
  168. mmwords = f'test/ref/{sid}.mmwords',
  169. udir = udir,
  170. datadir = datadir,
  171. kal_range = kal_range,
  172. kafile = f'{udir}/{sid}-XMR-M[{kal_range}].testnet.akeys',
  173. walletfile_fs = f'{udir}/{sid}-{{}}-MoneroWallet.testnet',
  174. addrfile_fs = f'{udir}/{sid}-{{}}-MoneroWallet.testnet.address.txt',
  175. md = md,
  176. md_rpc = md_rpc,
  177. md_json_rpc = md_json_rpc,
  178. wd = wd,
  179. wd_rpc = wd_rpc,
  180. )
  181. def init_daemon_args(self):
  182. common_args = ['--p2p-bind-ip=127.0.0.1','--fixed-difficulty=1'] # ,'--rpc-ssl-allow-any-cert']
  183. for u in self.users:
  184. other_ports = [self.users[u2].md.p2p_port for u2 in self.users if u2 != u]
  185. node_args = [f'--add-exclusive-node=127.0.0.1:{p}' for p in other_ports]
  186. self.users[u].md.usr_coind_args = common_args + node_args
  187. # cmd_group methods
  188. def gen_kafiles(self):
  189. for user,data in self.users.items():
  190. if not data.kal_range:
  191. continue
  192. run(['mkdir','-p',data.udir])
  193. run(f'rm -f {data.kafile}',shell=True)
  194. t = self.spawn(
  195. 'mmgen-keygen', [
  196. '--testnet=1','-q', '--accept-defaults', '--coin=xmr',
  197. f'--outdir={data.udir}', data.mmwords, data.kal_range
  198. ],
  199. extra_desc = f'({capfirst(user)})' )
  200. t.read()
  201. t.ok()
  202. t.skip_ok = True
  203. return t
  204. def create_wallets(self):
  205. for user,data in self.users.items():
  206. if not data.kal_range:
  207. continue
  208. run('rm -f {}*'.format( data.walletfile_fs.format('*') ),shell=True)
  209. dir_arg = [f'--outdir='+data.udir]
  210. cmd_opts = ['wallets={}'.format(data.kal_range)]
  211. t = self.spawn(
  212. 'mmgen-tool',
  213. self.tool_args + dir_arg + [ 'xmrwallet', 'create', data.kafile ] + cmd_opts,
  214. extra_desc = f'({capfirst(user)})' )
  215. t.expect('Check key-to-address validity? (y/N): ','n')
  216. for i in MMGenRange(data.kal_range).items:
  217. t.expect('Address: ')
  218. t.read()
  219. t.ok()
  220. t.skip_ok = True
  221. return t
  222. async def set_dest_miner(self):
  223. self.do_msg()
  224. self.set_dest('miner',1,0,lambda x: x > 20,'unlocked balance > 20')
  225. await self.open_wallet_user('miner',1)
  226. return 'ok'
  227. async def fund_alice(self):
  228. self.do_msg()
  229. await self.transfer(
  230. 'miner',
  231. 1234567891234,
  232. read_from_file(self.users['alice'].addrfile_fs.format(1)),
  233. )
  234. self.set_dest('alice',1,0,lambda x: x > 1,'unlocked balance > 1')
  235. return 'ok'
  236. def sync_wallets_selected(self):
  237. return self.sync_wallets(wallets='1,3-4')
  238. def sync_wallets(self,wallets=None):
  239. data = self.users['alice']
  240. dir_arg = [f'--outdir={data.udir}']
  241. cmd_opts = list_gen(
  242. [f'daemon=localhost:{data.md.rpc_port}'],
  243. [f'wallets={wallets}', wallets],
  244. )
  245. t = self.spawn(
  246. 'mmgen-tool',
  247. self.tool_args + dir_arg + [ 'xmrwallet', 'sync', data.kafile ] + cmd_opts )
  248. t.expect('Check key-to-address validity? (y/N): ','n')
  249. wlist = AddrIdxList(wallets) if wallets else MMGenRange(data.kal_range).items
  250. for n,wnum in enumerate(wlist):
  251. t.expect('Syncing wallet {}/{} ({})'.format(
  252. n+1,
  253. len(wlist),
  254. os.path.basename(data.walletfile_fs.format(wnum)),
  255. ))
  256. t.expect('Chain height: ')
  257. t.expect('Wallet height: ')
  258. t.expect('Balance: ')
  259. t.read()
  260. return t
  261. def _sweep_user(self,user,spec,tx_relay_daemon=None):
  262. data = self.users[user]
  263. dir_arg = [f'--outdir='+data.udir]
  264. cmd_opts = list_gen(
  265. [f'daemon=localhost:{data.md.rpc_port}'],
  266. [f'wallets={spec}'],
  267. [f'tx_relay_daemon={tx_relay_daemon}', tx_relay_daemon]
  268. )
  269. t = self.spawn(
  270. 'mmgen-tool',
  271. self.tool_args + dir_arg + [ 'xmrwallet', 'sweep', data.kafile ] + cmd_opts,
  272. extra_desc = f'({capfirst(user)})' )
  273. t.expect('Check key-to-address validity? (y/N): ','n')
  274. t.expect(
  275. 'Create new {} .* \(y/N\): '.format('account' if ',' in spec else 'address'),
  276. 'y', regex=True )
  277. t.expect('Relay sweep transaction? (y/N): ','y')
  278. t.read()
  279. return t
  280. def sweep_to_address_proxy(self):
  281. ret = self._sweep_user(
  282. 'alice',
  283. '1:0',
  284. tx_relay_daemon = 'localhost:{}:127.0.0.1:{}'.format( # proxy must be IP, not 'localhost'
  285. self.users['bob'].md.rpc_port,
  286. self.socks_port
  287. ) if self.use_proxy else None
  288. )
  289. self.set_dest('alice',1,0,lambda x: x > 1,'unlocked balance > 1')
  290. return ret
  291. def sweep_to_account(self):
  292. ret = self._sweep_user('alice','1:0,2')
  293. self.set_dest('alice',2,1,lambda x: x > 1,'unlocked balance > 1')
  294. return ret
  295. def sweep_to_address_noproxy(self):
  296. ret = self._sweep_user(
  297. 'alice',
  298. '2:1',
  299. tx_relay_daemon = 'localhost:{}'.format(self.users['bob'].md.rpc_port)
  300. )
  301. self.set_dest('alice',2,1,lambda x: x > 1,'unlocked balance > 1')
  302. return ret
  303. # wallet methods
  304. async def open_wallet_user(self,user,wnum):
  305. data = self.users[user]
  306. silence()
  307. kal = KeyAddrList(self.proto,data.kafile,skip_key_address_validity_check=True)
  308. end_silence()
  309. return await data.wd_rpc.call(
  310. 'open_wallet',
  311. filename = os.path.basename(data.walletfile_fs.format(wnum)),
  312. password = kal.entry(wnum).wallet_passwd )
  313. async def close_wallet_user(self,user):
  314. ret = await self.users[user].wd_rpc.call('close_wallet')
  315. return 'ok'
  316. # mining methods
  317. async def start_mining(self):
  318. data = self.users['miner']
  319. addr = read_from_file(data.addrfile_fs.format(1)) # mine to wallet #1, account 0
  320. for i in range(20):
  321. ret = await data.md_rpc.call(
  322. 'start_mining',
  323. do_background_mining = False, # run mining in background or foreground
  324. ignore_battery = True, # ignore battery state (on laptop)
  325. miner_address = addr, # account address to mine to
  326. threads_count = 3 ) # number of mining threads to run
  327. status = self.get_status(ret)
  328. if status == 'OK':
  329. return True
  330. elif status == 'BUSY':
  331. await asyncio.sleep(5)
  332. omsg('Daemon busy. Attempting to start mining...')
  333. else:
  334. die(2,f'Monerod returned status {status}')
  335. else:
  336. die(2,'Max retries exceeded')
  337. async def stop_mining(self):
  338. ret = await self.users['miner'].md_rpc.call('stop_mining')
  339. return self.get_status(ret)
  340. async def mine_blocks(self,random_txs=None):
  341. """
  342. - open destination wallet
  343. - optionally create and broadcast random TXs
  344. - start mining
  345. - mine until funds appear in wallet
  346. - stop mining
  347. - close wallet
  348. """
  349. async def get_height():
  350. u = self.users['miner']
  351. for i in range(20):
  352. try:
  353. return (await u.md_json_rpc.call('get_last_block_header'))['block_header']['height']
  354. except Exception as e:
  355. if 'onnection refused' in str(e):
  356. omsg(f'{e}\nMonerod appears to have crashed. Attempting to restart...')
  357. await asyncio.sleep(5)
  358. u.md.restart()
  359. await asyncio.sleep(5)
  360. await self.start_mining()
  361. else:
  362. raise
  363. else:
  364. die(2,'Restart attempt limit exceeded')
  365. async def send_random_txs():
  366. from mmgen.tool import tool_api
  367. t = tool_api()
  368. t.init_coin('XMR','testnet')
  369. t.usr_randchars = 0
  370. imsg_r(f'Sending random transactions: ')
  371. for i in range(random_txs):
  372. await self.transfer(
  373. 'miner',
  374. 123456789,
  375. t.randpair()[1],
  376. )
  377. imsg_r(f'{i+1} ')
  378. oqmsg_r('+')
  379. await asyncio.sleep(0.5)
  380. imsg('')
  381. def print_balance(dest,ub):
  382. imsg('Total balance in {}’s wallet #{}, account {}: {}'.format(
  383. capfirst(dest.user),
  384. dest.wnum,
  385. dest.account,
  386. ub.hl()
  387. ))
  388. async def get_balance(dest):
  389. data = self.users[dest.user]
  390. await data.wd_rpc.call('refresh')
  391. ret = await data.wd_rpc.call('get_accounts')
  392. return XMRAmt(ret['subaddress_accounts'][dest.account]['unlocked_balance'],from_unit='atomic')
  393. self.do_msg(extra_desc=f'+{random_txs} random TXs' if random_txs else None)
  394. if self.dest.user != 'miner':
  395. await self.open_wallet_user(self.dest.user,self.dest.wnum)
  396. if random_txs:
  397. await send_random_txs()
  398. await self.start_mining()
  399. h = await get_height()
  400. imsg_r(f'Chain height: {h} ')
  401. while True:
  402. ub = await get_balance(self.dest)
  403. if self.dest.test(ub):
  404. imsg('')
  405. oqmsg_r('+')
  406. print_balance(self.dest,ub)
  407. break
  408. # else:
  409. # imsg(f'Test {self.dest.test_desc!r} failed')
  410. await asyncio.sleep(2)
  411. h = await get_height()
  412. imsg_r(f'{h} ')
  413. oqmsg_r('+')
  414. await self.stop_mining()
  415. if self.dest.user != 'miner':
  416. await self.close_wallet_user(self.dest.user)
  417. return 'ok'
  418. async def mine_blocks_tx(self):
  419. return await self.mine_blocks(random_txs=self.dfl_random_txs)
  420. # util methods
  421. def get_status(self,ret):
  422. if ret['status'] != 'OK':
  423. imsg( 'RPC status: {}'.format(ret['status']) )
  424. return ret['status']
  425. def do_msg(self,extra_desc=None):
  426. self.spawn(
  427. '',
  428. msg_only = True,
  429. extra_desc = f'({extra_desc})' if extra_desc else None
  430. )
  431. def set_dest(self,user,wnum,account,test,test_desc):
  432. self.dest = namedtuple(
  433. 'dest_info',['user','wnum','account','test','test_desc'])(user,wnum,account,test,test_desc)
  434. async def transfer(self,user,amt,addr):
  435. return await self.users[user].wd_rpc.call('transfer',destinations=[{'amount':amt,'address':addr}])
  436. # daemon start/stop methods
  437. def start_daemons(self):
  438. self.stop_daemons()
  439. for v in self.users.values():
  440. run(['mkdir','-p',v.datadir])
  441. v.md.start()
  442. def stop_daemons(self):
  443. for v in self.users.values():
  444. if v.md.state != 'stopped':
  445. v.md.stop()
  446. run(['rm','-rf',self.datadir_base])
  447. def start_wallet_daemons(self):
  448. for v in self.users.values():
  449. if v.kal_range:
  450. v.wd.start()
  451. def stop_wallet_daemons(self):
  452. for v in self.users.values():
  453. if v.kal_range and v.wd.state != 'stopped':
  454. v.wd.stop()