Litewallets

This commit is contained in:
gerlofvanek
2026-01-28 16:05:52 +01:00
parent a04ce28ca2
commit afae62ae38
37 changed files with 10525 additions and 272 deletions

View File

@@ -5,10 +5,12 @@
# Distributed under the MIT software license, see the accompanying # Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php. # file LICENSE or http://www.opensource.org/licenses/mit-license.php.
import json
import logging import logging
import os import os
import random import random
import shlex import shlex
import shutil
import socket import socket
import socks import socks
import subprocess import subprocess
@@ -55,7 +57,7 @@ class BaseApp(DBMethods):
self.settings = settings self.settings = settings
self.coin_clients = {} self.coin_clients = {}
self.coin_interfaces = {} self.coin_interfaces = {}
self.mxDB = threading.Lock() self.mxDB = threading.RLock()
self.debug = self.settings.get("debug", False) self.debug = self.settings.get("debug", False)
self.delay_event = threading.Event() self.delay_event = threading.Event()
self.chainstate_delay_event = threading.Event() self.chainstate_delay_event = threading.Event()
@@ -156,6 +158,71 @@ class BaseApp(DBMethods):
except Exception: except Exception:
return {} return {}
def getElectrumAddressIndex(self, coin_name: str) -> tuple:
try:
chain_settings = self.settings["chainclients"].get(coin_name, {})
ext_idx = chain_settings.get("electrum_address_index", 0)
int_idx = chain_settings.get("electrum_internal_address_index", 0)
return (ext_idx, int_idx)
except Exception:
return (0, 0)
def updateElectrumAddressIndex(
self, coin_name: str, ext_idx: int, int_idx: int
) -> None:
try:
if coin_name not in self.settings["chainclients"]:
self.log.debug(
f"updateElectrumAddressIndex: {coin_name} not in chainclients"
)
return
chain_settings = self.settings["chainclients"][coin_name]
current_ext = chain_settings.get("electrum_address_index", 0)
current_int = chain_settings.get("electrum_internal_address_index", 0)
if ext_idx <= current_ext and int_idx <= current_int:
return
if ext_idx > current_ext:
chain_settings["electrum_address_index"] = ext_idx
if int_idx > current_int:
chain_settings["electrum_internal_address_index"] = int_idx
self.log.debug(
f"Persisting electrum address index for {coin_name}: ext={ext_idx}, int={int_idx}"
)
self._saveSettings()
except Exception as e:
self.log.warning(
f"Failed to update electrum address index for {coin_name}: {e}"
)
def _normalizeSettingsPaths(self, settings: dict) -> dict:
if "chainclients" in settings:
for coin_name, cc in settings["chainclients"].items():
for path_key in ("datadir", "bindir", "walletsdir"):
if path_key in cc and isinstance(cc[path_key], str):
cc[path_key] = os.path.normpath(cc[path_key])
return settings
def _saveSettings(self) -> None:
from basicswap import config as cfg
self._normalizeSettingsPaths(self.settings)
settings_path = os.path.join(self.data_dir, cfg.CONFIG_FILENAME)
settings_path_new = settings_path + ".new"
try:
if os.path.exists(settings_path):
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(f"Settings saved to {settings_path}")
except Exception as e:
self.log.warning(f"Failed to save settings: {e}")
def setDaemonPID(self, name, pid) -> None: def setDaemonPID(self, name, pid) -> None:
if isinstance(name, Coins): if isinstance(name, Coins):
self.coin_clients[name]["pid"] = pid self.coin_clients[name]["pid"] = pid

File diff suppressed because it is too large Load Diff

View File

@@ -212,6 +212,10 @@ class EventLogTypes(IntEnum):
BCH_MERCY_TX_FOUND = auto() BCH_MERCY_TX_FOUND = auto()
LOCK_TX_A_IN_MEMPOOL = auto() LOCK_TX_A_IN_MEMPOOL = auto()
LOCK_TX_A_CONFLICTS = auto() LOCK_TX_A_CONFLICTS = auto()
LOCK_TX_B_RPC_ERROR = auto()
LOCK_TX_A_SPEND_TX_SEEN = auto()
LOCK_TX_B_SPEND_TX_SEEN = auto()
LOCK_TX_B_REFUND_TX_SEEN = auto()
class XmrSplitMsgTypes(IntEnum): class XmrSplitMsgTypes(IntEnum):
@@ -247,6 +251,7 @@ class NotificationTypes(IntEnum):
BID_ACCEPTED = auto() BID_ACCEPTED = auto()
SWAP_COMPLETED = auto() SWAP_COMPLETED = auto()
UPDATE_AVAILABLE = auto() UPDATE_AVAILABLE = auto()
SWEEP_COMPLETED = auto()
class ConnectionRequestTypes(IntEnum): class ConnectionRequestTypes(IntEnum):
@@ -458,6 +463,8 @@ def describeEventEntry(event_type, event_msg):
return "Failed to publish lock tx B refund" return "Failed to publish lock tx B refund"
if event_type == EventLogTypes.LOCK_TX_B_INVALID: if event_type == EventLogTypes.LOCK_TX_B_INVALID:
return "Detected invalid lock Tx B" return "Detected invalid lock Tx B"
if event_type == EventLogTypes.LOCK_TX_B_RPC_ERROR:
return "Temporary RPC error checking lock tx B: " + event_msg
if event_type == EventLogTypes.LOCK_TX_A_REFUND_TX_PUBLISHED: if event_type == EventLogTypes.LOCK_TX_A_REFUND_TX_PUBLISHED:
return "Lock tx A pre-refund tx published" return "Lock tx A pre-refund tx published"
if event_type == EventLogTypes.LOCK_TX_A_REFUND_SPEND_TX_PUBLISHED: if event_type == EventLogTypes.LOCK_TX_A_REFUND_SPEND_TX_PUBLISHED:
@@ -498,6 +505,12 @@ def describeEventEntry(event_type, event_msg):
return "BCH mercy tx found" return "BCH mercy tx found"
if event_type == EventLogTypes.BCH_MERCY_TX_PUBLISHED: if event_type == EventLogTypes.BCH_MERCY_TX_PUBLISHED:
return "Lock tx B mercy tx published" return "Lock tx B mercy tx published"
if event_type == EventLogTypes.LOCK_TX_A_SPEND_TX_SEEN:
return "Lock tx A spend tx seen in chain"
if event_type == EventLogTypes.LOCK_TX_B_SPEND_TX_SEEN:
return "Lock tx B spend tx seen in chain"
if event_type == EventLogTypes.LOCK_TX_B_REFUND_TX_SEEN:
return "Lock tx B refund tx seen in chain"
def getVoutByAddress(txjs, p2sh): def getVoutByAddress(txjs, p2sh):

View File

@@ -1748,6 +1748,13 @@ def printHelp():
) )
print("--client-auth-password= Set or update the password to protect the web UI.") print("--client-auth-password= Set or update the password to protect the web UI.")
print("--disable-client-auth Remove password protection from the web UI.") print("--disable-client-auth Remove password protection from the web UI.")
print(
"--light Use light wallet mode (Electrum) for all supported coins."
)
print("--btc-mode=MODE Set BTC connection mode: rpc, electrum, or remote.")
print("--ltc-mode=MODE Set LTC connection mode: rpc, electrum, or remote.")
print("--btc-electrum-server= Custom Electrum server for BTC (host:port:ssl).")
print("--ltc-electrum-server= Custom Electrum server for LTC (host:port:ssl).")
active_coins = [] active_coins = []
for coin_name in known_coins.keys(): for coin_name in known_coins.keys():
@@ -1955,6 +1962,11 @@ def initialise_wallets(
hex_seed = swap_client.getWalletKey(Coins.DCR, 1).hex() hex_seed = swap_client.getWalletKey(Coins.DCR, 1).hex()
createDCRWallet(args, hex_seed, logger, threading.Event()) createDCRWallet(args, hex_seed, logger, threading.Event())
continue continue
if coin_settings.get("connection_type") == "electrum":
logger.info(
f"Skipping RPC wallet creation for {getCoinName(c)} (electrum mode)."
)
continue
swap_client.waitForDaemonRPC(c, with_wallet=False) swap_client.waitForDaemonRPC(c, with_wallet=False)
# Create wallet if it doesn't exist yet # Create wallet if it doesn't exist yet
wallets = swap_client.callcoinrpc(c, "listwallets") wallets = swap_client.callcoinrpc(c, "listwallets")
@@ -2052,7 +2064,11 @@ def initialise_wallets(
c = swap_client.getCoinIdFromName(coin_name) c = swap_client.getCoinIdFromName(coin_name)
if c in (Coins.PART,): if c in (Coins.PART,):
continue continue
if c not in (Coins.DCR,): if coin_settings.get("connection_type") == "electrum":
logger.info(
f"Skipping daemon RPC wait for {getCoinName(c)} (electrum mode)."
)
elif c not in (Coins.DCR,):
# initialiseWallet only sets main_wallet_seedid_ # initialiseWallet only sets main_wallet_seedid_
swap_client.waitForDaemonRPC(c) swap_client.waitForDaemonRPC(c)
try: try:
@@ -2279,6 +2295,9 @@ def main():
tor_control_password = None tor_control_password = None
client_auth_pwd_value = None client_auth_pwd_value = None
disable_client_auth_flag = False disable_client_auth_flag = False
light_mode = False
coin_modes = {}
electrum_servers = {}
extra_opts = {} extra_opts = {}
if os.getenv("SSL_CERT_DIR", "") == "" and GUIX_SSL_CERT_DIR is not None: if os.getenv("SSL_CERT_DIR", "") == "" and GUIX_SSL_CERT_DIR is not None:
@@ -2433,6 +2452,32 @@ def main():
if name == "disable-client-auth": if name == "disable-client-auth":
disable_client_auth_flag = True disable_client_auth_flag = True
continue continue
if name == "light":
light_mode = True
continue
if name.endswith("-mode") and len(s) == 2:
coin_prefix = name[:-5]
mode_value = s[1].strip().lower()
if mode_value not in ("rpc", "electrum", "remote"):
exitWithError(
f"Invalid mode '{mode_value}' for {coin_prefix}. Use: rpc, electrum, or remote"
)
coin_modes[coin_prefix] = mode_value
continue
if name.endswith("-electrum-server") and len(s) == 2:
coin_prefix = name[:-16]
server_str = s[1].strip()
parts = server_str.split(":")
if len(parts) >= 2:
server = {
"host": parts[0],
"port": int(parts[1]),
"ssl": parts[2].lower() == "true" if len(parts) > 2 else True,
}
if coin_prefix not in electrum_servers:
electrum_servers[coin_prefix] = []
electrum_servers[coin_prefix].append(server)
continue
if len(s) != 2: if len(s) != 2:
exitWithError("Unknown argument {}".format(v)) exitWithError("Unknown argument {}".format(v))
exitWithError("Unknown argument {}".format(v)) exitWithError("Unknown argument {}".format(v))
@@ -2791,6 +2836,32 @@ def main():
}, },
} }
electrum_supported_coins = {
"bitcoin": "btc",
"litecoin": "ltc",
}
for coin_name, coin_prefix in electrum_supported_coins.items():
if coin_name not in chainclients:
continue
use_electrum = False
if light_mode and coin_name != "particl":
use_electrum = True
if coin_prefix in coin_modes:
if coin_modes[coin_prefix] == "electrum":
use_electrum = True
elif coin_modes[coin_prefix] == "rpc":
use_electrum = False
if use_electrum:
chainclients[coin_name]["connection_type"] = "electrum"
chainclients[coin_name]["manage_daemon"] = False
if coin_prefix in electrum_servers:
chainclients[coin_name]["electrum_servers"] = electrum_servers[
coin_prefix
]
for coin_name, coin_settings in chainclients.items(): for coin_name, coin_settings in chainclients.items():
coin_id = getCoinIdFromName(coin_name) coin_id = getCoinIdFromName(coin_name)
coin_params = chainparams[coin_id] coin_params = chainparams[coin_id]

View File

@@ -303,21 +303,24 @@ def getCoreBinArgs(coin_id: int, coin_settings, prepare=False, use_tor_proxy=Fal
if prepare is False and use_tor_proxy: if prepare is False and use_tor_proxy:
if coin_id == Coins.BCH: if coin_id == Coins.BCH:
# Without this BCH (27.1) will bind to the default BTC port, even with proxy set # Without this BCH (27.1) will bind to the default BTC port, even with proxy set
extra_args.append("--bind=127.0.0.1:" + str(int(coin_settings["port"]))) port: int = int(coin_settings["port"])
onionport: int = coin_settings.get("onionport", 8335)
extra_args.append(f"--bind=127.0.0.1:{port}")
extra_args.append(f"--bind=127.0.0.1:{onionport}=onion")
else: else:
extra_args.append("--port=" + str(int(coin_settings["port"]))) extra_args.append("--port=" + str(int(coin_settings["port"])))
# BTC versions from v28 fail to start if the onionport is in use. # BTC versions from v28 fail to start if the onionport is in use.
# As BCH may use port 8334, disable it here. # As BCH may use port 8334, disable it here.
# When tor is enabled a bind option for the onionport will be added to bitcoin.conf. # When tor is enabled a bind option for the onionport will be added to bitcoin.conf.
# https://github.com/bitcoin/bitcoin/blob/master/doc/release-notes/release-notes-28.0.md?plain=1#L84 # https://github.com/bitcoin/bitcoin/blob/master/doc/release-notes/release-notes-28.0.md?plain=1#L84
if ( if prepare is False and coin_id in (Coins.BTC, Coins.NMC):
prepare is False
and use_tor_proxy is False
and coin_id in (Coins.BTC, Coins.NMC)
):
port: int = coin_settings.get("port", 8333) port: int = coin_settings.get("port", 8333)
extra_args.append(f"--bind=0.0.0.0:{port}") if use_tor_proxy:
onionport: int = coin_settings.get("onionport", 8334)
extra_args.append(f"--bind=0.0.0.0:{port}")
extra_args.append(f"--bind=127.0.0.1:{onionport}=onion")
else:
extra_args.append(f"--bind=0.0.0.0:{port}")
return extra_args return extra_args

View File

@@ -13,7 +13,7 @@ from enum import IntEnum, auto
from typing import Optional from typing import Optional
CURRENT_DB_VERSION = 33 CURRENT_DB_VERSION = 34
CURRENT_DB_DATA_VERSION = 7 CURRENT_DB_DATA_VERSION = 7
@@ -772,8 +772,13 @@ class NetworkPortal(Table):
created_at = Column("integer") created_at = Column("integer")
def extract_schema(input_globals=None) -> dict: def extract_schema(extra_tables: list = None) -> dict:
g = globals() if input_globals is None else input_globals g = globals().copy()
if extra_tables:
for table_class in extra_tables:
g[table_class.__name__] = table_class
tables = {} tables = {}
for name, obj in g.items(): for name, obj in g.items():
if not inspect.isclass(obj): if not inspect.isclass(obj):
@@ -893,18 +898,18 @@ def create_table(c, table_name, table) -> None:
c.execute(query) c.execute(query)
def create_db_(con, log) -> None: def create_db_(con, log, extra_tables: list = None) -> None:
db_schema = extract_schema() db_schema = extract_schema(extra_tables=extra_tables)
c = con.cursor() c = con.cursor()
for table_name, table in db_schema.items(): for table_name, table in db_schema.items():
create_table(c, table_name, table) create_table(c, table_name, table)
def create_db(db_path: str, log) -> None: def create_db(db_path: str, log, extra_tables: list = None) -> None:
con = None con = None
try: try:
con = sqlite3.connect(db_path) con = sqlite3.connect(db_path)
create_db_(con, log) create_db_(con, log, extra_tables=extra_tables)
con.commit() con.commit()
finally: finally:
if con: if con:
@@ -912,42 +917,63 @@ def create_db(db_path: str, log) -> None:
class DBMethods: class DBMethods:
_db_lock_depth = 0
def _db_lock_held(self) -> bool:
if hasattr(self.mxDB, "_is_owned"):
return self.mxDB._is_owned()
return self.mxDB.locked()
def openDB(self, cursor=None): def openDB(self, cursor=None):
if cursor: if cursor:
# assert(self._thread_debug == threading.get_ident()) assert self._db_lock_held()
assert self.mxDB.locked()
return cursor return cursor
if self._db_lock_held():
self._db_lock_depth += 1
return self._db_con.cursor()
self.mxDB.acquire() self.mxDB.acquire()
# self._thread_debug = threading.get_ident() self._db_lock_depth = 1
self._db_con = sqlite3.connect(self.sqlite_file) self._db_con = sqlite3.connect(self.sqlite_file)
self._db_con.execute("PRAGMA busy_timeout = 30000")
return self._db_con.cursor() return self._db_con.cursor()
def getNewDBCursor(self): def getNewDBCursor(self):
assert self.mxDB.locked() assert self._db_lock_held()
return self._db_con.cursor() return self._db_con.cursor()
def commitDB(self): def commitDB(self):
assert self.mxDB.locked() assert self._db_lock_held()
self._db_con.commit() self._db_con.commit()
def rollbackDB(self): def rollbackDB(self):
assert self.mxDB.locked() assert self._db_lock_held()
self._db_con.rollback() self._db_con.rollback()
def closeDBCursor(self, cursor): def closeDBCursor(self, cursor):
assert self.mxDB.locked() assert self._db_lock_held()
if cursor: if cursor:
cursor.close() cursor.close()
def closeDB(self, cursor, commit=True): def closeDB(self, cursor, commit=True):
assert self.mxDB.locked() assert self._db_lock_held()
if self._db_lock_depth > 1:
if commit:
self._db_con.commit()
cursor.close()
self._db_lock_depth -= 1
return
if commit: if commit:
self._db_con.commit() self._db_con.commit()
cursor.close() cursor.close()
self._db_con.close() self._db_con.close()
self._db_lock_depth = 0
self.mxDB.release() self.mxDB.release()
def setIntKV(self, str_key: str, int_val: int, cursor=None) -> None: def setIntKV(self, str_key: str, int_val: int, cursor=None) -> None:
@@ -1037,7 +1063,7 @@ class DBMethods:
) )
finally: finally:
if cursor is None: if cursor is None:
self.closeDB(use_cursor, commit=False) self.closeDB(use_cursor, commit=True)
def add(self, obj, cursor, upsert: bool = False, columns_list=None): def add(self, obj, cursor, upsert: bool = False, columns_list=None):
if cursor is None: if cursor is None:
@@ -1201,6 +1227,9 @@ class DBMethods:
query: str = f"UPDATE {table_name} SET " query: str = f"UPDATE {table_name} SET "
values = {} values = {}
constraint_values = {}
set_columns = []
for mc in inspect.getmembers(obj.__class__): for mc in inspect.getmembers(obj.__class__):
mc_name, mc_obj = mc mc_name, mc_obj = mc
if not hasattr(mc_obj, "__sqlite3_column__"): if not hasattr(mc_obj, "__sqlite3_column__"):
@@ -1212,18 +1241,19 @@ class DBMethods:
continue continue
if mc_name in constraints: if mc_name in constraints:
values[mc_name] = m_obj constraint_values[mc_name] = m_obj
continue continue
if columns_list is not None and mc_name not in columns_list: if columns_list is not None and mc_name not in columns_list:
continue continue
if len(values) > 0:
query += ", " set_columns.append(f"{mc_name} = :{mc_name}")
query += f"{mc_name} = :{mc_name}"
values[mc_name] = m_obj values[mc_name] = m_obj
query += ", ".join(set_columns)
query += " WHERE 1=1 " query += " WHERE 1=1 "
for ck in constraints: for ck in constraints:
query += f" AND {ck} = :{ck} " query += f" AND {ck} = :{ck} "
values.update(constraint_values)
cursor.execute(query, values) cursor.execute(query, values)

View File

@@ -18,6 +18,15 @@ from .db import (
extract_schema, extract_schema,
) )
from .db_wallet import (
WalletAddress,
WalletLockedUTXO,
WalletPendingTx,
WalletState,
WalletTxCache,
WalletWatchOnly,
)
from .basicswap_util import ( from .basicswap_util import (
BidStates, BidStates,
canAcceptBidState, canAcceptBidState,
@@ -260,7 +269,16 @@ def upgradeDatabase(self, db_version: int):
), ),
] ]
expect_schema = extract_schema() wallet_tables = [
WalletAddress,
WalletLockedUTXO,
WalletPendingTx,
WalletState,
WalletTxCache,
WalletWatchOnly,
]
expect_schema = extract_schema(extra_tables=wallet_tables)
have_tables = {}
try: try:
cursor = self.openDB() cursor = self.openDB()
for rename_column in rename_columns: for rename_column in rename_columns:
@@ -269,7 +287,93 @@ def upgradeDatabase(self, db_version: int):
cursor.execute( cursor.execute(
f"ALTER TABLE {table_name} RENAME COLUMN {colname_from} TO {colname_to}" f"ALTER TABLE {table_name} RENAME COLUMN {colname_from} TO {colname_to}"
) )
upgradeDatabaseFromSchema(self, cursor, expect_schema)
query = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"
tables = cursor.execute(query).fetchall()
for table in tables:
table_name = table[0]
if table_name in ("sqlite_sequence",):
continue
have_table = {}
have_columns = {}
query = "SELECT * FROM PRAGMA_TABLE_INFO(:table_name) ORDER BY cid DESC;"
columns = cursor.execute(query, {"table_name": table_name}).fetchall()
for column in columns:
cid, name, data_type, notnull, default_value, primary_key = column
have_columns[name] = {"type": data_type, "primary_key": primary_key}
have_table["columns"] = have_columns
cursor.execute(f"PRAGMA INDEX_LIST('{table_name}');")
indices = cursor.fetchall()
for index in indices:
seq, index_name, unique, origin, partial = index
if origin == "pk":
continue
cursor.execute(f"PRAGMA INDEX_INFO('{index_name}');")
index_info = cursor.fetchall()
add_index = {"index_name": index_name}
for index_columns in index_info:
seqno, cid, name = index_columns
if origin == "u":
have_columns[name]["unique"] = 1
else:
if "column_1" not in add_index:
add_index["column_1"] = name
elif "column_2" not in add_index:
add_index["column_2"] = name
elif "column_3" not in add_index:
add_index["column_3"] = name
else:
raise RuntimeError("Add more index columns.")
if origin == "c":
if "indices" not in have_table:
have_table["indices"] = []
have_table["indices"].append(add_index)
have_tables[table_name] = have_table
for table_name, table in expect_schema.items():
if table_name not in have_tables:
self.log.info(f"Creating table {table_name}.")
create_table(cursor, table_name, table)
continue
have_table = have_tables[table_name]
have_columns = have_table["columns"]
for colname, column in table["columns"].items():
if colname not in have_columns:
col_type = column["type"]
self.log.info(f"Adding column {colname} to table {table_name}.")
cursor.execute(
f"ALTER TABLE {table_name} ADD COLUMN {colname} {col_type}"
)
indices = table.get("indices", [])
have_indices = have_table.get("indices", [])
for index in indices:
index_name = index["index_name"]
if not any(
have_idx.get("index_name") == index_name
for have_idx in have_indices
):
self.log.info(f"Adding index {index_name} to table {table_name}.")
column_1 = index["column_1"]
column_2 = index.get("column_2", None)
column_3 = index.get("column_3", None)
query: str = (
f"CREATE INDEX {index_name} ON {table_name} ({column_1}"
)
if column_2:
query += f", {column_2}"
if column_3:
query += f", {column_3}"
query += ")"
cursor.execute(query)
if CURRENT_DB_VERSION != db_version: if CURRENT_DB_VERSION != db_version:
self.db_version = CURRENT_DB_VERSION self.db_version = CURRENT_DB_VERSION
self.setIntKV("db_version", CURRENT_DB_VERSION, cursor) self.setIntKV("db_version", CURRENT_DB_VERSION, cursor)

