Cache coin rates.

This commit is contained in:
tecnovert 2025-02-25 23:35:57 +02:00
parent 3cdab962d3
commit 5bedc6289f
11 changed files with 357 additions and 105 deletions

View file

@ -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"])),
)
)

View file

@ -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,

View file

@ -35,6 +35,12 @@ class Coins(IntEnum):
DOGE = 18
class Fiat(IntEnum):
USD = -1
GBP = -2
EUR = -3
chainparams = {
Coins.PART: {
"name": "particl",

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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) => {

View file

@ -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)

View file

@ -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),

View file

@ -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