diff --git a/.gitignore b/.gitignore index c85c716..f8aa81c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__ .eggs .ruff_cache .pytest_cache +.vectorcode *~ # geckodriver.log diff --git a/basicswap/base.py b/basicswap/base.py index a834aee..5543c44 100644 --- a/basicswap/base.py +++ b/basicswap/base.py @@ -31,6 +31,7 @@ from .util import ( ) from .util.logging import ( BSXLogger, + LogCategories as LC, ) from .chainparams import ( Coins, @@ -43,7 +44,7 @@ def getaddrinfo_tor(*args): class BaseApp(DBMethods): - def __init__(self, data_dir, settings, chain, log_name="BasicSwap"): + def __init__(self, data_dir, settings, chain, log_name="BasicSwap", **kwargs): self.fp = None self.log_name = log_name self.fail_code = 0 @@ -73,6 +74,24 @@ class BaseApp(DBMethods): self.default_socket_getaddrinfo = socket.getaddrinfo self._force_db_upgrade = False + self._enabled_log_categories = set() + for category in self.settings.get("enabled_log_categories", []): + if category == "net": + self._enabled_log_categories.add(LC.NET) + else: + self.log.warning(f"Unknown entry \"{category}\" in \"enabled_log_categories\"") + + if len(self._enabled_log_categories) > 0: + self.log.info("Enabled logging categories: {}".format(",".join(sorted([c.name for c in self._enabled_log_categories])))) + + super().__init__( + data_dir=data_dir, + settings=settings, + chain=chain, + log_name=log_name, + **kwargs, + ) + def __del__(self): if self.fp: self.fp.close() @@ -236,11 +255,16 @@ class BaseApp(DBMethods): request = urllib.request.Request(url, headers=headers) return opener.open(request, timeout=timeout).read() - def logException(self, message) -> None: + def logException(self, message: str) -> None: self.log.error(message) if self.debug: self.log.error(traceback.format_exc()) + def logD(self, log_category: int, message: str) -> None: + if log_category not in self._enabled_log_categories: + return + self.log.debug("(" + LC(log_category).name + ") " + message) + def torControl(self, query): try: command = 'AUTHENTICATE "{}"\r\n{}\r\nQUIT\r\n'.format( diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index d14df20..9384236 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -48,6 +48,7 @@ from .basicswap_util import ( isActiveBidState, KeyTypes, MessageNetworks, + MessageNetworkLinkTypes, MessageTypes, NotificationTypes as NT, OfferStates, @@ -94,6 +95,7 @@ from .util.address import ( pubkeyToAddress, ) from .util.crypto import sha256 +from .util.logging import LogCategories as LC from .util.network import is_private_ip_address from .util.smsg import smsgGetID from .interface.base import Curves @@ -151,17 +153,12 @@ from .explorers import ( ExplorerChainz, ) from .network.simplex import ( - closeSimplexChat, encryptMsg, getJoinedSimplexLink, getResponseData, - initialiseSimplexNetwork, - readSimplexMsgs, - sendSimplexMsg, -) -from .network.util import ( - getMsgPubkey, ) +from .network.bsx_network import BSXNetwork, networkTypeToID +from .network.util import getMsgPubkey import basicswap.config as cfg import basicswap.network.network as bsn import basicswap.protocols.atomic_swap_1 as atomic_swap_1 @@ -322,9 +319,8 @@ class WatchedTransaction: self.swap_type = swap_type -class BasicSwap(BaseApp, UIApp): +class BasicSwap(BaseApp, BSXNetwork, UIApp): ws_server = None - _read_zmq_queue: bool = True protocolInterfaces = { SwapTypes.SELLER_FIRST: atomic_swap_1.AtomicSwapInterface(), SwapTypes.XMR_SWAP: xmr_swap_1.XmrSwapInterface(), @@ -357,9 +353,6 @@ class BasicSwap(BaseApp, UIApp): self.check_progress_seconds = self.get_int_setting( "check_progress_seconds", 60, 1, 10 * 60 ) - self.check_smsg_seconds = self.get_int_setting( - "check_smsg_seconds", 10, 1, 10 * 60 - ) self.check_watched_seconds = self.get_int_setting( "check_watched_seconds", 60, 1, 10 * 60 ) @@ -379,7 +372,6 @@ class BasicSwap(BaseApp, UIApp): self._last_checked_expired = 0 self._last_checked_expiring_bids_offers = 0 self._last_checked_progress = 0 - self._last_checked_smsg = 0 self._last_checked_watched = 0 self._last_checked_split_messages = 0 self._last_checked_delayed_auto_accept = 0 @@ -390,9 +382,6 @@ class BasicSwap(BaseApp, UIApp): self._expiring_offers = [] # List of offers expiring soon self._updating_wallets_info = {} self._last_updated_wallets_info = 0 - self._zmq_queue_enabled = self.settings.get("zmq_queue_enabled", True) - self._poll_smsg = self.settings.get("poll_smsg", False) - self._notifications_enabled = self.settings.get("notifications_enabled", True) self._disabled_notification_types = self.settings.get( "disabled_notification_types", [] @@ -403,11 +392,6 @@ class BasicSwap(BaseApp, UIApp): self._expire_db_records_after = self.get_int_setting( "expire_db_records_after", 7 * 86400, 0, 31 * 86400 ) # Seconds - self._expire_message_routes_after = self._expire_db_records_after = ( - self.get_int_setting( - "expire_message_routes_after", 48 * 3600, 10 * 60, 31 * 86400 - ) - ) # Seconds self._max_logfile_bytes = self.settings.get( "max_logfile_size", 100 ) # In MB. Set to 0 to disable truncation @@ -419,9 +403,6 @@ class BasicSwap(BaseApp, UIApp): self._is_encrypted = None self._is_locked = None - self.num_group_simplex_messages_received = 0 - self.num_direct_simplex_messages_received = 0 - self._max_transient_errors = self.settings.get( "max_transient_errors", 100 ) # Number of retries before a bid will stop when encountering transient errors. @@ -487,14 +468,9 @@ class BasicSwap(BaseApp, UIApp): ) self._max_check_loop_blocks = self.settings.get("max_check_loop_blocks", 100000) self._bid_expired_leeway = 5 - self._use_direct_message_routes = True self.swaps_in_progress = dict() - self.SMSG_SECONDS_IN_HOUR = ( - 60 * 60 - ) # Note: Set smsgsregtestadjust=0 for regtest - self.threads = [] self.thread_pool = concurrent.futures.ThreadPoolExecutor( max_workers=4, thread_name_prefix="bsp" @@ -529,18 +505,6 @@ class BasicSwap(BaseApp, UIApp): finally: self.closeDB(cursor) - if self._zmq_queue_enabled: - self.zmqContext = zmq.Context() - self.zmqSubscriber = self.zmqContext.socket(zmq.SUB) - - self.zmqSubscriber.connect( - self.settings["zmqhost"] + ":" + str(self.settings["zmqport"]) - ) - self.zmqSubscriber.setsockopt_string(zmq.SUBSCRIBE, "smsg") - - if Coins.PART in chainparams: - self.zmqSubscriber.setsockopt_string(zmq.SUBSCRIBE, "hashwtx") - self.with_coins_override = extra_opts.get("with_coins", set()) self.without_coins_override = extra_opts.get("without_coins", set()) self._force_db_upgrade = extra_opts.get("force_db_upgrade", False) @@ -618,10 +582,8 @@ class BasicSwap(BaseApp, UIApp): else: self.thread_pool.shutdown() - if self._zmq_queue_enabled: - self.zmqContext.destroy() - self.swaps_in_progress.clear() + super().finalise() def logIDB(self, concept_id: bytes) -> str: return self.log.id(concept_id, prefix="B_") @@ -1105,9 +1067,6 @@ class BasicSwap(BaseApp, UIApp): upgradeDatabase(self, self.db_version) upgradeDatabaseData(self, self.db_data_version) - if self._zmq_queue_enabled and self._poll_smsg: - self.log.warning("SMSG polling and zmq listener enabled.") - for c in Coins: if c not in chainparams: continue @@ -1181,32 +1140,7 @@ class BasicSwap(BaseApp, UIApp): f"network_key {self.network_key}\nnetwork_pubkey {self.network_pubkey}\nnetwork_addr {self.network_addr}" ) - self.active_networks = [] - network_config_list = self.settings.get("networks", []) - if len(network_config_list) < 1: - network_config_list = [{"type": "smsg", "enabled": True}] - - for network in network_config_list: - if network.get("enabled", True) is False: - continue - if network["type"] == "smsg": - self.active_networks.append({"type": "smsg"}) - elif network["type"] == "simplex": - initialiseSimplexNetwork(self, network) - - ro = self.callrpc("smsglocalkeys") - found = False - for k in ro["smsg_keys"]: - if k["address"] == self.network_addr: - found = True - break - if not found: - self.log.info("Importing network key to SMSG") - self.callrpc("smsgimportprivkey", [self.network_key, "basicswap offers"]) - ro = self.callrpc("smsglocalkeys", ["anon", "-", self.network_addr]) - ensure(ro["result"] == "Success.", "smsglocalkeys failed") - - # TODO: Ensure smsg is enabled for the active wallet. + self.startNetworks() # Initialise locked state _, _ = self.getLockedState() @@ -1216,16 +1150,13 @@ class BasicSwap(BaseApp, UIApp): # Scan inbox # TODO: Redundant? small window for zmq messages to go unnoticed during startup? - # options = {'encoding': 'hex'} - options = {"encoding": "none"} + options = {"encoding": "hex"} + if self._smsg_plaintext_version >= 2: + options["pubkey_from"] = True ro = self.callrpc("smsginbox", ["unread", "", options]) nm = 0 for msg in ro["messages"]: - # TODO: Remove workaround for smsginbox bug - get_msg = self.callrpc( - "smsg", [msg["msgid"], {"encoding": "hex", "setread": True}] - ) - self.processMsg(get_msg) + self.processMsg(msg) nm += 1 self.log.info(f"Scanned {nm} unread messages.") @@ -1607,25 +1538,6 @@ class BasicSwap(BaseApp, UIApp): if cursor is None: self.closeDB(use_cursor) - def getMessageRoute( - self, network_id: int, address_from: str, address_to: str, cursor=None - ): - try: - use_cursor = self.openDB(cursor) - route = self.queryOne( - DirectMessageRoute, - use_cursor, - { - "network_id": network_id, - "smsg_addr_local": address_from, - "smsg_addr_remote": address_to, - }, - ) - return route - finally: - if cursor is None: - self.closeDB(use_cursor) - def activateBid(self, cursor, bid) -> None: if bid.bid_id in self.swaps_in_progress: self.log.debug(f"Bid {self.log.id(bid.bid_id)} is already in progress") @@ -1842,127 +1754,6 @@ class BasicSwap(BaseApp, UIApp): bid_valid = (bid.expire_at - now) + 10 * 60 # Add 10 minute buffer return max(smsg_min_valid, min(smsg_max_valid, bid_valid)) - def getActiveNetwork(self, network_id: int): - # TODO: Add more network types - for network in self.active_networks: - if network["type"] == "simplex": - return network - raise RuntimeError("Network not found.") - - def getActiveNetworkInterface(self, network_id: int): - network = self.getActiveNetwork(network_id) - return network["ws_thread"] - - def sendMessage( - self, - addr_from: str, - addr_to: str, - payload_hex: bytes, - msg_valid: int, - cursor, - linked_type=None, - linked_id=None, - timestamp=None, - deterministic=False, - ) -> bytes: - message_id: bytes = None - - message_route = self.getMessageRoute(1, addr_from, addr_to, cursor=cursor) - if message_route: - raise RuntimeError("Trying to send through an unestablished direct route.") - - message_route = self.getMessageRoute(2, addr_from, addr_to, cursor=cursor) - if message_route: - network = self.getActiveNetwork(2) - net_i = network["ws_thread"] - - remote_name = None - route_data = json.loads(message_route.route_data.decode("UTF-8")) - if "localDisplayName" in route_data: - remote_name = route_data["localDisplayName"] - else: - pccConnId = route_data["pccConnId"] - self.log.debug(f"Finding name for Simplex chat, ID: {pccConnId}") - cmd_id = net_i.send_command("/chats") - response = net_i.wait_for_command_response(cmd_id) - for chat in getResponseData(response, "chats"): - if ( - "chatInfo" not in chat - or "type" not in chat["chatInfo"] - or chat["chatInfo"]["type"] != "direct" - ): - continue - try: - if ( - chat["chatInfo"]["contact"]["activeConn"]["connId"] - == pccConnId - ): - remote_name = chat["chatInfo"]["contact"][ - "localDisplayName" - ] - break - except Exception as e: - self.log.debug(f"Error parsing chat: {e}") - - if remote_name is None: - raise RuntimeError( - f"Unable to find remote name for simplex direct chat, pccConnId: {pccConnId}" - ) - - message_id = sendSimplexMsg( - self, - network, - addr_from, - addr_to, - bytes.fromhex(payload_hex), - msg_valid, - cursor, - timestamp, - deterministic, - to_user_name=remote_name, - ) - return message_id - - # First network in list will set message_id - for network in self.active_networks: - net_message_id = None - if network["type"] == "smsg": - net_message_id = self.sendSmsg( - addr_from, addr_to, payload_hex, msg_valid - ) - elif network["type"] == "simplex": - net_message_id = sendSimplexMsg( - self, - network, - addr_from, - addr_to, - bytes.fromhex(payload_hex), - msg_valid, - cursor, - timestamp, - deterministic, - ) - else: - raise ValueError("Unknown network: {}".format(network["type"])) - if not message_id: - message_id = net_message_id - return message_id - - def sendSmsg( - self, addr_from: str, addr_to: str, payload_hex: bytes, msg_valid: int - ) -> bytes: - options = {"decodehex": True, "ttl_is_seconds": True} - try: - ro = self.callrpc( - "smsgsend", - [addr_from, addr_to, payload_hex, False, msg_valid, False, options], - ) - return bytes.fromhex(ro["msgid"]) - except Exception as e: - if self.debug: - self.log.error("smsgsend failed {}".format(json.dumps(ro, indent=4))) - raise e - def is_reverse_ads_bid(self, coin_from, coin_to) -> bool: return coin_from in self.scriptless_coins + self.coins_without_segwit @@ -2413,6 +2204,8 @@ class BasicSwap(BaseApp, UIApp): msg_buf.amount_negotiable = extra_options.get("amount_negotiable", False) msg_buf.rate_negotiable = extra_options.get("rate_negotiable", False) + msg_buf.message_nets = self.getMessageNetsString() + if msg_buf.amount_negotiable or msg_buf.rate_negotiable: ensure( auto_accept_bids is False, @@ -2503,6 +2296,7 @@ class BasicSwap(BaseApp, UIApp): offer_bytes = msg_buf.to_bytes() payload_hex = str.format("{:02x}", MessageTypes.OFFER) + offer_bytes.hex() msg_valid: int = max(self.SMSG_SECONDS_IN_HOUR, valid_for_seconds) + # Send offers to active and bridged networks, message_nets contains only the active networks. offer_id = self.sendMessage( offer_addr, offer_addr_to, payload_hex, msg_valid, cursor ) @@ -2541,7 +2335,9 @@ class BasicSwap(BaseApp, UIApp): from_feerate=msg_buf.fee_rate_from, to_feerate=msg_buf.fee_rate_to, auto_accept_type=msg_buf.auto_accept_type, + message_nets=msg_buf.message_nets, ) + offer.setState(OfferStates.OFFER_SENT) if swap_type == SwapTypes.XMR_SWAP: @@ -3495,6 +3291,10 @@ class BasicSwap(BaseApp, UIApp): dt.datetime.fromtimestamp(now).date(), contract_count ) + bid_message_nets = self.selectMessageNetStringForConcept( + Concepts.OFFER, offer_id, offer.message_nets, cursor + ) + bid = Bid( protocol_version=PROTOCOL_VERSION_SECRET_HASH, active_ind=1, @@ -3513,6 +3313,7 @@ class BasicSwap(BaseApp, UIApp): was_sent=True, chain_a_height_start=ci_from.getChainHeight(), chain_b_height_start=ci_to.getChainHeight(), + message_nets=bid_message_nets, ) pkhash_buyer_to = ci_to.pkh(contract_pubkey) @@ -3861,7 +3662,12 @@ class BasicSwap(BaseApp, UIApp): msg_valid: int = self.getAcceptBidMsgValidTime(bid) accept_msg_id = self.sendMessage( - offer.addr_from, bid.bid_addr, payload_hex, msg_valid, use_cursor + offer.addr_from, + bid.bid_addr, + payload_hex, + msg_valid, + use_cursor, + message_nets=bid.message_nets, ) self.addMessageLink( @@ -3892,6 +3698,7 @@ class BasicSwap(BaseApp, UIApp): msg_valid: int, bid_msg_ids, cursor, + message_nets, ) -> None: dleag_split_size_init, dleag_split_size = xmr_swap.getMsgSplitInfo() @@ -3911,7 +3718,12 @@ class BasicSwap(BaseApp, UIApp): str.format("{:02x}", MessageTypes.XMR_BID_SPLIT) + msg_bytes.hex() ) bid_msg_ids[num_sent] = self.sendMessage( - addr_from, addr_to, payload_hex, msg_valid, cursor + addr_from, + addr_to, + payload_hex, + msg_valid, + cursor, + message_nets=message_nets, ) num_sent += 1 sent_bytes += size_to_send @@ -3925,6 +3737,10 @@ class BasicSwap(BaseApp, UIApp): msg_buf.amount_from = bid.amount_to msg_buf.amount_to = bid.amount + # Set msg_buf.message_nets to let the remote node know what networks to respond on. + # bid.message_nets is a local field denoting the network/s to send to + msg_buf.message_nets = self.getMessageNetsString() + return msg_buf def sendADSBidIntentMessage(self, bid, offer, cursor) -> bytes: @@ -3934,6 +3750,8 @@ class BasicSwap(BaseApp, UIApp): payload_hex = ( str.format("{:02x}", MessageTypes.ADS_BID_LF) + msg_buf.to_bytes().hex() ) + + self.logD(LC.NET, f"sendADSBidIntentMessage offer.message_nets {offer.message_nets}, bid.message_nets {bid.message_nets}, msg_buf.message_nets {msg_buf.message_nets}") return self.sendMessage( bid.bid_addr, offer.addr_from, @@ -3942,6 +3760,7 @@ class BasicSwap(BaseApp, UIApp): cursor, timestamp=bid.created_at, deterministic=(False if bid.bid_id is None else True), + message_nets=bid.message_nets, ) def getXmrBidMessage(self, bid, xmr_swap, offer) -> XmrBidMessage: @@ -3963,6 +3782,10 @@ class BasicSwap(BaseApp, UIApp): else: msg_buf.kbsf_dleag = xmr_swap.kbsf_dleag + # Set msg_buf.message_nets to let the remote node know what networks to respond on. + # bid.message_nets is a local field denoting the network/s to send to + msg_buf.message_nets = self.getMessageNetsString() + return msg_buf def sendXmrBidMessage(self, bid, xmr_swap, offer, cursor) -> bytes: @@ -3977,6 +3800,7 @@ class BasicSwap(BaseApp, UIApp): ) msg_valid: int = max(self.SMSG_SECONDS_IN_HOUR, valid_for_seconds) + self.logD(LC.NET, f"sendXmrBidMessage offer.message_nets {offer.message_nets}, bid.message_nets {bid.message_nets}, msg_buf.message_nets {msg_buf.message_nets}") bid_msg_id = self.sendMessage( bid.bid_addr, offer.addr_from, @@ -3985,6 +3809,7 @@ class BasicSwap(BaseApp, UIApp): cursor, timestamp=bid.created_at, deterministic=(False if bid.bid_id is None else True), + message_nets=bid.message_nets, ) bid_id = bid_msg_id if bid.bid_id and bid_msg_id != bid.bid_id: @@ -4005,6 +3830,7 @@ class BasicSwap(BaseApp, UIApp): msg_valid, bid_msg_ids, cursor, + message_nets=bid.message_nets, ) for k, msg_id in bid_msg_ids.items(): self.addMessageLink( @@ -4037,6 +3863,10 @@ class BasicSwap(BaseApp, UIApp): if bid.proof_utxos: msg_buf.proof_utxos = bid.proof_utxos + # Set msg_buf.message_nets to let the remote node know what networks to respond on. + # bid.message_nets is a local field denoting the network/s to send to + msg_buf.message_nets = self.getMessageNetsString() + return msg_buf def sendBidMessage(self, bid, offer, cursor) -> bytes: @@ -4047,6 +3877,7 @@ class BasicSwap(BaseApp, UIApp): payload_hex = str.format("{:02x}", MessageTypes.BID) + msg_buf.to_bytes().hex() msg_valid: int = max(self.SMSG_SECONDS_IN_HOUR, valid_for_seconds) + self.logD(LC.NET, f"sendBidMessage offer.message_nets {offer.message_nets}, bid.message_nets {bid.message_nets}, msg_buf.message_nets {msg_buf.message_nets}") bid_msg_id = self.sendMessage( bid.bid_addr, offer.addr_from, @@ -4055,6 +3886,7 @@ class BasicSwap(BaseApp, UIApp): cursor, timestamp=bid.created_at, deterministic=(False if bid.bid_id is None else True), + message_nets=bid.message_nets, ) if bid.bid_id and bid_msg_id != bid.bid_id: self.log.warning( @@ -4202,6 +4034,9 @@ class BasicSwap(BaseApp, UIApp): valid_for_seconds, ) + bid_message_nets = self.selectMessageNetStringForConcept( + Concepts.OFFER, offer.offer_id, offer.message_nets, cursor + ) reverse_bid: bool = self.is_reverse_ads_bid(coin_from, coin_to) if reverse_bid: reversed_rate: int = ci_to.make_int(amount / amount_to, r=1) @@ -4223,6 +4058,7 @@ class BasicSwap(BaseApp, UIApp): bid_addr=bid_addr, was_sent=True, was_received=False, + message_nets=bid_message_nets, ) if route_id and route_established is False: @@ -4338,6 +4174,7 @@ class BasicSwap(BaseApp, UIApp): expire_at=bid_created_at + valid_for_seconds, bid_addr=bid_addr, was_sent=True, + message_nets=bid_message_nets, ) if route_id and route_established is False: @@ -4656,7 +4493,12 @@ class BasicSwap(BaseApp, UIApp): msg_valid: int = self.getAcceptBidMsgValidTime(bid) bid_msg_ids = {} bid_msg_ids[0] = self.sendMessage( - addr_from, addr_to, payload_hex, msg_valid, use_cursor + addr_from, + addr_to, + payload_hex, + msg_valid, + use_cursor, + message_nets=bid.message_nets, ) if ci_to.curve_type() == Curves.ed25519: @@ -4669,6 +4511,7 @@ class BasicSwap(BaseApp, UIApp): msg_valid, bid_msg_ids, use_cursor, + bid.message_nets, ) bid.setState(BidStates.BID_ACCEPTED) # ADS @@ -4805,7 +4648,12 @@ class BasicSwap(BaseApp, UIApp): msg_valid: int = self.getAcceptBidMsgValidTime(bid) bid_msg_ids = {} bid_msg_ids[0] = self.sendMessage( - addr_from, addr_to, payload_hex, msg_valid, use_cursor + addr_from, + addr_to, + payload_hex, + msg_valid, + use_cursor, + message_nets=bid.message_nets, ) if ci_to.curve_type() == Curves.ed25519: @@ -4818,6 +4666,7 @@ class BasicSwap(BaseApp, UIApp): msg_valid, bid_msg_ids, use_cursor, + message_nets=bid.message_nets, ) bid.setState(BidStates.BID_REQUEST_ACCEPTED) @@ -7638,7 +7487,7 @@ class BasicSwap(BaseApp, UIApp): self.closeDB(cursor) def processOffer(self, msg) -> None: - offer_bytes = bytes.fromhex(msg["hex"][2:-2]) + offer_bytes = self.getSmsgMsgBytes(msg) offer_data = OfferMessage(init_all=False) try: @@ -7696,6 +7545,8 @@ class BasicSwap(BaseApp, UIApp): self.log.debug("Ignoring expired offer.") return + _ = self.expandMessageNets(offer_data.message_nets) + offer_rate: int = ci_from.make_int( offer_data.amount_to / offer_data.amount_from, r=1 ) @@ -7787,6 +7638,7 @@ class BasicSwap(BaseApp, UIApp): if b"\xa0\x01" in offer_bytes else None ), + message_nets=offer_data.message_nets, ) offer.setState(OfferStates.OFFER_RECEIVED) self.add(offer, cursor) @@ -7812,17 +7664,25 @@ class BasicSwap(BaseApp, UIApp): self.add(xmr_offer, cursor) - self.notify(NT.OFFER_RECEIVED, {"offer_id": offer_id.hex()}, cursor) + self.notify(NT.OFFER_RECEIVED, {"offer_id": offer_id.hex()}, cursor) else: existing_offer.setState(OfferStates.OFFER_RECEIVED) self.add(existing_offer, cursor, upsert=True) + received_on_net: str = networkTypeToID(msg.get("type", "smsg")) + self.addMessageNetworkLink( + Concepts.OFFER, + offer_id, + MessageNetworkLinkTypes.RECEIVED_ON, + received_on_net, + cursor, + ) finally: self.closeDB(cursor) def processOfferRevoke(self, msg) -> None: ensure(msg["to"] == self.network_addr, "Message received on wrong address") - msg_bytes = bytes.fromhex(msg["hex"][2:-2]) + msg_bytes = self.getSmsgMsgBytes(msg) msg_data = OfferRevokeMessage(init_all=False) msg_data.from_bytes(msg_bytes) @@ -8067,7 +7927,7 @@ class BasicSwap(BaseApp, UIApp): if cursor is None: self.closeDB(use_cursor) - def addRecvBidNetworkLink(self, msg, bid_id): + def addRecvBidNetworkLink(self, msg, bid_id, cursor=None): if "chat_type" not in msg or msg["chat_type"] != "direct": return conn_id = msg["conn_id"] @@ -8075,9 +7935,8 @@ class BasicSwap(BaseApp, UIApp): "SELECT record_id, network_id, route_data FROM direct_message_routes" ) try: - cursor = self.openDB() - - rows = cursor.execute(query_str).fetchall() + use_cursor = self.openDB(cursor) + rows = use_cursor.execute(query_str).fetchall() for row in rows: record_id, network_id, route_data = row @@ -8091,15 +7950,16 @@ class BasicSwap(BaseApp, UIApp): linked_id=bid_id, created_at=self.getTime(), ) - self.add(message_route_link, cursor) + self.add(message_route_link, use_cursor) break finally: - self.closeDB(cursor) + if cursor is None: + self.closeDB(use_cursor) def processBid(self, msg) -> None: self.log.debug("Processing bid msg {}.".format(self.log.id(msg["msgid"]))) now: int = self.getTime() - bid_bytes = bytes.fromhex(msg["hex"][2:-2]) + bid_bytes = self.getSmsgMsgBytes(msg) bid_data = BidMessage(init_all=False) bid_data.from_bytes(bid_bytes) @@ -8128,6 +7988,10 @@ class BasicSwap(BaseApp, UIApp): bid_rate: int = ci_from.make_int(bid_data.amount_to / bid_data.amount, r=1) self.validateBidAmount(offer, bid_data.amount, bid_rate) + network_type: str = msg.get("msg_net", "smsg") + network_type_received_on_id: int = networkTypeToID(network_type) + bid_message_nets: str = self.selectMessageNetString([network_type_received_on_id, ], bid_data.message_nets) + self.logD(LC.NET, f"processBid offer.message_nets {offer.message_nets}, bid.message_nets {bid_message_nets}, bid_data.message_nets {bid_data.message_nets}") # TODO: Allow higher bids # assert (bid_data.rate != offer['data'].rate), 'Bid rate mismatch' @@ -8174,6 +8038,7 @@ class BasicSwap(BaseApp, UIApp): was_received=True, chain_a_height_start=ci_from.getChainHeight(), chain_b_height_start=ci_to.getChainHeight(), + message_nets=bid_message_nets, ) if len(bid_data.pkhash_buyer_to) > 0: @@ -8190,9 +8055,21 @@ class BasicSwap(BaseApp, UIApp): bid.proof_address = bid_data.proof_address bid.setState(BidStates.BID_RECEIVED) - self.addRecvBidNetworkLink(msg, bid_id) + try: + cursor = self.openDB() + self.addRecvBidNetworkLink(msg, bid_id, cursor) + self.saveBidInSession(bid_id, bid, cursor) + received_on_net: str = networkTypeToID(msg.get("type", "smsg")) + self.addMessageNetworkLink( + Concepts.BID, + offer_id, + MessageNetworkLinkTypes.RECEIVED_ON, + received_on_net, + cursor, + ) + finally: + self.closeDB(cursor) - self.saveBid(bid_id, bid) self.notify( NT.BID_RECEIVED, { @@ -8214,7 +8091,7 @@ class BasicSwap(BaseApp, UIApp): "Processing bid accepted msg {}".format(self.log.id(msg["msgid"])) ) now: int = self.getTime() - bid_accept_bytes = bytes.fromhex(msg["hex"][2:-2]) + bid_accept_bytes = self.getSmsgMsgBytes(msg) bid_accept_data = BidAcceptMessage(init_all=False) bid_accept_data.from_bytes(bid_accept_bytes) @@ -8527,7 +8404,7 @@ class BasicSwap(BaseApp, UIApp): "Processing adaptor-sig bid msg {}".format(self.log.id(msg["msgid"])) ) now: int = self.getTime() - bid_bytes = bytes.fromhex(msg["hex"][2:-2]) + bid_bytes = self.getSmsgMsgBytes(msg) bid_data = XmrBidMessage(init_all=False) bid_data.from_bytes(bid_bytes) @@ -8574,6 +8451,11 @@ class BasicSwap(BaseApp, UIApp): bid_id = bytes.fromhex(msg["msgid"]) + network_type: str = msg.get("msg_net", "smsg") + network_type_received_on_id: int = networkTypeToID(network_type) + bid_message_nets: str = self.selectMessageNetString([network_type_received_on_id, ], bid_data.message_nets) + self.logD(LC.NET, f"processXmrBid offer.message_nets {offer.message_nets}, bid.message_nets {bid_message_nets}, bid_data.message_nets {bid_data.message_nets}") + bid, xmr_swap = self.getXmrBid(bid_id) if bid is None: pk_from: bytes = getMsgPubkey(self, msg) @@ -8592,6 +8474,7 @@ class BasicSwap(BaseApp, UIApp): was_received=True, chain_a_height_start=ci_from.getChainHeight(), chain_b_height_start=ci_to.getChainHeight(), + message_nets=bid_message_nets, ) xmr_swap = XmrSwap( @@ -8619,19 +8502,26 @@ class BasicSwap(BaseApp, UIApp): bid.was_received = True bid.setState(BidStates.BID_RECEIVING) - self.addRecvBidNetworkLink(msg, bid_id) self.log.info( f"Receiving adaptor-sig bid {self.log.id(bid_id)} for offer {self.log.id(bid_data.offer_msg_id)}." ) - self.saveBid(bid_id, bid, xmr_swap=xmr_swap) - - if ci_to.curve_type() != Curves.ed25519: - try: - cursor = self.openDB() + try: + cursor = self.openDB() + self.addRecvBidNetworkLink(msg, bid_id, cursor) + self.saveBidInSession(bid_id, bid, cursor, xmr_swap) + received_on_net: str = networkTypeToID(msg.get("type", "smsg")) + self.addMessageNetworkLink( + Concepts.BID, + offer_id, + MessageNetworkLinkTypes.RECEIVED_ON, + received_on_net, + cursor, + ) + if ci_to.curve_type() != Curves.ed25519: self.receiveXmrBid(bid, cursor) - finally: - self.closeDB(cursor) + finally: + self.closeDB(cursor) def processXmrBidAccept(self, msg) -> None: # F receiving MSG1F and MSG2F @@ -8641,7 +8531,7 @@ class BasicSwap(BaseApp, UIApp): ) ) - msg_bytes = bytes.fromhex(msg["hex"][2:-2]) + msg_bytes = self.getSmsgMsgBytes(msg) msg_data = XmrBidAcceptMessage(init_all=False) msg_data.from_bytes(msg_bytes) @@ -8921,7 +8811,12 @@ class BasicSwap(BaseApp, UIApp): addr_send_from: str = offer.addr_from if reverse_bid else bid.bid_addr addr_send_to: str = bid.bid_addr if reverse_bid else offer.addr_from coin_a_lock_tx_sigs_l_msg_id = self.sendMessage( - addr_send_from, addr_send_to, payload_hex, msg_valid, cursor + addr_send_from, + addr_send_to, + payload_hex, + msg_valid, + cursor, + message_nets=bid.message_nets, ) self.addMessageLink( Concepts.BID, @@ -9290,7 +9185,12 @@ class BasicSwap(BaseApp, UIApp): addr_send_to: str = offer.addr_from if reverse_bid else bid.bid_addr msg_valid: int = self.getActiveBidMsgValidTime() coin_a_lock_release_msg_id = self.sendMessage( - addr_send_from, addr_send_to, payload_hex, msg_valid, cursor + addr_send_from, + addr_send_to, + payload_hex, + msg_valid, + cursor, + message_nets=bid.message_nets, ) self.addMessageLink( Concepts.BID, @@ -9713,7 +9613,12 @@ class BasicSwap(BaseApp, UIApp): msg_valid: int = self.getActiveBidMsgValidTime() xmr_swap.coin_a_lock_refund_spend_tx_msg_id = self.sendMessage( - addr_send_from, addr_send_to, payload_hex, msg_valid, cursor + addr_send_from, + addr_send_to, + payload_hex, + msg_valid, + cursor, + message_nets=bid.message_nets, ) bid.setState(BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX) @@ -9727,7 +9632,7 @@ class BasicSwap(BaseApp, UIApp): ) ) - msg_bytes = bytes.fromhex(msg["hex"][2:-2]) + msg_bytes = self.getSmsgMsgBytes(msg) msg_data = XmrBidLockTxSigsMessage(init_all=False) msg_data.from_bytes(msg_bytes) @@ -9868,7 +9773,7 @@ class BasicSwap(BaseApp, UIApp): ) ) - msg_bytes = bytes.fromhex(msg["hex"][2:-2]) + msg_bytes = self.getSmsgMsgBytes(msg) msg_data = XmrBidLockSpendTxMessage(init_all=False) msg_data.from_bytes(msg_bytes) @@ -9931,7 +9836,7 @@ class BasicSwap(BaseApp, UIApp): def processXmrSplitMessage(self, msg) -> None: self.log.debug("Processing xmr split msg {}".format(self.log.id(msg["msgid"]))) now: int = self.getTime() - msg_bytes = bytes.fromhex(msg["hex"][2:-2]) + msg_bytes = self.getSmsgMsgBytes(msg) msg_data = XmrSplitMessage(init_all=False) msg_data.from_bytes(msg_bytes) @@ -9981,7 +9886,7 @@ class BasicSwap(BaseApp, UIApp): ) ) - msg_bytes = bytes.fromhex(msg["hex"][2:-2]) + msg_bytes = self.getSmsgMsgBytes(msg) msg_data = XmrBidLockReleaseMessage(init_all=False) msg_data.from_bytes(msg_bytes) @@ -10055,7 +9960,7 @@ class BasicSwap(BaseApp, UIApp): ) now: int = self.getTime() - bid_bytes = bytes.fromhex(msg["hex"][2:-2]) + bid_bytes = self.getSmsgMsgBytes(msg) bid_data = ADSBidIntentMessage(init_all=False) bid_data.from_bytes(bid_bytes) @@ -10072,6 +9977,8 @@ class BasicSwap(BaseApp, UIApp): ensure(offer.swap_type == SwapTypes.XMR_SWAP, "Bid/offer swap type mismatch") ensure(xmr_offer, f"Adaptor-sig offer not found: {self.log.id(offer_id)}.") + _ = self.expandMessageNets(bid_data.message_nets) + ci_from = self.ci(offer.coin_to) ci_to = self.ci(offer.coin_from) @@ -10091,6 +9998,8 @@ class BasicSwap(BaseApp, UIApp): ) self.validateBidAmount(offer, bid_data.amount_from, bid_rate) + _ = self.expandMessageNets(bid_data.message_nets) + bid_id = bytes.fromhex(msg["msgid"]) bid, xmr_swap = self.getXmrBid(bid_id) @@ -10112,6 +10021,7 @@ class BasicSwap(BaseApp, UIApp): was_received=True, chain_a_height_start=ci_from.getChainHeight(), chain_b_height_start=ci_to.getChainHeight(), + message_nets=bid_data.message_nets, ) xmr_swap = XmrSwap( @@ -10173,7 +10083,7 @@ class BasicSwap(BaseApp, UIApp): ) ) - msg_bytes = bytes.fromhex(msg["hex"][2:-2]) + msg_bytes = self.getSmsgMsgBytes(msg) msg_data = ADSBidIntentAcceptMessage(init_all=False) msg_data.from_bytes(msg_bytes) @@ -10249,7 +10159,7 @@ class BasicSwap(BaseApp, UIApp): self.log.debug( "Processing connection request msg {}.".format(self.log.id(msg["msgid"])) ) - msg_bytes = bytes.fromhex(msg["hex"][2:-2]) + msg_bytes = self.getSmsgMsgBytes(msg) msg_data = ConnectReqMessage(init_all=False) msg_data.from_bytes(msg_bytes) @@ -10307,35 +10217,6 @@ class BasicSwap(BaseApp, UIApp): finally: self.closeDB(cursor) - def routeEstablishedForBid(self, bid_id: bytes, cursor): - self.log.info(f"Route established for bid {self.log.id(bid_id)}") - - bid, offer = self.getBidAndOffer(bid_id, cursor) - ensure(bid, "Bid not found") - ensure(offer, "Offer not found") - - coin_from = Coins(offer.coin_from) - coin_to = Coins(offer.coin_to) - - if offer.swap_type == SwapTypes.XMR_SWAP: - xmr_swap = self.queryOne(XmrSwap, cursor, {"bid_id": bid.bid_id}) - - reverse_bid: bool = self.is_reverse_ads_bid(coin_from, coin_to) - if reverse_bid: - bid_id = self.sendADSBidIntentMessage(bid, offer, cursor) - bid.setState(BidStates.BID_REQUEST_SENT) - self.log.info(f"Sent ADS_BID_LF {self.logIDB(xmr_swap.bid_id)}") - else: - bid_id = self.sendXmrBidMessage(bid, xmr_swap, offer, cursor) - bid.setState(BidStates.BID_SENT) - self.log.info(f"Sent XMR_BID_FL {self.logIDB(xmr_swap.bid_id)}") - self.saveBidInSession(bid.bid_id, bid, cursor, xmr_swap) - else: - bid_id = self.sendBidMessage(bid, offer, cursor) - bid.setState(BidStates.BID_SENT) - self.log.info(f"Sent BID {self.log.id(bid_id)}") - self.saveBidInSession(bid_id, bid, cursor) - def processContactConnected(self, event_data) -> None: contact_data = getResponseData(event_data, "contact") connId = contact_data["activeConn"]["connId"] @@ -10399,48 +10280,34 @@ class BasicSwap(BaseApp, UIApp): finally: self.closeDB(cursor) - def processContactDisconnected(self, event_data) -> None: - net_i = self.getActiveNetworkInterface(2) - connId = getResponseData(event_data, "contact")["activeConn"]["connId"] - self.log.info(f"Direct message route disconnected, connId: {connId}") - closeSimplexChat(self, net_i, connId) + def routeEstablishedForBid(self, bid_id: bytes, cursor): + self.log.info(f"Route established for bid {self.log.id(bid_id)}") - query_str = "SELECT record_id, network_id, smsg_addr_local, smsg_addr_remote, route_data FROM direct_message_routes" - try: - cursor = self.openDB() + bid, offer = self.getBidAndOffer(bid_id, cursor) + ensure(bid, "Bid not found") + ensure(offer, "Offer not found") - rows = cursor.execute(query_str).fetchall() + coin_from = Coins(offer.coin_from) + coin_to = Coins(offer.coin_to) - for row in rows: - record_id, network_id, smsg_addr_local, smsg_addr_remote, route_data = ( - row - ) - route_data = json.loads(route_data.decode("UTF-8")) + if offer.swap_type == SwapTypes.XMR_SWAP: + xmr_swap = self.queryOne(XmrSwap, cursor, {"bid_id": bid.bid_id}) - if connId == route_data["pccConnId"]: - self.log.debug(f"Removing direct message route: {record_id}.") - cursor.execute( - "DELETE FROM direct_message_routes WHERE record_id = :record_id ", - {"record_id": record_id}, - ) - break - finally: - self.closeDB(cursor) - - def closeMessageRoute(self, record_id, network_id, route_data, cursor): - net_i = self.getActiveNetworkInterface(2) - - connId = route_data["pccConnId"] - - self.log.info(f"Closing Simplex chat, id: {connId}") - closeSimplexChat(self, net_i, connId) - - self.log.debug(f"Removing direct message route: {record_id}.") - cursor.execute( - "DELETE FROM direct_message_routes WHERE record_id = :record_id ", - {"record_id": record_id}, - ) - self.commitDB() + reverse_bid: bool = self.is_reverse_ads_bid(coin_from, coin_to) + if reverse_bid: + bid_id = self.sendADSBidIntentMessage(bid, offer, cursor) + bid.setState(BidStates.BID_REQUEST_SENT) + self.log.info(f"Sent ADS_BID_LF {self.logIDB(xmr_swap.bid_id)}") + else: + bid_id = self.sendXmrBidMessage(bid, xmr_swap, offer, cursor) + bid.setState(BidStates.BID_SENT) + self.log.info(f"Sent XMR_BID_FL {self.logIDB(xmr_swap.bid_id)}") + self.saveBidInSession(bid.bid_id, bid, cursor, xmr_swap) + else: + bid_id = self.sendBidMessage(bid, offer, cursor) + bid.setState(BidStates.BID_SENT) + self.log.info(f"Sent BID {self.log.id(bid_id)}") + self.saveBidInSession(bid_id, bid, cursor) def processMsg(self, msg) -> None: try: @@ -10452,6 +10319,14 @@ class BasicSwap(BaseApp, UIApp): ) raise ValueError("Invalid msg received {}.".format(msg["msgid"])) return + + network_type = msg.get("msg_net", "smsg") + if network_type == "smsg": + self.num_smsg_messages_received += 1 + elif network_type == "simplex": + pass # Counted earlier, split between group and direct + else: + self.log.warning(f"processMsg unknown network: {network_type}") msg_type = int(msg["hex"][:2], 16) if msg_type == MessageTypes.OFFER: @@ -10481,6 +10356,10 @@ class BasicSwap(BaseApp, UIApp): self.processADSBidReversedAccept(msg) elif msg_type == MessageTypes.CONNECT_REQ: self.processConnectRequest(msg) + elif msg_type == MessageTypes.PORTAL_OFFER: + self.processPortalOffer(msg) + elif msg_type == MessageTypes.PORTAL_SEND: + self.processPortalMessage(msg) except InactiveCoin as ex: self.log.debug( @@ -10498,29 +10377,6 @@ class BasicSwap(BaseApp, UIApp): None, ) - def processZmqSmsg(self) -> None: - message = self.zmqSubscriber.recv() - # Clear - _ = self.zmqSubscriber.recv() - - if message[0] == 3: # Paid smsg - return # TODO: Switch to paid? - - msg_id = message[2:] - options = {"encoding": "hex", "setread": True} - num_tries = 5 - for i in range(num_tries + 1): - try: - msg = self.callrpc("smsg", [msg_id.hex(), options]) - break - except Exception as e: - if "Unknown message id" in str(e) and i < num_tries: - self.delay_event.wait(1) - else: - raise e - - self.processMsg(msg) - def processZmqHashwtx(self) -> None: self.zmqSubscriber.recv() @@ -10686,20 +10542,9 @@ class BasicSwap(BaseApp, UIApp): except Exception as e: self.logException(f"smsg zmq {e}") - if self._poll_smsg: - now: int = self.getTime() - if now - self._last_checked_smsg >= self.check_smsg_seconds: - self._last_checked_smsg = now - options = {"encoding": "hex", "setread": True} - msgs = self.callrpc("smsginbox", ["unread", "", options]) - for msg in msgs["messages"]: - self.processMsg(msg) + self.updateNetwork() try: - for network in self.active_networks: - if network["type"] == "simplex": - readSimplexMsgs(self, network) - # TODO: Wait for blocks / txns, would need to check multiple coins now: int = self.getTime() self.expireBidsAndOffers(now) @@ -12196,15 +12041,6 @@ class BasicSwap(BaseApp, UIApp): finally: self.closeDB(cursor, commit=False) - def add_connection(self, host, port, peer_pubkey): - self.log.info(f"add_connection {host} {port} {peer_pubkey.hex()}.") - self._network.add_connection(host, port, peer_pubkey) - - def get_network_info(self): - if not self._network: - return {"Error": "Not Initialised"} - return self._network.get_info() - def getLockedState(self): if self._is_encrypted is None or self._is_locked is None: self._is_encrypted, self._is_locked = self.ci( @@ -12445,24 +12281,3 @@ class BasicSwap(BaseApp, UIApp): return rv_array return rv - - def setMsgSplitInfo(self, xmr_swap) -> None: - for network in self.active_networks: - if network["type"] == "simplex": - xmr_swap.msg_split_info = "9000:11000" - return - xmr_swap.msg_split_info = "16000:17000" - - def setFilters(self, prefix, filters): - key_str = "saved_filters_" + prefix - value_str = json.dumps(filters) - self.setStringKV(key_str, value_str) - - def getFilters(self, prefix): - key_str = "saved_filters_" + prefix - value_str = self.getStringKV(key_str) - return None if not value_str else json.loads(value_str) - - def clearFilters(self, prefix) -> None: - key_str = "saved_filters_" + prefix - self.clearStringKV(key_str) diff --git a/basicswap/basicswap_util.py b/basicswap/basicswap_util.py index 0687a90..20b2f90 100644 --- a/basicswap/basicswap_util.py +++ b/basicswap/basicswap_util.py @@ -41,6 +41,11 @@ class MessageNetworks(IntEnum): SIMPLEX = auto() +class MessageNetworkLinkTypes(IntEnum): + RECEIVED_ON = auto() + SENT_ON = auto() + + class MessageTypes(IntEnum): OFFER = auto() BID = auto() @@ -59,6 +64,8 @@ class MessageTypes(IntEnum): ADS_BID_ACCEPT_FL = auto() CONNECT_REQ = auto() + PORTAL_OFFER = auto() + PORTAL_SEND = auto() class AddressTypes(IntEnum): @@ -66,6 +73,8 @@ class AddressTypes(IntEnum): BID = auto() RECV_OFFER = auto() SEND_OFFER = auto() + PORTAL_LOCAL = auto() + PORTAL = auto() class SwapTypes(IntEnum): @@ -395,15 +404,14 @@ def strTxType(tx_type): def strAddressType(addr_type): - if addr_type == AddressTypes.OFFER: - return "Offer" - if addr_type == AddressTypes.BID: - return "Bid" - if addr_type == AddressTypes.RECV_OFFER: - return "Offer recv" - if addr_type == AddressTypes.SEND_OFFER: - return "Offer send" - return "Unknown" + return { + AddressTypes.OFFER: "Offer", + AddressTypes.BID: "Bid", + AddressTypes.RECV_OFFER: "Offer recv", + AddressTypes.SEND_OFFER: "Offer send", + AddressTypes.PORTAL_LOCAL: "Portal (local)", + AddressTypes.PORTAL: "Portal", + }.get(addr_type, "Unknown") def getLockName(lock_type): diff --git a/basicswap/bin/run.py b/basicswap/bin/run.py index 649d3e5..a972f8c 100755 --- a/basicswap/bin/run.py +++ b/basicswap/bin/run.py @@ -415,7 +415,7 @@ def runClient( for network in settings.get("networks", []): if network.get("enabled", True) is False: continue - network_type = network.get("type", "unknown") + network_type: str = network.get("type", "unknown") if network_type == "simplex": simplex_dir = os.path.join(data_dir, "simplex") diff --git a/basicswap/db.py b/basicswap/db.py index 25e2e94..bf374c5 100644 --- a/basicswap/db.py +++ b/basicswap/db.py @@ -13,7 +13,7 @@ from enum import IntEnum, auto from typing import Optional -CURRENT_DB_VERSION = 29 +CURRENT_DB_VERSION = 30 CURRENT_DB_DATA_VERSION = 6 @@ -185,6 +185,7 @@ class Offer(Table): amount_negotiable = Column("bool") rate_negotiable = Column("bool") auto_accept_type = Column("integer") + message_nets = Column("string") # Local fields auto_accept_bids = Column("bool") @@ -233,6 +234,7 @@ class Bid(Table): rate = Column("integer") pkhash_seller = Column("blob") + message_nets = Column("string") initiate_txn_redeem = Column("blob") initiate_txn_refund = Column("blob") @@ -381,6 +383,8 @@ class SmsgAddress(Table): use_type = Column("integer") note = Column("string") + index = Index("smsgaddresses_address_index", "addr") + class Action(Table): __tablename__ = "actions" @@ -676,6 +680,20 @@ class MessageNetworks(Table): created_at = Column("integer") +class MessageNetworkLink(Table): + __tablename__ = "message_network_links" + + record_id = Column("integer", primary_key=True, autoincrement=True) + active_ind = Column("integer") + + linked_type = Column("integer") + linked_id = Column("blob") + + network_id = Column("string") + link_type = Column("integer") # MessageNetworkLinkTypes + created_at = Column("integer") + + class DirectMessageRoute(Table): __tablename__ = "direct_message_routes" @@ -694,6 +712,7 @@ class DirectMessageRoute(Table): class DirectMessageRouteLink(Table): __tablename__ = "direct_message_route_links" + record_id = Column("integer", primary_key=True, autoincrement=True) active_ind = Column("integer") direct_message_route_id = Column("integer") diff --git a/basicswap/db_util.py b/basicswap/db_util.py index 73ef7c4..2307c2a 100644 --- a/basicswap/db_util.py +++ b/basicswap/db_util.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2023-2024 The Basicswap Developers +# Copyright (c) 2023-2025 The Basicswap Developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -26,97 +26,46 @@ def remove_expired_data(self, time_offset: int = 0): ) for offer_row in offer_rows: num_offers += 1 + offer_query_data = { + "type_ind": int(Concepts.OFFER), + "offer_id": offer_row[0], + } bid_rows = cursor.execute( "SELECT bids.bid_id FROM bids WHERE bids.offer_id = :offer_id", - {"offer_id": offer_row[0]}, + offer_query_data, ) for bid_row in bid_rows: num_bids += 1 - cursor.execute( + bid_query_data = {"type_ind": int(Concepts.BID), "bid_id": bid_row[0]} + for query_str in [ "DELETE FROM transactions WHERE transactions.bid_id = :bid_id", - {"bid_id": bid_row[0]}, - ) - cursor.execute( "DELETE FROM eventlog WHERE eventlog.linked_type = :type_ind AND eventlog.linked_id = :bid_id", - {"type_ind": int(Concepts.BID), "bid_id": bid_row[0]}, - ) - cursor.execute( "DELETE FROM automationlinks WHERE automationlinks.linked_type = :type_ind AND automationlinks.linked_id = :bid_id", - {"type_ind": int(Concepts.BID), "bid_id": bid_row[0]}, - ) - cursor.execute( "DELETE FROM prefunded_transactions WHERE prefunded_transactions.linked_type = :type_ind AND prefunded_transactions.linked_id = :bid_id", - {"type_ind": int(Concepts.BID), "bid_id": bid_row[0]}, - ) - cursor.execute( "DELETE FROM history WHERE history.concept_type = :type_ind AND history.concept_id = :bid_id", - {"type_ind": int(Concepts.BID), "bid_id": bid_row[0]}, - ) - cursor.execute( "DELETE FROM xmr_swaps WHERE xmr_swaps.bid_id = :bid_id", - {"bid_id": bid_row[0]}, - ) - cursor.execute( "DELETE FROM actions WHERE actions.linked_id = :bid_id", - {"bid_id": bid_row[0]}, - ) - cursor.execute( "DELETE FROM addresspool WHERE addresspool.bid_id = :bid_id", - {"bid_id": bid_row[0]}, - ) - cursor.execute( "DELETE FROM xmr_split_data WHERE xmr_split_data.bid_id = :bid_id", - {"bid_id": bid_row[0]}, - ) - cursor.execute( "DELETE FROM bids WHERE bids.bid_id = :bid_id", - {"bid_id": bid_row[0]}, - ) - cursor.execute( - "DELETE FROM message_links WHERE linked_type = :type_ind AND linked_id = :linked_id", - {"type_ind": int(Concepts.BID), "linked_id": bid_row[0]}, - ) - cursor.execute( - "DELETE FROM direct_message_route_links WHERE linked_type = :type_ind AND linked_id = :linked_id", - {"type_ind": int(Concepts.BID), "linked_id": bid_row[0]}, - ) - - cursor.execute( + "DELETE FROM message_links WHERE linked_type = :type_ind AND linked_id = :bid_id", + "DELETE FROM direct_message_route_links WHERE linked_type = :type_ind AND linked_id = :bid_id", + "DELETE FROM message_network_links WHERE linked_type = :type_ind AND linked_id = :bid_id", + ]: + cursor.execute(query_str, bid_query_data) + for query_str in [ "DELETE FROM eventlog WHERE eventlog.linked_type = :type_ind AND eventlog.linked_id = :offer_id", - {"type_ind": int(Concepts.OFFER), "offer_id": offer_row[0]}, - ) - cursor.execute( "DELETE FROM automationlinks WHERE automationlinks.linked_type = :type_ind AND automationlinks.linked_id = :offer_id", - {"type_ind": int(Concepts.OFFER), "offer_id": offer_row[0]}, - ) - cursor.execute( "DELETE FROM prefunded_transactions WHERE prefunded_transactions.linked_type = :type_ind AND prefunded_transactions.linked_id = :offer_id", - {"type_ind": int(Concepts.OFFER), "offer_id": offer_row[0]}, - ) - cursor.execute( "DELETE FROM history WHERE history.concept_type = :type_ind AND history.concept_id = :offer_id", - {"type_ind": int(Concepts.OFFER), "offer_id": offer_row[0]}, - ) - cursor.execute( "DELETE FROM xmr_offers WHERE xmr_offers.offer_id = :offer_id", - {"offer_id": offer_row[0]}, - ) - cursor.execute( "DELETE FROM sentoffers WHERE sentoffers.offer_id = :offer_id", - {"offer_id": offer_row[0]}, - ) - cursor.execute( "DELETE FROM actions WHERE actions.linked_id = :offer_id", - {"offer_id": offer_row[0]}, - ) - cursor.execute( "DELETE FROM offers WHERE offers.offer_id = :offer_id", - {"offer_id": offer_row[0]}, - ) - cursor.execute( "DELETE FROM message_links WHERE linked_type = :type_ind AND linked_id = :offer_id", - {"type_ind": int(Concepts.OFFER), "offer_id": offer_row[0]}, - ) + "DELETE FROM message_network_links WHERE linked_type = :type_ind AND linked_id = :offer_id", + ]: + cursor.execute(query_str, offer_query_data) if num_offers > 0 or num_bids > 0: self.log.info( diff --git a/basicswap/messages_npb.py b/basicswap/messages_npb.py index 1c5dafd..c513714 100644 --- a/basicswap/messages_npb.py +++ b/basicswap/messages_npb.py @@ -144,7 +144,8 @@ class OfferMessage(NonProtobufClass): 17: ("amount_negotiable", NPBW_INT, NPBF_BOOL), 18: ("rate_negotiable", NPBW_INT, NPBF_BOOL), 19: ("proof_utxos", NPBW_BYTES, 0), - 20: ("auto_accept_type", 0, 0), + 20: ("auto_accept_type", NPBW_INT, 0), + 21: ("message_nets", NPBW_BYTES, NPBF_STR), } @@ -160,6 +161,7 @@ class BidMessage(NonProtobufClass): 8: ("proof_signature", NPBW_BYTES, NPBF_STR), 9: ("proof_utxos", NPBW_BYTES, 0), 10: ("pkhash_buyer_to", NPBW_BYTES, 0), + 11: ("message_nets", NPBW_BYTES, NPBF_STR), } @@ -199,6 +201,7 @@ class XmrBidMessage(NonProtobufClass): 7: ("kbvf", NPBW_BYTES, 0), 8: ("kbsf_dleag", NPBW_BYTES, 0), 9: ("dest_af", NPBW_BYTES, 0), + 10: ("message_nets", NPBW_BYTES, NPBF_STR), } @@ -261,6 +264,7 @@ class ADSBidIntentMessage(NonProtobufClass): 3: ("time_valid", NPBW_INT, 0), 4: ("amount_from", NPBW_INT, 0), 5: ("amount_to", NPBW_INT, 0), + 6: ("message_nets", NPBW_BYTES, NPBF_STR), } @@ -282,3 +286,21 @@ class ConnectReqMessage(NonProtobufClass): 3: ("request_type", NPBW_INT, 0), 4: ("request_data", NPBW_BYTES, 0), } + + +class MessagePortalOffer(NonProtobufClass): + _map = { + 1: ("network_type_from", NPBW_INT, 0), + 2: ("network_type_to", NPBW_INT, 0), + 3: ("portal_address_from", NPBW_BYTES, 0), + 4: ("portal_address_to", NPBW_BYTES, 0), + 5: ("time_valid", NPBW_INT, 0), + 6: ("smsg_difficulty", NPBW_INT, 0), + } + + +class MessagePortalSend(NonProtobufClass): + _map = { + 1: ("forward_address", NPBW_BYTES, 0), # pubkey, 33 bytes + 2: ("message_bytes", NPBW_BYTES, 0), + } diff --git a/basicswap/network/bsx_network.py b/basicswap/network/bsx_network.py new file mode 100644 index 0000000..9887adc --- /dev/null +++ b/basicswap/network/bsx_network.py @@ -0,0 +1,975 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 The Basicswap developers +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. + +import json +import random +import zmq + +from basicswap.basicswap_util import ( + AddressTypes, + MessageNetworkLinkTypes, + MessageNetworks, + MessageTypes, +) +from basicswap.db import DirectMessageRoute +from basicswap.messages_npb import ( + MessagePortalOffer, + MessagePortalSend, +) +from basicswap.network.simplex import ( + closeSimplexChat, + encryptMsg, + forwardSimplexMsg, + getResponseData, + initialiseSimplexNetwork, + readSimplexMsgs, + sendSimplexMsg, +) +from basicswap.util import ensure +from basicswap.util.logging import LogCategories as LC +from basicswap.util.smsg import smsgGetID + + +class NetworkPortal: + __slots__ = ( + "time_start", + "time_valid", + "network_from", + "network_to", + "address_from", + "address_to", + "smsg_difficulty", + "num_refreshes", + "messages_sent", + "responses_seen", + "time_last_used", + "num_issues", + ) + + def __init__( + self, time_start, time_valid, network_from, network_to, address_from, address_to + ): + self.time_start = time_start + self.time_valid = time_valid + self.network_from = network_from + self.network_to = network_to + self.address_from = address_from + self.address_to = address_to + + self.smsg_difficulty = 0x1EFFFFFF + + self.num_refreshes = 0 + self.messages_sent = 0 + self.responses_seen = 0 + self.time_last_used = 0 + self.num_issues = 0 + + +def networkTypeToID(type: str) -> int: + # TODO: remove + if type == "smsg": + return MessageNetworks.SMSG + elif type == "simplex": + return MessageNetworks.SIMPLEX + raise RuntimeError(f"Unknown message type: {type}") + + +def networkIDToType(id: int) -> str: + if id == MessageNetworks.SMSG: + return "smsg" + elif id == MessageNetworks.SIMPLEX: + return "simplex" + raise RuntimeError(f"Unknown message network id: {id}") + + +class BSXNetwork: + _read_zmq_queue: bool = True + + def __init__(self, data_dir, settings, **kwargs): + self._bridge_networks = self.settings.get("bridge_networks", False) + self._use_direct_message_routes = True + self._smsg_plaintext_version = self.settings.get("smsg_plaintext_version", 1) + self._smsg_add_to_outbox = self.settings.get("smsg_add_to_outbox", False) + self._have_smsg_rpc = False # Set in startNetworks + + self._expire_message_routes_after = self._expire_db_records_after = ( + self.get_int_setting( + "expire_message_routes_after", 48 * 3600, 10 * 60, 31 * 86400 + ) + ) # Seconds + self.check_smsg_seconds = self.get_int_setting( + "check_smsg_seconds", 10, 1, 10 * 60 + ) + self._last_checked_smsg = 0 + self.check_bridges_seconds = self.get_int_setting( + "check_bridges_seconds", 10, 1, 10 * 60 + ) + self._last_checked_bridges = 0 + + self._zmq_queue_enabled = self.settings.get("zmq_queue_enabled", True) + self._poll_smsg = self.settings.get("poll_smsg", False) + self.zmqContext = None + self.zmqSubscriber = None + + self.SMSG_SECONDS_IN_HOUR = ( + 60 * 60 + ) # Note: Set smsgsregtestadjust=0 for regtest + + self.num_group_simplex_messages_received = 0 + self.num_group_simplex_messages_sent = 0 + self.num_direct_simplex_messages_received = 0 + self.num_direct_simplex_messages_sent = 0 + + self.num_smsg_messages_received = 0 + self.num_smsg_messages_sent = 0 + + self.known_portals = {} + self.own_portals = {} + + super().__init__(data_dir=data_dir, settings=settings, **kwargs) + + def finalise(self): + if self._network: + self._network.stopNetwork() + self._network = None + + if self.zmqContext: + self.zmqContext.destroy() + + def startNetworks(self): + if self._zmq_queue_enabled and self._poll_smsg: + self.log.warning("SMSG polling and zmq listener enabled.") + self.active_networks = [] + network_config_list = self.settings.get("networks", []) + if len(network_config_list) < 1: + network_config_list = [{"type": "smsg", "enabled": True}] + + have_smsg: bool = False + for network in network_config_list: + if network.get("enabled", True) is False: + continue + if network["type"] == "smsg": + have_smsg = True + add_network = {"type": "smsg"} + if "bridged" in network: + add_network["bridged"] = network["bridged"] + self.active_networks.append(add_network) + elif network["type"] == "simplex": + initialiseSimplexNetwork(self, network) + + if have_smsg: + self._have_smsg_rpc = True + if self._zmq_queue_enabled: + self.zmqContext = zmq.Context() + self.zmqSubscriber = self.zmqContext.socket(zmq.SUB) + + self.zmqSubscriber.connect( + self.settings["zmqhost"] + ":" + str(self.settings["zmqport"]) + ) + self.zmqSubscriber.setsockopt_string(zmq.SUBSCRIBE, "smsg") + self.zmqSubscriber.setsockopt_string(zmq.SUBSCRIBE, "hashwtx") + + ro = self.callrpc("smsglocalkeys") + found = False + for k in ro["smsg_keys"]: + if k["address"] == self.network_addr: + found = True + break + if not found: + self.log.info("Importing network key to SMSG") + self.callrpc( + "smsgimportprivkey", [self.network_key, "basicswap offers"] + ) + ro = self.callrpc("smsglocalkeys", ["anon", "-", self.network_addr]) + ensure(ro["result"] == "Success.", "smsglocalkeys failed") + else: + now = self.getTime() + try: + cursor = self.openDB() + query: str = "SELECT addr_id FROM smsgaddresses WHERE addr = :addr" + addresses = cursor.execute( + query, {"addr": self.network_addr} + ).fetchall() + if len(addresses) < 1: + query: str = ( + "INSERT INTO smsgaddresses (active_ind, created_at, addr, pubkey, use_type) VALUES (:active_ind, :created_at, :addr, :pubkey, :use_type)" + ) + cursor.execute( + query, + { + "active_ind": 1, + "created_at": now, + "addr": self.network_addr, + "pubkey": self.network_pubkey, + "use_type": AddressTypes.OFFER, + }, + ) + finally: + self.closeDB(cursor) + + # TODO: Ensure smsg is enabled for the active wallet. + + if self._smsg_plaintext_version >= 2: + self.log.debug("TODO: disable addReceivedPubkeys") + # ro = self.callrpc("smsgoptions", ["set", "addReceivedPubkeys", False]) + # self.log.debug("smsgoptions {ro}") + + def add_connection(self, host, port, peer_pubkey): + self.log.info(f"add_connection {host} {port} {peer_pubkey.hex()}.") + self._network.add_connection(host, port, peer_pubkey) + + def get_network_info(self): + if not self._network: + return {"Error": "Not Initialised"} + return self._network.get_info() + + def addMessageNetworkLink( + self, linked_type, linked_id, link_type, network_id, cursor + ) -> None: + now: int = self.getTime() + query = """INSERT INTO message_network_links + (active_ind, linked_type, linked_id, link_type, network_id, created_at) + VALUES + (1, :linked_type, :linked_id, :link_type, :network_id, :created_at)""" + cursor.execute( + query, + { + "linked_type": linked_type, + "linked_id": linked_id, + "link_type": link_type, + "network_id": network_id, + "created_at": now, + }, + ) + + def getActiveNetwork(self, network_id: int): + for network in self.active_networks: + if networkTypeToID(network["type"]) == network_id: + return network + raise RuntimeError("Network not found.") + + def getActiveNetworkInterface(self, network_id: int): + network = self.getActiveNetwork(network_id) + return network["ws_thread"] + + def getMessageNetsString(self, with_bridged: bool = False) -> str: + if self._smsg_plaintext_version < 2: + return "" + active_networks_set = set() + bridged_networks_set = set() + for network in self.active_networks: + network_type = network.get("type", "smsg") + active_networks_set.add(network_type) + if with_bridged is False: + continue + for bridged_network in network.get("bridged", []): + bridged_network_type = bridged_network.get("type", "smsg") + bridged_networks_set.add(bridged_network_type) + all_networks = active_networks_set | bridged_networks_set + return ",".join(all_networks) + + def selectMessageNetString(self, received_on_network_ids, remote_message_nets: str) -> str: + if self._smsg_plaintext_version < 2: + return "" + active_networks_set = set() + bridged_networks_set = set() + for network in self.active_networks: + network_type: str = network.get("type", "smsg") + active_networks_set.add(networkTypeToID(network_type)) + for bridged_network in network.get("bridged", []): + bridged_network_type = bridged_network.get("type", "smsg") + bridged_networks_set.add(networkTypeToID(bridged_network_type)) + remote_network_ids = self.expandMessageNets(remote_message_nets) + + if len(remote_network_ids) < 1 and len(received_on_network_ids) < 1: + return networkIDToType(random.choice(tuple(active_networks_set))) + + # Choose which network to respond on + # Pick the received on network if it's in the local node's active networks and the list of remote node's networks + # else prefer a network the local node has active + for received_on_id in received_on_network_ids: + if received_on_id in active_networks_set and received_on_id in remote_network_ids: + return networkIDToType(received_on_id) + for local_net_id in active_networks_set: + if local_net_id in remote_network_ids: + return networkIDToType(local_net_id) + for local_net_id in bridged_networks_set: + if local_net_id in remote_network_ids: + return networkIDToType(local_net_id) + raise RuntimeError("Unable to select network to respond on") + + def selectMessageNetStringForConcept( + self, linked_type: int, linked_id: bytes, remote_message_nets: str, cursor + ) -> str: + received_on_network_ids = set() + query = """SELECT network_id FROM message_network_links + WHERE linked_type = :linked_type AND linked_id = :linked_id AND link_type = :link_type""" + rows = cursor.execute( + query, + { + "linked_type": linked_type, + "linked_id": linked_id, + "link_type": MessageNetworkLinkTypes.RECEIVED_ON, + }, + ) + for row in rows: + # TODO: rank networks + network_id = row + received_on_network_ids.add(network_id) + + return self.selectMessageNetString(received_on_network_ids, remote_message_nets) + + def expandMessageNets(self, message_nets: str) -> list: + if message_nets is None or len(message_nets) < 1: + return [] + if len(message_nets) > 256: + raise ValueError("message_nets string is too large") + rv = [] + for network_string in message_nets.split(","): + try: + rv.append(networkTypeToID(network_string)) + except Exception as e: # noqa: F841 + self.log.debug(f"Unknown message_net {network_string}") + return rv + + def getMessageRoute( + self, network_id: int, address_from: str, address_to: str, cursor=None + ): + try: + use_cursor = self.openDB(cursor) + route = self.queryOne( + DirectMessageRoute, + use_cursor, + { + "network_id": network_id, + "smsg_addr_local": address_from, + "smsg_addr_remote": address_to, + }, + ) + return route + finally: + if cursor is None: + self.closeDB(use_cursor) + + def setMsgSplitInfo(self, xmr_swap) -> None: + for network in self.active_networks: + if network["type"] == "simplex": + xmr_swap.msg_split_info = "9000:11000" + return + for bridged_network in network.get("bridged", []): + if bridged_network["type"] == "simplex": + xmr_swap.msg_split_info = "9000:11000" + return + xmr_swap.msg_split_info = "16000:17000" + + def sendMessage( + self, + addr_from: str, + addr_to: str, + payload_hex: bytes, + msg_valid: int, + cursor, + linked_type=None, + linked_id=None, + timestamp=None, + deterministic=False, + message_nets=None, # None -> all, else + ) -> bytes: + message_id: bytes = None + networks_list = self.expandMessageNets( + message_nets + ) # Empty list means send to all networks + networks_sent_to = set() + + # Message routes work only with simplex messages for now. + message_route = self.getMessageRoute(1, addr_from, addr_to, cursor=cursor) + if message_route: + raise RuntimeError("Trying to send through an unestablished direct route.") + + message_route = self.getMessageRoute(2, addr_from, addr_to, cursor=cursor) + if message_route: + network = self.getActiveNetwork(2) + net_i = network["ws_thread"] + + remote_name = None + route_data = json.loads(message_route.route_data.decode("UTF-8")) + if "localDisplayName" in route_data: + remote_name = route_data["localDisplayName"] + else: + pccConnId = route_data["pccConnId"] + self.log.debug(f"Finding name for Simplex chat, ID: {pccConnId}") + cmd_id = net_i.send_command("/chats") + response = net_i.wait_for_command_response(cmd_id) + for chat in getResponseData(response, "chats"): + if ( + "chatInfo" not in chat + or "type" not in chat["chatInfo"] + or chat["chatInfo"]["type"] != "direct" + ): + continue + try: + if ( + chat["chatInfo"]["contact"]["activeConn"]["connId"] + == pccConnId + ): + remote_name = chat["chatInfo"]["contact"][ + "localDisplayName" + ] + break + except Exception as e: + self.log.debug(f"Error parsing chat: {e}") + + if remote_name is None: + raise RuntimeError( + f"Unable to find remote name for simplex direct chat, pccConnId: {pccConnId}" + ) + + message_id = sendSimplexMsg( + self, + network, + addr_from, + addr_to, + bytes.fromhex(payload_hex), + msg_valid, + cursor, + timestamp, + deterministic, + to_user_name=remote_name, + ) + return message_id + + smsg_difficulty: int = 0x1EFFFFFF + if self._have_smsg_rpc: + smsg_difficulty = self.callrpc("smsggetdifficulty", [-1, True]) + else: + self.log.debug("TODO, get difficulty from a portal") + + # First network in list will set message_id + smsg_msg: bytes = None + for network in self.active_networks: + net_message_id = None + network_type: int = networkTypeToID(network.get("type", "smsg")) + if network_type in networks_sent_to: + self.logD( + LC.NET, f"Skipping active network {network_type}, already sent to" + ) + continue + if len(networks_list) > 0 and network_type not in networks_list: + self.logD( + LC.NET, + f"Skipping active network {network_type}, not in networks_list", + ) + continue + + if network_type == MessageNetworks.SMSG: + if smsg_msg: + self.forwardSmsg(smsg_msg) + else: + net_message_id, smsg_msg = self.sendSmsg( + addr_from, addr_to, payload_hex, msg_valid, return_msg=True + ) + elif network_type == MessageNetworks.SIMPLEX: + if smsg_msg: + forwardSimplexMsg(self, network, smsg_msg) + else: + net_message_id, smsg_msg = sendSimplexMsg( + self, + network, + addr_from, + addr_to, + bytes.fromhex(payload_hex), + msg_valid, + cursor, + timestamp, + deterministic, + return_msg=True, + difficulty_target=smsg_difficulty, + ) + else: + raise ValueError("Unknown network: {}".format(network["type"])) + networks_sent_to.add(network_type) + if not message_id: + message_id = net_message_id + + for network in self.active_networks: + net_message_id = None + network_type_from = networkTypeToID(network.get("type", "smsg")) + if network_type_from not in self.known_portals: + self.known_portals[network_type_from] = {} + portals_from = self.known_portals[network_type_from] + + for bridged_network in network.get("bridged", []): + network_type_to = networkTypeToID(bridged_network["type"]) + if network_type_to in networks_sent_to: + self.logD( + LC.NET, + f"Skipping bridged network {network_type_to}, already sent to", + ) + continue + if len(networks_list) > 0 and network_type_to not in networks_list: + self.logD( + LC.NET, + f"Skipping bridged network {network_type_to}, not in networks_list", + ) + continue + + if network_type_to not in portals_from: + portals_from[network_type_to] = [] + portals_from_to = portals_from[network_type_to] + use_portal = None + for portal in portals_from_to: + if use_portal is None: + use_portal = portal + else: + # TODO: Pick better + if portal.num_issues < use_portal.num_issues: + use_portal = portal + + if use_portal is None: + self.log.warning( + f"Could not pick portal to network {network_type_to}, msg {self.logIDM(net_message_id)}" + ) + else: + if smsg_msg is None: + smsg_msg = encryptMsg( + self, + addr_from, + addr_to, + bytes.fromhex(payload_hex), + msg_valid, + cursor, + timestamp, + deterministic, + use_portal.smsg_difficulty, + ) + if not message_id: + message_id = smsgGetID(smsg_msg) + + forward_to = None # TODO - simplex username + self.usePortal(use_portal, smsg_msg, addr_from, forward_to, cursor) + networks_sent_to.add(network_type_to) + + return message_id + + def sendSmsg( + self, + addr_from: str, + addr_to: str, + payload_hex: bytes, + msg_valid: int, + return_msg: bool = False, + ) -> bytes: + + options = {"decodehex": True, "ttl_is_seconds": True} + if self._smsg_plaintext_version >= 2: + options["plaintext_format_version"] = 2 + options["compression"] = 0 + if self._smsg_add_to_outbox is False: + options["add_to_outbox"] = False + if return_msg: + options["returnmsg"] = True + try: + ro = self.callrpc( + "smsgsend", + [addr_from, addr_to, payload_hex, False, msg_valid, False, options], + ) + self.num_smsg_messages_sent += 1 + if return_msg: + return bytes.fromhex(ro["msgid"]), bytes.fromhex(ro["msg"]) + return bytes.fromhex(ro["msgid"]) + except Exception as e: + if self.debug: + self.log.error("smsgsend failed {}".format(json.dumps(ro, indent=4))) + raise e + + def forwardSmsg(self, smsg_msg: bytes) -> None: + options = {"submitmsg": True, "rehashmsg": False} + self.callrpc("smsgimport", [smsg_msg.hex(), options]) + self.num_smsg_messages_sent += 1 + + def processContactDisconnected(self, event_data) -> None: + net_i = self.getActiveNetworkInterface(2) + connId = getResponseData(event_data, "contact")["activeConn"]["connId"] + self.log.info(f"Direct message route disconnected, connId: {connId}") + closeSimplexChat(self, net_i, connId) + + query_str = "SELECT record_id, network_id, smsg_addr_local, smsg_addr_remote, route_data FROM direct_message_routes" + try: + cursor = self.openDB() + + rows = cursor.execute(query_str).fetchall() + + for row in rows: + record_id, network_id, smsg_addr_local, smsg_addr_remote, route_data = ( + row + ) + route_data = json.loads(route_data.decode("UTF-8")) + + if connId == route_data["pccConnId"]: + self.log.debug(f"Removing direct message route: {record_id}.") + cursor.execute( + "DELETE FROM direct_message_routes WHERE record_id = :record_id ", + {"record_id": record_id}, + ) + break + finally: + self.closeDB(cursor) + + def closeMessageRoute(self, record_id, network_id, route_data, cursor): + net_i = self.getActiveNetworkInterface(2) + + connId = route_data["pccConnId"] + + self.log.info(f"Closing Simplex chat, id: {connId}") + closeSimplexChat(self, net_i, connId) + + self.log.debug(f"Removing direct message route: {record_id}.") + cursor.execute( + "DELETE FROM direct_message_routes WHERE record_id = :record_id ", + {"record_id": record_id}, + ) + self.commitDB() + + def getSmsgMsgBytes(self, msg) -> bytes: + if int(self._smsg_plaintext_version) < 2: + return bytes.fromhex(msg["hex"][2:-2]) + return bytes.fromhex(msg["hex"][2:]) + + def processZmqSmsg(self) -> None: + message = self.zmqSubscriber.recv() + # Clear + _ = self.zmqSubscriber.recv() + + if message[0] == 3: # Paid smsg + return # TODO: Switch to paid? + + msg_id = message[2:] + options = {"encoding": "hex", "setread": True} + if self._smsg_plaintext_version >= 2: + options["pubkey_from"] = True + num_tries = 5 + for i in range(num_tries + 1): + try: + msg = self.callrpc("smsg", [msg_id.hex(), options]) + break + except Exception as e: + if "Unknown message id" in str(e) and i < num_tries: + self.delay_event.wait(1) + else: + raise e + + self.processMsg(msg) + + def newPortal(self, network_from_id, network_to_id, now): + addr_to: str = self.network_addr + cursor = self.openDB() + try: + addr_portal: str = self.prepareSMSGAddress( + None, AddressTypes.PORTAL_LOCAL, cursor + ) + finally: + self.closeDB(cursor) + + portal = NetworkPortal( + now, 30 * 60, network_from_id, network_to_id, addr_portal, addr_to + ) + + smsg_difficulty: int = 0x1EFFFFFF + if self._have_smsg_rpc: + smsg_difficulty = self.callrpc("smsggetdifficulty", [-1, True]) + + msg_buf = MessagePortalOffer() + msg_buf.network_type_from = network_from_id + msg_buf.network_type_to = network_to_id + msg_buf.time_valid = portal.time_valid + msg_buf.smsg_difficulty = smsg_difficulty + payload_hex = ( + str.format("{:02x}", MessageTypes.PORTAL_OFFER) + msg_buf.to_bytes().hex() + ) + + msg_valid: int = max(self.SMSG_SECONDS_IN_HOUR, portal.time_valid) + if network_from_id == MessageNetworks.SMSG: + net_message_id = self.sendSmsg(addr_portal, addr_to, payload_hex, msg_valid) + elif network_from_id == MessageNetworks.SIMPLEX: + network = self.getActiveNetwork(MessageNetworks.SIMPLEX) + + deterministic: bool = True + cursor = self.openDB() + try: + net_message_id = sendSimplexMsg( + self, + network, + addr_portal, + addr_to, + bytes.fromhex(payload_hex), + msg_valid, + cursor, + portal.time_start, + deterministic, + ) + finally: + self.closeDB(cursor) + else: + raise RuntimeError(f"Unknown network id {network_from_id}") + if network_from_id not in self.own_portals: + self.own_portals[network_from_id] = {} + self.own_portals[network_from_id][network_to_id] = portal + portal = self.own_portals.get(network_from_id, {}).get(network_to_id, None) + self.logD( + LC.NET, + f"Opened new portal {addr_portal} {network_from_id} -> {network_to_id}, {self.logIDM(net_message_id)}", + ) + + def refreshPortal(self, portal): + # TODO: Add random delay between refreshes + + now: int = self.getTime() + addr_portal: str = portal.address_from + addr_to: str = self.network_addr + smsg_difficulty: int = 0x1EFFFFFF + if self._have_smsg_rpc: + smsg_difficulty = self.callrpc("smsggetdifficulty", [-1, True]) + + msg_buf = MessagePortalOffer() + msg_buf.network_type_from = portal.network_from + msg_buf.network_type_to = portal.network_to + msg_buf.time_valid = portal.time_valid + msg_buf.smsg_difficulty = smsg_difficulty + payload_hex = ( + str.format("{:02x}", MessageTypes.PORTAL_OFFER) + msg_buf.to_bytes().hex() + ) + + msg_valid: int = max(self.SMSG_SECONDS_IN_HOUR, portal.time_valid) + if portal.network_from == MessageNetworks.SMSG: + net_message_id = self.sendSmsg(addr_portal, addr_to, payload_hex, msg_valid) + elif portal.network_from == MessageNetworks.SIMPLEX: + network = self.getActiveNetwork(MessageNetworks.SIMPLEX) + + cursor = self.openDB() + try: + net_message_id = sendSimplexMsg( + self, + network, + addr_portal, + addr_to, + bytes.fromhex(payload_hex), + msg_valid, + cursor, + portal.time_start, + ) + finally: + self.closeDB(cursor) + else: + raise RuntimeError(f"Unknown network id {portal.network_from}") + + portal.time_start = now + self.logD( + LC.NET, + f"Refreshed portal {addr_portal} {portal.network_from} -> {portal.network_to}, {self.logIDM(net_message_id)}", + ) + + def usePortal(self, portal, smsg, addr_from: str, forward_to: str, cursor): + if forward_to is not None: + raise ValueError("TODO") + now: int = self.getTime() + msg_buf = MessagePortalSend() + msg_buf.message_bytes = smsg + payload_hex = ( + str.format("{:02x}", MessageTypes.PORTAL_SEND) + msg_buf.to_bytes().hex() + ) + msg_valid: int = max(self.SMSG_SECONDS_IN_HOUR, portal.time_valid) + addr_to: str = portal.address_from + + if portal.network_from == MessageNetworks.SMSG: + net_message_id = self.sendSmsg(addr_from, addr_to, payload_hex, msg_valid) + elif portal.network_from == MessageNetworks.SIMPLEX: + network = self.getActiveNetwork(MessageNetworks.SIMPLEX) + + net_message_id = sendSimplexMsg( + self, + network, + addr_from, + addr_to, + bytes.fromhex(payload_hex), + msg_valid, + cursor, + now, + ) + else: + raise ValueError("Unknown network from ind.") + self.logD( + LC.NET, + f"Sending through portal {portal.address_from} {portal.network_from} -> {portal.network_to}, {self.logIDM(net_message_id)}", + ) + + def processPortalOffer(self, msg) -> None: + self.log.debug( + "Processing network portal offer {}.".format(self.log.id(msg["msgid"])) + ) + network_received_on: int = networkTypeToID(msg.get("msg_net", "smsg")) + + time_start: int = msg["sent"] + addr_portal: str = msg["from"] + msg_bytes = self.getSmsgMsgBytes(msg) + portal_data = MessagePortalOffer(init_all=False) + portal_data.from_bytes(msg_bytes) + if portal_data.network_type_from != network_received_on: + raise RuntimeError("Network from must match network received on.") + + network_from = self.getActiveNetwork(portal_data.network_type_from) + + # Ignore own portals + for network_to_id, portal in self.own_portals.get( + portal_data.network_type_from, {} + ).items(): + if portal.address_from == addr_portal: + self.log.debug("Ignoring own portal.") + return + + # Skip portals to networks this node is not using + found_enabled_bridge: bool = False + enabled_bridged = network_from.get("bridged", []) + for network_to_cross in enabled_bridged: + if network_to_cross.get("enabled", True) is False: + continue + if ( + networkTypeToID(network_to_cross.get("type", "smsg")) + == portal_data.network_type_to + ): + found_enabled_bridge = True + break + + if found_enabled_bridge is False: + self.log.debug("Ignoring portal to an unbridged network.") + return + + now: int = self.getTime() + if time_start + portal_data.time_valid < now: + self.log.warning("Offered portal is expired.") + return + + received_portal = NetworkPortal( + time_start, + portal_data.time_valid, + portal_data.network_type_from, + portal_data.network_type_to, + addr_portal, + portal_data.portal_address_to, + ) + received_portal.smsg_difficulty = portal_data.smsg_difficulty + + if received_portal.network_from not in self.known_portals: + self.known_portals[received_portal.network_from] = {} + portals_from = self.known_portals[received_portal.network_from] + + if received_portal.network_to not in portals_from: + portals_from[received_portal.network_to] = [] + + portals_from_to = portals_from[received_portal.network_to] + + for portal in portals_from_to: + if portal.address_from == received_portal.address_from: + portal.num_refreshes += 1 + portal.time_start = received_portal.time_start + portal.time_valid = received_portal.time_valid + portal.smsg_difficulty = received_portal.smsg_difficulty + return + + portals_from_to.append(received_portal) + + try: + cursor = self.openDB() + query: str = "SELECT addr_id FROM smsgaddresses WHERE addr = :addr" + addresses = cursor.execute( + query, {"addr": received_portal.address_from} + ).fetchall() + if len(addresses) < 1: + pk_address_from: str = msg["pubkey_from"] + query: str = ( + "INSERT INTO smsgaddresses (active_ind, created_at, addr, pubkey, use_type) VALUES (:active_ind, :created_at, :addr, :pubkey, :use_type)" + ) + cursor.execute( + query, + { + "active_ind": 1, + "created_at": now, + "addr": received_portal.address_from, + "pubkey": pk_address_from, + "use_type": AddressTypes.PORTAL, + }, + ) + + finally: + self.closeDB(cursor) + + def processPortalMessage(self, msg): + msg_id = msg["msgid"] + self.log.debug(f"Processing network portal message {msg_id}.") + + addr_to: str = msg["to"] + network_from_id: int = networkTypeToID(msg.get("msg_net", "smsg")) + from_portals = self.own_portals.get(network_from_id, {}) + portal = None + for network_to_id, to_portal in from_portals.items(): + if to_portal.address_from == addr_to: + portal = to_portal + break + if portal is None: + self.log.debug(f"Portal not found for portal message {msg_id}") + return + network_to_id = portal.network_to + + msg_bytes = self.getSmsgMsgBytes(msg) + portal_msg = MessagePortalSend(init_all=False) + portal_msg.from_bytes(msg_bytes) + + if network_to_id == MessageNetworks.SMSG: + self.forwardSmsg(portal_msg.message_bytes) + elif network_to_id == MessageNetworks.SIMPLEX: + network = self.getActiveNetwork(MessageNetworks.SIMPLEX) + forwardSimplexMsg(self, network, portal_msg.message_bytes) + else: + raise ValueError(f"Unknown network ID {network_to_id}") + + def updateNetworkBridges(self, now: int) -> None: + for network in self.active_networks: + network_from_id: int = networkTypeToID(network["type"]) + + for other_network in self.active_networks: + if network == other_network: + continue + network_id: int = networkTypeToID(other_network["type"]) + portal = self.own_portals.get(network_from_id, {}).get(network_id, None) + + if portal is None: + self.newPortal(network_from_id, network_id, now) + else: + if portal.time_start + portal.time_valid <= now - (5 * 60): + self.refreshPortal(portal) + self._last_checked_bridges = now + + def updateNetwork(self) -> None: + now: int = self.getTime() + + if self._poll_smsg: + if now - self._last_checked_smsg >= self.check_smsg_seconds: + self._last_checked_smsg = now + options = {"encoding": "hex", "setread": True} + msgs = self.callrpc("smsginbox", ["unread", "", options]) + for msg in msgs["messages"]: + self.processMsg(msg) + + try: + if self._bridge_networks: + if len(self.active_networks) > 1: + if now - self._last_checked_bridges >= self.check_bridges_seconds: + self.updateNetworkBridges(now) + for network in self.active_networks: + if network["type"] == "simplex": + readSimplexMsgs(self, network) + + except Exception as ex: + self.logException(f"updateNetwork {ex}") diff --git a/basicswap/network/simplex.py b/basicswap/network/simplex.py index 3841ca8..bc6b0ca 100644 --- a/basicswap/network/simplex.py +++ b/basicswap/network/simplex.py @@ -26,6 +26,7 @@ from basicswap.util.address import ( b58decode, decodeWif, ) +from basicswap.basicswap_util import AddressTypes def encode_base64(data: bytes) -> str: @@ -172,8 +173,7 @@ def waitForConnected(ws_thread, delay_event): raise ValueError("waitForConnected timed-out.") -def getPrivkeyForAddress(self, addr) -> bytes: - +def getPrivkeyForAddress(self, cursor, addr: str) -> bytes: ci_part = self.ci(Coins.PART) try: return ci_part.decodeKey( @@ -200,6 +200,38 @@ def getPrivkeyForAddress(self, addr) -> bytes: raise ValueError("key not found") +def getPubkeyForAddress(self, cursor, addr: str) -> bytes: + if self._have_smsg_rpc: + try: + rv = self.callrpc( + "smsggetpubkey", + [ + addr, + ], + ) + return b58decode(rv["publickey"]) + except Exception as e: # noqa: F841 + pass + use_cursor = self.openDB(cursor) + try: + query: str = "SELECT pk_from FROM offers WHERE addr_from = :addr_to LIMIT 1" + rows = use_cursor.execute(query, {"addr_to": addr}).fetchall() + if len(rows) > 0: + return rows[0][0] + query: str = "SELECT pk_bid_addr FROM bids WHERE bid_addr = :addr_to LIMIT 1" + rows = use_cursor.execute(query, {"addr_to": addr}).fetchall() + if len(rows) > 0: + return rows[0][0] + query: str = "SELECT pubkey FROM smsgaddresses WHERE addr = :addr LIMIT 1" + rows = use_cursor.execute(query, {"addr": addr}).fetchall() + if len(rows) > 0: + return bytes.fromhex(rows[0][0]) + raise ValueError(f"Could not get public key for address: {addr}") + finally: + if cursor is None: + self.closeDB(use_cursor, commit=False) + + def encryptMsg( self, addr_from: str, @@ -209,42 +241,20 @@ def encryptMsg( cursor, timestamp=None, deterministic=False, + difficulty_target=0x1EFFFFFF, ) -> bytes: self.log.debug("encryptMsg") - try: - rv = self.callrpc( - "smsggetpubkey", - [ - addr_to, - ], - ) - pubkey_to: bytes = b58decode(rv["publickey"]) - except Exception as e: # noqa: F841 - use_cursor = self.openDB(cursor) - try: - query: str = "SELECT pk_from FROM offers WHERE addr_from = :addr_to LIMIT 1" - rows = use_cursor.execute(query, {"addr_to": addr_to}).fetchall() - if len(rows) > 0: - pubkey_to = rows[0][0] - else: - query: str = ( - "SELECT pk_bid_addr FROM bids WHERE bid_addr = :addr_to LIMIT 1" - ) - rows = use_cursor.execute(query, {"addr_to": addr_to}).fetchall() - if len(rows) > 0: - pubkey_to = rows[0][0] - else: - raise ValueError(f"Could not get public key for address {addr_to}") - finally: - if cursor is None: - self.closeDB(use_cursor, commit=False) + pubkey_to = getPubkeyForAddress(self, cursor, addr_to) + privkey_from = getPrivkeyForAddress(self, cursor, addr_from) - privkey_from = getPrivkeyForAddress(self, addr_from) - - payload += bytes((0,)) # Include null byte to match smsg smsg_msg: bytes = smsgEncrypt( - privkey_from, pubkey_to, payload, timestamp, deterministic + privkey_from, + pubkey_to, + payload, + timestamp, + deterministic, + difficulty_target=difficulty_target, ) return smsg_msg @@ -261,11 +271,21 @@ def sendSimplexMsg( timestamp: int = None, deterministic: bool = False, to_user_name: str = None, + return_msg: bool = False, + difficulty_target=0x1EFFFFFF, ) -> bytes: self.log.debug("sendSimplexMsg") smsg_msg: bytes = encryptMsg( - self, addr_from, addr_to, payload, msg_valid, cursor, timestamp, deterministic + self, + addr_from, + addr_to, + payload, + msg_valid, + cursor, + timestamp, + deterministic, + difficulty_target, ) smsg_id = smsgGetID(smsg_msg) @@ -280,6 +300,33 @@ def sendSimplexMsg( json_str = json.dumps(response, indent=4) self.log.debug(f"Response {json_str}") raise ValueError("Send failed") + if to_user_name is not None: + self.num_direct_simplex_messages_sent += 1 + else: + self.num_group_simplex_messages_sent += 1 + + if return_msg: + return smsg_id, smsg_msg + return smsg_id + + +def forwardSimplexMsg(self, network, smsg_msg, to_user_name: str = None): + smsg_id = smsgGetID(smsg_msg) + ws_thread = network["ws_thread"] + if to_user_name is not None: + to = "@" + to_user_name + " " + else: + to = "#bsx " + sent_id = ws_thread.send_command(to + encode_base64(smsg_msg)) + response = waitForResponse(ws_thread, sent_id, self.delay_event) + if getResponseData(response, "type") != "newChatItems": + json_str = json.dumps(response, indent=4) + self.log.debug(f"Response {json_str}") + raise ValueError("Send failed") + if to_user_name is not None: + self.num_direct_simplex_messages_sent += 1 + else: + self.num_group_simplex_messages_sent += 1 return smsg_id @@ -292,7 +339,7 @@ def decryptSimplexMsg(self, msg_data): try: decrypted = smsgDecrypt(network_key, msg_data, output_dict=True) decrypted["from"] = ci_part.pubkey_to_address( - bytes.fromhex(decrypted["pk_from"]) + bytes.fromhex(decrypted["pubkey_from"]) ) decrypted["to"] = self.network_addr decrypted["msg_net"] = "simplex" @@ -308,31 +355,34 @@ def decryptSimplexMsg(self, msg_data): AND (s.in_progress OR (s.swap_ended = 0 AND b.expire_at > :now)) UNION SELECT addr_from AS address FROM offers WHERE active_ind = 1 AND expire_at > :now + UNION + SELECT addr AS address FROM smsgaddresses WHERE active_ind = 1 AND use_type = :local_portal )""" now: int = self.getTime() try: cursor = self.openDB() - addr_rows = cursor.execute(query, {"now": now}).fetchall() + addr_rows = cursor.execute( + query, {"now": now, "local_portal": AddressTypes.PORTAL_LOCAL} + ).fetchall() + decrypted = None + for row in addr_rows: + addr = row[0] + try: + vk_addr = getPrivkeyForAddress(self, cursor, addr) + decrypted = smsgDecrypt(vk_addr, msg_data, output_dict=True) + decrypted["from"] = ci_part.pubkey_to_address( + bytes.fromhex(decrypted["pubkey_from"]) + ) + decrypted["to"] = addr + decrypted["msg_net"] = "simplex" + return decrypted + except Exception as e: # noqa: F841 + pass finally: self.closeDB(cursor, commit=False) - decrypted = None - for row in addr_rows: - addr = row[0] - try: - vk_addr = getPrivkeyForAddress(self, addr) - decrypted = smsgDecrypt(vk_addr, msg_data, output_dict=True) - decrypted["from"] = ci_part.pubkey_to_address( - bytes.fromhex(decrypted["pk_from"]) - ) - decrypted["to"] = addr - decrypted["msg_net"] = "simplex" - return decrypted - except Exception as e: # noqa: F841 - pass - return decrypted @@ -375,7 +425,6 @@ def parseSimplexMsg(self, chat_item): return decrypted_msg except Exception as e: # noqa: F841 # self.log.debug(f"decryptSimplexMsg error: {e}") - self.log.debug(f"decryptSimplexMsg error: {e}") pass return None @@ -421,7 +470,7 @@ def readSimplexMsgs(self, network): elif processEvent(self, ws_thread, msg_type, data): pass else: - self.log.debug(f"Unknown msg_type: {msg_type}") + self.log.debug(f"simplex: Unknown msg_type: {msg_type}") # self.log.debug(f"Message: {json.dumps(data, indent=4)}") except Exception as e: self.log.debug(f"readSimplexMsgs error: {e}") @@ -432,10 +481,11 @@ def readSimplexMsgs(self, network): def getResponseData(data, tag=None): - if "Right" in data["resp"]: - if tag: - return data["resp"]["Right"][tag] - return data["resp"]["Right"] + for pretag in ("Right", "Left"): + if pretag in data["resp"]: + if tag: + return data["resp"][pretag][tag] + return data["resp"][pretag] if tag: return data["resp"][tag] return data["resp"] @@ -474,12 +524,14 @@ def initialiseSimplexNetwork(self, network_config) -> None: response = waitForResponse(ws_thread, sent_id, self.delay_event) assert "groupLinkId" in getResponseData(response, "connection") - network = { + add_network = { "type": "simplex", "ws_thread": ws_thread, } + if "bridged" in network_config: + add_network["bridged"] = network_config["bridged"] - self.active_networks.append(network) + self.active_networks.append(add_network) def closeSimplexChat(self, net_i, connId) -> bool: diff --git a/basicswap/network/util.py b/basicswap/network/util.py index 0ec019f..b23b74f 100644 --- a/basicswap/network/util.py +++ b/basicswap/network/util.py @@ -9,8 +9,8 @@ from basicswap.util.address import b58decode def getMsgPubkey(self, msg) -> bytes: - if "pk_from" in msg: - return bytes.fromhex(msg["pk_from"]) + if "pubkey_from" in msg: + return bytes.fromhex(msg["pubkey_from"]) rv = self.callrpc( "smsggetpubkey", [ diff --git a/basicswap/ui/app.py b/basicswap/ui/app.py index c78b79c..e6c3ea1 100644 --- a/basicswap/ui/app.py +++ b/basicswap/ui/app.py @@ -10,6 +10,23 @@ from basicswap.db import getOrderByStr class UIApp: + def __init__(self, **kwargs): + pass + + def setFilters(self, prefix, filters): + key_str = "saved_filters_" + prefix + value_str = json.dumps(filters) + self.setStringKV(key_str, value_str) + + def getFilters(self, prefix): + key_str = "saved_filters_" + prefix + value_str = self.getStringKV(key_str) + return None if not value_str else json.loads(value_str) + + def clearFilters(self, prefix) -> None: + key_str = "saved_filters_" + prefix + self.clearStringKV(key_str) + def listMessageRoutes(self, filters={}, action=None): cursor = self.openDB() try: diff --git a/basicswap/util/__init__.py b/basicswap/util/__init__.py index 5528e4a..4d10315 100644 --- a/basicswap/util/__init__.py +++ b/basicswap/util/__init__.py @@ -43,7 +43,7 @@ class LockedCoinError(Exception): self.coinid = coinid def __str__(self): - return "Coin must be unlocked: " + str(self.coinid) + return "must be unlocked: " + str(self.coinid) def ensure(v, err_string): diff --git a/basicswap/util/logging.py b/basicswap/util/logging.py index 043d4df..370f27c 100644 --- a/basicswap/util/logging.py +++ b/basicswap/util/logging.py @@ -5,11 +5,16 @@ # file LICENSE or http://www.opensource.org/licenses/mit-license.php. import logging +from enum import IntEnum, auto from basicswap.util.crypto import ( sha256, ) +class LogCategories(IntEnum): + NET = auto() + + class BSXLogger(logging.Logger): def __init__(self, name): super().__init__(name) diff --git a/basicswap/util/smsg.py b/basicswap/util/smsg.py index dd30884..4e67652 100644 --- a/basicswap/util/smsg.py +++ b/basicswap/util/smsg.py @@ -66,6 +66,11 @@ def smsgGetTimestamp(smsg_message: bytes) -> int: return int.from_bytes(smsg_message[11 : 11 + 8], byteorder="little") +def smsgGetTTL(smsg_message: bytes) -> int: + assert len(smsg_message) > SMSG_HDR_LEN + return int.from_bytes(smsg_message[19 : 19 + 4], byteorder="little") + + def smsgGetPOWHash(smsg_message: bytes) -> bytes: assert len(smsg_message) > SMSG_HDR_LEN ofs: int = 4 @@ -79,7 +84,7 @@ def smsgGetPOWHash(smsg_message: bytes) -> bytes: def smsgGetID(smsg_message: bytes) -> bytes: assert len(smsg_message) > SMSG_HDR_LEN - smsg_timestamp = int.from_bytes(smsg_message[11 : 11 + 8], byteorder="little") + smsg_timestamp = smsgGetTimestamp(smsg_message) return smsg_timestamp.to_bytes(8, byteorder="big") + ripemd160(smsg_message[8:]) @@ -89,6 +94,8 @@ def smsgEncrypt( payload: bytes, smsg_timestamp: int = None, deterministic: bool = False, + plaintext_format: int = 2, + difficulty_target=0x1EFFFFFF, ) -> bytes: # assert len(payload) < 128 # Requires lz4 if payload > 128 bytes # TODO: Add lz4 to match core smsg @@ -125,13 +132,18 @@ def smsgEncrypt( pkh_from: bytes = hash160(pubkey_from) len_payload = len(payload) - address_version = 0 - plaintext_data: bytes = ( - bytes((address_version,)) - + pkh_from - + signature - + len_payload.to_bytes(4, byteorder="little") - + payload + + if plaintext_format == 2: + address_version = 249 # Marker for format 2 + compressed = 0 + plaintext_data: bytes = bytes((address_version, compressed)) + elif plaintext_format == 1: + address_version = 0 + plaintext_data: bytes = bytes((address_version,)) + else: + raise ValueError("Unknown plaintext format.") + plaintext_data += bytes( + pkh_from + signature + len_payload.to_bytes(4, byteorder="little") + payload ) ciphertext: bytes = aes_encrypt(plaintext_data, key_e, smsg_iv) @@ -166,8 +178,7 @@ def smsgEncrypt( + ciphertext ) - target: int = uint256_from_compact(0x1EFFFFFF) - + target: int = uint256_from_compact(difficulty_target) for i in range(1000000): pow_hash = smsgGetPOWHash(smsg_message) if uint256_from_str(pow_hash) > target: @@ -216,7 +227,14 @@ def smsgDecrypt( plaintext = aes_decrypt(ciphertext, key_e, smsg_iv) - ofs = 1 + ofs: int = 0 + version = plaintext[0] + if version == 249: + compressed = plaintext[1] + assert compressed == 0 + ofs += 1 + + ofs += 1 pkh_from = plaintext[ofs : ofs + 20] ofs += 20 signature = plaintext[ofs : ofs + 65] @@ -240,6 +258,6 @@ def smsgDecrypt( "msgid": smsgGetID(encrypted_message).hex(), "sent": smsg_timestamp, "hex": payload.hex(), - "pk_from": pubkey_signer.hex(), + "pubkey_from": pubkey_signer.hex(), } return payload diff --git a/tests/basicswap/extended/test_dcr.py b/tests/basicswap/extended/test_dcr.py index e3c7bfe..046fa21 100644 --- a/tests/basicswap/extended/test_dcr.py +++ b/tests/basicswap/extended/test_dcr.py @@ -1384,7 +1384,7 @@ class Test(BaseTest): # Entire system is locked with Particl wallet jsw = read_json_api(1800, "wallets/dcr") - assert "Coin must be unlocked" in jsw["error"] + assert "must be unlocked" in jsw["error"] read_json_api(1800, "unlock", {"coin": "part", "password": "notapassword123"}) @@ -1395,7 +1395,7 @@ class Test(BaseTest): read_json_api(1800, "lock", {"coin": "part"}) jsw = read_json_api(1800, "wallets/part") - assert "Coin must be unlocked" in jsw["error"] + assert "must be unlocked" in jsw["error"] read_json_api( 1800, diff --git a/tests/basicswap/extended/test_multinet.py b/tests/basicswap/extended/test_multinet.py new file mode 100644 index 0000000..c7e83e3 --- /dev/null +++ b/tests/basicswap/extended/test_multinet.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 The Basicswap developers +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. + +""" +docker run \ + -e "ADDR=127.0.0.1" \ + -e "PASS=password" \ + -p 5223:5223 \ + -v /tmp/simplex/smp/config:/etc/opt/simplex:z \ + -v /tmp/simplex/smp/logs:/var/opt/simplex:z \ + -v /tmp/simplex/certs:/certificates \ + simplexchat/smp-server:latest + +Fingerprint: Q8SNxc2SRcKyXlhJM8KFUgPNW4KXPGRm4eSLtT_oh-I= + +export SIMPLEX_SERVER_ADDRESS=smp://Q8SNxc2SRcKyXlhJM8KFUgPNW4KXPGRm4eSLtT_oh-I=:password@127.0.0.1:5223 + +https://github.com/simplex-chat/simplex-chat/issues/4127 + json: {"corrId":"3","cmd":"/_send #1 text test123"} + direct message: {"corrId":"1","cmd":"/_send @2 text the message"} + +""" + +import logging +import random +import sys + +from basicswap.basicswap import ( + BidStates, + SwapTypes, +) +from basicswap.chainparams import Coins + +from tests.basicswap.common import ( + wait_for_bid, + wait_for_offer, +) +from tests.basicswap.test_xmr import test_delay_event +from tests.basicswap.extended.test_simplex import ( + TestSimplex2, + SIMPLEX_SERVER_ADDRESS, + SIMPLEX_CLIENT_PATH, +) + +logger = logging.getLogger() +logger.level = logging.DEBUG +if not len(logger.handlers): + logger.addHandler(logging.StreamHandler(sys.stdout)) + + +def wait_for_portal(delay_event, swap_client, wait_for=20): + logging.info("wait_for_portal") + + for i in range(wait_for): + if delay_event.is_set(): + raise ValueError("Test stopped.") + delay_event.wait(1) + if len(swap_client.known_portals) > 0: + return + raise ValueError("wait_for_portal timed out.") + + +class Test(TestSimplex2): + __test__ = True + + @classmethod + def addCoinSettings(cls, settings, datadir, node_id): + settings["networks"] = [] + settings["smsg_plaintext_version"] = 2 + if node_id in (0, 2): + settings["networks"].append( + { + "type": "simplex", + "server_address": SIMPLEX_SERVER_ADDRESS, + "client_path": SIMPLEX_CLIENT_PATH, + "ws_port": 5225 + node_id, + "group_link": cls.group_link, + "enabled": True, + }, + ) + if node_id == 0: + settings["networks"][-1]["bridged"] = [{"type": "smsg"}] + if node_id in (1, 2): + settings["networks"].append( + { + "type": "smsg", + "enabled": True, + }, + ) + if node_id == 1: + settings["networks"][-1]["bridged"] = [{"type": "simplex"}] + + for node_id in range(3): + settings["enabled_log_categories"] = ["net", ] + + def test_01_across_networks(self): + logger.info("---------- Test multinet swap across networks") + + swap_clients = self.swap_clients + for sc in swap_clients: + sc._use_direct_message_routes = False + swap_clients[2]._bridge_networks = True + + assert len(swap_clients[0].active_networks) == 1 + assert swap_clients[0].active_networks[0]["type"] == "simplex" + assert len(swap_clients[1].active_networks) == 1 + assert swap_clients[1].active_networks[0]["type"] == "smsg" + assert len(swap_clients[2].active_networks) == 2 + + coin_from = Coins.BTC + coin_to = self.coin_to + + ci_from = swap_clients[0].ci(coin_from) + ci_to = swap_clients[1].ci(coin_to) + + wait_for_portal(test_delay_event, swap_clients[0]) + + swap_value = ci_from.make_int(random.uniform(0.2, 20.0), r=1) + rate_swap = ci_to.make_int(random.uniform(0.2, 20.0), r=1) + offer_id = swap_clients[0].postOffer( + coin_from, coin_to, swap_value, rate_swap, swap_value, SwapTypes.XMR_SWAP + ) + + wait_for_offer(test_delay_event, swap_clients[1], offer_id) + offer = swap_clients[1].getOffer(offer_id) + bid_id = swap_clients[1].postBid(offer_id, offer.amount_from) + + wait_for_bid( + test_delay_event, + swap_clients[0], + bid_id, + BidStates.BID_RECEIVED, + wait_for=60, + ) + swap_clients[0].acceptBid(bid_id) + + wait_for_bid( + test_delay_event, + swap_clients[0], + bid_id, + BidStates.SWAP_COMPLETED, + wait_for=320, + ) + wait_for_bid( + test_delay_event, + swap_clients[1], + bid_id, + BidStates.SWAP_COMPLETED, + sent=True, + wait_for=320, + ) + + def test_02_across_networks(self): + logger.info("---------- Test reversed swap across networks") + + swap_clients = self.swap_clients + for sc in swap_clients: + sc._use_direct_message_routes = False + swap_clients[2]._bridge_networks = True + + coin_from = Coins.XMR + coin_to = Coins.BTC + + ci_from = swap_clients[1].ci(coin_from) + ci_to = swap_clients[0].ci(coin_to) + + wait_for_portal(test_delay_event, swap_clients[1]) + + swap_value = ci_from.make_int(random.uniform(0.2, 20.0), r=1) + rate_swap = ci_to.make_int(random.uniform(0.2, 20.0), r=1) + offer_id = swap_clients[1].postOffer( + coin_from, coin_to, swap_value, rate_swap, swap_value, SwapTypes.XMR_SWAP + ) + + wait_for_offer(test_delay_event, swap_clients[0], offer_id) + offer = swap_clients[0].getOffer(offer_id) + bid_id = swap_clients[0].postBid(offer_id, offer.amount_from) + + wait_for_bid( + test_delay_event, + swap_clients[1], + bid_id, + BidStates.BID_RECEIVED, + wait_for=60, + ) + swap_clients[1].acceptBid(bid_id) + + wait_for_bid( + test_delay_event, + swap_clients[1], + bid_id, + BidStates.SWAP_COMPLETED, + wait_for=320, + ) + wait_for_bid( + test_delay_event, + swap_clients[0], + bid_id, + BidStates.SWAP_COMPLETED, + sent=True, + wait_for=320, + ) + + def test_03_across_networks(self): + logger.info("---------- Test secret hash swap across networks") + + swap_clients = self.swap_clients + for sc in swap_clients: + sc._use_direct_message_routes = False + swap_clients[2]._bridge_networks = True + coin_from = Coins.PART + coin_to = Coins.BTC + self.prepare_balance(coin_to, 100.0, 1801, 1800) + + ci_from = swap_clients[0].ci(coin_from) + ci_to = swap_clients[1].ci(coin_to) + + wait_for_portal(test_delay_event, swap_clients[0]) + + swap_value = ci_from.make_int(random.uniform(0.2, 20.0), r=1) + rate_swap = ci_to.make_int(random.uniform(0.2, 20.0), r=1) + offer_id = swap_clients[0].postOffer( + coin_from, coin_to, swap_value, rate_swap, swap_value, SwapTypes.SELLER_FIRST + ) + + wait_for_offer(test_delay_event, swap_clients[1], offer_id) + offer = swap_clients[1].getOffer(offer_id) + bid_id = swap_clients[1].postBid(offer_id, offer.amount_from) + + wait_for_bid( + test_delay_event, + swap_clients[0], + bid_id, + BidStates.BID_RECEIVED, + wait_for=60, + ) + swap_clients[0].acceptBid(bid_id) + + wait_for_bid( + test_delay_event, + swap_clients[0], + bid_id, + BidStates.SWAP_COMPLETED, + wait_for=320, + ) + wait_for_bid( + test_delay_event, + swap_clients[1], + bid_id, + BidStates.SWAP_COMPLETED, + sent=True, + wait_for=320, + ) + + def test_04_multiple_active(self): + logger.info("---------- Test multinet swap with multiple active networks") + + # Messages for bids should only be sent to one network + swap_clients = self.swap_clients + + for sc in swap_clients: + sc._use_direct_message_routes = False + swap_clients[2]._bridge_networks = True + assert len(swap_clients[2].active_networks) == 2 + + num_group_simplex_messages_received_before = [0 for _ in range(3)] + num_group_simplex_messages_sent_before = [0 for _ in range(3)] + num_direct_simplex_messages_received_before = [0 for _ in range(3)] + num_direct_simplex_messages_sent_before = [0 for _ in range(3)] + num_smsg_messages_received_before = [0 for _ in range(3)] + num_smsg_messages_sent_before = [0 for _ in range(3)] + + for i in range(3): + num_group_simplex_messages_received_before[i] = swap_clients[ + i + ].num_group_simplex_messages_received + num_group_simplex_messages_sent_before[i] = swap_clients[ + i + ].num_group_simplex_messages_sent + num_direct_simplex_messages_received_before[i] = swap_clients[ + i + ].num_direct_simplex_messages_received + num_direct_simplex_messages_sent_before[i] = swap_clients[ + i + ].num_direct_simplex_messages_sent + num_smsg_messages_received_before[i] = swap_clients[ + i + ].num_smsg_messages_received + num_smsg_messages_sent_before[i] = swap_clients[i].num_smsg_messages_sent + + coin_from = Coins.BTC + coin_to = self.coin_to + + # Prepare balances + self.prepare_balance(coin_from, 100.0, 1802, 1800) + self.prepare_balance(coin_from, 200.0, 1802, 1800) + self.prepare_balance(coin_to, 1000.0, 1800, 1801) + + ci_from = swap_clients[2].ci(coin_from) + ci_to0 = swap_clients[0].ci(coin_to) + + wait_for_portal(test_delay_event, swap_clients[0]) + swap_value = ci_from.make_int(random.uniform(0.2, 10.0), r=1) + rate_swap = ci_to0.make_int(random.uniform(0.2, 10.0), r=1) + offer_id = swap_clients[2].postOffer( + coin_from, coin_to, swap_value, rate_swap, swap_value, SwapTypes.XMR_SWAP + ) + + bid_ids = [] + wait_for_offer(test_delay_event, swap_clients[0], offer_id) + offer = swap_clients[0].getOffer(offer_id) + bid_ids.append(swap_clients[0].postBid(offer_id, offer.amount_from)) + + wait_for_offer(test_delay_event, swap_clients[1], offer_id) + bid_ids.append(swap_clients[1].postBid(offer_id, offer.amount_from)) + + for bid_id in bid_ids: + wait_for_bid( + test_delay_event, + swap_clients[2], + bid_id, + BidStates.BID_RECEIVED, + wait_for=60, + ) + swap_clients[2].acceptBid(bid_id) + + wait_for_bid( + test_delay_event, + swap_clients[0], + bid_ids[0], + BidStates.SWAP_COMPLETED, + sent=True, + wait_for=320, + ) + + wait_for_bid( + test_delay_event, + swap_clients[1], + bid_ids[1], + BidStates.SWAP_COMPLETED, + sent=True, + wait_for=320, + ) + for bid_id in bid_ids: + wait_for_bid( + test_delay_event, + swap_clients[2], + bid_id, + BidStates.SWAP_COMPLETED, + wait_for=320, + ) + + num_group_simplex_messages_received = [0 for _ in range(3)] + num_group_simplex_messages_sent = [0 for _ in range(3)] + num_direct_simplex_messages_received = [0 for _ in range(3)] + num_direct_simplex_messages_sent = [0 for _ in range(3)] + num_smsg_messages_received = [0 for _ in range(3)] + num_smsg_messages_sent = [0 for _ in range(3)] + + for i in range(3): + num_group_simplex_messages_received[i] = ( + swap_clients[i].num_group_simplex_messages_received + - num_group_simplex_messages_received_before[i] + ) + num_group_simplex_messages_sent[i] = ( + swap_clients[i].num_group_simplex_messages_sent + - num_group_simplex_messages_sent_before[i] + ) + num_direct_simplex_messages_received[i] = ( + swap_clients[i].num_direct_simplex_messages_received + - num_direct_simplex_messages_received_before[i] + ) + num_direct_simplex_messages_sent[i] = ( + swap_clients[i].num_direct_simplex_messages_sent + - num_direct_simplex_messages_sent_before[i] + ) + num_smsg_messages_received[i] = ( + swap_clients[i].num_smsg_messages_received + - num_smsg_messages_received_before[i] + ) + num_smsg_messages_sent[i] = ( + swap_clients[i].num_smsg_messages_sent + - num_smsg_messages_sent_before[i] + ) + + assert num_group_simplex_messages_sent[2] <= 9 + assert num_smsg_messages_sent[2] <= 9 diff --git a/tests/basicswap/extended/test_simplex.py b/tests/basicswap/extended/test_simplex.py index 2d09e04..20e2183 100644 --- a/tests/basicswap/extended/test_simplex.py +++ b/tests/basicswap/extended/test_simplex.py @@ -391,8 +391,8 @@ class TestSimplex(unittest.TestCase): t.join() -class Test(BaseTest): - __test__ = True +class TestSimplex2(BaseTest): + __test__ = False start_ltc_nodes = False start_xmr_nodes = True group_link = None @@ -452,7 +452,7 @@ class Test(BaseTest): @classmethod def tearDownClass(cls): logger.info("Finalising Test") - super(Test, cls).tearDownClass() + super().tearDownClass() stopDaemons(cls.daemons) @classmethod @@ -468,6 +468,10 @@ class Test(BaseTest): }, ] + +class Test(TestSimplex2): + __test__ = True + def test_01_swap(self): logger.info("---------- Test adaptor sig swap") @@ -475,6 +479,7 @@ class Test(BaseTest): for sc in swap_clients: sc._use_direct_message_routes = False + sc._smsg_plaintext_version = 2 assert len(swap_clients[0].active_networks) == 1 assert swap_clients[0].active_networks[0]["type"] == "simplex" @@ -533,6 +538,7 @@ class Test(BaseTest): for sc in swap_clients: sc._use_direct_message_routes = False + sc._smsg_plaintext_version = 2 assert len(swap_clients[0].active_networks) == 1 assert swap_clients[0].active_networks[0]["type"] == "simplex" @@ -591,6 +597,7 @@ class Test(BaseTest): for sc in swap_clients: sc._use_direct_message_routes = True + sc._smsg_plaintext_version = 2 assert len(swap_clients[0].active_networks) == 1 assert swap_clients[0].active_networks[0]["type"] == "simplex" @@ -665,6 +672,7 @@ class Test(BaseTest): for sc in swap_clients: sc._use_direct_message_routes = True + sc._smsg_plaintext_version = 2 assert len(swap_clients[0].active_networks) == 1 assert swap_clients[0].active_networks[0]["type"] == "simplex" @@ -737,6 +745,7 @@ class Test(BaseTest): for sc in swap_clients: sc._use_direct_message_routes = False + sc._smsg_plaintext_version = 2 assert len(swap_clients[0].active_networks) == 1 assert swap_clients[0].active_networks[0]["type"] == "simplex" diff --git a/tests/basicswap/extended/test_smsg.py b/tests/basicswap/extended/test_smsg.py index d6170c3..353a525 100644 --- a/tests/basicswap/extended/test_smsg.py +++ b/tests/basicswap/extended/test_smsg.py @@ -6,6 +6,8 @@ # file LICENSE or http://www.opensource.org/licenses/mit-license.php. import logging +import random +import string from basicswap.chainparams import Coins from basicswap.util.smsg import ( @@ -77,7 +79,6 @@ class Test(BaseTest): cls.network_thread = NetworkThread() cls.network_thread.network_event_loop.set_debug(True) cls.network_thread.start() - cls.network_thread.network_event_loop.set_debug(True) @classmethod def run_loop_ended(cls): @@ -88,10 +89,6 @@ class Test(BaseTest): @classmethod def tearDownClass(cls): logging.info("Finalising Test") - - # logging.info('Closing down network thread') - # cls.network_thread.close() - super(Test, cls).tearDownClass() @classmethod @@ -145,3 +142,72 @@ class Test(BaseTest): wait_for_smsg(ci0_part, msg_id.hex()) rv = ci0_part.rpc_wallet("smsg", [msg_id.hex()]) assert rv["text"] == message_test + + ci1_part = swap_clients[1].ci(Coins.PART) + rv = ci1_part.rpc("smsgimport", [encrypted_message.hex(), {"submitmsg": True}]) + assert rv["msgid"] == msg_id.hex() + + def test_02_plaintext_v2(self): + # Test SMSG plaintext version 2 + + ci0_part = self.swap_clients[0].ci(Coins.PART) + + len_smsgaddresses_start = len(ci0_part.rpc("smsgaddresses")) + + message_test: str = "Test message" + for i in range(2048): + message_test += random.choice(string.ascii_letters + string.digits) + message_test += "end." + + test_key_recv: bytes = ci0_part.getNewRandomKey() + test_key_recv_wif: str = ci0_part.encodeKey(test_key_recv) + test_key_recv_pk: bytes = ci0_part.getPubkey(test_key_recv) + ci0_part.rpc("smsgimportprivkey", [test_key_recv_wif, "test key"]) + ro = ci0_part.rpc("smsgoptions", ["set", "addReceivedPubkeys", False]) + assert "addReceivedPubkeys = false" in str(ro) + + test_addr_core = ci0_part.pubkey_to_address(ci0_part.getPubkey(test_key_recv)) + test_key_send: bytes = ci0_part.getNewRandomKey() + test_pk_bsx = ci0_part.getPubkey(test_key_send) + + logging.info("Test core to BSX") + options = { + "submitmsg": False, + "ttl_is_seconds": True, + "plaintext_format_version": 2, + "compression": 0, + "add_to_outbox": False, + } + ro = ci0_part.rpc( + "smsgsend", + [ + test_addr_core, + test_pk_bsx.hex(), + message_test, + False, + self.swap_clients[0].SMSG_SECONDS_IN_HOUR, + False, + options, + ], + ) + encrypted_message = bytes.fromhex(ro["msg"]) + decrypted_message: bytes = smsgDecrypt(test_key_send, encrypted_message) + assert decrypted_message.decode("utf-8") == message_test + + logging.info("Test BSX to core") + encrypted_message: bytes = smsgEncrypt( + test_key_send, test_key_recv_pk, message_test.encode("utf-8") + ) + msg_id: bytes = smsgGetID(encrypted_message) + + rv = ci0_part.rpc("smsgimport", [encrypted_message.hex(), {"submitmsg": True}]) + assert rv["msgid"] == msg_id.hex() + + options = {"pubkey_from": True} + rv = ci0_part.rpc("smsg", [msg_id.hex(), options]) + assert rv["text"].endswith("end.") + assert rv["msgid"] == msg_id.hex() + assert "pubkey_from" in rv + + smsgaddresses = ci0_part.rpc("smsgaddresses") + assert len(smsgaddresses) == len_smsgaddresses_start diff --git a/tests/basicswap/test_btc_xmr.py b/tests/basicswap/test_btc_xmr.py index 5e8e82b..ed66eae 100644 --- a/tests/basicswap/test_btc_xmr.py +++ b/tests/basicswap/test_btc_xmr.py @@ -72,6 +72,11 @@ class TestFunctions(BaseTest): node_b_id = 1 node_c_id = 2 + @classmethod + def prepareExtraCoins(cls): + for sc in cls.swap_clients: + sc._smsg_add_to_outbox = True + def callnoderpc(self, method, params=[], wallet=None, node_id=0): return callnoderpc(node_id, method, params, wallet, self.base_rpc_port) @@ -2280,7 +2285,7 @@ class TestBTC(BasicSwapTest): # Entire system is locked with Particl wallet jsw = read_json_api(1800, "wallets/btc") - assert "Coin must be unlocked" in jsw["error"] + assert "must be unlocked" in jsw["error"] read_json_api(1800, "unlock", {"coin": "part", "password": "notapassword123"}) @@ -2291,7 +2296,7 @@ class TestBTC(BasicSwapTest): read_json_api(1800, "lock", {"coin": "part"}) jsw = read_json_api(1800, "wallets/part") - assert "Coin must be unlocked" in jsw["error"] + assert "must be unlocked" in jsw["error"] read_json_api( 1800,