122
basicswap/db_wallet.py Normal file
View File

@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2024-2026 The Basicswap developers
# Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
from .db import Column, Index, Table, UniqueConstraint
class WalletAddress(Table):
__tablename__ = "wallet_addresses"
record_id = Column("integer", primary_key=True, autoincrement=True)
coin_type = Column("integer")
derivation_index = Column("integer")
is_internal = Column("bool")
derivation_path = Column("string")
address = Column("string")
scripthash = Column("string")
pubkey = Column("blob")
is_funded = Column("bool")
cached_balance = Column("integer")
cached_balance_time = Column("integer")
first_seen_height = Column("integer")
created_at = Column("integer")
__unique_1__ = UniqueConstraint("coin_type", "derivation_index", "is_internal")
__index_address__ = Index("idx_wallet_address", "address")
__index_scripthash__ = Index("idx_wallet_scripthash", "scripthash")
__index_funded__ = Index("idx_wallet_funded", "coin_type", "is_funded")
class WalletState(Table):
__tablename__ = "wallet_state"
coin_type = Column("integer", primary_key=True)
last_external_index = Column("integer")
last_internal_index = Column("integer")
derivation_path_type = Column("string")
last_sync_height = Column("integer")
migration_complete = Column("bool")
created_at = Column("integer")
updated_at = Column("integer")
class WalletWatchOnly(Table):
__tablename__ = "wallet_watch_only"
record_id = Column("integer", primary_key=True, autoincrement=True)
coin_type = Column("integer")
address = Column("string")
scripthash = Column("string")
label = Column("string")
source = Column("string")
is_funded = Column("bool")
cached_balance = Column("integer")
cached_balance_time = Column("integer")
private_key_encrypted = Column("blob")
created_at = Column("integer")
__unique_1__ = UniqueConstraint("coin_type", "address")
__index_watch_address__ = Index("idx_watch_address", "address")
__index_watch_scripthash__ = Index("idx_watch_scripthash", "scripthash")
class WalletLockedUTXO(Table):
__tablename__ = "wallet_locked_utxos"
record_id = Column("integer", primary_key=True, autoincrement=True)
coin_type = Column("integer")
txid = Column("string")
vout = Column("integer")
value = Column("integer")
address = Column("string")
bid_id = Column("blob")
locked_at = Column("integer")
expires_at = Column("integer")
__unique_1__ = UniqueConstraint("coin_type", "txid", "vout")
__index_locked_coin__ = Index("idx_locked_coin", "coin_type")
__index_locked_bid__ = Index("idx_locked_bid", "bid_id")
class WalletTxCache(Table):
__tablename__ = "wallet_tx_cache"
record_id = Column("integer", primary_key=True, autoincrement=True)
coin_type = Column("integer")
txid = Column("string")
block_height = Column("integer")
confirmations = Column("integer")
tx_data = Column("blob")
cached_at = Column("integer")
expires_at = Column("integer")
__unique_1__ = UniqueConstraint("coin_type", "txid")
__index_tx_cache__ = Index("idx_tx_cache", "coin_type", "txid")
class WalletPendingTx(Table):
__tablename__ = "wallet_pending_txs"
record_id = Column("integer", primary_key=True, autoincrement=True)
coin_type = Column("integer")
txid = Column("string")
tx_type = Column("string")
amount = Column("integer")
fee = Column("integer")
addresses = Column("string")
bid_id = Column("blob")
first_seen = Column("integer")
confirmed_at = Column("integer")
__unique_1__ = UniqueConstraint("coin_type", "txid")
__index_pending_coin__ = Index("idx_pending_coin", "coin_type", "confirmed_at")

File diff suppressed because it is too large Load Diff

View File

@@ -632,6 +632,15 @@ class DCRInterface(Secp256k1Interface):
# TODO: filter errors # TODO: filter errors
return None return None
def listWalletTransactions(self, count=100, skip=0, include_watchonly=True):
try:
return self.rpc_wallet(
"listtransactions", ["*", count, skip, include_watchonly]
)
except Exception as e:
self._log.error(f"listWalletTransactions failed: {e}")
return []
def getProofOfFunds(self, amount_for, extra_commit_bytes): def getProofOfFunds(self, amount_for, extra_commit_bytes):
# TODO: Lock unspent and use same output/s to fund bid # TODO: Lock unspent and use same output/s to fund bid

File diff suppressed because it is too large Load Diff

View File

@@ -27,12 +27,21 @@ class LTCInterface(BTCInterface):
) )
def getNewMwebAddress(self, use_segwit=False, label="swap_receive") -> str: def getNewMwebAddress(self, use_segwit=False, label="swap_receive") -> str:
if self.useBackend():
raise ValueError("MWEB addresses not supported in electrum mode")
return self.rpc_wallet_mweb("getnewaddress", [label, "mweb"]) return self.rpc_wallet_mweb("getnewaddress", [label, "mweb"])
def getNewStealthAddress(self, label=""): def getNewStealthAddress(self, label=""):
if self.useBackend():
raise ValueError("MWEB addresses not supported in electrum mode")
return self.getNewMwebAddress(False, label) return self.getNewMwebAddress(False, label)
def withdrawCoin(self, value, type_from: str, addr_to: str, subfee: bool) -> str: def withdrawCoin(self, value, type_from: str, addr_to: str, subfee: bool) -> str:
if self.useBackend():
if type_from == "mweb":
raise ValueError("MWEB withdrawals not supported in electrum mode")
return self._withdrawCoinElectrum(value, addr_to, subfee)
params = [addr_to, value, "", "", subfee, True, self._conf_target] params = [addr_to, value, "", "", subfee, True, self._conf_target]
if type_from == "mweb": if type_from == "mweb":
return self.rpc_wallet_mweb("sendtoaddress", params) return self.rpc_wallet_mweb("sendtoaddress", params)
@@ -53,14 +62,27 @@ class LTCInterface(BTCInterface):
def getWalletInfo(self): def getWalletInfo(self):
rv = super(LTCInterface, self).getWalletInfo() rv = super(LTCInterface, self).getWalletInfo()
mweb_info = self.rpc_wallet_mweb("getwalletinfo") if not self.useBackend():
rv["mweb_balance"] = mweb_info["balance"] try:
rv["mweb_unconfirmed"] = mweb_info["unconfirmed_balance"] mweb_info = self.rpc_wallet_mweb("getwalletinfo")
rv["mweb_immature"] = mweb_info["immature_balance"] rv["mweb_balance"] = mweb_info["balance"]
rv["mweb_unconfirmed"] = mweb_info["unconfirmed_balance"]
rv["mweb_immature"] = mweb_info["immature_balance"]
except Exception:
pass
return rv return rv
def getUnspentsByAddr(self): def getUnspentsByAddr(self):
unspent_addr = dict() unspent_addr = dict()
if self.useBackend():
wm = self.getWalletManager()
if wm:
addresses = wm.getAllAddresses(self.coin_type())
if addresses:
return self._backend.getBalance(addresses)
return unspent_addr
unspent = self.rpc_wallet("listunspent") unspent = self.rpc_wallet("listunspent")
for u in unspent: for u in unspent:
if u.get("spendable", False) is False: if u.get("spendable", False) is False:
@@ -152,6 +174,9 @@ class LTCInterfaceMWEB(LTCInterface):
return return
self._log.info("unlockWallet - {}".format(self.ticker())) self._log.info("unlockWallet - {}".format(self.ticker()))
if self.useBackend():
return
if not self.has_mweb_wallet(): if not self.has_mweb_wallet():
self.init_wallet(password) self.init_wallet(password)
else: else:

View File

@@ -94,6 +94,9 @@ class XMRInterface(CoinInterface):
"failed to get output distribution", "failed to get output distribution",
"request-sent", "request-sent",
"idle", "idle",
"busy",
"responsenotready",
"connection",
] ]
): ):
return True return True
@@ -832,3 +835,25 @@ class XMRInterface(CoinInterface):
] ]
}, },
) )
def listWalletTransactions(self, count=100, skip=0, include_watchonly=True):
try:
with self._mx_wallet:
self.openWallet(self._wallet_filename)
rv = self.rpc_wallet(
"get_transfers",
{"in": True, "out": True, "pending": True, "failed": True},
)
transactions = []
for tx_type in ["in", "out", "pending", "failed"]:
if tx_type in rv:
for tx in rv[tx_type]:
tx["type"] = tx_type
transactions.append(tx)
transactions.sort(key=lambda x: x.get("timestamp", 0), reverse=True)
return (
transactions[skip : skip + count] if count else transactions[skip:]
)
except Exception as e:
self._log.error(f"listWalletTransactions failed: {e}")
return []

View File

@@ -135,7 +135,7 @@ def js_walletbalances(self, url_split, post_string, is_json) -> bytes:
for k, v in swap_client.coin_clients.items(): for k, v in swap_client.coin_clients.items():
if k not in chainparams: if k not in chainparams:
continue continue
if v["connection_type"] == "rpc": if v["connection_type"] in ("rpc", "electrum"):
balance = "0.0" balance = "0.0"
if k in wallets: if k in wallets:
@@ -168,8 +168,20 @@ def js_walletbalances(self, url_split, post_string, is_json) -> bytes:
"balance": balance, "balance": balance,
"pending": pending, "pending": pending,
"ticker": chainparams[k]["ticker"], "ticker": chainparams[k]["ticker"],
"connection_type": v["connection_type"],
} }
ci = swap_client.ci(k)
if hasattr(ci, "getScanStatus"):
coin_entry["scan_status"] = ci.getScanStatus()
if hasattr(ci, "getElectrumServer"):
server = ci.getElectrumServer()
if server:
coin_entry["electrum_server"] = server
version = ci.getDaemonVersion()
if version:
coin_entry["version"] = version
coins_with_balances.append(coin_entry) coins_with_balances.append(coin_entry)
if k == Coins.PART: if k == Coins.PART:
@@ -293,6 +305,9 @@ def js_wallets(self, url_split, post_string, is_json):
elif cmd == "reseed": elif cmd == "reseed":
swap_client.reseedWallet(coin_type) swap_client.reseedWallet(coin_type)
return bytes(json.dumps({"reseeded": True}), "UTF-8") return bytes(json.dumps({"reseeded": True}), "UTF-8")
elif cmd == "rescan":
result = swap_client.rescanWalletAddresses(coin_type)
return bytes(json.dumps(result), "UTF-8")
elif cmd == "newstealthaddress": elif cmd == "newstealthaddress":
if coin_type != Coins.PART: if coin_type != Coins.PART:
raise ValueError("Invalid coin for command") raise ValueError("Invalid coin for command")
@@ -306,6 +321,31 @@ def js_wallets(self, url_split, post_string, is_json):
return bytes( return bytes(
json.dumps(swap_client.ci(coin_type).getNewMwebAddress()), "UTF-8" json.dumps(swap_client.ci(coin_type).getNewMwebAddress()), "UTF-8"
) )
elif cmd == "watchaddress":
post_data = getFormData(post_string, is_json)
address = get_data_entry(post_data, "address")
label = get_data_entry_or(post_data, "label", "manual_import")
wm = swap_client.getWalletManager()
if wm is None:
raise ValueError("WalletManager not available")
wm.importWatchOnlyAddress(
coin_type, address, label=label, source="manual_import"
)
return bytes(json.dumps({"success": True, "address": address}), "UTF-8")
elif cmd == "listaddresses":
wm = swap_client.getWalletManager()
if wm is None:
raise ValueError("WalletManager not available")
addresses = wm.getAllAddresses(coin_type)
return bytes(json.dumps({"addresses": addresses}), "UTF-8")
elif cmd == "fixseedid":
root_key = swap_client.getWalletKey(coin_type, 1)
swap_client.storeSeedIDForCoin(root_key, coin_type)
swap_client.checkWalletSeed(coin_type)
return bytes(
json.dumps({"success": True, "message": "Seed IDs updated"}),
"UTF-8",
)
raise ValueError("Unknown command") raise ValueError("Unknown command")
if coin_type == Coins.LTC_MWEB: if coin_type == Coins.LTC_MWEB:
@@ -1526,6 +1566,71 @@ def js_coinhistory(self, url_split, post_string, is_json) -> bytes:
) )
def js_wallettransactions(self, url_split, post_string, is_json) -> bytes:
from basicswap.ui.page_wallet import format_transactions
import time
TX_CACHE_DURATION = 30
swap_client = self.server.swap_client
swap_client.checkSystemStatus()
if len(url_split) < 4:
return bytes(json.dumps({"error": "No coin specified"}), "UTF-8")
ticker_str = url_split[3]
coin_id = getCoinIdFromTicker(ticker_str)
post_data = {} if post_string == "" else getFormData(post_string, is_json)
page_no = 1
limit = 30
offset = 0
if have_data_entry(post_data, "page_no"):
page_no = int(get_data_entry(post_data, "page_no"))
if page_no < 1:
page_no = 1
if page_no > 1:
offset = (page_no - 1) * limit
try:
ci = swap_client.ci(coin_id)
current_time = time.time()
cache_entry = swap_client._tx_cache.get(coin_id)
if (
cache_entry is None
or (current_time - cache_entry["time"]) > TX_CACHE_DURATION
):
all_txs = ci.listWalletTransactions(count=10000, skip=0)
all_txs = list(reversed(all_txs)) if all_txs else []
swap_client._tx_cache[coin_id] = {"txs": all_txs, "time": current_time}
else:
all_txs = cache_entry["txs"]
total_transactions = len(all_txs)
raw_txs = all_txs[offset : offset + limit] if all_txs else []
transactions = format_transactions(ci, raw_txs, coin_id)
return bytes(
json.dumps(
{
"transactions": transactions,
"page_no": page_no,
"total": total_transactions,
"limit": limit,
"total_pages": (total_transactions + limit - 1) // limit,
}
),
"UTF-8",
)
except Exception as e:
return bytes(json.dumps({"error": str(e)}), "UTF-8")
def js_messageroutes(self, url_split, post_string, is_json) -> bytes: def js_messageroutes(self, url_split, post_string, is_json) -> bytes:
swap_client = self.server.swap_client swap_client = self.server.swap_client
post_data = {} if post_string == "" else getFormData(post_string, is_json) post_data = {} if post_string == "" else getFormData(post_string, is_json)
@@ -1569,10 +1674,107 @@ def js_messageroutes(self, url_split, post_string, is_json) -> bytes:
return bytes(json.dumps(message_routes), "UTF-8") return bytes(json.dumps(message_routes), "UTF-8")
def js_electrum_discover(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)
coin_str = get_data_entry(post_data, "coin")
do_ping = toBool(get_data_entry_or(post_data, "ping", "false"))
coin_type = None
try:
coin_id = int(coin_str)
coin_type = Coins(coin_id)
except ValueError:
try:
coin_type = getCoinIdFromName(coin_str)
except ValueError:
coin_type = getCoinType(coin_str)
electrum_supported = ["bitcoin", "litecoin"]
coin_name = chainparams.get(coin_type, {}).get("name", "").lower()
if coin_name not in electrum_supported:
return bytes(
json.dumps(
{"error": f"Electrum not supported for {coin_name}", "servers": []}
),
"UTF-8",
)
ci = swap_client.ci(coin_type)
connection_type = getattr(ci, "_connection_type", "rpc")
discovered_servers = []
current_server = None
if connection_type == "electrum":
backend = ci.getBackend()
if backend and hasattr(backend, "_server"):
server = backend._server
current_server = server.get_current_server_info()
discovered_servers = server.discover_peers()
if do_ping and discovered_servers:
for srv in discovered_servers[:10]:
latency = server.ping_server(
srv["host"], srv["port"], srv.get("ssl", True)
)
srv["latency_ms"] = latency
srv["online"] = latency is not None
else:
try:
from .interface.electrumx import ElectrumServer
temp_server = ElectrumServer(
coin_name,
log=swap_client.log,
)
temp_server.connect()
current_server = temp_server.get_current_server_info()
discovered_servers = temp_server.discover_peers()
if do_ping and discovered_servers:
for srv in discovered_servers[:10]:
latency = temp_server.ping_server(
srv["host"], srv["port"], srv.get("ssl", True)
)
srv["latency_ms"] = latency
srv["online"] = latency is not None
temp_server.disconnect()
except Exception as e:
return bytes(
json.dumps(
{
"error": f"Failed to connect to electrum server: {str(e)}",
"servers": [],
}
),
"UTF-8",
)
onion_servers = [s for s in discovered_servers if s.get("is_onion")]
clearnet_servers = [s for s in discovered_servers if not s.get("is_onion")]
return bytes(
json.dumps(
{
"coin": coin_name,
"current_server": current_server,
"clearnet_servers": clearnet_servers,
"onion_servers": onion_servers,
"total_discovered": len(discovered_servers),
}
),
"UTF-8",
)
endpoints = { endpoints = {
"coins": js_coins, "coins": js_coins,
"walletbalances": js_walletbalances, "walletbalances": js_walletbalances,
"wallets": js_wallets, "wallets": js_wallets,
"wallettransactions": js_wallettransactions,
"offers": js_offers, "offers": js_offers,
"sentoffers": js_sentoffers, "sentoffers": js_sentoffers,
"bids": js_bids, "bids": js_bids,
@@ -1602,6 +1804,7 @@ endpoints = {
"coinvolume": js_coinvolume, "coinvolume": js_coinvolume,
"coinhistory": js_coinhistory, "coinhistory": js_coinhistory,
"messageroutes": js_messageroutes, "messageroutes": js_messageroutes,
"electrumdiscover": js_electrum_discover,
} }

View File

@@ -2,15 +2,42 @@
'use strict'; 'use strict';
const EventHandlers = { const EventHandlers = {
confirmPopup: function(action = 'proceed', coinName = '') { showConfirmModal: function(title, message, callback) {
const message = action === 'Accept' const modal = document.getElementById('confirmModal');
? 'Are you sure you want to accept this bid?' if (!modal) {
: coinName if (callback) callback();
? `Are you sure you want to ${action} ${coinName}?` return;
: 'Are you sure you want to proceed?'; }
return confirm(message); const titleEl = document.getElementById('confirmTitle');
const messageEl = document.getElementById('confirmMessage');
const yesBtn = document.getElementById('confirmYes');
const noBtn = document.getElementById('confirmNo');
const bidDetails = document.getElementById('bidDetailsSection');
if (titleEl) titleEl.textContent = title;
if (messageEl) {
messageEl.textContent = message;
messageEl.classList.remove('hidden');
}
if (bidDetails) bidDetails.classList.add('hidden');
modal.classList.remove('hidden');
const newYesBtn = yesBtn.cloneNode(true);
yesBtn.parentNode.replaceChild(newYesBtn, yesBtn);
newYesBtn.addEventListener('click', function() {
modal.classList.add('hidden');
if (callback) callback();
});
const newNoBtn = noBtn.cloneNode(true);
noBtn.parentNode.replaceChild(newNoBtn, noBtn);
newNoBtn.addEventListener('click', function() {
modal.classList.add('hidden');
});
}, },
confirmReseed: function() { confirmReseed: function() {
@@ -18,7 +45,6 @@
}, },
confirmWithdrawal: function() { confirmWithdrawal: function() {
if (window.WalletPage && typeof window.WalletPage.confirmWithdrawal === 'function') { if (window.WalletPage && typeof window.WalletPage.confirmWithdrawal === 'function') {
return window.WalletPage.confirmWithdrawal(); return window.WalletPage.confirmWithdrawal();
} }
@@ -67,14 +93,36 @@
return; return;
} }
const balanceElement = amountInput.closest('form')?.querySelector('[data-balance]'); let coinFromId;
const balance = balanceElement ? parseFloat(balanceElement.getAttribute('data-balance')) : 0; if (inputId === 'add-amm-amount') {
coinFromId = 'add-amm-coin-from';
} else if (inputId === 'edit-amm-amount') {
coinFromId = 'edit-amm-coin-from';
} else {
const form = amountInput.closest('form') || amountInput.closest('.modal-content') || amountInput.closest('[id*="modal"]');
const select = form?.querySelector('select[id*="coin-from"]');
coinFromId = select?.id;
}
const coinFromSelect = coinFromId ? document.getElementById(coinFromId) : null;
if (!coinFromSelect) {
console.error('EventHandlers: Coin-from dropdown not found for:', inputId);
return;
}
const selectedOption = coinFromSelect.options[coinFromSelect.selectedIndex];
if (!selectedOption) {
console.error('EventHandlers: No option selected in coin-from dropdown');
return;
}
const balance = parseFloat(selectedOption.getAttribute('data-balance') || '0');
if (balance > 0) { if (balance > 0) {
const calculatedAmount = balance * percent; const calculatedAmount = balance * percent;
amountInput.value = calculatedAmount.toFixed(8); amountInput.value = calculatedAmount.toFixed(8);
} else { } else {
console.warn('EventHandlers: No balance found for AMM amount calculation'); console.warn('EventHandlers: No balance found for selected coin');
} }
}, },
@@ -132,13 +180,10 @@
}, },
hideConfirmModal: function() { hideConfirmModal: function() {
if (window.DOMCache) { const modal = document.getElementById('confirmModal');
window.DOMCache.hide('confirmModal'); if (modal) {
} else { modal.classList.add('hidden');
const modal = document.getElementById('confirmModal'); modal.style.display = '';
if (modal) {
modal.style.display = 'none';
}
} }
}, },
@@ -187,17 +232,43 @@
}, },
initialize: function() { initialize: function() {
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
const target = e.target.closest('[data-confirm]'); const target = e.target.closest('[data-confirm]');
if (target) { if (target) {
if (target.dataset.confirmHandled) {
delete target.dataset.confirmHandled;
return;
}
e.preventDefault();
e.stopPropagation();
const action = target.getAttribute('data-confirm-action') || 'proceed'; const action = target.getAttribute('data-confirm-action') || 'proceed';
const coinName = target.getAttribute('data-confirm-coin') || ''; const coinName = target.getAttribute('data-confirm-coin') || '';
if (!this.confirmPopup(action, coinName)) { const message = action === 'Accept'
e.preventDefault(); ? 'Are you sure you want to accept this bid?'
return false; : coinName
} ? `Are you sure you want to ${action} ${coinName}?`
: 'Are you sure you want to proceed?';
const title = `Confirm ${action}`;
this.showConfirmModal(title, message, function() {
target.dataset.confirmHandled = 'true';
if (target.form) {
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = target.name;
hiddenInput.value = target.value;
target.form.appendChild(hiddenInput);
target.form.submit();
} else {
target.click();
}
});
} }
}); });
@@ -326,8 +397,6 @@
} }
window.EventHandlers = EventHandlers; window.EventHandlers = EventHandlers;
window.confirmPopup = EventHandlers.confirmPopup.bind(EventHandlers);
window.confirmReseed = EventHandlers.confirmReseed.bind(EventHandlers); window.confirmReseed = EventHandlers.confirmReseed.bind(EventHandlers);
window.confirmWithdrawal = EventHandlers.confirmWithdrawal.bind(EventHandlers); window.confirmWithdrawal = EventHandlers.confirmWithdrawal.bind(EventHandlers);
window.confirmUTXOResize = EventHandlers.confirmUTXOResize.bind(EventHandlers); window.confirmUTXOResize = EventHandlers.confirmUTXOResize.bind(EventHandlers);

