diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 6f2c5bb..c1569e7 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -100,10 +100,7 @@ from .util.network import is_private_ip_address from .util.smsg import smsgGetID from .interface.base import Curves from .interface.part import PARTInterface, PARTInterfaceAnon, PARTInterfaceBlind -from .explorers import ( - default_chart_api_key, - default_coingecko_api_key, -) +from .explorers import default_coingecko_api_key from .script import OpCodes from .messages_npb import ( ADSBidIntentAcceptMessage, @@ -417,6 +414,21 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self._is_encrypted = None self._is_locked = None + self._price_cache = {} + self._volume_cache = {} + self._historical_cache = {} + self._price_cache_lock = threading.Lock() + self._price_fetch_thread = None + self._price_fetch_running = False + self._last_price_fetch = 0 + self._last_volume_fetch = 0 + self.price_fetch_interval = self.get_int_setting( + "price_fetch_interval", 5 * 60, 60, 60 * 60 + ) + self.volume_fetch_interval = self.get_int_setting( + "volume_fetch_interval", 5 * 60, 60, 60 * 60 + ) + self._max_transient_errors = self.settings.get( "max_transient_errors", 100 ) # Number of retries before a bid will stop when encountering transient errors. @@ -562,9 +574,55 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): random.seed(secrets.randbits(128)) + self._prepare_rpc_pooling() + + def _prepare_rpc_pooling(self): + if "rpc_connection_pool" not in self.settings: + self.settings["rpc_connection_pool"] = { + "enabled": True, + "max_connections_per_daemon": 5, + } + self._save_settings() + + def _enable_rpc_pooling(self): + rpc_pool_settings = self.settings.get("rpc_connection_pool", {}) + if rpc_pool_settings.get("enabled", False): + from basicswap import rpc + from basicswap import rpc_pool + + rpc.enable_rpc_pooling(rpc_pool_settings) + rpc_pool.set_pool_logger(self.log) + + def _save_settings(self): + import shutil + from basicswap import config as cfg + + settings_path = os.path.join(self.data_dir, cfg.CONFIG_FILENAME) + settings_path_new = settings_path + ".new" + try: + shutil.copyfile(settings_path, settings_path + ".last") + with open(settings_path_new, "w") as fp: + json.dump(self.settings, fp, indent=4) + shutil.move(settings_path_new, settings_path) + self.log.debug("Settings saved to basicswap.json") + except Exception as e: + self.log.error(f"Failed to save settings: {str(e)}") + def finalise(self): self.log.info("Finalising") + self._price_fetch_running = False + if self._price_fetch_thread and self._price_fetch_thread.is_alive(): + self._price_fetch_thread.join(timeout=5) + self.log.info("Background price fetching stopped") + + try: + from basicswap.rpc_pool import close_all_pools + + close_all_pools() + except Exception as e: + self.log.debug(f"Error closing RPC pools: {e}") + try: from basicswap.ui.page_amm import stop_amm_process, get_amm_status @@ -1058,6 +1116,19 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): elif self.coin_clients[coin]["connection_type"] == "passthrough": self.coin_clients[coin]["interface"] = self.createPassthroughInterface(coin) + def _cleanupOldSettings(self): + settings_changed = False + deprecated_keys = ["chart_api_key", "chart_api_key_enc"] + + for key in deprecated_keys: + if key in self.settings: + self.log.info(f"Removing deprecated setting: {key}") + self.settings.pop(key) + settings_changed = True + + if settings_changed: + self._save_settings() + def start(self): import platform @@ -1078,6 +1149,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): "SQLite {} or higher required.".format(".".join(MIN_SQLITE_VERSION)) ) + self._cleanupOldSettings() + upgradeDatabase(self, self.db_version) upgradeDatabaseData(self, self.db_data_version) @@ -1143,6 +1216,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.checkWalletSeed(c) + self._enable_rpc_pooling() + if "p2p_host" in self.settings: network_key = self.getNetworkKey(1) self._network = bsn.Network( @@ -1225,6 +1300,13 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): else: self.log.info("AMM autostart is disabled") + self._price_fetch_running = True + self._price_fetch_thread = threading.Thread( + target=self._backgroundPriceFetchLoop, daemon=True + ) + self._price_fetch_thread.start() + self.log.info("Background price fetching started") + def stopDaemon(self, coin) -> None: if coin in (Coins.XMR, Coins.DCR, Coins.WOW): return @@ -3867,7 +3949,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): # 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 - if offer.smsg_payload_version > 1: + if offer.smsg_payload_version is not None and offer.smsg_payload_version > 1: msg_buf.message_nets = self.getMessageNetsString() return msg_buf @@ -3917,7 +3999,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): # 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 - if offer.smsg_payload_version > 1: + if offer.smsg_payload_version is not None and offer.smsg_payload_version > 1: msg_buf.message_nets = self.getMessageNetsString() return msg_buf @@ -4004,7 +4086,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): # 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 - if offer.smsg_payload_version > 1: + if offer.smsg_payload_version is not None and offer.smsg_payload_version > 1: msg_buf.message_nets = self.getMessageNetsString() return msg_buf @@ -7908,7 +7990,17 @@ class BasicSwap(BaseApp, BSXNetwork, 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(), + "coin_from": offer_data.coin_from, + "coin_to": offer_data.coin_to, + "amount_from": offer_data.amount_from, + "amount_to": offer_data.amount_to, + }, + cursor, + ) else: existing_offer.setState(OfferStates.OFFER_RECEIVED) existing_offer.pk_from = pk_from @@ -11020,27 +11112,6 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): settings_copy["show_chart"] = new_value settings_changed = True - if "chart_api_key" in data: - new_value = data["chart_api_key"] - ensure( - isinstance(new_value, str), "New chart_api_key value not a string" - ) - ensure(len(new_value) <= 128, "New chart_api_key value too long") - if all(c in string.hexdigits for c in new_value): - if settings_copy.get("chart_api_key", "") != new_value: - settings_copy["chart_api_key"] = new_value - if "chart_api_key_enc" in settings_copy: - settings_copy.pop("chart_api_key_enc") - settings_changed = True - else: - # Encode value as hex to avoid escaping - new_value = new_value.encode("UTF-8").hex() - if settings_copy.get("chart_api_key_enc", "") != new_value: - settings_copy["chart_api_key_enc"] = new_value - if "chart_api_key" in settings_copy: - settings_copy.pop("chart_api_key") - settings_changed = True - if "coingecko_api_key" in data: new_value = data["coingecko_api_key"] ensure( @@ -11048,7 +11119,15 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): "New coingecko_api_key value not a string", ) ensure(len(new_value) <= 128, "New coingecko_api_keyvalue too long") - if all(c in string.hexdigits for c in new_value): + if new_value == "": + if ( + "coingecko_api_key" in settings_copy + or "coingecko_api_key_enc" in settings_copy + ): + settings_copy.pop("coingecko_api_key", None) + settings_copy.pop("coingecko_api_key_enc", None) + settings_changed = True + elif all(c in string.hexdigits for c in new_value): if settings_copy.get("coingecko_api_key", "") != new_value: settings_copy["coingecko_api_key"] = new_value if "coingecko_api_key_enc" in settings_copy: @@ -12301,11 +12380,15 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.closeDB(cursor, commit=False) def getLockedState(self): - if self._is_encrypted is None or self._is_locked is None: - self._is_encrypted, self._is_locked = self.ci( - Coins.PART - ).isWalletEncryptedLocked() - return self._is_encrypted, self._is_locked + try: + if self._is_encrypted is None or self._is_locked is None: + self._is_encrypted, self._is_locked = self.ci( + Coins.PART + ).isWalletEncryptedLocked() + return self._is_encrypted, self._is_locked + except Exception as e: + self.log.warning(f"getLockedState failed: {e}") + return False, False def getExchangeName(self, coin_id: int, exchange_name: str) -> str: if coin_id == Coins.BCH: @@ -12322,6 +12405,195 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): return chainparams[use_coinid]["name"] + def _backgroundPriceFetchLoop(self): + while self._price_fetch_running: + try: + now = int(time.time()) + if now - self._last_price_fetch >= self.price_fetch_interval: + self._fetchPricesBackground() + self._last_price_fetch = now + if now - self._last_volume_fetch >= self.volume_fetch_interval: + self._fetchVolumeBackground() + self._last_volume_fetch = now + except Exception as e: + self.log.error(f"Background price/volume fetch error: {e}") + + for _ in range(60): + if not self._price_fetch_running: + break + time.sleep(1) + + def _fetchPricesBackground(self): + all_coins = [c for c in Coins if c in chainparams] + if not all_coins: + return + + for rate_source in ["coingecko.com"]: + try: + self._fetchPricesForSource(all_coins, rate_source, Fiat.USD) + except Exception as e: + self.log.warning( + f"Background price fetch from {rate_source} failed: {e}" + ) + + def _fetchVolumeBackground(self): + all_coins = [c for c in Coins if c in chainparams] + if not all_coins: + return + + for rate_source in ["coingecko.com"]: + try: + self._fetchVolumeForSource(all_coins, rate_source) + except Exception as e: + self.log.warning( + f"Background volume fetch from {rate_source} failed: {e}" + ) + + def _fetchPricesForSource(self, coins_list, rate_source, currency_to): + now = int(time.time()) + headers = {"User-Agent": "Mozilla/5.0", "Connection": "close"} + + exchange_name_map = {} + coin_ids = "" + for coin_id in coins_list: + if len(coin_ids) > 0: + coin_ids += "," + exchange_name = self.getExchangeName(coin_id, rate_source) + coin_ids += exchange_name + exchange_name_map[exchange_name] = coin_id + + if rate_source == "coingecko.com": + ticker_to = fiatTicker(currency_to).lower() + api_key = get_api_key_setting( + self.settings, + "coingecko_api_key", + default_coingecko_api_key, + escape=True, + ) + url = f"https://api.coingecko.com/api/v3/simple/price?ids={coin_ids}&vs_currencies={ticker_to}" + if api_key != "": + url += f"&api_key={api_key}" + + js = json.loads(self.readURL(url, timeout=3, headers=headers)) + + with self._price_cache_lock: + for k, v in js.items(): + coin_id = exchange_name_map[k] + cache_key = (coin_id, currency_to, rate_source) + self._price_cache[cache_key] = { + "rate": v[ticker_to], + "timestamp": now, + } + + cursor = self.openDB() + try: + update_query = """ + UPDATE coinrates SET + rate=:rate, + last_updated=:last_updated + WHERE currency_from = :currency_from AND currency_to = :currency_to AND source = :rate_source + """ + + insert_query = """INSERT INTO coinrates(currency_from, currency_to, rate, source, last_updated) + VALUES(:currency_from, :currency_to, :rate, :rate_source, :last_updated)""" + + for k, v in js.items(): + coin_id = exchange_name_map[k] + cursor.execute( + update_query, + { + "currency_from": coin_id, + "currency_to": currency_to, + "rate": v[ticker_to], + "rate_source": rate_source, + "last_updated": now, + }, + ) + if cursor.rowcount < 1: + cursor.execute( + insert_query, + { + "currency_from": coin_id, + "currency_to": currency_to, + "rate": v[ticker_to], + "rate_source": rate_source, + "last_updated": now, + }, + ) + self.commitDB() + finally: + self.closeDB(cursor, commit=False) + + def _fetchVolumeForSource(self, coins_list, rate_source): + now = int(time.time()) + headers = {"User-Agent": "Mozilla/5.0", "Connection": "close"} + + exchange_name_map = {} + coin_ids = "" + for coin_id in coins_list: + if len(coin_ids) > 0: + coin_ids += "," + exchange_name = self.getExchangeName(coin_id, rate_source) + coin_ids += exchange_name + exchange_name_map[exchange_name] = coin_id + + if rate_source == "coingecko.com": + api_key = get_api_key_setting( + self.settings, + "coingecko_api_key", + default_coingecko_api_key, + escape=True, + ) + url = f"https://api.coingecko.com/api/v3/simple/price?ids={coin_ids}&vs_currencies=usd&include_24hr_vol=true&include_24hr_change=true" + if api_key != "": + url += f"&api_key={api_key}" + + js = json.loads(self.readURL(url, timeout=3, headers=headers)) + + with self._price_cache_lock: + for k, v in js.items(): + coin_id = exchange_name_map[k] + cache_key = (coin_id, rate_source) + volume_24h = v.get("usd_24h_vol") + price_change_24h = v.get("usd_24h_change") + self._volume_cache[cache_key] = { + "volume_24h": ( + float(volume_24h) if volume_24h is not None else None + ), + "price_change_24h": ( + float(price_change_24h) + if price_change_24h is not None + else 0.0 + ), + "timestamp": now, + } + + cursor = self.openDB() + try: + for k, v in js.items(): + coin_id = exchange_name_map[k] + volume_24h = v.get("usd_24h_vol") + price_change_24h = v.get("usd_24h_change") + cursor.execute( + "INSERT OR REPLACE INTO coinvolume (coin_id, volume_24h, price_change_24h, source, last_updated) VALUES (:coin_id, :volume_24h, :price_change_24h, :rate_source, :last_updated)", + { + "coin_id": coin_id, + "volume_24h": ( + str(volume_24h) if volume_24h is not None else "None" + ), + "price_change_24h": ( + str(price_change_24h) + if price_change_24h is not None + else "0.0" + ), + "rate_source": rate_source, + "last_updated": now, + }, + ) + self.commitDB() + finally: + self.closeDB(cursor, commit=False) + def lookupFiatRates( self, coins_list, @@ -12329,9 +12601,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): rate_source: str = "coingecko.com", saved_ttl: int = 300, ): - if self.debug: - coins_list_display = ", ".join([Coins(c).name for c in coins_list]) - self.log.debug(f"lookupFiatRates {coins_list_display}.") + coins_list_display = ", ".join([Coins(c).name for c in coins_list]) + self.log.debug(f"lookupFiatRates {coins_list_display}.") ensure(len(coins_list) > 0, "Must specify coin/s") ensure(saved_ttl >= 0, "Invalid saved time") @@ -12339,7 +12610,13 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): oldest_time_valid: int = now - saved_ttl return_rates = {} - headers = {"User-Agent": "Mozilla/5.0", "Connection": "close"} + with self._price_cache_lock: + for coin_id in coins_list: + cache_key = (coin_id, currency_to, rate_source) + if cache_key in self._price_cache: + cached = self._price_cache[cache_key] + if cached["timestamp"] >= oldest_time_valid: + return_rates[coin_id] = cached["rate"] cursor = self.openDB() try: @@ -12365,122 +12642,188 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): rows = cursor.execute(query, parameters) for row in rows: - return_rates[int(row[0])] = float(row[1]) + coin_id = int(row[0]) + if coin_id not in return_rates: + return_rates[coin_id] = float(row[1]) + + return return_rates + finally: + self.closeDB(cursor, commit=False) + + def lookupVolume( + self, + coins_list, + rate_source: str = "coingecko.com", + saved_ttl: int = 300, + ): + coins_list_display = ", ".join([Coins(c).name for c in coins_list]) + self.log.debug(f"lookupVolume {coins_list_display}.") + ensure(len(coins_list) > 0, "Must specify coin/s") + ensure(saved_ttl >= 0, "Invalid saved time") + + now: int = int(time.time()) + oldest_time_valid: int = now - saved_ttl + return_data = {} + + with self._price_cache_lock: + for coin_id in coins_list: + cache_key = (coin_id, rate_source) + if cache_key in self._volume_cache: + cached = self._volume_cache[cache_key] + if cached["timestamp"] >= oldest_time_valid: + return_data[coin_id] = { + "volume_24h": cached["volume_24h"], + "price_change_24h": cached["price_change_24h"], + } + + cursor = self.openDB() + try: + parameters = { + "rate_source": rate_source, + "oldest_time_valid": oldest_time_valid, + } + coins_list_query = "" + for i, coin_id in enumerate(coins_list): + try: + _ = Coins(coin_id) + except Exception: + raise ValueError(f"Unknown coin type {coin_id}") + + param_name = f"coin_{i}" + if i > 0: + coins_list_query += "," + coins_list_query += f":{param_name}" + parameters[param_name] = coin_id + + query = f"SELECT coin_id, volume_24h, price_change_24h FROM coinvolume WHERE coin_id IN ({coins_list_query}) AND source = :rate_source AND last_updated >= :oldest_time_valid" + rows = cursor.execute(query, parameters) + + for row in rows: + coin_id = int(row[0]) + if coin_id in return_data: + continue + volume_24h = None + price_change_24h = 0.0 + try: + if row[1] is not None and row[1] != "None": + volume_24h = float(row[1]) + except (ValueError, TypeError): + pass + try: + if row[2] is not None and row[2] != "None": + price_change_24h = float(row[2]) + except (ValueError, TypeError): + pass + return_data[coin_id] = { + "volume_24h": volume_24h, + "price_change_24h": price_change_24h, + } + + return return_data + finally: + self.closeDB(cursor, commit=False) + + def lookupHistoricalData( + self, + coins_list, + days: int = 1, + rate_source: str = "coingecko.com", + saved_ttl: int = 3600, + ): + coins_list_display = ", ".join([Coins(c).name for c in coins_list]) + self.log.debug(f"lookupHistoricalData {coins_list_display}, days={days}.") + ensure(len(coins_list) > 0, "Must specify coin/s") + ensure(saved_ttl >= 0, "Invalid saved time") + ensure(days > 0, "Days must be positive") + + now: int = int(time.time()) + oldest_time_valid: int = now - saved_ttl + return_data = {} + + headers = {"User-Agent": "Mozilla/5.0", "Connection": "close"} + + cursor = self.openDB() + try: + parameters = { + "rate_source": rate_source, + "oldest_time_valid": oldest_time_valid, + "days": days, + } + coins_list_query = "" + for i, coin_id in enumerate(coins_list): + try: + _ = Coins(coin_id) + except Exception: + raise ValueError(f"Unknown coin type {coin_id}") + + param_name = f"coin_{i}" + if i > 0: + coins_list_query += "," + coins_list_query += f":{param_name}" + parameters[param_name] = coin_id + + query = f"SELECT coin_id, price_data FROM coinhistory WHERE coin_id IN ({coins_list_query}) AND days = :days AND source = :rate_source AND last_updated >= :oldest_time_valid" + rows = cursor.execute(query, parameters) + + for row in rows: + return_data[int(row[0])] = json.loads(row[1]) need_coins = [] new_values = {} - exchange_name_map = {} for coin_id in coins_list: - if coin_id not in return_rates: + if coin_id not in return_data: need_coins.append(coin_id) if len(need_coins) < 1: - return return_rates + return return_data if rate_source == "coingecko.com": - ticker_to: str = fiatTicker(currency_to).lower() - # Update all requested coins - coin_ids: str = "" - for coin_id in coins_list: - if len(coin_ids) > 0: - coin_ids += "," - exchange_name: str = self.getExchangeName(coin_id, rate_source) - coin_ids += exchange_name - exchange_name_map[exchange_name] = coin_id - api_key: str = get_api_key_setting( self.settings, "coingecko_api_key", default_coingecko_api_key, escape=True, ) - url: str = ( - f"https://api.coingecko.com/api/v3/simple/price?ids={coin_ids}&vs_currencies={ticker_to}" - ) - if api_key != "": - url += f"&api_key={api_key}" - self.log.debug(f"lookupFiatRates: {url}") - js = json.loads(self.readURL(url, timeout=10, headers=headers)) + for coin_id in need_coins: + try: + exchange_name: str = self.getExchangeName(coin_id, rate_source) + url: str = ( + f"https://api.coingecko.com/api/v3/coins/{exchange_name}/market_chart?vs_currency=usd&days={days}" + ) + if api_key != "": + url += f"&api_key={api_key}" - for k, v in js.items(): - return_rates[int(exchange_name_map[k])] = v[ticker_to] - new_values[exchange_name_map[k]] = v[ticker_to] - elif rate_source == "cryptocompare.com": - ticker_to: str = fiatTicker(currency_to).upper() - api_key: str = get_api_key_setting( - self.settings, - "chart_api_key", - default_chart_api_key, - escape=True, - ) - if len(need_coins) == 1: - coin_ticker: str = chainparams[coin_id]["ticker"] - url: str = ( - f"https://min-api.cryptocompare.com/data/price?fsym={coin_ticker}&tsyms={ticker_to}" - ) - self.log.debug(f"lookupFiatRates: {url}") - js = json.loads(self.readURL(url, timeout=10, headers=headers)) - return_rates[int(coin_id)] = js[ticker_to] - new_values[coin_id] = js[ticker_to] - else: - coin_ids: str = "" - for coin_id in coins_list: - if len(coin_ids) > 0: - coin_ids += "," - coin_ticker: str = chainparams[coin_id]["ticker"] - coin_ids += coin_ticker - exchange_name_map[coin_ticker] = coin_id - url: str = ( - f"https://min-api.cryptocompare.com/data/pricemulti?fsyms={coin_ids}&tsyms={ticker_to}" - ) - self.log.debug(f"lookupFiatRates: {url}") - js = json.loads(self.readURL(url, timeout=10, headers=headers)) - for k, v in js.items(): - return_rates[int(exchange_name_map[k])] = v[ticker_to] - new_values[exchange_name_map[k]] = v[ticker_to] + js = json.loads(self.readURL(url, timeout=5, headers=headers)) + + if "prices" in js: + return_data[coin_id] = js["prices"] + new_values[coin_id] = js["prices"] + except Exception as e: + self.log.warning( + f"Could not fetch historical data for {Coins(coin_id).name}: {e}" + ) + return_data[coin_id] = [] else: raise ValueError(f"Unknown rate source {rate_source}") if len(new_values) < 1: - return return_rates + return return_data - # ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint - update_query = """ - UPDATE coinrates SET - rate=:rate, - last_updated=:last_updated - WHERE currency_from = :currency_from AND currency_to = :currency_to AND source = :rate_source - """ - - insert_query = """INSERT INTO coinrates(currency_from, currency_to, rate, source, last_updated) - VALUES(:currency_from, :currency_to, :rate, :rate_source, :last_updated)""" - - for k, v in new_values.items(): + for coin_id, price_data in new_values.items(): cursor.execute( - update_query, + "INSERT OR REPLACE INTO coinhistory (coin_id, days, price_data, source, last_updated) VALUES (:coin_id, :days, :price_data, :rate_source, :last_updated)", { - "currency_from": k, - "currency_to": currency_to, - "rate": v, + "coin_id": coin_id, + "days": days, + "price_data": json.dumps(price_data), "rate_source": rate_source, "last_updated": now, }, ) - if cursor.rowcount < 1: - cursor.execute( - insert_query, - { - "currency_from": k, - "currency_to": currency_to, - "rate": v, - "rate_source": rate_source, - "last_updated": now, - }, - ) self.commitDB() - return return_rates + return return_data finally: self.closeDB(cursor, commit=False) @@ -12502,14 +12845,35 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if rate_sources.get("coingecko.com", True): try: - js = self.lookupFiatRates([int(coin_from), int(coin_to)]) - rate = float(js[int(coin_from)]) / float(js[int(coin_to)]) - js["rate_inferred"] = ci_to.format_amount(rate, conv_int=True, r=1) + price_coin_from = int(coin_from) + price_coin_to = int(coin_to) - js[name_from] = {"usd": js[int(coin_from)]} - js.pop(int(coin_from)) - js[name_to] = {"usd": js[int(coin_to)]} - js.pop(int(coin_to)) + if price_coin_from in (Coins.PART_BLIND, Coins.PART_ANON): + price_coin_from = Coins.PART + elif price_coin_from == Coins.LTC_MWEB: + price_coin_from = Coins.LTC + + if price_coin_to in (Coins.PART_BLIND, Coins.PART_ANON): + price_coin_to = Coins.PART + elif price_coin_to == Coins.LTC_MWEB: + price_coin_to = Coins.LTC + + if price_coin_from == price_coin_to: + rate = 1.0 + js = self.lookupFiatRates([price_coin_from]) + usd_price = js[price_coin_from] + js["rate_inferred"] = ci_to.format_amount(rate, conv_int=True, r=1) + js[name_from] = {"usd": usd_price} + js[name_to] = {"usd": usd_price} + js.pop(price_coin_from) + else: + js = self.lookupFiatRates([price_coin_from, price_coin_to]) + rate = float(js[price_coin_from]) / float(js[price_coin_to]) + js["rate_inferred"] = ci_to.format_amount(rate, conv_int=True, r=1) + js[name_from] = {"usd": js[price_coin_from]} + js.pop(price_coin_from) + js[name_to] = {"usd": js[price_coin_to]} + js.pop(price_coin_to) rv["coingecko"] = js except Exception as e: diff --git a/basicswap/config.py b/basicswap/config.py index cc010a0..ea76b54 100644 --- a/basicswap/config.py +++ b/basicswap/config.py @@ -9,6 +9,8 @@ import os CONFIG_FILENAME = "basicswap.json" BASICSWAP_DATADIR = os.getenv("BASICSWAP_DATADIR", os.path.join("~", ".basicswap")) DEFAULT_ALLOW_CORS = False +DEFAULT_RPC_POOL_ENABLED = True +DEFAULT_RPC_POOL_MAX_CONNECTIONS = 5 TEST_DATADIRS = os.path.expanduser(os.getenv("DATADIRS", "/tmp/basicswap")) DEFAULT_TEST_BINDIR = os.path.expanduser( os.getenv("DEFAULT_TEST_BINDIR", os.path.join("~", ".basicswap", "bin")) diff --git a/basicswap/db.py b/basicswap/db.py index 2c3286d..a0a15d5 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 = 31 +CURRENT_DB_VERSION = 32 CURRENT_DB_DATA_VERSION = 7 @@ -674,6 +674,28 @@ class CoinRates(Table): last_updated = Column("integer") +class CoinVolume(Table): + __tablename__ = "coinvolume" + + record_id = Column("integer", primary_key=True, autoincrement=True) + coin_id = Column("integer") + volume_24h = Column("string") + price_change_24h = Column("string") + source = Column("string") + last_updated = Column("integer") + + +class CoinHistory(Table): + __tablename__ = "coinhistory" + + record_id = Column("integer", primary_key=True, autoincrement=True) + coin_id = Column("integer") + days = Column("integer") + price_data = Column("blob") + source = Column("string") + last_updated = Column("integer") + + class MessageNetworks(Table): __tablename__ = "message_networks" diff --git a/basicswap/explorers.py b/basicswap/explorers.py index 8d10eeb..b34f254 100644 --- a/basicswap/explorers.py +++ b/basicswap/explorers.py @@ -8,9 +8,6 @@ import json -default_chart_api_key = ( - "95dd900af910656e0e17c41f2ddc5dba77d01bf8b0e7d2787634a16bd976c553" -) default_coingecko_api_key = "CG-8hm3r9iLfpEXv4ied8oLbeUj" diff --git a/basicswap/http_server.py b/basicswap/http_server.py index 77bbe28..ba30795 100644 --- a/basicswap/http_server.py +++ b/basicswap/http_server.py @@ -14,7 +14,7 @@ import threading import http.client import base64 -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from jinja2 import Environment, PackageLoader from socket import error as SocketError from urllib import parse @@ -169,15 +169,16 @@ class HttpHandler(BaseHTTPRequestHandler): if not session_id: return False - session_data = self.server.active_sessions.get(session_id) - if session_data and session_data["expires"] > datetime.now(timezone.utc): - session_data["expires"] = datetime.now(timezone.utc) + timedelta( - minutes=SESSION_DURATION_MINUTES - ) - return True + with self.server.session_lock: + session_data = self.server.active_sessions.get(session_id) + if session_data and session_data["expires"] > datetime.now(timezone.utc): + session_data["expires"] = datetime.now(timezone.utc) + timedelta( + minutes=SESSION_DURATION_MINUTES + ) + return True - if session_id in self.server.active_sessions: - del self.server.active_sessions[session_id] + if session_id in self.server.active_sessions: + del self.server.active_sessions[session_id] return False def log_error(self, format, *args): @@ -195,10 +196,11 @@ class HttpHandler(BaseHTTPRequestHandler): return None form_data = parse.parse_qs(post_string) form_id = form_data[b"formid"][0].decode("utf-8") - if self.server.last_form_id.get(name, None) == form_id: - messages.append("Prevented double submit for form {}.".format(form_id)) - return None - self.server.last_form_id[name] = form_id + with self.server.form_id_lock: + if self.server.last_form_id.get(name, None) == form_id: + messages.append("Prevented double submit for form {}.".format(form_id)) + return None + self.server.last_form_id[name] = form_id return form_data def render_template( @@ -216,43 +218,48 @@ class HttpHandler(BaseHTTPRequestHandler): args_dict["debug_mode"] = True if swap_client.debug_ui: args_dict["debug_ui_mode"] = True - if swap_client.use_tor_proxy: - args_dict["use_tor_proxy"] = True + + is_authenticated = self.is_authenticated() or not swap_client.settings.get( + "client_auth_hash" + ) + + if is_authenticated: + if swap_client.use_tor_proxy: + args_dict["use_tor_proxy"] = True + try: + tor_state = get_tor_established_state(swap_client) + args_dict["tor_established"] = True if tor_state == "1" else False + except Exception: + args_dict["tor_established"] = False + + from .ui.page_amm import get_amm_status, get_amm_active_count + try: - tor_state = get_tor_established_state(swap_client) - args_dict["tor_established"] = True if tor_state == "1" else False - except Exception as e: - args_dict["tor_established"] = False - if swap_client.debug: - swap_client.log.error(f"Error getting Tor state: {str(e)}") - swap_client.log.error(traceback.format_exc()) + args_dict["current_status"] = get_amm_status() + args_dict["amm_active_count"] = get_amm_active_count(swap_client) + except Exception: + args_dict["current_status"] = "stopped" + args_dict["amm_active_count"] = 0 - from .ui.page_amm import get_amm_status, get_amm_active_count - - try: - args_dict["current_status"] = get_amm_status() - args_dict["amm_active_count"] = get_amm_active_count(swap_client) - except Exception as e: - args_dict["current_status"] = "stopped" + if swap_client._show_notifications: + args_dict["notifications"] = swap_client.getNotifications() + else: + args_dict["current_status"] = "unknown" args_dict["amm_active_count"] = 0 - if swap_client.debug: - swap_client.log.error(f"Error getting AMM state: {str(e)}") - swap_client.log.error(traceback.format_exc()) - - if swap_client._show_notifications: - args_dict["notifications"] = swap_client.getNotifications() if "messages" in args_dict: messages_with_ids = [] - for msg in args_dict["messages"]: - messages_with_ids.append((self.server.msg_id_counter, msg)) - self.server.msg_id_counter += 1 + with self.server.msg_id_lock: + for msg in args_dict["messages"]: + messages_with_ids.append((self.server.msg_id_counter, msg)) + self.server.msg_id_counter += 1 args_dict["messages"] = messages_with_ids if "err_messages" in args_dict: err_messages_with_ids = [] - for msg in args_dict["err_messages"]: - err_messages_with_ids.append((self.server.msg_id_counter, msg)) - self.server.msg_id_counter += 1 + with self.server.msg_id_lock: + for msg in args_dict["err_messages"]: + err_messages_with_ids.append((self.server.msg_id_counter, msg)) + self.server.msg_id_counter += 1 args_dict["err_messages"] = err_messages_with_ids if self.path: @@ -266,15 +273,27 @@ class HttpHandler(BaseHTTPRequestHandler): args_dict["current_page"] = "index" shutdown_token = os.urandom(8).hex() - self.server.session_tokens["shutdown"] = shutdown_token + with self.server.session_lock: + self.server.session_tokens["shutdown"] = shutdown_token args_dict["shutdown_token"] = shutdown_token - encrypted, locked = swap_client.getLockedState() - args_dict["encrypted"] = encrypted - args_dict["locked"] = locked + if is_authenticated: + try: + encrypted, locked = swap_client.getLockedState() + args_dict["encrypted"] = encrypted + args_dict["locked"] = locked + except Exception as e: + args_dict["encrypted"] = False + args_dict["locked"] = False + if swap_client.debug: + swap_client.log.warning(f"Could not get wallet locked state: {e}") + else: + args_dict["encrypted"] = args_dict.get("encrypted", False) + args_dict["locked"] = args_dict.get("locked", False) - if self.server.msg_id_counter >= 0x7FFFFFFF: - self.server.msg_id_counter = 0 + with self.server.msg_id_lock: + if self.server.msg_id_counter >= 0x7FFFFFFF: + self.server.msg_id_counter = 0 args_dict["version"] = version @@ -364,7 +383,8 @@ class HttpHandler(BaseHTTPRequestHandler): expires = datetime.now(timezone.utc) + timedelta( minutes=SESSION_DURATION_MINUTES ) - self.server.active_sessions[session_id] = {"expires": expires} + with self.server.session_lock: + self.server.active_sessions[session_id] = {"expires": expires} cookie_header = self._set_session_cookie(session_id) if is_json_request: @@ -628,13 +648,15 @@ class HttpHandler(BaseHTTPRequestHandler): if len(url_split) > 2: token = url_split[2] - expect_token = self.server.session_tokens.get("shutdown", None) + with self.server.session_lock: + expect_token = self.server.session_tokens.get("shutdown", None) if token != expect_token: return self.page_info("Unexpected token, still running.") session_id = self._get_session_cookie() - if session_id and session_id in self.server.active_sessions: - del self.server.active_sessions[session_id] + with self.server.session_lock: + if session_id and session_id in self.server.active_sessions: + del self.server.active_sessions[session_id] clear_cookie_header = self._clear_session_cookie() extra_headers.append(clear_cookie_header) @@ -935,22 +957,28 @@ class HttpHandler(BaseHTTPRequestHandler): return self.page_error(str(ex)) def do_GET(self): - response = self.handle_http(200, self.path) try: - self.wfile.write(response) - except SocketError as e: - self.server.swap_client.log.debug(f"do_GET SocketError {e}") + response = self.handle_http(200, self.path) + try: + self.wfile.write(response) + except SocketError as e: + self.server.swap_client.log.debug(f"do_GET SocketError {e}") + finally: + pass def do_POST(self): - content_length = int(self.headers.get("Content-Length", 0)) - post_string = self.rfile.read(content_length) - - is_json = True if "json" in self.headers.get("Content-Type", "") else False - response = self.handle_http(200, self.path, post_string, is_json) try: - self.wfile.write(response) - except SocketError as e: - self.server.swap_client.log.debug(f"do_POST SocketError {e}") + content_length = int(self.headers.get("Content-Length", 0)) + post_string = self.rfile.read(content_length) + + is_json = True if "json" in self.headers.get("Content-Type", "") else False + response = self.handle_http(200, self.path, post_string, is_json) + try: + self.wfile.write(response) + except SocketError as e: + self.server.swap_client.log.debug(f"do_POST SocketError {e}") + finally: + pass def do_HEAD(self): self.putHeaders(200, "text/html") @@ -963,7 +991,9 @@ class HttpHandler(BaseHTTPRequestHandler): self.end_headers() -class HttpThread(threading.Thread, HTTPServer): +class HttpThread(threading.Thread, ThreadingHTTPServer): + daemon_threads = True + def __init__(self, host_name, port_no, allow_cors, swap_client): threading.Thread.__init__(self) @@ -979,8 +1009,15 @@ class HttpThread(threading.Thread, HTTPServer): self.env = env self.msg_id_counter = 0 + self.session_lock = threading.Lock() + self.form_id_lock = threading.Lock() + self.msg_id_lock = threading.Lock() + self.timeout = 60 - HTTPServer.__init__(self, (self.host_name, self.port_no), HttpHandler) + ThreadingHTTPServer.__init__(self, (self.host_name, self.port_no), HttpHandler) + + if swap_client.debug: + swap_client.log.info("HTTP server initialized with threading support") def stop(self): self.stop_event.set() diff --git a/basicswap/js_server.py b/basicswap/js_server.py index 4b37d6f..a29d64e 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -1395,6 +1395,111 @@ def js_coinprices(self, url_split, post_string, is_json) -> bytes: ) +def js_coinvolume(self, url_split, post_string, is_json) -> bytes: + swap_client = self.server.swap_client + post_data = {} if post_string == "" else getFormData(post_string, is_json) + if not have_data_entry(post_data, "coins"): + raise ValueError("Requires coins list.") + + rate_source: str = "coingecko.com" + if have_data_entry(post_data, "source"): + rate_source = get_data_entry(post_data, "source") + + match_input_key: bool = toBool( + get_data_entry_or(post_data, "match_input_key", "true") + ) + ttl: int = int(get_data_entry_or(post_data, "ttl", 300)) + + coins = get_data_entry(post_data, "coins") + coins_list = coins.split(",") + coin_ids = [] + input_id_map = {} + for coin in coins_list: + if coin.isdigit(): + try: + coin_id = Coins(int(coin)) + except Exception: + raise ValueError(f"Unknown coin type {coin}") + else: + try: + coin_id = getCoinIdFromTicker(coin) + except Exception: + try: + coin_id = getCoinIdFromName(coin) + except Exception: + raise ValueError(f"Unknown coin type {coin}") + coin_ids.append(coin_id) + input_id_map[coin_id] = coin + + volume_data = swap_client.lookupVolume( + coin_ids, rate_source=rate_source, saved_ttl=ttl + ) + + rv = {} + for k, v in volume_data.items(): + if match_input_key: + rv[input_id_map[k]] = v + else: + rv[int(k)] = v + return bytes( + json.dumps({"source": rate_source, "data": rv}), + "UTF-8", + ) + + +def js_coinhistory(self, url_split, post_string, is_json) -> bytes: + swap_client = self.server.swap_client + post_data = {} if post_string == "" else getFormData(post_string, is_json) + if not have_data_entry(post_data, "coins"): + raise ValueError("Requires coins list.") + + rate_source: str = "coingecko.com" + if have_data_entry(post_data, "source"): + rate_source = get_data_entry(post_data, "source") + + match_input_key: bool = toBool( + get_data_entry_or(post_data, "match_input_key", "true") + ) + ttl: int = int(get_data_entry_or(post_data, "ttl", 3600)) + days: int = int(get_data_entry_or(post_data, "days", 1)) + + coins = get_data_entry(post_data, "coins") + coins_list = coins.split(",") + coin_ids = [] + input_id_map = {} + for coin in coins_list: + if coin.isdigit(): + try: + coin_id = Coins(int(coin)) + except Exception: + raise ValueError(f"Unknown coin type {coin}") + else: + try: + coin_id = getCoinIdFromTicker(coin) + except Exception: + try: + coin_id = getCoinIdFromName(coin) + except Exception: + raise ValueError(f"Unknown coin type {coin}") + coin_ids.append(coin_id) + input_id_map[coin_id] = coin + + historical_data = swap_client.lookupHistoricalData( + coin_ids, days=days, rate_source=rate_source, saved_ttl=ttl + ) + + rv = {} + for k, v in historical_data.items(): + if match_input_key: + rv[input_id_map[k]] = v + else: + rv[int(k)] = v + return bytes( + json.dumps({"source": rate_source, "days": days, "data": rv}), + "UTF-8", + ) + + def js_messageroutes(self, url_split, post_string, is_json) -> bytes: swap_client = self.server.swap_client post_data = {} if post_string == "" else getFormData(post_string, is_json) @@ -1467,6 +1572,8 @@ endpoints = { "readurl": js_readurl, "active": js_active, "coinprices": js_coinprices, + "coinvolume": js_coinvolume, + "coinhistory": js_coinhistory, "messageroutes": js_messageroutes, } diff --git a/basicswap/rpc.py b/basicswap/rpc.py index 49eff25..9a400c4 100644 --- a/basicswap/rpc.py +++ b/basicswap/rpc.py @@ -6,8 +6,10 @@ # file LICENSE or http://www.opensource.org/licenses/mit-license.php. import json +import logging import traceback import urllib +import http.client from xmlrpc.client import ( Fault, Transport, @@ -15,6 +17,35 @@ from xmlrpc.client import ( ) from .util import jsonDecimal +_use_rpc_pooling = False +_rpc_pool_settings = {} + + +def enable_rpc_pooling(settings): + global _use_rpc_pooling, _rpc_pool_settings + _use_rpc_pooling = settings.get("enabled", False) + _rpc_pool_settings = settings + + +class TimeoutTransport(Transport): + def __init__(self, timeout=10, *args, **kwargs): + self.timeout = timeout + super().__init__(*args, **kwargs) + + def make_connection(self, host): + conn = http.client.HTTPConnection(host, timeout=self.timeout) + return conn + + +class TimeoutSafeTransport(SafeTransport): + def __init__(self, timeout=10, *args, **kwargs): + self.timeout = timeout + super().__init__(*args, **kwargs) + + def make_connection(self, host): + conn = http.client.HTTPSConnection(host, timeout=self.timeout) + return conn + class Jsonrpc: # __getattr__ complicates extending ServerProxy @@ -29,22 +60,40 @@ class Jsonrpc: use_builtin_types=False, *, context=None, + timeout=10, ): # establish a "logical" server connection - # get the url parsed = urllib.parse.urlparse(uri) if parsed.scheme not in ("http", "https"): raise OSError("unsupported XML-RPC protocol") - self.__host = parsed.netloc + + self.__auth = None + if "@" in parsed.netloc: + auth_part, host_port = parsed.netloc.rsplit("@", 1) + self.__host = host_port + if ":" in auth_part: + import base64 + + auth_bytes = auth_part.encode("utf-8") + auth_b64 = base64.b64encode(auth_bytes).decode("ascii") + self.__auth = f"Basic {auth_b64}" + else: + self.__host = parsed.netloc + + if not self.__host: + raise ValueError(f"Invalid or empty hostname in URI: {uri}") self.__handler = parsed.path if not self.__handler: self.__handler = "/RPC2" if transport is None: - handler = SafeTransport if parsed.scheme == "https" else Transport + handler = ( + TimeoutSafeTransport if parsed.scheme == "https" else TimeoutTransport + ) extra_kwargs = {} transport = handler( + timeout=timeout, use_datetime=use_datetime, use_builtin_types=use_builtin_types, **extra_kwargs, @@ -62,6 +111,7 @@ class Jsonrpc: self.__transport.close() def json_request(self, method, params): + connection = None try: connection = self.__transport.make_connection(self.__host) headers = self.__transport._extra_headers[:] @@ -71,6 +121,10 @@ class Jsonrpc: connection.putrequest("POST", self.__handler) headers.append(("Content-Type", "application/json")) headers.append(("User-Agent", "jsonrpc")) + + if self.__auth: + headers.append(("Authorization", self.__auth)) + self.__transport.send_headers(connection, headers) self.__transport.send_content( connection, @@ -79,18 +133,29 @@ class Jsonrpc: self.__request_id += 1 resp = connection.getresponse() - return resp.read() + result = resp.read() + + connection.close() + + return result except Fault: raise except Exception: - # All unexpected errors leave connection in - # a strange state, so we clear it. self.__transport.close() raise + finally: + if connection is not None: + try: + connection.close() + except Exception: + pass def callrpc(rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1"): + if _use_rpc_pooling: + return callrpc_pooled(rpc_port, auth, method, params, wallet, host) + try: url = "http://{}@{}:{}/".format(auth, host, rpc_port) if wallet is not None: @@ -101,7 +166,6 @@ def callrpc(rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1"): x.close() r = json.loads(v.decode("utf-8")) except Exception as ex: - traceback.print_exc() raise ValueError(f"RPC server error: {ex}, method: {method}") if "error" in r and r["error"] is not None: @@ -110,6 +174,62 @@ def callrpc(rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1"): return r["result"] +def callrpc_pooled(rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1"): + from .rpc_pool import get_rpc_pool + import http.client + import socket + + url = "http://{}@{}:{}/".format(auth, host, rpc_port) + if wallet is not None: + url += "wallet/" + urllib.parse.quote(wallet) + + max_connections = _rpc_pool_settings.get("max_connections_per_daemon", 5) + pool = get_rpc_pool(url, max_connections) + + max_retries = 2 + + for attempt in range(max_retries): + conn = pool.get_connection() + + try: + v = conn.json_request(method, params) + r = json.loads(v.decode("utf-8")) + + if "error" in r and r["error"] is not None: + pool.discard_connection(conn) + raise ValueError("RPC error " + str(r["error"])) + + pool.return_connection(conn) + return r["result"] + + except ( + http.client.RemoteDisconnected, + http.client.IncompleteRead, + http.client.BadStatusLine, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + BrokenPipeError, + TimeoutError, + socket.timeout, + socket.error, + OSError, + ) as ex: + pool.discard_connection(conn) + if attempt < max_retries - 1: + continue + logging.warning( + f"RPC server error after {max_retries} attempts: {ex}, method: {method}" + ) + raise ValueError(f"RPC server error: {ex}, method: {method}") + except ValueError: + raise + except Exception as ex: + pool.discard_connection(conn) + logging.error(f"Unexpected RPC error: {ex}, method: {method}") + raise ValueError(f"RPC server error: {ex}, method: {method}") + + def openrpc(rpc_port, auth, wallet=None, host="127.0.0.1"): try: url = "http://{}@{}:{}/".format(auth, host, rpc_port) @@ -142,5 +262,6 @@ def make_rpc_func(port, auth, wallet=None, host="127.0.0.1"): def escape_rpcauth(auth_str: str) -> str: username, password = auth_str.split(":", 1) + username = urllib.parse.quote(username, safe="") password = urllib.parse.quote(password, safe="") return f"{username}:{password}" diff --git a/basicswap/rpc_pool.py b/basicswap/rpc_pool.py new file mode 100644 index 0000000..3042937 --- /dev/null +++ b/basicswap/rpc_pool.py @@ -0,0 +1,131 @@ +# -*- 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 queue +import threading +import time +from basicswap.rpc import Jsonrpc + + +class RPCConnectionPool: + def __init__( + self, url, max_connections=5, timeout=10, logger=None, max_idle_time=300 + ): + self.url = url + self.max_connections = max_connections + self.timeout = timeout + self.logger = logger + self.max_idle_time = max_idle_time + self._pool = queue.Queue(maxsize=max_connections) + self._lock = threading.Lock() + self._created_connections = 0 + self._connection_timestamps = {} + + def get_connection(self): + try: + conn_data = self._pool.get(block=False) + conn, timestamp = ( + conn_data if isinstance(conn_data, tuple) else (conn_data, time.time()) + ) + + if time.time() - timestamp > self.max_idle_time: + if self.logger: + self.logger.debug( + f"RPC pool: discarding stale connection (idle for {time.time() - timestamp:.1f}s)" + ) + conn.close() + with self._lock: + if self._created_connections > 0: + self._created_connections -= 1 + return self._create_new_connection() + + return conn + except queue.Empty: + return self._create_new_connection() + + def _create_new_connection(self): + with self._lock: + if self._created_connections < self.max_connections: + self._created_connections += 1 + return Jsonrpc(self.url) + + try: + conn_data = self._pool.get(block=True, timeout=self.timeout) + conn, timestamp = ( + conn_data if isinstance(conn_data, tuple) else (conn_data, time.time()) + ) + + if time.time() - timestamp > self.max_idle_time: + if self.logger: + self.logger.debug( + f"RPC pool: discarding stale connection (idle for {time.time() - timestamp:.1f}s)" + ) + conn.close() + with self._lock: + if self._created_connections > 0: + self._created_connections -= 1 + return Jsonrpc(self.url) + + return conn + except queue.Empty: + if self.logger: + self.logger.warning( + f"RPC pool: timeout waiting for connection, creating temporary connection for {self.url}" + ) + return Jsonrpc(self.url) + + def return_connection(self, conn): + try: + self._pool.put((conn, time.time()), block=False) + except queue.Full: + conn.close() + with self._lock: + if self._created_connections > 0: + self._created_connections -= 1 + + def discard_connection(self, conn): + conn.close() + with self._lock: + if self._created_connections > 0: + self._created_connections -= 1 + + def close_all(self): + while not self._pool.empty(): + try: + conn_data = self._pool.get(block=False) + conn = conn_data[0] if isinstance(conn_data, tuple) else conn_data + conn.close() + except queue.Empty: + break + with self._lock: + self._created_connections = 0 + self._connection_timestamps.clear() + + +_rpc_pools = {} +_pool_lock = threading.Lock() +_pool_logger = None + + +def set_pool_logger(logger): + global _pool_logger + _pool_logger = logger + + +def get_rpc_pool(url, max_connections=5): + with _pool_lock: + if url not in _rpc_pools: + _rpc_pools[url] = RPCConnectionPool( + url, max_connections, logger=_pool_logger + ) + return _rpc_pools[url] + + +def close_all_pools(): + with _pool_lock: + for pool in _rpc_pools.values(): + pool.close_all() + _rpc_pools.clear() diff --git a/basicswap/static/js/modules/api-manager.js b/basicswap/static/js/modules/api-manager.js index 0a332fe..12b0914 100644 --- a/basicswap/static/js/modules/api-manager.js +++ b/basicswap/static/js/modules/api-manager.js @@ -4,34 +4,34 @@ const ApiManager = (function() { isInitialized: false }; - const config = { - requestTimeout: 60000, - retryDelays: [5000, 15000, 30000], - rateLimits: { - coingecko: { - requestsPerMinute: 50, - minInterval: 1200 - }, - cryptocompare: { - requestsPerMinute: 30, - minInterval: 2000 + function getConfig() { + return window.config || window.ConfigManager || { + requestTimeout: 60000, + retryDelays: [5000, 15000, 30000], + rateLimits: { + coingecko: { requestsPerMinute: 50, minInterval: 1200 } } - } - }; + }; + } const rateLimiter = { lastRequestTime: {}, - minRequestInterval: { - coingecko: 1200, - cryptocompare: 2000 - }, requestQueue: {}, - retryDelays: [5000, 15000, 30000], + + getMinInterval: function(apiName) { + const config = getConfig(); + return config.rateLimits?.[apiName]?.minInterval || 1200; + }, + + getRetryDelays: function() { + const config = getConfig(); + return config.retryDelays || [5000, 15000, 30000]; + }, canMakeRequest: function(apiName) { const now = Date.now(); const lastRequest = this.lastRequestTime[apiName] || 0; - return (now - lastRequest) >= this.minRequestInterval[apiName]; + return (now - lastRequest) >= this.getMinInterval(apiName); }, updateLastRequestTime: function(apiName) { @@ -41,7 +41,7 @@ const ApiManager = (function() { getWaitTime: function(apiName) { const now = Date.now(); const lastRequest = this.lastRequestTime[apiName] || 0; - return Math.max(0, this.minRequestInterval[apiName] - (now - lastRequest)); + return Math.max(0, this.getMinInterval(apiName) - (now - lastRequest)); }, queueRequest: async function(apiName, requestFn, retryCount = 0) { @@ -55,29 +55,30 @@ const ApiManager = (function() { const executeRequest = async () => { const waitTime = this.getWaitTime(apiName); if (waitTime > 0) { - await new Promise(resolve => setTimeout(resolve, waitTime)); + await new Promise(resolve => CleanupManager.setTimeout(resolve, waitTime)); } try { this.updateLastRequestTime(apiName); return await requestFn(); } catch (error) { - if (error.message.includes('429') && retryCount < this.retryDelays.length) { - const delay = this.retryDelays[retryCount]; + const retryDelays = this.getRetryDelays(); + if (error.message.includes('429') && retryCount < retryDelays.length) { + const delay = retryDelays[retryCount]; console.log(`Rate limit hit, retrying in ${delay/1000} seconds...`); - await new Promise(resolve => setTimeout(resolve, delay)); + await new Promise(resolve => CleanupManager.setTimeout(resolve, delay)); return publicAPI.rateLimiter.queueRequest(apiName, requestFn, retryCount + 1); } if ((error.message.includes('timeout') || error.name === 'NetworkError') && - retryCount < this.retryDelays.length) { - const delay = this.retryDelays[retryCount]; + retryCount < retryDelays.length) { + const delay = retryDelays[retryCount]; console.warn(`Request failed, retrying in ${delay/1000} seconds...`, { apiName, retryCount, error: error.message }); - await new Promise(resolve => setTimeout(resolve, delay)); + await new Promise(resolve => CleanupManager.setTimeout(resolve, delay)); return publicAPI.rateLimiter.queueRequest(apiName, requestFn, retryCount + 1); } @@ -118,19 +119,7 @@ const ApiManager = (function() { } if (options.config) { - Object.assign(config, options.config); - } - - if (config.rateLimits) { - Object.keys(config.rateLimits).forEach(api => { - if (config.rateLimits[api].minInterval) { - rateLimiter.minRequestInterval[api] = config.rateLimits[api].minInterval; - } - }); - } - - if (config.retryDelays) { - rateLimiter.retryDelays = [...config.retryDelays]; + console.log('[ApiManager] Config options provided, but using ConfigManager instead'); } if (window.CleanupManager) { @@ -143,6 +132,31 @@ const ApiManager = (function() { }, makeRequest: async function(url, method = 'GET', headers = {}, body = null) { + if (window.ErrorHandler) { + return window.ErrorHandler.safeExecuteAsync(async () => { + const options = { + method: method, + headers: { + 'Content-Type': 'application/json', + ...headers + }, + signal: AbortSignal.timeout(getConfig().requestTimeout || 60000) + }; + + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(url, options); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + }, `ApiManager.makeRequest(${url})`, null); + } + try { const options = { method: method, @@ -150,7 +164,7 @@ const ApiManager = (function() { 'Content-Type': 'application/json', ...headers }, - signal: AbortSignal.timeout(config.requestTimeout) + signal: AbortSignal.timeout(getConfig().requestTimeout || 60000) }; if (body) { @@ -233,11 +247,8 @@ const ApiManager = (function() { .join(',') : 'bitcoin,monero,particl,bitcoincash,pivx,firo,dash,litecoin,dogecoin,decred,namecoin'; - //console.log('Fetching coin prices for:', coins); const response = await this.fetchCoinPrices(coins); - //console.log('Full API response:', response); - if (!response || typeof response !== 'object') { throw new Error('Invalid response type'); } @@ -260,80 +271,38 @@ const ApiManager = (function() { fetchVolumeData: async function() { return this.rateLimiter.queueRequest('coingecko', async () => { try { - let coinList = (window.config && window.config.coins) ? - window.config.coins - .filter(coin => coin.usesCoinGecko) - .map(coin => { - return window.config.getCoinBackendId ? - window.config.getCoinBackendId(coin.name) : - (typeof getCoinBackendId === 'function' ? - getCoinBackendId(coin.name) : coin.name.toLowerCase()); - }) - .join(',') : - 'bitcoin,monero,particl,bitcoin-cash,pivx,firo,dash,litecoin,dogecoin,decred,namecoin,wownero'; + const coinSymbols = window.CoinManager + ? window.CoinManager.getAllCoins().map(c => c.symbol).filter(symbol => symbol && symbol.trim() !== '') + : (window.config.coins + ? window.config.coins.map(c => c.symbol).filter(symbol => symbol && symbol.trim() !== '') + : ['BTC', 'XMR', 'PART', 'BCH', 'PIVX', 'FIRO', 'DASH', 'LTC', 'DOGE', 'DCR', 'NMC', 'WOW']); - if (!coinList.includes('zcoin') && coinList.includes('firo')) { - coinList = coinList + ',zcoin'; - } - - const url = `https://api.coingecko.com/api/v3/simple/price?ids=${coinList}&vs_currencies=usd&include_24hr_vol=true&include_24hr_change=true`; - - const response = await this.makePostRequest(url, { - 'User-Agent': 'Mozilla/5.0', - 'Accept': 'application/json' + const response = await this.makeRequest('/json/coinvolume', 'POST', {}, { + coins: coinSymbols.join(','), + source: 'coingecko.com', + ttl: 300 }); - if (!response || typeof response !== 'object') { - throw new Error('Invalid response from CoinGecko API'); + if (!response) { + console.error('No response from backend'); + throw new Error('Invalid response from backend'); + } + + if (!response.data) { + console.error('Response missing data field:', response); + throw new Error('Invalid response from backend'); } const volumeData = {}; - Object.entries(response).forEach(([coinId, data]) => { - if (data && data.usd_24h_vol !== undefined) { - volumeData[coinId] = { - total_volume: data.usd_24h_vol || 0, - price_change_percentage_24h: data.usd_24h_change || 0 - }; - } + Object.entries(response.data).forEach(([coinSymbol, data]) => { + const coinKey = coinSymbol.toLowerCase(); + volumeData[coinKey] = { + total_volume: (data.volume_24h !== undefined && data.volume_24h !== null) ? data.volume_24h : null, + price_change_percentage_24h: data.price_change_24h || 0 + }; }); - const coinMappings = { - 'firo': ['firo', 'zcoin'], - 'zcoin': ['zcoin', 'firo'], - 'bitcoin-cash': ['bitcoin-cash', 'bitcoincash', 'bch'], - 'bitcoincash': ['bitcoincash', 'bitcoin-cash', 'bch'], - 'particl': ['particl', 'part'] - }; - - if (response['zcoin'] && (!volumeData['firo'] || volumeData['firo'].total_volume === 0)) { - volumeData['firo'] = { - total_volume: response['zcoin'].usd_24h_vol || 0, - price_change_percentage_24h: response['zcoin'].usd_24h_change || 0 - }; - } - - if (response['bitcoin-cash'] && (!volumeData['bitcoincash'] || volumeData['bitcoincash'].total_volume === 0)) { - volumeData['bitcoincash'] = { - total_volume: response['bitcoin-cash'].usd_24h_vol || 0, - price_change_percentage_24h: response['bitcoin-cash'].usd_24h_change || 0 - }; - } - - for (const [mainCoin, alternativeIds] of Object.entries(coinMappings)) { - if (!volumeData[mainCoin] || volumeData[mainCoin].total_volume === 0) { - for (const altId of alternativeIds) { - if (response[altId] && response[altId].usd_24h_vol) { - volumeData[mainCoin] = { - total_volume: response[altId].usd_24h_vol, - price_change_percentage_24h: response[altId].usd_24h_change || 0 - }; - break; - } - } - } - } - return volumeData; } catch (error) { console.error("Error fetching volume data:", error); @@ -342,104 +311,45 @@ const ApiManager = (function() { }); }, - fetchCryptoCompareData: function(coin) { - return this.rateLimiter.queueRequest('cryptocompare', async () => { - try { - const apiKey = window.config?.apiKeys?.cryptoCompare || ''; - const url = `https://min-api.cryptocompare.com/data/pricemultifull?fsyms=${coin}&tsyms=USD,BTC&api_key=${apiKey}`; - const headers = { - 'User-Agent': 'Mozilla/5.0', - 'Accept': 'application/json' - }; - - return await this.makePostRequest(url, headers); - } catch (error) { - console.error(`CryptoCompare request failed for ${coin}:`, error); - throw error; - } - }); - }, - fetchHistoricalData: async function(coinSymbols, resolution = 'day') { if (!Array.isArray(coinSymbols)) { coinSymbols = [coinSymbols]; } - const results = {}; - const fetchPromises = coinSymbols.map(async coin => { - let useCoinGecko = false; - let coingeckoId = null; - - if (window.CoinManager) { - const coinConfig = window.CoinManager.getCoinByAnyIdentifier(coin); - if (coinConfig) { - useCoinGecko = !coinConfig.usesCryptoCompare || coin === 'PART'; - coingeckoId = coinConfig.coingeckoId; + return this.rateLimiter.queueRequest('coingecko', async () => { + try { + let days; + if (resolution === 'day') { + days = 1; + } else if (resolution === 'year') { + days = 365; + } else { + days = 180; } - } else { - const coinGeckoCoins = { - 'WOW': 'wownero', - 'PART': 'particl', - 'BTC': 'bitcoin' - }; - if (coinGeckoCoins[coin]) { - useCoinGecko = true; - coingeckoId = coinGeckoCoins[coin]; + + const response = await this.makeRequest('/json/coinhistory', 'POST', {}, { + coins: coinSymbols.join(','), + days: days, + source: 'coingecko.com', + ttl: 3600 + }); + + if (!response) { + console.error('No response from backend'); + throw new Error('Invalid response from backend'); } - } - if (useCoinGecko && coingeckoId) { - return this.rateLimiter.queueRequest('coingecko', async () => { - let days; - if (resolution === 'day') { - days = 1; - } else if (resolution === 'year') { - days = 365; - } else { - days = 180; - } - const url = `https://api.coingecko.com/api/v3/coins/${coingeckoId}/market_chart?vs_currency=usd&days=${days}`; - try { - const response = await this.makePostRequest(url); - if (response && response.prices) { - results[coin] = response.prices; - } - } catch (error) { - console.error(`Error fetching CoinGecko data for ${coin}:`, error); - throw error; - } - }); - } else { - return this.rateLimiter.queueRequest('cryptocompare', async () => { - try { - const apiKey = window.config?.apiKeys?.cryptoCompare || ''; - let url; + if (!response.data) { + console.error('Response missing data field:', response); + throw new Error('Invalid response from backend'); + } - if (resolution === 'day') { - url = `https://min-api.cryptocompare.com/data/v2/histohour?fsym=${coin}&tsym=USD&limit=24&api_key=${apiKey}`; - } else if (resolution === 'year') { - url = `https://min-api.cryptocompare.com/data/v2/histoday?fsym=${coin}&tsym=USD&limit=365&api_key=${apiKey}`; - } else { - url = `https://min-api.cryptocompare.com/data/v2/histoday?fsym=${coin}&tsym=USD&limit=180&api_key=${apiKey}`; - } - - const response = await this.makePostRequest(url); - if (response.Response === "Error") { - console.error(`API Error for ${coin}:`, response.Message); - throw new Error(response.Message); - } else if (response.Data && response.Data.Data) { - results[coin] = response.Data; - } - } catch (error) { - console.error(`Error fetching CryptoCompare data for ${coin}:`, error); - throw error; - } - }); + return response.data; + } catch (error) { + console.error('Error fetching historical data:', error); + throw error; } }); - - await Promise.all(fetchPromises); - return results; }, dispose: function() { @@ -453,17 +363,6 @@ const ApiManager = (function() { return publicAPI; })(); -function getCoinBackendId(coinName) { - const nameMap = { - 'bitcoin-cash': 'bitcoincash', - 'bitcoin cash': 'bitcoincash', - 'firo': 'zcoin', - 'zcoin': 'zcoin', - 'bitcoincash': 'bitcoin-cash' - }; - return nameMap[coinName.toLowerCase()] || coinName.toLowerCase(); -} - window.Api = ApiManager; window.ApiManager = ApiManager; @@ -474,5 +373,4 @@ document.addEventListener('DOMContentLoaded', function() { } }); -//console.log('ApiManager initialized with methods:', Object.keys(ApiManager)); console.log('ApiManager initialized'); diff --git a/basicswap/static/js/modules/balance-updates.js b/basicswap/static/js/modules/balance-updates.js index 92f1735..465cc49 100644 --- a/basicswap/static/js/modules/balance-updates.js +++ b/basicswap/static/js/modules/balance-updates.js @@ -15,7 +15,21 @@ const BalanceUpdatesManager = (function() { initialized: false }; - function fetchBalanceData() { + async function fetchBalanceData() { + if (window.ApiManager) { + const data = await window.ApiManager.makeRequest('/json/walletbalances', 'GET'); + + if (data && data.error) { + throw new Error(data.error); + } + + if (!Array.isArray(data)) { + throw new Error('Invalid response format'); + } + + return data; + } + return fetch('/json/walletbalances', { headers: { 'Accept': 'application/json', @@ -43,27 +57,41 @@ const BalanceUpdatesManager = (function() { function clearTimeoutByKey(key) { if (state.timeouts.has(key)) { - clearTimeout(state.timeouts.get(key)); + const timeoutId = state.timeouts.get(key); + if (window.CleanupManager) { + window.CleanupManager.clearTimeout(timeoutId); + } else { + clearTimeout(timeoutId); + } state.timeouts.delete(key); } } function setTimeoutByKey(key, callback, delay) { clearTimeoutByKey(key); - const timeoutId = setTimeout(callback, delay); + const timeoutId = window.CleanupManager + ? window.CleanupManager.setTimeout(callback, delay) + : setTimeout(callback, delay); state.timeouts.set(key, timeoutId); } function clearIntervalByKey(key) { if (state.intervals.has(key)) { - clearInterval(state.intervals.get(key)); + const intervalId = state.intervals.get(key); + if (window.CleanupManager) { + window.CleanupManager.clearInterval(intervalId); + } else { + clearInterval(intervalId); + } state.intervals.delete(key); } } function setIntervalByKey(key, callback, interval) { clearIntervalByKey(key); - const intervalId = setInterval(callback, interval); + const intervalId = window.CleanupManager + ? window.CleanupManager.setInterval(callback, interval) + : setInterval(callback, interval); state.intervals.set(key, intervalId); } diff --git a/basicswap/static/js/modules/cache-manager.js b/basicswap/static/js/modules/cache-manager.js index 8211a89..9807995 100644 --- a/basicswap/static/js/modules/cache-manager.js +++ b/basicswap/static/js/modules/cache-manager.js @@ -1,9 +1,19 @@ const CacheManager = (function() { - const defaults = window.config?.cacheConfig?.storage || { - maxSizeBytes: 10 * 1024 * 1024, - maxItems: 200, - defaultTTL: 5 * 60 * 1000 - }; + function getDefaults() { + if (window.config?.cacheConfig?.storage) { + return window.config.cacheConfig.storage; + } + if (window.ConfigManager?.cacheConfig?.storage) { + return window.ConfigManager.cacheConfig.storage; + } + return { + maxSizeBytes: 10 * 1024 * 1024, + maxItems: 200, + defaultTTL: 5 * 60 * 1000 + }; + } + + const defaults = getDefaults(); const PRICES_CACHE_KEY = 'crypto_prices_unified'; @@ -45,8 +55,12 @@ const CacheManager = (function() { const cacheAPI = { getTTL: function(resourceType) { - const ttlConfig = window.config?.cacheConfig?.ttlSettings || {}; - return ttlConfig[resourceType] || window.config?.cacheConfig?.defaultTTL || defaults.defaultTTL; + const ttlConfig = window.config?.cacheConfig?.ttlSettings || + window.ConfigManager?.cacheConfig?.ttlSettings || {}; + const defaultTTL = window.config?.cacheConfig?.defaultTTL || + window.ConfigManager?.cacheConfig?.defaultTTL || + defaults.defaultTTL; + return ttlConfig[resourceType] || defaultTTL; }, set: function(key, value, resourceTypeOrCustomTtl = null) { @@ -73,13 +87,18 @@ const CacheManager = (function() { expiresAt: Date.now() + ttl }; - let serializedItem; - try { - serializedItem = JSON.stringify(item); - } catch (e) { - console.error('Failed to serialize cache item:', e); - return false; - } + const serializedItem = window.ErrorHandler + ? window.ErrorHandler.safeExecute(() => JSON.stringify(item), 'CacheManager.set.serialize', null) + : (() => { + try { + return JSON.stringify(item); + } catch (e) { + console.error('Failed to serialize cache item:', e); + return null; + } + })(); + + if (!serializedItem) return false; const itemSize = new Blob([serializedItem]).size; if (itemSize > defaults.maxSizeBytes) { @@ -118,7 +137,7 @@ const CacheManager = (function() { const keysToDelete = Array.from(memoryCache.keys()) .filter(k => isCacheKey(k)) .sort((a, b) => memoryCache.get(a).timestamp - memoryCache.get(b).timestamp) - .slice(0, Math.floor(memoryCache.size * 0.2)); // Remove oldest 20% + .slice(0, Math.floor(memoryCache.size * 0.2)); keysToDelete.forEach(k => memoryCache.delete(k)); } @@ -285,7 +304,7 @@ const CacheManager = (function() { const keysToDelete = Array.from(memoryCache.keys()) .filter(key => isCacheKey(key)) .sort((a, b) => memoryCache.get(a).timestamp - memoryCache.get(b).timestamp) - .slice(0, Math.floor(memoryCache.size * 0.3)); // Remove oldest 30% during aggressive cleanup + .slice(0, Math.floor(memoryCache.size * 0.3)); keysToDelete.forEach(key => memoryCache.delete(key)); } @@ -328,7 +347,6 @@ const CacheManager = (function() { .filter(key => isCacheKey(key)) .forEach(key => memoryCache.delete(key)); - //console.log("Cache cleared successfully"); return true; }, @@ -531,6 +549,4 @@ const CacheManager = (function() { window.CacheManager = CacheManager; - -//console.log('CacheManager initialized with methods:', Object.keys(CacheManager)); console.log('CacheManager initialized'); diff --git a/basicswap/static/js/modules/cleanup-manager.js b/basicswap/static/js/modules/cleanup-manager.js index c50a93c..d813747 100644 --- a/basicswap/static/js/modules/cleanup-manager.js +++ b/basicswap/static/js/modules/cleanup-manager.js @@ -233,7 +233,7 @@ const CleanupManager = (function() { }, setupMemoryOptimization: function(options = {}) { - const memoryCheckInterval = options.interval || 2 * 60 * 1000; // Default: 2 minutes + const memoryCheckInterval = options.interval || 2 * 60 * 1000; const maxCacheSize = options.maxCacheSize || 100; const maxDataSize = options.maxDataSize || 1000; diff --git a/basicswap/static/js/modules/coin-manager.js b/basicswap/static/js/modules/coin-manager.js index a84db04..9ebc24e 100644 --- a/basicswap/static/js/modules/coin-manager.js +++ b/basicswap/static/js/modules/coin-manager.js @@ -178,19 +178,7 @@ const CoinManager = (function() { function getCoinByAnyIdentifier(identifier) { if (!identifier) return null; const normalizedId = identifier.toString().toLowerCase().trim(); - const coin = coinAliasesMap[normalizedId]; - if (coin) return coin; - if (normalizedId.includes('bitcoin') && normalizedId.includes('cash') || - normalizedId === 'bch') { - return symbolToInfo['bch']; - } - if (normalizedId === 'zcoin' || normalizedId.includes('firo')) { - return symbolToInfo['firo']; - } - if (normalizedId.includes('particl')) { - return symbolToInfo['part']; - } - return null; + return coinAliasesMap[normalizedId] || null; } return { diff --git a/basicswap/static/js/modules/coin-utils.js b/basicswap/static/js/modules/coin-utils.js new file mode 100644 index 0000000..cb337a5 --- /dev/null +++ b/basicswap/static/js/modules/coin-utils.js @@ -0,0 +1,191 @@ +const CoinUtils = (function() { + function buildAliasesFromCoinManager() { + const aliases = {}; + const symbolMap = {}; + + if (window.CoinManager) { + const coins = window.CoinManager.getAllCoins(); + coins.forEach(coin => { + const canonical = coin.name.toLowerCase(); + aliases[canonical] = coin.aliases || [coin.name.toLowerCase()]; + symbolMap[canonical] = coin.symbol; + }); + } + + return { aliases, symbolMap }; + } + + let COIN_ALIASES = {}; + let CANONICAL_TO_SYMBOL = {}; + + function initializeAliases() { + const { aliases, symbolMap } = buildAliasesFromCoinManager(); + COIN_ALIASES = aliases; + CANONICAL_TO_SYMBOL = symbolMap; + } + + if (window.CoinManager) { + initializeAliases(); + } else { + document.addEventListener('DOMContentLoaded', () => { + if (window.CoinManager) { + initializeAliases(); + } + }); + } + + function getCanonicalName(coin) { + if (!coin) return null; + const lower = coin.toString().toLowerCase().trim(); + + for (const [canonical, aliases] of Object.entries(COIN_ALIASES)) { + if (aliases.includes(lower)) { + return canonical; + } + } + return lower; + } + + return { + normalizeCoinName: function(coin, priceData = null) { + const canonical = getCanonicalName(coin); + if (!canonical) return null; + + if (priceData) { + if (canonical === 'bitcoin-cash') { + if (priceData['bitcoin-cash']) return 'bitcoin-cash'; + if (priceData['bch']) return 'bch'; + if (priceData['bitcoincash']) return 'bitcoincash'; + return 'bitcoin-cash'; + } + + if (canonical === 'particl') { + if (priceData['part']) return 'part'; + if (priceData['particl']) return 'particl'; + return 'part'; + } + } + + return canonical; + }, + + isSameCoin: function(coin1, coin2) { + if (!coin1 || !coin2) return false; + + if (window.CoinManager) { + return window.CoinManager.coinMatches(coin1, coin2); + } + + const canonical1 = getCanonicalName(coin1); + const canonical2 = getCanonicalName(coin2); + if (canonical1 === canonical2) return true; + + const lower1 = coin1.toString().toLowerCase().trim(); + const lower2 = coin2.toString().toLowerCase().trim(); + + const particlVariants = ['particl', 'particl anon', 'particl blind', 'part', 'part_anon', 'part_blind']; + if (particlVariants.includes(lower1) && particlVariants.includes(lower2)) { + return true; + } + + if (lower1.includes(' ') || lower2.includes(' ')) { + const word1 = lower1.split(' ')[0]; + const word2 = lower2.split(' ')[0]; + if (word1 === word2 && word1.length > 4) { + return true; + } + } + + return false; + }, + + getCoinSymbol: function(identifier) { + if (!identifier) return null; + + if (window.CoinManager) { + const coin = window.CoinManager.getCoinByAnyIdentifier(identifier); + if (coin) return coin.symbol; + } + + const canonical = getCanonicalName(identifier); + if (canonical && CANONICAL_TO_SYMBOL[canonical]) { + return CANONICAL_TO_SYMBOL[canonical]; + } + + return identifier.toString().toUpperCase(); + }, + + getDisplayName: function(identifier) { + if (!identifier) return null; + + if (window.CoinManager) { + const coin = window.CoinManager.getCoinByAnyIdentifier(identifier); + if (coin) return coin.displayName || coin.name; + } + + const symbol = this.getCoinSymbol(identifier); + return symbol || identifier; + }, + + getCoinImage: function(coinName) { + if (!coinName) return null; + + const canonical = getCanonicalName(coinName); + const symbol = this.getCoinSymbol(canonical); + + if (!symbol) return null; + + const imagePath = `/static/images/coins/${symbol.toLowerCase()}.png`; + return imagePath; + }, + + getPriceKey: function(coin, priceData = null) { + return this.normalizeCoinName(coin, priceData); + }, + + getCoingeckoId: function(coinName) { + if (!coinName) return null; + + if (window.CoinManager) { + const coin = window.CoinManager.getCoinByAnyIdentifier(coinName); + if (coin && coin.coingeckoId) { + return coin.coingeckoId; + } + } + + const canonical = getCanonicalName(coinName); + return canonical; + }, + + formatCoinAmount: function(amount, decimals = 8) { + if (amount === null || amount === undefined) return '0'; + + const numAmount = parseFloat(amount); + if (isNaN(numAmount)) return '0'; + + return numAmount.toFixed(decimals).replace(/\.?0+$/, ''); + }, + + getAllAliases: function(coin) { + const canonical = getCanonicalName(coin); + return COIN_ALIASES[canonical] || [canonical]; + }, + + isValidCoin: function(coin) { + if (!coin) return false; + const canonical = getCanonicalName(coin); + return canonical !== null && COIN_ALIASES.hasOwnProperty(canonical); + }, + + refreshAliases: function() { + initializeAliases(); + return Object.keys(COIN_ALIASES).length; + } + }; +})(); + +if (typeof window !== 'undefined') { + window.CoinUtils = CoinUtils; +} + +console.log('CoinUtils module loaded'); diff --git a/basicswap/static/js/modules/config-manager.js b/basicswap/static/js/modules/config-manager.js index 7cd1bd3..63ab310 100644 --- a/basicswap/static/js/modules/config-manager.js +++ b/basicswap/static/js/modules/config-manager.js @@ -35,38 +35,22 @@ const ConfigManager = (function() { }, itemsPerPage: 50, apiEndpoints: { - cryptoCompare: 'https://min-api.cryptocompare.com/data/pricemultifull', coinGecko: 'https://api.coingecko.com/api/v3', - cryptoCompareHistorical: 'https://min-api.cryptocompare.com/data/v2/histoday', - cryptoCompareHourly: 'https://min-api.cryptocompare.com/data/v2/histohour', volumeEndpoint: 'https://api.coingecko.com/api/v3/simple/price' }, rateLimits: { coingecko: { requestsPerMinute: 50, minInterval: 1200 - }, - cryptocompare: { - requestsPerMinute: 30, - minInterval: 2000 } }, retryDelays: [5000, 15000, 30000], get coins() { - return window.CoinManager ? window.CoinManager.getAllCoins() : [ - { symbol: 'BTC', name: 'bitcoin', usesCryptoCompare: false, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'XMR', name: 'monero', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'PART', name: 'particl', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'BCH', name: 'bitcoincash', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'PIVX', name: 'pivx', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'FIRO', name: 'firo', displayName: 'Firo', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'DASH', name: 'dash', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'LTC', name: 'litecoin', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'DOGE', name: 'dogecoin', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'DCR', name: 'decred', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'NMC', name: 'namecoin', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 }, - { symbol: 'WOW', name: 'wownero', usesCryptoCompare: false, usesCoinGecko: true, historicalDays: 30 } - ]; + if (window.CoinManager) { + return window.CoinManager.getAllCoins(); + } + console.warn('[ConfigManager] CoinManager not available, returning empty array'); + return []; }, chartConfig: { colors: { @@ -108,12 +92,10 @@ const ConfigManager = (function() { if (typeof window.getAPIKeys === 'function') { const apiKeys = window.getAPIKeys(); return { - cryptoCompare: apiKeys.cryptoCompare || '', coinGecko: apiKeys.coinGecko || '' }; } return { - cryptoCompare: '', coinGecko: '' }; }, @@ -122,55 +104,20 @@ const ConfigManager = (function() { if (window.CoinManager) { return window.CoinManager.getPriceKey(coinName); } - const nameMap = { - 'bitcoin-cash': 'bitcoincash', - 'bitcoin cash': 'bitcoincash', - 'firo': 'firo', - 'zcoin': 'firo', - 'bitcoincash': 'bitcoin-cash' - }; - const lowerCoinName = typeof coinName === 'string' ? coinName.toLowerCase() : ''; - return nameMap[lowerCoinName] || lowerCoinName; + if (window.CoinUtils) { + return window.CoinUtils.normalizeCoinName(coinName); + } + return typeof coinName === 'string' ? coinName.toLowerCase() : ''; }, coinMatches: function(offerCoin, filterCoin) { if (!offerCoin || !filterCoin) return false; if (window.CoinManager) { return window.CoinManager.coinMatches(offerCoin, filterCoin); } - offerCoin = offerCoin.toLowerCase(); - filterCoin = filterCoin.toLowerCase(); - if (offerCoin === filterCoin) return true; - if ((offerCoin === 'firo' || offerCoin === 'zcoin') && - (filterCoin === 'firo' || filterCoin === 'zcoin')) { - return true; + if (window.CoinUtils) { + return window.CoinUtils.isSameCoin(offerCoin, filterCoin); } - if ((offerCoin === 'bitcoincash' && filterCoin === 'bitcoin cash') || - (offerCoin === 'bitcoin cash' && filterCoin === 'bitcoincash')) { - return true; - } - const particlVariants = ['particl', 'particl anon', 'particl blind']; - if (filterCoin === 'particl' && particlVariants.includes(offerCoin)) { - return true; - } - - if (filterCoin.includes(' ') || offerCoin.includes(' ')) { - const filterFirstWord = filterCoin.split(' ')[0]; - const offerFirstWord = offerCoin.split(' ')[0]; - - if (filterFirstWord === 'bitcoin' && offerFirstWord === 'bitcoin') { - const filterHasCash = filterCoin.includes('cash'); - const offerHasCash = offerCoin.includes('cash'); - return filterHasCash === offerHasCash; - } - - if (filterFirstWord === offerFirstWord && filterFirstWord.length > 4) { - return true; - } - } - if (particlVariants.includes(filterCoin)) { - return offerCoin === filterCoin; - } - return false; + return offerCoin.toLowerCase() === filterCoin.toLowerCase(); }, update: function(path, value) { const parts = path.split('.'); @@ -229,7 +176,7 @@ const ConfigManager = (function() { let timeoutId; return function(...args) { clearTimeout(timeoutId); - timeoutId = setTimeout(() => func(...args), delay); + timeoutId = CleanupManager.setTimeout(() => func(...args), delay); }; }, formatTimeLeft: function(timestamp) { diff --git a/basicswap/static/js/modules/dom-cache.js b/basicswap/static/js/modules/dom-cache.js new file mode 100644 index 0000000..f3d075d --- /dev/null +++ b/basicswap/static/js/modules/dom-cache.js @@ -0,0 +1,207 @@ +(function() { + 'use strict'; + + const originalGetElementById = document.getElementById.bind(document); + + const DOMCache = { + + cache: {}, + + get: function(id, forceRefresh = false) { + if (!id) { + console.warn('DOMCache: No ID provided'); + return null; + } + + if (!forceRefresh && this.cache[id]) { + + if (document.body.contains(this.cache[id])) { + return this.cache[id]; + } else { + + delete this.cache[id]; + } + } + + const element = originalGetElementById(id); + if (element) { + this.cache[id] = element; + } + + return element; + }, + + getMultiple: function(ids) { + const elements = {}; + ids.forEach(id => { + elements[id] = this.get(id); + }); + return elements; + }, + + setValue: function(id, value) { + const element = this.get(id); + if (element) { + element.value = value; + return true; + } + console.warn(`DOMCache: Element not found: ${id}`); + return false; + }, + + getValue: function(id, defaultValue = '') { + const element = this.get(id); + return element ? element.value : defaultValue; + }, + + setText: function(id, text) { + const element = this.get(id); + if (element) { + element.textContent = text; + return true; + } + console.warn(`DOMCache: Element not found: ${id}`); + return false; + }, + + getText: function(id, defaultValue = '') { + const element = this.get(id); + return element ? element.textContent : defaultValue; + }, + + addClass: function(id, className) { + const element = this.get(id); + if (element) { + element.classList.add(className); + return true; + } + return false; + }, + + removeClass: function(id, className) { + const element = this.get(id); + if (element) { + element.classList.remove(className); + return true; + } + return false; + }, + + toggleClass: function(id, className) { + const element = this.get(id); + if (element) { + element.classList.toggle(className); + return true; + } + return false; + }, + + show: function(id) { + const element = this.get(id); + if (element) { + element.style.display = ''; + return true; + } + return false; + }, + + hide: function(id) { + const element = this.get(id); + if (element) { + element.style.display = 'none'; + return true; + } + return false; + }, + + exists: function(id) { + return this.get(id) !== null; + }, + + clear: function(id) { + if (id) { + delete this.cache[id]; + } else { + this.cache = {}; + } + }, + + size: function() { + return Object.keys(this.cache).length; + }, + + validate: function() { + const ids = Object.keys(this.cache); + let removed = 0; + + ids.forEach(id => { + const element = this.cache[id]; + if (!document.body.contains(element)) { + delete this.cache[id]; + removed++; + } + }); + + return removed; + }, + + createScope: function(elementIds) { + const scope = {}; + + elementIds.forEach(id => { + Object.defineProperty(scope, id, { + get: () => this.get(id), + enumerable: true + }); + }); + + return scope; + }, + + batch: function(operations) { + Object.keys(operations).forEach(id => { + const ops = operations[id]; + const element = this.get(id); + + if (!element) { + console.warn(`DOMCache: Element not found in batch operation: ${id}`); + return; + } + + if (ops.value !== undefined) element.value = ops.value; + if (ops.text !== undefined) element.textContent = ops.text; + if (ops.html !== undefined) element.innerHTML = ops.html; + if (ops.class) element.classList.add(ops.class); + if (ops.removeClass) element.classList.remove(ops.removeClass); + if (ops.hide) element.style.display = 'none'; + if (ops.show) element.style.display = ''; + if (ops.disabled !== undefined) element.disabled = ops.disabled; + }); + } + }; + + window.DOMCache = DOMCache; + + if (!window.$) { + window.$ = function(id) { + return DOMCache.get(id); + }; + } + + document.getElementById = function(id) { + return DOMCache.get(id); + }; + + document.getElementByIdOriginal = originalGetElementById; + + if (window.CleanupManager) { + const validationInterval = CleanupManager.setInterval(() => { + DOMCache.validate(); + }, 30000); + + CleanupManager.registerResource('domCacheValidation', validationInterval, () => { + clearInterval(validationInterval); + }); + } + +})(); diff --git a/basicswap/static/js/modules/error-handler.js b/basicswap/static/js/modules/error-handler.js new file mode 100644 index 0000000..b17359b --- /dev/null +++ b/basicswap/static/js/modules/error-handler.js @@ -0,0 +1,215 @@ +const ErrorHandler = (function() { + const config = { + logErrors: true, + throwErrors: false, + errorCallbacks: [] + }; + + function formatError(error, context) { + const timestamp = new Date().toISOString(); + const contextStr = context ? ` [${context}]` : ''; + + if (error instanceof Error) { + return `${timestamp}${contextStr} ${error.name}: ${error.message}`; + } + + return `${timestamp}${contextStr} ${String(error)}`; + } + + function notifyCallbacks(error, context) { + config.errorCallbacks.forEach(callback => { + try { + callback(error, context); + } catch (e) { + console.error('[ErrorHandler] Error in callback:', e); + } + }); + } + + return { + configure: function(options = {}) { + Object.assign(config, options); + return this; + }, + + addCallback: function(callback) { + if (typeof callback === 'function') { + config.errorCallbacks.push(callback); + } + return this; + }, + + removeCallback: function(callback) { + const index = config.errorCallbacks.indexOf(callback); + if (index > -1) { + config.errorCallbacks.splice(index, 1); + } + return this; + }, + + safeExecute: function(fn, context = null, fallbackValue = null) { + try { + return fn(); + } catch (error) { + if (config.logErrors) { + console.error(formatError(error, context)); + } + + notifyCallbacks(error, context); + + if (config.throwErrors) { + throw error; + } + + return fallbackValue; + } + }, + + safeExecuteAsync: async function(fn, context = null, fallbackValue = null) { + try { + return await fn(); + } catch (error) { + if (config.logErrors) { + console.error(formatError(error, context)); + } + + notifyCallbacks(error, context); + + if (config.throwErrors) { + throw error; + } + + return fallbackValue; + } + }, + + wrap: function(fn, context = null, fallbackValue = null) { + return (...args) => { + try { + return fn(...args); + } catch (error) { + if (config.logErrors) { + console.error(formatError(error, context)); + } + + notifyCallbacks(error, context); + + if (config.throwErrors) { + throw error; + } + + return fallbackValue; + } + }; + }, + + wrapAsync: function(fn, context = null, fallbackValue = null) { + return async (...args) => { + try { + return await fn(...args); + } catch (error) { + if (config.logErrors) { + console.error(formatError(error, context)); + } + + notifyCallbacks(error, context); + + if (config.throwErrors) { + throw error; + } + + return fallbackValue; + } + }; + }, + + handleError: function(error, context = null, fallbackValue = null) { + if (config.logErrors) { + console.error(formatError(error, context)); + } + + notifyCallbacks(error, context); + + if (config.throwErrors) { + throw error; + } + + return fallbackValue; + }, + + try: function(fn, catchFn = null, finallyFn = null) { + try { + return fn(); + } catch (error) { + if (config.logErrors) { + console.error(formatError(error, 'ErrorHandler.try')); + } + + notifyCallbacks(error, 'ErrorHandler.try'); + + if (catchFn) { + return catchFn(error); + } + + if (config.throwErrors) { + throw error; + } + + return null; + } finally { + if (finallyFn) { + finallyFn(); + } + } + }, + + tryAsync: async function(fn, catchFn = null, finallyFn = null) { + try { + return await fn(); + } catch (error) { + if (config.logErrors) { + console.error(formatError(error, 'ErrorHandler.tryAsync')); + } + + notifyCallbacks(error, 'ErrorHandler.tryAsync'); + + if (catchFn) { + return await catchFn(error); + } + + if (config.throwErrors) { + throw error; + } + + return null; + } finally { + if (finallyFn) { + await finallyFn(); + } + } + }, + + createBoundary: function(context) { + return { + execute: (fn, fallbackValue = null) => { + return ErrorHandler.safeExecute(fn, context, fallbackValue); + }, + executeAsync: (fn, fallbackValue = null) => { + return ErrorHandler.safeExecuteAsync(fn, context, fallbackValue); + }, + wrap: (fn, fallbackValue = null) => { + return ErrorHandler.wrap(fn, context, fallbackValue); + }, + wrapAsync: (fn, fallbackValue = null) => { + return ErrorHandler.wrapAsync(fn, context, fallbackValue); + } + }; + } + }; +})(); + +if (typeof window !== 'undefined') { + window.ErrorHandler = ErrorHandler; +} + +console.log('ErrorHandler module loaded'); diff --git a/basicswap/static/js/modules/event-handlers.js b/basicswap/static/js/modules/event-handlers.js new file mode 100644 index 0000000..592effe --- /dev/null +++ b/basicswap/static/js/modules/event-handlers.js @@ -0,0 +1,342 @@ +(function() { + 'use strict'; + + const EventHandlers = { + + confirmPopup: function(action = 'proceed', coinName = '') { + const message = action === 'Accept' + ? 'Are you sure you want to accept this bid?' + : coinName + ? `Are you sure you want to ${action} ${coinName}?` + : 'Are you sure you want to proceed?'; + + return confirm(message); + }, + + confirmReseed: function() { + return confirm('Are you sure you want to reseed the wallet? This will generate new addresses.'); + }, + + confirmWithdrawal: function() { + + if (window.WalletPage && typeof window.WalletPage.confirmWithdrawal === 'function') { + return window.WalletPage.confirmWithdrawal(); + } + return confirm('Are you sure you want to withdraw? Please verify the address and amount.'); + }, + + confirmUTXOResize: function() { + return confirm('Are you sure you want to create a UTXO? This will split your balance.'); + }, + + confirmRemoveExpired: function() { + return confirm('Are you sure you want to remove all expired offers and bids?'); + }, + + fillDonationAddress: function(address, coinType) { + + let addressInput = null; + + addressInput = window.DOMCache + ? window.DOMCache.get('address_to') + : document.getElementById('address_to'); + + if (!addressInput) { + addressInput = document.querySelector('input[name^="to_"]'); + } + + if (!addressInput) { + addressInput = document.querySelector('input[placeholder*="Address"]'); + } + + if (addressInput) { + addressInput.value = address; + console.log(`Filled donation address for ${coinType}: ${address}`); + } else { + console.error('EventHandlers: Address input not found'); + } + }, + + setAmmAmount: function(percent, inputId) { + const amountInput = window.DOMCache + ? window.DOMCache.get(inputId) + : document.getElementById(inputId); + + if (!amountInput) { + console.error('EventHandlers: AMM amount input not found:', inputId); + return; + } + + const balanceElement = amountInput.closest('form')?.querySelector('[data-balance]'); + const balance = balanceElement ? parseFloat(balanceElement.getAttribute('data-balance')) : 0; + + if (balance > 0) { + const calculatedAmount = balance * percent; + amountInput.value = calculatedAmount.toFixed(8); + } else { + console.warn('EventHandlers: No balance found for AMM amount calculation'); + } + }, + + setOfferAmount: function(percent, inputId) { + const amountInput = window.DOMCache + ? window.DOMCache.get(inputId) + : document.getElementById(inputId); + + if (!amountInput) { + console.error('EventHandlers: Offer amount input not found:', inputId); + return; + } + + const coinFromSelect = document.getElementById('coin_from'); + if (!coinFromSelect) { + console.error('EventHandlers: coin_from select not found'); + return; + } + + const selectedOption = coinFromSelect.options[coinFromSelect.selectedIndex]; + if (!selectedOption || selectedOption.value === '-1') { + if (window.showErrorModal) { + window.showErrorModal('Validation Error', 'Please select a coin first'); + } else { + alert('Please select a coin first'); + } + return; + } + + const balance = selectedOption.getAttribute('data-balance'); + if (!balance) { + console.error('EventHandlers: Balance not found for selected coin'); + return; + } + + const floatBalance = parseFloat(balance); + if (isNaN(floatBalance) || floatBalance <= 0) { + if (window.showErrorModal) { + window.showErrorModal('Invalid Balance', 'The selected coin has no available balance. Please select a coin with a positive balance.'); + } else { + alert('Invalid balance for selected coin'); + } + return; + } + + const calculatedAmount = floatBalance * percent; + amountInput.value = calculatedAmount.toFixed(8); + }, + + resetForm: function() { + const form = document.querySelector('form[name="offer_form"]') || document.querySelector('form'); + if (form) { + form.reset(); + } + }, + + hideConfirmModal: function() { + if (window.DOMCache) { + window.DOMCache.hide('confirmModal'); + } else { + const modal = document.getElementById('confirmModal'); + if (modal) { + modal.style.display = 'none'; + } + } + }, + + lookup_rates: function() { + + if (window.lookup_rates && typeof window.lookup_rates === 'function') { + window.lookup_rates(); + } else { + console.error('EventHandlers: lookup_rates function not found'); + } + }, + + checkForUpdatesNow: function() { + if (window.checkForUpdatesNow && typeof window.checkForUpdatesNow === 'function') { + window.checkForUpdatesNow(); + } else { + console.error('EventHandlers: checkForUpdatesNow function not found'); + } + }, + + testUpdateNotification: function() { + if (window.testUpdateNotification && typeof window.testUpdateNotification === 'function') { + window.testUpdateNotification(); + } else { + console.error('EventHandlers: testUpdateNotification function not found'); + } + }, + + toggleNotificationDropdown: function(event) { + if (window.toggleNotificationDropdown && typeof window.toggleNotificationDropdown === 'function') { + window.toggleNotificationDropdown(event); + } else { + console.error('EventHandlers: toggleNotificationDropdown function not found'); + } + }, + + closeMessage: function(messageId) { + if (window.DOMCache) { + window.DOMCache.hide(messageId); + } else { + const messageElement = document.getElementById(messageId); + if (messageElement) { + messageElement.style.display = 'none'; + } + } + }, + + initialize: function() { + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-confirm]'); + if (target) { + const action = target.getAttribute('data-confirm-action') || 'proceed'; + const coinName = target.getAttribute('data-confirm-coin') || ''; + + if (!this.confirmPopup(action, coinName)) { + e.preventDefault(); + return false; + } + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-confirm-reseed]'); + if (target) { + if (!this.confirmReseed()) { + e.preventDefault(); + return false; + } + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-confirm-utxo]'); + if (target) { + if (!this.confirmUTXOResize()) { + e.preventDefault(); + return false; + } + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-confirm-remove-expired]'); + if (target) { + if (!this.confirmRemoveExpired()) { + e.preventDefault(); + return false; + } + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-fill-donation]'); + if (target) { + e.preventDefault(); + const address = target.getAttribute('data-address'); + const coinType = target.getAttribute('data-coin-type'); + this.fillDonationAddress(address, coinType); + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-set-amm-amount]'); + if (target) { + e.preventDefault(); + const percent = parseFloat(target.getAttribute('data-set-amm-amount')); + const inputId = target.getAttribute('data-input-id'); + this.setAmmAmount(percent, inputId); + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-set-offer-amount]'); + if (target) { + e.preventDefault(); + const percent = parseFloat(target.getAttribute('data-set-offer-amount')); + const inputId = target.getAttribute('data-input-id'); + this.setOfferAmount(percent, inputId); + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-reset-form]'); + if (target) { + e.preventDefault(); + this.resetForm(); + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-hide-modal]'); + if (target) { + e.preventDefault(); + this.hideConfirmModal(); + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-lookup-rates]'); + if (target) { + e.preventDefault(); + this.lookup_rates(); + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-check-updates]'); + if (target) { + e.preventDefault(); + this.checkForUpdatesNow(); + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-test-notification]'); + if (target) { + e.preventDefault(); + const type = target.getAttribute('data-test-notification'); + if (type === 'update') { + this.testUpdateNotification(); + } else { + window.NotificationManager && window.NotificationManager.testToasts(); + } + } + }); + + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-close-message]'); + if (target) { + e.preventDefault(); + const messageId = target.getAttribute('data-close-message'); + this.closeMessage(messageId); + } + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + EventHandlers.initialize(); + }); + } else { + EventHandlers.initialize(); + } + + window.EventHandlers = EventHandlers; + + window.confirmPopup = EventHandlers.confirmPopup.bind(EventHandlers); + window.confirmReseed = EventHandlers.confirmReseed.bind(EventHandlers); + window.confirmWithdrawal = EventHandlers.confirmWithdrawal.bind(EventHandlers); + window.confirmUTXOResize = EventHandlers.confirmUTXOResize.bind(EventHandlers); + window.confirmRemoveExpired = EventHandlers.confirmRemoveExpired.bind(EventHandlers); + window.fillDonationAddress = EventHandlers.fillDonationAddress.bind(EventHandlers); + window.setAmmAmount = EventHandlers.setAmmAmount.bind(EventHandlers); + window.setOfferAmount = EventHandlers.setOfferAmount.bind(EventHandlers); + window.resetForm = EventHandlers.resetForm.bind(EventHandlers); + window.hideConfirmModal = EventHandlers.hideConfirmModal.bind(EventHandlers); + window.toggleNotificationDropdown = EventHandlers.toggleNotificationDropdown.bind(EventHandlers); + +})(); diff --git a/basicswap/static/js/modules/form-validator.js b/basicswap/static/js/modules/form-validator.js new file mode 100644 index 0000000..81efa81 --- /dev/null +++ b/basicswap/static/js/modules/form-validator.js @@ -0,0 +1,225 @@ +(function() { + 'use strict'; + + const FormValidator = { + + checkPasswordStrength: function(password) { + const requirements = { + length: password.length >= 8, + uppercase: /[A-Z]/.test(password), + lowercase: /[a-z]/.test(password), + number: /[0-9]/.test(password) + }; + + let score = 0; + if (requirements.length) score += 25; + if (requirements.uppercase) score += 25; + if (requirements.lowercase) score += 25; + if (requirements.number) score += 25; + + return { + score: score, + requirements: requirements, + isStrong: score >= 60 + }; + }, + + updatePasswordStrengthUI: function(password, elements) { + const result = this.checkPasswordStrength(password); + const { score, requirements } = result; + + if (!elements.bar || !elements.text) { + console.warn('FormValidator: Missing strength UI elements'); + return result.isStrong; + } + + elements.bar.style.width = `${score}%`; + + if (score === 0) { + elements.bar.className = 'h-2 rounded-full transition-all duration-300 bg-gray-300 dark:bg-gray-500'; + elements.text.textContent = 'Enter password'; + elements.text.className = 'text-sm font-medium text-gray-500 dark:text-gray-400'; + } else if (score < 40) { + elements.bar.className = 'h-2 rounded-full transition-all duration-300 bg-red-500'; + elements.text.textContent = 'Weak'; + elements.text.className = 'text-sm font-medium text-red-600 dark:text-red-400'; + } else if (score < 70) { + elements.bar.className = 'h-2 rounded-full transition-all duration-300 bg-yellow-500'; + elements.text.textContent = 'Fair'; + elements.text.className = 'text-sm font-medium text-yellow-600 dark:text-yellow-400'; + } else if (score < 90) { + elements.bar.className = 'h-2 rounded-full transition-all duration-300 bg-blue-500'; + elements.text.textContent = 'Good'; + elements.text.className = 'text-sm font-medium text-blue-600 dark:text-blue-400'; + } else { + elements.bar.className = 'h-2 rounded-full transition-all duration-300 bg-green-500'; + elements.text.textContent = 'Strong'; + elements.text.className = 'text-sm font-medium text-green-600 dark:text-green-400'; + } + + if (elements.requirements) { + this.updateRequirement(elements.requirements.length, requirements.length); + this.updateRequirement(elements.requirements.uppercase, requirements.uppercase); + this.updateRequirement(elements.requirements.lowercase, requirements.lowercase); + this.updateRequirement(elements.requirements.number, requirements.number); + } + + return result.isStrong; + }, + + updateRequirement: function(element, met) { + if (!element) return; + + if (met) { + element.className = 'flex items-center text-green-600 dark:text-green-400'; + } else { + element.className = 'flex items-center text-gray-500 dark:text-gray-400'; + } + }, + + checkPasswordMatch: function(password1, password2, elements) { + if (!elements) { + return password1 === password2; + } + + const { container, success, error } = elements; + + if (password2.length === 0) { + if (container) container.classList.add('hidden'); + return false; + } + + if (container) container.classList.remove('hidden'); + + if (password1 === password2) { + if (success) success.classList.remove('hidden'); + if (error) error.classList.add('hidden'); + return true; + } else { + if (success) success.classList.add('hidden'); + if (error) error.classList.remove('hidden'); + return false; + } + }, + + validateEmail: function(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }, + + validateRequired: function(value) { + return value && value.trim().length > 0; + }, + + validateMinLength: function(value, minLength) { + return value && value.length >= minLength; + }, + + validateMaxLength: function(value, maxLength) { + return value && value.length <= maxLength; + }, + + validateNumeric: function(value) { + return !isNaN(value) && !isNaN(parseFloat(value)); + }, + + validateRange: function(value, min, max) { + const num = parseFloat(value); + return !isNaN(num) && num >= min && num <= max; + }, + + showError: function(element, message) { + if (!element) return; + + element.classList.add('border-red-500', 'focus:border-red-500', 'focus:ring-red-500'); + element.classList.remove('border-gray-300', 'focus:border-blue-500', 'focus:ring-blue-500'); + + let errorElement = element.parentElement.querySelector('.validation-error'); + if (!errorElement) { + errorElement = document.createElement('p'); + errorElement.className = 'validation-error text-red-600 dark:text-red-400 text-sm mt-1'; + element.parentElement.appendChild(errorElement); + } + + errorElement.textContent = message; + errorElement.classList.remove('hidden'); + }, + + clearError: function(element) { + if (!element) return; + + element.classList.remove('border-red-500', 'focus:border-red-500', 'focus:ring-red-500'); + element.classList.add('border-gray-300', 'focus:border-blue-500', 'focus:ring-blue-500'); + + const errorElement = element.parentElement.querySelector('.validation-error'); + if (errorElement) { + errorElement.classList.add('hidden'); + } + }, + + validateForm: function(form, rules) { + if (!form || !rules) return false; + + let isValid = true; + + Object.keys(rules).forEach(fieldName => { + const field = form.querySelector(`[name="${fieldName}"]`); + if (!field) return; + + const fieldRules = rules[fieldName]; + let fieldValid = true; + let errorMessage = ''; + + if (fieldRules.required && !this.validateRequired(field.value)) { + fieldValid = false; + errorMessage = fieldRules.requiredMessage || 'This field is required'; + } + + if (fieldValid && fieldRules.minLength && !this.validateMinLength(field.value, fieldRules.minLength)) { + fieldValid = false; + errorMessage = fieldRules.minLengthMessage || `Minimum ${fieldRules.minLength} characters required`; + } + + if (fieldValid && fieldRules.maxLength && !this.validateMaxLength(field.value, fieldRules.maxLength)) { + fieldValid = false; + errorMessage = fieldRules.maxLengthMessage || `Maximum ${fieldRules.maxLength} characters allowed`; + } + + if (fieldValid && fieldRules.email && !this.validateEmail(field.value)) { + fieldValid = false; + errorMessage = fieldRules.emailMessage || 'Invalid email format'; + } + + if (fieldValid && fieldRules.numeric && !this.validateNumeric(field.value)) { + fieldValid = false; + errorMessage = fieldRules.numericMessage || 'Must be a number'; + } + + if (fieldValid && fieldRules.range && !this.validateRange(field.value, fieldRules.range.min, fieldRules.range.max)) { + fieldValid = false; + errorMessage = fieldRules.rangeMessage || `Must be between ${fieldRules.range.min} and ${fieldRules.range.max}`; + } + + if (fieldValid && fieldRules.custom) { + const customResult = fieldRules.custom(field.value, form); + if (!customResult.valid) { + fieldValid = false; + errorMessage = customResult.message || 'Invalid value'; + } + } + + if (fieldValid) { + this.clearError(field); + } else { + this.showError(field, errorMessage); + isValid = false; + } + }); + + return isValid; + } + }; + + window.FormValidator = FormValidator; + +})(); diff --git a/basicswap/static/js/modules/identity-manager.js b/basicswap/static/js/modules/identity-manager.js index 68359f7..bebc49d 100644 --- a/basicswap/static/js/modules/identity-manager.js +++ b/basicswap/static/js/modules/identity-manager.js @@ -23,10 +23,24 @@ const IdentityManager = (function() { return null; } - const cachedData = this.getCachedIdentity(address); - if (cachedData) { - log(`Cache hit for ${address}`); - return cachedData; + const cached = state.cache.get(address); + const now = Date.now(); + + if (cached && (now - cached.timestamp) < state.config.cacheTimeout) { + log(`Cache hit (fresh) for ${address}`); + return cached.data; + } + + if (cached && (now - cached.timestamp) < state.config.cacheTimeout * 2) { + log(`Cache hit (stale) for ${address}, refreshing in background`); + + const staleData = cached.data; + + if (!state.pendingRequests.has(address)) { + this.refreshIdentityInBackground(address); + } + + return staleData; } if (state.pendingRequests.has(address)) { @@ -47,6 +61,20 @@ const IdentityManager = (function() { } }, + refreshIdentityInBackground: function(address) { + const request = fetchWithRetry(address); + state.pendingRequests.set(address, request); + + request.then(data => { + this.setCachedIdentity(address, data); + log(`Background refresh completed for ${address}`); + }).catch(error => { + log(`Background refresh failed for ${address}:`, error); + }).finally(() => { + state.pendingRequests.delete(address); + }); + }, + getCachedIdentity: function(address) { const cached = state.cache.get(address); if (cached && (Date.now() - cached.timestamp) < state.config.cacheTimeout) { @@ -155,15 +183,23 @@ const IdentityManager = (function() { async function fetchWithRetry(address, attempt = 1) { try { - const response = await fetch(`/json/identities/${address}`, { - signal: AbortSignal.timeout(5000) - }); + let data; - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + if (window.ApiManager) { + data = await window.ApiManager.makeRequest(`/json/identities/${address}`, 'GET'); + } else { + const response = await fetch(`/json/identities/${address}`, { + signal: AbortSignal.timeout(5000) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + data = await response.json(); } - return await response.json(); + return data; } catch (error) { if (attempt >= state.config.maxRetries) { console.error(`[IdentityManager] Error:`, error.message); @@ -171,7 +207,10 @@ const IdentityManager = (function() { return null; } - await new Promise(resolve => setTimeout(resolve, state.config.retryDelay * attempt)); + const delay = state.config.retryDelay * attempt; + await new Promise(resolve => { + CleanupManager.setTimeout(resolve, delay); + }); return fetchWithRetry(address, attempt + 1); } } @@ -188,5 +227,4 @@ document.addEventListener('DOMContentLoaded', function() { } }); -//console.log('IdentityManager initialized with methods:', Object.keys(IdentityManager)); console.log('IdentityManager initialized'); diff --git a/basicswap/static/js/modules/network-manager.js b/basicswap/static/js/modules/network-manager.js index 6a8a932..dcf6f34 100644 --- a/basicswap/static/js/modules/network-manager.js +++ b/basicswap/static/js/modules/network-manager.js @@ -108,7 +108,7 @@ const NetworkManager = (function() { log(`Scheduling reconnection attempt in ${delay/1000} seconds`); - state.reconnectTimer = setTimeout(() => { + state.reconnectTimer = CleanupManager.setTimeout(() => { state.reconnectTimer = null; this.attemptReconnect(); }, delay); @@ -167,7 +167,20 @@ const NetworkManager = (function() { }); }, - testBackendConnection: function() { + testBackendConnection: async function() { + if (window.ApiManager) { + try { + await window.ApiManager.makeRequest(config.connectionTestEndpoint, 'HEAD', { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + }); + return true; + } catch (error) { + log('Backend connection test failed:', error.message); + return false; + } + } + return fetch(config.connectionTestEndpoint, { method: 'HEAD', headers: { @@ -275,6 +288,4 @@ document.addEventListener('DOMContentLoaded', function() { } }); -//console.log('NetworkManager initialized with methods:', Object.keys(NetworkManager)); console.log('NetworkManager initialized'); - diff --git a/basicswap/static/js/modules/notification-manager.js b/basicswap/static/js/modules/notification-manager.js index 751faa1..fcea181 100644 --- a/basicswap/static/js/modules/notification-manager.js +++ b/basicswap/static/js/modules/notification-manager.js @@ -1,6 +1,5 @@ const NotificationManager = (function() { - const defaultConfig = { showNewOffers: false, showNewBids: true, @@ -12,7 +11,6 @@ const NotificationManager = (function() { notificationDuration: 20000 }; - function loadConfig() { const saved = localStorage.getItem('notification_settings'); if (saved) { @@ -25,7 +23,6 @@ const NotificationManager = (function() { return { ...defaultConfig }; } - function saveConfig(newConfig) { try { localStorage.setItem('notification_settings', JSON.stringify(newConfig)); @@ -269,7 +266,6 @@ function ensureToastContainer() { return colors[type] || colors['success']; } -// Todo: Remove later and use global. function getCoinDisplayName(coinId) { const coinMap = { 1: 'PART', @@ -292,25 +288,24 @@ function ensureToastContainer() { return coinMap[coinId] || `Coin ${coinId}`; } -// Todo: Remove later. function formatCoinAmount(amount, coinId) { const divisors = { - 1: 100000000, // PART - 8 decimals - 2: 100000000, // BTC - 8 decimals - 3: 100000000, // LTC - 8 decimals - 4: 100000000, // DCR - 8 decimals - 5: 100000000, // NMC - 8 decimals + 1: 100000000, // PART - 8 decimals + 2: 100000000, // BTC - 8 decimals + 3: 100000000, // LTC - 8 decimals + 4: 100000000, // DCR - 8 decimals + 5: 100000000, // NMC - 8 decimals 6: 1000000000000, // XMR - 12 decimals - 7: 100000000, // PART (Blind) - 8 decimals - 8: 100000000, // PART (Anon) - 8 decimals - 9: 100000000000, // WOW - 11 decimals - 11: 100000000, // PIVX - 8 decimals - 12: 100000000, // DASH - 8 decimals - 13: 100000000, // FIRO - 8 decimals - 14: 100000000, // NAV - 8 decimals - 15: 100000000, // LTC (MWEB) - 8 decimals - 17: 100000000, // BCH - 8 decimals - 18: 100000000 // DOGE - 8 decimals + 7: 100000000, // PART (Blind) - 8 decimals + 8: 100000000, // PART (Anon) - 8 decimals + 9: 100000000000, // WOW - 11 decimals + 11: 100000000, // PIVX - 8 decimals + 12: 100000000, // DASH - 8 decimals + 13: 100000000, // FIRO - 8 decimals + 14: 100000000, // NAV - 8 decimals + 15: 100000000, // LTC (MWEB) - 8 decimals + 17: 100000000, // BCH - 8 decimals + 18: 100000000 // DOGE - 8 decimals }; const divisor = divisors[coinId] || 100000000; @@ -358,7 +353,7 @@ function ensureToastContainer() { testToasts: function() { if (!this.createToast) return; - setTimeout(() => { + CleanupManager.setTimeout(() => { this.createToast( '+0.05000000 PART', 'balance_change', @@ -366,7 +361,7 @@ function ensureToastContainer() { ); }, 500); - setTimeout(() => { + CleanupManager.setTimeout(() => { this.createToast( '+0.00123456 XMR', 'balance_change', @@ -374,7 +369,7 @@ function ensureToastContainer() { ); }, 1000); - setTimeout(() => { + CleanupManager.setTimeout(() => { this.createToast( '-29.86277595 PART', 'balance_change', @@ -382,7 +377,7 @@ function ensureToastContainer() { ); }, 1500); - setTimeout(() => { + CleanupManager.setTimeout(() => { this.createToast( '-0.05000000 PART (Anon)', 'balance_change', @@ -390,7 +385,7 @@ function ensureToastContainer() { ); }, 2000); - setTimeout(() => { + CleanupManager.setTimeout(() => { this.createToast( '+1.23456789 PART (Anon)', 'balance_change', @@ -398,33 +393,37 @@ function ensureToastContainer() { ); }, 2500); - setTimeout(() => { + CleanupManager.setTimeout(() => { + const btcIcon = getCoinIcon('BTC'); + const xmrIcon = getCoinIcon('XMR'); this.createToast( - 'BTC1.00000000 BTC → XMR15.50000000 XMR', + 'New Network Offer', 'new_offer', { offerId: '000000006873f4ef17d4f220730400f4fdd57157492289c5d414ea66', - subtitle: 'New offer • Rate: 1 BTC = 15.50000000 XMR', + subtitle: `BTC1.00000000 BTC → XMR15.50000000 XMR
Rate: 1 BTC = 15.50000000 XMR`, coinFrom: 2, coinTo: 6 } ); }, 3000); - setTimeout(() => { + CleanupManager.setTimeout(() => { + const btcIcon = getCoinIcon('BTC'); + const xmrIcon = getCoinIcon('XMR'); this.createToast( - 'BTC0.50000000 BTC → XMR7.75000000 XMR', + 'New Bid Received', 'new_bid', { bidId: '000000006873f4ef17d4f220730400f4fdd57157492289c5d414ea66', - subtitle: 'New bid • Rate: 1 BTC = 15.50000000 XMR', + subtitle: `BTC0.50000000 BTC → XMR7.75000000 XMR
Rate: 1 BTC = 15.50000000 XMR`, coinFrom: 2, coinTo: 6 } ); }, 3500); - setTimeout(() => { + CleanupManager.setTimeout(() => { this.createToast( 'Swap completed successfully', 'swap_completed', @@ -435,25 +434,68 @@ function ensureToastContainer() { ); }, 4000); - setTimeout(() => { - this.createToast( - 'Update Available: v0.15.0', - 'update_available', - { - subtitle: 'Current: v0.14.6 • Click to view release', - releaseUrl: 'https://github.com/basicswap/basicswap/releases/tag/v0.15.0', - releaseNotes: 'New version v0.15.0 is available. Click to view details on GitHub.' + CleanupManager.setTimeout(async () => { + try { + const response = await fetch('/json/checkupdates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const data = await response.json(); + + if (data.error) { + console.warn('Test notification - API returned error, using fallback:', data.error); + this.createToast( + 'Update Available: v0.15.0', + 'update_available', + { + subtitle: 'Current: v0.14.6 • Click to view release', + releaseUrl: 'https://github.com/basicswap/basicswap/releases/tag/v0.15.0', + releaseNotes: 'New version v0.15.0 is available. Click to view details on GitHub.' + } + ); + return; } - ); + + const currentVer = (data.current_version && String(data.current_version) !== 'null' && String(data.current_version) !== 'None') + ? String(data.current_version) + : '0.14.6'; + const latestVer = (data.latest_version && String(data.latest_version) !== 'null' && String(data.latest_version) !== 'None') + ? String(data.latest_version) + : currentVer; + + this.createToast( + `Update Available: v${latestVer}`, + 'update_available', + { + subtitle: `Current: v${currentVer} • Click to view release`, + releaseUrl: `https://github.com/basicswap/basicswap/releases/tag/v${latestVer}`, + releaseNotes: `New version v${latestVer} is available. Click to view details on GitHub.` + } + ); + } catch (error) { + console.error('Test notification - API error:', error); + this.createToast( + 'Update Available: v0.15.0', + 'update_available', + { + subtitle: 'Current: v0.14.6 • Click to view release', + releaseUrl: 'https://github.com/basicswap/basicswap/releases/tag/v0.15.0', + releaseNotes: 'New version v0.15.0 is available. Click to view details on GitHub.' + } + ); + } }, 4500); }, - initializeBalanceTracking: function() { + initializeBalanceTracking: async function() { this.checkAndResetStaleBalanceTracking(); - fetch('/json/walletbalances') - .then(response => response.json()) + const fetchBalances = window.ApiManager + ? window.ApiManager.makeRequest('/json/walletbalances', 'GET') + : fetch('/json/walletbalances').then(response => response.json()); + + fetchBalances .then(balanceData => { if (Array.isArray(balanceData)) { balanceData.forEach(coin => { @@ -533,7 +575,6 @@ function ensureToastContainer() { coinIconHtml = `${options.coinSymbol}`; } - let clickAction = ''; let cursorStyle = 'cursor-default'; @@ -585,10 +626,10 @@ function ensureToastContainer() { messages.appendChild(message); if (!isPersistent) { - setTimeout(() => { + CleanupManager.setTimeout(() => { if (message.parentNode) { message.classList.add('toast-slide-out'); - setTimeout(() => { + CleanupManager.setTimeout(() => { if (message.parentNode) { message.parentNode.removeChild(message); } @@ -613,12 +654,12 @@ function ensureToastContainer() { const amountTo = formatCoinAmount(data.amount_to, data.coin_to); const coinFromIcon = getCoinIcon(coinFromName); const coinToIcon = getCoinIcon(coinToName); - toastTitle = `${coinFromName}${amountFrom} ${coinFromName} → ${coinToName}${amountTo} ${coinToName}`; - toastOptions.subtitle = `New offer • Rate: 1 ${coinFromName} = ${(data.amount_to / data.amount_from).toFixed(8)} ${coinToName}`; + toastTitle = `New Network Offer`; + toastOptions.subtitle = `${coinFromName}${amountFrom} ${coinFromName} → ${coinToName}${amountTo} ${coinToName}
Rate: 1 ${coinFromName} = ${(data.amount_to / data.amount_from).toFixed(8)} ${coinToName}`; toastOptions.coinFrom = data.coin_from; toastOptions.coinTo = data.coin_to; } else { - toastTitle = `New network offer`; + toastTitle = `New Network Offer`; toastOptions.subtitle = 'Click to view offer'; } toastType = 'new_offer'; @@ -633,12 +674,12 @@ function ensureToastContainer() { const bidAmountTo = formatCoinAmount(data.bid_amount_to, data.coin_to); const coinFromIcon = getCoinIcon(coinFromName); const coinToIcon = getCoinIcon(coinToName); - toastTitle = `${coinFromName}${bidAmountFrom} ${coinFromName} → ${coinToName}${bidAmountTo} ${coinToName}`; - toastOptions.subtitle = `New bid • Rate: 1 ${coinFromName} = ${(data.bid_amount_to / data.bid_amount).toFixed(8)} ${coinToName}`; + toastTitle = `New Bid Received`; + toastOptions.subtitle = `${coinFromName}${bidAmountFrom} ${coinFromName} → ${coinToName}${bidAmountTo} ${coinToName}
Rate: 1 ${coinFromName} = ${(data.bid_amount_to / data.bid_amount).toFixed(8)} ${coinToName}`; toastOptions.coinFrom = data.coin_from; toastOptions.coinTo = data.coin_to; } else { - toastTitle = `New bid received`; + toastTitle = `New Bid Received`; toastOptions.subtitle = 'Click to view bid'; } toastOptions.bidId = data.bid_id; @@ -696,14 +737,17 @@ function ensureToastContainer() { this.balanceTimeouts = {}; } - this.balanceTimeouts[balanceKey] = setTimeout(() => { + this.balanceTimeouts[balanceKey] = CleanupManager.setTimeout(() => { this.fetchAndShowBalanceChange(data.coin); }, 2000); }, fetchAndShowBalanceChange: function(coinSymbol) { - fetch('/json/walletbalances') - .then(response => response.json()) + const fetchBalances = window.ApiManager + ? window.ApiManager.makeRequest('/json/walletbalances', 'GET') + : fetch('/json/walletbalances').then(response => response.json()); + + fetchBalances .then(balanceData => { if (Array.isArray(balanceData)) { @@ -748,13 +792,10 @@ function ensureToastContainer() { const pendingIncrease = currentPending - prevPending; const pendingDecrease = prevPending - currentPending; - - const totalChange = Math.abs(balanceIncrease) + Math.abs(pendingIncrease); const maxReasonableChange = Math.max(currentBalance, prevBalance) * 0.5; if (totalChange > maxReasonableChange && totalChange > 1.0) { - console.log(`Detected potentially stale balance data for ${coinData.name}, resetting tracking`); localStorage.setItem(storageKey, currentBalance.toString()); localStorage.setItem(pendingStorageKey, currentPending.toString()); return; @@ -782,7 +823,6 @@ function ensureToastContainer() { const isPendingToConfirmed = pendingDecrease > 0.00000001 && balanceIncrease > 0.00000001; - const displaySymbol = originalCoinSymbol; let variantInfo = ''; @@ -871,8 +911,6 @@ function ensureToastContainer() { } }, - - updateConfig: function(newConfig) { Object.assign(config, newConfig); return this; diff --git a/basicswap/static/js/modules/price-manager.js b/basicswap/static/js/modules/price-manager.js index 5ef672b..a24ef20 100644 --- a/basicswap/static/js/modules/price-manager.js +++ b/basicswap/static/js/modules/price-manager.js @@ -42,7 +42,7 @@ const PriceManager = (function() { }); } - setTimeout(() => this.getPrices(), 1500); + CleanupManager.setTimeout(() => this.getPrices(), 1500); isInitialized = true; console.log('PriceManager initialized'); return this; @@ -60,7 +60,6 @@ const PriceManager = (function() { return fetchPromise; } - lastFetchTime = Date.now(); fetchPromise = this.fetchPrices() .then(prices => { @@ -90,8 +89,6 @@ const PriceManager = (function() { ? window.config.coins.map(c => c.symbol).filter(symbol => symbol && symbol.trim() !== '') : ['BTC', 'XMR', 'PART', 'BCH', 'PIVX', 'FIRO', 'DASH', 'LTC', 'DOGE', 'DCR', 'NMC', 'WOW']); - //console.log('PriceManager: lookupFiatRates ' + coinSymbols.join(', ')); - if (!coinSymbols.length) { throw new Error('No valid coins configured'); } @@ -133,15 +130,15 @@ const PriceManager = (function() { const coin = window.CoinManager.getCoinByAnyIdentifier(coinId); if (coin) { normalizedCoinId = window.CoinManager.getPriceKey(coin.name); + } else if (window.CoinUtils) { + normalizedCoinId = window.CoinUtils.normalizeCoinName(coinId); } else { - normalizedCoinId = coinId === 'bitcoincash' ? 'bitcoin-cash' : coinId.toLowerCase(); + normalizedCoinId = coinId.toLowerCase(); } + } else if (window.CoinUtils) { + normalizedCoinId = window.CoinUtils.normalizeCoinName(coinId); } else { - normalizedCoinId = coinId === 'bitcoincash' ? 'bitcoin-cash' : coinId.toLowerCase(); - } - - if (coinId.toLowerCase() === 'zcoin') { - normalizedCoinId = 'firo'; + normalizedCoinId = coinId.toLowerCase(); } processedData[normalizedCoinId] = { @@ -230,5 +227,3 @@ document.addEventListener('DOMContentLoaded', function() { window.priceManagerInitialized = true; } }); - - diff --git a/basicswap/static/js/modules/qrcode-manager.js b/basicswap/static/js/modules/qrcode-manager.js new file mode 100644 index 0000000..9a002f8 --- /dev/null +++ b/basicswap/static/js/modules/qrcode-manager.js @@ -0,0 +1,79 @@ + +(function() { + 'use strict'; + + const QRCodeManager = { + + defaultOptions: { + width: 200, + height: 200, + colorDark: "#000000", + colorLight: "#ffffff", + correctLevel: QRCode.CorrectLevel.L + }, + + initialize: function() { + const qrElements = document.querySelectorAll('[data-qrcode]'); + + qrElements.forEach(element => { + this.generateQRCode(element); + }); + }, + + generateQRCode: function(element) { + const address = element.getAttribute('data-address'); + const width = parseInt(element.getAttribute('data-width')) || this.defaultOptions.width; + const height = parseInt(element.getAttribute('data-height')) || this.defaultOptions.height; + + if (!address) { + console.error('QRCodeManager: No address provided for element', element); + return; + } + + element.innerHTML = ''; + + try { + new QRCode(element, { + text: address, + width: width, + height: height, + colorDark: this.defaultOptions.colorDark, + colorLight: this.defaultOptions.colorLight, + correctLevel: this.defaultOptions.correctLevel + }); + } catch (error) { + console.error('QRCodeManager: Failed to generate QR code', error); + } + }, + + generateById: function(elementId, address, options = {}) { + + const element = window.DOMCache + ? window.DOMCache.get(elementId) + : document.getElementById(elementId); + + if (!element) { + console.error('QRCodeManager: Element not found:', elementId); + return; + } + + element.setAttribute('data-address', address); + + if (options.width) element.setAttribute('data-width', options.width); + if (options.height) element.setAttribute('data-height', options.height); + + this.generateQRCode(element); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + QRCodeManager.initialize(); + }); + } else { + QRCodeManager.initialize(); + } + + window.QRCodeManager = QRCodeManager; + +})(); diff --git a/basicswap/static/js/modules/summary-manager.js b/basicswap/static/js/modules/summary-manager.js index 251ebf2..99efe2f 100644 --- a/basicswap/static/js/modules/summary-manager.js +++ b/basicswap/static/js/modules/summary-manager.js @@ -166,7 +166,7 @@ const SummaryManager = (function() { } if (window.TooltipManager && typeof window.TooltipManager.initializeTooltips === 'function') { - setTimeout(() => { + CleanupManager.setTimeout(() => { window.TooltipManager.initializeTooltips(`[data-tooltip-target="${tooltipId}"]`); debugLog(`Re-initialized tooltips for ${tooltipId}`); }, 50); @@ -205,8 +205,16 @@ const SummaryManager = (function() { } function fetchSummaryDataWithTimeout() { + if (window.ApiManager) { + return window.ApiManager.makeRequest(config.summaryEndpoint, 'GET', { + 'Accept': 'application/json', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + }); + } + const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), config.requestTimeout); + const timeoutId = CleanupManager.setTimeout(() => controller.abort(), config.requestTimeout); return fetch(config.summaryEndpoint, { signal: controller.signal, @@ -217,7 +225,11 @@ const SummaryManager = (function() { } }) .then(response => { - clearTimeout(timeoutId); + if (window.CleanupManager) { + window.CleanupManager.clearTimeout(timeoutId); + } else { + clearTimeout(timeoutId); + } if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); @@ -226,7 +238,11 @@ const SummaryManager = (function() { return response.json(); }) .catch(error => { - clearTimeout(timeoutId); + if (window.CleanupManager) { + window.CleanupManager.clearTimeout(timeoutId); + } else { + clearTimeout(timeoutId); + } throw error; }); } @@ -275,7 +291,7 @@ const SummaryManager = (function() { }; webSocket.onclose = () => { - setTimeout(setupWebSocket, 5000); + CleanupManager.setTimeout(setupWebSocket, 5000); }; } @@ -303,7 +319,7 @@ const SummaryManager = (function() { .then(() => {}) .catch(() => {}); - refreshTimer = setInterval(() => { + refreshTimer = CleanupManager.setInterval(() => { publicAPI.fetchSummaryData() .then(() => {}) .catch(() => {}); @@ -386,7 +402,7 @@ const SummaryManager = (function() { } return new Promise(resolve => { - setTimeout(() => { + CleanupManager.setTimeout(() => { resolve(this.fetchSummaryData()); }, config.retryDelay); }); @@ -446,5 +462,4 @@ document.addEventListener('DOMContentLoaded', function() { } }); -//console.log('SummaryManager initialized with methods:', Object.keys(SummaryManager)); console.log('SummaryManager initialized'); diff --git a/basicswap/static/js/modules/tooltips-manager.js b/basicswap/static/js/modules/tooltips-manager.js index 8fa7613..b43c007 100644 --- a/basicswap/static/js/modules/tooltips-manager.js +++ b/basicswap/static/js/modules/tooltips-manager.js @@ -14,6 +14,9 @@ const TooltipManager = (function() { this.debug = false; this.tooltipData = new WeakMap(); this.resources = {}; + this.creationQueue = []; + this.batchSize = 5; + this.isProcessingQueue = false; if (window.CleanupManager) { CleanupManager.registerResource( @@ -48,40 +51,69 @@ const TooltipManager = (function() { this.performPeriodicCleanup(true); } - const createTooltip = () => { - if (!document.body.contains(element)) return; + this.creationQueue.push({ element, content, options }); - const rect = element.getBoundingClientRect(); - if (rect.width > 0 && rect.height > 0) { - this.createTooltipInstance(element, content, options); - } else { - let retryCount = 0; - const maxRetries = 3; + if (!this.isProcessingQueue) { + this.processCreationQueue(); + } - const retryCreate = () => { - const newRect = element.getBoundingClientRect(); - if ((newRect.width > 0 && newRect.height > 0) || retryCount >= maxRetries) { - if (newRect.width > 0 && newRect.height > 0) { - this.createTooltipInstance(element, content, options); - } - } else { - retryCount++; - CleanupManager.setTimeout(() => { - CleanupManager.requestAnimationFrame(retryCreate); - }, 100); - } - }; - - CleanupManager.setTimeout(() => { - CleanupManager.requestAnimationFrame(retryCreate); - }, 100); - } - }; - - CleanupManager.requestAnimationFrame(createTooltip); return null; } + processCreationQueue() { + if (this.creationQueue.length === 0) { + this.isProcessingQueue = false; + return; + } + + this.isProcessingQueue = true; + const batch = this.creationQueue.splice(0, this.batchSize); + + CleanupManager.requestAnimationFrame(() => { + batch.forEach(({ element, content, options }) => { + this.createTooltipSync(element, content, options); + }); + + if (this.creationQueue.length > 0) { + CleanupManager.setTimeout(() => { + this.processCreationQueue(); + }, 0); + } else { + this.isProcessingQueue = false; + } + }); + } + + createTooltipSync(element, content, options) { + if (!document.body.contains(element)) return; + + const rect = element.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + this.createTooltipInstance(element, content, options); + } else { + let retryCount = 0; + const maxRetries = 3; + + const retryCreate = () => { + const newRect = element.getBoundingClientRect(); + if ((newRect.width > 0 && newRect.height > 0) || retryCount >= maxRetries) { + if (newRect.width > 0 && newRect.height > 0) { + this.createTooltipInstance(element, content, options); + } + } else { + retryCount++; + CleanupManager.setTimeout(() => { + CleanupManager.requestAnimationFrame(retryCreate); + }, 100); + } + }; + + CleanupManager.setTimeout(() => { + CleanupManager.requestAnimationFrame(retryCreate); + }, 100); + } + } + createTooltipInstance(element, content, options = {}) { if (!element || !document.body.contains(element)) { return null; @@ -191,6 +223,9 @@ const TooltipManager = (function() { return null; } catch (error) { + if (window.ErrorHandler) { + return window.ErrorHandler.handleError(error, 'TooltipManager.createTooltipInstance', null); + } console.error('Error creating tooltip:', error); return null; } @@ -199,7 +234,7 @@ const TooltipManager = (function() { destroy(element) { if (!element) return; - try { + const destroyFn = () => { const tooltipId = element.getAttribute('data-tooltip-trigger-id'); if (!tooltipId) return; @@ -224,8 +259,16 @@ const TooltipManager = (function() { this.tooltipData.delete(element); tooltipInstanceMap.delete(element); - } catch (error) { - console.error('Error destroying tooltip:', error); + }; + + if (window.ErrorHandler) { + window.ErrorHandler.safeExecute(destroyFn, 'TooltipManager.destroy', null); + } else { + try { + destroyFn(); + } catch (error) { + console.error('Error destroying tooltip:', error); + } } } @@ -738,10 +781,40 @@ const TooltipManager = (function() { } } + initializeLazyTooltips(selector = '[data-tooltip-target]') { + + const initializedTooltips = new Set(); + + const initializeTooltip = (element) => { + const targetId = element.getAttribute('data-tooltip-target'); + if (!targetId || initializedTooltips.has(targetId)) return; + + const tooltipContent = document.getElementById(targetId); + if (tooltipContent) { + this.create(element, tooltipContent.innerHTML, { + placement: element.getAttribute('data-tooltip-placement') || 'top' + }); + initializedTooltips.add(targetId); + } + }; + + document.addEventListener('mouseover', (e) => { + const target = e.target.closest(selector); + if (target) { + initializeTooltip(target); + } + }, { passive: true, capture: true }); + + this.log('Lazy tooltip initialization enabled'); + } + dispose() { this.log('Disposing TooltipManager'); try { + this.creationQueue = []; + this.isProcessingQueue = false; + this.cleanup(); Object.values(this.resources).forEach(resourceId => { @@ -830,6 +903,11 @@ const TooltipManager = (function() { return manager.initializeTooltips(...args); }, + initializeLazyTooltips: function(...args) { + const manager = this.getInstance(); + return manager.initializeLazyTooltips(...args); + }, + setDebugMode: function(enabled) { const manager = this.getInstance(); return manager.setDebugMode(enabled); diff --git a/basicswap/static/js/modules/wallet-amount.js b/basicswap/static/js/modules/wallet-amount.js new file mode 100644 index 0000000..3e14cb5 --- /dev/null +++ b/basicswap/static/js/modules/wallet-amount.js @@ -0,0 +1,196 @@ +(function() { + 'use strict'; + + const WalletAmountManager = { + + coinConfigs: { + 1: { + types: ['plain', 'blind', 'anon'], + hasSubfee: true, + hasSweepAll: false + }, + 3: { + types: ['plain', 'mweb'], + hasSubfee: true, + hasSweepAll: false + }, + 6: { + types: ['default'], + hasSubfee: false, + hasSweepAll: true + }, + 9: { + types: ['default'], + hasSubfee: false, + hasSweepAll: true + } + }, + + safeParseFloat: function(value) { + const numValue = Number(value); + + if (!isNaN(numValue) && numValue > 0) { + return numValue; + } + + console.warn('WalletAmountManager: Invalid balance value:', value); + return 0; + }, + + getBalance: function(coinId, balances, selectedType) { + const cid = parseInt(coinId); + + if (cid === 1) { + switch(selectedType) { + case 'plain': + return this.safeParseFloat(balances.main || balances.balance); + case 'blind': + return this.safeParseFloat(balances.blind); + case 'anon': + return this.safeParseFloat(balances.anon); + default: + return this.safeParseFloat(balances.main || balances.balance); + } + } + + if (cid === 3) { + switch(selectedType) { + case 'plain': + return this.safeParseFloat(balances.main || balances.balance); + case 'mweb': + return this.safeParseFloat(balances.mweb); + default: + return this.safeParseFloat(balances.main || balances.balance); + } + } + + return this.safeParseFloat(balances.main || balances.balance); + }, + + calculateAmount: function(balance, percent, coinId) { + const cid = parseInt(coinId); + + if (percent === 1) { + return balance; + } + + if (cid === 1) { + return Math.max(0, Math.floor(balance * percent * 100000000) / 100000000); + } + + const calculatedAmount = balance * percent; + + if (calculatedAmount < 0.00000001) { + console.warn('WalletAmountManager: Calculated amount too small, setting to zero'); + return 0; + } + + return calculatedAmount; + }, + + setAmount: function(percent, balances, coinId) { + + const amountInput = window.DOMCache + ? window.DOMCache.get('amount') + : document.getElementById('amount'); + const typeSelect = window.DOMCache + ? window.DOMCache.get('withdraw_type') + : document.getElementById('withdraw_type'); + + if (!amountInput) { + console.error('WalletAmountManager: Amount input not found'); + return; + } + + const cid = parseInt(coinId); + const selectedType = typeSelect ? typeSelect.value : 'plain'; + + const balance = this.getBalance(cid, balances, selectedType); + + const calculatedAmount = this.calculateAmount(balance, percent, cid); + + const specialCids = [6, 9]; + if (specialCids.includes(cid) && percent === 1) { + amountInput.setAttribute('data-hidden', 'true'); + amountInput.placeholder = 'Sweep All'; + amountInput.value = ''; + amountInput.disabled = true; + + const sweepAllCheckbox = window.DOMCache + ? window.DOMCache.get('sweepall') + : document.getElementById('sweepall'); + if (sweepAllCheckbox) { + sweepAllCheckbox.checked = true; + } + } else { + + amountInput.value = calculatedAmount.toFixed(8); + amountInput.setAttribute('data-hidden', 'false'); + amountInput.placeholder = ''; + amountInput.disabled = false; + + const sweepAllCheckbox = window.DOMCache + ? window.DOMCache.get('sweepall') + : document.getElementById('sweepall'); + if (sweepAllCheckbox) { + sweepAllCheckbox.checked = false; + } + } + + const subfeeCheckbox = document.querySelector(`[name="subfee_${cid}"]`); + if (subfeeCheckbox) { + subfeeCheckbox.checked = (percent === 1); + } + }, + + initialize: function() { + + const amountButtons = document.querySelectorAll('[data-set-amount]'); + + amountButtons.forEach(button => { + button.addEventListener('click', (e) => { + e.preventDefault(); + + const percent = parseFloat(button.getAttribute('data-set-amount')); + const balancesJson = button.getAttribute('data-balances'); + const coinId = button.getAttribute('data-coin-id'); + + if (!balancesJson || !coinId) { + console.error('WalletAmountManager: Missing data attributes on button', button); + return; + } + + try { + const balances = JSON.parse(balancesJson); + this.setAmount(percent, balances, coinId); + } catch (error) { + console.error('WalletAmountManager: Failed to parse balances', error); + } + }); + }); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + WalletAmountManager.initialize(); + }); + } else { + WalletAmountManager.initialize(); + } + + window.WalletAmountManager = WalletAmountManager; + + window.setAmount = function(percent, balance, coinId, balance2, balance3) { + + const balances = { + main: balance || balance, + balance: balance, + blind: balance2, + anon: balance3, + mweb: balance2 + }; + WalletAmountManager.setAmount(percent, balances, coinId); + }; + +})(); diff --git a/basicswap/static/js/modules/wallet-manager.js b/basicswap/static/js/modules/wallet-manager.js index dc4a1ee..4cd039a 100644 --- a/basicswap/static/js/modules/wallet-manager.js +++ b/basicswap/static/js/modules/wallet-manager.js @@ -11,8 +11,7 @@ const WalletManager = (function() { defaultTTL: 300, priceSource: { primary: 'coingecko.com', - fallback: 'cryptocompare.com', - enabledSources: ['coingecko.com', 'cryptocompare.com'] + enabledSources: ['coingecko.com'] } }; @@ -95,22 +94,32 @@ const WalletManager = (function() { const fetchCoinsString = coinsToFetch.join(','); - const mainResponse = await fetch("/json/coinprices", { - method: "POST", - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ + let mainData; + + if (window.ApiManager) { + mainData = await window.ApiManager.makeRequest("/json/coinprices", "POST", {}, { coins: fetchCoinsString, source: currentSource, ttl: config.defaultTTL - }) - }); + }); + } else { + const mainResponse = await fetch("/json/coinprices", { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + coins: fetchCoinsString, + source: currentSource, + ttl: config.defaultTTL + }) + }); - if (!mainResponse.ok) { - throw new Error(`HTTP error: ${mainResponse.status}`); + if (!mainResponse.ok) { + throw new Error(`HTTP error: ${mainResponse.status}`); + } + + mainData = await mainResponse.json(); } - const mainData = await mainResponse.json(); - if (mainData && mainData.rates) { document.querySelectorAll('.coinname-value').forEach(el => { const coinName = el.getAttribute('data-coinname'); @@ -154,7 +163,7 @@ const WalletManager = (function() { if (attempt < config.maxRetries - 1) { const delay = Math.min(config.baseDelay * Math.pow(2, attempt), 10000); - await new Promise(resolve => setTimeout(resolve, delay)); + await new Promise(resolve => CleanupManager.setTimeout(resolve, delay)); } } } @@ -428,7 +437,7 @@ const WalletManager = (function() { clearTimeout(state.toggleDebounceTimer); } - state.toggleDebounceTimer = window.setTimeout(async () => { + state.toggleDebounceTimer = CleanupManager.setTimeout(async () => { state.toggleInProgress = false; if (newVisibility) { await updatePrices(true); @@ -539,7 +548,6 @@ const WalletManager = (function() { } } - // Public API const publicAPI = { initialize: async function(options) { if (state.initialized) { @@ -579,7 +587,7 @@ const WalletManager = (function() { clearInterval(state.priceUpdateInterval); } - state.priceUpdateInterval = setInterval(() => { + state.priceUpdateInterval = CleanupManager.setInterval(() => { if (localStorage.getItem('balancesVisible') === 'true' && !state.toggleInProgress) { updatePrices(false); } @@ -661,5 +669,4 @@ document.addEventListener('DOMContentLoaded', function() { } }); -//console.log('WalletManager initialized with methods:', Object.keys(WalletManager)); console.log('WalletManager initialized'); diff --git a/basicswap/static/js/modules/websocket-manager.js b/basicswap/static/js/modules/websocket-manager.js index d85996a..b636085 100644 --- a/basicswap/static/js/modules/websocket-manager.js +++ b/basicswap/static/js/modules/websocket-manager.js @@ -32,26 +32,24 @@ const WebSocketManager = (function() { } function determineWebSocketPort() { - let wsPort; + if (window.ConfigManager && window.ConfigManager.wsPort) { + return window.ConfigManager.wsPort.toString(); + } if (window.config && window.config.wsPort) { - wsPort = window.config.wsPort; - return wsPort; + return window.config.wsPort.toString(); } if (window.ws_port) { - wsPort = window.ws_port.toString(); - return wsPort; + return window.ws_port.toString(); } if (typeof getWebSocketConfig === 'function') { const wsConfig = getWebSocketConfig(); - wsPort = (wsConfig.port || wsConfig.fallbackPort || '11700').toString(); - return wsPort; + return (wsConfig.port || wsConfig.fallbackPort || '11700').toString(); } - wsPort = '11700'; - return wsPort; + return '11700'; } const publicAPI = { @@ -77,7 +75,11 @@ const WebSocketManager = (function() { } if (state.reconnectTimeout) { - clearTimeout(state.reconnectTimeout); + if (window.CleanupManager) { + window.CleanupManager.clearTimeout(state.reconnectTimeout); + } else { + clearTimeout(state.reconnectTimeout); + } state.reconnectTimeout = null; } @@ -96,13 +98,17 @@ const WebSocketManager = (function() { ws = new WebSocket(`ws://${window.location.hostname}:${wsPort}`); setupEventHandlers(); - state.connectTimeout = setTimeout(() => { + const timeoutFn = () => { if (state.isConnecting) { log('Connection timeout, cleaning up'); cleanup(); handleReconnect(); } - }, 5000); + }; + + state.connectTimeout = window.CleanupManager + ? window.CleanupManager.setTimeout(timeoutFn, 5000) + : setTimeout(timeoutFn, 5000); return true; } catch (error) { @@ -159,18 +165,25 @@ const WebSocketManager = (function() { cleanup: function() { log('Cleaning up WebSocket resources'); - clearTimeout(state.connectTimeout); + if (window.CleanupManager) { + window.CleanupManager.clearTimeout(state.connectTimeout); + } else { + clearTimeout(state.connectTimeout); + } stopHealthCheck(); if (state.reconnectTimeout) { - clearTimeout(state.reconnectTimeout); + if (window.CleanupManager) { + window.CleanupManager.clearTimeout(state.reconnectTimeout); + } else { + clearTimeout(state.reconnectTimeout); + } state.reconnectTimeout = null; } state.isConnecting = false; state.messageHandlers = {}; - if (ws) { ws.onopen = null; ws.onmessage = null; @@ -228,7 +241,11 @@ const WebSocketManager = (function() { ws.onopen = () => { state.isConnecting = false; config.reconnectAttempts = 0; - clearTimeout(state.connectTimeout); + if (window.CleanupManager) { + window.CleanupManager.clearTimeout(state.connectTimeout); + } else { + clearTimeout(state.connectTimeout); + } state.lastHealthCheck = Date.now(); window.ws = ws; @@ -298,37 +315,42 @@ const WebSocketManager = (function() { function handlePageHidden() { log('Page hidden'); state.isPageHidden = true; - stopHealthCheck(); - - if (ws && ws.readyState === WebSocket.OPEN) { - state.isIntentionallyClosed = true; - ws.close(1000, 'Page hidden'); - } } function handlePageVisible() { log('Page visible'); state.isPageHidden = false; - state.isIntentionallyClosed = false; - setTimeout(() => { + const resumeFn = () => { if (!publicAPI.isConnected()) { publicAPI.connect(); } - startHealthCheck(); - }, 0); + }; + + if (window.CleanupManager) { + window.CleanupManager.setTimeout(resumeFn, 0); + } else { + setTimeout(resumeFn, 0); + } } function startHealthCheck() { stopHealthCheck(); - state.healthCheckInterval = setInterval(() => { + const healthCheckFn = () => { performHealthCheck(); - }, 30000); + }; + state.healthCheckInterval = window.CleanupManager + ? window.CleanupManager.setInterval(healthCheckFn, 30000) + : setInterval(healthCheckFn, 30000); } function stopHealthCheck() { if (state.healthCheckInterval) { - clearInterval(state.healthCheckInterval); + if (window.CleanupManager) { + window.CleanupManager.clearInterval(state.healthCheckInterval); + } else { + clearInterval(state.healthCheckInterval); + } state.healthCheckInterval = null; } } @@ -356,7 +378,11 @@ const WebSocketManager = (function() { function handleReconnect() { if (state.reconnectTimeout) { - clearTimeout(state.reconnectTimeout); + if (window.CleanupManager) { + window.CleanupManager.clearTimeout(state.reconnectTimeout); + } else { + clearTimeout(state.reconnectTimeout); + } state.reconnectTimeout = null; } @@ -369,23 +395,31 @@ const WebSocketManager = (function() { log(`Scheduling reconnect in ${delay}ms (attempt ${config.reconnectAttempts})`); - state.reconnectTimeout = setTimeout(() => { + const reconnectFn = () => { state.reconnectTimeout = null; if (!state.isIntentionallyClosed) { publicAPI.connect(); } - }, delay); + }; + + state.reconnectTimeout = window.CleanupManager + ? window.CleanupManager.setTimeout(reconnectFn, delay) + : setTimeout(reconnectFn, delay); } else { log('Max reconnect attempts reached'); if (typeof updateConnectionStatus === 'function') { updateConnectionStatus('error'); } - state.reconnectTimeout = setTimeout(() => { + const resetFn = () => { state.reconnectTimeout = null; config.reconnectAttempts = 0; publicAPI.connect(); - }, 60000); + }; + + state.reconnectTimeout = window.CleanupManager + ? window.CleanupManager.setTimeout(resetFn, 60000) + : setTimeout(resetFn, 60000); } } @@ -442,5 +476,4 @@ document.addEventListener('DOMContentLoaded', function() { } }); -//console.log('WebSocketManager initialized with methods:', Object.keys(WebSocketManager)); console.log('WebSocketManager initialized'); diff --git a/basicswap/static/js/pages/amm-config-tabs.js b/basicswap/static/js/pages/amm-config-tabs.js new file mode 100644 index 0000000..52ed3b8 --- /dev/null +++ b/basicswap/static/js/pages/amm-config-tabs.js @@ -0,0 +1,294 @@ +(function() { + 'use strict'; + + const AMMConfigTabs = { + + init: function() { + const jsonTab = document.getElementById('json-tab'); + const settingsTab = document.getElementById('settings-tab'); + const overviewTab = document.getElementById('overview-tab'); + const jsonContent = document.getElementById('json-content'); + const settingsContent = document.getElementById('settings-content'); + const overviewContent = document.getElementById('overview-content'); + + if (!jsonTab || !settingsTab || !overviewTab || !jsonContent || !settingsContent || !overviewContent) { + return; + } + + const activeConfigTab = localStorage.getItem('amm_active_config_tab'); + + const switchConfigTab = (tabId) => { + jsonContent.classList.add('hidden'); + jsonContent.classList.remove('block'); + settingsContent.classList.add('hidden'); + settingsContent.classList.remove('block'); + overviewContent.classList.add('hidden'); + overviewContent.classList.remove('block'); + + jsonTab.classList.remove('bg-gray-100', 'text-gray-900', 'dark:bg-gray-600', 'dark:text-white'); + settingsTab.classList.remove('bg-gray-100', 'text-gray-900', 'dark:bg-gray-600', 'dark:text-white'); + overviewTab.classList.remove('bg-gray-100', 'text-gray-900', 'dark:bg-gray-600', 'dark:text-white'); + + if (tabId === 'json-tab') { + jsonContent.classList.remove('hidden'); + jsonContent.classList.add('block'); + jsonTab.classList.add('bg-gray-100', 'text-gray-900', 'dark:bg-gray-600', 'dark:text-white'); + localStorage.setItem('amm_active_config_tab', 'json-tab'); + } else if (tabId === 'settings-tab') { + settingsContent.classList.remove('hidden'); + settingsContent.classList.add('block'); + settingsTab.classList.add('bg-gray-100', 'text-gray-900', 'dark:bg-gray-600', 'dark:text-white'); + localStorage.setItem('amm_active_config_tab', 'settings-tab'); + + this.loadSettingsFromJson(); + } else if (tabId === 'overview-tab') { + overviewContent.classList.remove('hidden'); + overviewContent.classList.add('block'); + overviewTab.classList.add('bg-gray-100', 'text-gray-900', 'dark:bg-gray-600', 'dark:text-white'); + localStorage.setItem('amm_active_config_tab', 'overview-tab'); + } + }; + + jsonTab.addEventListener('click', () => switchConfigTab('json-tab')); + settingsTab.addEventListener('click', () => switchConfigTab('settings-tab')); + overviewTab.addEventListener('click', () => switchConfigTab('overview-tab')); + + const returnToTab = localStorage.getItem('amm_return_to_tab'); + if (returnToTab && (returnToTab === 'json-tab' || returnToTab === 'settings-tab' || returnToTab === 'overview-tab')) { + localStorage.removeItem('amm_return_to_tab'); + switchConfigTab(returnToTab); + } else if (activeConfigTab === 'settings-tab') { + switchConfigTab('settings-tab'); + } else if (activeConfigTab === 'overview-tab') { + switchConfigTab('overview-tab'); + } else { + switchConfigTab('json-tab'); + } + + const globalSettingsForm = document.getElementById('global-settings-form'); + if (globalSettingsForm) { + globalSettingsForm.addEventListener('submit', () => { + this.updateJsonFromSettings(); + }); + } + + this.setupCollapsibles(); + + this.setupConfigForm(); + + this.setupCreateDefaultButton(); + + this.handleCreateDefaultRefresh(); + }, + + loadSettingsFromJson: function() { + const configTextarea = document.querySelector('textarea[name="config_content"]'); + if (!configTextarea) return; + + try { + const configText = configTextarea.value.trim(); + if (!configText) return; + + const config = JSON.parse(configText); + + document.getElementById('min_seconds_between_offers').value = config.min_seconds_between_offers || 15; + document.getElementById('max_seconds_between_offers').value = config.max_seconds_between_offers || 60; + document.getElementById('main_loop_delay').value = config.main_loop_delay || 60; + + const minSecondsBetweenBidsEl = document.getElementById('min_seconds_between_bids'); + const maxSecondsBetweenBidsEl = document.getElementById('max_seconds_between_bids'); + const pruneStateDelayEl = document.getElementById('prune_state_delay'); + const pruneStateAfterSecondsEl = document.getElementById('prune_state_after_seconds'); + + if (minSecondsBetweenBidsEl) minSecondsBetweenBidsEl.value = config.min_seconds_between_bids || 15; + if (maxSecondsBetweenBidsEl) maxSecondsBetweenBidsEl.value = config.max_seconds_between_bids || 60; + if (pruneStateDelayEl) pruneStateDelayEl.value = config.prune_state_delay || 120; + if (pruneStateAfterSecondsEl) pruneStateAfterSecondsEl.value = config.prune_state_after_seconds || 604800; + document.getElementById('auth').value = config.auth || ''; + } catch (error) { + console.error('Error loading settings from JSON:', error); + } + }, + + updateJsonFromSettings: function() { + const configTextarea = document.querySelector('textarea[name="config_content"]'); + if (!configTextarea) return; + + try { + const configText = configTextarea.value.trim(); + let config = {}; + + if (configText) { + config = JSON.parse(configText); + } + + config.min_seconds_between_offers = parseInt(document.getElementById('min_seconds_between_offers').value) || 15; + config.max_seconds_between_offers = parseInt(document.getElementById('max_seconds_between_offers').value) || 60; + config.main_loop_delay = parseInt(document.getElementById('main_loop_delay').value) || 60; + + const minSecondsBetweenBidsEl = document.getElementById('min_seconds_between_bids'); + const maxSecondsBetweenBidsEl = document.getElementById('max_seconds_between_bids'); + const pruneStateDelayEl = document.getElementById('prune_state_delay'); + const pruneStateAfterSecondsEl = document.getElementById('prune_state_after_seconds'); + + if (minSecondsBetweenBidsEl) config.min_seconds_between_bids = parseInt(minSecondsBetweenBidsEl.value) || 15; + if (maxSecondsBetweenBidsEl) config.max_seconds_between_bids = parseInt(maxSecondsBetweenBidsEl.value) || 60; + if (pruneStateDelayEl) config.prune_state_delay = parseInt(pruneStateDelayEl.value) || 120; + if (pruneStateAfterSecondsEl) config.prune_state_after_seconds = parseInt(pruneStateAfterSecondsEl.value) || 604800; + config.auth = document.getElementById('auth').value || ''; + + configTextarea.value = JSON.stringify(config, null, 2); + + localStorage.setItem('amm_return_to_tab', 'settings-tab'); + } catch (error) { + console.error('Error updating JSON from settings:', error); + alert('Error updating configuration: ' + error.message); + } + }, + + setupCollapsibles: function() { + const collapsibleHeaders = document.querySelectorAll('.collapsible-header'); + + if (collapsibleHeaders.length === 0) return; + + let collapsibleStates = {}; + try { + const storedStates = localStorage.getItem('amm_collapsible_states'); + if (storedStates) { + collapsibleStates = JSON.parse(storedStates); + } + } catch (e) { + console.error('Error parsing stored collapsible states:', e); + collapsibleStates = {}; + } + + const toggleCollapsible = (header) => { + const targetId = header.getAttribute('data-target'); + const content = document.getElementById(targetId); + const arrow = header.querySelector('svg'); + + if (content) { + if (content.classList.contains('hidden')) { + content.classList.remove('hidden'); + arrow.classList.add('rotate-180'); + collapsibleStates[targetId] = 'open'; + } else { + content.classList.add('hidden'); + arrow.classList.remove('rotate-180'); + collapsibleStates[targetId] = 'closed'; + } + + localStorage.setItem('amm_collapsible_states', JSON.stringify(collapsibleStates)); + } + }; + + collapsibleHeaders.forEach(header => { + const targetId = header.getAttribute('data-target'); + const content = document.getElementById(targetId); + const arrow = header.querySelector('svg'); + + if (content) { + if (collapsibleStates[targetId] === 'open') { + content.classList.remove('hidden'); + arrow.classList.add('rotate-180'); + } else { + content.classList.add('hidden'); + arrow.classList.remove('rotate-180'); + collapsibleStates[targetId] = 'closed'; + } + } + + header.addEventListener('click', () => toggleCollapsible(header)); + }); + + localStorage.setItem('amm_collapsible_states', JSON.stringify(collapsibleStates)); + }, + + setupConfigForm: function() { + const configForm = document.querySelector('form[method="post"]'); + const saveConfigBtn = document.getElementById('save_config_btn'); + + if (configForm && saveConfigBtn) { + configForm.addEventListener('submit', (e) => { + if (e.submitter && e.submitter.name === 'save_config') { + localStorage.setItem('amm_update_tables', 'true'); + } + }); + + if (localStorage.getItem('amm_update_tables') === 'true') { + localStorage.removeItem('amm_update_tables'); + CleanupManager.setTimeout(() => { + if (window.ammTablesManager && window.ammTablesManager.updateTables) { + window.ammTablesManager.updateTables(); + } + }, 500); + } + } + }, + + setupCreateDefaultButton: function() { + const createDefaultBtn = document.getElementById('create_default_btn'); + const configForm = document.querySelector('form[method="post"]'); + + if (createDefaultBtn && configForm) { + createDefaultBtn.addEventListener('click', (e) => { + e.preventDefault(); + + const title = 'Create Default Configuration'; + const message = 'This will overwrite your current configuration with a default template.\n\nAre you sure you want to continue?'; + + if (window.showConfirmModal) { + window.showConfirmModal(title, message, () => { + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = 'create_default'; + hiddenInput.value = 'true'; + configForm.appendChild(hiddenInput); + + localStorage.setItem('amm_create_default_refresh', 'true'); + + configForm.submit(); + }); + } else { + if (confirm('This will overwrite your current configuration with a default template.\n\nAre you sure you want to continue?')) { + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = 'create_default'; + hiddenInput.value = 'true'; + configForm.appendChild(hiddenInput); + + localStorage.setItem('amm_create_default_refresh', 'true'); + configForm.submit(); + } + } + }); + } + }, + + handleCreateDefaultRefresh: function() { + if (localStorage.getItem('amm_create_default_refresh') === 'true') { + localStorage.removeItem('amm_create_default_refresh'); + + CleanupManager.setTimeout(() => { + window.location.href = window.location.pathname + window.location.search; + }, 500); + } + }, + + cleanup: function() { + } + }; + + document.addEventListener('DOMContentLoaded', function() { + AMMConfigTabs.init(); + + if (window.CleanupManager) { + CleanupManager.registerResource('ammConfigTabs', AMMConfigTabs, (tabs) => { + if (tabs.cleanup) tabs.cleanup(); + }); + } + }); + + window.AMMConfigTabs = AMMConfigTabs; + +})(); diff --git a/basicswap/static/js/amm_counter.js b/basicswap/static/js/pages/amm-counter.js similarity index 95% rename from basicswap/static/js/amm_counter.js rename to basicswap/static/js/pages/amm-counter.js index ec665c6..f1ca0f1 100644 --- a/basicswap/static/js/amm_counter.js +++ b/basicswap/static/js/pages/amm-counter.js @@ -16,13 +16,7 @@ const AmmCounterManager = (function() { } function debugLog(message, data) { - // if (isDebugEnabled()) { - // if (data) { - // console.log(`[AmmCounter] ${message}`, data); - // } else { - // console.log(`[AmmCounter] ${message}`); - // } - // } + } function updateAmmCounter(count, status) { @@ -103,7 +97,7 @@ const AmmCounterManager = (function() { } if (window.TooltipManager && typeof window.TooltipManager.initializeTooltips === 'function') { - setTimeout(() => { + CleanupManager.setTimeout(() => { window.TooltipManager.initializeTooltips(`[data-tooltip-target="${tooltipId}"]`); debugLog(`Re-initialized tooltips for ${tooltipId}`); }, 50); @@ -148,7 +142,7 @@ const AmmCounterManager = (function() { debugLog(`Retrying AMM status fetch (${fetchRetryCount}/${config.maxRetries}) in ${config.retryDelay/1000}s`); return new Promise(resolve => { - setTimeout(() => { + CleanupManager.setTimeout(() => { resolve(fetchAmmStatus()); }, config.retryDelay); }); @@ -168,7 +162,7 @@ const AmmCounterManager = (function() { .then(() => {}) .catch(() => {}); - refreshTimer = setInterval(() => { + refreshTimer = CleanupManager.setInterval(() => { fetchAmmStatus() .then(() => {}) .catch(() => {}); @@ -251,5 +245,11 @@ document.addEventListener('DOMContentLoaded', function() { if (!window.ammCounterManagerInitialized) { window.AmmCounterManager = AmmCounterManager.initialize(); window.ammCounterManagerInitialized = true; + + if (window.CleanupManager) { + CleanupManager.registerResource('ammCounter', window.AmmCounterManager, (mgr) => { + if (mgr && mgr.dispose) mgr.dispose(); + }); + } } }); diff --git a/basicswap/static/js/pages/amm-page.js b/basicswap/static/js/pages/amm-page.js new file mode 100644 index 0000000..b9242c4 --- /dev/null +++ b/basicswap/static/js/pages/amm-page.js @@ -0,0 +1,573 @@ +(function() { + 'use strict'; + + const AMMPage = { + + init: function() { + this.loadDebugSetting(); + this.setupAutostartCheckbox(); + this.setupStartupValidation(); + this.setupDebugCheckbox(); + this.setupModals(); + this.setupClearStateButton(); + this.setupWebSocketBalanceUpdates(); + this.setupCleanup(); + }, + + saveDebugSetting: function() { + const debugCheckbox = document.getElementById('debug-mode'); + if (debugCheckbox) { + localStorage.setItem('amm_debug_enabled', debugCheckbox.checked); + } + }, + + loadDebugSetting: function() { + const debugCheckbox = document.getElementById('debug-mode'); + if (debugCheckbox) { + const savedSetting = localStorage.getItem('amm_debug_enabled'); + if (savedSetting !== null) { + debugCheckbox.checked = savedSetting === 'true'; + } + } + }, + + setupDebugCheckbox: function() { + const debugCheckbox = document.getElementById('debug-mode'); + if (debugCheckbox) { + debugCheckbox.addEventListener('change', this.saveDebugSetting.bind(this)); + } + }, + + saveAutostartSetting: function(checked) { + const bodyData = `autostart=${checked ? 'true' : 'false'}`; + + fetch('/amm/autostart', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: bodyData + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + localStorage.setItem('amm_autostart_enabled', checked); + + if (data.autostart !== checked) { + console.warn('WARNING: API returned different autostart value than expected!', { + sent: checked, + received: data.autostart + }); + } + } else { + console.error('Failed to save autostart setting:', data.error); + const autostartCheckbox = document.getElementById('autostart-amm'); + if (autostartCheckbox) { + autostartCheckbox.checked = !checked; + } + } + }) + .catch(error => { + console.error('Error saving autostart setting:', error); + const autostartCheckbox = document.getElementById('autostart-amm'); + if (autostartCheckbox) { + autostartCheckbox.checked = !checked; + } + }); + }, + + setupAutostartCheckbox: function() { + const autostartCheckbox = document.getElementById('autostart-amm'); + if (autostartCheckbox) { + autostartCheckbox.addEventListener('change', () => { + this.saveAutostartSetting(autostartCheckbox.checked); + }); + } + }, + + showErrorModal: function(title, message) { + document.getElementById('errorTitle').textContent = title || 'Error'; + document.getElementById('errorMessage').textContent = message || 'An error occurred'; + const modal = document.getElementById('errorModal'); + if (modal) { + modal.classList.remove('hidden'); + } + }, + + hideErrorModal: function() { + const modal = document.getElementById('errorModal'); + if (modal) { + modal.classList.add('hidden'); + } + }, + + showConfirmModal: function(title, message, callback) { + document.getElementById('confirmTitle').textContent = title || 'Confirm Action'; + document.getElementById('confirmMessage').textContent = message || 'Are you sure?'; + const modal = document.getElementById('confirmModal'); + if (modal) { + modal.classList.remove('hidden'); + } + + window.confirmCallback = callback; + }, + + hideConfirmModal: function() { + const modal = document.getElementById('confirmModal'); + if (modal) { + modal.classList.add('hidden'); + } + window.confirmCallback = null; + }, + + setupModals: function() { + const errorOkBtn = document.getElementById('errorOk'); + if (errorOkBtn) { + errorOkBtn.addEventListener('click', this.hideErrorModal.bind(this)); + } + + const errorModal = document.getElementById('errorModal'); + if (errorModal) { + errorModal.addEventListener('click', (e) => { + if (e.target === errorModal) { + this.hideErrorModal(); + } + }); + } + + const confirmYesBtn = document.getElementById('confirmYes'); + if (confirmYesBtn) { + confirmYesBtn.addEventListener('click', () => { + if (window.confirmCallback && typeof window.confirmCallback === 'function') { + window.confirmCallback(); + } + this.hideConfirmModal(); + }); + } + + const confirmNoBtn = document.getElementById('confirmNo'); + if (confirmNoBtn) { + confirmNoBtn.addEventListener('click', this.hideConfirmModal.bind(this)); + } + + const confirmModal = document.getElementById('confirmModal'); + if (confirmModal) { + confirmModal.addEventListener('click', (e) => { + if (e.target === confirmModal) { + this.hideConfirmModal(); + } + }); + } + }, + + setupStartupValidation: function() { + const controlForm = document.querySelector('form[method="post"]'); + if (!controlForm) return; + + const startButton = controlForm.querySelector('input[name="start"]'); + if (!startButton) return; + + startButton.addEventListener('click', (e) => { + e.preventDefault(); + this.performStartupValidation(); + }); + }, + + performStartupValidation: function() { + const feedbackDiv = document.getElementById('startup-feedback'); + const titleEl = document.getElementById('startup-title'); + const messageEl = document.getElementById('startup-message'); + const progressBar = document.getElementById('startup-progress-bar'); + + feedbackDiv.classList.remove('hidden'); + + const steps = [ + { message: 'Checking configuration...', progress: 20 }, + { message: 'Validating offers and bids...', progress: 40 }, + { message: 'Checking wallet balances...', progress: 60 }, + { message: 'Verifying API connection...', progress: 80 }, + { message: 'Starting AMM process...', progress: 100 } + ]; + + let currentStep = 0; + + const runNextStep = () => { + if (currentStep >= steps.length) { + this.submitStartForm(); + return; + } + + const step = steps[currentStep]; + messageEl.textContent = step.message; + progressBar.style.width = step.progress + '%'; + + CleanupManager.setTimeout(() => { + this.validateStep(currentStep).then(result => { + if (result.success) { + currentStep++; + runNextStep(); + } else { + this.showStartupError(result.error); + } + }).catch(error => { + this.showStartupError('Validation failed: ' + error.message); + }); + }, 500); + }; + + runNextStep(); + }, + + validateStep: async function(stepIndex) { + try { + switch (stepIndex) { + case 0: + return await this.validateConfiguration(); + case 1: + return await this.validateOffersAndBids(); + case 2: + return await this.validateWalletBalances(); + case 3: + return await this.validateApiConnection(); + case 4: + return { success: true }; + default: + return { success: true }; + } + } catch (error) { + return { success: false, error: error.message }; + } + }, + + validateConfiguration: async function() { + const configData = window.ammTablesConfig?.configData; + if (!configData) { + return { success: false, error: 'No configuration found. Please save a configuration first.' }; + } + + if (!configData.min_seconds_between_offers || !configData.max_seconds_between_offers) { + return { success: false, error: 'Missing timing configuration. Please check your settings.' }; + } + + return { success: true }; + }, + + validateOffersAndBids: async function() { + const configData = window.ammTablesConfig?.configData; + if (!configData) { + return { success: false, error: 'Configuration not available for validation.' }; + } + + const offers = configData.offers || []; + const bids = configData.bids || []; + const enabledOffers = offers.filter(o => o.enabled); + const enabledBids = bids.filter(b => b.enabled); + + if (enabledOffers.length === 0 && enabledBids.length === 0) { + return { success: false, error: 'No enabled offers or bids found. Please enable at least one offer or bid before starting.' }; + } + + for (const offer of enabledOffers) { + if (!offer.amount_step) { + return { success: false, error: `Offer "${offer.name}" is missing required Amount Step (privacy feature).` }; + } + + const amountStep = parseFloat(offer.amount_step); + const amount = parseFloat(offer.amount); + + if (amountStep <= 0 || amountStep < 0.001) { + return { success: false, error: `Offer "${offer.name}" has invalid Amount Step. Must be >= 0.001.` }; + } + + if (amountStep > amount) { + return { success: false, error: `Offer "${offer.name}" Amount Step (${amountStep}) cannot be greater than offer amount (${amount}).` }; + } + } + + return { success: true }; + }, + + validateWalletBalances: async function() { + const configData = window.ammTablesConfig?.configData; + if (!configData) return { success: true }; + + const offers = configData.offers || []; + const enabledOffers = offers.filter(o => o.enabled); + + for (const offer of enabledOffers) { + if (!offer.min_coin_from_amt || parseFloat(offer.min_coin_from_amt) <= 0) { + return { success: false, error: `Offer "${offer.name}" needs a minimum coin amount to protect your wallet balance.` }; + } + } + + return { success: true }; + }, + + validateApiConnection: async function() { + return { success: true }; + }, + + showStartupError: function(errorMessage) { + const feedbackDiv = document.getElementById('startup-feedback'); + feedbackDiv.classList.add('hidden'); + + if (window.showErrorModal) { + window.showErrorModal('AMM Startup Failed', errorMessage); + } else { + alert('AMM Startup Failed: ' + errorMessage); + } + }, + + submitStartForm: function() { + const feedbackDiv = document.getElementById('startup-feedback'); + const titleEl = document.getElementById('startup-title'); + const messageEl = document.getElementById('startup-message'); + + titleEl.textContent = 'Starting AMM...'; + messageEl.textContent = 'AMM process is starting. Please wait...'; + + const controlForm = document.querySelector('form[method="post"]'); + if (controlForm) { + const formData = new FormData(controlForm); + formData.append('start', 'Start'); + + fetch(window.location.pathname, { + method: 'POST', + body: formData + }).then(response => { + if (response.ok) { + window.location.reload(); + } else { + throw new Error('Failed to start AMM'); + } + }).catch(error => { + this.showStartupError('Failed to start AMM: ' + error.message); + }); + } + }, + + setupClearStateButton: function() { + const clearStateBtn = document.getElementById('clearStateBtn'); + if (clearStateBtn) { + clearStateBtn.addEventListener('click', () => { + this.showConfirmModal( + 'Clear AMM State', + 'This will clear the AMM state file. All running offers/bids will be lost. Are you sure?', + () => { + const form = clearStateBtn.closest('form'); + if (form) { + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = 'prune_state'; + hiddenInput.value = 'true'; + form.appendChild(hiddenInput); + form.submit(); + } + } + ); + }); + } + }, + + setAmmAmount: function(percent, fieldId) { + const amountInput = document.getElementById(fieldId); + let coinSelect; + + let modalType = null; + if (fieldId.includes('add-amm')) { + const addModal = document.getElementById('add-amm-modal'); + modalType = addModal ? addModal.getAttribute('data-amm-type') : null; + } else if (fieldId.includes('edit-amm')) { + const editModal = document.getElementById('edit-amm-modal'); + modalType = editModal ? editModal.getAttribute('data-amm-type') : null; + } + + if (fieldId.includes('add-amm')) { + const isBidModal = modalType === 'bid'; + coinSelect = document.getElementById(isBidModal ? 'add-amm-coin-to' : 'add-amm-coin-from'); + } else if (fieldId.includes('edit-amm')) { + const isBidModal = modalType === 'bid'; + coinSelect = document.getElementById(isBidModal ? 'edit-amm-coin-to' : 'edit-amm-coin-from'); + } + + if (!amountInput || !coinSelect) { + console.error('Required elements not found'); + return; + } + + const selectedOption = coinSelect.options[coinSelect.selectedIndex]; + if (!selectedOption) { + if (window.showErrorModal) { + window.showErrorModal('Validation Error', 'Please select a coin first'); + } else { + alert('Please select a coin first'); + } + return; + } + + const balance = selectedOption.getAttribute('data-balance'); + if (!balance) { + console.error('Balance not found for selected coin'); + return; + } + + const floatBalance = parseFloat(balance); + if (isNaN(floatBalance) || floatBalance <= 0) { + if (window.showErrorModal) { + window.showErrorModal('Invalid Balance', 'The selected coin has no available balance. Please select a coin with a positive balance.'); + } else { + alert('Invalid balance for selected coin'); + } + return; + } + + const calculatedAmount = floatBalance * percent; + amountInput.value = calculatedAmount.toFixed(8); + + const event = new Event('input', { bubbles: true }); + amountInput.dispatchEvent(event); + }, + + updateAmmModalBalances: function(balanceData) { + const addModal = document.getElementById('add-amm-modal'); + const editModal = document.getElementById('edit-amm-modal'); + const addModalVisible = addModal && !addModal.classList.contains('hidden'); + const editModalVisible = editModal && !editModal.classList.contains('hidden'); + + let modalType = null; + if (addModalVisible) { + modalType = addModal.getAttribute('data-amm-type'); + } else if (editModalVisible) { + modalType = editModal.getAttribute('data-amm-type'); + } + + if (modalType === 'offer') { + this.updateOfferDropdownBalances(balanceData); + } else if (modalType === 'bid') { + this.updateBidDropdownBalances(balanceData); + } + }, + + setupWebSocketBalanceUpdates: function() { + window.BalanceUpdatesManager.setup({ + contextKey: 'amm', + balanceUpdateCallback: this.updateAmmModalBalances.bind(this), + swapEventCallback: this.updateAmmModalBalances.bind(this), + errorContext: 'AMM', + enablePeriodicRefresh: true, + periodicInterval: 120000 + }); + }, + + updateAmmDropdownBalances: function(balanceData) { + const balanceMap = {}; + const pendingMap = {}; + balanceData.forEach(coin => { + balanceMap[coin.name] = coin.balance; + pendingMap[coin.name] = coin.pending || '0.0'; + }); + + const dropdownIds = ['add-amm-coin-from', 'edit-amm-coin-from', 'add-amm-coin-to', 'edit-amm-coin-to']; + + dropdownIds.forEach(dropdownId => { + const select = document.getElementById(dropdownId); + if (!select) { + return; + } + + Array.from(select.options).forEach(option => { + const coinName = option.value; + const balance = balanceMap[coinName] || '0.0'; + const pending = pendingMap[coinName] || '0.0'; + + option.setAttribute('data-balance', balance); + option.setAttribute('data-pending-balance', pending); + }); + }); + + const addModal = document.getElementById('add-amm-modal'); + const editModal = document.getElementById('edit-amm-modal'); + const addModalVisible = addModal && !addModal.classList.contains('hidden'); + const editModalVisible = editModal && !editModal.classList.contains('hidden'); + + let currentModalType = null; + if (addModalVisible) { + currentModalType = addModal.getAttribute('data-amm-type'); + } else if (editModalVisible) { + currentModalType = editModal.getAttribute('data-amm-type'); + } + + if (currentModalType && window.ammTablesManager) { + if (currentModalType === 'offer' && typeof window.ammTablesManager.refreshOfferDropdownBalanceDisplay === 'function') { + window.ammTablesManager.refreshOfferDropdownBalanceDisplay(); + } else if (currentModalType === 'bid' && typeof window.ammTablesManager.refreshBidDropdownBalanceDisplay === 'function') { + window.ammTablesManager.refreshBidDropdownBalanceDisplay(); + } + } + }, + + updateOfferDropdownBalances: function(balanceData) { + this.updateAmmDropdownBalances(balanceData); + }, + + updateBidDropdownBalances: function(balanceData) { + this.updateAmmDropdownBalances(balanceData); + }, + + cleanupAmmBalanceUpdates: function() { + window.BalanceUpdatesManager.cleanup('amm'); + + if (window.ammDropdowns) { + window.ammDropdowns.forEach(dropdown => { + if (dropdown.parentNode) { + dropdown.parentNode.removeChild(dropdown); + } + }); + window.ammDropdowns = []; + } + }, + + setupCleanup: function() { + if (window.CleanupManager) { + window.CleanupManager.registerResource('ammBalanceUpdates', null, this.cleanupAmmBalanceUpdates.bind(this)); + } + + const beforeUnloadHandler = this.cleanupAmmBalanceUpdates.bind(this); + window.addEventListener('beforeunload', beforeUnloadHandler); + + if (window.CleanupManager) { + CleanupManager.registerResource('ammBeforeUnload', beforeUnloadHandler, () => { + window.removeEventListener('beforeunload', beforeUnloadHandler); + }); + } + }, + + cleanup: function() { + const debugCheckbox = document.getElementById('amm_debug'); + const autostartCheckbox = document.getElementById('amm_autostart'); + const errorOkBtn = document.getElementById('errorOk'); + const confirmYesBtn = document.getElementById('confirmYes'); + const confirmNoBtn = document.getElementById('confirmNo'); + const startButton = document.getElementById('startAMM'); + const clearStateBtn = document.getElementById('clearAmmState'); + + this.cleanupAmmBalanceUpdates(); + } + }; + + document.addEventListener('DOMContentLoaded', function() { + AMMPage.init(); + + if (window.BalanceUpdatesManager) { + window.BalanceUpdatesManager.initialize(); + } + }); + + window.AMMPage = AMMPage; + window.showErrorModal = AMMPage.showErrorModal.bind(AMMPage); + window.hideErrorModal = AMMPage.hideErrorModal.bind(AMMPage); + window.showConfirmModal = AMMPage.showConfirmModal.bind(AMMPage); + window.hideConfirmModal = AMMPage.hideConfirmModal.bind(AMMPage); + window.setAmmAmount = AMMPage.setAmmAmount.bind(AMMPage); + +})(); diff --git a/basicswap/static/js/amm_tables.js b/basicswap/static/js/pages/amm-tables.js similarity index 98% rename from basicswap/static/js/amm_tables.js rename to basicswap/static/js/pages/amm-tables.js index ad8f074..8d4d17e 100644 --- a/basicswap/static/js/amm_tables.js +++ b/basicswap/static/js/pages/amm-tables.js @@ -61,53 +61,8 @@ const AmmTablesManager = (function() { } function getCoinDisplayName(coinId) { - if (config.debug) { - console.log('[AMM Tables] getCoinDisplayName called with:', coinId, typeof coinId); - } - - if (typeof coinId === 'string') { - const lowerCoinId = coinId.toLowerCase(); - - if (lowerCoinId === 'part_anon' || - lowerCoinId === 'particl_anon' || - lowerCoinId === 'particl anon') { - if (config.debug) { - console.log('[AMM Tables] Matched Particl Anon variant:', coinId); - } - return 'Particl Anon'; - } - - if (lowerCoinId === 'part_blind' || - lowerCoinId === 'particl_blind' || - lowerCoinId === 'particl blind') { - if (config.debug) { - console.log('[AMM Tables] Matched Particl Blind variant:', coinId); - } - return 'Particl Blind'; - } - - if (lowerCoinId === 'ltc_mweb' || - lowerCoinId === 'litecoin_mweb' || - lowerCoinId === 'litecoin mweb') { - if (config.debug) { - console.log('[AMM Tables] Matched Litecoin MWEB variant:', coinId); - } - return 'Litecoin MWEB'; - } - } - if (window.CoinManager && window.CoinManager.getDisplayName) { - const displayName = window.CoinManager.getDisplayName(coinId); - if (displayName) { - if (config.debug) { - console.log('[AMM Tables] CoinManager returned:', displayName); - } - return displayName; - } - } - - if (config.debug) { - console.log('[AMM Tables] Returning coin name as-is:', coinId); + return window.CoinManager.getDisplayName(coinId) || coinId; } return coinId; } @@ -303,7 +258,6 @@ const AmmTablesManager = (function() { `; }); - if (offersBody.innerHTML.trim() !== tableHtml.trim()) { offersBody.innerHTML = tableHtml; } @@ -438,7 +392,6 @@ const AmmTablesManager = (function() { `; }); - if (bidsBody.innerHTML.trim() !== tableHtml.trim()) { bidsBody.innerHTML = tableHtml; } @@ -540,7 +493,6 @@ const AmmTablesManager = (function() { coinPrice = window.latestPrices[coinName.toUpperCase()]; } - if (!coinPrice || isNaN(coinPrice)) { return null; } @@ -550,6 +502,9 @@ const AmmTablesManager = (function() { function formatUSDPrice(usdValue) { if (!usdValue || isNaN(usdValue)) return ''; + if (window.config && window.config.utils && window.config.utils.formatPrice) { + return `($${window.config.utils.formatPrice('USD', usdValue)} USD)`; + } return `($${usdValue.toFixed(2)} USD)`; } @@ -728,7 +683,6 @@ const AmmTablesManager = (function() { const isMakerDropdown = select.id.includes('coin-from'); const isTakerDropdown = select.id.includes('coin-to'); - const addModal = document.getElementById('add-amm-modal'); const editModal = document.getElementById('edit-amm-modal'); const addModalVisible = addModal && !addModal.classList.contains('hidden'); @@ -755,7 +709,6 @@ const AmmTablesManager = (function() { } } - const result = isBidModal ? isTakerDropdown : isMakerDropdown; console.log(`[DEBUG] shouldDropdownOptionsShowBalance: ${select.id}, isBidModal=${isBidModal}, isMaker=${isMakerDropdown}, isTaker=${isTakerDropdown}, result=${result}`); @@ -773,22 +726,18 @@ const AmmTablesManager = (function() { const wrapper = select.parentNode.querySelector('.relative'); if (!wrapper) return; - const dropdown = wrapper.querySelector('[role="listbox"]'); if (!dropdown) return; - const options = dropdown.querySelectorAll('[data-value]'); options.forEach(optionElement => { const coinValue = optionElement.getAttribute('data-value'); const originalOption = Array.from(select.options).find(opt => opt.value === coinValue); if (!originalOption) return; - const textContainer = optionElement.querySelector('div.flex.flex-col, div.flex.items-center'); if (!textContainer) return; - textContainer.innerHTML = ''; const shouldShowBalance = shouldDropdownOptionsShowBalance(select); @@ -828,7 +777,6 @@ const AmmTablesManager = (function() { }); } - function refreshDropdownBalances() { const dropdownIds = ['add-amm-coin-from', 'add-amm-coin-to', 'edit-amm-coin-from', 'edit-amm-coin-to']; @@ -839,7 +787,6 @@ const AmmTablesManager = (function() { const wrapper = select.parentNode.querySelector('.relative'); if (!wrapper) return; - const dropdownItems = wrapper.querySelectorAll('[data-value]'); dropdownItems.forEach(item => { const value = item.getAttribute('data-value'); @@ -852,7 +799,6 @@ const AmmTablesManager = (function() { if (balanceDiv) { balanceDiv.textContent = `Balance: ${balance}`; - let pendingDiv = item.querySelector('.text-green-500'); if (pendingBalance && parseFloat(pendingBalance) > 0) { if (!pendingDiv) { @@ -880,7 +826,6 @@ const AmmTablesManager = (function() { balanceDiv.textContent = `Balance: ${balance}`; - let pendingDiv = textContainer.querySelector('.text-green-500'); if (pendingBalance && parseFloat(pendingBalance) > 0) { if (!pendingDiv) { @@ -940,10 +885,8 @@ const AmmTablesManager = (function() { if (!coinFromSelect || !coinToSelect) return; - const balanceData = {}; - Array.from(coinFromSelect.options).forEach(option => { const balance = option.getAttribute('data-balance'); if (balance) { @@ -951,7 +894,6 @@ const AmmTablesManager = (function() { } }); - Array.from(coinToSelect.options).forEach(option => { const balance = option.getAttribute('data-balance'); if (balance) { @@ -959,7 +901,6 @@ const AmmTablesManager = (function() { } }); - updateDropdownOptions(coinFromSelect, balanceData); updateDropdownOptions(coinToSelect, balanceData); } @@ -970,11 +911,9 @@ const AmmTablesManager = (function() { const balance = balanceData[coinName] || '0.00000000'; const pending = pendingData[coinName] || '0.0'; - option.setAttribute('data-balance', balance); option.setAttribute('data-pending-balance', pending); - option.textContent = coinName; }); } @@ -982,7 +921,6 @@ const AmmTablesManager = (function() { function createSimpleDropdown(select, showBalance = false) { if (!select) return; - const existingWrapper = select.parentNode.querySelector('.relative'); if (existingWrapper) { existingWrapper.remove(); @@ -994,13 +932,11 @@ const AmmTablesManager = (function() { const wrapper = document.createElement('div'); wrapper.className = 'relative'; - const button = document.createElement('button'); button.type = 'button'; button.className = 'flex items-center justify-between w-full p-2.5 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:text-white'; button.style.minHeight = '60px'; - const displayContent = document.createElement('div'); displayContent.className = 'flex items-center'; @@ -1019,11 +955,9 @@ const AmmTablesManager = (function() { button.appendChild(displayContent); button.appendChild(arrow); - const dropdown = document.createElement('div'); dropdown.className = 'absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg hidden dark:bg-gray-700 dark:border-gray-600 max-h-60 overflow-y-auto'; - Array.from(select.options).forEach(option => { const item = document.createElement('div'); item.className = 'flex items-center p-2 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer'; @@ -1047,7 +981,6 @@ const AmmTablesManager = (function() {
Balance: ${balance}
`; - if (pendingBalance && parseFloat(pendingBalance) > 0) { html += `
+${pendingBalance} pending
`; } @@ -1061,11 +994,9 @@ const AmmTablesManager = (function() { item.appendChild(itemIcon); item.appendChild(itemText); - item.addEventListener('click', function() { select.value = this.getAttribute('data-value'); - const selectedOption = select.options[select.selectedIndex]; const selectedCoinName = selectedOption.textContent.trim(); const selectedBalance = selectedOption.getAttribute('data-balance') || '0.00000000'; @@ -1079,7 +1010,6 @@ const AmmTablesManager = (function() {
Balance: ${selectedBalance}
`; - if (selectedPendingBalance && parseFloat(selectedPendingBalance) > 0) { html += `
+${selectedPendingBalance} pending
`; } @@ -1093,7 +1023,6 @@ const AmmTablesManager = (function() { dropdown.classList.add('hidden'); - const event = new Event('change', { bubbles: true }); select.dispatchEvent(event); }); @@ -1101,7 +1030,6 @@ const AmmTablesManager = (function() { dropdown.appendChild(item); }); - const selectedOption = select.options[select.selectedIndex]; if (selectedOption) { const selectedCoinName = selectedOption.textContent.trim(); @@ -1116,7 +1044,6 @@ const AmmTablesManager = (function() {
Balance: ${selectedBalance}
`; - if (selectedPendingBalance && parseFloat(selectedPendingBalance) > 0) { html += `
+${selectedPendingBalance} pending
`; } @@ -1129,12 +1056,10 @@ const AmmTablesManager = (function() { } } - button.addEventListener('click', function() { dropdown.classList.toggle('hidden'); }); - document.addEventListener('click', function(e) { if (!wrapper.contains(e.target)) { dropdown.classList.add('hidden'); @@ -1267,7 +1192,6 @@ const AmmTablesManager = (function() { modalTitle.textContent = `Add New ${type.charAt(0).toUpperCase() + type.slice(1)}`; } - const modal = document.getElementById('add-amm-modal'); if (modal) { modal.classList.remove('hidden'); @@ -1275,17 +1199,14 @@ const AmmTablesManager = (function() { modal.setAttribute('data-amm-type', type); } - setTimeout(() => { updateDropdownsForModalType('add'); initializeCustomSelects(type); - refreshDropdownBalanceDisplay(type); - if (typeof fetchBalanceData === 'function') { fetchBalanceData() .then(balanceData => { @@ -1721,7 +1642,6 @@ const AmmTablesManager = (function() { modalTitle.textContent = `Edit ${type.charAt(0).toUpperCase() + type.slice(1)}`; } - const modal = document.getElementById('edit-amm-modal'); if (modal) { modal.classList.remove('hidden'); @@ -1729,17 +1649,14 @@ const AmmTablesManager = (function() { modal.setAttribute('data-amm-type', type); } - setTimeout(() => { updateDropdownsForModalType('edit'); initializeCustomSelects(type); - refreshDropdownBalanceDisplay(type); - if (typeof fetchBalanceData === 'function') { fetchBalanceData() .then(balanceData => { @@ -1873,7 +1790,6 @@ const AmmTablesManager = (function() { }); } - function closeEditModal() { const modal = document.getElementById('edit-amm-modal'); if (modal) { @@ -2306,14 +2222,11 @@ const AmmTablesManager = (function() { document.getElementById('edit-offer-swap-type') ]; - - function createSwapTypeDropdown(select) { if (!select) return; - if (select.style.display === 'none' && select.parentNode.querySelector('.relative')) { - return; // Custom dropdown already exists + return; } const wrapper = document.createElement('div'); @@ -2416,9 +2329,9 @@ const AmmTablesManager = (function() { let showBalance = false; if (modalType === 'offer' && select.id.includes('coin-from')) { - showBalance = true; // OFFER: maker shows balance + showBalance = true; } else if (modalType === 'bid' && select.id.includes('coin-to')) { - showBalance = true; // BID: taker shows balance + showBalance = true; } createSimpleDropdown(select, showBalance); @@ -2720,7 +2633,7 @@ const AmmTablesManager = (function() { icon.classList.remove('animate-spin'); } refreshButton.disabled = false; - }, 500); // Reduced from 1000ms to 500ms + }, 500); } }); } diff --git a/basicswap/static/js/bids_available.js b/basicswap/static/js/pages/bids-available-page.js similarity index 98% rename from basicswap/static/js/bids_available.js rename to basicswap/static/js/pages/bids-available-page.js index 1120189..fa49168 100644 --- a/basicswap/static/js/bids_available.js +++ b/basicswap/static/js/pages/bids-available-page.js @@ -53,9 +53,9 @@ const getTimeStrokeColor = (expireTime) => { const now = Math.floor(Date.now() / 1000); const timeLeft = expireTime - now; - if (timeLeft <= 300) return '#9CA3AF'; // 5 minutes or less - if (timeLeft <= 1800) return '#3B82F6'; // 30 minutes or less - return '#10B981'; // More than 30 minutes + if (timeLeft <= 300) return '#9CA3AF'; + if (timeLeft <= 1800) return '#3B82F6'; + return '#10B981'; }; const createTimeTooltip = (bid) => { @@ -249,7 +249,7 @@ const updateLoadingState = (isLoading) => { const refreshText = elements.refreshBidsButton.querySelector('#refreshText'); if (refreshIcon) { - // Add CSS transition for smoother animation + refreshIcon.style.transition = 'transform 0.3s ease'; refreshIcon.classList.toggle('animate-spin', isLoading); } @@ -631,7 +631,7 @@ if (elements.refreshBidsButton) { updateLoadingState(true); - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise(resolve => CleanupManager.setTimeout(resolve, 500)); try { await updateBidsTable({ resetPage: true, refreshData: true }); diff --git a/basicswap/static/js/bids_sentreceived_export.js b/basicswap/static/js/pages/bids-export.js similarity index 98% rename from basicswap/static/js/bids_sentreceived_export.js rename to basicswap/static/js/pages/bids-export.js index a916f6e..43ea810 100644 --- a/basicswap/static/js/bids_sentreceived_export.js +++ b/basicswap/static/js/pages/bids-export.js @@ -66,7 +66,7 @@ const BidExporter = { link.click(); document.body.removeChild(link); - setTimeout(() => { + CleanupManager.setTimeout(() => { URL.revokeObjectURL(url); }, 100); } catch (error) { @@ -104,7 +104,7 @@ const BidExporter = { }; document.addEventListener('DOMContentLoaded', function() { - setTimeout(function() { + CleanupManager.setTimeout(function() { if (typeof state !== 'undefined' && typeof EventManager !== 'undefined') { const exportAllButton = document.getElementById('exportAllBids'); if (exportAllButton) { diff --git a/basicswap/static/js/bids_sentreceived.js b/basicswap/static/js/pages/bids-page.js similarity index 97% rename from basicswap/static/js/bids_sentreceived.js rename to basicswap/static/js/pages/bids-page.js index c6f2110..05f88e4 100644 --- a/basicswap/static/js/bids_sentreceived.js +++ b/basicswap/static/js/pages/bids-page.js @@ -32,7 +32,7 @@ document.addEventListener('tabactivated', function(event) { if (event.detail && event.detail.tabId) { const tabType = event.detail.type || (event.detail.tabId === '#all' ? 'all' : (event.detail.tabId === '#sent' ? 'sent' : 'received')); - //console.log('Tab activation event received for:', tabType); + state.currentTab = tabType; updateBidsTable(); } @@ -190,8 +190,7 @@ const EventManager = { }; function cleanup() { - //console.log('Starting comprehensive cleanup process for bids table'); - + try { if (searchTimeout) { clearTimeout(searchTimeout); @@ -326,8 +325,7 @@ window.cleanupBidsTable = cleanup; CleanupManager.addListener(document, 'visibilitychange', () => { if (document.hidden) { - //console.log('Page hidden - pausing WebSocket and optimizing memory'); - + if (WebSocketManager && typeof WebSocketManager.pause === 'function') { WebSocketManager.pause(); } else if (WebSocketManager && typeof WebSocketManager.disconnect === 'function') { @@ -351,7 +349,7 @@ CleanupManager.addListener(document, 'visibilitychange', () => { const lastUpdateTime = state.lastRefresh || 0; const now = Date.now(); - const refreshInterval = 5 * 60 * 1000; // 5 minutes + const refreshInterval = 5 * 60 * 1000; if (now - lastUpdateTime > refreshInterval) { setTimeout(() => { @@ -490,13 +488,7 @@ function coinMatches(offerCoin, filterCoin) { if (offerCoin === filterCoin) return true; - if ((offerCoin === 'firo' || offerCoin === 'zcoin') && - (filterCoin === 'firo' || filterCoin === 'zcoin')) { - return true; - } - - if ((offerCoin === 'bitcoincash' && filterCoin === 'bitcoin cash') || - (offerCoin === 'bitcoin cash' && filterCoin === 'bitcoincash')) { + if (window.CoinUtils && window.CoinUtils.isSameCoin(offerCoin, filterCoin)) { return true; } @@ -926,6 +918,12 @@ const forceTooltipDOMCleanup = () => { foundCount += allTooltipElements.length; allTooltipElements.forEach(element => { + const isInTooltipContainer = element.closest('.tooltip-container'); + + if (isInTooltipContainer) { + return; + } + const isDetached = !document.body.contains(element) || element.classList.contains('hidden') || element.style.display === 'none'; @@ -1012,7 +1010,7 @@ const forceTooltipDOMCleanup = () => { }); } if (removedCount > 0) { - // console.log(`Tooltip cleanup: found ${foundCount}, removed ${removedCount} detached tooltips`); + } } @@ -1146,7 +1144,7 @@ const createTableRow = async (bid) => { `; } - return ` + const rowHtml = ` @@ -1232,13 +1230,16 @@ const createTableRow = async (bid) => { + `; - + const tooltipIdentityHtml = ` + `; + const tooltipStatusHtml = ` @@ -315,7 +311,7 @@
-
@@ -338,6 +334,29 @@
+ + - + {% include 'footer.html' %}
diff --git a/basicswap/templates/offer_new_2.html b/basicswap/templates/offer_new_2.html index b92a8a8..895664d 100644 --- a/basicswap/templates/offer_new_2.html +++ b/basicswap/templates/offer_new_2.html @@ -1,23 +1,14 @@ -{% include 'header.html' %} {% from 'style.html' import breadcrumb_line_svg, input_down_arrow_offer_svg, select_network_svg, select_address_svg, select_swap_type_svg, select_bid_amount_svg, select_rate_svg, step_one_svg, step_two_svg, step_three_svg, select_setup_svg, input_time_svg, select_target_svg , select_auto_strategy_svg %} +{% include 'header.html' %} {% from 'style.html' import input_down_arrow_offer_svg, select_network_svg, select_address_svg, select_swap_type_svg, select_bid_amount_svg, select_rate_svg, step_one_svg, step_two_svg, step_three_svg, select_setup_svg, input_time_svg, select_target_svg , select_auto_strategy_svg %} +{% from 'macros.html' import breadcrumb %}
-
    -
  • -

    Home

    -
    -
  • - {{ breadcrumb_line_svg | safe }} -
  • - Placer -
  • - {{ breadcrumb_line_svg | safe }} -
  • - Setup -
  • - {{ breadcrumb_line_svg | safe }} -
+ {{ breadcrumb([ + {'text': 'Home', 'url': '/'}, + {'text': 'Place', 'url': '/newoffer'}, + {'text': 'Setup', 'url': '#'} + ]) }}
@@ -447,7 +438,7 @@ {% endif %} - +
diff --git a/basicswap/templates/offers.html b/basicswap/templates/offers.html index a39475e..40a7c22 100644 --- a/basicswap/templates/offers.html +++ b/basicswap/templates/offers.html @@ -1,5 +1,6 @@ {% include 'header.html' %} -{% from 'style.html' import breadcrumb_line_svg, place_new_offer_svg, page_back_svg, page_forwards_svg, filter_clear_svg, filter_apply_svg, input_arrow_down_svg, arrow_right_svg %} +{% from 'style.html' import place_new_offer_svg, page_back_svg, page_forwards_svg, filter_clear_svg, filter_apply_svg, input_arrow_down_svg, arrow_right_svg %} +{% from 'macros.html' import page_header %} -
-
-
- - - -
-
-

{{ page_type }}

-

{{ page_type_description }}

-
- -
-
-
-
+{{ page_header(page_type, page_type_description, dark_bg='dark:bg-gray-500') }} {% include 'inc_messages.html' %} @@ -194,7 +173,7 @@ - + {% endif %} @@ -432,6 +411,6 @@ - + {% include 'footer.html' %} diff --git a/basicswap/templates/rpc.html b/basicswap/templates/rpc.html index 8937b6d..a0b7f09 100644 --- a/basicswap/templates/rpc.html +++ b/basicswap/templates/rpc.html @@ -1,21 +1,14 @@ {% include 'header.html' %} -{% from 'style.html' import breadcrumb_line_svg, input_arrow_down_svg %} +{% from 'style.html' import input_arrow_down_svg %} +{% from 'macros.html' import breadcrumb %}
-
    -
  • - -

    Home

    -
    -
  • -
  • {{ breadcrumb_line_svg | safe }}
  • -
  • - RPC Console -
  • -
  • {{ breadcrumb_line_svg | safe }}
  • -
+ {{ breadcrumb([ + {'text': 'Home', 'url': '/'}, + {'text': 'RPC Console', 'url': '/rpc'} + ]) }}
diff --git a/basicswap/templates/settings.html b/basicswap/templates/settings.html index 8d25a7d..ce8b7a7 100644 --- a/basicswap/templates/settings.html +++ b/basicswap/templates/settings.html @@ -1,21 +1,14 @@ {% include 'header.html' %} -{% from 'style.html' import breadcrumb_line_svg, input_arrow_down_svg %} +{% from 'style.html' import input_arrow_down_svg %} +{% from 'macros.html' import breadcrumb %}
-
    -
  • - -

    Home

    -
    -
  • -
  • {{ breadcrumb_line_svg | safe }}
  • -
  • - Settings -
  • -
  • {{ breadcrumb_line_svg | safe }}
  • -
+ {{ breadcrumb([ + {'text': 'Home', 'url': '/'}, + {'text': 'Settings', 'url': '/settings'} + ]) }}
@@ -268,12 +261,12 @@ Apply Changes {% if c.can_disable == true %} - {% endif %} {% if c.can_reenable == true %} - {% endif %} @@ -401,12 +394,6 @@

Enable price charts in the interface

-
- - -

API key for CryptoCompare price data

-
-
@@ -507,7 +494,7 @@
-
@@ -549,10 +536,10 @@

Test Notifications

- -
- - - + {% include 'footer.html' %} diff --git a/basicswap/templates/smsgaddresses.html b/basicswap/templates/smsgaddresses.html index e1dd9cd..21c9f4a 100644 --- a/basicswap/templates/smsgaddresses.html +++ b/basicswap/templates/smsgaddresses.html @@ -1,21 +1,14 @@ {% include 'header.html' %} -{% from 'style.html' import breadcrumb_line_svg, input_arrow_down_svg, filter_apply_svg, circle_plus_svg, page_forwards_svg, page_back_svg %} +{% from 'style.html' import input_arrow_down_svg, filter_apply_svg, circle_plus_svg, page_forwards_svg, page_back_svg %} +{% from 'macros.html' import breadcrumb %}
- + {{ breadcrumb([ + {'text': 'Home', 'url': '/'}, + {'text': 'SMSG Addresses', 'url': '/smsgaddresses'} + ]) }}
diff --git a/basicswap/templates/tor.html b/basicswap/templates/tor.html index d9c1fc6..5a866fe 100644 --- a/basicswap/templates/tor.html +++ b/basicswap/templates/tor.html @@ -1,21 +1,14 @@ {% include 'header.html' %} -{% from 'style.html' import breadcrumb_line_svg, circular_arrows_svg %} +{% from 'style.html' import circular_arrows_svg %} +{% from 'macros.html' import breadcrumb %}
-
    -
  • - -

    Home

    -
    -
  • -
  • {{ breadcrumb_line_svg | safe }}
  • -
  • - Tor -
  • -
  • {{ breadcrumb_line_svg | safe }}
  • -
+ {{ breadcrumb([ + {'text': 'Home', 'url': '/'}, + {'text': 'Tor', 'url': '/tor'} + ]) }}
diff --git a/basicswap/templates/unlock.html b/basicswap/templates/unlock.html index e81c03d..3790547 100644 --- a/basicswap/templates/unlock.html +++ b/basicswap/templates/unlock.html @@ -129,7 +129,7 @@
Main Address:
@@ -282,7 +269,7 @@
{{ w.main_address }}
{% else %} -
+
Deposit Address:
@@ -309,7 +296,7 @@
{% if w.cid in '6, 9' %} {# XMR | WOW #} -
+
Subaddress:
@@ -323,7 +310,7 @@
{% elif w.cid == '1' %} {# PART #} -
+
Stealth Address:
@@ -333,7 +320,7 @@ {# / PART #} {% elif w.cid == '3' %} {# LTC #} -
+
MWEB Address:
@@ -362,247 +349,9 @@ -{% if w.cid == '1' %} - {# PART #} - - {% elif w.cid == '3' %} - {# LTC #} - - {% endif %} - - {% if w.cid in '6, 9' %} - {# XMR | WOW #} - - - - - {% else %} - -{% endif %} - - +
@@ -682,12 +431,12 @@ function fillDonationAddress(address, type) { {% if donation_info.mweb_address %}
- -
@@ -696,8 +445,8 @@ function fillDonationAddress(address, type) {
{% else %} - {% endif %} @@ -729,64 +478,6 @@ function fillDonationAddress(address, type) { - - {# / PART #} {% elif w.cid == '3' %} @@ -795,80 +486,11 @@ function setAmount(percent, balance, cid, blindBalance, anonBalance) { - - {# / LTC #} {% else %} - - {% endif %} @@ -974,7 +596,7 @@ function setAmount(percent, balance, cid, blindBalance, anonBalance) { {% else %}
{% endif %} -
+
@@ -1019,7 +641,7 @@ function setAmount(percent, balance, cid, blindBalance, anonBalance) { - + @@ -1080,492 +702,7 @@ function setAmount(percent, balance, cid, blindBalance, anonBalance) { - + {% include 'footer.html' %} diff --git a/basicswap/templates/wallets.html b/basicswap/templates/wallets.html index 464b5ba..bbda852 100644 --- a/basicswap/templates/wallets.html +++ b/basicswap/templates/wallets.html @@ -1,5 +1,5 @@ {% include 'header.html' %} -{% from 'style.html' import breadcrumb_line_svg, circular_arrows_svg, withdraw_svg, utxo_groups_svg, create_utxo_svg, lock_svg, eye_show_svg %} +{% from 'style.html' import circular_arrows_svg, withdraw_svg, utxo_groups_svg, create_utxo_svg, lock_svg, eye_show_svg %}
@@ -190,425 +190,7 @@ {% include 'footer.html' %} - + diff --git a/basicswap/templates/watched.html b/basicswap/templates/watched.html index 0dbd6c6..5e73178 100644 --- a/basicswap/templates/watched.html +++ b/basicswap/templates/watched.html @@ -1,21 +1,14 @@ {% include 'header.html' %} -{% from 'style.html' import breadcrumb_line_svg, circular_arrows_svg %} +{% from 'style.html' import circular_arrows_svg %} +{% from 'macros.html' import breadcrumb %}
- + {{ breadcrumb([ + {'text': 'Home', 'url': '/'}, + {'text': 'Watched Outputs', 'url': '/watched'} + ]) }}
diff --git a/basicswap/ui/page_amm.py b/basicswap/ui/page_amm.py index 1730d94..f1e1e21 100644 --- a/basicswap/ui/page_amm.py +++ b/basicswap/ui/page_amm.py @@ -409,10 +409,6 @@ def get_amm_active_count(swap_client, debug_override=False): state_path = get_amm_state_path(swap_client) if not os.path.exists(state_path): - if debug_enabled: - swap_client.log.info( - f"AMM state file not found at {state_path}, returning count 0" - ) return 0 config_path = get_amm_config_path(swap_client) @@ -432,11 +428,6 @@ def get_amm_active_count(swap_client, debug_override=False): if bid.get("enabled", False): enabled_bids.add(bid.get("name", "")) - if debug_enabled: - swap_client.log.info( - f"Enabled templates: {len(enabled_offers)} offers, {len(enabled_bids)} bids" - ) - except Exception as e: if debug_enabled: swap_client.log.error(f"Error reading config file: {str(e)}") @@ -450,11 +441,6 @@ def get_amm_active_count(swap_client, debug_override=False): with open(state_path, "r") as f: state_data = json.load(f) - if debug_enabled: - swap_client.log.debug( - f"AMM state data loaded with {len(state_data.get('offers', {}))} offer templates" - ) - try: network_offers = swap_client.listOffers() @@ -501,31 +487,17 @@ def get_amm_active_count(swap_client, debug_override=False): swap_client.log.error(traceback.format_exc()) continue - if debug_enabled: - swap_client.log.debug( - f"Found {len(active_network_offers)} active offers in the network" - ) - except Exception as e: if debug_enabled: swap_client.log.error(f"Error getting network offers: {str(e)}") swap_client.log.error(traceback.format_exc()) if len(active_network_offers) == 0: - if debug_enabled: - swap_client.log.info( - "No active network offers found, trying direct API call" - ) - try: global amm_host, amm_port if "amm_host" not in globals() or "amm_port" not in globals(): amm_host = "127.0.0.1" amm_port = 12700 - if debug_enabled: - swap_client.log.info( - f"Using default host {amm_host} and port {amm_port} for API call" - ) api_url = f"http://{amm_host}:{amm_port}/api/v1/offers" @@ -539,11 +511,6 @@ def get_amm_active_count(swap_client, debug_override=False): offer_id = offer["id"] active_network_offers[offer_id] = True - if debug_enabled: - swap_client.log.info( - f"Found {len(active_network_offers)} active offers via API" - ) - except Exception as e: if debug_enabled: swap_client.log.error(f"Error getting offers via API: {str(e)}") @@ -561,21 +528,6 @@ def get_amm_active_count(swap_client, debug_override=False): active_offer_count += 1 amm_count += active_offer_count - if debug_enabled: - total_offers = len(offers) - enabled_status = ( - "enabled" - if enabled_offers is None or template_name in enabled_offers - else "disabled" - ) - if debug_enabled: - swap_client.log.debug( - f"Template '{template_name}' ({enabled_status}): {active_offer_count} active out of {total_offers} total offers" - ) - elif debug_enabled: - swap_client.log.debug( - f"Template '{template_name}' is disabled, skipping {len(offers)} offers" - ) if "bids" in state_data: for template_name, bids in state_data["bids"].items(): @@ -586,36 +538,12 @@ def get_amm_active_count(swap_client, debug_override=False): active_bid_count += 1 amm_count += active_bid_count - if debug_enabled: - total_bids = len(bids) - enabled_status = ( - "enabled" - if enabled_bids is None or template_name in enabled_bids - else "disabled" - ) - - if debug_enabled: - swap_client.log.debug( - f"Template '{template_name}' ({enabled_status}): {active_bid_count} active out of {total_bids} total bids" - ) - elif debug_enabled: - swap_client.log.debug( - f"Template '{template_name}' is disabled, skipping {len(bids)} bids" - ) - - if debug_enabled: - swap_client.log.debug(f"Total active AMM count: {amm_count}") if ( amm_count == 0 and len(active_network_offers) == 0 and "offers" in state_data ): - if debug_enabled: - swap_client.log.info( - "No active network offers found, using most recent offer from state file" - ) - most_recent_time = 0 most_recent_offer = None @@ -631,21 +559,8 @@ def get_amm_active_count(swap_client, debug_override=False): if offer_age < 3600: amm_count = 1 - if debug_enabled: - swap_client.log.info( - f"Using most recent offer as active (age: {offer_age} seconds)" - ) - if "offer_id" in most_recent_offer: - swap_client.log.info( - f"Most recent offer ID: {most_recent_offer['offer_id']}" - ) if amm_count == 0 and "delay_next_offer_before" in state_data: - if debug_enabled: - swap_client.log.info( - "Found delay_next_offer_before in state, AMM is running but waiting to create next offer" - ) - config_path = get_amm_config_path(swap_client) if os.path.exists(config_path): try: @@ -654,10 +569,6 @@ def get_amm_active_count(swap_client, debug_override=False): for offer in config_data.get("offers", []): if offer.get("enabled", False): - if debug_enabled: - swap_client.log.info( - f"Found enabled offer '{offer.get('name')}', but no active offers in network" - ) break except Exception as e: if debug_enabled: @@ -669,10 +580,6 @@ def get_amm_active_count(swap_client, debug_override=False): and "offers" in state_data and len(state_data["offers"]) > 0 ): - if debug_enabled: - swap_client.log.info( - "AMM is running with offers in state file, but none are active. Setting count to 1." - ) amm_count = 1 except Exception as e: if debug_enabled: @@ -680,9 +587,6 @@ def get_amm_active_count(swap_client, debug_override=False): swap_client.log.error(traceback.format_exc()) return 0 - if debug_enabled: - swap_client.log.debug(f"Final AMM active count: {amm_count}") - return amm_count diff --git a/basicswap/ui/page_offers.py b/basicswap/ui/page_offers.py index b99ff95..6572d9c 100644 --- a/basicswap/ui/page_offers.py +++ b/basicswap/ui/page_offers.py @@ -43,10 +43,7 @@ from basicswap.chainparams import ( Coins, ticker_map, ) -from basicswap.explorers import ( - default_chart_api_key, - default_coingecko_api_key, -) +from basicswap.explorers import default_coingecko_api_key def value_or_none(v): @@ -541,23 +538,9 @@ def page_newoffer(self, url_split, post_string): automation_filters = {"type_ind": Concepts.OFFER, "sort_by": "label"} automation_strategies = swap_client.listAutomationStrategies(automation_filters) - chart_api_key = swap_client.settings.get("chart_api_key", "") - if chart_api_key == "": - chart_api_key_enc = swap_client.settings.get("chart_api_key_enc", "") - chart_api_key = ( - default_chart_api_key - if chart_api_key_enc == "" - else bytes.fromhex(chart_api_key_enc).decode("utf-8") - ) - - coingecko_api_key = swap_client.settings.get("coingecko_api_key", "") - if coingecko_api_key == "": - coingecko_api_key_enc = swap_client.settings.get("coingecko_api_key_enc", "") - coingecko_api_key = ( - default_coingecko_api_key - if coingecko_api_key_enc == "" - else bytes.fromhex(coingecko_api_key_enc).decode("utf-8") - ) + coingecko_api_key = get_api_key_setting( + swap_client.settings, "coingecko_api_key", default_coingecko_api_key + ) return self.render_template( template, @@ -575,7 +558,6 @@ def page_newoffer(self, url_split, post_string): (strSwapType(x), strSwapDesc(x)) for x in SwapTypes if strSwapType(x) ], "show_chart": swap_client.settings.get("show_chart", True), - "chart_api_key": chart_api_key, "coingecko_api_key": coingecko_api_key, }, ) @@ -990,9 +972,6 @@ def page_offers(self, url_split, post_string, sent=False): coins_from, coins_to = listAvailableCoins(swap_client, split_from=True) - chart_api_key = get_api_key_setting( - swap_client.settings, "chart_api_key", default_chart_api_key - ) coingecko_api_key = get_api_key_setting( swap_client.settings, "coingecko_api_key", default_coingecko_api_key ) @@ -1041,7 +1020,6 @@ def page_offers(self, url_split, post_string, sent=False): "show_chart": ( False if sent else swap_client.settings.get("show_chart", True) ), - "chart_api_key": chart_api_key, "coingecko_api_key": coingecko_api_key, "coins_from": coins_from, "coins": coins_to, diff --git a/basicswap/ui/page_settings.py b/basicswap/ui/page_settings.py index 221a9e9..d156379 100644 --- a/basicswap/ui/page_settings.py +++ b/basicswap/ui/page_settings.py @@ -49,9 +49,6 @@ def page_settings(self, url_split, post_string): active_tab = "general" data = { "show_chart": toBool(get_data_entry(form_data, "showchart")), - "chart_api_key": html.unescape( - get_data_entry_or(form_data, "chartapikey", "") - ), "coingecko_api_key": html.unescape( get_data_entry_or(form_data, "coingeckoapikey", "") ), @@ -213,16 +210,12 @@ def page_settings(self, url_split, post_string): "check_updates": swap_client.settings.get("check_updates", True), } - chart_api_key = get_api_key_setting( - swap_client.settings, "chart_api_key", escape=True - ) coingecko_api_key = get_api_key_setting( - swap_client.settings, "coingecko_api_key", escape=True + swap_client.settings, "coingecko_api_key", default_value="", escape=True ) chart_settings = { "show_chart": swap_client.settings.get("show_chart", True), - "chart_api_key": chart_api_key, "coingecko_api_key": coingecko_api_key, "enabled_chart_coins": swap_client.settings.get("enabled_chart_coins", ""), } diff --git a/scripts/createoffers.py b/scripts/createoffers.py index e94673c..da6cdc4 100755 --- a/scripts/createoffers.py +++ b/scripts/createoffers.py @@ -711,17 +711,25 @@ def process_offers(args, config, script_state) -> None: print(f"Wallet data: {wallet_from}") continue - for offer in sent_offers: - created_offers = script_state.get("offers", {}) - prev_template_offers = created_offers.get(offer_template["name"], {}) + created_offers = script_state.get("offers", {}) + prev_template_offers = created_offers.get(offer_template["name"], []) - if next( - (x for x in prev_template_offers if x["offer_id"] == offer["offer_id"]), - None, - ): + template_offer_ids = set() + for prev_offer in prev_template_offers: + if "offer_id" in prev_offer: + template_offer_ids.add(prev_offer["offer_id"]) + + matching_sent_offers = [] + for offer in sent_offers: + offer_id = offer.get("offer_id") + if not offer_id: + continue + + if offer_id in template_offer_ids: + matching_sent_offers.append(offer) offers_found += 1 + if wallet_balance <= float(offer_template["min_coin_from_amt"]): - offer_id = offer["offer_id"] print( "Revoking offer {}, wallet from balance below minimum".format( offer_id @@ -732,6 +740,57 @@ def process_offers(args, config, script_state) -> None: print("revokeoffer", result) else: print("Offer revoked successfully") + else: + coin_from_match = offer.get("coin_from") == coin_from_data["id"] + coin_to_match = offer.get("coin_to") == coin_to_data["id"] + + if coin_from_match and coin_to_match: + if args.debug: + print( + f"Found untracked offer {offer_id} matching template {offer_template['name']} coins" + ) + matching_sent_offers.append(offer) + offers_found += 1 + + if len(matching_sent_offers) > 1: + print( + f"WARNING: Found {len(matching_sent_offers)} active offers for template '{offer_template['name']}'" + ) + if args.debug: + print(f"Offer IDs: {[o.get('offer_id') for o in matching_sent_offers]}") + + matching_sent_offers.sort( + key=lambda x: x.get("created_at", 0), reverse=True + ) + newest_offer = matching_sent_offers[0] + + for old_offer in matching_sent_offers[1:]: + old_offer_id = old_offer.get("offer_id") + print(f"Revoking duplicate offer {old_offer_id}") + try: + result = read_json_api(f"revokeoffer/{old_offer_id}") + if args.debug: + print(f"Revoke result: {result}") + + for i, prev_offer in enumerate(prev_template_offers): + if prev_offer.get("offer_id") == old_offer_id: + del prev_template_offers[i] + break + except Exception as e: + print(f"Error revoking duplicate offer {old_offer_id}: {e}") + + offers_found = 1 + + if newest_offer.get("offer_id") not in template_offer_ids: + if "offers" not in script_state: + script_state["offers"] = {} + if offer_template["name"] not in script_state["offers"]: + script_state["offers"][offer_template["name"]] = [] + script_state["offers"][offer_template["name"]].append( + {"offer_id": newest_offer["offer_id"], "time": int(time.time())} + ) + write_state(args.statefile, script_state) + print(f"Added untracked offer {newest_offer['offer_id']} to state") if offers_found > 0: continue diff --git a/tests/basicswap/selenium/test_settings.py b/tests/basicswap/selenium/test_settings.py index 807dc91..99e7c57 100644 --- a/tests/basicswap/selenium/test_settings.py +++ b/tests/basicswap/selenium/test_settings.py @@ -18,7 +18,7 @@ from util import ( BSX_0_PORT, get_driver, ) -from basicswap.ui.page_offers import default_chart_api_key +from basicswap.explorers import default_coingecko_api_key def click_option(el, option_text): @@ -102,13 +102,13 @@ def test_settings(driver): settings = json.load(fs) assert settings["show_chart"] is expected_chart_state - chart_api_key = bytes.fromhex(settings.get("chart_api_key_enc", "")).decode( - "utf-8" - ) - assert chart_api_key == difficult_text + coingecko_api_key = bytes.fromhex( + settings.get("coingecko_api_key_enc", "") + ).decode("utf-8") + assert coingecko_api_key == difficult_text - hex_text = default_chart_api_key - el = driver.find_element(By.NAME, "chartapikey") + hex_text = default_coingecko_api_key + el = driver.find_element(By.NAME, "coingeckoapikey") el.clear() el.send_keys(hex_text) btn_apply_chart = wait.until( @@ -117,13 +117,13 @@ def test_settings(driver): btn_apply_chart.click() time.sleep(1) - el = driver.find_element(By.NAME, "chartapikey") + el = driver.find_element(By.NAME, "coingeckoapikey") assert el.get_property("value") == hex_text with open(settings_path_0) as fs: settings = json.load(fs) - assert settings.get("chart_api_key") == hex_text + assert settings.get("coingecko_api_key") == hex_text else: print("Chart settings not accessible, skipping chart tests") expected_chart_state = None diff --git a/tests/basicswap/selenium/test_swap_direction.py b/tests/basicswap/selenium/test_swap_direction.py index c2cd4f8..b2e8163 100644 --- a/tests/basicswap/selenium/test_swap_direction.py +++ b/tests/basicswap/selenium/test_swap_direction.py @@ -293,8 +293,14 @@ def test_swap_dir(driver): try: bid_rows = dict() table = driver.find_element(By.XPATH, "//tbody[@id='active-swaps-body']") - for row in table.find_elements(By.XPATH, ".//tr"): + rows = table.find_elements(By.XPATH, ".//tr") + if len(rows) == 0: + time.sleep(2) + continue + for row in rows: tds = row.find_elements(By.XPATH, ".//td") + if len(tds) < 6: + continue td_details = tds[2] td_send = tds[5] td_recv = tds[3] @@ -336,8 +342,14 @@ def test_swap_dir(driver): try: bid_rows = dict() table = driver.find_element(By.XPATH, "//tbody[@id='active-swaps-body']") - for row in table.find_elements(By.XPATH, ".//tr"): + rows = table.find_elements(By.XPATH, ".//tr") + if len(rows) == 0: + time.sleep(2) + continue + for row in rows: tds = row.find_elements(By.XPATH, ".//td") + if len(tds) < 6: + continue td_details = tds[2] td_send = tds[5] td_recv = tds[3]