diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index d2c9e0a..e99b431 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -34,6 +34,7 @@ from .interface.part import PARTInterface, PARTInterfaceAnon, PARTInterfaceBlind from . import __version__ from .rpc import escape_rpcauth from .rpc_xmr import make_xmr_rpc2_func +from .rpc_wow import make_wow_rpc2_func from .ui.util import getCoinName, known_chart_coins from .util import ( AutomationConstraint, @@ -180,6 +181,21 @@ def threadPollXMRChainState(swap_client, coin_type): swap_client.chainstate_delay_event.wait(random.randrange(20, 30)) # Random to stagger updates +def threadPollWOWChainState(swap_client, coin_type): + ci = swap_client.ci(coin_type) + cc = swap_client.coin_clients[coin_type] + while not swap_client.chainstate_delay_event.is_set(): + try: + new_height = ci.getChainHeight() + if new_height != cc['chain_height']: + swap_client.log.debug('New {} block at height: {}'.format(ci.ticker(), new_height)) + with swap_client.mxDB: + cc['chain_height'] = new_height + except Exception as e: + swap_client.log.warning('threadPollWOWChainState {}, error: {}'.format(ci.ticker(), str(e))) + swap_client.chainstate_delay_event.wait(random.randrange(20, 30)) # Random to stagger updates + + def threadPollChainState(swap_client, coin_type): ci = swap_client.ci(coin_type) cc = swap_client.coin_clients[coin_type] @@ -281,7 +297,7 @@ class BasicSwap(BaseApp): # TODO: Set dynamically self.balance_only_coins = (Coins.LTC_MWEB, ) - self.scriptless_coins = (Coins.XMR, Coins.PART_ANON, Coins.FIRO) + self.scriptless_coins = (Coins.XMR, Coins.WOW, Coins.PART_ANON, Coins.FIRO) self.adaptor_swap_only_coins = self.scriptless_coins + (Coins.PART_BLIND, ) self.coins_without_segwit = (Coins.PIVX, Coins.DASH, Coins.NMC) @@ -523,7 +539,7 @@ class BasicSwap(BaseApp): if self.coin_clients[coin]['connection_type'] == 'rpc': if coin == Coins.DCR: self.coin_clients[coin]['walletrpcport'] = chain_client_settings['walletrpcport'] - elif coin == Coins.XMR: + elif coin in (Coins.XMR, Coins.WOW): self.coin_clients[coin]['rpctimeout'] = chain_client_settings.get('rpctimeout', 60) self.coin_clients[coin]['walletrpctimeout'] = chain_client_settings.get('walletrpctimeout', 120) self.coin_clients[coin]['walletrpctimeoutlong'] = chain_client_settings.get('walletrpctimeoutlong', 600) @@ -561,9 +577,9 @@ class BasicSwap(BaseApp): if self.use_tor_proxy: have_cc_tor_opt = 'use_tor' in chain_client_settings if have_cc_tor_opt and chain_client_settings['use_tor'] is False: - self.log.warning('use_tor is true for system but false for XMR.') + self.log.warning('use_tor is true for system but false for ' + coin + '.') elif have_cc_tor_opt is False and is_private_ip_address(node_host): - self.log.warning(f'Not using proxy for XMR node at private ip address {node_host}.') + self.log.warning(f'Not using proxy for {coin} node at private ip address {node_host}.') else: proxy_host = self.tor_proxy_host proxy_port = self.tor_proxy_port @@ -585,7 +601,10 @@ class BasicSwap(BaseApp): if proxy_host: self.log.info(f'Connecting through proxy at {proxy_host}.') - return make_xmr_rpc2_func(rpcport, daemon_login, rpchost, proxy_host=proxy_host, proxy_port=proxy_port) + if coin == Coins.XMR: + return make_xmr_rpc2_func(rpcport, daemon_login, rpchost, proxy_host=proxy_host, proxy_port=proxy_port) + if coin == Coins.WOW: + return make_wow_rpc2_func(rpcport, daemon_login, rpchost, proxy_host=proxy_host, proxy_port=proxy_port) daemon_login = None if coin_settings.get('rpcuser', '') != '': @@ -687,6 +706,12 @@ class BasicSwap(BaseApp): chain_client_settings = self.getChainClientSettings(coin) xmr_i.setWalletFilename(chain_client_settings['walletfile']) return xmr_i + elif coin == Coins.WOW: + from .interface.wow import WOWInterface + wow_i = WOWInterface(self.coin_clients[coin], self.chain, self) + chain_client_settings = self.getChainClientSettings(coin) + wow_i.setWalletFilename(chain_client_settings['walletfile']) + return wow_i elif coin == Coins.PIVX: from .interface.pivx import PIVXInterface return PIVXInterface(self.coin_clients[coin], self.chain, self) @@ -711,7 +736,7 @@ class BasicSwap(BaseApp): def setCoinRunParams(self, coin): cc = self.coin_clients[coin] - if coin == Coins.XMR: + if coin in (Coins.XMR, Coins.WOW): return if cc['connection_type'] == 'rpc' and cc['rpcauth'] is None: chain_client_settings = self.getChainClientSettings(coin) @@ -782,8 +807,14 @@ class BasicSwap(BaseApp): core_version = ci.getDaemonVersion() self.log.info('%s Core version %d', ci.coin_name(), core_version) self.coin_clients[c]['core_version'] = core_version + # thread_func = threadPollXMRChainState if c in (Coins.XMR, Coins.WOW) else threadPollChainState + if c == Coins.XMR: + thread_func = threadPollXMRChainState + elif c == Coins.WOW: + thread_func = threadPollWOWChainState + else: + thread_func = threadPollChainState - thread_func = threadPollXMRChainState if c == Coins.XMR else threadPollChainState t = threading.Thread(target=thread_func, args=(self, c)) self.threads.append(t) t.start() @@ -808,6 +839,12 @@ class BasicSwap(BaseApp): except Exception as e: self.log.warning('Can\'t open XMR wallet, could be locked.') continue + elif c == Coins.WOW: + try: + ci.ensureWalletExists() + except Exception as e: + self.log.warning('Can\'t open WOW wallet, could be locked.') + continue elif c == Coins.LTC: ci_mweb = self.ci(Coins.LTC_MWEB) is_encrypted, _ = self.getLockedState() @@ -858,7 +895,7 @@ class BasicSwap(BaseApp): self.log.info('Scanned %d unread messages.', nm) def stopDaemon(self, coin) -> None: - if coin in (Coins.XMR, Coins.DCR): + if coin in (Coins.XMR, Coins.DCR, Coins.WOW): return num_tries = 10 authcookiepath = os.path.join(self.getChainDatadirPath(coin), '.cookie') @@ -897,7 +934,7 @@ class BasicSwap(BaseApp): if with_wallet: self.waitForDaemonRPC(coin_type, with_wallet=False) - if coin_type in (Coins.XMR,): + if coin_type in (Coins.XMR, Coins.WOW): return ci = self.ci(coin_type) # checkWallets can adjust the wallet name. @@ -932,7 +969,7 @@ class BasicSwap(BaseApp): raise ValueError('{} has an unexpected wallet seed and "restrict_unknown_seed_wallets" is enabled.'.format(ci.coin_name())) if self.coin_clients[c]['connection_type'] != 'rpc': continue - if c == Coins.XMR: + if c in (Coins.XMR, Coins.WOW): continue # TODO synced = round(ci.getBlockchainInfo()['verificationprogress'], 3) if synced < 1.0: @@ -1035,7 +1072,7 @@ class BasicSwap(BaseApp): db_key_coin_name = ci.coin_name().lower() self.log.info('Initialising {} wallet.'.format(ci.coin_name())) - if coin_type == Coins.XMR: + if coin_type in (Coins.XMR, Coins.WOW): key_view = self.getWalletKey(coin_type, 1, for_ed25519=True) key_spend = self.getWalletKey(coin_type, 2, for_ed25519=True) ci.initialiseWallet(key_view, key_spend) @@ -1951,7 +1988,7 @@ class BasicSwap(BaseApp): return self.ci(coin_type).get_fee_rate(conf_target) def estimateWithdrawFee(self, coin_type, fee_rate): - if coin_type == Coins.XMR: + if coin_type in (Coins.XMR, Coins.WOW): # Fee estimate must be manually initiated return None tx_vsize = self.ci(coin_type).getHTLCSpendTxVSize() @@ -1960,7 +1997,7 @@ class BasicSwap(BaseApp): def withdrawCoin(self, coin_type, value, addr_to, subfee: bool) -> str: ci = self.ci(coin_type) - if subfee and coin_type == Coins.XMR: + if subfee and coin_type in (Coins.XMR, Coins.WOW): self.log.info('withdrawCoin sweep all {} to {}'.format(ci.ticker(), addr_to)) else: self.log.info('withdrawCoin {} {} to {} {}'.format(value, ci.ticker(), addr_to, ' subfee' if subfee else '')) @@ -2012,7 +2049,7 @@ class BasicSwap(BaseApp): if c == Coins.PART: ci.setWalletSeedWarning(False) # All keys should be be derived from the Particl mnemonic return True # TODO - if c == Coins.XMR: + if c in (Coins.XMR, Coins.WOW): expect_address = self.getCachedMainWalletAddress(ci) if expect_address is None: self.log.warning('Can\'t find expected main wallet address for coin {}'.format(ci.coin_name())) @@ -2056,7 +2093,7 @@ class BasicSwap(BaseApp): # TODO: How to scan pruned blocks? if not self.checkWalletSeed(coin_type): - if coin_type == Coins.XMR: + if coin_type in (Coins.XMR, Coins.WOW): raise ValueError('TODO: How to reseed XMR wallet?') else: raise ValueError('Wallet seed doesn\'t match expected.') @@ -5865,7 +5902,7 @@ class BasicSwap(BaseApp): kbsl = self.getPathKey(coin_from, coin_to, bid.created_at, xmr_swap.contract_count, KeyTypes.KBSL, for_ed25519) vkbs = ci_to.sumKeys(kbsl, kbsf) - if coin_to == Coins.XMR: + if coin_to == (Coins.XMR, Coins.WOW): address_to = self.getCachedMainWalletAddress(ci_to, session) elif coin_to in (Coins.PART_BLIND, Coins.PART_ANON): address_to = self.getCachedStealthAddressForCoin(coin_to, session) @@ -5932,7 +5969,7 @@ class BasicSwap(BaseApp): vkbs = ci_to.sumKeys(kbsl, kbsf) try: - if offer.coin_to == Coins.XMR: + if offer.coin_to in (Coins.XMR, Coins.WOW): address_to = self.getCachedMainWalletAddress(ci_to, session) elif coin_to in (Coins.PART_BLIND, Coins.PART_ANON): address_to = self.getCachedStealthAddressForCoin(coin_to, session) @@ -6984,7 +7021,7 @@ class BasicSwap(BaseApp): rv['anon_pending'] = walletinfo['unconfirmed_anon'] + walletinfo['immature_anon_balance'] rv['blind_balance'] = walletinfo['blind_balance'] rv['blind_unconfirmed'] = walletinfo['unconfirmed_blind'] - elif coin == Coins.XMR: + elif coin in (Coins.XMR, Coins.WOW): rv['main_address'] = self.getCachedMainWalletAddress(ci) elif coin == Coins.NAV: rv['immature'] = walletinfo['immature_balance'] diff --git a/basicswap/chainparams.py b/basicswap/chainparams.py index 0d967e8..ba8bc58 100644 --- a/basicswap/chainparams.py +++ b/basicswap/chainparams.py @@ -10,6 +10,7 @@ from .util import ( ) XMR_COIN = 10 ** 12 +WOW_COIN = 10 ** 11 class Coins(IntEnum): @@ -21,13 +22,14 @@ class Coins(IntEnum): XMR = 6 PART_BLIND = 7 PART_ANON = 8 - # ZANO = 9 + WOW = 9 # NDAU = 10 PIVX = 11 DASH = 12 FIRO = 13 NAV = 14 LTC_MWEB = 15 + # ZANO = 16 was 9 chainparams = { @@ -247,6 +249,33 @@ chainparams = { 'address_prefix': 18, } }, + Coins.WOW: { + 'name': 'wownero', + 'ticker': 'WOW', + 'client': 'wow', + 'decimal_places': 11, + 'mainnet': { + 'rpcport': 34568, + 'walletrpcport': 34572, # todo + 'min_amount': 100000, + 'max_amount': 10000 * WOW_COIN, + 'address_prefix': 4146, + }, + 'testnet': { + 'rpcport': 44568, + 'walletrpcport': 44572, + 'min_amount': 100000, + 'max_amount': 10000 * WOW_COIN, + 'address_prefix': 4146, + }, + 'regtest': { + 'rpcport': 54568, + 'walletrpcport': 54572, + 'min_amount': 100000, + 'max_amount': 10000 * WOW_COIN, + 'address_prefix': 4146, + } + }, Coins.PIVX: { 'name': 'pivx', 'ticker': 'PIVX', diff --git a/basicswap/config.py b/basicswap/config.py index cad16b1..1fb3511 100644 --- a/basicswap/config.py +++ b/basicswap/config.py @@ -36,3 +36,7 @@ NAMECOIN_TX = os.getenv('NAMECOIN_TX', 'namecoin-tx' + bin_suffix) XMR_BINDIR = os.path.expanduser(os.getenv('XMR_BINDIR', os.path.join(DEFAULT_TEST_BINDIR, 'monero'))) XMRD = os.getenv('XMRD', 'monerod' + bin_suffix) XMR_WALLET_RPC = os.getenv('XMR_WALLET_RPC', 'monero-wallet-rpc' + bin_suffix) + +WOW_BINDIR = os.path.expanduser(os.getenv('WOW_BINDIR', os.path.join(DEFAULT_TEST_BINDIR, 'wownero'))) +WOWD = os.getenv('WOWD', 'wownerod' + bin_suffix) +WOW_WALLET_RPC = os.getenv('WOW_WALLET_RPC', 'wownero-wallet-rpc' + bin_suffix) diff --git a/basicswap/http_server.py b/basicswap/http_server.py index ba9c6d3..6a2d846 100644 --- a/basicswap/http_server.py +++ b/basicswap/http_server.py @@ -311,6 +311,22 @@ class HttpHandler(BaseHTTPRequestHandler): else: raise ValueError('Unknown RPC variant') result = json.dumps(rv, indent=4) + elif coin_type == Coins.WOW: + ci = swap_client.ci(coin_type) + arr = cmd.split(None, 1) + method = arr[0] + params = json.loads(arr[1]) if len(arr) > 1 else [] + if coin_id == -8: + rv = ci.rpc_wallet(method, params) + elif coin_id == -7: + rv = ci.rpc(method, params) + elif coin_id == -6: + if params == []: + params = None + rv = ci.rpc2(method, params) + else: + raise ValueError('Unknown WOW RPC variant') + result = json.dumps(rv, indent=4) else: if call_type == 'http': ci = swap_client.ci(coin_type) @@ -338,6 +354,7 @@ class HttpHandler(BaseHTTPRequestHandler): coin_available = listAvailableCoins(swap_client, with_variants=False) with_xmr: bool = any(c[0] == Coins.XMR for c in coin_available) + with_wow: bool = any(c[0] == Coins.WOW for c in coin_available) coins = [(str(c[0]) + ',0', c[1]) for c in coin_available if c[0] not in (Coins.XMR, )] if any(c[0] == Coins.DCR for c in coin_available): @@ -348,6 +365,10 @@ class HttpHandler(BaseHTTPRequestHandler): coins.append((str(int(Coins.XMR)) + ',0', 'Monero')) coins.append((str(int(Coins.XMR)) + ',1', 'Monero JSON')) coins.append((str(int(Coins.XMR)) + ',2', 'Monero Wallet')) + if with_wow: + coins.append((str(int(Coins.WOW)) + ',0', 'Wownero')) + coins.append((str(int(Coins.WOW)) + ',1', 'Wownero JSON')) + coins.append((str(int(Coins.WOW)) + ',2', 'Wownero Wallet')) return self.render_template(template, { 'messages': messages, diff --git a/basicswap/interface/wow.py b/basicswap/interface/wow.py new file mode 100644 index 0000000..9d66a45 --- /dev/null +++ b/basicswap/interface/wow.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2020-2024 tecnovert +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. + +import json +import logging + +import basicswap.contrib.ed25519_fast as edf +import basicswap.ed25519_fast_util as edu +import basicswap.util_xmr as wow_util +from coincurve.ed25519 import ( + ed25519_add, + ed25519_get_pubkey, + ed25519_scalar_add, +) +from coincurve.keys import PrivateKey +from coincurve.dleag import ( + dleag_prove, + dleag_verify, + dleag_proof_len, + verify_ed25519_point, +) + +from basicswap.interface import ( + Curves) +from basicswap.util import ( + i2b, b2i, b2h, + dumpj, + ensure, + make_int, + TemporaryError) +from basicswap.util.network import ( + is_private_ip_address) +from basicswap.rpc_wow import ( + make_wow_rpc_func, + make_wow_rpc2_func) +from basicswap.chainparams import WOW_COIN, CoinInterface, Coins + + +class WOWInterface(CoinInterface): + @staticmethod + def curve_type(): + return Curves.ed25519 + + @staticmethod + def coin_type(): + return Coins.WOW + + @staticmethod + def COIN(): + return WOW_COIN + + @staticmethod + def exp() -> int: + return 11 + + @staticmethod + def nbk() -> int: + return 32 + + @staticmethod + def nbK() -> int: # No. of bytes requires to encode a public key + return 32 + + @staticmethod + def depth_spendable() -> int: + return 10 + + @staticmethod + def xmr_swap_a_lock_spend_tx_vsize() -> int: + raise ValueError('Not possible') + + @staticmethod + def xmr_swap_b_lock_spend_tx_vsize() -> int: + # TODO: Estimate with ringsize + return 1604 + + def __init__(self, coin_settings, network, swap_client=None): + super().__init__(network) + + self._addr_prefix = self.chainparams_network()['address_prefix'] + + self.blocks_confirmed = coin_settings['blocks_confirmed'] + self._restore_height = coin_settings.get('restore_height', 0) + self.setFeePriority(coin_settings.get('fee_priority', 0)) + self._sc = swap_client + self._log = self._sc.log if self._sc and self._sc.log else logging + self._wallet_password = None + self._have_checked_seed = False + + daemon_login = None + if coin_settings.get('rpcuser', '') != '': + daemon_login = (coin_settings.get('rpcuser', ''), coin_settings.get('rpcpassword', '')) + + rpchost = coin_settings.get('rpchost', '127.0.0.1') + proxy_host = None + proxy_port = None + # Connect to the daemon over a proxy if not running locally + if swap_client: + chain_client_settings = swap_client.getChainClientSettings(self.coin_type()) + manage_daemon: bool = chain_client_settings['manage_daemon'] + if swap_client.use_tor_proxy: + if manage_daemon is False: + log_str: str = '' + have_cc_tor_opt = 'use_tor' in chain_client_settings + if have_cc_tor_opt and chain_client_settings['use_tor'] is False: + log_str = ' bypassing proxy (use_tor false for WOW)' + elif have_cc_tor_opt is False and is_private_ip_address(rpchost): + log_str = ' bypassing proxy (private ip address)' + else: + proxy_host = swap_client.tor_proxy_host + proxy_port = swap_client.tor_proxy_port + log_str = f' through proxy at {proxy_host}' + self._log.info(f'Connecting to remote {self.coin_name()} daemon at {rpchost}{log_str}.') + else: + self._log.info(f'Not connecting to local {self.coin_name()} daemon through proxy.') + elif manage_daemon is False: + self._log.info(f'Connecting to remote {self.coin_name()} daemon at {rpchost}.') + + self._rpctimeout = coin_settings.get('rpctimeout', 60) + self._walletrpctimeout = coin_settings.get('walletrpctimeout', 120) + self._walletrpctimeoutlong = coin_settings.get('walletrpctimeoutlong', 600) + + self.rpc = make_wow_rpc_func(coin_settings['rpcport'], daemon_login, host=rpchost, proxy_host=proxy_host, proxy_port=proxy_port, default_timeout=self._rpctimeout, tag='Node(j) ') + self.rpc2 = make_wow_rpc2_func(coin_settings['rpcport'], daemon_login, host=rpchost, proxy_host=proxy_host, proxy_port=proxy_port, default_timeout=self._rpctimeout, tag='Node ') # non-json endpoint + self.rpc_wallet = make_wow_rpc_func(coin_settings['walletrpcport'], coin_settings['walletrpcauth'], host=coin_settings.get('walletrpchost', '127.0.0.1'), default_timeout=self._walletrpctimeout, tag='Wallet ') + + def checkWallets(self) -> int: + return 1 + + def setFeePriority(self, new_priority): + ensure(new_priority >= 0 and new_priority < 4, 'Invalid fee_priority value') + self._fee_priority = new_priority + + def setWalletFilename(self, wallet_filename): + self._wallet_filename = wallet_filename + + def createWallet(self, params): + if self._wallet_password is not None: + params['password'] = self._wallet_password + rv = self.rpc_wallet('generate_from_keys', params) + self._log.info('generate_from_keys %s', dumpj(rv)) + + def openWallet(self, filename): + params = {'filename': filename} + if self._wallet_password is not None: + params['password'] = self._wallet_password + + try: + # Can't reopen the same wallet in windows, !is_keys_file_locked() + self.rpc_wallet('close_wallet') + except Exception: + pass + self.rpc_wallet('open_wallet', params) + + def initialiseWallet(self, key_view, key_spend, restore_height=None): + with self._mx_wallet: + try: + self.openWallet(self._wallet_filename) + # TODO: Check address + return # Wallet exists + except Exception as e: + pass + + Kbv = self.getPubkey(key_view) + Kbs = self.getPubkey(key_spend) + address_b58 = wow_util.encode_address(Kbv, Kbs, self._addr_prefix) + + params = { + 'filename': self._wallet_filename, + 'address': address_b58, + 'viewkey': b2h(key_view[::-1]), + 'spendkey': b2h(key_spend[::-1]), + 'restore_height': self._restore_height, + } + self.createWallet(params) + self.openWallet(self._wallet_filename) + + def ensureWalletExists(self) -> None: + with self._mx_wallet: + self.openWallet(self._wallet_filename) + + def testDaemonRPC(self, with_wallet=True) -> None: + self.rpc_wallet('get_languages') + + def getDaemonVersion(self): + return self.rpc_wallet('get_version')['version'] + + def getBlockchainInfo(self): + get_height = self.rpc2('get_height', timeout=self._rpctimeout) + rv = { + 'blocks': get_height['height'], + 'verificationprogress': 0.0, + } + + try: + # get_block_count.block_count is how many blocks are in the longest chain known to the node. + # get_block_count returns "Internal error" if bootstrap-daemon is active + if get_height['untrusted'] is True: + rv['bootstrapping'] = True + get_info = self.rpc2('get_info', timeout=self._rpctimeout) + if 'height_without_bootstrap' in get_info: + rv['blocks'] = get_info['height_without_bootstrap'] + + rv['known_block_count'] = get_info['height'] + if rv['known_block_count'] > rv['blocks']: + rv['verificationprogress'] = rv['blocks'] / rv['known_block_count'] + else: + rv['known_block_count'] = self.rpc('get_block_count', timeout=self._rpctimeout)['count'] + rv['verificationprogress'] = rv['blocks'] / rv['known_block_count'] + except Exception as e: + self._log.warning('WOW get_block_count failed with: %s', str(e)) + rv['verificationprogress'] = 0.0 + + return rv + + def getChainHeight(self): + return self.rpc2('get_height', timeout=self._rpctimeout)['height'] + + def getWalletInfo(self): + with self._mx_wallet: + try: + self.openWallet(self._wallet_filename) + except Exception as e: + if 'Failed to open wallet' in str(e): + rv = {'encrypted': True, 'locked': True, 'balance': 0, 'unconfirmed_balance': 0} + return rv + raise e + + rv = {} + self.rpc_wallet('refresh') + balance_info = self.rpc_wallet('get_balance') + + rv['balance'] = self.format_amount(balance_info['unlocked_balance']) + rv['unconfirmed_balance'] = self.format_amount(balance_info['balance'] - balance_info['unlocked_balance']) + rv['encrypted'] = False if self._wallet_password is None else True + rv['locked'] = False + return rv + + def walletRestoreHeight(self): + return self._restore_height + + def getMainWalletAddress(self) -> str: + with self._mx_wallet: + self.openWallet(self._wallet_filename) + return self.rpc_wallet('get_address')['address'] + + def getNewAddress(self, placeholder) -> str: + with self._mx_wallet: + self.openWallet(self._wallet_filename) + new_address = self.rpc_wallet('create_address', {'account_index': 0})['address'] + self.rpc_wallet('store') + return new_address + + def get_fee_rate(self, conf_target: int = 2): + # fees - array of unsigned int; Represents the base fees at different priorities [slow, normal, fast, fastest]. + fee_est = self.rpc('get_fee_estimate') + if conf_target <= 1: + conf_target = 1 # normal + else: + conf_target = 0 # slow + fee_per_k_bytes = fee_est['fees'][conf_target] * 1000 + + return float(self.format_amount(fee_per_k_bytes)), 'get_fee_estimate' + + def getNewSecretKey(self) -> bytes: + # Note: Returned bytes are in big endian order + return i2b(edu.get_secret()) + + def pubkey(self, key: bytes) -> bytes: + return edf.scalarmult_B(key) + + def encodeKey(self, vk: bytes) -> str: + return vk[::-1].hex() + + def decodeKey(self, k_hex: str) -> bytes: + return bytes.fromhex(k_hex)[::-1] + + def encodePubkey(self, pk: bytes) -> str: + return edu.encodepoint(pk) + + def decodePubkey(self, pke): + return edf.decodepoint(pke) + + def getPubkey(self, privkey): + return ed25519_get_pubkey(privkey) + + def getAddressFromKeys(self, key_view: bytes, key_spend: bytes) -> str: + pk_view = self.getPubkey(key_view) + pk_spend = self.getPubkey(key_spend) + return wow_util.encode_address(pk_view, pk_spend, self._addr_prefix) + + def verifyKey(self, k: int) -> bool: + i = b2i(k) + return (i < edf.l and i > 8) + + def verifyPubkey(self, pubkey_bytes): + # Calls ed25519_decode_check_point() in secp256k1 + # Checks for small order + return verify_ed25519_point(pubkey_bytes) + + def proveDLEAG(self, key: bytes) -> bytes: + privkey = PrivateKey(key) + return dleag_prove(privkey) + + def verifyDLEAG(self, dleag_bytes: bytes) -> bool: + return dleag_verify(dleag_bytes) + + def lengthDLEAG(self) -> int: + return dleag_proof_len() + + def sumKeys(self, ka: bytes, kb: bytes) -> bytes: + return ed25519_scalar_add(ka, kb) + + def sumPubkeys(self, Ka: bytes, Kb: bytes) -> bytes: + return ed25519_add(Ka, Kb) + + def encodeSharedAddress(self, Kbv: bytes, Kbs: bytes) -> str: + return wow_util.encode_address(Kbv, Kbs, self._addr_prefix) + + def publishBLockTx(self, kbv: bytes, Kbs: bytes, output_amount: int, feerate: int, unlock_time: int = 0) -> bytes: + with self._mx_wallet: + self.openWallet(self._wallet_filename) + self.rpc_wallet('refresh') + + Kbv = self.getPubkey(kbv) + shared_addr = wow_util.encode_address(Kbv, Kbs, self._addr_prefix) + + params = {'destinations': [{'amount': output_amount, 'address': shared_addr}], 'unlock_time': unlock_time} + if self._fee_priority > 0: + params['priority'] = self._fee_priority + rv = self.rpc_wallet('transfer', params) + self._log.info('publishBLockTx %s to address_b58 %s', rv['tx_hash'], shared_addr) + tx_hash = bytes.fromhex(rv['tx_hash']) + + return tx_hash + + def findTxB(self, kbv, Kbs, cb_swap_value, cb_block_confirmed, restore_height, bid_sender): + with self._mx_wallet: + Kbv = self.getPubkey(kbv) + address_b58 = wow_util.encode_address(Kbv, Kbs, self._addr_prefix) + + kbv_le = kbv[::-1] + params = { + 'restore_height': restore_height, + 'filename': address_b58, + 'address': address_b58, + 'viewkey': b2h(kbv_le), + } + + try: + self.openWallet(address_b58) + except Exception as e: + self.createWallet(params) + self.openWallet(address_b58) + + self.rpc_wallet('refresh', timeout=self._walletrpctimeoutlong) + + ''' + # Debug + try: + current_height = self.rpc_wallet('get_height')['height'] + self._log.info('findTxB WOW current_height %d\nAddress: %s', current_height, address_b58) + except Exception as e: + self._log.info('rpc failed %s', str(e)) + current_height = None # If the transfer is available it will be deep enough + # and (current_height is None or current_height - transfer['block_height'] > cb_block_confirmed): + ''' + params = {'transfer_type': 'available'} + transfers = self.rpc_wallet('incoming_transfers', params) + rv = None + if 'transfers' in transfers: + for transfer in transfers['transfers']: + # unlocked <- wallet->is_transfer_unlocked() checks unlock_time and CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE + if not transfer['unlocked']: + full_tx = self.rpc_wallet('get_transfer_by_txid', {'txid': transfer['tx_hash']}) + unlock_time = full_tx['transfer']['unlock_time'] + if unlock_time != 0: + self._log.warning('Coin b lock txn is locked: {}, unlock_time {}'.format(transfer['tx_hash'], unlock_time)) + rv = -1 + continue + if transfer['amount'] == cb_swap_value: + return {'txid': transfer['tx_hash'], 'amount': transfer['amount'], 'height': 0 if 'block_height' not in transfer else transfer['block_height']} + else: + self._log.warning('Incorrect amount detected for coin b lock txn: {}'.format(transfer['tx_hash'])) + rv = -1 + return rv + + def findTxnByHash(self, txid): + with self._mx_wallet: + self.openWallet(self._wallet_filename) + self.rpc_wallet('refresh', timeout=self._walletrpctimeoutlong) + + try: + current_height = self.rpc2('get_height', timeout=self._rpctimeout)['height'] + self._log.info('findTxnByHash WOW current_height %d\nhash: %s', current_height, txid) + except Exception as e: + self._log.info('rpc failed %s', str(e)) + current_height = None # If the transfer is available it will be deep enough + + params = {'transfer_type': 'available'} + rv = self.rpc_wallet('incoming_transfers', params) + if 'transfers' in rv: + for transfer in rv['transfers']: + if transfer['tx_hash'] == txid \ + and (current_height is None or current_height - transfer['block_height'] > self.blocks_confirmed): + return {'txid': transfer['tx_hash'], 'amount': transfer['amount'], 'height': transfer['block_height']} + + return None + + def spendBLockTx(self, chain_b_lock_txid: bytes, address_to: str, kbv: bytes, kbs: bytes, cb_swap_value: int, b_fee_rate: int, restore_height: int, spend_actual_balance: bool = False) -> bytes: + ''' + Notes: + "Error: No unlocked balance in the specified subaddress(es)" can mean not enough funds after tx fee. + ''' + with self._mx_wallet: + Kbv = self.getPubkey(kbv) + Kbs = self.getPubkey(kbs) + address_b58 = wow_util.encode_address(Kbv, Kbs, self._addr_prefix) + + wallet_filename = address_b58 + '_spend' + + params = { + 'filename': wallet_filename, + 'address': address_b58, + 'viewkey': b2h(kbv[::-1]), + 'spendkey': b2h(kbs[::-1]), + 'restore_height': restore_height, + } + + try: + self.openWallet(wallet_filename) + except Exception as e: + self.createWallet(params) + self.openWallet(wallet_filename) + + self.rpc_wallet('refresh') + rv = self.rpc_wallet('get_balance') + if rv['balance'] < cb_swap_value: + self._log.warning('Balance is too low, checking for existing spend.') + txns = self.rpc_wallet('get_transfers', {'out': True}) + if 'out' in txns: + txns = txns['out'] + if len(txns) > 0: + txid = txns[0]['txid'] + self._log.warning(f'spendBLockTx detected spending tx: {txid}.') + if txns[0]['address'] == address_b58: + return bytes.fromhex(txid) + + self._log.error('wallet {} balance {}, expected {}'.format(wallet_filename, rv['balance'], cb_swap_value)) + + if not spend_actual_balance: + raise TemporaryError('Invalid balance') + + if spend_actual_balance and rv['balance'] != cb_swap_value: + self._log.warning('Spending actual balance {}, not swap value {}.'.format(rv['balance'], cb_swap_value)) + cb_swap_value = rv['balance'] + if rv['unlocked_balance'] < cb_swap_value: + self._log.error('wallet {} balance {}, expected {}, blocks_to_unlock {}'.format(wallet_filename, rv['unlocked_balance'], cb_swap_value, rv['blocks_to_unlock'])) + raise TemporaryError('Invalid unlocked_balance') + + params = {'address': address_to} + if self._fee_priority > 0: + params['priority'] = self._fee_priority + + rv = self.rpc_wallet('sweep_all', params) + self._log.debug('sweep_all {}'.format(json.dumps(rv))) + + return bytes.fromhex(rv['tx_hash_list'][0]) + + def withdrawCoin(self, value, addr_to: str, sweepall: bool, estimate_fee: bool = False) -> str: + with self._mx_wallet: + self.openWallet(self._wallet_filename) + self.rpc_wallet('refresh') + + if sweepall: + balance = self.rpc_wallet('get_balance') + if balance['balance'] != balance['unlocked_balance']: + raise ValueError('Balance must be fully confirmed to use sweep all.') + self._log.info('WOW {} sweep_all.'.format('estimate fee' if estimate_fee else 'withdraw')) + self._log.debug('WOW balance: {}'.format(balance['balance'])) + params = {'address': addr_to, 'do_not_relay': estimate_fee} + if self._fee_priority > 0: + params['priority'] = self._fee_priority + rv = self.rpc_wallet('sweep_all', params) + if estimate_fee: + return {'num_txns': len(rv['fee_list']), 'sum_amount': sum(rv['amount_list']), 'sum_fee': sum(rv['fee_list']), 'sum_weight': sum(rv['weight_list'])} + return rv['tx_hash_list'][0] + + value_sats: int = make_int(value, self.exp()) + params = {'destinations': [{'amount': value_sats, 'address': addr_to}], 'do_not_relay': estimate_fee} + if self._fee_priority > 0: + params['priority'] = self._fee_priority + rv = self.rpc_wallet('transfer', params) + if estimate_fee: + return {'num_txns': 1, 'sum_amount': rv['amount'], 'sum_fee': rv['fee'], 'sum_weight': rv['weight']} + return rv['tx_hash'] + + def estimateFee(self, value: int, addr_to: str, sweepall: bool) -> str: + return self.withdrawCoin(value, addr_to, sweepall, estimate_fee=True) + + def showLockTransfers(self, kbv, Kbs, restore_height): + with self._mx_wallet: + try: + Kbv = self.getPubkey(kbv) + address_b58 = wow_util.encode_address(Kbv, Kbs, self._addr_prefix) + wallet_file = address_b58 + '_spend' + try: + self.openWallet(wallet_file) + except Exception: + wallet_file = address_b58 + try: + self.openWallet(wallet_file) + except Exception: + self._log.info(f'showLockTransfers trying to create wallet for address {address_b58}.') + kbv_le = kbv[::-1] + params = { + 'restore_height': restore_height, + 'filename': address_b58, + 'address': address_b58, + 'viewkey': b2h(kbv_le), + } + self.createWallet(params) + self.openWallet(address_b58) + + self.rpc_wallet('refresh') + + rv = self.rpc_wallet('get_transfers', {'in': True, 'out': True, 'pending': True, 'failed': True}) + rv['filename'] = wallet_file + return rv + except Exception as e: + return {'error': str(e)} + + def getSpendableBalance(self) -> int: + with self._mx_wallet: + self.openWallet(self._wallet_filename) + + self.rpc_wallet('refresh') + balance_info = self.rpc_wallet('get_balance') + return balance_info['unlocked_balance'] + + def changeWalletPassword(self, old_password, new_password): + self._log.info('changeWalletPassword - {}'.format(self.ticker())) + orig_password = self._wallet_password + if old_password != '': + self._wallet_password = old_password + try: + self.openWallet(self._wallet_filename) + self.rpc_wallet('change_wallet_password', {'old_password': old_password, 'new_password': new_password}) + except Exception as e: + self._wallet_password = orig_password + raise e + + def unlockWallet(self, password: str) -> None: + self._log.info('unlockWallet - {}'.format(self.ticker())) + self._wallet_password = password + + if not self._have_checked_seed: + self._sc.checkWalletSeed(self.coin_type()) + + def lockWallet(self) -> None: + self._log.info('lockWallet - {}'.format(self.ticker())) + self._wallet_password = None + + def isAddressMine(self, address): + # TODO + return True + + def ensureFunds(self, amount: int) -> None: + if self.getSpendableBalance() < amount: + raise ValueError('Balance too low') + + def getTransaction(self, txid: bytes): + return self.rpc2('get_transactions', {'txs_hashes': [txid.hex(), ]}) diff --git a/basicswap/js_server.py b/basicswap/js_server.py index d3ff2e4..0f8d303 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -54,7 +54,7 @@ def withdraw_coin(swap_client, coin_type, post_string, is_json): post_data = getFormData(post_string, is_json) address = get_data_entry(post_data, 'address') - if coin_type == Coins.XMR: + if coin_type in (Coins.XMR, Coins.WOW): value = None sweepall = get_data_entry(post_data, 'sweepall') if not isinstance(sweepall, bool): @@ -74,7 +74,7 @@ def withdraw_coin(swap_client, coin_type, post_string, is_json): elif coin_type == Coins.LTC: type_from = get_data_entry_or(post_data, 'type_from', 'plain') txid_hex = swap_client.withdrawLTC(type_from, value, address, subfee) - elif coin_type == Coins.XMR: + elif coin_type in (Coins.XMR, Coins.WOW): txid_hex = swap_client.withdrawCoin(coin_type, value, address, sweepall) else: txid_hex = swap_client.withdrawCoin(coin_type, value, address, subfee) @@ -685,7 +685,7 @@ def js_getcoinseed(self, url_split, post_string, is_json) -> bytes: raise ValueError('Particl wallet seed is set from the Basicswap mnemonic.') ci = swap_client.ci(coin) - if coin == Coins.XMR: + if coin in (Coins.XMR, Coins.WOW): key_view = swap_client.getWalletKey(coin, 1, for_ed25519=True) key_spend = swap_client.getWalletKey(coin, 2, for_ed25519=True) address = ci.getAddressFromKeys(key_view, key_spend) diff --git a/basicswap/rpc_wow.py b/basicswap/rpc_wow.py new file mode 100644 index 0000000..21ccac3 --- /dev/null +++ b/basicswap/rpc_wow.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- + +import os +import json +import socks +import time +import urllib +import hashlib +from xmlrpc.client import ( + Fault, + Transport, + SafeTransport, +) +from sockshandler import SocksiPyConnection +from .util import jsonDecimal + + +class SocksTransport(Transport): + + def set_proxy(self, proxy_host, proxy_port): + self.proxy_host = proxy_host + self.proxy_port = proxy_port + + self.proxy_type = socks.PROXY_TYPE_SOCKS5 + self.proxy_rdns = True + self.proxy_username = None + self.proxy_password = None + + def make_connection(self, host): + # return an existing connection if possible. This allows + # HTTP/1.1 keep-alive. + if self._connection and host == self._connection[0]: + return self._connection[1] + # create a HTTP connection object from a host descriptor + chost, self._extra_headers, x509 = self.get_host_info(host) + self._connection = host, SocksiPyConnection(self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns, self.proxy_username, self.proxy_password, chost) + return self._connection[1] + + +class JsonrpcDigest(): + # __getattr__ complicates extending ServerProxy + def __init__(self, uri, transport=None, encoding=None, verbose=False, + allow_none=False, use_datetime=False, use_builtin_types=False, + *, context=None): + + parsed = urllib.parse.urlparse(uri) + if parsed.scheme not in ('http', 'https'): + raise OSError('unsupported XML-RPC protocol') + self.__host = parsed.netloc + self.__handler = parsed.path + + if transport is None: + handler = SafeTransport if parsed.scheme == 'https' else Transport + extra_kwargs = {} + transport = handler(use_datetime=use_datetime, + use_builtin_types=use_builtin_types, + **extra_kwargs) + self.__transport = transport + + self.__encoding = encoding or 'utf-8' + self.__verbose = verbose + self.__allow_none = allow_none + + self.__request_id = 0 + + def close(self): + if self.__transport is not None: + self.__transport.close() + + def request_id(self): + return self.__request_id + + def post_request(self, method, params, timeout=None): + try: + connection = self.__transport.make_connection(self.__host) + if timeout: + connection.timeout = timeout + headers = self.__transport._extra_headers[:] + + connection.putrequest('POST', self.__handler) + headers.append(('Content-Type', 'application/json')) + headers.append(('User-Agent', 'jsonrpc')) + self.__transport.send_headers(connection, headers) + self.__transport.send_content(connection, '' if params is None else json.dumps(params, default=jsonDecimal).encode('utf-8')) + self.__request_id += 1 + + resp = connection.getresponse() + return resp.read() + + except Fault: + raise + except Exception: + self.__transport.close() + raise + + def json_request(self, request_body, username='', password='', timeout=None): + try: + connection = self.__transport.make_connection(self.__host) + if timeout: + connection.timeout = timeout + + headers = self.__transport._extra_headers[:] + + connection.putrequest('POST', self.__handler) + headers.append(('Content-Type', 'application/json')) + headers.append(('Connection', 'keep-alive')) + self.__transport.send_headers(connection, headers) + self.__transport.send_content(connection, json.dumps(request_body, default=jsonDecimal).encode('utf-8') if request_body else '') + resp = connection.getresponse() + + if resp.status == 401: + resp_headers = resp.getheaders() + v = resp.read() + + algorithm = '' + realm = '' + nonce = '' + for h in resp_headers: + if h[0] != 'WWW-authenticate': + continue + fields = h[1].split(',') + for f in fields: + key, value = f.split('=', 1) + if key == 'algorithm' and value != 'MD5': + break + if key == 'realm': + realm = value.strip('"') + if key == 'nonce': + nonce = value.strip('"') + if realm != '' and nonce != '': + break + + if realm == '' or nonce == '': + raise ValueError('Authenticate header not found.') + + path = self.__handler + HA1 = hashlib.md5(f'{username}:{realm}:{password}'.encode('utf-8')).hexdigest() + + http_method = 'POST' + HA2 = hashlib.md5(f'{http_method}:{path}'.encode('utf-8')).hexdigest() + + ncvalue = '{:08x}'.format(1) + s = ncvalue.encode('utf-8') + s += nonce.encode('utf-8') + s += time.ctime().encode('utf-8') + s += os.urandom(8) + cnonce = (hashlib.sha1(s).hexdigest()[:16]) + + # MD5-SESS + HA1 = hashlib.md5(f'{HA1}:{nonce}:{cnonce}'.encode('utf-8')).hexdigest() + + respdig = hashlib.md5(f'{HA1}:{nonce}:{ncvalue}:{cnonce}:auth:{HA2}'.encode('utf-8')).hexdigest() + + header_value = f'Digest username="{username}", realm="{realm}", nonce="{nonce}", uri="{path}", response="{respdig}", algorithm="MD5-sess", qop="auth", nc={ncvalue}, cnonce="{cnonce}"' + headers = self.__transport._extra_headers[:] + headers.append(('Authorization', header_value)) + + connection.putrequest('POST', self.__handler) + headers.append(('Content-Type', 'application/json')) + headers.append(('Connection', 'keep-alive')) + self.__transport.send_headers(connection, headers) + self.__transport.send_content(connection, json.dumps(request_body, default=jsonDecimal).encode('utf-8') if request_body else '') + resp = connection.getresponse() + + self.__request_id += 1 + return resp.read() + + except Fault: + raise + except Exception: + self.__transport.close() + raise + + +def callrpc_wow(rpc_port, method, params=[], rpc_host='127.0.0.1', path='json_rpc', auth=None, timeout=120, transport=None, tag=''): + # auth is a tuple: (username, password) + try: + if rpc_host.count('://') > 0: + url = '{}:{}/{}'.format(rpc_host, rpc_port, path) + else: + url = 'http://{}:{}/{}'.format(rpc_host, rpc_port, path) + + x = JsonrpcDigest(url, transport=transport) + request_body = { + 'method': method, + 'params': params, + 'jsonrpc': '2.0', + 'id': x.request_id() + } + if auth: + v = x.json_request(request_body, username=auth[0], password=auth[1], timeout=timeout) + else: + v = x.json_request(request_body, timeout=timeout) + x.close() + r = json.loads(v.decode('utf-8')) + except Exception as ex: + raise ValueError('{}RPC Server Error: {}'.format(tag, str(ex))) + + if 'error' in r and r['error'] is not None: + raise ValueError(tag + 'RPC error ' + str(r['error'])) + + return r['result'] + + +def callrpc_wow2(rpc_port: int, method: str, params=None, auth=None, rpc_host='127.0.0.1', timeout=120, transport=None, tag=''): + try: + if rpc_host.count('://') > 0: + url = '{}:{}/{}'.format(rpc_host, rpc_port, method) + else: + url = 'http://{}:{}/{}'.format(rpc_host, rpc_port, method) + + x = JsonrpcDigest(url, transport=transport) + if auth: + v = x.json_request(params, username=auth[0], password=auth[1], timeout=timeout) + else: + v = x.json_request(params, timeout=timeout) + x.close() + r = json.loads(v.decode('utf-8')) + except Exception as ex: + raise ValueError('{}RPC Server Error: {}'.format(tag, str(ex))) + + return r + + +def make_wow_rpc2_func(port, auth, host='127.0.0.1', proxy_host=None, proxy_port=None, default_timeout=120, tag=''): + port = port + auth = auth + host = host + transport = None + default_timeout = default_timeout + tag = tag + + if proxy_host: + transport = SocksTransport() + transport.set_proxy(proxy_host, proxy_port) + + def rpc_func(method, params=None, wallet=None, timeout=default_timeout): + nonlocal port, auth, host, transport, tag + return callrpc_wow2(port, method, params, auth=auth, rpc_host=host, timeout=timeout, transport=transport, tag=tag) + return rpc_func + + +def make_wow_rpc_func(port, auth, host='127.0.0.1', proxy_host=None, proxy_port=None, default_timeout=120, tag=''): + port = port + auth = auth + host = host + transport = None + default_timeout = default_timeout + tag = tag + + if proxy_host: + transport = SocksTransport() + transport.set_proxy(proxy_host, proxy_port) + + def rpc_func(method, params=None, wallet=None, timeout=default_timeout): + nonlocal port, auth, host, transport, tag + return callrpc_wow(port, method, params, rpc_host=host, auth=auth, timeout=timeout, transport=transport, tag=tag) + return rpc_func diff --git a/basicswap/templates/offer_new_1.html b/basicswap/templates/offer_new_1.html index a86b5ec..14d4f87 100644 --- a/basicswap/templates/offer_new_1.html +++ b/basicswap/templates/offer_new_1.html @@ -409,7 +409,7 @@ document.getElementById('get_rate_inferred_button').addEventListener('click', getRateInferred); function set_swap_type_enabled(coin_from, coin_to, swap_type) { - const adaptor_sig_only_coins = ['6' /* XMR */, '8' /* PART_ANON */, '7' /* PART_BLIND */, '13' /* FIRO */]; + const adaptor_sig_only_coins = ['6' /* XMR */,'9' /* WOW */, '8' /* PART_ANON */, '7' /* PART_BLIND */, '13' /* FIRO */]; const secret_hash_only_coins = ['11' /* PIVX */, '12' /* DASH */]; let make_hidden = false; if (adaptor_sig_only_coins.includes(coin_from) || adaptor_sig_only_coins.includes(coin_to)) { diff --git a/basicswap/templates/settings.html b/basicswap/templates/settings.html index 1d3bdec..ff4a2c6 100644 --- a/basicswap/templates/settings.html +++ b/basicswap/templates/settings.html @@ -105,7 +105,7 @@ {% endif %} {% if c.manage_daemon is defined %} - {% if c.name == 'monero' %} + {% if c.name in ('wownero', 'monero') %}