View File

@@ -250,6 +250,7 @@ function ensureToastContainer() {
'new_bid': 'bg-green-500', 'new_bid': 'bg-green-500',
'bid_accepted': 'bg-purple-500', 'bid_accepted': 'bg-purple-500',
'swap_completed': 'bg-green-600', 'swap_completed': 'bg-green-600',
'sweep_completed': 'bg-orange-500',
'balance_change': 'bg-yellow-500', 'balance_change': 'bg-yellow-500',
'update_available': 'bg-blue-600', 'update_available': 'bg-blue-600',
'success': 'bg-blue-500' 'success': 'bg-blue-500'
@@ -735,6 +736,17 @@ function ensureToastContainer() {
shouldShowToast = config.showUpdateNotifications; shouldShowToast = config.showUpdateNotifications;
break; break;
case 'sweep_completed':
const sweepAmount = parseFloat(data.amount || 0).toFixed(8).replace(/\.?0+$/, '');
const sweepFee = parseFloat(data.fee || 0).toFixed(8).replace(/\.?0+$/, '');
toastTitle = `Swept ${sweepAmount} ${data.coin_name} to RPC wallet`;
toastOptions.subtitle = `Fee: ${sweepFee} ${data.coin_name} • TXID: ${(data.txid || '').substring(0, 12)}...`;
toastOptions.coinSymbol = data.coin_name;
toastOptions.txid = data.txid;
toastType = 'sweep_completed';
shouldShowToast = true;
break;
case 'coin_balance_updated': case 'coin_balance_updated':
if (data.coin && config.showBalanceChanges) { if (data.coin && config.showBalanceChanges) {
this.handleBalanceUpdate(data); this.handleBalanceUpdate(data);

View File

@@ -0,0 +1,459 @@
const BidPage = {
bidId: null,
bidStateInd: null,
createdAtTimestamp: null,
autoRefreshInterval: null,
elapsedTimeInterval: null,
AUTO_REFRESH_SECONDS: 15,
INACTIVE_STATES: [8, 17, 18, 19, 21, 22, 23, 25, 31], // Completed, Failed variants, Timed-out, Abandoned, Error, Rejected, Expired
STATE_TOOLTIPS: {
'Bid Sent': 'Your bid has been broadcast to the network',
'Bid Receiving': 'Receiving partial bid message from the network',
'Bid Received': 'Bid received and waiting for decision to accept or reject',
'Bid Receiving accept': 'Receiving acceptance message from the other party',
'Bid Accepted': 'Bid accepted. The atomic swap process is starting',
'Bid Initiated': 'Swap initiated. First lock transaction is being created',
'Bid Participating': 'Participating in the swap. Second lock transaction is being created',
'Bid Completed': 'Swap completed successfully! Both parties received their coins',
'Bid Script coin locked': 'Your coins are locked in the atomic swap contract on the script chain (e.g., BTC/LTC)',
'Bid Script coin spend tx valid': 'The spend transaction for the script coin has been validated and is ready',
'Bid Scriptless coin locked': 'The other party\'s coins are locked using adaptor signatures (e.g., XMR)',
'Bid Script coin lock released': 'Secret key revealed. The script coin can now be claimed',
'Bid Script tx redeemed': 'Script coin has been successfully claimed',
'Bid Script pre-refund tx in chain': 'Pre-refund transaction detected. Swap may be failing',
'Bid Scriptless tx redeemed': 'Scriptless coin (e.g., XMR) has been successfully claimed',
'Bid Scriptless tx recovered': 'Scriptless coin recovered after swap failure',
'Bid Failed, refunded': 'Swap failed but your coins have been refunded',
'Bid Failed, swiped': 'Swap failed due to an unexpected issue. Please check the event log for details',
'Bid Failed': 'Swap failed. Check events for details',
'Bid Delaying': 'Brief delay between swap steps to ensure network propagation',
'Bid Timed-out': 'Swap timed out waiting for the other party',
'Bid Abandoned': 'Swap was manually abandoned. Locked coins will be refunded after timelock',
'Bid Error': 'An error occurred. Check events for details',
'Bid Rejected': 'Bid was rejected by the offer owner',
'Bid Stalled (debug)': 'Debug mode: swap intentionally stalled for testing',
'Bid Exchanged script lock tx sigs msg': 'Exchanging cryptographic signatures needed for lock transactions',
'Bid Exchanged script lock spend tx msg': 'Exchanging signed spend transaction for locked coins',
'Bid Request sent': 'Connection request sent to the other party',
'Bid Request accepted': 'Connection request accepted',
'Bid Expired': 'Bid expired before being accepted',
'Bid Auto accept delay': 'Waiting for automation delay before auto-accepting',
'Bid Auto accept failed': 'Automation failed to accept this bid',
'Bid Connect request sent': 'Sent connection request to peer',
'Bid Unknown bid state': 'Unknown state - please check the swap details',
'ITX Sent': 'Initiate transaction has been broadcast to the network',
'ITX Confirmed': 'Initiate transaction has been confirmed by miners',
'ITX Redeemed': 'Initiate transaction has been successfully claimed',
'ITX Refunded': 'Initiate transaction has been refunded',
'ITX In Mempool': 'Initiate transaction is in the mempool (unconfirmed)',
'ITX In Chain': 'Initiate transaction is included in a block',
'PTX Sent': 'Participate transaction has been broadcast to the network',
'PTX Confirmed': 'Participate transaction has been confirmed by miners',
'PTX Redeemed': 'Participate transaction has been successfully claimed',
'PTX Refunded': 'Participate transaction has been refunded',
'PTX In Mempool': 'Participate transaction is in the mempool (unconfirmed)',
'PTX In Chain': 'Participate transaction is included in a block'
},
EVENT_TOOLTIPS: {
'Lock tx A published': 'First lock transaction broadcast to the blockchain network',
'Lock tx A seen in mempool': 'First lock transaction detected in mempool (unconfirmed)',
'Lock tx A seen in chain': 'First lock transaction included in a block',
'Lock tx A confirmed in chain': 'First lock transaction has enough confirmations',
'Lock tx B published': 'Second lock transaction broadcast to the blockchain network',
'Lock tx B seen in mempool': 'Second lock transaction detected in mempool (unconfirmed)',
'Lock tx B seen in chain': 'Second lock transaction included in a block',
'Lock tx B confirmed in chain': 'Second lock transaction has enough confirmations',
'Lock tx A spend tx published': 'Transaction to claim coins from first lock has been broadcast',
'Lock tx A spend tx seen in chain': 'First lock spend transaction included in a block',
'Lock tx B spend tx published': 'Transaction to claim coins from second lock has been broadcast',
'Lock tx B spend tx seen in chain': 'Second lock spend transaction included in a block',
'Failed to publish lock tx B': 'ERROR: Could not broadcast second lock transaction',
'Failed to publish lock tx B spend': 'ERROR: Could not broadcast spend transaction for second lock',
'Failed to publish lock tx B refund': 'ERROR: Could not broadcast refund transaction',
'Detected invalid lock Tx B': 'ERROR: Second lock transaction is invalid or malformed',
'Lock tx A pre-refund tx published': 'Pre-refund transaction broadcast. Swap is being cancelled',
'Lock tx A refund spend tx published': 'Refund transaction for first lock has been broadcast',
'Lock tx A refund swipe tx published': 'Other party claimed your refund (swiped)',
'Lock tx B refund tx published': 'Refund transaction for second lock has been broadcast',
'Lock tx A conflicting txn/s': 'WARNING: Conflicting transaction detected for first lock',
'Lock tx A pre-refund tx seen in chain': 'Pre-refund transaction detected in blockchain',
'Lock tx A refund spend tx seen in chain': 'Refund spend transaction detected in blockchain',
'Initiate tx published': 'Secret-hash swap: Initiate transaction broadcast',
'Initiate tx redeem tx published': 'Secret-hash swap: Initiate transaction claimed',
'Initiate tx refund tx published': 'Secret-hash swap: Initiate transaction refunded',
'Participate tx published': 'Secret-hash swap: Participate transaction broadcast',
'Participate tx redeem tx published': 'Secret-hash swap: Participate transaction claimed',
'Participate tx refund tx published': 'Secret-hash swap: Participate transaction refunded',
'BCH mercy tx found': 'BCH specific: Mercy transaction detected',
'Lock tx B mercy tx published': 'BCH specific: Mercy transaction broadcast',
'Auto accepting': 'Automation is accepting this bid',
'Failed auto accepting': 'Automation constraints prevented accepting this bid',
'Debug tweak applied': 'Debug mode: A test tweak was applied'
},
STATE_PHASES: {
1: { phase: 'negotiation', order: 1, label: 'Negotiation' }, // BID_SENT
2: { phase: 'negotiation', order: 2, label: 'Negotiation' }, // BID_RECEIVING
3: { phase: 'negotiation', order: 3, label: 'Negotiation' }, // BID_RECEIVED
4: { phase: 'negotiation', order: 4, label: 'Negotiation' }, // BID_RECEIVING_ACC
5: { phase: 'accepted', order: 5, label: 'Accepted' }, // BID_ACCEPTED
6: { phase: 'locking', order: 6, label: 'Locking' }, // SWAP_INITIATED
7: { phase: 'locking', order: 7, label: 'Locking' }, // SWAP_PARTICIPATING
8: { phase: 'complete', order: 100, label: 'Complete' }, // SWAP_COMPLETED
9: { phase: 'locking', order: 8, label: 'Locking' }, // XMR_SWAP_SCRIPT_COIN_LOCKED
10: { phase: 'locking', order: 9, label: 'Locking' }, // XMR_SWAP_HAVE_SCRIPT_COIN_SPEND_TX
11: { phase: 'locking', order: 10, label: 'Locking' }, // XMR_SWAP_NOSCRIPT_COIN_LOCKED
12: { phase: 'redemption', order: 11, label: 'Redemption' }, // XMR_SWAP_LOCK_RELEASED
13: { phase: 'redemption', order: 12, label: 'Redemption' }, // XMR_SWAP_SCRIPT_TX_REDEEMED
14: { phase: 'failed', order: 90, label: 'Failed' }, // XMR_SWAP_SCRIPT_TX_PREREFUND
15: { phase: 'redemption', order: 13, label: 'Redemption' }, // XMR_SWAP_NOSCRIPT_TX_REDEEMED
16: { phase: 'failed', order: 91, label: 'Recovered' }, // XMR_SWAP_NOSCRIPT_TX_RECOVERED
17: { phase: 'failed', order: 92, label: 'Failed' }, // XMR_SWAP_FAILED_REFUNDED
18: { phase: 'failed', order: 93, label: 'Failed' }, // XMR_SWAP_FAILED_SWIPED
19: { phase: 'failed', order: 94, label: 'Failed' }, // XMR_SWAP_FAILED
20: { phase: 'locking', order: 7.5, label: 'Locking' }, // SWAP_DELAYING
21: { phase: 'failed', order: 95, label: 'Failed' }, // SWAP_TIMEDOUT
22: { phase: 'failed', order: 96, label: 'Abandoned' }, // BID_ABANDONED
23: { phase: 'failed', order: 97, label: 'Error' }, // BID_ERROR
25: { phase: 'failed', order: 98, label: 'Rejected' }, // BID_REJECTED
27: { phase: 'accepted', order: 5.5, label: 'Accepted' }, // XMR_SWAP_MSG_SCRIPT_LOCK_TX_SIGS
28: { phase: 'accepted', order: 5.6, label: 'Accepted' }, // XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX
29: { phase: 'negotiation', order: 0.5, label: 'Negotiation' }, // BID_REQUEST_SENT
30: { phase: 'negotiation', order: 0.6, label: 'Negotiation' }, // BID_REQUEST_ACCEPTED
31: { phase: 'failed', order: 99, label: 'Expired' }, // BID_EXPIRED
32: { phase: 'negotiation', order: 3.5, label: 'Negotiation' }, // BID_AACCEPT_DELAY
33: { phase: 'failed', order: 89, label: 'Failed' }, // BID_AACCEPT_FAIL
34: { phase: 'negotiation', order: 0.4, label: 'Negotiation' } // CONNECT_REQ_SENT
},
init: function(bidId, bidStateInd, createdAtTimestamp, stateTimeTimestamp) {
this.bidId = bidId;
this.bidStateInd = bidStateInd;
this.createdAtTimestamp = createdAtTimestamp;
this.stateTimeTimestamp = stateTimeTimestamp;
this.tooltipCounter = 0;
this.applyStateTooltips();
this.applyEventTooltips();
this.createProgressBar();
this.startElapsedTimeUpdater();
this.setupAutoRefresh();
},
isActiveState: function() {
return !this.INACTIVE_STATES.includes(this.bidStateInd);
},
setupAutoRefresh: function() {
if (!this.isActiveState()) {
return;
}
const refreshBtn = document.getElementById('refresh');
if (!refreshBtn) return;
let countdown = this.AUTO_REFRESH_SECONDS;
const originalSpan = refreshBtn.querySelector('span');
if (!originalSpan) return;
const updateCountdown = () => {
originalSpan.textContent = `Auto-refresh in ${countdown}s`;
countdown--;
if (countdown < 0) {
window.location.href = window.location.pathname + window.location.search;
}
};
updateCountdown();
this.autoRefreshInterval = setInterval(updateCountdown, 1000);
refreshBtn.addEventListener('mouseenter', () => {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
originalSpan.textContent = 'Click to refresh (paused)';
}
});
refreshBtn.addEventListener('mouseleave', () => {
countdown = this.AUTO_REFRESH_SECONDS;
updateCountdown();
this.autoRefreshInterval = setInterval(updateCountdown, 1000);
});
},
createTooltip: function(element, tooltipText) {
if (window.TooltipManager && typeof window.TooltipManager.create === 'function') {
try {
const tooltipContent = `
<div class="py-1 px-2 text-sm text-white">
${tooltipText}
</div>
`;
window.TooltipManager.create(element, tooltipContent, {
placement: 'top'
});
element.classList.add('cursor-help');
} catch (e) {
element.setAttribute('title', tooltipText);
element.classList.add('cursor-help');
}
} else {
element.setAttribute('title', tooltipText);
element.classList.add('cursor-help');
}
},
applyStateTooltips: function() {
const sections = document.querySelectorAll('section');
let oldStatesSection = null;
sections.forEach(section => {
const h4 = section.querySelector('h4');
if (h4 && h4.textContent.includes('Old states')) {
oldStatesSection = section.nextElementSibling;
}
});
if (oldStatesSection) {
const table = oldStatesSection.querySelector('table');
if (table) {
const rows = table.querySelectorAll('tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length >= 2) {
const stateCell = cells[cells.length - 1];
const stateText = stateCell.textContent.trim();
const tooltip = this.STATE_TOOLTIPS[stateText];
if (tooltip) {
this.addHelpIcon(stateCell, tooltip);
}
}
});
}
}
const allRows = document.querySelectorAll('table tr');
allRows.forEach(row => {
const firstCell = row.querySelector('td');
if (firstCell) {
const labelText = firstCell.textContent.trim();
if (labelText === 'Bid State') {
const valueCell = row.querySelectorAll('td')[1];
if (valueCell) {
const stateText = valueCell.textContent.trim();
const tooltip = this.STATE_TOOLTIPS[stateText] || this.STATE_TOOLTIPS['Bid ' + stateText];
if (tooltip) {
this.addHelpIcon(valueCell, tooltip);
}
}
}
}
});
},
addHelpIcon: function(cell, tooltipText) {
if (cell.querySelector('.help-icon')) return;
const helpIcon = document.createElement('span');
helpIcon.className = 'help-icon cursor-help inline-flex items-center justify-center w-4 h-4 ml-2 text-xs font-medium text-white bg-blue-500 dark:bg-blue-600 rounded-full hover:bg-blue-600 dark:hover:bg-blue-500';
helpIcon.textContent = '?';
helpIcon.style.fontSize = '10px';
helpIcon.style.verticalAlign = 'middle';
helpIcon.style.flexShrink = '0';
cell.appendChild(helpIcon);
setTimeout(() => {
this.createTooltip(helpIcon, tooltipText);
}, 50);
},
applyEventTooltips: function() {
const sections = document.querySelectorAll('section');
let eventsSection = null;
sections.forEach(section => {
const h4 = section.querySelector('h4');
if (h4 && h4.textContent.includes('Events')) {
eventsSection = section.nextElementSibling;
}
});
if (eventsSection) {
const table = eventsSection.querySelector('table');
if (table) {
const rows = table.querySelectorAll('tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length >= 2) {
const eventCell = cells[cells.length - 1];
const eventText = eventCell.textContent.trim();
let tooltip = this.EVENT_TOOLTIPS[eventText];
if (!tooltip) {
for (const [key, value] of Object.entries(this.EVENT_TOOLTIPS)) {
if (eventText.startsWith(key.replace(':', ''))) {
tooltip = value;
break;
}
}
}
if (!tooltip && eventText.startsWith('Warning:')) {
tooltip = 'System warning - check message for details';
}
if (!tooltip && eventText.startsWith('Error:')) {
tooltip = 'Error occurred - check message for details';
}
if (!tooltip && eventText.startsWith('Temporary RPC error')) {
tooltip = 'Temporary error checking transaction. Will retry automatically';
}
if (tooltip) {
this.addHelpIcon(eventCell, tooltip);
}
}
});
}
}
},
createProgressBar: function() {
const phaseInfo = this.STATE_PHASES[this.bidStateInd];
if (!phaseInfo) return;
let progressPercent = 0;
const phase = phaseInfo.phase;
if (phase === 'negotiation') progressPercent = 15;
else if (phase === 'accepted') progressPercent = 30;
else if (phase === 'locking') progressPercent = 55;
else if (phase === 'redemption') progressPercent = 80;
else if (phase === 'complete') progressPercent = 100;
else if (phase === 'failed' || phase === 'error') progressPercent = 100;
const bidStateRow = document.querySelector('td.bold');
if (!bidStateRow) return;
let targetRow = null;
const rows = document.querySelectorAll('table tr');
rows.forEach(row => {
const firstTd = row.querySelector('td.bold');
if (firstTd && firstTd.textContent.trim() === 'Bid State') {
targetRow = row;
}
});
if (!targetRow) return;
const progressRow = document.createElement('tr');
progressRow.className = 'opacity-100 text-gray-500 dark:text-gray-100';
const isError = ['failed', 'error'].includes(phase);
const isComplete = phase === 'complete';
const barColor = isError ? 'bg-red-500' : (isComplete ? 'bg-green-500' : 'bg-blue-500');
const phaseLabel = isError ? phaseInfo.label : (isComplete ? 'Complete' : `${phaseInfo.label} (${progressPercent}%)`);
progressRow.innerHTML = `
<td class="py-3 px-6 bold">Swap Progress</td>
<td class="py-3 px-6">
<div class="flex items-center gap-3">
<div class="flex-1 bg-gray-200 dark:bg-gray-600 rounded-full h-2.5 max-w-xs">
<div class="${barColor} h-2.5 rounded-full transition-all duration-500" style="width: ${progressPercent}%"></div>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white">${phaseLabel}</span>
</div>
</td>
`;
targetRow.parentNode.insertBefore(progressRow, targetRow.nextSibling);
},
startElapsedTimeUpdater: function() {
if (!this.createdAtTimestamp) return;
let createdAtRow = null;
const rows = document.querySelectorAll('table tr');
rows.forEach(row => {
const firstTd = row.querySelector('td');
if (firstTd && firstTd.textContent.includes('Created At')) {
createdAtRow = row;
}
});
if (!createdAtRow) return;
const isCompleted = !this.isActiveState() && this.stateTimeTimestamp;
const elapsedRow = document.createElement('tr');
elapsedRow.className = 'opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600';
const labelText = isCompleted ? 'Swap Duration' : 'Time Elapsed';
const iconColor = isCompleted ? '#10B981' : '#3B82F6';
elapsedRow.innerHTML = `
<td class="flex items-center px-46 whitespace-nowrap">
<svg alt="" class="w-5 h-5 rounded-full ml-5" xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 24 24">
<g stroke-linecap="round" stroke-width="2" fill="none" stroke="${iconColor}" stroke-linejoin="round">
<circle cx="12" cy="12" r="11"></circle>
<polyline points="12,6 12,12 18,12" stroke="${iconColor}"></polyline>
</g>
</svg>
<div class="py-3 pl-2 bold">
<div>${labelText}</div>
</div>
</td>
<td class="py-3 px-6" id="elapsed-time-display">Calculating...</td>
`;
createdAtRow.parentNode.insertBefore(elapsedRow, createdAtRow.nextSibling);
const elapsedDisplay = document.getElementById('elapsed-time-display');
if (isCompleted) {
const duration = this.stateTimeTimestamp - this.createdAtTimestamp;
elapsedDisplay.textContent = this.formatDuration(duration);
} else {
const updateElapsed = () => {
const now = Math.floor(Date.now() / 1000);
const elapsed = now - this.createdAtTimestamp;
elapsedDisplay.textContent = this.formatDuration(elapsed);
};
updateElapsed();
this.elapsedTimeInterval = setInterval(updateElapsed, 1000);
}
},
formatDuration: function(seconds) {
if (seconds < 60) {
return `${seconds} second${seconds !== 1 ? 's' : ''}`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
const remainingSeconds = seconds % 60;
if (remainingSeconds > 0) {
return `${minutes} min ${remainingSeconds} sec`;
}
return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours < 24) {
if (remainingMinutes > 0) {
return `${hours} hr ${remainingMinutes} min`;
}
return `${hours} hour${hours !== 1 ? 's' : ''}`;
}
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
if (remainingHours > 0) {
return `${days} day${days !== 1 ? 's' : ''} ${remainingHours} hr`;
}
return `${days} day${days !== 1 ? 's' : ''}`;
}
};

