diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index f79cbda..c927565 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -65,8 +65,13 @@ from basicswap.util.network import is_private_ip_address from .chainparams import ( Coins, chainparams, + Fiat, ticker_map, ) +from .explorers import ( + default_chart_api_key, + default_coingecko_api_key, +) from .script import ( OpCodes, ) @@ -127,6 +132,8 @@ from .basicswap_util import ( BidStates, DebugTypes, EventLogTypes, + fiatTicker, + get_api_key_setting, KeyTypes, MessageTypes, NotificationTypes as NT, @@ -11059,6 +11066,160 @@ class BasicSwap(BaseApp): ).isWalletEncryptedLocked() return self._is_encrypted, self._is_locked + def getExchangeName(self, coin_id: int, exchange_name: str) -> str: + if coin_id == Coins.BCH: + return "bitcoin-cash" + if coin_id == Coins.FIRO: + return "zcoin" + return chainparams[coin_id]["name"] + + def lookupFiatRates( + self, + coins_list, + currency_to: int = Fiat.USD, + rate_source: str = "coingecko.com", + saved_ttl: int = 300, + ): + self.log.debug(f"lookupFiatRates {coins_list}.") + ensure(len(coins_list) > 0, "Must specify coin/s") + + now: int = int(time.time()) + oldest_time_valid: int = now - saved_ttl + return_rates = {} + + headers = {"User-Agent": "Mozilla/5.0", "Connection": "close"} + + cursor = self.openDB() + try: + parameters = { + "rate_source": rate_source, + "oldest_time_valid": oldest_time_valid, + "currency_to": currency_to, + } + 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 currency_from, rate FROM coinrates WHERE currency_from IN ({coins_list_query}) AND currency_to = :currency_to AND source = :rate_source AND last_updated >= :oldest_time_valid" + rows = cursor.execute(query, parameters) + + for row in rows: + return_rates[int(row[0])] = float(row[1]) + + need_coins = [] + new_values = {} + exchange_name_map = {} + for coin_id in coins_list: + if coin_id not in return_rates: + need_coins.append(coin_id) + + if len(need_coins) < 1: + return return_rates + + 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 = 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 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() + for coin_id in need_coins: + coin_ticker: str = chainparams[coin_id]["ticker"] + + api_key: str = get_api_key_setting( + self.settings, + "chart_api_key", + default_chart_api_key, + escape=True, + ) + url: str = ( + f"https://min-api.cryptocompare.com/data/price?fsym={coin_ticker}&tsyms={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)) + return_rates[int(coin_id)] = js[ticker_to] + new_values[coin_id] = js[ticker_to] + else: + raise ValueError(f"Unknown rate source {rate_source}") + + if len(new_values) < 1: + return return_rates + + # 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(): + cursor.execute( + update_query, + { + "currency_from": k, + "currency_to": currency_to, + "rate": v, + "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 + finally: + self.closeDB(cursor, commit=False) + def lookupRates(self, coin_from, coin_to, output_array=False): self.log.debug( "lookupRates {}, {}.".format( @@ -11071,25 +11232,14 @@ class BasicSwap(BaseApp): ci_to = self.ci(int(coin_to)) name_from = ci_from.chainparams()["name"] name_to = ci_to.chainparams()["name"] - exchange_name_from = ci_from.getExchangeName("coingecko.com") - exchange_name_to = ci_to.getExchangeName("coingecko.com") ticker_from = ci_from.chainparams()["ticker"] ticker_to = ci_to.chainparams()["ticker"] - headers = {"User-Agent": "Mozilla/5.0", "Connection": "close"} rv = {} if rate_sources.get("coingecko.com", True): try: - url = "https://api.coingecko.com/api/v3/simple/price?ids={},{}&vs_currencies=usd,btc".format( - exchange_name_from, exchange_name_to - ) - self.log.debug(f"lookupRates: {url}") - start = time.time() - js = json.loads(self.readURL(url, timeout=10, headers=headers)) - js["time_taken"] = time.time() - start - rate = float(js[exchange_name_from]["usd"]) / float( - js[exchange_name_to]["usd"] - ) + 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) rv["coingecko"] = js except Exception as e: @@ -11097,12 +11247,10 @@ class BasicSwap(BaseApp): if self.debug: self.log.error(traceback.format_exc()) - if exchange_name_from != name_from: - js[name_from] = js[exchange_name_from] - js.pop(exchange_name_from) - if exchange_name_to != name_to: - js[name_to] = js[exchange_name_to] - js.pop(exchange_name_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 output_array: @@ -11121,8 +11269,6 @@ class BasicSwap(BaseApp): ticker_to, format_float(float(js[name_from]["usd"])), format_float(float(js[name_to]["usd"])), - format_float(float(js[name_from]["btc"])), - format_float(float(js[name_to]["btc"])), format_float(float(js["rate_inferred"])), ) ) diff --git a/basicswap/basicswap_util.py b/basicswap/basicswap_util.py index 7970b98..39403d0 100644 --- a/basicswap/basicswap_util.py +++ b/basicswap/basicswap_util.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021-2024 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -9,12 +9,14 @@ import struct import hashlib from enum import IntEnum, auto +from html import escape as html_escape from .util.address import ( encodeAddress, decodeAddress, ) from .chainparams import ( chainparams, + Fiat, ) @@ -520,7 +522,7 @@ def getLastBidState(packed_states): return BidStates.BID_STATE_UNKNOWN -def strSwapType(swap_type): +def strSwapType(swap_type) -> str: if swap_type == SwapTypes.SELLER_FIRST: return "seller_first" if swap_type == SwapTypes.XMR_SWAP: @@ -528,7 +530,7 @@ def strSwapType(swap_type): return None -def strSwapDesc(swap_type): +def strSwapDesc(swap_type) -> str: if swap_type == SwapTypes.SELLER_FIRST: return "Secret Hash" if swap_type == SwapTypes.XMR_SWAP: @@ -536,6 +538,31 @@ def strSwapDesc(swap_type): return None +def fiatTicker(fiat_ind: int) -> str: + try: + return Fiat(fiat_ind).name + except Exception as e: # noqa: F841 + raise ValueError(f"Unknown fiat ind {fiat_ind}") + + +def fiatFromTicker(ticker: str) -> int: + ticker_uc = ticker.upper() + for entry in Fiat: + if entry.name == ticker_uc: + return entry + raise ValueError(f"Unknown fiat {ticker}") + + +def get_api_key_setting( + settings, setting_name: str, default_value: str = "", escape: bool = False +): + setting_name_enc: str = setting_name + "_enc" + if setting_name_enc in settings: + rv = bytes.fromhex(settings[setting_name_enc]).decode("utf-8") + return html_escape(rv) if escape else rv + return settings.get(setting_name, default_value) + + inactive_states = [ BidStates.SWAP_COMPLETED, BidStates.BID_ERROR, diff --git a/basicswap/chainparams.py b/basicswap/chainparams.py index 188e7d5..7ed0935 100644 --- a/basicswap/chainparams.py +++ b/basicswap/chainparams.py @@ -35,6 +35,12 @@ class Coins(IntEnum): DOGE = 18 +class Fiat(IntEnum): + USD = -1 + GBP = -2 + EUR = -3 + + chainparams = { Coins.PART: { "name": "particl", diff --git a/basicswap/db.py b/basicswap/db.py index d947f8c..2f9df62 100644 --- a/basicswap/db.py +++ b/basicswap/db.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019-2024 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -13,7 +13,7 @@ from enum import IntEnum, auto from typing import Optional -CURRENT_DB_VERSION = 25 +CURRENT_DB_VERSION = 26 CURRENT_DB_DATA_VERSION = 5 @@ -644,6 +644,17 @@ class CheckedBlock(Table): block_time = Column("integer") +class CoinRates(Table): + __tablename__ = "coinrates" + + record_id = Column("integer", primary_key=True, autoincrement=True) + currency_from = Column("integer") + currency_to = Column("integer") + rate = Column("string") + source = Column("string") + last_updated = Column("integer") + + def create_db(db_path: str, log) -> None: con = None try: diff --git a/basicswap/db_upgrades.py b/basicswap/db_upgrades.py index 5f136c0..642dfc5 100644 --- a/basicswap/db_upgrades.py +++ b/basicswap/db_upgrades.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2022-2024 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -410,6 +410,19 @@ def upgradeDatabase(self, db_version): elif current_version == 24: db_version += 1 cursor.execute("ALTER TABLE bidstates ADD COLUMN can_accept INTEGER") + elif current_version == 25: + db_version += 1 + cursor.execute( + """ + CREATE TABLE coinrates ( + record_id INTEGER NOT NULL, + currency_from INTEGER, + currency_to INTEGER, + amount VARCHAR, + source VARCHAR, + last_updated INTEGER, + PRIMARY KEY (record_id))""" + ) if current_version != db_version: self.db_version = db_version self.setIntKV("db_version", db_version, cursor) diff --git a/basicswap/explorers.py b/basicswap/explorers.py index 4cf2233..8d10eeb 100644 --- a/basicswap/explorers.py +++ b/basicswap/explorers.py @@ -1,12 +1,19 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019-2023 tecnovert +# Copyright (c) 2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. import json +default_chart_api_key = ( + "95dd900af910656e0e17c41f2ddc5dba77d01bf8b0e7d2787634a16bd976c553" +) +default_coingecko_api_key = "CG-8hm3r9iLfpEXv4ied8oLbeUj" + + class Explorer: def __init__(self, swapclient, coin_type, base_url): self.swapclient = swapclient diff --git a/basicswap/js_server.py b/basicswap/js_server.py index ec3f8ee..5d1dc52 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -14,6 +14,7 @@ from .util import ( toBool, ) from .basicswap_util import ( + fiatFromTicker, strBidState, strTxState, SwapTypes, @@ -22,7 +23,9 @@ from .basicswap_util import ( from .chainparams import ( Coins, chainparams, + Fiat, getCoinIdFromTicker, + getCoinIdFromName, ) from .ui.util import ( PAGE_LIMIT, @@ -951,7 +954,7 @@ def js_404(self, url_split, post_string, is_json) -> bytes: def js_help(self, url_split, post_string, is_json) -> bytes: # TODO: Add details and examples commands = [] - for k in pages: + for k in endpoints: commands.append(k) return bytes(json.dumps({"commands": commands}), "UTF-8") @@ -959,22 +962,22 @@ def js_help(self, url_split, post_string, is_json) -> bytes: def js_readurl(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 have_data_entry(post_data, "url"): - url = get_data_entry(post_data, "url") - default_headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.5", - } - response = swap_client.readURL(url, headers=default_headers) - try: - error = json.loads(response.decode()) - if "Error" in error: - return json.dumps({"Error": error["Error"]}).encode() - except json.JSONDecodeError: - pass - return response - raise ValueError("Requires URL.") + if not have_data_entry(post_data, "url"): + raise ValueError("Requires URL.") + url = get_data_entry(post_data, "url") + default_headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + } + response = swap_client.readURL(url, headers=default_headers) + try: + error = json.loads(response.decode()) + if "Error" in error: + return json.dumps({"Error": error["Error"]}).encode() + except json.JSONDecodeError: + pass + return response def js_active(self, url_split, post_string, is_json) -> bytes: @@ -1035,7 +1038,62 @@ def js_active(self, url_split, post_string, is_json) -> bytes: return bytes(json.dumps(all_bids), "UTF-8") -pages = { +def js_coinprices(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.") + + currency_to = Fiat.USD + if have_data_entry(post_data, "currency_to"): + currency_to = fiatFromTicker(get_data_entry(post_data, "currency_to")) + + 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") + ) + + 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 + + coinprices = swap_client.lookupFiatRates( + coin_ids, currency_to=currency_to, rate_source=rate_source + ) + + rv = {} + for k, v in coinprices.items(): + if match_input_key: + rv[input_id_map[k]] = v + else: + rv[int(k)] = v + return bytes( + json.dumps({"currency": currency_to.name, "source": rate_source, "rates": rv}), + "UTF-8", + ) + + +endpoints = { "coins": js_coins, "wallets": js_wallets, "offers": js_offers, @@ -1061,10 +1119,11 @@ pages = { "help": js_help, "readurl": js_readurl, "active": js_active, + "coinprices": js_coinprices, } def js_url_to_function(url_split): if len(url_split) > 2: - return pages.get(url_split[2], js_404) + return endpoints.get(url_split[2], js_404) return js_index diff --git a/basicswap/templates/wallet.html b/basicswap/templates/wallet.html index 8eb4143..8d4d583 100644 --- a/basicswap/templates/wallet.html +++ b/basicswap/templates/wallet.html @@ -877,29 +877,30 @@ const coinNameToSymbol = { }; const getUsdValue = (cryptoValue, coinSymbol) => { + let source = "cryptocompare.com"; + let coin_id = coinSymbol; if (coinSymbol === 'WOW') { - return fetch(`https://api.coingecko.com/api/v3/simple/price?ids=wownero&vs_currencies=usd`) - .then(response => response.json()) - .then(data => { - const exchangeRate = data.wownero.usd; - if (!isNaN(exchangeRate)) { - return cryptoValue * exchangeRate; - } else { - throw new Error(`Invalid exchange rate for ${coinSymbol}`); - } - }); - } else { - return fetch(`https://min-api.cryptocompare.com/data/price?fsym=${coinSymbol}&tsyms=USD`) - .then(response => response.json()) - .then(data => { - const exchangeRate = data.USD; - if (!isNaN(exchangeRate)) { - return cryptoValue * exchangeRate; - } else { - throw new Error(`Invalid exchange rate for ${coinSymbol}`); - } - }); + source = "coingecko.com" + coin_id = "wownero" } + + return fetch("/json/coinprices", { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + coins: coin_id, + source: source + }) + }) + .then(response => response.json()) + .then(data => { + const exchangeRate = data.rates[coin_id]; + if (!isNaN(exchangeRate)) { + return cryptoValue * exchangeRate; + } else { + throw new Error(`Invalid exchange rate for ${coinSymbol}`); + } + }); }; const updateUsdValue = async (cryptoCell, coinFullName, usdValueSpan) => { diff --git a/basicswap/ui/page_offers.py b/basicswap/ui/page_offers.py index dd6b16c..859e7f6 100644 --- a/basicswap/ui/page_offers.py +++ b/basicswap/ui/page_offers.py @@ -31,6 +31,7 @@ from basicswap.basicswap_util import ( SwapTypes, DebugTypes, getLockName, + get_api_key_setting, strBidState, strSwapDesc, strSwapType, @@ -41,11 +42,10 @@ from basicswap.chainparams import ( Coins, ticker_map, ) - -default_chart_api_key = ( - "95dd900af910656e0e17c41f2ddc5dba77d01bf8b0e7d2787634a16bd976c553" +from basicswap.explorers import ( + default_chart_api_key, + default_coingecko_api_key, ) -default_coingecko_api_key = "CG-8hm3r9iLfpEXv4ied8oLbeUj" def value_or_none(v): @@ -973,23 +973,12 @@ def page_offers(self, url_split, post_string, sent=False): coins_from, coins_to = listAvailableCoins(swap_client, split_from=True) - 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") - ) + 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 + ) offers_count = len(formatted_offers) diff --git a/basicswap/ui/page_settings.py b/basicswap/ui/page_settings.py index b8f5f74..de49e75 100644 --- a/basicswap/ui/page_settings.py +++ b/basicswap/ui/page_settings.py @@ -16,6 +16,9 @@ from basicswap.util import ( toBool, InactiveCoin, ) +from basicswap.basicswap_util import ( + get_api_key_setting, +) from basicswap.chainparams import ( Coins, ) @@ -168,23 +171,13 @@ def page_settings(self, url_split, post_string): "debug_ui": swap_client.debug_ui, "expire_db_records": swap_client._expire_db_records, } - if "chart_api_key_enc" in swap_client.settings: - chart_api_key = html.escape( - bytes.fromhex(swap_client.settings.get("chart_api_key_enc", "")).decode( - "utf-8" - ) - ) - else: - chart_api_key = swap_client.settings.get("chart_api_key", "") - if "coingecko_api_key_enc" in swap_client.settings: - coingecko_api_key = html.escape( - bytes.fromhex(swap_client.settings.get("coingecko_api_key_enc", "")).decode( - "utf-8" - ) - ) - else: - coingecko_api_key = swap_client.settings.get("coingecko_api_key", "") + 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 + ) chart_settings = { "show_chart": swap_client.settings.get("show_chart", True), diff --git a/tests/basicswap/extended/test_xmr_persistent.py b/tests/basicswap/extended/test_xmr_persistent.py index b99cb9d..60eb064 100644 --- a/tests/basicswap/extended/test_xmr_persistent.py +++ b/tests/basicswap/extended/test_xmr_persistent.py @@ -17,7 +17,7 @@ python tests/basicswap/extended/test_xmr_persistent.py # Copy coin releases to permanent storage for faster subsequent startups -cp -r ${TEST_PATH}/bin/ ~/tmp/basicswap_bin/ +cp -r ${TEST_PATH}/bin/* ~/tmp/basicswap_bin/ # Continue existing chains with