View File

@@ -41,12 +41,29 @@
setupEventListeners: function() { setupEventListeners: function() {
const sendBidBtn = document.querySelector('button[name="sendbid"][value="Send Bid"]'); const sendBidBtn = document.querySelector('button[name="sendbid"][value="Send Bid"]');
if (sendBidBtn) { if (sendBidBtn) {
sendBidBtn.onclick = this.showConfirmModal.bind(this); sendBidBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.showConfirmModal();
});
} }
const modalCancelBtn = document.querySelector('#confirmModal .flex button:last-child'); const modalCancelBtn = document.querySelector('#confirmModal [data-hide-modal]');
if (modalCancelBtn) { if (modalCancelBtn) {
modalCancelBtn.onclick = this.hideConfirmModal.bind(this); modalCancelBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.hideConfirmModal();
});
}
const confirmModal = document.getElementById('confirmModal');
if (confirmModal) {
confirmModal.addEventListener('click', (e) => {
if (e.target === confirmModal || e.target.classList.contains('bg-opacity-50')) {
this.hideConfirmModal();
}
});
} }
const mainCancelBtn = document.querySelector('button[name="cancel"]'); const mainCancelBtn = document.querySelector('button[name="cancel"]');

View File

@@ -6,11 +6,15 @@
confirmCallback: null, confirmCallback: null,
triggerElement: null, triggerElement: null,
originalConnectionTypes: {},
init: function() { init: function() {
this.setupTabs(); this.setupTabs();
this.setupCoinHeaders(); this.setupCoinHeaders();
this.setupConfirmModal(); this.setupConfirmModal();
this.setupNotificationSettings(); this.setupNotificationSettings();
this.setupMigrationIndicator();
this.setupServerDiscovery();
}, },
setupTabs: function() { setupTabs: function() {
@@ -61,6 +65,144 @@
}); });
}, },
pendingModeSwitch: null,
setupMigrationIndicator: function() {
const connectionTypeSelects = document.querySelectorAll('select[name^="connection_type_"]');
connectionTypeSelects.forEach(select => {
const originalValue = select.dataset.originalValue || select.value;
this.originalConnectionTypes[select.name] = originalValue;
});
this.setupWalletModeModal();
const coinsForm = document.getElementById('coins-form');
if (coinsForm) {
coinsForm.addEventListener('submit', (e) => {
const submitter = e.submitter;
if (!submitter || !submitter.name.startsWith('apply_')) return;
const coinName = submitter.name.replace('apply_', '');
const select = document.querySelector(`select[name="connection_type_${coinName}"]`);
if (!select) return;
const original = this.originalConnectionTypes[select.name];
const current = select.value;
if (original && current && original !== current) {
e.preventDefault();
const direction = (original === 'rpc' && current === 'electrum') ? 'lite' : 'rpc';
this.showWalletModeConfirmation(coinName, direction, submitter);
}
});
}
},
setupWalletModeModal: function() {
const confirmBtn = document.getElementById('walletModeConfirm');
const cancelBtn = document.getElementById('walletModeCancel');
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
this.hideWalletModeModal();
if (this.pendingModeSwitch) {
const { coinName, direction, submitter } = this.pendingModeSwitch;
this.showMigrationModal(coinName.toUpperCase(), direction);
const form = submitter.form;
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = submitter.name;
hiddenInput.value = submitter.value;
form.appendChild(hiddenInput);
form.submit();
}
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
this.hideWalletModeModal();
if (this.pendingModeSwitch) {
const { coinName } = this.pendingModeSwitch;
const select = document.querySelector(`select[name="connection_type_${coinName}"]`);
if (select) {
select.value = this.originalConnectionTypes[select.name];
}
}
this.pendingModeSwitch = null;
});
}
},
showWalletModeConfirmation: function(coinName, direction, submitter) {
const modal = document.getElementById('walletModeModal');
const title = document.getElementById('walletModeTitle');
const message = document.getElementById('walletModeMessage');
const details = document.getElementById('walletModeDetails');
if (!modal || !title || !message || !details) return;
this.pendingModeSwitch = { coinName, direction, submitter };
const displayName = coinName.charAt(0).toUpperCase() + coinName.slice(1).toLowerCase();
if (direction === 'lite') {
title.textContent = `Switch ${displayName} to Lite Wallet Mode`;
message.textContent = 'Please confirm you want to switch to lite wallet mode.';
details.innerHTML = `
<p class="mb-2"><strong>Before switching:</strong></p>
<ul class="list-disc list-inside space-y-1">
<li>Active swaps must be completed first</li>
<li>Wait for any pending transactions to confirm</li>
</ul>
<p class="mt-3 text-green-600 dark:text-green-400">
<strong>Note:</strong> Your balance will remain accessible - same seed means same funds in both modes.
</p>
`;
} else {
title.textContent = `Switch ${displayName} to Full Node Mode`;
message.textContent = 'Please confirm you want to switch to full node mode.';
details.innerHTML = `
<p class="mb-2"><strong>Switching to full node mode:</strong></p>
<ul class="list-disc list-inside space-y-1">
<li>Requires synced ${displayName} blockchain</li>
<li>Your wallet addresses will be synced</li>
<li>Active swaps must be completed first</li>
<li>Restart required after switch</li>
</ul>
<p class="mt-3 text-green-600 dark:text-green-400">
<strong>Note:</strong> Your balance will remain accessible - same seed means same funds in both modes.
</p>
`;
}
modal.classList.remove('hidden');
},
hideWalletModeModal: function() {
const modal = document.getElementById('walletModeModal');
if (modal) {
modal.classList.add('hidden');
}
},
showMigrationModal: function(coinName, direction) {
const modal = document.getElementById('migrationModal');
const title = document.getElementById('migrationTitle');
const message = document.getElementById('migrationMessage');
if (modal && title && message) {
if (direction === 'lite') {
title.textContent = `Migrating ${coinName} to Lite Wallet`;
message.textContent = 'Checking wallet balance and migrating addresses. Please wait...';
} else {
title.textContent = `Switching ${coinName} to Full Node`;
message.textContent = 'Syncing wallet indices. Please wait...';
}
modal.classList.remove('hidden');
}
},
setupConfirmModal: function() { setupConfirmModal: function() {
const confirmYesBtn = document.getElementById('confirmYes'); const confirmYesBtn = document.getElementById('confirmYes');
if (confirmYesBtn) { if (confirmYesBtn) {
@@ -307,6 +449,166 @@
} }
}; };
SettingsPage.setupServerDiscovery = function() {
const discoverBtns = document.querySelectorAll('.discover-servers-btn');
discoverBtns.forEach(btn => {
btn.addEventListener('click', () => {
const coin = btn.dataset.coin;
this.discoverServers(coin, btn);
});
});
const closeBtns = document.querySelectorAll('.close-discovered-btn');
closeBtns.forEach(btn => {
btn.addEventListener('click', () => {
const coin = btn.dataset.coin;
const panel = document.getElementById(`discovered-servers-${coin}`);
if (panel) panel.classList.add('hidden');
});
});
};
SettingsPage.discoverServers = function(coin, button) {
const originalHtml = button.innerHTML;
button.innerHTML = `<svg class="w-3.5 h-3.5 mr-1 animate-spin inline-block" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Discovering...`;
button.disabled = true;
const panel = document.getElementById(`discovered-servers-${coin}`);
const listContainer = document.getElementById(`discovered-list-${coin}`);
fetch('/json/electrumdiscover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coin: coin, ping: true })
})
.then(response => response.json())
.then(data => {
if (data.error) {
listContainer.innerHTML = `<div class="text-sm text-red-500">${data.error}</div>`;
} else {
let html = '';
if (data.current_server) {
html += `
<div class="flex items-center mb-4 p-3 bg-gray-100 dark:bg-gray-600 border border-gray-200 dark:border-gray-500 rounded-lg">
<span class="w-2 h-2 bg-green-500 rounded-full mr-3 animate-pulse"></span>
<span class="text-sm text-gray-900 dark:text-white">
Connected to: <span class="font-mono font-medium">${data.current_server.host}:${data.current_server.port}</span>
</span>
</div>`;
}
if (data.clearnet_servers && data.clearnet_servers.length > 0) {
html += `
<div class="mb-4">
<div class="text-sm font-semibold text-gray-900 dark:text-white mb-2 pb-2 border-b border-gray-200 dark:border-gray-600">
Clearnet
</div>
<div class="space-y-1">`;
data.clearnet_servers.forEach(srv => {
const statusClass = srv.online ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500';
const statusText = srv.online ? (srv.latency_ms ? srv.latency_ms.toFixed(0) + 'ms' : 'online') : 'offline';
const statusDot = srv.online ? 'bg-green-500' : 'bg-gray-400';
html += `
<div class="flex items-center justify-between py-2 px-3 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg cursor-pointer add-server-btn transition-colors border border-transparent hover:border-blue-500"
data-coin="${coin}" data-host="${srv.host}" data-port="${srv.port}" data-type="clearnet">
<div class="flex items-center flex-1 min-w-0">
<svg class="w-4 h-4 mr-2 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
<span class="font-mono text-sm text-gray-900 dark:text-white truncate">${srv.host}:${srv.port}</span>
</div>
<div class="flex items-center ml-3">
<span class="w-2 h-2 ${statusDot} rounded-full mr-2"></span>
<span class="text-xs ${statusClass}">${statusText}</span>
</div>
</div>`;
});
html += `
</div>
</div>`;
}
if (data.onion_servers && data.onion_servers.length > 0) {
html += `
<div class="mb-4">
<div class="text-sm font-semibold text-gray-900 dark:text-white mb-2 pb-2 border-b border-gray-200 dark:border-gray-600">
TOR (.onion)
</div>
<div class="space-y-1">`;
data.onion_servers.forEach(srv => {
const statusClass = srv.online ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500';
const statusText = srv.online ? (srv.latency_ms ? srv.latency_ms.toFixed(0) + 'ms' : 'online') : 'offline';
const statusDot = srv.online ? 'bg-green-500' : 'bg-gray-400';
html += `
<div class="flex items-center justify-between py-2 px-3 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg cursor-pointer add-server-btn transition-colors border border-transparent hover:border-blue-500"
data-coin="${coin}" data-host="${srv.host}" data-port="${srv.port}" data-type="onion">
<div class="flex items-center flex-1 min-w-0">
<svg class="w-4 h-4 mr-2 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
<span class="font-mono text-sm text-gray-900 dark:text-white truncate" title="${srv.host}">${srv.host.substring(0, 24)}...:${srv.port}</span>
</div>
<div class="flex items-center ml-3">
<span class="w-2 h-2 ${statusDot} rounded-full mr-2"></span>
<span class="text-xs ${statusClass}">${statusText}</span>
</div>
</div>`;
});
html += `
</div>
</div>`;
}
if (!data.clearnet_servers?.length && !data.onion_servers?.length) {
html = '<div class="text-sm text-gray-500 dark:text-gray-400 py-4 text-center">No servers discovered. The connected server may not support peer discovery.</div>';
} else {
html += `<div class="text-xs text-gray-500 dark:text-gray-400 pt-3 border-t border-gray-200 dark:border-gray-600">Click a server to add it to your list</div>`;
}
listContainer.innerHTML = html;
listContainer.querySelectorAll('.add-server-btn').forEach(item => {
item.addEventListener('click', () => {
const host = item.dataset.host;
const port = item.dataset.port;
const type = item.dataset.type;
const coinName = item.dataset.coin;
const textareaId = type === 'onion' ?
`electrum_onion_${coinName}` : `electrum_clearnet_${coinName}`;
const textarea = document.getElementById(textareaId);
if (textarea) {
const serverLine = `${host}:${port}`;
const currentValue = textarea.value.trim();
if (currentValue.split('\n').some(line => line.trim() === serverLine)) {
item.classList.add('bg-yellow-100', 'dark:bg-yellow-800/30');
setTimeout(() => item.classList.remove('bg-yellow-100', 'dark:bg-yellow-800/30'), 500);
return;
}
textarea.value = currentValue ? currentValue + '\n' + serverLine : serverLine;
item.classList.add('bg-green-100', 'dark:bg-green-800/30');
setTimeout(() => item.classList.remove('bg-green-100', 'dark:bg-green-800/30'), 500);
}
});
});
}
panel.classList.remove('hidden');
})
.catch(err => {
listContainer.innerHTML = `<div class="text-xs text-red-500">Failed to discover servers: ${err.message}</div>`;
panel.classList.remove('hidden');
})
.finally(() => {
button.innerHTML = originalHtml;
button.disabled = false;
});
};
SettingsPage.cleanup = function() { SettingsPage.cleanup = function() {
}; };

View File

@@ -13,6 +13,7 @@
this.setupWithdrawalConfirmation(); this.setupWithdrawalConfirmation();
this.setupTransactionDisplay(); this.setupTransactionDisplay();
this.setupWebSocketUpdates(); this.setupWebSocketUpdates();
this.setupTransactionPagination();
}, },
setupAddressCopy: function() { setupAddressCopy: function() {
@@ -340,13 +341,289 @@
}, },
handleBalanceUpdate: function(balanceData) { handleBalanceUpdate: function(balanceData) {
if (!balanceData || !Array.isArray(balanceData)) return;
console.log('Balance updated:', balanceData);
const coinId = this.currentCoinId;
if (!coinId) return;
const matchingCoins = balanceData.filter(coin =>
coin.ticker && coin.ticker.toLowerCase() === coinId.toLowerCase()
);
matchingCoins.forEach(coinData => {
const balanceElements = document.querySelectorAll('.coinname-value[data-coinname]');
balanceElements.forEach(element => {
const elementCoinName = element.getAttribute('data-coinname');
if (elementCoinName === coinData.name) {
const currentText = element.textContent;
const ticker = coinData.ticker || coinId.toUpperCase();
const newBalance = `${coinData.balance} ${ticker}`;
if (currentText !== newBalance) {
element.textContent = newBalance;
console.log(`Updated balance: ${coinData.name} -> ${newBalance}`);
}
}
});
this.updatePendingForCoin(coinData);
});
this.refreshTransactions();
},
updatePendingForCoin: function(coinData) {
const pendingAmount = parseFloat(coinData.pending || '0');
const pendingElements = document.querySelectorAll('.inline-block.py-1.px-2.rounded-full.bg-green-100');
pendingElements.forEach(el => {
const text = el.textContent || '';
if (text.includes('Pending:') && text.includes(coinData.ticker)) {
if (pendingAmount > 0) {
el.textContent = `Pending: +${coinData.pending} ${coinData.ticker}`;
el.style.display = '';
} else {
el.style.display = 'none';
}
}
});
},
refreshTransactions: function() {
const txTable = document.querySelector('#transaction-history-section tbody');
if (txTable) {
const pathParts = window.location.pathname.split('/');
const ticker = pathParts[pathParts.length - 1];
fetch(`/json/wallettransactions/${ticker}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_no: 1 })
})
.then(response => response.json())
.then(data => {
if (data.transactions && data.transactions.length > 0) {
const currentPageSpan = document.getElementById('currentPageTx');
const totalPagesSpan = document.getElementById('totalPagesTx');
if (currentPageSpan) currentPageSpan.textContent = data.page_no;
if (totalPagesSpan) totalPagesSpan.textContent = data.total_pages;
}
})
.catch(error => console.error('Error refreshing transactions:', error));
}
}, },
handleSwapEvent: function(eventData) { handleSwapEvent: function(eventData) {
if (window.BalanceUpdatesManager) {
console.log('Swap event:', eventData); window.BalanceUpdatesManager.fetchBalanceData()
.then(data => this.handleBalanceUpdate(data))
.catch(error => console.error('Error updating balance after swap:', error));
}
},
setupTransactionPagination: function() {
const txContainer = document.getElementById('tx-container');
if (!txContainer) return;
const pathParts = window.location.pathname.split('/');
const ticker = pathParts[pathParts.length - 1];
let currentPage = 1;
let totalPages = 1;
let isLoading = false;
const prevBtn = document.getElementById('prevPageTx');
const nextBtn = document.getElementById('nextPageTx');
const currentPageSpan = document.getElementById('currentPageTx');
const totalPagesSpan = document.getElementById('totalPagesTx');
const paginationControls = document.getElementById('tx-pagination-section');
const copyToClipboard = (text, button) => {
const showSuccess = () => {
const originalHTML = button.innerHTML;
button.innerHTML = `<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>`;
setTimeout(() => {
button.innerHTML = originalHTML;
}, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(showSuccess).catch(err => {
console.error('Clipboard API failed:', err);
fallbackCopy(text, showSuccess);
});
} else {
fallbackCopy(text, showSuccess);
}
};
const fallbackCopy = (text, onSuccess) => {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
onSuccess();
} catch (err) {
console.error('Fallback copy failed:', err);
}
document.body.removeChild(textArea);
};
const loadTransactions = async (page) => {
if (isLoading) return;
isLoading = true;
try {
const response = await fetch(`/json/wallettransactions/${ticker}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ page_no: page })
});
const data = await response.json();
if (data.error) {
console.error('Error loading transactions:', data.error);
return;
}
currentPage = data.page_no;
totalPages = data.total_pages;
currentPageSpan.textContent = currentPage;
totalPagesSpan.textContent = totalPages;
txContainer.innerHTML = '';
if (data.transactions && data.transactions.length > 0) {
data.transactions.forEach(tx => {
const card = document.createElement('div');
card.className = 'bg-white dark:bg-gray-600 rounded-lg border border-gray-200 dark:border-gray-500 p-4 hover:shadow-md transition-shadow';
let typeClass = 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
let amountClass = 'text-gray-700 dark:text-gray-200';
let typeIcon = '';
let amountPrefix = '';
if (tx.type === 'Incoming') {
typeClass = 'bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-300';
amountClass = 'text-green-600 dark:text-green-400';
typeIcon = '↓';
amountPrefix = '+';
} else if (tx.type === 'Outgoing') {
typeClass = 'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300';
amountClass = 'text-red-600 dark:text-red-400';
typeIcon = '↑';
amountPrefix = '-';
}
let confirmClass = 'text-gray-600 dark:text-gray-300';
if (tx.confirmations === 0) {
confirmClass = 'text-yellow-600 dark:text-yellow-400 font-medium';
} else if (tx.confirmations >= 1 && tx.confirmations <= 5) {
confirmClass = 'text-blue-600 dark:text-blue-400';
} else if (tx.confirmations >= 6) {
confirmClass = 'text-green-600 dark:text-green-400';
}
card.innerHTML = `
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-3">
<span class="inline-flex items-center gap-1 py-1 px-2 rounded-full text-xs font-semibold ${typeClass}">
${typeIcon} ${tx.type}
</span>
<span class="font-semibold ${amountClass}">
${amountPrefix}${tx.amount} ${ticker.toUpperCase()}
</span>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="${confirmClass}">${tx.confirmations} Confirmations</span>
<span class="text-gray-500 dark:text-gray-400">${tx.timestamp}</span>
</div>
</div>
${tx.address ? `
<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-gray-500 dark:text-gray-400 w-16 flex-shrink-0">Address:</span>
<span class="font-mono text-xs text-gray-700 dark:text-gray-200 break-all flex-1">${tx.address}</span>
<button class="copy-address-btn p-1.5 hover:bg-gray-100 dark:hover:bg-gray-500 rounded flex-shrink-0 focus:outline-none focus:ring-0" title="Copy Address">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
` : ''}
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400 w-16 flex-shrink-0">Txid:</span>
<span class="font-mono text-xs text-gray-700 dark:text-gray-200 break-all flex-1">${tx.txid}</span>
<button class="copy-txid-btn p-1.5 hover:bg-gray-100 dark:hover:bg-gray-500 rounded flex-shrink-0 focus:outline-none focus:ring-0" title="Copy Transaction ID">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
`;
const copyAddressBtn = card.querySelector('.copy-address-btn');
if (copyAddressBtn) {
copyAddressBtn.addEventListener('click', () => copyToClipboard(tx.address, copyAddressBtn));
}
const copyTxidBtn = card.querySelector('.copy-txid-btn');
if (copyTxidBtn) {
copyTxidBtn.addEventListener('click', () => copyToClipboard(tx.txid, copyTxidBtn));
}
txContainer.appendChild(card);
});
if (totalPages > 1 && paginationControls) {
paginationControls.style.display = 'block';
} else if (paginationControls) {
paginationControls.style.display = 'none';
}
} else {
txContainer.innerHTML = '<div class="text-center py-8 text-gray-500 dark:text-gray-400">No transactions found</div>';
if (paginationControls) paginationControls.style.display = 'none';
}
prevBtn.style.display = currentPage > 1 ? 'inline-flex' : 'none';
nextBtn.style.display = currentPage < totalPages ? 'inline-flex' : 'none';
} catch (error) {
console.error('Error fetching transactions:', error);
} finally {
isLoading = false;
}
};
if (prevBtn) {
prevBtn.addEventListener('click', () => {
if (currentPage > 1) {
loadTransactions(currentPage - 1);
}
});
}
if (nextBtn) {
nextBtn.addEventListener('click', () => {
if (currentPage < totalPages) {
loadTransactions(currentPage + 1);
}
});
}
loadTransactions(1);
} }
}; };

View File

@@ -86,6 +86,56 @@
} }
} }
} }
if (coinData.scan_status) {
this.updateScanStatus(coinData);
}
if (coinData.version) {
const versionEl = document.querySelector(`.electrum-version[data-coin="${coinData.name}"]`);
if (versionEl && versionEl.textContent !== coinData.version) {
versionEl.textContent = coinData.version;
}
}
if (coinData.electrum_server) {
const serverEl = document.querySelector(`.electrum-server[data-coin="${coinData.name}"]`);
if (serverEl && serverEl.textContent !== coinData.electrum_server) {
serverEl.textContent = coinData.electrum_server;
}
}
},
updateScanStatus: function(coinData) {
const scanStatusEl = document.querySelector(`.scan-status[data-coin="${coinData.name}"]`);
if (!scanStatusEl) return;
const status = coinData.scan_status;
if (status.in_progress) {
scanStatusEl.innerHTML = `
<div class="flex items-center justify-between text-xs">
<span class="text-blue-600 dark:text-blue-300">
<svg class="inline-block w-3 h-3 mr-1 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Scanning ${status.status}
</span>
<span class="text-blue-500 dark:text-blue-200 font-medium">${status.progress}%</span>
</div>
<div class="w-full bg-blue-200 dark:bg-gray-700 rounded-full h-1 mt-1">
<div class="bg-blue-600 dark:bg-blue-400 h-1 rounded-full transition-all" style="width: ${status.progress}%"></div>
</div>
`;
} else {
scanStatusEl.innerHTML = `
<div class="flex items-center text-xs text-green-600 dark:text-green-400">
<svg class="inline-block w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Electrum Wallet Synced
</div>
`;
}
}, },
updateSpecificBalance: function(coinName, labelText, balance, ticker, isPending = false) { updateSpecificBalance: function(coinName, labelText, balance, ticker, isPending = false) {
@@ -102,12 +152,13 @@
const currentLabel = labelElement.textContent.trim(); const currentLabel = labelElement.textContent.trim();
if (currentLabel === labelText) { if (currentLabel === labelText) {
const cleanBalance = balance.toString().replace(/^\+/, '');
if (isPending) { if (isPending) {
const cleanBalance = balance.toString().replace(/^\+/, '');
element.textContent = `+${cleanBalance} ${ticker}`; element.textContent = `+${cleanBalance} ${ticker}`;
} else { } else {
element.textContent = `${balance} ${ticker}`; element.textContent = `${balance} ${ticker}`;
} }
element.setAttribute('data-original-value', `${cleanBalance} ${ticker}`);
} }
} }
} }
@@ -139,6 +190,7 @@
if (pendingSpan) { if (pendingSpan) {
const cleanPending = coinData.pending.toString().replace(/^\+/, ''); const cleanPending = coinData.pending.toString().replace(/^\+/, '');
pendingSpan.textContent = `+${cleanPending} ${coinData.ticker || coinData.name}`; pendingSpan.textContent = `+${cleanPending} ${coinData.ticker || coinData.name}`;
pendingSpan.setAttribute('data-original-value', `+${cleanPending} ${coinData.ticker || coinData.name}`);
} }
let initialUSD = '$0.00'; let initialUSD = '$0.00';
@@ -218,7 +270,7 @@
const balanceElements = document.querySelectorAll('.coinname-value[data-coinname]'); const balanceElements = document.querySelectorAll('.coinname-value[data-coinname]');
for (const element of balanceElements) { for (const element of balanceElements) {
if (element.getAttribute('data-coinname') === coinName) { if (element.getAttribute('data-coinname') === coinName) {
return element.closest('.bg-white, .dark\\:bg-gray-500'); return element.closest('.bg-gray-50, .dark\\:bg-gray-500');
} }
} }
return null; return null;
@@ -330,6 +382,7 @@
if (pendingSpan) { if (pendingSpan) {
const cleanPending = pendingAmount.toString().replace(/^\+/, ''); const cleanPending = pendingAmount.toString().replace(/^\+/, '');
pendingSpan.textContent = `+${cleanPending} ${ticker}`; pendingSpan.textContent = `+${cleanPending} ${ticker}`;
pendingSpan.setAttribute('data-original-value', `+${cleanPending} ${ticker}`);
} }
} }
} }

View File

@@ -525,14 +525,14 @@
</div> </div>
{% if data.can_abandon == true and not edit_bid %} {% if data.can_abandon == true and not edit_bid %}
<div class="w-full md:w-auto p-1.5"> <div class="w-full md:w-auto p-1.5">
<button name="abandon_bid" type="submit" value="Abandon Bid" data-confirm class="flex flex-wrap justify-center w-full px-4 py-2.5 font-medium text-sm text-white hover:text-red border border-red-500 hover:border-red-500 hover:bg-red-600 bg-red-500 rounded-md shadow-button focus:ring-0 focus:outline-none">Abandon Bid</button> <button name="abandon_bid" type="submit" value="Abandon Bid" class="flex flex-wrap justify-center w-full px-4 py-2.5 font-medium text-sm text-white hover:text-red border border-red-500 hover:border-red-500 hover:bg-red-600 bg-red-500 rounded-md shadow-button focus:ring-0 focus:outline-none">Abandon Bid</button>
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if data.was_received and not edit_bid and data.can_accept_bid %} {% if data.was_received and not edit_bid and data.can_accept_bid %}
<div class="w-full md:w-auto p-1.5"> <div class="w-full md:w-auto p-1.5">
<button name="accept_bid" value="Accept Bid" type="submit" data-confirm data-confirm-action="Accept" class="flex flex-wrap justify-center w-full px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none">Accept Bid</button> <button name="accept_bid" value="Accept Bid" type="submit" class="flex flex-wrap justify-center w-full px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none">Accept Bid</button>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -689,6 +689,12 @@ document.addEventListener('DOMContentLoaded', function() {
overrideButtonConfirm(acceptBidBtn, 'Accept'); overrideButtonConfirm(acceptBidBtn, 'Accept');
}); });
</script> </script>
<script src="/static/js/pages/bid-page.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
BidPage.init('{{ bid_id }}', {{ data.bid_state_ind }}, {{ data.created_at_timestamp }}, {{ data.state_time_timestamp or 'null' }});
});
</script>
</div> </div>
{% include 'footer.html' %} {% include 'footer.html' %}
</body> </body>

View File

@@ -801,14 +801,14 @@
</div> </div>
{% if data.can_abandon == true %} {% if data.can_abandon == true %}
<div class="w-full md:w-auto p-1.5"> <div class="w-full md:w-auto p-1.5">
<button name="abandon_bid" type="submit" value="Abandon Bid" data-confirm class="flex flex-wrap justify-center w-full px-4 py-2.5 font-medium text-sm text-white hover:text-red border border-red-500 hover:border-red-500 hover:bg-red-600 bg-red-500 rounded-md focus:ring-0 focus:outline-none">Abandon Bid</button> <button name="abandon_bid" type="submit" value="Abandon Bid" class="flex flex-wrap justify-center w-full px-4 py-2.5 font-medium text-sm text-white hover:text-red border border-red-500 hover:border-red-500 hover:bg-red-600 bg-red-500 rounded-md focus:ring-0 focus:outline-none">Abandon Bid</button>
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if data.was_received and not edit_bid and data.can_accept_bid %} {% if data.was_received and not edit_bid and data.can_accept_bid %}
<div class="w-full md:w-auto p-1.5"> <div class="w-full md:w-auto p-1.5">
<button name="accept_bid" value="Accept Bid" type="submit" data-confirm data-confirm-action="Accept" class="flex flex-wrap justify-center w-full px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md focus:ring-0 focus:outline-none">Accept Bid</button> <button name="accept_bid" value="Accept Bid" type="submit" class="flex flex-wrap justify-center w-full px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md focus:ring-0 focus:outline-none">Accept Bid</button>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -965,6 +965,12 @@ document.addEventListener('DOMContentLoaded', function() {
overrideButtonConfirm(acceptBidBtn, 'Accept'); overrideButtonConfirm(acceptBidBtn, 'Accept');
}); });
</script> </script>
<script src="/static/js/pages/bid-page.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
BidPage.init('{{ bid_id }}', {{ data.bid_state_ind }}, {{ data.created_at_timestamp }}, {{ data.state_time_timestamp or 'null' }});
});
</script>
</div> </div>
{% include 'footer.html' %} {% include 'footer.html' %}
</body> </body>

View File

@@ -26,7 +26,7 @@
<div class="flex items-center"> <div class="flex items-center">
<p class="text-sm text-gray-90 dark:text-white font-medium">© 2025~ (BSX) BasicSwap</p> <span class="w-1 h-1 mx-1.5 bg-gray-500 dark:bg-white rounded-full"></span> <p class="text-sm text-gray-90 dark:text-white font-medium">© 2025~ (BSX) BasicSwap</p> <span class="w-1 h-1 mx-1.5 bg-gray-500 dark:bg-white rounded-full"></span>
<p class="text-sm text-coolGray-400 font-medium">BSX: v{{ version }}</p> <span class="w-1 h-1 mx-1.5 bg-gray-500 dark:bg-white rounded-full"></span> <p class="text-sm text-coolGray-400 font-medium">BSX: v{{ version }}</p> <span class="w-1 h-1 mx-1.5 bg-gray-500 dark:bg-white rounded-full"></span>
<p class="text-sm text-coolGray-400 font-medium">GUI: v3.3.1</p> <span class="w-1 h-1 mx-1.5 bg-gray-500 dark:bg-white rounded-full"></span> <p class="text-sm text-coolGray-400 font-medium">GUI: v3.4.0</p> <span class="w-1 h-1 mx-1.5 bg-gray-500 dark:bg-white rounded-full"></span>
<p class="mr-2 text-sm font-bold dark:text-white text-gray-90 ">Made with </p> <p class="mr-2 text-sm font-bold dark:text-white text-gray-90 ">Made with </p>
{{ love_svg | safe }} {{ love_svg | safe }}
</div> </div>

View File

@@ -105,17 +105,31 @@
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4"> <h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">
Connection Connection
</h4> </h4>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4"> <div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connection Type</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connection Type</label>
{% if c.supports_electrum %}
<div class="relative">
<select class="hover:border-blue-500 bg-gray-50 text-gray-900 appearance-none dark:bg-gray-700 dark:text-white border border-gray-300 dark:border-gray-600 dark:placeholder-gray-400 text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 focus:ring-1 focus:outline-none" name="connection_type_{{ c.name }}" data-original-value="{{ c.connection_type }}">
<option value="rpc" {% if c.connection_type == 'rpc' %} selected{% endif %}>Full Node (RPC)</option>
<option value="electrum" {% if c.connection_type == 'electrum' %} selected{% endif %}>Light Wallet (Electrum)</option>
</select>
<div class="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
{% else %}
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg text-sm text-gray-900 dark:text-gray-100"> <div class="px-3 py-2 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg text-sm text-gray-900 dark:text-gray-100">
{{ c.connection_type }} {{ c.connection_type }}
</div> </div>
{% endif %}
</div> </div>
{% if c.manage_daemon is defined %} {% if c.manage_daemon is defined %}
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Manage Daemon</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Manage Daemon</label>
@@ -138,12 +152,164 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if c.supports_electrum %}
<div class="mb-6">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">
Mode Information
</h4>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 space-y-4">
<div>
<p class="text-xs font-medium text-gray-900 dark:text-white mb-1">To Light Wallet:</p>
<ul class="text-xs text-gray-700 dark:text-gray-200 space-y-0.5 ml-3">
<li>• Your full node stops running</li>
<li>• Light wallet uses your seed to access existing funds</li>
<li>• No transfer needed - same seed, same funds</li>
</ul>
</div>
<div>
<p class="text-xs font-medium text-gray-900 dark:text-white mb-1">While in Light Wallet mode:</p>
<ul class="text-xs text-gray-700 dark:text-gray-200 space-y-0.5 ml-3">
<li>• Light wallet generates NEW addresses (BIP84 format: bc1q.../ltc1q...)</li>
<li>• Any funds you RECEIVE go to these new addresses</li>
<li>• Your full node doesn't know about these addresses</li>
</ul>
</div>
<div>
<p class="text-xs font-medium text-gray-900 dark:text-white mb-1">To Full Node:</p>
<ul class="text-xs text-gray-700 dark:text-gray-200 space-y-0.5 ml-3">
<li>• Full node can't see funds on light wallet addresses</li>
<li>• These funds must be SENT back to your node wallet (real transaction, network fee applies based on current rate)</li>
<li>• Enable "Auto-transfer" in Fund Transfer section to do this automatically on unlock</li>
</ul>
</div>
<div class="pt-2 border-t border-gray-200 dark:border-gray-600">
<p class="text-xs text-red-600 dark:text-red-400"><strong>Active Swaps:</strong> Complete all swaps before switching modes.</p>
{% if c.name == 'litecoin' %}
<p class="text-xs text-gray-700 dark:text-gray-200 mt-1"><strong>MWEB:</strong> Not supported in light wallet mode.</p>
{% endif %}
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1"><strong>If TOR enabled:</strong> Electrum connections routed through TOR.</p>
</div>
</div>
</div>
{% if c.connection_type == 'electrum' %}
<div class="mb-6">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">
Electrum Servers
</h4>
<div class="mb-6">
<h5 class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-3">Clearnet</h5>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<textarea class="hover:border-blue-500 bg-gray-50 text-gray-900 appearance-none dark:bg-gray-700 dark:text-white border border-gray-300 dark:border-gray-600 dark:placeholder-gray-400 text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 focus:ring-1 focus:outline-none font-mono" name="electrum_clearnet_{{ c.name }}" id="electrum_clearnet_{{ c.name }}" rows="3" placeholder="electrum.blockstream.info:50002&#10;electrum.emzy.de:50002">{{ c.clearnet_servers_text }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">One per line. Format: host:port (50002=SSL, 50001=non-SSL)</p>
</div>
</div>
<div class="mb-4 pt-2">
<h5 class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-3">TOR (.onion)</h5>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<textarea class="hover:border-blue-500 bg-gray-50 text-gray-900 appearance-none dark:bg-gray-700 dark:text-white border border-gray-300 dark:border-gray-600 dark:placeholder-gray-400 text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 focus:ring-1 focus:outline-none font-mono text-xs" name="electrum_onion_{{ c.name }}" id="electrum_onion_{{ c.name }}" rows="3" placeholder="explorerzyd...onion:110&#10;lksvbmwwi2b...onion:50001">{{ c.onion_servers_text }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">One per line. Used when TOR is enabled.</p>
</div>
</div>
<!-- Discover Servers Button -->
<div class="mb-4 flex justify-end">
<button type="button" class="discover-servers-btn inline-flex items-center px-3 py-1.5 text-xs font-medium text-white bg-blue-500 hover:bg-blue-600 rounded-md transition-colors focus:outline-none focus:ring-0" data-coin="{{ c.name }}">
Discover {{ c.name }} electrum servers
</button>
</div>
<!-- Discovered Servers Panel -->
<div id="discovered-servers-{{ c.name }}" class="hidden mb-4">
<div class="bg-gray-50 dark:bg-gray-700 rounded-xl overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<h5 class="text-sm font-semibold text-gray-900 dark:text-white">
Discovered Servers
</h5>
<button type="button" class="close-discovered-btn text-gray-400 hover:text-gray-600 dark:text-gray-300 dark:hover:text-white transition-colors" data-coin="{{ c.name }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="px-6 py-4">
<div id="discovered-list-{{ c.name }}" class="space-y-1 max-h-64 overflow-y-auto">
<div class="text-sm text-gray-500 dark:text-gray-400">Click "Discover Servers" to find available servers...</div>
</div>
</div>
</div>
</div>
</div>
<div class="mb-6">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">
Fund Transfer
</h4>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
{% if c.lite_wallet_balance %}
<div class="mb-4 p-3 bg-orange-50 dark:bg-orange-900/30 border border-orange-200 dark:border-orange-700 rounded-lg">
<p class="text-sm font-medium text-orange-800 dark:text-orange-200 mb-2">
<svg class="inline w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>
Light Wallet Balance Detected
</p>
<div class="text-xs text-orange-700 dark:text-orange-300 space-y-1">
<p><strong>Confirmed:</strong> {{ "%.8f"|format(c.lite_wallet_balance.confirmed) }} {{ c.display_name }}</p>
{% if c.lite_wallet_balance.unconfirmed > 0 %}
<p><strong>Unconfirmed:</strong> {{ "%.8f"|format(c.lite_wallet_balance.unconfirmed) }} {{ c.display_name }}</p>
{% endif %}
<p class="text-xs text-orange-600 dark:text-orange-400 mt-2">
{% if c.lite_wallet_balance.is_pending_sweep %}
<span class="inline-flex items-center"><svg class="animate-spin h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Sweep pending - waiting for confirmations...</span>
{% else %}
These funds will be swept to your RPC wallet automatically.
{% endif %}
</p>
{% if c.lite_wallet_balance.confirmed > 0 %}
<div class="mt-3">
<button type="submit" name="force_sweep_{{ c.name }}" value="1" class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-white bg-orange-600 hover:bg-orange-700 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 dark:focus:ring-offset-gray-800" onclick="return confirm('Sweep {{ '%.8f'|format(c.lite_wallet_balance.confirmed) }} {{ c.display_name }} to your RPC wallet now? Network fee will apply.');">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
Force Sweep Now
</button>
</div>
{% endif %}
</div>
</div>
{% endif %}
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="auto_transfer_{{ c.name }}" value="true" {% if c.auto_transfer_on_mode_switch != false %}checked{% endif %} class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<span class="ml-2 text-sm font-medium text-red-600 dark:text-red-400">Auto-transfer funds when switching to Full Node</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2 ml-6">Funds in light wallet addresses will be swept to your RPC wallet after switching. Network fee applies based on current rate.</p>
{% if general_settings.debug %}
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address Gap Limit</label>
<div class="flex items-center">
<input type="number" name="gap_limit_{{ c.name }}" value="{{ c.address_gap_limit }}" min="5" max="100" class="hover:border-blue-500 bg-gray-50 text-gray-900 appearance-none dark:bg-gray-700 dark:text-white border border-gray-300 dark:border-gray-600 dark:placeholder-gray-400 text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-24 p-2.5 focus:ring-1 focus:outline-none" placeholder="20">
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(5-100)</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Number of consecutive unfunded addresses to scan. Increase if you generated many unused addresses.</p>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% endif %}
{% if c.name in ('wownero', 'monero') %} {% if c.name in ('wownero', 'monero') %}
<div class="mb-6"> <div class="mb-6">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4"> <h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">
@@ -656,6 +822,53 @@
</div> </div>
</div> </div>
<div id="migrationModal" class="fixed inset-0 z-50 hidden overflow-y-auto">
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity duration-300 ease-out"></div>
<div class="relative z-50 min-h-screen px-4 flex items-center justify-center">
<div class="bg-white dark:bg-gray-500 rounded-lg max-w-md w-full p-6 shadow-lg">
<div class="text-center">
<div class="flex justify-center mb-4">
<svg class="animate-spin h-12 w-12 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2" id="migrationTitle">Migrating Wallet</h2>
<p class="text-gray-600 dark:text-gray-200" id="migrationMessage">Extracting addresses from wallet. Please wait...</p>
</div>
</div>
</div>
</div>
<div id="walletModeModal" class="fixed inset-0 z-50 hidden overflow-y-auto">
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity duration-300 ease-out"></div>
<div class="relative z-50 min-h-screen px-4 flex items-center justify-center">
<div class="bg-white dark:bg-gray-500 rounded-lg max-w-lg w-full p-6 shadow-lg">
<div class="text-center">
<div class="flex justify-center mb-4">
<svg class="h-12 w-12 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4" id="walletModeTitle">Switch Wallet Mode</h2>
<p class="text-gray-600 dark:text-gray-200 mb-4" id="walletModeMessage">Are you sure you want to switch wallet modes?</p>
<div id="walletModeDetails" class="text-left bg-gray-100 dark:bg-gray-600 rounded-lg p-4 mb-4 text-sm text-gray-700 dark:text-gray-200">
</div>
<div class="flex justify-center gap-4">
<button type="button" id="walletModeConfirm"
class="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none">
Switch Mode
</button>
<button type="button" id="walletModeCancel"
class="px-4 py-2.5 bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-700 font-medium text-sm text-gray-700 dark:text-white rounded-md focus:ring-0 focus:outline-none">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/pages/settings-page.js"></script> <script src="/static/js/pages/settings-page.js"></script>
{% include 'footer.html' %} {% include 'footer.html' %}

View File

@@ -101,7 +101,7 @@
</svg> </svg>
</button> </button>
</div> </div>
<div id="caps-warning" class="hidden mt-2 text-sm text-amber-700 dark:text-amber-300 flex items-center"> <div id="caps-warning" class="hidden mt-2 text-sm text-red-600 dark:text-white flex items-center">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path> <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg> </svg>

View File

@@ -111,6 +111,9 @@
{% if w.pending %} {% if w.pending %}
<span class="inline-block py-1 px-2 rounded-full bg-green-100 text-green-500 dark:bg-gray-500 dark:text-green-500">Pending: +{{ w.pending }} {{ w.ticker }} </span> <span class="inline-block py-1 px-2 rounded-full bg-green-100 text-green-500 dark:bg-gray-500 dark:text-green-500">Pending: +{{ w.pending }} {{ w.ticker }} </span>
{% endif %} {% endif %}
{% if w.pending_out %}
<span class="inline-block py-1 px-2 rounded-full bg-yellow-100 text-yellow-600 dark:bg-gray-500 dark:text-yellow-400">Unconfirmed: -{{ w.pending_out }} {{ w.ticker }} </span>
{% endif %}
</td> </td>
</tr> </tr>
{% if w.cid == '1' %} {# PART #} {% if w.cid == '1' %} {# PART #}
@@ -139,11 +142,15 @@
<tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600"> <tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600">
<td class="py-3 px-6 bold"> <span class="inline-flex align-middle items-center justify-center w-9 h-10 bg-white-50 rounded"> <img class="h-7" src="/static/images/coins/{{ w.name }}.png" alt="{{ w.name }} MWEB"> </span>MWEB Balance: </td> <td class="py-3 px-6 bold"> <span class="inline-flex align-middle items-center justify-center w-9 h-10 bg-white-50 rounded"> <img class="h-7" src="/static/images/coins/{{ w.name }}.png" alt="{{ w.name }} MWEB"> </span>MWEB Balance: </td>
<td class="py-3 px-6 bold"> <td class="py-3 px-6 bold">
{% if is_electrum_mode %}
<span class="text-gray-400 dark:text-gray-300">Not available in light mode</span>
{% else %}
<span class="coinname-value" data-coinname="{{ w.name }}">{{ w.mweb_balance }} {{ w.ticker }}</span> <span class="coinname-value" data-coinname="{{ w.name }}">{{ w.mweb_balance }} {{ w.ticker }}</span>
(<span class="usd-value"></span>) (<span class="usd-value"></span>)
{% if w.mweb_pending %} {% if w.mweb_pending %}
<span class="inline-block py-1 px-2 rounded-full bg-green-100 text-green-500 dark:bg-gray-500 dark:text-green-500">Pending: +{{ w.mweb_pending }} {{ w.ticker }} </span> <span class="inline-block py-1 px-2 rounded-full bg-green-100 text-green-500 dark:bg-gray-500 dark:text-green-500">Pending: +{{ w.mweb_pending }} {{ w.ticker }} </span>
{% endif %} {% endif %}
{% endif %}
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
@@ -163,6 +170,19 @@
<td class="py-3 px-6 bold">{{ w.name }} Version:</td> <td class="py-3 px-6 bold">{{ w.name }} Version:</td>
<td class="py-3 px-6">{{ w.version }}</td> <td class="py-3 px-6">{{ w.version }}</td>
</tr> </tr>
<tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600">
<td class="py-3 px-6 bold">Wallet Mode:</td>
<td class="py-3 px-6">
{% if w.connection_type == 'electrum' %}
<span class="inline-block py-1 px-2 rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300">Light Wallet (Electrum)</span>
{% else %}
<span class="inline-block py-1 px-2 rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">Full Node</span>
{% endif %}
{% if use_tor %}
<span class="inline-block py-1 px-2 ml-2 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300" title="Electrum connections routed through TOR">TOR</span>
{% endif %}
</td>
</tr>
<tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600"> <tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600">
<td class="py-3 px-6 bold">Blockheight:</td> <td class="py-3 px-6 bold">Blockheight:</td>
<td class="py-3 px-6">{{ w.blocks }} <td class="py-3 px-6">{{ w.blocks }}
@@ -178,8 +198,66 @@
{% endif %} {% endif %}
<tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600"> <tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600">
<td class="py-3 px-6 bold">Synced:</td> <td class="py-3 px-6 bold">Synced:</td>
<td class="py-3 px-6">{{ w.synced }}</td> <td class="py-3 px-6">
{% if is_electrum_mode %}
<span class="text-green-600 dark:text-green-400">Electrum Wallet Synced</span>
{% else %}
{{ w.synced }}
{% endif %}
</td>
</tr> </tr>
{% if is_electrum_mode and w.electrum_server %}
<tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600">
<td class="py-3 px-6 bold">Server:</td>
<td class="py-3 px-6 font-mono text-sm">
{{ w.electrum_server }}
{% if w.electrum_status == 'connected' %}
<span class="ml-2 inline-flex items-center">
<span class="text-xs text-green-600 dark:text-green-400">(Connected)</span>
</span>
{% elif w.electrum_status == 'all_failed' %}
<span class="ml-2 inline-flex items-center">
<span class="text-xs text-red-600 dark:text-red-400">(All Servers Failed)</span>
</span>
{% elif w.electrum_status == 'disconnected' %}
<span class="ml-2 inline-flex items-center">
<span class="text-xs text-red-600 dark:text-red-400">(Disconnected - Reconnecting...)</span>
</span>
{% elif w.electrum_status == 'error' %}
<span class="ml-2 inline-flex items-center">
<span class="w-2 h-2 rounded-full bg-yellow-500 mr-1"></span>
<span class="text-xs text-yellow-600 dark:text-yellow-400">(Connection Error)</span>
</span>
{% endif %}
</td>
</tr>
{% endif %}
{% if is_electrum_mode and w.electrum_all_failed and w.electrum_using_defaults %}
<tr class="opacity-100 text-gray-500 dark:text-gray-100">
<td colspan="2" class="py-3 px-6">
<div class="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-lg">
<p class="text-sm font-medium text-red-800 dark:text-red-200 mb-1">
<svg class="inline w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>
Default Electrum Servers Unavailable
</p>
<p class="text-xs text-red-700 dark:text-red-300">
All default servers failed to connect. Please configure custom Electrum servers in
<a href="/settings" class="underline font-medium hover:text-red-900 dark:hover:text-red-100">Settings</a>
under the {{ w.name }} section.
</p>
{% if w.electrum_last_error %}
<p class="text-xs text-red-600 dark:text-red-400 mt-1 font-mono">Last error: {{ w.electrum_last_error }}</p>
{% endif %}
</div>
</td>
</tr>
{% endif %}
{% if is_electrum_mode and w.electrum_version %}
<tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600">
<td class="py-3 px-6 bold">Server Version:</td>
<td class="py-3 px-6">{{ w.electrum_version }}</td>
</tr>
{% endif %}
{% if w.bootstrapping %} {% if w.bootstrapping %}
<tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600"> <tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600">
<td class="py-3 px-6 bold">Bootstrapping:</td> <td class="py-3 px-6 bold">Bootstrapping:</td>
@@ -319,13 +397,14 @@
</div> </div>
{# / PART #} {# / PART #}
{% elif w.cid == '3' %} {% elif w.cid == '3' %}
{# LTC #} {# LTC - MWEB not available in light mode #}
<div id="qrcode-mweb" class="qrcode" data-qrcode data-address="{{ w.mweb_address }}"> </div> {% if not is_electrum_mode %}
<div id="qrcode-mweb" class="qrcode" data-qrcode data-address="{{ w.mweb_address }}"> </div>
</div> </div>
</div> </div>
<div class="font-normal bold text-gray-500 text-center dark:text-white mb-5">MWEB Address: </div> <div class="font-normal bold text-gray-500 text-center dark:text-white mb-5">MWEB Address: </div>
<div class="text-center relative"> <div class="text-center relative">
<div class="input-like-container hover:border-blue-500 bg-gray-50 text-gray-900 appearance-none pr-10 dark:bg-gray-500 dark:text-white border border-gray-300 dark:border-gray-400 dark:text-gray-50 dark:placeholder-gray-400 text-lg lg:text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 focus:ring-0" id="stealth_address">{{ w.mweb_address }}</div> <div class="input-like-container hover:border-blue-500 bg-gray-50 text-gray-900 appearance-none pr-10 dark:bg-gray-500 dark:text-white border border-gray-300 dark:border-gray-400 dark:text-gray-50 dark:placeholder-gray-400 text-lg lg:text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 focus:ring-0" id="mweb_address">{{ w.mweb_address }}</div>
<span class="absolute inset-y-0 right-0 flex items-center pr-3 cursor-pointer" id="copyIcon"></span> <span class="absolute inset-y-0 right-0 flex items-center pr-3 cursor-pointer" id="copyIcon"></span>
</div> </div>
<div class="opacity-100 text-gray-500 dark:text-gray-100 flex justify-center items-center"> <div class="opacity-100 text-gray-500 dark:text-gray-100 flex justify-center items-center">
@@ -333,6 +412,7 @@
<button type="submit" class="flex justify-center py-2 px-4 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none" name="newmwebaddr_{{ w.cid }}" value="New MWEB Address"> {{ circular_arrows_svg }} New MWEB Address </button> <button type="submit" class="flex justify-center py-2 px-4 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none" name="newmwebaddr_{{ w.cid }}" value="New MWEB Address"> {{ circular_arrows_svg }} New MWEB Address </button>
</div> </div>
</div> </div>
{% endif %}
{# / LTC #} {# / LTC #}
{% endif %} {% endif %}
</div> </div>
@@ -349,10 +429,6 @@
</div> </div>
</section> </section>
<!-- Address copy functionality handled by external JS -->
<section class="p-6"> <section class="p-6">
<div class="lg:container mx-auto"> <div class="lg:container mx-auto">
<div class="flex items-center"> <div class="flex items-center">
@@ -393,8 +469,12 @@
<tr class="opacity-100 text-gray-500 dark:text-gray-100"> <tr class="opacity-100 text-gray-500 dark:text-gray-100">
<td class="py-4 pl-6 bold w-1/4"> <span class="inline-flex align-middle items-center justify-center w-9 h-10 bg-white-50 rounded"> <img class="h-7" src="/static/images/coins/{{ w.name }}.png" alt="{{ w.name }}"> </span>MWEB Balance: </td> <td class="py-4 pl-6 bold w-1/4"> <span class="inline-flex align-middle items-center justify-center w-9 h-10 bg-white-50 rounded"> <img class="h-7" src="/static/images/coins/{{ w.name }}.png" alt="{{ w.name }}"> </span>MWEB Balance: </td>
<td class="py-3 px-6"> <td class="py-3 px-6">
{% if is_electrum_mode %}
<span class="text-gray-400 dark:text-gray-300">Not available in light mode</span>
{% else %}
<span class="coinname-value" data-coinname="{{ w.name }}">{{ w.mweb_balance }} {{ w.ticker }}</span> <span class="coinname-value" data-coinname="{{ w.name }}">{{ w.mweb_balance }} {{ w.ticker }}</span>
(<span class="usd-value"></span>) (<span class="usd-value"></span>)
{% endif %}
</td> </td>
</tr> </tr>
{% elif w.cid == '1' %} {% elif w.cid == '1' %}
@@ -541,7 +621,7 @@
</td> </td>
</tr> </tr>
{# / PART #} {# / PART #}
{% elif w.cid == '3' %} {# LTC #} {% elif w.cid == '3' and not is_electrum_mode %} {# LTC - only show in full node mode #}
<tr class="opacity-100 text-gray-500 dark:text-gray-100"> <tr class="opacity-100 text-gray-500 dark:text-gray-100">
<td class="py-3 px-6 bold">Type From:</td> <td class="py-3 px-6 bold">Type From:</td>
<td class="py-3 px-6"> <td class="py-3 px-6">
@@ -593,6 +673,8 @@
<div class="w-full md:w-auto p-1.5 mx-1"> <button type="submit" class="flex flex-wrap justify-center w-full px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-lg lg:text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none" name="estfee_{{ w.cid }}" value="Estimate Fee">Estimate {{ w.ticker }} Fee </button> </div> <div class="w-full md:w-auto p-1.5 mx-1"> <button type="submit" class="flex flex-wrap justify-center w-full px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-lg lg:text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none" name="estfee_{{ w.cid }}" value="Estimate Fee">Estimate {{ w.ticker }} Fee </button> </div>
{# / XMR | WOW #} {# / XMR | WOW #}
{% elif w.show_utxo_groups %} {% elif w.show_utxo_groups %}
{% elif is_electrum_mode %}
{# Hide UTXO Groups button in electrum/lite wallet mode #}
{% else %} {% else %}
<div class="w-full md:w-auto p-1.5 mx-1"> <button type="submit" class="flex flex-wrap justify-center w-full px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-lg lg:text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none" id="showutxogroups" name="showutxogroups" value="Show UTXO Groups"> {{ utxo_groups_svg | safe }} Show UTXO Groups </button> </div> <div class="w-full md:w-auto p-1.5 mx-1"> <button type="submit" class="flex flex-wrap justify-center w-full px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-lg lg:text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none" id="showutxogroups" name="showutxogroups" value="Show UTXO Groups"> {{ utxo_groups_svg | safe }} Show UTXO Groups </button> </div>
{% endif %} {% endif %}
@@ -680,6 +762,96 @@
<input type="hidden" name="formid" value="{{ form_id }}"> <input type="hidden" name="formid" value="{{ form_id }}">
</form> </form>
{% if w.havedata and not w.error %}
<section class="p-6">
<div class="lg:container mx-auto">
<div class="flex items-center">
<h4 class="font-semibold text-2xl text-black dark:text-white">Transaction History</h4>
</div>
</div>
</section>
<section>
<div class="px-6 py-0 h-full overflow-hidden">
<div class="border-coolGray-100">
<div class="flex flex-wrap items-center justify-between -m-2">
<div class="w-full pt-2">
<div class="lg:container mt-5 mx-auto">
<div id="transaction-history-section" class="pt-6 pb-8 bg-coolGray-100 dark:bg-gray-500 rounded-xl">
<div class="px-6">
{% if is_electrum_mode %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
Transaction history is not available in Light Wallet mode.
</div>
{% else %}
<div id="tx-container" class="space-y-3 pb-6">
<div class="text-center py-8 text-gray-500 dark:text-gray-400">Loading transactions...</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{% if not is_electrum_mode %}
<section id="tx-pagination-section" style="display: none;">
<div class="px-6 py-0 h-full overflow-hidden">
<div class="pb-6">
<div class="flex flex-wrap items-center justify-between -m-2">
<div class="w-full pt-2">
<div class="lg:container mx-auto">
<div class="py-6 bg-coolGray-100 border-t border-gray-100 dark:border-gray-400 dark:bg-gray-500 rounded-bl-xl rounded-br-xl">
<div class="px-6">
<div class="flex flex-wrap justify-end items-center space-x-4">
<button id="prevPageTx" class="inline-flex items-center h-9 py-1 px-4 text-xs text-blue-50 font-semibold bg-blue-500 hover:bg-green-600 rounded-md transition duration-200 focus:ring-0 focus:outline-none">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Previous
</button>
<p class="text-sm font-heading dark:text-white">
Page <span id="currentPageTx">1</span> of <span id="totalPagesTx">1</span>
</p>
<button id="nextPageTx" class="inline-flex items-center h-9 py-1 px-4 text-xs text-blue-50 font-semibold bg-blue-500 hover:bg-green-600 rounded-md transition duration-200 focus:ring-0 focus:outline-none">
Next
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{% endif %}
{% if is_electrum_mode %}
<section id="tx-pagination-section">
<div class="px-6 py-0 h-full overflow-hidden">
<div class="pb-6">
<div class="flex flex-wrap items-center justify-between -m-2">
<div class="w-full pt-2">
<div class="lg:container mx-auto">
<div class="py-6 bg-coolGray-100 border-t border-gray-100 dark:border-gray-400 dark:bg-gray-500 rounded-bl-xl rounded-br-xl">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{% endif %}
{% endif %}
<div id="confirmModal" class="fixed inset-0 z-50 hidden overflow-y-auto"> <div id="confirmModal" class="fixed inset-0 z-50 hidden overflow-y-auto">
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity duration-300 ease-out"></div> <div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity duration-300 ease-out"></div>
<div class="relative z-50 min-h-screen px-4 flex items-center justify-center"> <div class="relative z-50 min-h-screen px-4 flex items-center justify-center">

View File

@@ -48,8 +48,18 @@
<div class="px-6 mb-6"> <div class="px-6 mb-6">
<h4 class="text-xl font-bold dark:text-white">{{ w.name }} <h4 class="text-xl font-bold dark:text-white">{{ w.name }}
<span class="inline-block font-medium text-xs text-gray-500 dark:text-white">({{ w.ticker }})</span> <span class="inline-block font-medium text-xs text-gray-500 dark:text-white">({{ w.ticker }})</span>
{% if w.connection_type == 'electrum' %}
<span class="inline-block py-1 px-2 rounded-full bg-yellow-100 text-xs text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300">Light Wallet (Electrum)</span>
{% else %}
<span class="inline-block py-1 px-2 rounded-full bg-green-100 text-xs text-green-700 dark:bg-green-900 dark:text-green-300">Full Node</span>
{% endif %}
{% if use_tor %}
<span class="inline-block py-1 px-2 rounded-full bg-purple-100 text-xs text-purple-700 dark:bg-purple-900 dark:text-purple-300">TOR</span>
{% endif %}
</h4> </h4>
<p class="text-xs text-gray-500 dark:text-gray-200">Version: {{ w.version }} {% if w.updating %} <span class="hidden inline-block py-1 px-2 rounded-full bg-blue-100 text-xs text-black-500 dark:bg-gray-700 dark:hover:bg-gray-700">Updating..</span></p> <p class="pt-2 text-xs text-gray-500 dark:text-gray-200">Version: <span class="electrum-version" data-coin="{{ w.name }}">{{ w.version }}</span> {% if w.updating %} <span class="hidden inline-block py-1 px-2 rounded-full bg-blue-100 text-xs text-black-500 dark:bg-gray-700 dark:hover:bg-gray-700">Updating..</span>{% endif %}</p>
{% if w.electrum_server %}
<p class="text-xs text-gray-500 dark:text-gray-200">Server: <span class="electrum-server" data-coin="{{ w.name }}">{{ w.electrum_server }}</span></p>
{% endif %} {% endif %}
</div> </div>
<div class="p-6 bg-coolGray-100 dark:bg-gray-600"> <div class="p-6 bg-coolGray-100 dark:bg-gray-600">
@@ -71,6 +81,12 @@
<div class="bold inline-block py-1 px-2 rounded-full bg-green-100 text-xs text-green-500 dark:bg-gray-500 dark:text-green-500 usd-value"></div> <div class="bold inline-block py-1 px-2 rounded-full bg-green-100 text-xs text-green-500 dark:bg-gray-500 dark:text-green-500 usd-value"></div>
</div> </div>
{% endif %} {% endif %}
{% if w.pending_out %}
<div class="flex mb-2 justify-between items-center">
<h4 class="text-xs font-bold text-yellow-600 dark:text-yellow-400">Unconfirmed:</h4>
<span class="bold inline-block py-1 px-2 rounded-full bg-yellow-100 text-xs text-yellow-600 dark:bg-gray-500 dark:text-yellow-400 coinname-value" data-coinname="{{ w.name }}">-{{ w.pending_out }} {{ w.ticker }}</span>
</div>
{% endif %}
{% if w.cid == '1' %} {# PART #} {% if w.cid == '1' %} {# PART #}
<div class="flex mb-2 justify-between items-center"> <div class="flex mb-2 justify-between items-center">
<h4 class="text-xs font-medium dark:text-white">Blind Balance:</h4> <h4 class="text-xs font-medium dark:text-white">Blind Balance:</h4>
@@ -110,7 +126,7 @@
</div> </div>
{% endif %} {% endif %}
{% endif %} {# / PART #} {% endif %} {# / PART #}
{% if w.cid == '3' %} {# LTC #} {% if w.cid == '3' and w.connection_type != 'electrum' %} {# LTC - MWEB not available in electrum mode #}
<div class="flex mb-2 justify-between items-center"> <div class="flex mb-2 justify-between items-center">
<h4 class="text-xs font-medium dark:text-white">MWEB Balance:</h4> <h4 class="text-xs font-medium dark:text-white">MWEB Balance:</h4>
<span class="bold inline-block py-1 px-2 rounded-full bg-blue-100 text-xs text-black-500 dark:bg-gray-500 dark:text-gray-200 coinname-value" data-coinname="{{ w.name }}">{{ w.mweb_balance }} {{ w.ticker }}</span> <span class="bold inline-block py-1 px-2 rounded-full bg-blue-100 text-xs text-black-500 dark:bg-gray-500 dark:text-gray-200 coinname-value" data-coinname="{{ w.name }}">{{ w.mweb_balance }} {{ w.ticker }}</span>
@@ -159,6 +175,33 @@
<h4 class="text-xs font-medium dark:text-white">Expected Seed:</h4> <h4 class="text-xs font-medium dark:text-white">Expected Seed:</h4>
<span class="inline-block py-1 px-2 rounded-full bg-blue-100 text-xs text-black-500 dark:bg-gray-500 dark:text-gray-200">{{ w.expected_seed }}</span> <span class="inline-block py-1 px-2 rounded-full bg-blue-100 text-xs text-black-500 dark:bg-gray-500 dark:text-gray-200">{{ w.expected_seed }}</span>
</div> </div>
{% if w.connection_type == 'electrum' %}
<div class="scan-status mt-10 p-2 rounded" data-coin="{{ w.name }}">
{% if w.scan_status and w.scan_status.in_progress %}
<div class="bg-blue-50 dark:bg-gray-500 p-2 rounded">
<div class="flex items-center justify-between text-xs">
<span class="text-blue-600 dark:text-blue-300">
<svg class="inline-block w-3 h-3 mr-1 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Scanning {{ w.scan_status.status }}
</span>
<span class="text-blue-500 dark:text-blue-200 font-medium">{{ w.scan_status.progress }}%</span>
</div>
<div class="w-full bg-blue-200 dark:bg-gray-700 rounded-full h-1 mt-1">
<div class="bg-blue-600 dark:bg-blue-400 h-1 rounded-full" style="width: {{ w.scan_status.progress }}%"></div>
</div>
</div>
{% else %}
<div class="bg-green-50 dark:bg-gray-500 p-2 rounded">
<div class="flex items-center text-xs text-green-600 dark:text-green-400">
Electrum Wallet Synced
</div>
</div>
{% endif %}
</div>
{% else %}
<div class="flex justify-between mb-1 mt-10"> <div class="flex justify-between mb-1 mt-10">
<span class="text-xs font-medium dark:text-gray-200">Blockchain</span> <span class="text-xs font-medium dark:text-gray-200">Blockchain</span>
<span class="text-xs font-medium dark:text-gray-200">{{ w.synced }}%</span> <span class="text-xs font-medium dark:text-gray-200">{{ w.synced }}%</span>
@@ -179,6 +222,7 @@
</div> </div>
</span> </span>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@@ -1253,7 +1253,8 @@ def amm_autostart_api(swap_client, post_string, params=None):
settings_path = os.path.join(swap_client.data_dir, cfg.CONFIG_FILENAME) settings_path = os.path.join(swap_client.data_dir, cfg.CONFIG_FILENAME)
settings_path_new = settings_path + ".new" settings_path_new = settings_path + ".new"
shutil.copyfile(settings_path, settings_path + ".last") if os.path.exists(settings_path):
shutil.copyfile(settings_path, settings_path + ".last")
with open(settings_path_new, "w") as fp: with open(settings_path_new, "w") as fp:
json.dump(swap_client.settings, fp, indent=4) json.dump(swap_client.settings, fp, indent=4)
shutil.move(settings_path_new, settings_path) shutil.move(settings_path_new, settings_path)

View File

@@ -218,6 +218,8 @@ def parseOfferFormData(swap_client, form_data, page_data, options={}):
if have_data_entry(form_data, "fee_from_extra"): if have_data_entry(form_data, "fee_from_extra"):
page_data["fee_from_extra"] = int(get_data_entry(form_data, "fee_from_extra")) page_data["fee_from_extra"] = int(get_data_entry(form_data, "fee_from_extra"))
parsed_data["fee_from_extra"] = page_data["fee_from_extra"] parsed_data["fee_from_extra"] = page_data["fee_from_extra"]
else:
page_data["fee_from_extra"] = 0
if have_data_entry(form_data, "fee_to_conf"): if have_data_entry(form_data, "fee_to_conf"):
page_data["fee_to_conf"] = int(get_data_entry(form_data, "fee_to_conf")) page_data["fee_to_conf"] = int(get_data_entry(form_data, "fee_to_conf"))
@@ -226,6 +228,8 @@ def parseOfferFormData(swap_client, form_data, page_data, options={}):
if have_data_entry(form_data, "fee_to_extra"): if have_data_entry(form_data, "fee_to_extra"):
page_data["fee_to_extra"] = int(get_data_entry(form_data, "fee_to_extra")) page_data["fee_to_extra"] = int(get_data_entry(form_data, "fee_to_extra"))
parsed_data["fee_to_extra"] = page_data["fee_to_extra"] parsed_data["fee_to_extra"] = page_data["fee_to_extra"]
else:
page_data["fee_to_extra"] = 0
if have_data_entry(form_data, "check_offer"): if have_data_entry(form_data, "check_offer"):
page_data["check_offer"] = True page_data["check_offer"] = True

View File

@@ -104,6 +104,10 @@ def page_settings(self, url_split, post_string):
"TODO: If running in docker see doc/tor.md to enable/disable tor." "TODO: If running in docker see doc/tor.md to enable/disable tor."
) )
electrum_supported_coins = (
"bitcoin",
"litecoin",
)
for name, c in swap_client.settings["chainclients"].items(): for name, c in swap_client.settings["chainclients"].items():
if have_data_entry(form_data, "apply_" + name): if have_data_entry(form_data, "apply_" + name):
data = {"lookups": get_data_entry(form_data, "lookups_" + name)} data = {"lookups": get_data_entry(form_data, "lookups_" + name)}
@@ -138,10 +142,67 @@ def page_settings(self, url_split, post_string):
data["anon_tx_ring_size"] = int( data["anon_tx_ring_size"] = int(
get_data_entry(form_data, "rct_ring_size_" + name) get_data_entry(form_data, "rct_ring_size_" + name)
) )
if name in electrum_supported_coins:
new_connection_type = get_data_entry_or(
form_data, "connection_type_" + name, None
)
if new_connection_type and new_connection_type != c.get(
"connection_type"
):
coin_id = swap_client.getCoinIdFromName(name)
has_active_swaps = False
for bid_id, (bid, offer) in list(
swap_client.swaps_in_progress.items()
):
if (
offer.coin_from == coin_id
or offer.coin_to == coin_id
):
has_active_swaps = True
break
if has_active_swaps:
display_name = getCoinName(coin_id)
err_messages.append(
f"Cannot change {display_name} connection mode while swaps are in progress. "
f"Please wait for all {display_name} swaps to complete."
)
else:
data["connection_type"] = new_connection_type
if new_connection_type == "electrum":
data["manage_daemon"] = False
elif new_connection_type == "rpc":
data["manage_daemon"] = True
clearnet_servers = get_data_entry_or(
form_data, "electrum_clearnet_" + name, ""
).strip()
data["electrum_clearnet_servers"] = clearnet_servers
onion_servers = get_data_entry_or(
form_data, "electrum_onion_" + name, ""
).strip()
data["electrum_onion_servers"] = onion_servers
auto_transfer = have_data_entry(
form_data, "auto_transfer_" + name
)
data["auto_transfer_on_mode_switch"] = auto_transfer
# Address gap limit for scanning
gap_limit_str = get_data_entry_or(
form_data, "gap_limit_" + name, "20"
).strip()
try:
gap_limit = int(gap_limit_str)
if gap_limit < 5:
gap_limit = 5
elif gap_limit > 100:
gap_limit = 100
data["address_gap_limit"] = gap_limit
except ValueError:
pass
settings_changed, suggest_reboot = swap_client.editSettings( settings_changed, suggest_reboot, migration_message = (
name, data swap_client.editSettings(name, data)
) )
if migration_message:
messages.append(migration_message)
if settings_changed is True: if settings_changed is True:
messages.append("Settings applied.") messages.append("Settings applied.")
if suggest_reboot is True: if suggest_reboot is True:
@@ -156,19 +217,65 @@ def page_settings(self, url_split, post_string):
display_name = getCoinName(swap_client.getCoinIdFromName(name)) display_name = getCoinName(swap_client.getCoinIdFromName(name))
messages.append(display_name + " disabled, shutting down.") messages.append(display_name + " disabled, shutting down.")
swap_client.stopRunning() swap_client.stopRunning()
elif have_data_entry(form_data, "force_sweep_" + name):
coin_id = swap_client.getCoinIdFromName(name)
display_name = getCoinName(coin_id)
try:
result = swap_client.sweepLiteWalletFunds(coin_id)
if result.get("success"):
amount = result.get("amount", 0)
fee = result.get("fee", 0)
txid = result.get("txid", "")
messages.append(
f"Successfully swept {amount:.8f} {display_name} to RPC wallet. "
f"Fee: {fee:.8f}. TXID: {txid} (1 confirmation required)"
)
elif result.get("skipped"):
messages.append(
f"{display_name}: {result.get('reason', 'Sweep skipped')}"
)
else:
err_messages.append(
f"{display_name}: Sweep failed - {result.get('error', 'Unknown error')}"
)
except Exception as e:
err_messages.append(f"{display_name}: Sweep failed - {str(e)}")
except InactiveCoin as ex: except InactiveCoin as ex:
err_messages.append("InactiveCoin {}".format(Coins(ex.coinid).name)) err_messages.append("InactiveCoin {}".format(Coins(ex.coinid).name))
except Exception as e: except Exception as e:
err_messages.append(str(e)) err_messages.append(str(e))
chains_formatted = [] chains_formatted = []
electrum_supported_coins = (
"bitcoin",
"litecoin",
)
sorted_names = sorted(swap_client.settings["chainclients"].keys()) sorted_names = sorted(swap_client.settings["chainclients"].keys())
from basicswap.interface.electrumx import (
DEFAULT_ELECTRUM_SERVERS,
DEFAULT_ONION_SERVERS,
)
for name in sorted_names: for name in sorted_names:
c = swap_client.settings["chainclients"][name] c = swap_client.settings["chainclients"][name]
try: try:
display_name = getCoinName(swap_client.getCoinIdFromName(name)) display_name = getCoinName(swap_client.getCoinIdFromName(name))
except Exception: except Exception:
display_name = name display_name = name
clearnet_servers = c.get("electrum_clearnet_servers", None)
onion_servers = c.get("electrum_onion_servers", None)
if clearnet_servers is None:
default_clearnet = DEFAULT_ELECTRUM_SERVERS.get(name, [])
clearnet_servers = [f"{s['host']}:{s['port']}" for s in default_clearnet]
if onion_servers is None:
default_onion = DEFAULT_ONION_SERVERS.get(name, [])
onion_servers = [f"{s['host']}:{s['port']}" for s in default_onion]
clearnet_text = "\n".join(clearnet_servers) if clearnet_servers else ""
onion_text = "\n".join(onion_servers) if onion_servers else ""
chains_formatted.append( chains_formatted.append(
{ {
"name": name, "name": name,
@@ -176,6 +283,13 @@ def page_settings(self, url_split, post_string):
"lookups": c.get("chain_lookups", "local"), "lookups": c.get("chain_lookups", "local"),
"manage_daemon": c.get("manage_daemon", "Unknown"), "manage_daemon": c.get("manage_daemon", "Unknown"),
"connection_type": c.get("connection_type", "Unknown"), "connection_type": c.get("connection_type", "Unknown"),
"supports_electrum": name in electrum_supported_coins,
"clearnet_servers_text": clearnet_text,
"onion_servers_text": onion_text,
"auto_transfer_on_mode_switch": c.get(
"auto_transfer_on_mode_switch", True
),
"address_gap_limit": c.get("address_gap_limit", 20),
} }
) )
if name in ("monero", "wownero"): if name in ("monero", "wownero"):
@@ -203,6 +317,14 @@ def page_settings(self, url_split, post_string):
else: else:
chains_formatted[-1]["can_disable"] = True chains_formatted[-1]["can_disable"] = True
try:
coin_id = swap_client.getCoinIdFromName(name)
lite_balance_info = swap_client.getLiteWalletBalanceInfo(coin_id)
if lite_balance_info:
chains_formatted[-1]["lite_wallet_balance"] = lite_balance_info
except Exception:
pass
general_settings = { general_settings = {
"debug": swap_client.debug, "debug": swap_client.debug,
"debug_ui": swap_client.debug_ui, "debug_ui": swap_client.debug_ui,

View File

@@ -32,11 +32,15 @@ DONATION_ADDRESSES = {
def format_wallet_data(swap_client, ci, w): def format_wallet_data(swap_client, ci, w):
coin_id = ci.coin_type()
connection_type = swap_client.coin_clients.get(coin_id, {}).get(
"connection_type", w.get("connection_type", "rpc")
)
wf = { wf = {
"name": ci.coin_name(), "name": ci.coin_name(),
"version": w.get("version", "?"), "version": w.get("version", "?"),
"ticker": ci.ticker_mainnet(), "ticker": ci.ticker_mainnet(),
"cid": str(int(ci.coin_type())), "cid": str(int(coin_id)),
"balance": w.get("balance", "?"), "balance": w.get("balance", "?"),
"blocks": w.get("blocks", "?"), "blocks": w.get("blocks", "?"),
"synced": w.get("synced", "?"), "synced": w.get("synced", "?"),
@@ -45,6 +49,7 @@ def format_wallet_data(swap_client, ci, w):
"locked": w.get("locked", "?"), "locked": w.get("locked", "?"),
"updating": w.get("updating", "?"), "updating": w.get("updating", "?"),
"havedata": True, "havedata": True,
"connection_type": connection_type,
} }
if "wallet_blocks" in w: if "wallet_blocks" in w:
@@ -70,6 +75,9 @@ def format_wallet_data(swap_client, ci, w):
if pending > 0.0: if pending > 0.0:
wf["pending"] = ci.format_amount(pending) wf["pending"] = ci.format_amount(pending)
if "unconfirmed" in w and float(w["unconfirmed"]) < 0.0:
wf["pending_out"] = ci.format_amount(abs(ci.make_int(w["unconfirmed"])))
if ci.coin_type() == Coins.PART: if ci.coin_type() == Coins.PART:
wf["stealth_address"] = w.get("stealth_address", "?") wf["stealth_address"] = w.get("stealth_address", "?")
wf["blind_balance"] = w.get("blind_balance", "?") wf["blind_balance"] = w.get("blind_balance", "?")
@@ -83,10 +91,85 @@ def format_wallet_data(swap_client, ci, w):
wf["mweb_balance"] = w.get("mweb_balance", "?") wf["mweb_balance"] = w.get("mweb_balance", "?")
wf["mweb_pending"] = w.get("mweb_pending", "?") wf["mweb_pending"] = w.get("mweb_pending", "?")
if hasattr(ci, "getScanStatus"):
wf["scan_status"] = ci.getScanStatus()
if connection_type == "electrum" and hasattr(ci, "_backend") and ci._backend:
backend = ci._backend
wf["electrum_server"] = backend.getServerHost()
wf["electrum_version"] = backend.getServerVersion()
try:
conn_status = backend.getConnectionStatus()
wf["electrum_connected"] = conn_status.get("connected", False)
wf["electrum_failures"] = conn_status.get("failures", 0)
wf["electrum_using_defaults"] = conn_status.get("using_defaults", True)
wf["electrum_all_failed"] = conn_status.get("all_failed", False)
wf["electrum_last_error"] = conn_status.get("last_error")
if conn_status.get("connected"):
wf["electrum_status"] = "connected"
elif conn_status.get("all_failed"):
wf["electrum_status"] = "all_failed"
else:
wf["electrum_status"] = "disconnected"
except Exception:
wf["electrum_connected"] = False
wf["electrum_status"] = "error"
checkAddressesOwned(swap_client, ci, wf) checkAddressesOwned(swap_client, ci, wf)
return wf return wf
def format_transactions(ci, transactions, coin_id):
formatted_txs = []
if coin_id in (Coins.XMR, Coins.WOW):
for tx in transactions:
tx_type = tx.get("type", "")
direction = (
"Incoming"
if tx_type == "in"
else "Outgoing" if tx_type == "out" else tx_type.capitalize()
)
formatted_txs.append(
{
"txid": tx.get("txid", ""),
"type": direction,
"amount": ci.format_amount(tx.get("amount", 0)),
"confirmations": tx.get("confirmations", 0),
"timestamp": format_timestamp(tx.get("timestamp", 0)),
"height": tx.get("height", 0),
}
)
else:
for tx in transactions:
category = tx.get("category", "")
if category == "send":
direction = "Outgoing"
amount = abs(tx.get("amount", 0))
elif category == "receive":
direction = "Incoming"
amount = tx.get("amount", 0)
else:
direction = category.capitalize()
amount = abs(tx.get("amount", 0))
formatted_txs.append(
{
"txid": tx.get("txid", ""),
"type": direction,
"amount": ci.format_amount(ci.make_int(amount)),
"confirmations": tx.get("confirmations", 0),
"timestamp": format_timestamp(
tx.get("time", tx.get("timereceived", 0))
),
"address": tx.get("address", ""),
}
)
return formatted_txs
def page_wallets(self, url_split, post_string): def page_wallets(self, url_split, post_string):
server = self.server server = self.server
swap_client = server.swap_client swap_client = server.swap_client
@@ -131,6 +214,7 @@ def page_wallets(self, url_split, post_string):
"err_messages": err_messages, "err_messages": err_messages,
"wallets": wallets_formatted, "wallets": wallets_formatted,
"summary": summary, "summary": summary,
"use_tor": getattr(swap_client, "use_tor_proxy", False),
}, },
) )
@@ -151,8 +235,25 @@ def page_wallet(self, url_split, post_string):
show_utxo_groups: bool = False show_utxo_groups: bool = False
withdrawal_successful: bool = False withdrawal_successful: bool = False
force_refresh: bool = False force_refresh: bool = False
tx_filters = {
"page_no": 1,
"limit": 30,
"offset": 0,
}
form_data = self.checkForm(post_string, "wallet", err_messages) form_data = self.checkForm(post_string, "wallet", err_messages)
if form_data: if form_data:
if have_data_entry(form_data, "pageback"):
tx_filters["page_no"] = int(form_data[b"pageno"][0]) - 1
if tx_filters["page_no"] < 1:
tx_filters["page_no"] = 1
elif have_data_entry(form_data, "pageforwards"):
tx_filters["page_no"] = int(form_data[b"pageno"][0]) + 1
if tx_filters["page_no"] > 1:
tx_filters["offset"] = (tx_filters["page_no"] - 1) * 30
cid = str(int(coin_id)) cid = str(int(coin_id))
estimate_fee: bool = have_data_entry(form_data, "estfee_" + cid) estimate_fee: bool = have_data_entry(form_data, "estfee_" + cid)
@@ -170,6 +271,22 @@ def page_wallet(self, url_split, post_string):
except Exception as ex: except Exception as ex:
err_messages.append("Reseed failed " + str(ex)) err_messages.append("Reseed failed " + str(ex))
swap_client.updateWalletsInfo(True, coin_id) swap_client.updateWalletsInfo(True, coin_id)
elif have_data_entry(form_data, "importkey_" + cid):
try:
wif_key = form_data[bytes("wifkey_" + cid, "utf-8")][0].decode("utf-8")
if wif_key:
result = swap_client.importWIFKey(coin_id, wif_key)
if result.get("success"):
messages.append(
f"Imported key for address: {result['address']}"
)
else:
err_messages.append(f"Import failed: {result.get('error')}")
else:
err_messages.append("Missing WIF key")
except Exception as ex:
err_messages.append(f"Import failed: {ex}")
swap_client.updateWalletsInfo(True, coin_id)
elif withdraw or estimate_fee: elif withdraw or estimate_fee:
subfee = True if have_data_entry(form_data, "subfee_" + cid) else False subfee = True if have_data_entry(form_data, "subfee_" + cid) else False
page_data["wd_subfee_" + cid] = subfee page_data["wd_subfee_" + cid] = subfee
@@ -215,7 +332,14 @@ def page_wallet(self, url_split, post_string):
].decode("utf-8") ].decode("utf-8")
page_data["wd_type_from_" + cid] = type_from page_data["wd_type_from_" + cid] = type_from
except Exception as e: # noqa: F841 except Exception as e: # noqa: F841
err_messages.append("Missing type") if (
swap_client.coin_clients[coin_id].get("connection_type")
== "electrum"
):
type_from = "plain"
page_data["wd_type_from_" + cid] = type_from
else:
err_messages.append("Missing type")
if len(err_messages) == 0: if len(err_messages) == 0:
ci = swap_client.ci(coin_id) ci = swap_client.ci(coin_id)
@@ -328,6 +452,9 @@ def page_wallet(self, url_split, post_string):
cid = str(int(coin_id)) cid = str(int(coin_id))
wallet_data = format_wallet_data(swap_client, ci, w) wallet_data = format_wallet_data(swap_client, ci, w)
wallet_data["is_electrum_mode"] = (
getattr(ci, "_connection_type", "rpc") == "electrum"
)
fee_rate, fee_src = swap_client.getFeeRateForCoin(k) fee_rate, fee_src = swap_client.getFeeRateForCoin(k)
est_fee = swap_client.estimateWithdrawFee(k, fee_rate) est_fee = swap_client.estimateWithdrawFee(k, fee_rate)
@@ -400,6 +527,26 @@ def page_wallet(self, url_split, post_string):
"coin_name": wallet_data.get("name", ticker), "coin_name": wallet_data.get("name", ticker),
} }
transactions = []
total_transactions = 0
is_electrum_mode = False
if wallet_data.get("havedata", False) and not wallet_data.get("error"):
try:
ci = swap_client.ci(coin_id)
is_electrum_mode = getattr(ci, "_connection_type", "rpc") == "electrum"
if not is_electrum_mode:
count = tx_filters.get("limit", 30)
skip = tx_filters.get("offset", 0)
all_txs = ci.listWalletTransactions(count=10000, skip=0)
all_txs = list(reversed(all_txs)) if all_txs else []
total_transactions = len(all_txs)
raw_txs = all_txs[skip : skip + count] if all_txs else []
transactions = format_transactions(ci, raw_txs, coin_id)
except Exception as e:
swap_client.log.warning(f"Failed to fetch transactions for {ticker}: {e}")
template = server.env.get_template("wallet.html") template = server.env.get_template("wallet.html")
return self.render_template( return self.render_template(
template, template,
@@ -411,5 +558,11 @@ def page_wallet(self, url_split, post_string):
"block_unknown_seeds": swap_client._restrict_unknown_seed_wallets, "block_unknown_seeds": swap_client._restrict_unknown_seed_wallets,
"donation_info": donation_info, "donation_info": donation_info,
"debug_ui": swap_client.debug_ui, "debug_ui": swap_client.debug_ui,
"transactions": transactions,
"tx_page_no": tx_filters.get("page_no", 1),
"tx_total": total_transactions,
"tx_limit": tx_filters.get("limit", 30),
"is_electrum_mode": is_electrum_mode,
"use_tor": getattr(swap_client, "use_tor_proxy", False),
}, },
) )

View File

@@ -331,6 +331,7 @@ def describeBid(
"ticker_from": ci_from.ticker(), "ticker_from": ci_from.ticker(),
"ticker_to": ci_to.ticker(), "ticker_to": ci_to.ticker(),
"bid_state": strBidState(bid.state), "bid_state": strBidState(bid.state),
"bid_state_ind": int(bid.state),
"state_description": state_description, "state_description": state_description,
"itx_state": strTxState(bid.getITxState()), "itx_state": strTxState(bid.getITxState()),
"ptx_state": strTxState(bid.getPTxState()), "ptx_state": strTxState(bid.getPTxState()),
@@ -343,6 +344,8 @@ def describeBid(
if for_api if for_api
else format_timestamp(bid.created_at, with_seconds=True) else format_timestamp(bid.created_at, with_seconds=True)
), ),
"created_at_timestamp": bid.created_at,
"state_time_timestamp": getLastStateTimestamp(bid),
"expired_at": ( "expired_at": (
bid.expire_at bid.expire_at
if for_api if for_api
@@ -623,6 +626,14 @@ def listOldBidStates(bid):
return old_states return old_states
def getLastStateTimestamp(bid):
if not bid.states or len(bid.states) < 12:
return None
num_states = len(bid.states) // 12
last_entry = struct.unpack_from("<iq", bid.states[(num_states - 1) * 12 :])
return last_entry[1]
def getCoinName(c): def getCoinName(c):
if c == Coins.PART_ANON: if c == Coins.PART_ANON:
return chainparams[Coins.PART]["name"].capitalize() + " Anon" return chainparams[Coins.PART]["name"].capitalize() + " Anon"
@@ -643,7 +654,7 @@ def listAvailableCoins(swap_client, with_variants=True, split_from=False):
for k, v in swap_client.coin_clients.items(): for k, v in swap_client.coin_clients.items():
if k not in chainparams: if k not in chainparams:
continue continue
if v["connection_type"] == "rpc": if v["connection_type"] in ("rpc", "electrum"):
coins.append((int(k), getCoinName(k))) coins.append((int(k), getCoinName(k)))
if split_from: if split_from:
coins_from.append(coins[-1]) coins_from.append(coins[-1])
@@ -670,7 +681,7 @@ def listAvailableCoinsWithBalances(swap_client, with_variants=True, split_from=F
for k, v in swap_client.coin_clients.items(): for k, v in swap_client.coin_clients.items():
if k not in chainparams: if k not in chainparams:
continue continue
if v["connection_type"] == "rpc": if v["connection_type"] in ("rpc", "electrum"):
balance = "0.0" balance = "0.0"
if k in wallets: if k in wallets:
@@ -735,10 +746,23 @@ def checkAddressesOwned(swap_client, ci, wallet_info):
if wallet_info["stealth_address"] != "?": if wallet_info["stealth_address"] != "?":
if not ci.isAddressMine(wallet_info["stealth_address"]): if not ci.isAddressMine(wallet_info["stealth_address"]):
ci._log.error( ci._log.warning(
"Unowned stealth address: {}".format(wallet_info["stealth_address"]) "Unowned stealth address: {} - clearing cache and regenerating".format(
wallet_info["stealth_address"]
)
) )
wallet_info["stealth_address"] = "Error: unowned address" key_str = "stealth_addr_" + ci.coin_name().lower()
swap_client.clearStringKV(key_str)
try:
new_addr = ci.getNewStealthAddress()
swap_client.setStringKV(key_str, new_addr)
wallet_info["stealth_address"] = new_addr
ci._log.info(
"Regenerated stealth address for {}".format(ci.coin_name())
)
except Exception as e:
ci._log.error("Failed to regenerate stealth address: {}".format(e))
wallet_info["stealth_address"] = "Error: unowned address"
elif ( elif (
swap_client._restrict_unknown_seed_wallets and not ci.knownWalletSeed() swap_client._restrict_unknown_seed_wallets and not ci.knownWalletSeed()
): ):
@@ -747,10 +771,24 @@ def checkAddressesOwned(swap_client, ci, wallet_info):
if "deposit_address" in wallet_info: if "deposit_address" in wallet_info:
if wallet_info["deposit_address"] != "Refresh necessary": if wallet_info["deposit_address"] != "Refresh necessary":
if not ci.isAddressMine(wallet_info["deposit_address"]): if not ci.isAddressMine(wallet_info["deposit_address"]):
ci._log.error( ci._log.warning(
"Unowned deposit address: {}".format(wallet_info["deposit_address"]) "Unowned deposit address: {} - clearing cache and regenerating".format(
wallet_info["deposit_address"]
)
) )
wallet_info["deposit_address"] = "Error: unowned address" key_str = "receive_addr_" + ci.coin_name().lower()
swap_client.clearStringKV(key_str)
try:
coin_type = ci.coin_type()
new_addr = swap_client.getReceiveAddressForCoin(coin_type)
swap_client.setStringKV(key_str, new_addr)
wallet_info["deposit_address"] = new_addr
ci._log.info(
"Regenerated deposit address for {}".format(ci.coin_name())
)
except Exception as e:
ci._log.error("Failed to regenerate deposit address: {}".format(e))
wallet_info["deposit_address"] = "Error: unowned address"
elif ( elif (
swap_client._restrict_unknown_seed_wallets and not ci.knownWalletSeed() swap_client._restrict_unknown_seed_wallets and not ci.knownWalletSeed()
): ):

View File

@@ -190,11 +190,14 @@ def format_amount(i: int, display_scale: int, scale: int = None) -> str:
return rv return rv
def format_timestamp(value: int, with_seconds: bool = False) -> str: def format_timestamp(
value: int, with_seconds: bool = False, with_timezone: bool = False
) -> str:
str_format = "%Y-%m-%d %H:%M" str_format = "%Y-%m-%d %H:%M"
if with_seconds: if with_seconds:
str_format += ":%S" str_format += ":%S"
str_format += " %z" if with_timezone:
str_format += " %z"
return time.strftime(str_format, time.localtime(value)) return time.strftime(str_format, time.localtime(value))

752
basicswap/wallet_backend.py Normal file
View File

@@ -0,0 +1,752 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2024-2026 The Basicswap developers
# Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
import time
from abc import ABC, abstractmethod
from typing import Dict, List, Optional
class WalletBackend(ABC):
@abstractmethod
def getBalance(self, addresses: List[str]) -> Dict[str, int]:
pass
def findAddressWithBalance(
self, addresses: List[str], min_balance: int
) -> Optional[tuple]:
balances = self.getBalance(addresses)
for addr, balance in balances.items():
if balance >= min_balance:
return (addr, balance)
return None
@abstractmethod
def getUnspentOutputs(
self, addresses: List[str], min_confirmations: int = 0
) -> List[dict]:
pass
@abstractmethod
def broadcastTransaction(self, tx_hex: str) -> str:
pass
@abstractmethod
def getTransaction(self, txid: str) -> Optional[dict]:
pass
@abstractmethod
def getTransactionRaw(self, txid: str) -> Optional[str]:
pass
@abstractmethod
def getBlockHeight(self) -> int:
pass
@abstractmethod
def estimateFee(self, blocks: int = 6) -> int:
pass
@abstractmethod
def isConnected(self) -> bool:
pass
@abstractmethod
def getAddressHistory(self, address: str) -> List[dict]:
pass
class FullNodeBackend(WalletBackend):
def __init__(self, rpc_client, coin_type, log):
self._rpc = rpc_client
self._coin_type = coin_type
self._log = log
def getBalance(self, addresses: List[str]) -> Dict[str, int]:
result = {}
for addr in addresses:
result[addr] = 0
try:
utxos = self._rpc("listunspent", [0, 9999999, addresses])
for utxo in utxos:
addr = utxo.get("address")
if addr in result:
result[addr] += int(utxo.get("amount", 0) * 1e8)
except Exception as e:
self._log.warning(f"FullNodeBackend.getBalance error: {e}")
return result
def getUnspentOutputs(
self, addresses: List[str], min_confirmations: int = 0
) -> List[dict]:
try:
utxos = self._rpc("listunspent", [min_confirmations, 9999999, addresses])
result = []
for utxo in utxos:
result.append(
{
"txid": utxo.get("txid"),
"vout": utxo.get("vout"),
"value": int(utxo.get("amount", 0) * 1e8),
"address": utxo.get("address"),
"confirmations": utxo.get("confirmations", 0),
"scriptPubKey": utxo.get("scriptPubKey"),
}
)
return result
except Exception as e:
self._log.warning(f"FullNodeBackend.getUnspentOutputs error: {e}")
return []
def broadcastTransaction(self, tx_hex: str) -> str:
return self._rpc("sendrawtransaction", [tx_hex])
def getTransaction(self, txid: str) -> Optional[dict]:
try:
return self._rpc("getrawtransaction", [txid, True])
except Exception:
return None
def getTransactionRaw(self, txid: str) -> Optional[str]:
try:
return self._rpc("getrawtransaction", [txid, False])
except Exception:
return None
def getBlockHeight(self) -> int:
return self._rpc("getblockcount")
def estimateFee(self, blocks: int = 6) -> int:
try:
result = self._rpc("estimatesmartfee", [blocks])
if "feerate" in result:
return int(result["feerate"] * 1e8 / 1000)
return 1
except Exception:
return 1
def isConnected(self) -> bool:
try:
self._rpc("getblockchaininfo")
return True
except Exception:
return False
def getAddressHistory(self, address: str) -> List[dict]:
return []
def importAddress(self, address: str, label: str = "", rescan: bool = False):
try:
self._rpc("importaddress", [address, label, rescan])
except Exception as e:
if "already in wallet" not in str(e).lower():
raise
class ElectrumBackend(WalletBackend):
def __init__(
self,
coin_type,
log,
clearnet_servers=None,
onion_servers=None,
chain="mainnet",
proxy_host=None,
proxy_port=None,
):
from basicswap.interface.electrumx import ElectrumServer
from basicswap.chainparams import Coins, chainparams
self._coin_type = coin_type
self._log = log
self._subscribed_scripthashes = set()
coin_params = chainparams.get(coin_type, chainparams.get(Coins.BTC))
self._network_params = coin_params.get(chain, coin_params.get("mainnet", {}))
coin_name_map = {
Coins.BTC: "bitcoin",
Coins.LTC: "litecoin",
}
coin_name = coin_name_map.get(coin_type, "bitcoin")
self._host = "localhost"
self._port = 50002
self._use_ssl = True
self._server = ElectrumServer(
coin_name,
clearnet_servers=clearnet_servers,
onion_servers=onion_servers,
log=log,
proxy_host=proxy_host,
proxy_port=proxy_port,
)
self._realtime_callback = None
self._address_to_scripthash = {}
self._cached_height = 0
self._cached_height_time = 0
self._height_cache_ttl = 5
self._max_batch_size = 10
self._background_mode = False
def setBackgroundMode(self, enabled: bool):
self._background_mode = enabled
def _call(self, method: str, params: list = None, timeout: int = 10):
if self._background_mode and hasattr(self._server, "call_background"):
return self._server.call_background(method, params, timeout)
return self._server.call(method, params, timeout)
def _call_batch(self, calls: list, timeout: int = 15):
if self._background_mode and hasattr(self._server, "call_batch_background"):
return self._server.call_batch_background(calls, timeout)
return self._server.call_batch(calls, timeout)
def _split_batch_call(
self, scripthashes: list, method: str, batch_size: int = None
) -> list:
if batch_size is None:
batch_size = self._max_batch_size
all_results = []
for i in range(0, len(scripthashes), batch_size):
chunk = scripthashes[i : i + batch_size]
try:
calls = [(method, [sh]) for sh in chunk]
results = self._call_batch(calls)
all_results.extend(results)
except Exception as e:
self._log.debug(f"Batch chunk failed ({len(chunk)} items): {e}")
for sh in chunk:
try:
result = self._call(method, [sh])
all_results.append(result)
except Exception as e2:
self._log.debug(f"Individual call failed for {sh[:8]}...: {e2}")
all_results.append(None)
return all_results
def _isUnsupportedAddress(self, address: str) -> bool:
if address.startswith("ltcmweb1"):
return True
return False
def _addressToScripthash(self, address: str) -> str:
from basicswap.interface.electrumx import scripthash_from_address
return scripthash_from_address(address, self._network_params)
def getBalance(self, addresses: List[str]) -> Dict[str, int]:
result = {}
for addr in addresses:
result[addr] = 0
if not addresses:
return result
addr_list = [addr for addr in addresses if not self._isUnsupportedAddress(addr)]
if not addr_list:
return result
addr_to_scripthash = {}
for addr in addr_list:
try:
addr_to_scripthash[addr] = self._addressToScripthash(addr)
except Exception as e:
self._log.debug(f"getBalance: scripthash error for {addr[:10]}...: {e}")
if not addr_to_scripthash:
return result
scripthashes = list(addr_to_scripthash.values())
scripthash_to_addr = {v: k for k, v in addr_to_scripthash.items()}
batch_results = self._split_batch_call(
scripthashes, "blockchain.scripthash.get_balance"
)
for i, balance in enumerate(batch_results):
if balance and isinstance(balance, dict):
addr = scripthash_to_addr.get(scripthashes[i])
if addr:
confirmed = balance.get("confirmed", 0)
unconfirmed = balance.get("unconfirmed", 0)
result[addr] = confirmed + unconfirmed
return result
def getDetailedBalance(self, addresses: List[str]) -> Dict[str, dict]:
result = {}
for addr in addresses:
result[addr] = {"confirmed": 0, "unconfirmed": 0}
if not addresses:
return result
addr_list = [addr for addr in addresses if not self._isUnsupportedAddress(addr)]
if not addr_list:
return result
batch_size = 10
for batch_start in range(0, len(addr_list), batch_size):
batch = addr_list[batch_start : batch_start + batch_size]
addr_to_scripthash = {}
for addr in batch:
try:
addr_to_scripthash[addr] = self._addressToScripthash(addr)
except Exception as e:
self._log.debug(
f"getDetailedBalance: scripthash error for {addr[:10]}...: {e}"
)
if not addr_to_scripthash:
continue
scripthashes = list(addr_to_scripthash.values())
scripthash_to_addr = {v: k for k, v in addr_to_scripthash.items()}
batch_success = False
for attempt in range(2):
try:
batch_results = self._server.get_balance_batch(scripthashes)
for i, balance in enumerate(batch_results):
if balance and isinstance(balance, dict):
addr = scripthash_to_addr.get(scripthashes[i])
if addr:
result[addr] = {
"confirmed": balance.get("confirmed", 0),
"unconfirmed": balance.get("unconfirmed", 0),
}
batch_success = True
break
except Exception as e:
if attempt == 0:
self._log.debug(
f"Batch detailed balance query failed, reconnecting: {e}"
)
try:
self._server.disconnect()
except Exception:
pass
time.sleep(0.5)
else:
self._log.debug(
f"Batch detailed balance query failed after retry, falling back: {e}"
)
if not batch_success:
for addr, scripthash in addr_to_scripthash.items():
try:
balance = self._call(
"blockchain.scripthash.get_balance", [scripthash]
)
if balance and isinstance(balance, dict):
result[addr] = {
"confirmed": balance.get("confirmed", 0),
"unconfirmed": balance.get("unconfirmed", 0),
}
except Exception as e:
self._log.debug(
f"ElectrumBackend.getDetailedBalance error for {addr[:10]}...: {e}"
)
return result
def findAddressWithBalance(
self, addresses: List[str], min_balance: int
) -> Optional[tuple]:
if not addresses:
return None
addr_list = [addr for addr in addresses if not self._isUnsupportedAddress(addr)]
if not addr_list:
return None
batch_size = 50
for batch_start in range(0, len(addr_list), batch_size):
batch = addr_list[batch_start : batch_start + batch_size]
addr_to_scripthash = {}
for addr in batch:
try:
addr_to_scripthash[addr] = self._addressToScripthash(addr)
except Exception:
continue
if not addr_to_scripthash:
continue
try:
scripthashes = list(addr_to_scripthash.values())
batch_results = self._server.get_balance_batch(scripthashes)
scripthash_to_addr = {v: k for k, v in addr_to_scripthash.items()}
for i, balance in enumerate(batch_results):
if balance and isinstance(balance, dict):
confirmed = balance.get("confirmed", 0)
unconfirmed = balance.get("unconfirmed", 0)
total = confirmed + unconfirmed
if total >= min_balance:
addr = scripthash_to_addr.get(scripthashes[i])
if addr:
return (addr, total)
except Exception as e:
self._log.debug(f"findAddressWithBalance batch error: {e}")
return None
def getUnspentOutputs(
self, addresses: List[str], min_confirmations: int = 0
) -> List[dict]:
result = []
if not addresses:
return result
try:
current_height = self.getBlockHeight()
for addr in addresses:
if self._isUnsupportedAddress(addr):
continue
try:
scripthash = self._addressToScripthash(addr)
utxos = self._call(
"blockchain.scripthash.listunspent", [scripthash]
)
if utxos:
for utxo in utxos:
height = utxo.get("height", 0)
if height <= 0:
confirmations = 0
else:
confirmations = current_height - height + 1
if confirmations >= min_confirmations:
result.append(
{
"txid": utxo.get("tx_hash"),
"vout": utxo.get("tx_pos"),
"value": utxo.get("value", 0),
"address": addr,
"confirmations": confirmations,
}
)
except Exception as e:
self._log.debug(
f"ElectrumBackend.getUnspentOutputs error for {addr[:10]}...: {e}"
)
except Exception as e:
self._log.warning(f"ElectrumBackend.getUnspentOutputs error: {e}")
return result
def broadcastTransaction(self, tx_hex: str) -> str:
import time
max_retries = 3
retry_delay = 0.5
for attempt in range(max_retries):
try:
result = self._server.call("blockchain.transaction.broadcast", [tx_hex])
if result:
return result
except Exception as e:
error_msg = str(e).lower()
if any(
pattern in error_msg
for pattern in [
"missing inputs",
"bad-txns",
"txn-mempool-conflict",
"already in block chain",
"transaction already exists",
"insufficient fee",
"dust",
]
):
raise
if attempt < max_retries - 1:
self._log.debug(
f"broadcastTransaction retry {attempt + 1}/{max_retries}: {e}"
)
time.sleep(retry_delay * (2**attempt)) # Exponential backoff
continue
raise
return None
def getTransaction(self, txid: str) -> Optional[dict]:
try:
return self._call("blockchain.transaction.get", [txid, True])
except Exception:
return None
def getTransactionRaw(self, txid: str) -> Optional[str]:
try:
tx_hex = self._call("blockchain.transaction.get", [txid, False])
return tx_hex
except Exception as e:
self._log.warning(f"getTransactionRaw failed for {txid[:16]}...: {e}")
return None
def getTransactionBatch(self, txids: List[str]) -> Dict[str, Optional[dict]]:
result = {}
if not txids:
return result
try:
calls = [("blockchain.transaction.get", [txid, True]) for txid in txids]
responses = self._call_batch(calls)
for txid, tx_info in zip(txids, responses):
result[txid] = tx_info if tx_info else None
except Exception as e:
self._log.debug(f"getTransactionBatch error: {e}")
for txid in txids:
result[txid] = self.getTransaction(txid)
return result
def getTransactionBatchRaw(self, txids: List[str]) -> Dict[str, Optional[str]]:
result = {}
if not txids:
return result
try:
calls = [("blockchain.transaction.get", [txid, False]) for txid in txids]
responses = self._call_batch(calls)
for txid, tx_hex in zip(txids, responses):
result[txid] = tx_hex if tx_hex else None
except Exception as e:
self._log.debug(f"getTransactionBatchRaw error: {e}")
for txid in txids:
result[txid] = self.getTransactionRaw(txid)
return result
def getBlockHeight(self) -> int:
import time
if hasattr(self._server, "get_subscribed_height"):
subscribed_height = self._server.get_subscribed_height()
if subscribed_height > 0:
if subscribed_height > self._cached_height:
self._cached_height = subscribed_height
self._cached_height_time = time.time()
return subscribed_height
now = time.time()
if (
self._cached_height > 0
and (now - self._cached_height_time) < self._height_cache_ttl
):
return self._cached_height
try:
header = self._call("blockchain.headers.subscribe", [])
if header:
height = header.get("height", 0)
if height > 0:
self._cached_height = height
self._cached_height_time = now
return height
return self._cached_height if self._cached_height > 0 else 0
except Exception:
return self._cached_height if self._cached_height > 0 else 0
def estimateFee(self, blocks: int = 6) -> int:
try:
fee = self._call("blockchain.estimatefee", [blocks])
if fee and fee > 0:
return int(fee * 1e8 / 1000)
return 1
except Exception:
return 1
def isConnected(self) -> bool:
try:
self._call("server.ping", [])
return True
except Exception:
return False
def getServerVersion(self) -> str:
version = self._server.get_server_version()
if not version:
try:
self._call("server.ping", [])
version = self._server.get_server_version()
except Exception:
pass
return version or "electrum"
def getServerHost(self) -> str:
host, port = self._server.get_current_server()
if host and port:
return f"{host}:{port}"
return f"{self._host}:{self._port}"
def getConnectionStatus(self) -> dict:
if hasattr(self._server, "getConnectionStatus"):
status = self._server.getConnectionStatus()
else:
status = {
"connected": self.isConnected(),
"failures": 0,
"last_error": None,
"all_failed": False,
"using_defaults": True,
"server_count": 1,
}
status["server"] = self.getServerHost()
status["version"] = self.getServerVersion()
return status
def getAddressHistory(self, address: str) -> List[dict]:
if self._isUnsupportedAddress(address):
return []
try:
scripthash = self._addressToScripthash(address)
history = self._call("blockchain.scripthash.get_history", [scripthash])
if history:
return [
{"txid": h.get("tx_hash"), "height": h.get("height", 0)}
for h in history
]
return []
except Exception:
return []
def getAddressHistoryBackground(self, address: str) -> List[dict]:
if self._isUnsupportedAddress(address):
return []
try:
scripthash = self._addressToScripthash(address)
history = self._server.call_background(
"blockchain.scripthash.get_history", [scripthash]
)
if history:
return [
{"txid": h.get("tx_hash"), "height": h.get("height", 0)}
for h in history
]
return []
except Exception:
return []
def getBatchBalance(self, scripthashes: List[str]) -> Dict[str, int]:
result = {}
for sh in scripthashes:
result[sh] = 0
try:
calls = [("blockchain.scripthash.get_balance", [sh]) for sh in scripthashes]
responses = self._call_batch(calls)
for sh, balance in zip(scripthashes, responses):
if balance:
confirmed = balance.get("confirmed", 0)
unconfirmed = balance.get("unconfirmed", 0)
result[sh] = confirmed + unconfirmed
except Exception as e:
self._log.warning(f"ElectrumBackend.getBatchBalance error: {e}")
return result
def getBatchUnspent(
self, scripthashes: List[str], min_confirmations: int = 0
) -> Dict[str, List[dict]]:
result = {}
for sh in scripthashes:
result[sh] = []
try:
current_height = self.getBlockHeight()
calls = [("blockchain.scripthash.listunspent", [sh]) for sh in scripthashes]
responses = self._call_batch(calls)
for sh, utxos in zip(scripthashes, responses):
if utxos:
for utxo in utxos:
height = utxo.get("height", 0)
if height <= 0:
confirmations = 0
else:
confirmations = current_height - height + 1
if confirmations >= min_confirmations:
result[sh].append(
{
"txid": utxo.get("tx_hash"),
"vout": utxo.get("tx_pos"),
"value": utxo.get("value", 0),
"confirmations": confirmations,
}
)
except Exception as e:
self._log.warning(f"ElectrumBackend.getBatchUnspent error: {e}")
return result
def enableRealtimeNotifications(self, callback) -> None:
self._realtime_callback = callback
self._server.enable_realtime_notifications()
self._log.info(f"Real-time notifications enabled for {self._coin_type}")
def _create_scripthash_callback(self, scripthash):
def callback(sh, new_status):
self._handle_scripthash_notification(sh, new_status)
return callback
def _handle_scripthash_notification(self, scripthash, new_status):
if not self._realtime_callback:
return
address = None
for addr, sh in self._address_to_scripthash.items():
if sh == scripthash:
address = addr
break
try:
self._realtime_callback(
self._coin_type, address, scripthash, "balance_change"
)
except Exception as e:
self._log.debug(f"Error in realtime callback: {e}")
def subscribeAddressWithCallback(self, address: str) -> str:
if self._isUnsupportedAddress(address):
return None
try:
scripthash = self._addressToScripthash(address)
self._address_to_scripthash[address] = scripthash
if self._realtime_callback:
status = self._server.subscribe_with_callback(
scripthash, self._create_scripthash_callback(scripthash)
)
else:
status = self._call("blockchain.scripthash.subscribe", [scripthash])
self._subscribed_scripthashes.add(scripthash)
return status
except Exception as e:
self._log.debug(f"Failed to subscribe to {address}: {e}")
return None
def getServer(self):
return self._server

2028
basicswap/wallet_manager.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -676,7 +676,7 @@ class Test(unittest.TestCase):
def test_db(self): def test_db(self):
db_test = DBMethods() db_test = DBMethods()
db_test.sqlite_file = ":memory:" db_test.sqlite_file = ":memory:"
db_test.mxDB = threading.Lock() db_test.mxDB = threading.RLock()
cursor = db_test.openDB() cursor = db_test.openDB()
try: try:
create_db_(db_test._db_con, logger) create_db_(db_test._db_con, logger)