44 Commits

Author SHA1 Message Date
Gerlof van Ek
3c76454e68 Fix: Prevent silent wallet creation. (#444)
* Fix: Prevent silent wallet creation.

* Error on wallet name mismatch + fix wording.

* Revert init_wallet

* Don't create wallet on startup if other wallets exist on disk.

* Added back # wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors
2026-04-07 22:27:03 +02:00
Gerlof van Ek
c58e637987 Merge pull request #422 from gerlofvanek/electrum
GUI: v3.4.1 - Electrum/Lite Wallet Mode for Bitcoin and Litecoin.
2026-04-07 08:05:38 +02:00
gerlofvanek
61da9d703c GUI: v3.4.1 2026-04-01 09:44:45 +02:00
gerlofvanek
45e0f85cf0 Fix 2026-03-31 23:03:34 +02:00
gerlofvanek
303fe59d7b BLACK 2026-03-31 22:38:19 +02:00
gerlofvanek
1102ff1ddf Whitespace 2026-03-31 22:36:13 +02:00
Gerlof van Ek
e5e0e6e911 Merge branch 'dev' into electrum 2026-03-31 22:28:45 +02:00
gerlofvanek
129a5bb9b7 Electrum connection stability, swap fixes / UX improvements + Various fixes. 2026-03-31 22:10:25 +02:00
Gerlof van Ek
3258b76a49 Merge pull request #5 from tecnovert/electrum_tests
Electrum tests
2026-02-09 19:38:08 +01:00
tecnovert
54ece5dff9 tests: add remaining electrum tests
Signed-off-by: tecnovert <tecnovert@tecnovert.net>
2026-02-08 01:35:20 +02:00
tecnovert
1ca454b269 tests: add wait_for_bid_state 2026-02-07 00:33:05 +02:00
gerlofvanek
7d592a520b BIP87 (RPC -> Electrum) legacy balance fix. 2026-02-06 22:33:10 +01:00
gerlofvanek
7b0925de46 BIP87 2026-02-06 21:39:17 +01:00
gerlofvanek
c9029a5e34 Flake8 2026-02-06 20:48:07 +01:00
Gerlof van Ek
f4b645bccd Merge pull request #4 from tecnovert/electrum_tests
tests: add base for electrum functional tests
2026-02-06 20:28:49 +01:00
tecnovert
9b9078b153 tests: add more electrum tests 2026-02-06 19:46:26 +02:00
tecnovert
807880547e tests: add function to start electrumx server per coin 2026-02-06 13:40:58 +02:00
tecnovert
604171c3eb tests: add base for electrum functional tests 2026-02-06 12:11:52 +02:00
gerlofvanek
8fa0668079 Fix: Bids in Request accepted state now timeout correctly. 2026-02-02 01:16:12 +01:00
gerlofvanek
bbc5e64db0 Fix: Move getTxVSize call to after the change output is added. 2026-02-01 23:22:05 +01:00
gerlofvanek
5213ddd173 Fixes 2026-01-31 23:04:18 +01:00
gerlofvanek
79b45d59db Flake8 + Black. 2026-01-31 23:01:08 +01:00
gerlofvanek
d5a6d63e0b Fix: Use witness-aware vsize calculation in _fundTxElectrum. 2026-01-31 22:58:16 +01:00
gerlofvanek
a456a15b8d Debug message for _fundTxElectrum 2026-01-31 11:42:11 +01:00
gerlofvanek
44c77f162b Fix whitespace. 2026-01-30 23:03:22 +01:00
gerlofvanek
e737ba7e27 Flake8 + Black. 2026-01-30 22:37:11 +01:00
gerlofvanek
1afe1316d0 Fix: Clean BSX install litewallets switch in RPC mode wallet creation (RPC: BTC/LTC(+MWEB) 2026-01-30 22:34:25 +01:00
gerlofvanek
e67e37b801 Fix: For Tor set ssl: false (Tor already provides encryption.) 2026-01-30 02:20:36 +01:00
gerlofvanek
a2c37a13f8 Fix (host:port:ssl) 2026-01-30 02:09:43 +01:00
gerlofvanek
8a86c494ee Fix: Electrum reduced CLI noise (electrum server connections INFO/DEBUG) 2026-01-29 20:45:54 +01:00
gerlofvanek
f4f3fa63f2 Cleanup 2026-01-29 18:50:47 +01:00
gerlofvanek
12e3d3bab8 Fix: Use string format for electrum servers host:port with prepare.
- Added temp check if config using old object-format servers and convert them to the strings and saves to basicswap.json.
2026-01-29 16:48:24 +01:00
gerlofvanek
d1552717ae Hide refresh button for completed swaps. 2026-01-29 16:08:28 +01:00
gerlofvanek
8f1382d00d Fixed electrum logic (settings).
- Fixed empty arrays to fall back to default servers.
- Fixed RPC/Electrum settings logic.
- Added option set electrum servers before switch RPC -> Electrum mode.
- Fixed "No servers discovered", some servers don't support peer discovery.
2026-01-29 11:50:51 +01:00
gerlofvanek
60dd5d43e7 Fixed inconsistent with electrum_servers / electrum_clearnet_servers. 2026-01-29 11:03:29 +01:00
gerlofvanek
eee45858b5 Fix wallet creation (LTC) when switching from electrum to RPC mode. 2026-01-29 09:35:00 +01:00
gerlofvanek
162254c537 Added graceful shutdown for electrum. 2026-01-28 23:42:24 +01:00
gerlofvanek
e719ba3d6f Reduce log noise for watched outputs that were never broadcast. 2026-01-28 23:29:03 +01:00
gerlofvanek
75f058dad6 Add LTC/BTC stackwallet electrum nodes. 2026-01-28 23:21:13 +01:00
gerlofvanek
39e134c46c Fix bid tooltips (Adaptor / Secret hash) and progress bar. 2026-01-28 23:19:36 +01:00
gerlofvanek
3fcd70098a Fix electrum broadcast spam. 2026-01-28 22:32:46 +01:00
gerlofvanek
38c03a3abf Fix bid page bugs.
- Changed auto refresh from 15 to 60 seconds.
- Clear interval before refresh.
- Fixed bug with mouseleave.
- Fixed negative countdown values.
2026-01-28 22:15:33 +01:00
gerlofvanek
8f076c7bfb Reverted BCH to the original logic. 2026-01-28 20:57:30 +01:00
gerlofvanek
afae62ae38 Litewallets 2026-01-28 16:05:52 +01:00
51 changed files with 13590 additions and 475 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):
@@ -628,6 +641,7 @@ def canTimeoutBidState(state):
BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_TX_SIGS, BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_TX_SIGS,
BidStates.XMR_SWAP_HAVE_SCRIPT_COIN_SPEND_TX, BidStates.XMR_SWAP_HAVE_SCRIPT_COIN_SPEND_TX,
BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX, BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX,
BidStates.BID_REQUEST_ACCEPTED,
) )

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():
@@ -1834,6 +1841,7 @@ def initialise_wallets(
daemons = [] daemons = []
daemon_args = ["-noconnect", "-nodnsseed"] daemon_args = ["-noconnect", "-nodnsseed"]
generated_mnemonic: bool = False generated_mnemonic: bool = False
extended_keys = {}
coins_failed_to_initialise = [] coins_failed_to_initialise = []
@@ -1955,6 +1963,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 +2065,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:
@@ -2082,6 +2099,20 @@ def initialise_wallets(
except Exception as e: # noqa: F841 except Exception as e: # noqa: F841
logger.warning(f"changeWalletPassword failed for {coin_name}.") logger.warning(f"changeWalletPassword failed for {coin_name}.")
zprv_prefix = 0x04B2430C if chain == "mainnet" else 0x045F18BC
for coin_name in with_coins:
c = swap_client.getCoinIdFromName(coin_name)
if c == Coins.PART:
continue
try:
ci = swap_client.ci(c)
if hasattr(ci, "canExportToElectrum") and ci.canExportToElectrum():
seed_key = swap_client.getWalletKey(c, 1)
account_key = ci.getAccountKey(seed_key, zprv_prefix)
extended_keys[getCoinName(c)] = account_key
except Exception as e:
logger.debug(f"Could not generate extended key for {coin_name}: {e}")
finally: finally:
if swap_client: if swap_client:
swap_client.finalise() swap_client.finalise()
@@ -2113,6 +2144,18 @@ def initialise_wallets(
) )
) )
if extended_keys:
print("Extended private keys (for external wallet import):")
for coin_name, key in extended_keys.items():
print(f" {coin_name}: {key}")
print("")
print(
"NOTE: These keys can be imported into Electrum using 'Use a master key'."
)
print("WARNING: Write these down NOW. They will not be shown again.\n")
return extended_keys
def load_config(config_path): def load_config(config_path):
if not os.path.exists(config_path): if not os.path.exists(config_path):
@@ -2279,6 +2322,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 +2479,31 @@ 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:
if len(parts) >= 3:
server = f"{parts[0]}:{parts[1]}:{parts[2]}"
else:
server = f"{parts[0]}:{parts[1]}"
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 +2862,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_clearnet_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]
@@ -3001,7 +3098,7 @@ def main():
) )
if particl_wallet_mnemonic != "none": if particl_wallet_mnemonic != "none":
initialise_wallets( extended_keys = initialise_wallets(
None, None,
{ {
add_coin, add_coin,
@@ -3013,6 +3110,18 @@ def main():
extra_opts=extra_opts, extra_opts=extra_opts,
) )
if extended_keys:
print("\nExtended private key (for external wallet import):")
for coin_name, key in extended_keys.items():
print(f" {coin_name}: {key}")
print("")
print(
"NOTE: This key can be imported into Electrum using 'Use a master key'."
)
print(
"WARNING: Write this down NOW. It will not be shown again.\n"
)
save_config(config_path, settings) save_config(config_path, settings)
finally: finally:
if "particl_daemon" in extra_opts: if "particl_daemon" in extra_opts:

View File

@@ -36,22 +36,25 @@ def signal_handler(sig, frame):
os.write( os.write(
sys.stdout.fileno(), f"Signal {sig} detected, ending program.\n".encode("utf-8") sys.stdout.fileno(), f"Signal {sig} detected, ending program.\n".encode("utf-8")
) )
if swap_client is not None and not swap_client.chainstate_delay_event.is_set(): try:
try: if swap_client is not None and not swap_client.chainstate_delay_event.is_set():
from basicswap.ui.page_amm import stop_amm_process, get_amm_status try:
from basicswap.ui.page_amm import stop_amm_process, get_amm_status
amm_status = get_amm_status() amm_status = get_amm_status()
if amm_status == "running": if amm_status == "running":
logger.info("Signal handler stopping AMM process...") logger.info("Signal handler stopping AMM process...")
success, msg = stop_amm_process(swap_client) success, msg = stop_amm_process(swap_client)
if success: if success:
logger.info(f"AMM signal shutdown: {msg}") logger.info(f"AMM signal shutdown: {msg}")
else: else:
logger.warning(f"AMM signal shutdown warning: {msg}") logger.warning(f"AMM signal shutdown warning: {msg}")
except Exception as e: except Exception as e:
logger.error(f"Error stopping AMM in signal handler: {e}") logger.error(f"Error stopping AMM in signal handler: {e}")
swap_client.stopRunning() swap_client.stopRunning()
except NameError:
pass
def checkPARTZmqConfigBeforeStart(part_settings, swap_settings): def checkPARTZmqConfigBeforeStart(part_settings, swap_settings):
@@ -632,7 +635,7 @@ def runClient(
) )
fail_code: int = swap_client.fail_code fail_code: int = swap_client.fail_code
del swap_client swap_client = None
if os.path.exists(pids_path): if os.path.exists(pids_path):
with open(pids_path) as fd: with open(pids_path) as fd:

View File

@@ -13,8 +13,8 @@ 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 = 8
class Concepts(IntEnum): class Concepts(IntEnum):
@@ -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,
@@ -129,6 +138,14 @@ def upgradeDatabaseData(self, data_version):
"state_id": int(state), "state_id": int(state),
}, },
) )
if data_version > 0 and data_version < 8:
cursor.execute(
"UPDATE bidstates SET can_timeout = :can_timeout WHERE state_id = :state_id",
{
"can_timeout": 1,
"state_id": int(BidStates.BID_REQUEST_ACCEPTED),
},
)
if data_version > 0 and data_version < 4: if data_version > 0 and data_version < 4:
for state in ( for state in (
BidStates.BID_REQUEST_SENT, BidStates.BID_REQUEST_SENT,
@@ -260,7 +277,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 +295,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")

View File

@@ -6,8 +6,10 @@
# file LICENSE or http://www.opensource.org/licenses/mit-license.php. # file LICENSE or http://www.opensource.org/licenses/mit-license.php.
import os import os
import gzip
import json import json
import shlex import shlex
import hashlib
import secrets import secrets
import traceback import traceback
import threading import threading
@@ -19,6 +21,7 @@ from jinja2 import Environment, PackageLoader
from socket import error as SocketError from socket import error as SocketError
from urllib import parse from urllib import parse
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from email.utils import formatdate, parsedate_to_datetime
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from . import __version__ from . import __version__
@@ -802,7 +805,6 @@ class HttpHandler(BaseHTTPRequestHandler):
if page == "static": if page == "static":
try: try:
static_path = os.path.join(os.path.dirname(__file__), "static") static_path = os.path.join(os.path.dirname(__file__), "static")
content = None
mime_type = "" mime_type = ""
filepath = "" filepath = ""
if len(url_split) > 3 and url_split[2] == "sequence_diagrams": if len(url_split) > 3 and url_split[2] == "sequence_diagrams":
@@ -835,9 +837,71 @@ class HttpHandler(BaseHTTPRequestHandler):
if mime_type == "" or not filepath: if mime_type == "" or not filepath:
raise ValueError("Unknown file type or path") raise ValueError("Unknown file type or path")
file_stat = os.stat(filepath)
mtime = file_stat.st_mtime
file_size = file_stat.st_size
etag_hash = hashlib.md5(f"{file_size}-{mtime}".encode()).hexdigest()
etag = f'"{etag_hash}"'
last_modified = formatdate(mtime, usegmt=True)
if_none_match = self.headers.get("If-None-Match")
if if_none_match:
if if_none_match.strip() == "*" or etag in [
t.strip() for t in if_none_match.split(",")
]:
self.send_response(304)
self.send_header("ETag", etag)
self.send_header("Cache-Control", "public")
self.end_headers()
return b""
if_modified_since = self.headers.get("If-Modified-Since")
if if_modified_since and not if_none_match:
try:
ims_time = parsedate_to_datetime(if_modified_since)
file_time = datetime.fromtimestamp(int(mtime), tz=timezone.utc)
if file_time <= ims_time:
self.send_response(304)
self.send_header("Last-Modified", last_modified)
self.send_header("Cache-Control", "public")
self.end_headers()
return b""
except (TypeError, ValueError):
pass
is_lib = len(url_split) > 4 and url_split[3] == "libs"
if is_lib:
cache_control = "public, max-age=31536000, immutable"
elif url_split[2] in ("css", "js"):
cache_control = "public, max-age=3600, must-revalidate"
elif url_split[2] in ("images", "sequence_diagrams"):
cache_control = "public, max-age=86400"
else:
cache_control = "public, max-age=3600"
with open(filepath, "rb") as fp: with open(filepath, "rb") as fp:
content = fp.read() content = fp.read()
self.putHeaders(status_code, mime_type)
extra_headers = [
("Cache-Control", cache_control),
("Last-Modified", last_modified),
("ETag", etag),
]
is_compressible = mime_type in (
"text/css; charset=utf-8",
"application/javascript",
"image/svg+xml",
)
accept_encoding = self.headers.get("Accept-Encoding", "")
if is_compressible and "gzip" in accept_encoding:
content = gzip.compress(content)
extra_headers.append(("Content-Encoding", "gzip"))
extra_headers.append(("Vary", "Accept-Encoding"))
extra_headers.append(("Content-Length", str(len(content))))
self.putHeaders(status_code, mime_type, extra_headers=extra_headers)
return content return content
except FileNotFoundError: except FileNotFoundError:

File diff suppressed because it is too large Load Diff

View File

@@ -132,7 +132,7 @@ class DASHInterface(BTCInterface):
self.unlockWallet(old_password, check_seed=False) self.unlockWallet(old_password, check_seed=False)
seed_id_before: str = self.getWalletSeedID() seed_id_before: str = self.getWalletSeedID()
self.rpc_wallet("encryptwallet", [new_password]) self.rpc_wallet("encryptwallet", [new_password], timeout=120)
if check_seed is False or seed_id_before == "Not found": if check_seed is False or seed_id_before == "Not found":
return return
@@ -156,4 +156,6 @@ class DASHInterface(BTCInterface):
if self.isWalletEncrypted(): if self.isWalletEncrypted():
raise ValueError("Old password must be set") raise ValueError("Old password must be set")
return self.encryptWallet(old_password, new_password, check_seed_if_encrypt) return self.encryptWallet(old_password, new_password, check_seed_if_encrypt)
self.rpc_wallet("walletpassphrasechange", [old_password, new_password]) self.rpc_wallet(
"walletpassphrasechange", [old_password, new_password], timeout=120
)

View File

@@ -188,6 +188,10 @@ class DCRInterface(Secp256k1Interface):
def coin_type(): def coin_type():
return Coins.DCR return Coins.DCR
@staticmethod
def useBackend() -> bool:
return False
@staticmethod @staticmethod
def exp() -> int: def exp() -> int:
return 8 return 8
@@ -364,7 +368,9 @@ class DCRInterface(Secp256k1Interface):
# Read initial pwd from settings # Read initial pwd from settings
settings = self._sc.getChainClientSettings(self.coin_type()) settings = self._sc.getChainClientSettings(self.coin_type())
old_password = settings["wallet_pwd"] old_password = settings["wallet_pwd"]
self.rpc_wallet("walletpassphrasechange", [old_password, new_password]) self.rpc_wallet(
"walletpassphrasechange", [old_password, new_password], timeout=120
)
# Lock wallet to match other coins # Lock wallet to match other coins
self.rpc_wallet("walletlock") self.rpc_wallet("walletlock")
@@ -378,7 +384,7 @@ class DCRInterface(Secp256k1Interface):
self._log.info("unlockWallet - {}".format(self.ticker())) self._log.info("unlockWallet - {}".format(self.ticker()))
# Max timeout value, ~3 years # Max timeout value, ~3 years
self.rpc_wallet("walletpassphrase", [password, 100000000]) self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
if check_seed: if check_seed:
self._sc.checkWalletSeed(self.coin_type()) self._sc.checkWalletSeed(self.coin_type())
@@ -632,6 +638,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
@@ -1055,6 +1070,9 @@ class DCRInterface(Secp256k1Interface):
def describeTx(self, tx_hex: str): def describeTx(self, tx_hex: str):
return self.rpc("decoderawtransaction", [tx_hex]) return self.rpc("decoderawtransaction", [tx_hex])
def decodeRawTransaction(self, tx_hex: str):
return self.rpc("decoderawtransaction", [tx_hex])
def fundTx(self, tx: bytes, feerate) -> bytes: def fundTx(self, tx: bytes, feerate) -> bytes:
feerate_str = float(self.format_amount(feerate)) feerate_str = float(self.format_amount(feerate))
# TODO: unlock unspents if bid cancelled # TODO: unlock unspents if bid cancelled
@@ -1723,15 +1741,19 @@ class DCRInterface(Secp256k1Interface):
tx.vout.append(self.txoType()(output_amount, script_pk)) tx.vout.append(self.txoType()(output_amount, script_pk))
return tx.serialize() return tx.serialize()
def publishBLockTx( def publishBLockTx(self, kbv, Kbs, output_amount, feerate, unlock_time: int = 0):
self, kbv, Kbs, output_amount, feerate, unlock_time: int = 0
) -> bytes:
b_lock_tx = self.createBLockTx(Kbs, output_amount) b_lock_tx = self.createBLockTx(Kbs, output_amount)
b_lock_tx = self.fundTx(b_lock_tx, feerate) b_lock_tx = self.fundTx(b_lock_tx, feerate)
script_pk = self.getPkDest(Kbs)
funded_tx = self.loadTx(b_lock_tx)
lock_vout = findOutput(funded_tx, script_pk)
b_lock_tx = self.signTxWithWallet(b_lock_tx) b_lock_tx = self.signTxWithWallet(b_lock_tx)
return bytes.fromhex(self.publishTx(b_lock_tx)) txid = bytes.fromhex(self.publishTx(b_lock_tx))
return txid, lock_vout
def getBLockSpendTxFee(self, tx, fee_rate: int) -> int: def getBLockSpendTxFee(self, tx, fee_rate: int) -> int:
witness_bytes = 115 witness_bytes = 115
@@ -1755,26 +1777,53 @@ class DCRInterface(Secp256k1Interface):
lock_tx_vout=None, lock_tx_vout=None,
) -> bytes: ) -> bytes:
self._log.info("spendBLockTx %s:\n", chain_b_lock_txid.hex()) self._log.info("spendBLockTx %s:\n", chain_b_lock_txid.hex())
locked_n = lock_tx_vout
Kbs = self.getPubkey(kbs) Kbs = self.getPubkey(kbs)
script_pk = self.getPkDest(Kbs) script_pk = self.getPkDest(Kbs)
if locked_n is None: locked_n = None
self._log.debug( actual_value = None
f"Unknown lock vout, searching tx: {chain_b_lock_txid.hex()}" wtx = self.rpc_wallet(
"gettransaction",
[
chain_b_lock_txid.hex(),
],
)
lock_tx = self.loadTx(bytes.fromhex(wtx["hex"]))
locked_n = findOutput(lock_tx, script_pk)
if locked_n is not None:
actual_value = lock_tx.vout[locked_n].value
else:
self._log.error(
f"spendBLockTx: Output not found in tx {chain_b_lock_txid.hex()}, "
f"script_pk={script_pk.hex()}, num_outputs={len(lock_tx.vout)}"
) )
# When refunding a lock tx, it should be in the wallet as a sent tx for i, out in enumerate(lock_tx.vout):
wtx = self.rpc_wallet( self._log.debug(
"gettransaction", f" vout[{i}]: value={out.value}, scriptPubKey={out.scriptPubKey.hex()}"
[ )
chain_b_lock_txid.hex(),
], if (
locked_n is not None
and lock_tx_vout is not None
and locked_n != lock_tx_vout
):
self._log.warning(
f"spendBLockTx: Stored vout {lock_tx_vout} differs from actual vout {locked_n} "
f"for tx {chain_b_lock_txid.hex()}"
) )
lock_tx = self.loadTx(bytes.fromhex(wtx["hex"]))
locked_n = findOutput(lock_tx, script_pk)
ensure(locked_n is not None, "Output not found in tx") ensure(locked_n is not None, "Output not found in tx")
spend_value = cb_swap_value
if spend_actual_balance and actual_value is not None:
if actual_value != cb_swap_value:
self._log.warning(
f"spendBLockTx: Spending actual balance {actual_value}, "
f"not expected swap value {cb_swap_value}."
)
spend_value = actual_value
pkh_to = self.decodeAddress(address_to) pkh_to = self.decodeAddress(address_to)
tx = CTransaction() tx = CTransaction()
@@ -1783,10 +1832,10 @@ class DCRInterface(Secp256k1Interface):
chain_b_lock_txid_int = b2i(chain_b_lock_txid) chain_b_lock_txid_int = b2i(chain_b_lock_txid)
tx.vin.append(CTxIn(COutPoint(chain_b_lock_txid_int, locked_n, 0), sequence=0)) tx.vin.append(CTxIn(COutPoint(chain_b_lock_txid_int, locked_n, 0), sequence=0))
tx.vout.append(self.txoType()(cb_swap_value, self.getPubkeyHashDest(pkh_to))) tx.vout.append(self.txoType()(spend_value, self.getPubkeyHashDest(pkh_to)))
pay_fee = self.getBLockSpendTxFee(tx, b_fee) pay_fee = self.getBLockSpendTxFee(tx, b_fee)
tx.vout[0].value = cb_swap_value - pay_fee tx.vout[0].value = spend_value - pay_fee
b_lock_spend_tx = tx.serialize() b_lock_spend_tx = tx.serialize()
b_lock_spend_tx = self.signTxWithKey(b_lock_spend_tx, kbs) b_lock_spend_tx = self.signTxWithKey(b_lock_spend_tx, kbs)

File diff suppressed because it is too large Load Diff

View File

@@ -64,7 +64,7 @@ class FIROInterface(BTCInterface):
# Firo shuts down after encryptwallet # Firo shuts down after encryptwallet
seed_id_before: str = self.getWalletSeedID() if check_seed else "Not found" seed_id_before: str = self.getWalletSeedID() if check_seed else "Not found"
self.rpc_wallet("encryptwallet", [password]) self.rpc_wallet("encryptwallet", [password], timeout=120)
if check_seed is False or seed_id_before == "Not found": if check_seed is False or seed_id_before == "Not found":
return return

View File

@@ -26,13 +26,103 @@ class LTCInterface(BTCInterface):
wallet=self._rpc_wallet_mweb, wallet=self._rpc_wallet_mweb,
) )
def checkWallets(self) -> int:
if self._connection_type == "electrum":
wm = self.getWalletManager()
if wm and wm.isInitialized(self.coin_type()):
return 1
return 0
wallets = self.rpc("listwallets")
if self._rpc_wallet not in wallets:
self._log.debug(
f"Wallet: {self._rpc_wallet} not active, attempting to load."
)
try:
self.rpc(
"loadwallet",
[
self._rpc_wallet,
],
)
wallets = self.rpc("listwallets")
except Exception as e:
self._log.debug(f'Error loading wallet "{self._rpc_wallet}": {e}.')
if "does not exist" in str(e) or "Path does not exist" in str(e):
try:
wallet_dirs = self.rpc("listwalletdir")
existing = [w["name"] for w in wallet_dirs.get("wallets", [])]
except Exception:
existing = []
if len(existing) == 0:
self._log.info(
f'Creating wallet "{self._rpc_wallet}" for {self.coin_name()}.'
)
try:
# wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors
self.rpc(
"createwallet",
[
self._rpc_wallet,
False,
True,
"",
False,
self._use_descriptors,
],
)
wallets = self.rpc("listwallets")
if self.getWalletSeedID() == "Not found":
self._log.info(
f"Initializing HD seed for {self.coin_name()}."
)
self._sc.initialiseWallet(self.coin_type())
except Exception as create_e:
self._log.error(f"Error creating wallet: {create_e}")
if self._rpc_wallet not in wallets and len(wallets) > 0:
self._log.warning(f"Changing {self.ticker()} wallet name.")
for wallet_name in wallets:
if wallet_name in ("mweb",):
continue
change_watchonly_wallet: bool = (
self._rpc_wallet_watch == self._rpc_wallet
)
self._rpc_wallet = wallet_name
self._log.info(
f"Switched {self.ticker()} wallet name to {self._rpc_wallet}."
)
self.rpc_wallet = make_rpc_func(
self._rpcport,
self._rpcauth,
host=self._rpc_host,
wallet=self._rpc_wallet,
)
if change_watchonly_wallet:
self.rpc_wallet_watch = self.rpc_wallet
break
return len(wallets)
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 +143,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:
@@ -86,6 +189,82 @@ class LTCInterface(BTCInterface):
) + self.make_int(u["amount"], r=1) ) + self.make_int(u["amount"], r=1)
return unspent_addr return unspent_addr
def unlockWallet(self, password: str, check_seed: bool = True) -> None:
if password == "":
return
self._log.info("unlockWallet - {}".format(self.ticker()))
if self.useBackend():
return
wallets = self.rpc("listwallets")
if self._rpc_wallet not in wallets:
try:
self.rpc("loadwallet", [self._rpc_wallet])
except Exception as e:
if "does not exist" in str(e) or "Path does not exist" in str(e):
try:
wallet_dirs = self.rpc("listwalletdir")
existing = [w["name"] for w in wallet_dirs.get("wallets", [])]
except Exception:
existing = []
if len(existing) == 0:
self._log.info(
f'Creating wallet "{self._rpc_wallet}" for {self.coin_name()}.'
)
# wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors
self.rpc(
"createwallet",
[
self._rpc_wallet,
False,
True,
password,
False,
self._use_descriptors,
],
)
else:
raise
try:
seed_id = self.getWalletSeedID()
needs_seed_init = seed_id == "Not found"
except Exception as e:
self._log.debug(f"getWalletSeedID failed: {e}")
needs_seed_init = True
if needs_seed_init:
self._log.info(f"Initializing HD seed for {self.coin_name()}.")
self._sc.initialiseWallet(self.coin_type())
if password:
self._log.info(f"Encrypting {self.coin_name()} wallet.")
try:
self.rpc_wallet("encryptwallet", [password], timeout=120)
except Exception as e:
self._log.debug(f"encryptwallet returned: {e}")
import time
for i in range(10):
time.sleep(1)
try:
self.rpc("listwallets")
break
except Exception:
self._log.debug(
f"Waiting for wallet after encryption... {i + 1}/10"
)
wallets = self.rpc("listwallets")
if self._rpc_wallet not in wallets:
self.rpc("loadwallet", [self._rpc_wallet])
self.setWalletSeedWarning(False)
check_seed = False
if self.isWalletEncrypted():
self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
if check_seed:
self._sc.checkWalletSeed(self.coin_type())
class LTCInterfaceMWEB(LTCInterface): class LTCInterfaceMWEB(LTCInterface):
@@ -129,13 +308,50 @@ class LTCInterfaceMWEB(LTCInterface):
self._log.info("init_wallet - {}".format(self.ticker())) self._log.info("init_wallet - {}".format(self.ticker()))
self._log.info(f"Creating wallet {self._rpc_wallet} for {self.coin_name()}.") wallets = self.rpc("listwallets")
# wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors, load_on_startup if self._rpc_wallet not in wallets:
self.rpc("createwallet", ["mweb", False, True, password, False, False, True]) try:
self.rpc("loadwallet", [self._rpc_wallet])
self._log.debug(f'Loaded existing wallet "{self._rpc_wallet}".')
except Exception as e:
if "does not exist" in str(e) or "Path does not exist" in str(e):
self._log.info(
f'Creating wallet "{self._rpc_wallet}" for {self.coin_name()}.'
)
# wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors
self.rpc(
"createwallet",
[
self._rpc_wallet,
False,
True,
password,
False,
self._use_descriptors,
],
)
else:
raise
wallets = self.rpc("listwallets")
if "mweb" not in wallets:
try:
self.rpc("loadwallet", ["mweb"])
self._log.debug("Loaded existing MWEB wallet.")
except Exception as e:
if "does not exist" in str(e) or "Path does not exist" in str(e):
self._log.info(f"Creating MWEB wallet for {self.coin_name()}.")
# wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors, load_on_startup
self.rpc(
"createwallet",
["mweb", False, True, password, False, False, True],
)
else:
raise
if password is not None: if password is not None:
# Max timeout value, ~3 years # Max timeout value, ~3 years
self.rpc_wallet("walletpassphrase", [password, 100000000]) self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
if self.getWalletSeedID() == "Not found": if self.getWalletSeedID() == "Not found":
self._sc.initialiseWallet(self.interface_type()) self._sc.initialiseWallet(self.interface_type())
@@ -144,7 +360,7 @@ class LTCInterfaceMWEB(LTCInterface):
self.rpc("unloadwallet", ["mweb"]) self.rpc("unloadwallet", ["mweb"])
self.rpc("loadwallet", ["mweb"]) self.rpc("loadwallet", ["mweb"])
if password is not None: if password is not None:
self.rpc_wallet("walletpassphrase", [password, 100000000]) self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
self.rpc_wallet("keypoolrefill") self.rpc_wallet("keypoolrefill")
def unlockWallet(self, password: str, check_seed: bool = True) -> None: def unlockWallet(self, password: str, check_seed: bool = True) -> None:
@@ -152,10 +368,22 @@ 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:
# Max timeout value, ~3 years self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
self.rpc_wallet("walletpassphrase", [password, 100000000]) try:
seed_id = self.getWalletSeedID()
needs_seed_init = seed_id == "Not found"
except Exception as e:
self._log.debug(f"getWalletSeedID failed: {e}")
needs_seed_init = True
if needs_seed_init:
self._log.info(f"Initializing HD seed for {self.coin_name()}.")
self._sc.initialiseWallet(self.interface_type())
if check_seed: if check_seed:
self._sc.checkWalletSeed(self.coin_type()) self._sc.checkWalletSeed(self.interface_type())

View File

@@ -40,7 +40,7 @@ class PIVXInterface(BTCInterface):
seed_id_before: str = self.getWalletSeedID() seed_id_before: str = self.getWalletSeedID()
self.rpc_wallet("encryptwallet", [password]) self.rpc_wallet("encryptwallet", [password], timeout=120)
if check_seed is False or seed_id_before == "Not found": if check_seed is False or seed_id_before == "Not found":
return return

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

@@ -137,7 +137,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:
@@ -170,8 +170,29 @@ 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
if (
v["connection_type"] == "electrum"
and hasattr(ci, "_backend")
and ci._backend
and hasattr(ci._backend, "getSyncStatus")
):
sync_status = ci._backend.getSyncStatus()
coin_entry["electrum_synced"] = sync_status.get("synced", False)
coin_entry["electrum_height"] = sync_status.get("height", 0)
coins_with_balances.append(coin_entry) coins_with_balances.append(coin_entry)
if k == Coins.PART: if k == Coins.PART:
@@ -295,6 +316,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")
@@ -308,6 +332,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:
@@ -601,8 +650,13 @@ def js_bids(self, url_split, post_string: str, is_json: bool) -> bytes:
) )
if have_data_entry(post_data, "debugind"): if have_data_entry(post_data, "debugind"):
main_debug_ind: bool = toBool(
get_data_entry_or(post_data, "maindebugind", True)
)
swap_client.setBidDebugInd( swap_client.setBidDebugInd(
bid_id, int(get_data_entry(post_data, "debugind")) bid_id,
int(get_data_entry(post_data, "debugind")),
add_to_bid=main_debug_ind,
) )
rv = {"bid_id": bid_id.hex()} rv = {"bid_id": bid_id.hex()}
@@ -620,8 +674,13 @@ def js_bids(self, url_split, post_string: str, is_json: bool) -> bytes:
elif have_data_entry(post_data, "abandon"): elif have_data_entry(post_data, "abandon"):
swap_client.abandonBid(bid_id) swap_client.abandonBid(bid_id)
elif have_data_entry(post_data, "debugind"): elif have_data_entry(post_data, "debugind"):
main_debug_ind: bool = toBool(
get_data_entry_or(post_data, "maindebugind", True)
)
swap_client.setBidDebugInd( swap_client.setBidDebugInd(
bid_id, int(get_data_entry(post_data, "debugind")) bid_id,
int(get_data_entry(post_data, "debugind")),
add_to_bid=main_debug_ind,
) )
if have_data_entry(post_data, "show_extra"): if have_data_entry(post_data, "show_extra"):
@@ -630,7 +689,9 @@ def js_bids(self, url_split, post_string: str, is_json: bool) -> bytes:
with_events = True with_events = True
bid, xmr_swap, offer, xmr_offer, events = swap_client.getXmrBidAndOffer(bid_id) bid, xmr_swap, offer, xmr_offer, events = swap_client.getXmrBidAndOffer(bid_id)
assert bid, "Unknown bid ID" if bid is None:
swap_client.log.debug(f"js_bids: Unknown bid id {bid_id.hex()}")
return bytes(json.dumps({"error": "Unknown bid id"}), "UTF-8")
if post_string != "": if post_string != "":
if have_data_entry(post_data, "chainbkeysplit"): if have_data_entry(post_data, "chainbkeysplit"):
@@ -1210,10 +1271,12 @@ def js_getcoinseed(self, url_split, post_string, is_json) -> bytes:
"current_seed_id": wallet_seed_id, "current_seed_id": wallet_seed_id,
} }
) )
if hasattr(ci, "canExportToElectrum") and ci.canExportToElectrum():
rv.update( if hasattr(ci, "getAccountKey"):
{"account_key": ci.getAccountKey(seed_key, extkey_prefix)} try:
) # Master key can be imported into electrum (Must set prefix for P2WPKH) rv.update({"account_key": ci.getAccountKey(seed_key, extkey_prefix)})
except Exception as e:
rv.update({"account_key_error": str(e)})
return bytes( return bytes(
json.dumps(rv), json.dumps(rv),
@@ -1528,6 +1591,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)
@@ -1571,10 +1699,221 @@ 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_modeswitchinfo(self, url_split, post_string, is_json) -> bytes:
swap_client = self.server.swap_client
swap_client.checkSystemStatus()
post_data = getFormData(post_string, is_json)
coin_str = get_data_entry(post_data, "coin")
direction = get_data_entry_or(post_data, "direction", "lite")
try:
coin_type = getCoinIdFromName(coin_str)
except Exception:
coin_type = getCoinIdFromTicker(coin_str.upper())
ci = swap_client.ci(coin_type)
ticker = ci.ticker()
try:
wallet_info = ci.getWalletInfo()
balance = wallet_info.get("balance", 0)
balance_sats = ci.make_int(balance)
except Exception as e:
return bytes(json.dumps({"error": f"Failed to get balance: {e}"}), "UTF-8")
try:
fee_rate, rate_src = ci.get_fee_rate(ci._conf_target)
est_vsize = 180
if isinstance(fee_rate, int):
fee_per_vbyte = max(1, fee_rate // 1000)
else:
fee_per_vbyte = max(1, int(fee_rate * 100000))
estimated_fee_sats = est_vsize * fee_per_vbyte
except Exception:
estimated_fee_sats = 180
rate_src = "default"
min_viable = estimated_fee_sats * 2
can_transfer = balance_sats > min_viable
rv = {
"coin": ticker,
"direction": direction,
"balance": balance,
"balance_sats": balance_sats,
"estimated_fee_sats": estimated_fee_sats,
"estimated_fee": ci.format_amount(estimated_fee_sats),
"fee_rate_src": rate_src,
"can_transfer": can_transfer,
"min_viable_sats": min_viable,
}
if direction == "lite":
non_bip84_balance_sats = 0
has_non_bip84_funds = False
try:
if hasattr(ci, "rpc_wallet"):
unspent = ci.rpc_wallet("listunspent")
wm = swap_client.getWalletManager()
bip84_addresses = set()
if wm:
try:
all_addrs = wm.getAllAddresses(
coin_type, include_watch_only=False
)
bip84_addresses = set(all_addrs)
except Exception:
pass
for u in unspent:
addr = u.get("address")
if not addr:
continue
amount_sats = ci.make_int(u.get("amount", 0))
if amount_sats <= 0:
continue
if addr not in bip84_addresses:
non_bip84_balance_sats += amount_sats
has_non_bip84_funds = True
except Exception as e:
swap_client.log.debug(f"Error checking non-BIP84 addresses: {e}")
if has_non_bip84_funds and non_bip84_balance_sats > min_viable:
rv["show_transfer_option"] = True
rv["require_transfer"] = True
rv["legacy_balance_sats"] = non_bip84_balance_sats
rv["legacy_balance"] = ci.format_amount(non_bip84_balance_sats)
rv["message"] = (
"Funds on non-derivable addresses must be transferred for external wallet compatibility"
)
else:
rv["show_transfer_option"] = False
rv["require_transfer"] = False
if has_non_bip84_funds:
rv["legacy_balance_sats"] = non_bip84_balance_sats
rv["legacy_balance"] = ci.format_amount(non_bip84_balance_sats)
rv["message"] = "Non-derivable balance too low to transfer"
else:
rv["legacy_balance_sats"] = 0
rv["legacy_balance"] = "0"
rv["message"] = "All funds on BIP84 addresses"
else:
rv["show_transfer_option"] = can_transfer
if balance_sats == 0:
rv["message"] = "No funds to transfer"
elif not can_transfer:
rv["message"] = "Balance too low to transfer (fee would exceed funds)"
else:
rv["message"] = ""
return bytes(json.dumps(rv), "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,
@@ -1604,6 +1943,8 @@ endpoints = {
"coinvolume": js_coinvolume, "coinvolume": js_coinvolume,
"coinhistory": js_coinhistory, "coinhistory": js_coinhistory,
"messageroutes": js_messageroutes, "messageroutes": js_messageroutes,
"electrumdiscover": js_electrum_discover,
"modeswitchinfo": js_modeswitchinfo,
} }

View File

@@ -152,15 +152,17 @@ class Jsonrpc:
pass pass
def callrpc(rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1"): def callrpc(
rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1", timeout=None
):
if _use_rpc_pooling: if _use_rpc_pooling:
return callrpc_pooled(rpc_port, auth, method, params, wallet, host) return callrpc_pooled(rpc_port, auth, method, params, wallet, host, timeout)
try: try:
url = "http://{}@{}:{}/".format(auth, host, rpc_port) url = "http://{}@{}:{}/".format(auth, host, rpc_port)
if wallet is not None: if wallet is not None:
url += "wallet/" + urllib.parse.quote(wallet) url += "wallet/" + urllib.parse.quote(wallet)
x = Jsonrpc(url) x = Jsonrpc(url, timeout=timeout if timeout else 10)
v = x.json_request(method, params) v = x.json_request(method, params)
x.close() x.close()
@@ -174,7 +176,9 @@ def callrpc(rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1"):
return r["result"] return r["result"]
def callrpc_pooled(rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1"): def callrpc_pooled(
rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1", timeout=None
):
from .rpc_pool import get_rpc_pool from .rpc_pool import get_rpc_pool
import http.client import http.client
import socket import socket
@@ -183,6 +187,20 @@ def callrpc_pooled(rpc_port, auth, method, params=[], wallet=None, host="127.0.0
if wallet is not None: if wallet is not None:
url += "wallet/" + urllib.parse.quote(wallet) url += "wallet/" + urllib.parse.quote(wallet)
if timeout:
try:
conn = Jsonrpc(url, timeout=timeout)
v = conn.json_request(method, params)
r = json.loads(v.decode("utf-8"))
conn.close()
if "error" in r and r["error"] is not None:
raise ValueError("RPC error " + str(r["error"]))
return r["result"]
except ValueError:
raise
except Exception as ex:
raise ValueError(f"RPC server error: {ex}, method: {method}")
max_connections = _rpc_pool_settings.get("max_connections_per_daemon", 5) max_connections = _rpc_pool_settings.get("max_connections_per_daemon", 5)
pool = get_rpc_pool(url, max_connections) pool = get_rpc_pool(url, max_connections)
@@ -247,7 +265,7 @@ def make_rpc_func(port, auth, wallet=None, host="127.0.0.1"):
wallet = wallet wallet = wallet
host = host host = host
def rpc_func(method, params=None, wallet_override=None): def rpc_func(method, params=None, wallet_override=None, timeout=None):
return callrpc( return callrpc(
port, port,
auth, auth,
@@ -255,6 +273,7 @@ def make_rpc_func(port, auth, wallet=None, host="127.0.0.1"):
params, params,
wallet if wallet_override is None else wallet_override, wallet if wallet_override is None else wallet_override,
host, host,
timeout=timeout,
) )
return rpc_func return rpc_func

View File

@@ -3,14 +3,41 @@
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';
}
} }
}, },
@@ -191,13 +236,39 @@
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'
@@ -609,7 +610,7 @@ function ensureToastContainer() {
clickAction = `onclick="window.location.href='/bid/${options.bidId}'"`; clickAction = `onclick="window.location.href='/bid/${options.bidId}'"`;
cursorStyle = 'cursor-pointer'; cursorStyle = 'cursor-pointer';
} else if (options.coinSymbol) { } else if (options.coinSymbol) {
clickAction = `onclick="window.location.href='/wallet/${options.coinSymbol}'"`; clickAction = `onclick="window.location.href='/wallet/${options.coinSymbol.toLowerCase()}'"`;
cursorStyle = 'cursor-pointer'; cursorStyle = 'cursor-pointer';
} else if (options.releaseUrl) { } else if (options.releaseUrl) {
clickAction = `onclick="window.open('${options.releaseUrl}', '_blank')"`; clickAction = `onclick="window.open('${options.releaseUrl}', '_blank')"`;
@@ -735,6 +736,18 @@ 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+$/, '');
const sweepTicker = data.ticker || data.coin_name;
toastTitle = `Swept ${sweepAmount} ${sweepTicker} to RPC wallet`;
toastOptions.subtitle = `Fee: ${sweepFee} ${sweepTicker} • TXID: ${(data.txid || '').substring(0, 12)}...`;
toastOptions.coinSymbol = sweepTicker;
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,614 @@
const BidPage = {
bidId: null,
bidStateInd: null,
createdAtTimestamp: null,
autoRefreshInterval: null,
elapsedTimeInterval: null,
AUTO_REFRESH_SECONDS: 60,
refreshPaused: false,
swapType: null,
coinFrom: null,
coinTo: null,
previousStateInd: null,
INACTIVE_STATES: [8, 17, 18, 19, 21, 22, 23, 25, 31], // Completed, Failed variants, Timed-out, Abandoned, Error, Rejected, Expired
DELAYING_STATE: 20,
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': null,
'Bid Script coin spend tx valid': null,
'Bid Scriptless coin locked': null,
'Bid Script coin lock released': 'Adaptor signature revealed. The script coin can now be claimed',
'Bid Script tx redeemed': null,
'Bid Script pre-refund tx in chain': 'Pre-refund transaction detected. Swap may be failing',
'Bid Scriptless tx redeemed': null,
'Bid Scriptless tx recovered': null,
'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 adaptor 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'
},
getStateTooltip: function(stateText) {
const staticTooltip = this.STATE_TOOLTIPS[stateText];
if (staticTooltip !== null && staticTooltip !== undefined) {
return staticTooltip;
}
const scriptlessCoins = ['XMR', 'WOW'];
let scriptCoin, scriptlessCoin;
if (scriptlessCoins.includes(this.coinFrom)) {
scriptlessCoin = this.coinFrom;
scriptCoin = this.coinTo;
} else if (scriptlessCoins.includes(this.coinTo)) {
scriptlessCoin = this.coinTo;
scriptCoin = this.coinFrom;
} else {
scriptCoin = this.coinFrom;
scriptlessCoin = this.coinTo;
}
const dynamicTooltips = {
'Bid Script coin locked': `${scriptCoin} is locked in the atomic swap contract`,
'Bid Script coin spend tx valid': `The ${scriptCoin} spend transaction has been validated and is ready`,
'Bid Scriptless coin locked': `${scriptlessCoin} is locked using adaptor signatures`,
'Bid Script tx redeemed': `${scriptCoin} has been successfully claimed`,
'Bid Scriptless tx redeemed': `${scriptlessCoin} has been successfully claimed`,
'Bid Scriptless tx recovered': `${scriptlessCoin} recovered after swap failure`,
};
return dynamicTooltips[stateText] || null;
},
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, options) {
this.bidId = bidId;
this.bidStateInd = bidStateInd;
this.createdAtTimestamp = createdAtTimestamp;
this.stateTimeTimestamp = stateTimeTimestamp;
this.tooltipCounter = 0;
options = options || {};
this.swapType = options.swapType || 'secret-hash';
this.coinFrom = options.coinFrom || '';
this.coinTo = options.coinTo || '';
if (this.bidStateInd === this.DELAYING_STATE) {
this.previousStateInd = this.findPreviousState();
}
this.applyStateTooltips();
this.applyEventTooltips();
this.createProgressBar();
this.startElapsedTimeUpdater();
this.setupAutoRefresh();
},
findPreviousState: 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');
for (let i = rows.length - 1; i >= 0; i--) {
const cells = rows[i].querySelectorAll('td');
if (cells.length >= 2) {
const stateText = cells[cells.length - 1].textContent.trim();
if (!stateText.includes('Delaying')) {
return this.stateTextToIndex(stateText);
}
}
}
}
}
return null;
},
stateTextToIndex: function(stateText) {
const stateMap = {
'Sent': 1, 'Receiving': 2, 'Received': 3, 'Receiving accept': 4,
'Accepted': 5, 'Initiated': 6, 'Participating': 7, 'Completed': 8,
'Script coin locked': 9, 'Script coin spend tx valid': 10,
'Scriptless coin locked': 11, 'Script coin lock released': 12,
'Script tx redeemed': 13, 'Script pre-refund tx in chain': 14,
'Scriptless tx redeemed': 15, 'Scriptless tx recovered': 16,
'Failed, refunded': 17, 'Failed, swiped': 18, 'Failed': 19,
'Delaying': 20, 'Timed-out': 21, 'Abandoned': 22, 'Error': 23,
'Rejected': 25, 'Exchanged script lock tx sigs msg': 27,
'Exchanged script lock spend tx msg': 28, 'Request sent': 29,
'Request accepted': 30, 'Expired': 31
};
for (const [key, value] of Object.entries(stateMap)) {
if (stateText.includes(key)) {
return value;
}
}
return null;
},
isActiveState: function() {
return !this.INACTIVE_STATES.includes(this.bidStateInd);
},
setupAutoRefresh: function() {
const refreshBtn = document.getElementById('refresh');
if (!refreshBtn) return;
if (!this.isActiveState()) {
refreshBtn.style.display = 'none';
return;
}
const originalSpan = refreshBtn.querySelector('span');
if (!originalSpan) return;
let countdown = this.AUTO_REFRESH_SECONDS;
let isRefreshing = false;
let isPersistentlyPaused = false;
const updateCountdown = () => {
if (this.refreshPaused || isPersistentlyPaused || isRefreshing) return;
originalSpan.textContent = `Auto-refresh in ${countdown}s`;
countdown--;
if (countdown < 0 && !isRefreshing) {
isRefreshing = true;
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
window.location.href = window.location.pathname + window.location.search;
}
};
updateCountdown();
this.autoRefreshInterval = setInterval(updateCountdown, 1000);
refreshBtn.addEventListener('click', (e) => {
e.preventDefault();
if (isPersistentlyPaused) {
window.location.href = window.location.pathname + window.location.search;
} else {
isPersistentlyPaused = true;
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
originalSpan.textContent = 'Paused (click to refresh)';
}
});
refreshBtn.addEventListener('mouseenter', () => {
if (!isPersistentlyPaused) {
this.refreshPaused = true;
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
originalSpan.textContent = 'Click to pause';
}
});
refreshBtn.addEventListener('mouseleave', () => {
if (!isPersistentlyPaused) {
this.refreshPaused = false;
countdown = this.AUTO_REFRESH_SECONDS;
if (!this.autoRefreshInterval) {
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.getStateTooltip(stateText) || this.getStateTooltip('Bid ' + 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.getStateTooltip(stateText) || this.getStateTooltip('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() {
let stateForProgress = this.bidStateInd;
let isDelaying = false;
if (this.bidStateInd === this.DELAYING_STATE && this.previousStateInd) {
stateForProgress = this.previousStateInd;
isDelaying = true;
}
const phaseInfo = this.STATE_PHASES[stateForProgress];
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');
let phaseLabel;
if (isError) {
phaseLabel = phaseInfo.label;
} else if (isComplete) {
phaseLabel = 'Complete';
} else if (isDelaying) {
phaseLabel = `${phaseInfo.label} (${progressPercent}%) - Delaying`;
} else {
phaseLabel = `${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

@@ -1,7 +1,7 @@
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const state = { const state = {
dentities: new Map(), identities: new Map(),
currentPage: 1, currentPage: 1,
wsConnected: false, wsConnected: false,
jsonData: [], jsonData: [],

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,410 @@
}); });
}, },
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;
select.addEventListener('change', (e) => {
const coinName = select.name.replace('connection_type_', '');
const electrumSection = document.getElementById(`electrum-section-${coinName}`);
const fundTransferSection = document.getElementById(`fund-transfer-section-${coinName}`);
const originalValue = this.originalConnectionTypes[select.name];
if (e.target.value === 'electrum') {
if (electrumSection) {
electrumSection.classList.remove('hidden');
const clearnetTextarea = document.getElementById(`electrum_clearnet_${coinName}`);
const onionTextarea = document.getElementById(`electrum_onion_${coinName}`);
if (clearnetTextarea && !clearnetTextarea.value.trim()) {
clearnetTextarea.value = electrumSection.dataset.defaultClearnet || '';
}
if (onionTextarea && !onionTextarea.value.trim()) {
onionTextarea.value = electrumSection.dataset.defaultOnion || '';
}
}
if (fundTransferSection) {
fundTransferSection.classList.add('hidden');
}
} else {
if (electrumSection) {
electrumSection.classList.add('hidden');
}
if (fundTransferSection && originalValue === 'electrum') {
fundTransferSection.classList.remove('hidden');
}
}
});
});
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);
let transferValue = null;
const transferRadio = document.querySelector('input[name="transfer_choice"]:checked');
const transferHidden = document.querySelector('input[name="transfer_choice"][type="hidden"]');
if (transferRadio) {
transferValue = transferRadio.value;
} else if (transferHidden) {
transferValue = transferHidden.value;
}
if (transferValue) {
const transferInput = document.createElement('input');
transferInput.type = 'hidden';
transferInput.name = `auto_transfer_now_${coinName}`;
transferInput.value = transferValue === 'auto' ? 'true' : 'false';
form.appendChild(transferInput);
}
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;
});
}
},
updateConfirmButtonState: function() {
const confirmBtn = document.getElementById('walletModeConfirm');
const checkbox = document.getElementById('walletModeKeyConfirmCheckbox');
if (confirmBtn && checkbox) {
if (checkbox.checked) {
confirmBtn.disabled = false;
confirmBtn.classList.remove('opacity-50', 'cursor-not-allowed');
} else {
confirmBtn.disabled = true;
confirmBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
}
},
showWalletModeConfirmation: async function(coinName, direction, submitter) {
const modal = document.getElementById('walletModeModal');
const title = document.getElementById('walletModeTitle');
const message = document.getElementById('walletModeMessage');
const details = document.getElementById('walletModeDetails');
const confirmBtn = document.getElementById('walletModeConfirm');
if (!modal || !title || !message || !details) return;
this.pendingModeSwitch = { coinName, direction, submitter };
const displayName = coinName.charAt(0).toUpperCase() + coinName.slice(1).toLowerCase();
details.innerHTML = `
<div class="flex items-center justify-center py-4">
<svg class="animate-spin h-5 w-5 text-blue-500 mr-2" 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>
<span>Loading...</span>
</div>
`;
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
modal.classList.remove('hidden');
if (direction === 'lite') {
title.textContent = `Switch ${displayName} to Lite Wallet Mode`;
message.textContent = 'Write down this key before switching. It will only be shown ONCE.';
try {
const [infoResponse, seedResponse] = await Promise.all([
fetch('/json/modeswitchinfo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coin: coinName, direction: 'lite' })
}),
fetch('/json/getcoinseed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coin: coinName })
})
]);
const info = await infoResponse.json();
const data = await seedResponse.json();
let transferSection = '';
if (info.require_transfer && info.legacy_balance_sats > 0) {
transferSection = `
<div class="bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-500 rounded-lg p-3 mb-3">
<p class="text-sm font-medium text-gray-900 dark:text-white mb-2">Funds Transfer Required</p>
<p class="text-xs text-gray-700 dark:text-gray-200 mb-2">
<strong>${info.legacy_balance} ${info.coin}</strong> on non-derivable addresses will be automatically transferred to a BIP84 address.
</p>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">
Est. fee: ${info.estimated_fee} ${info.coin}
</p>
<p class="text-xs text-gray-700 dark:text-gray-200">
This ensures your funds are recoverable using the extended key backup in external Electrum wallets.
</p>
<input type="hidden" name="transfer_choice" value="auto">
</div>
`;
} else if (info.legacy_balance_sats > 0 && !info.show_transfer_option) {
transferSection = `
<p class="text-gray-700 dark:text-gray-300 text-xs mb-3">
Some funds on non-derivable addresses (${info.legacy_balance} ${info.coin}) - too low to transfer.
</p>
`;
}
if (data.account_key) {
details.innerHTML = `
<p class="mb-2 text-red-600 dark:text-red-300 font-semibold">
IMPORTANT: Write down this key NOW. It will not be shown again.
</p>
<p class="mb-2 text-gray-800 dark:text-gray-100"><strong>Extended Private Key (for external wallet import):</strong></p>
<div class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-500 rounded p-2 mb-3">
<code id="extendedKeyDisplay" class="text-xs break-all font-mono text-gray-900 dark:text-gray-100">${'*'.repeat(Math.min(data.account_key.length, 80))}</code>
<code id="extendedKeyActual" class="text-xs break-all select-all font-mono text-gray-900 dark:text-gray-100 hidden">${data.account_key}</code>
</div>
<div class="mb-3">
<button type="button" id="toggleKeyVisibility" class="px-3 py-1 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded">
Show Key
</button>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300 mb-3 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded p-2">
<p class="font-medium mb-1 text-gray-800 dark:text-gray-100">To import in Electrum wallet:</p>
<ol class="list-decimal list-inside space-y-0.5">
<li>Open Electrum → File → New/Restore</li>
<li>Choose "Standard wallet" → "Use a master key"</li>
<li>Paste this key (starts with zprv... or yprv...)</li>
</ol>
</div>
${transferSection}
<div class="border-t border-gray-300 dark:border-gray-500 pt-3">
<label class="flex items-center cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-500 rounded p-1 -m-1">
<input type="checkbox" id="walletModeKeyConfirmCheckbox" class="mr-2 h-4 w-4 text-blue-600 rounded border-gray-300 dark:border-gray-500 focus:ring-blue-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-800 dark:text-gray-100">I have written down this key</span>
</label>
</div>
`;
const toggleBtn = document.getElementById('toggleKeyVisibility');
const keyDisplay = document.getElementById('extendedKeyDisplay');
const keyActual = document.getElementById('extendedKeyActual');
if (toggleBtn && keyDisplay && keyActual) {
toggleBtn.addEventListener('click', () => {
if (keyDisplay.classList.contains('hidden')) {
keyDisplay.classList.remove('hidden');
keyActual.classList.add('hidden');
toggleBtn.textContent = 'Show Key';
} else {
keyDisplay.classList.add('hidden');
keyActual.classList.remove('hidden');
toggleBtn.textContent = 'Hide Key';
}
});
}
const checkbox = document.getElementById('walletModeKeyConfirmCheckbox');
if (checkbox) {
checkbox.addEventListener('change', () => this.updateConfirmButtonState());
}
} else {
details.innerHTML = `
<p class="mb-2 text-gray-800 dark:text-gray-100"><strong>Before switching:</strong></p>
<ul class="list-disc list-inside space-y-1 text-gray-700 dark:text-gray-200">
<li>Active swaps must be completed first</li>
<li>Wait for any pending transactions to confirm</li>
</ul>
${transferSection}
<p class="mt-3 text-green-700 dark:text-green-300">
<strong>Note:</strong> Your balance will remain accessible - same seed means same funds in both modes.
</p>
`;
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
}
} catch (error) {
console.error('Failed to fetch coin seed:', error);
details.innerHTML = `
<p class="text-red-600 dark:text-red-300 mb-2">Failed to retrieve extended key. Please try again.</p>
<p class="mb-2 text-gray-800 dark:text-gray-100"><strong>Before switching:</strong></p>
<ul class="list-disc list-inside space-y-1 text-gray-700 dark:text-gray-200">
<li>Active swaps must be completed first</li>
<li>Wait for any pending transactions to confirm</li>
</ul>
`;
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
}
} else {
title.textContent = `Switch ${displayName} to Full Node Mode`;
message.textContent = 'Please confirm you want to switch to full node mode.';
try {
const response = await fetch('/json/modeswitchinfo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coin: coinName, direction: 'rpc' })
});
const info = await response.json();
let transferSection = '';
if (info.error) {
transferSection = `<p class="text-yellow-700 dark:text-yellow-300 text-sm">${info.error}</p>`;
} else if (info.balance_sats === 0) {
transferSection = `<p class="text-gray-600 dark:text-gray-300 text-sm">No funds to transfer.</p>`;
} else if (!info.can_transfer) {
transferSection = `
<p class="text-yellow-700 dark:text-yellow-300 text-sm">
Balance (${info.balance} ${info.coin}) is too low to transfer - fee would exceed funds.
</p>
`;
} else {
transferSection = `
<div class="bg-gray-200 dark:bg-gray-700 border border-gray-300 dark:border-gray-500 rounded-lg p-3 mb-3">
<p class="text-sm font-medium text-gray-900 dark:text-white mb-2">Fund Transfer Options</p>
<p class="text-xs text-gray-700 dark:text-gray-300 mb-3">
Balance: ${info.balance} ${info.coin} | Est. fee: ${info.estimated_fee} ${info.coin}
</p>
<div class="space-y-2">
<label class="flex items-start cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 rounded p-1.5 -m-1">
<input type="radio" name="transfer_choice" value="auto" checked class="mt-0.5 mr-2 h-4 w-4 text-blue-600 border-gray-400 dark:border-gray-400 focus:ring-blue-500 bg-white dark:bg-gray-500">
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">Auto-transfer funds to RPC wallet</span>
<p class="text-xs text-gray-600 dark:text-gray-300">Recommended. Ensures all funds visible in full node wallet.</p>
</div>
</label>
<label class="flex items-start cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 rounded p-1.5 -m-1">
<input type="radio" name="transfer_choice" value="manual" class="mt-0.5 mr-2 h-4 w-4 text-blue-600 border-gray-400 dark:border-gray-400 focus:ring-blue-500 bg-white dark:bg-gray-500">
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">Keep funds on current addresses</span>
<p class="text-xs text-gray-600 dark:text-gray-300">Transfer manually later if needed.</p>
</div>
</label>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-3">
If you skip transfer, you will need to manually send funds from lite wallet addresses to your RPC wallet.
</p>
</div>
`;
}
details.innerHTML = `
<p class="mb-2 text-gray-800 dark:text-gray-100"><strong>Switching to full node mode:</strong></p>
<ul class="list-disc list-inside space-y-1 mb-3 text-gray-700 dark:text-gray-200">
<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>
${transferSection}
`;
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
} catch (error) {
console.error('Failed to fetch mode switch info:', error);
details.innerHTML = `
<p class="mb-2 text-gray-800 dark:text-gray-100"><strong>Switching to full node mode:</strong></p>
<ul class="list-disc list-inside space-y-1 text-gray-700 dark:text-gray-200">
<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>
`;
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
}
}
},
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 +715,167 @@
} }
}; };
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) {
const serverName = data.current_server ? `${data.current_server.host}:${data.current_server.port}` : 'The connected server';
html = `<div class="text-sm text-gray-500 dark:text-gray-400 py-4 text-center">No servers discovered. <span class="font-mono">${serverName}</span> does not return peer lists.</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) {
window.BalanceUpdatesManager.fetchBalanceData()
.then(data => this.handleBalanceUpdate(data))
.catch(error => console.error('Error updating balance after swap:', error));
}
},
console.log('Swap event:', eventData); 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,72 @@
} }
} }
} }
if (coinData.scan_status || coinData.electrum_synced !== undefined) {
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 && 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 if (coinData.electrum_synced) {
const height = coinData.electrum_height || '';
scanStatusEl.innerHTML = `
<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 (${height})
</div>
</div>
`;
} else if (coinData.electrum_synced === false) {
scanStatusEl.innerHTML = `
<div class="bg-yellow-50 dark:bg-gray-500 p-2 rounded">
<div class="flex items-center text-xs text-yellow-600 dark:text-yellow-400">
Waiting for Electrum Server...
</div>
</div>
`;
} else {
scanStatusEl.innerHTML = `
<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>
`;
}
}, },
updateSpecificBalance: function(coinName, labelText, balance, ticker, isPending = false) { updateSpecificBalance: function(coinName, labelText, balance, ticker, isPending = false) {
@@ -102,12 +168,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 +206,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 +286,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 +398,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,16 @@ 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' }}, {
swapType: 'secret-hash',
coinFrom: '{{ data.ticker_from }}',
coinTo: '{{ data.ticker_to }}'
});
});
</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,16 @@ 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' }}, {
swapType: 'adaptor-sig',
coinFrom: '{{ data.ticker_from }}',
coinTo: '{{ data.ticker_to }}'
});
});
</script>
</div> </div>
{% include 'footer.html' %} {% include 'footer.html' %}
</body> </body>

View File

@@ -24,9 +24,9 @@
<div class="w-full md:w-1/2 mb-6 md:mb-0"> <div class="w-full md:w-1/2 mb-6 md:mb-0">
<div class="flex items-center"> <div class="flex items-center">
<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">© 2026~ (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.1</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

@@ -111,9 +111,23 @@
<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 %}
@@ -144,6 +158,163 @@
</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">Light Wallet Mode (Electrum):</p>
<ul class="text-xs text-gray-700 dark:text-gray-200 space-y-0.5 ml-3">
<li>• No blockchain download needed - connect via external Electrum servers</li>
<li>• Uses BIP84 derivation (native SegWit) - lower fees, modern addresses (bc1q.../ltc1q...)</li>
<li>• You receive an extended private key (zprv/...) that can be imported into external wallets</li>
<li>• Best for: fresh installs, low storage, quick setup, mobile-friendly</li>
</ul>
</div>
<div>
<p class="text-xs font-medium text-gray-900 dark:text-white mb-1">Full Node Mode (RPC):</p>
<ul class="text-xs text-gray-700 dark:text-gray-200 space-y-0.5 ml-3">
<li>• Maximum privacy - no external servers, your node validates everything</li>
<li>• More wallet features: coin control, RBF, CPFP, raw transactions</li>
<li>• Supports legacy address types and coin-specific features (e.g. MWEB for LTC)</li>
<li>• Best for: existing node users, power users, maximum control</li>
</ul>
</div>
<div>
<p class="text-xs font-medium text-gray-900 dark:text-white mb-1">When switching modes:</p>
<ul class="text-xs text-gray-700 dark:text-gray-200 space-y-0.5 ml-3">
<li>• To Light: Save your BIP84 key shown during switch (for external wallet import)</li>
<li>• To Full Node: Funds on light wallet addresses must be transferred (network fee applies)</li>
<li>• Both modes share the same seed - switching is safe, just save keys when shown</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.supports_electrum %}
<div id="electrum-section-{{ c.name }}" class="mb-6 {% if c.connection_type != 'electrum' %}hidden{% endif %}"
data-default-clearnet="{{ c.clearnet_servers_text }}"
data-default-onion="{{ c.onion_servers_text }}">
<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">{% if c.connection_type == 'electrum' %}{{ c.clearnet_servers_text }}{% endif %}</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">{% if c.connection_type == 'electrum' %}{{ c.onion_servers_text }}{% endif %}</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 id="fund-transfer-section-{{ c.name }}" class="mb-6 hidden">
{% if c.lite_wallet_balance %}
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">
Pending Balance
</h4>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<div class="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>
</div>
{% endif %}
{% if general_settings.debug %}
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4 {% if c.lite_wallet_balance %}mt-6{% endif %}">
Advanced
</h4>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<div>
<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="50">
<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>
</div>
{% endif %}
</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 +827,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-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-700 font-medium text-sm text-gray-800 dark:text-white rounded-md border border-gray-300 dark:border-gray-500 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

@@ -84,6 +84,36 @@
</section> </section>
{% endif %} {% endif %}
{% if legacy_funds_info and legacy_funds_info.has_legacy_funds %}
<section class="py-4 px-6" id="legacy_funds_warning">
<div class="lg:container mx-auto">
<div class="p-6 rounded-lg bg-yellow-50 border border-yellow-400 dark:bg-yellow-900/30 dark:border-yellow-700">
<div class="flex flex-wrap justify-between items-center -m-2">
<div class="flex-1 p-2">
<div class="flex flex-wrap -m-1">
<div class="w-auto p-1">
<svg class="w-6 h-6 text-yellow-600 dark:text-yellow-400" 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"/>
</svg>
</div>
<div class="ml-3 flex-1">
<p class="font-semibold text-lg lg:text-sm text-yellow-700 dark:text-yellow-300">Legacy Address Funds</p>
<p class="mt-1 text-sm text-yellow-600 dark:text-yellow-400">
{{ legacy_funds_info.legacy_balance }} {{ legacy_funds_info.coin }} on legacy addresses won't be visible in external Electrum wallet.
To use funds with external wallets, transfer to a new address.
</p>
<p class="mt-2 text-xs text-yellow-500 dark:text-yellow-500">
Use the withdraw function below to send funds to a new <code class="bg-yellow-100 dark:bg-yellow-800/50 px-1 rounded">{{ w.ticker | lower }}1...</code> address.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{% endif %}
<section> <section>
<form method="post" autocomplete="off"> <form method="post" autocomplete="off">
<div class="px-6 py-0 h-full overflow-hidden"> <div class="px-6 py-0 h-full overflow-hidden">
@@ -113,6 +143,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 #}
@@ -141,11 +174,26 @@
<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>
</tr>
{% elif w.cid == '13' %} {# FIRO #}
<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 }} Spark"> </span>Spark Balance: </td>
<td class="py-3 px-6 bold">
<span class="coinname-value" data-coinname="{{ w.name }}">{{ w.spark_balance }} {{ w.ticker }}</span>
(<span class="usd-value"></span>)
{% if w.spark_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.spark_pending }} {{ w.ticker }} </span>
{% endif %}
</td> </td>
</tr> </tr>
{% elif w.cid == '13' %} {# FIRO #} {% elif w.cid == '13' %} {# FIRO #}
@@ -177,6 +225,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 }}
@@ -192,8 +253,70 @@
{% 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 %}
{% if w.electrum_synced %}
<span class="text-green-600 dark:text-green-400">Electrum Wallet Synced ({{ w.electrum_height }})</span>
{% else %}
<span class="text-yellow-600 dark:text-yellow-400">Waiting for Electrum Server...</span>
{% endif %}
{% 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>
@@ -333,13 +456,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">
@@ -347,6 +471,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 #}
{% elif w.cid == '13' %} {% elif w.cid == '13' %}
{# FIRO #} {# FIRO #}
@@ -379,10 +504,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">
@@ -423,8 +544,21 @@
<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>
</tr>
{% elif w.cid == '13' %}
{# FIRO #}
<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>Spark Balance: </td>
<td class="py-3 px-6">
<span class="coinname-value" data-coinname="{{ w.name }}">{{ w.spark_balance }} {{ w.ticker }}</span>
(<span class="usd-value"></span>)
</td> </td>
</tr> </tr>
{% elif w.cid == '13' %} {% elif w.cid == '13' %}
@@ -588,7 +722,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">
@@ -653,6 +787,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 %}
@@ -740,6 +876,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>
@@ -181,6 +197,39 @@
<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>
{% elif w.electrum_synced %}
<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 ({{ w.electrum_height }})
</div>
</div>
{% else %}
<div class="bg-yellow-50 dark:bg-gray-500 p-2 rounded">
<div class="flex items-center text-xs text-yellow-600 dark:text-yellow-400">
Waiting for Electrum Server...
</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>
@@ -201,6 +250,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
@@ -249,6 +253,14 @@ def parseOfferFormData(swap_client, form_data, page_data, options={}):
get_data_entry(form_data, "valid_for_seconds") get_data_entry(form_data, "valid_for_seconds")
) )
if swap_client.debug:
if have_data_entry(form_data, "lock_type"):
parsed_data["lock_type"] = TxLockTypes(
int(get_data_entry(form_data, "lock_type"))
)
if have_data_entry(form_data, "lock_blocks"):
parsed_data["lock_blocks"] = int(get_data_entry(form_data, "lock_blocks"))
try: try:
if len(errors) == 0 and page_data["swap_style"] == "xmr": if len(errors) == 0 and page_data["swap_style"] == "xmr":
reverse_bid: bool = swap_client.is_reverse_ads_bid(coin_from, coin_to) reverse_bid: bool = swap_client.is_reverse_ads_bid(coin_from, coin_to)
@@ -342,7 +354,15 @@ def postNewOfferFromParsed(swap_client, parsed_data):
lock_type = TxLockTypes.ABS_LOCK_TIME lock_type = TxLockTypes.ABS_LOCK_TIME
extra_options = {} extra_options = {}
lock_value: int = parsed_data.get("lock_seconds", -1)
if swap_client.debug:
if "lock_type" in parsed_data:
lock_type = parsed_data["lock_type"]
if "lock_blocks" in parsed_data:
lock_value = parsed_data["lock_blocks"]
if "fee_from_conf" in parsed_data:
extra_options["from_fee_conf_target"] = parsed_data["fee_from_conf"]
if "fee_from_conf" in parsed_data: if "fee_from_conf" in parsed_data:
extra_options["from_fee_conf_target"] = parsed_data["fee_from_conf"] extra_options["from_fee_conf_target"] = parsed_data["fee_from_conf"]
if "from_fee_multiplier_percent" in parsed_data: if "from_fee_multiplier_percent" in parsed_data:
@@ -393,7 +413,7 @@ def postNewOfferFromParsed(swap_client, parsed_data):
parsed_data["amt_bid_min"], parsed_data["amt_bid_min"],
swap_type, swap_type,
lock_type=lock_type, lock_type=lock_type,
lock_value=parsed_data["lock_seconds"], lock_value=lock_value,
addr_send_from=parsed_data["addr_from"], addr_send_from=parsed_data["addr_from"],
extra_options=extra_options, extra_options=extra_options,
) )

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,70 @@ 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_now = have_data_entry(
form_data, "auto_transfer_now_" + name
)
if auto_transfer_now:
transfer_value = get_data_entry_or(
form_data, "auto_transfer_now_" + name, "false"
)
data["auto_transfer_now"] = transfer_value == "true"
gap_limit_str = get_data_entry_or(
form_data, "gap_limit_" + name, "50"
).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 +220,71 @@ 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 not clearnet_servers:
default_clearnet = DEFAULT_ELECTRUM_SERVERS.get(name, [])
clearnet_servers = [
f"{s['host']}:{s['port']}:{str(s.get('ssl', True)).lower()}"
for s in default_clearnet
]
if not onion_servers:
default_onion = DEFAULT_ONION_SERVERS.get(name, [])
onion_servers = [
f"{s['host']}:{s['port']}:{str(s.get('ssl', False)).lower()}"
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 +292,10 @@ 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,
"address_gap_limit": c.get("address_gap_limit", 50),
} }
) )
if name in ("monero", "wownero"): if name in ("monero", "wownero"):
@@ -203,6 +323,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", "?")
@@ -87,10 +95,92 @@ def format_wallet_data(swap_client, ci, w):
wf["spark_balance"] = w.get("spark_balance", "?") wf["spark_balance"] = w.get("spark_balance", "?")
wf["spark_pending"] = w.get("spark_pending", "?") wf["spark_pending"] = w.get("spark_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"
try:
sync_status = backend.getSyncStatus()
wf["electrum_synced"] = sync_status.get("synced", False)
wf["electrum_height"] = sync_status.get("height", 0)
except Exception:
wf["electrum_synced"] = False
wf["electrum_height"] = 0
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
@@ -135,6 +225,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),
}, },
) )
@@ -155,8 +246,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)
@@ -176,6 +284,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
@@ -221,7 +345,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)
@@ -311,8 +442,12 @@ def page_wallet(self, url_split, post_string):
if swap_client.debug is True: if swap_client.debug is True:
swap_client.log.error(traceback.format_exc()) swap_client.log.error(traceback.format_exc())
is_electrum_mode = (
swap_client.coin_clients.get(coin_id, {}).get("connection_type") == "electrum"
)
swap_client.updateWalletsInfo( swap_client.updateWalletsInfo(
force_refresh, only_coin=coin_id, wait_for_complete=True force_refresh, only_coin=coin_id, wait_for_complete=not is_electrum_mode
) )
wallets = swap_client.getCachedWalletsInfo({"coin_id": coin_id}) wallets = swap_client.getCachedWalletsInfo({"coin_id": coin_id})
wallet_data = {} wallet_data = {}
@@ -334,6 +469,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)
@@ -408,6 +546,30 @@ 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
legacy_funds_info = None
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)
else:
if coin_id in (Coins.BTC, Coins.LTC):
legacy_funds_info = swap_client.getElectrumLegacyFundsInfo(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,
@@ -419,5 +581,12 @@ 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,
"legacy_funds_info": legacy_funds_info,
"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))

821
basicswap/wallet_backend.py Normal file
View File

@@ -0,0 +1,821 @@
# -*- 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._cached_fee = {}
self._cached_fee_time = {}
self._fee_cache_ttl = 300
self._max_batch_size = 5
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)
if hasattr(self._server, "call_user"):
return self._server.call_user(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)
if hasattr(self._server, "call_batch_user"):
return self._server.call_batch_user(calls, timeout)
return self._server.call_batch(calls, timeout)
def _is_server_stopping(self) -> bool:
return getattr(self._server, "_stopping", False)
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):
if self._is_server_stopping():
self._log.debug("_split_batch_call: server stopping, aborting")
break
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:
if self._is_server_stopping():
self._log.debug(
"_split_batch_call: server stopping after batch failure, aborting"
)
break
for sh in chunk:
if self._is_server_stopping():
self._log.debug(
"_split_batch_call: server stopping during fallback, aborting"
)
break
try:
result = self._call(method, [sh])
all_results.append(result)
except Exception:
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 = self._max_batch_size
for batch_start in range(0, len(addr_list), batch_size):
if self._is_server_stopping():
break
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 self._is_server_stopping():
break
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():
if self._is_server_stopping():
break
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",
"non-bip68-final",
"non-final",
"locktime",
]
):
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:
now = time.time()
cache_key = blocks
if cache_key in self._cached_fee:
if (now - self._cached_fee_time.get(cache_key, 0)) < self._fee_cache_ttl:
return self._cached_fee[cache_key]
try:
fee = self._call("blockchain.estimatefee", [blocks])
if fee and fee > 0:
result = int(fee * 1e8 / 1000)
self._cached_fee[cache_key] = result
self._cached_fee_time[cache_key] = now
return result
return self._cached_fee.get(cache_key, 1)
except Exception:
return self._cached_fee.get(cache_key, 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 recentlyReconnected(self, grace_seconds: int = 30) -> bool:
if hasattr(self._server, "recently_reconnected"):
return self._server.recently_reconnected(grace_seconds)
return False
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 getSyncStatus(self) -> dict:
import time
height = 0
height_time = 0
if hasattr(self._server, "get_subscribed_height"):
height = self._server.get_subscribed_height()
height_time = getattr(self._server, "_subscribed_height_time", 0)
if self._cached_height > 0:
if self._cached_height > height:
height = self._cached_height
if self._cached_height_time > height_time:
height_time = self._cached_height_time
now = time.time()
stale_threshold = 300
is_synced = height > 0 and (now - height_time) < stale_threshold
return {
"height": height,
"synced": is_synced,
"last_update": height_time,
}
def getServer(self):
return self._server

2034
basicswap/wallet_manager.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright (c) 2020-2024 tecnovert # Copyright (c) 2020-2024 tecnovert
# Copyright (c) 2024-2025 The Basicswap developers # Copyright (c) 2024-2026 The Basicswap developers
# Distributed under the MIT software license, see the accompanying # Distributed under the MIT software license, see the accompanying
# file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php. # file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php.
@@ -15,6 +15,7 @@ import subprocess
from urllib.request import urlopen from urllib.request import urlopen
from .util import read_json_api from .util import read_json_api
from basicswap.basicswap import Coins
from basicswap.rpc import callrpc from basicswap.rpc import callrpc
from basicswap.util import toBool from basicswap.util import toBool
from basicswap.contrib.rpcauth import generate_salt, password_to_hmac from basicswap.contrib.rpcauth import generate_salt, password_to_hmac
@@ -125,6 +126,65 @@ def prepareDataDir(
return node_dir return node_dir
def prepare_balance(
use_delay_event,
coin,
amount: float,
port_target_node: int,
port_take_from_node: int,
test_balance: bool = True,
) -> None:
if coin == Coins.PART_BLIND:
coin_ticker: str = "PART"
balance_type: str = "blind_balance"
address_type: str = "stealth_address"
type_to: str = "blind"
elif coin == Coins.PART_ANON:
coin_ticker: str = "PART"
balance_type: str = "anon_balance"
address_type: str = "stealth_address"
type_to: str = "anon"
else:
coin_ticker: str = coin.name
balance_type: str = "balance"
address_type: str = "deposit_address"
js_w = read_json_api(port_target_node, "wallets")
current_balance: float = float(js_w[coin_ticker][balance_type])
if test_balance and current_balance >= amount:
return
post_json = {
"value": amount,
"address": js_w[coin_ticker][address_type],
"subfee": False,
}
if coin in (Coins.XMR, Coins.WOW):
post_json["sweepall"] = False
if coin in (Coins.PART_BLIND, Coins.PART_ANON):
post_json["type_to"] = type_to
json_rv = read_json_api(
port_take_from_node,
"wallets/{}/withdraw".format(coin_ticker.lower()),
post_json,
)
assert len(json_rv["txid"]) == 64
wait_for_amount: float = amount
if not test_balance:
wait_for_amount += current_balance
delay_iterations = 100 if coin == Coins.NAV else 30
delay_time = 5 if coin == Coins.NAV else 3
wait_for_balance(
use_delay_event,
"http://127.0.0.1:{}/json/wallets/{}".format(
port_target_node, coin_ticker.lower()
),
balance_type,
wait_for_amount,
iterations=delay_iterations,
delay_time=delay_time,
)
def checkForks(ro): def checkForks(ro):
try: try:
if "bip9_softforks" in ro: if "bip9_softforks" in ro:

View File

@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright (c) 2020-2024 tecnovert # Copyright (c) 2020-2024 tecnovert
# Copyright (c) 2024-2025 The Basicswap developers # Copyright (c) 2024-2026 The Basicswap developers
# 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.
@@ -139,6 +139,7 @@ def run_prepare(
use_rpcauth=False, use_rpcauth=False,
extra_settings={}, extra_settings={},
port_ofs=0, port_ofs=0,
extra_args=[],
): ):
config_path = os.path.join(datadir_path, cfg.CONFIG_FILENAME) config_path = os.path.join(datadir_path, cfg.CONFIG_FILENAME)
@@ -180,7 +181,7 @@ def run_prepare(
"-noextractover", "-noextractover",
"-noreleasesizecheck", "-noreleasesizecheck",
"-xmrrestoreheight=0", "-xmrrestoreheight=0",
] ] + extra_args
if mnemonic_in: if mnemonic_in:
testargs.append(f'-particl_mnemonic="{mnemonic_in}"') testargs.append(f'-particl_mnemonic="{mnemonic_in}"')
@@ -645,6 +646,7 @@ class XmrTestBase(TestBase):
prepare_nodes(3, "monero") prepare_nodes(3, "monero")
def start_processes(self): def start_processes(self):
multiprocessing.set_start_method("fork")
self.delay_event.clear() self.delay_event.clear()
for i in range(3): for i in range(3):

View File

@@ -1489,7 +1489,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, fee_rate) result = ci.publishBLockTx(v, S, amount, fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
test_delay_event.wait(1) test_delay_event.wait(1)
addr_out = ci.getNewAddress(True) addr_out = ci.getNewAddress(True)

View File

@@ -412,7 +412,11 @@ class Test(TestFunctions):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, fee_rate) result = ci.publishBLockTx(v, S, amount, fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
test_delay_event.wait(1) test_delay_event.wait(1)
addr_out = ci.getNewAddress(False) addr_out = ci.getNewAddress(False)

View File

@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright (c) 2021-2024 tecnovert # Copyright (c) 2021-2024 tecnovert
# Copyright (c) 2024-2025 The Basicswap developers # Copyright (c) 2024-2026 The Basicswap developers
# 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.
@@ -209,7 +209,7 @@ def updateThread(cls):
calldogerpc(0, "generatetoaddress", [1, cls.doge_addr]) calldogerpc(0, "generatetoaddress", [1, cls.doge_addr])
except Exception as e: except Exception as e:
print("updateThread error", str(e)) print("updateThread error", str(e))
cls.delay_event.wait(random.randrange(cls.update_min, cls.update_max)) cls.delay_event.wait(random.uniform(cls.update_min, cls.update_max))
def updateThreadXMR(cls): def updateThreadXMR(cls):
@@ -228,7 +228,7 @@ def updateThreadXMR(cls):
) )
except Exception as e: except Exception as e:
print("updateThreadXMR error", str(e)) print("updateThreadXMR error", str(e))
cls.delay_event.wait(random.randrange(cls.xmr_update_min, cls.xmr_update_max)) cls.delay_event.wait(random.uniform(cls.xmr_update_min, cls.xmr_update_max))
def updateThreadDCR(cls): def updateThreadDCR(cls):
@@ -262,7 +262,7 @@ def updateThreadDCR(cls):
logging.warning("updateThreadDCR generate {}".format(e)) logging.warning("updateThreadDCR generate {}".format(e))
except Exception as e: except Exception as e:
print("updateThreadDCR error", str(e)) print("updateThreadDCR error", str(e))
cls.delay_event.wait(random.randrange(cls.dcr_update_min, cls.dcr_update_max)) cls.delay_event.wait(random.uniform(cls.dcr_update_min, cls.dcr_update_max))
def signal_handler(self, sig, frame): def signal_handler(self, sig, frame):
@@ -283,6 +283,7 @@ def run_process(client_id):
def start_processes(self): def start_processes(self):
multiprocessing.set_start_method("spawn")
self.delay_event.clear() self.delay_event.clear()
for i in range(NUM_NODES): for i in range(NUM_NODES):
@@ -299,7 +300,7 @@ def start_processes(self):
wallets = read_json_api(UI_PORT + 1, "wallets") wallets = read_json_api(UI_PORT + 1, "wallets")
if "monero" in TEST_COINS_LIST: if "monero" in self.test_coins_list:
xmr_auth = None xmr_auth = None
if os.getenv("XMR_RPC_USER", "") != "": if os.getenv("XMR_RPC_USER", "") != "":
xmr_auth = (os.getenv("XMR_RPC_USER", ""), os.getenv("XMR_RPC_PWD", "")) xmr_auth = (os.getenv("XMR_RPC_USER", ""), os.getenv("XMR_RPC_PWD", ""))
@@ -333,7 +334,7 @@ def start_processes(self):
callbtcrpc(0, "generatetoaddress", [num_blocks, self.btc_addr]) callbtcrpc(0, "generatetoaddress", [num_blocks, self.btc_addr])
logging.info("BTC blocks: {}".format(callbtcrpc(0, "getblockcount"))) logging.info("BTC blocks: {}".format(callbtcrpc(0, "getblockcount")))
if "litecoin" in TEST_COINS_LIST: if "litecoin" in self.test_coins_list:
self.ltc_addr = callltcrpc( self.ltc_addr = callltcrpc(
0, "getnewaddress", ["mining_addr"], wallet="wallet.dat" 0, "getnewaddress", ["mining_addr"], wallet="wallet.dat"
) )
@@ -364,7 +365,7 @@ def start_processes(self):
wallet="wallet.dat", wallet="wallet.dat",
) )
if "decred" in TEST_COINS_LIST: if "decred" in self.test_coins_list:
if RESET_TEST: if RESET_TEST:
_ = calldcrrpc(0, "getnewaddress") _ = calldcrrpc(0, "getnewaddress")
# assert (addr == self.dcr_addr) # assert (addr == self.dcr_addr)
@@ -394,7 +395,7 @@ def start_processes(self):
self.update_thread_dcr = threading.Thread(target=updateThreadDCR, args=(self,)) self.update_thread_dcr = threading.Thread(target=updateThreadDCR, args=(self,))
self.update_thread_dcr.start() self.update_thread_dcr.start()
if "firo" in TEST_COINS_LIST: if "firo" in self.test_coins_list:
self.firo_addr = callfirorpc(0, "getnewaddress", ["mining_addr"]) self.firo_addr = callfirorpc(0, "getnewaddress", ["mining_addr"])
num_blocks: int = 200 num_blocks: int = 200
have_blocks: int = callfirorpc(0, "getblockcount") have_blocks: int = callfirorpc(0, "getblockcount")
@@ -410,7 +411,7 @@ def start_processes(self):
[num_blocks - have_blocks, self.firo_addr], [num_blocks - have_blocks, self.firo_addr],
) )
if "bitcoincash" in TEST_COINS_LIST: if "bitcoincash" in self.test_coins_list:
self.bch_addr = callbchrpc( self.bch_addr = callbchrpc(
0, "getnewaddress", ["mining_addr"], wallet="wallet.dat" 0, "getnewaddress", ["mining_addr"], wallet="wallet.dat"
) )
@@ -429,7 +430,7 @@ def start_processes(self):
wallet="wallet.dat", wallet="wallet.dat",
) )
if "dogecoin" in TEST_COINS_LIST: if "dogecoin" in self.test_coins_list:
self.doge_addr = calldogerpc(0, "getnewaddress", ["mining_addr"]) self.doge_addr = calldogerpc(0, "getnewaddress", ["mining_addr"])
num_blocks: int = 200 num_blocks: int = 200
have_blocks: int = calldogerpc(0, "getblockcount") have_blocks: int = calldogerpc(0, "getblockcount")
@@ -443,7 +444,7 @@ def start_processes(self):
0, "generatetoaddress", [num_blocks - have_blocks, self.doge_addr] 0, "generatetoaddress", [num_blocks - have_blocks, self.doge_addr]
) )
if "namecoin" in TEST_COINS_LIST: if "namecoin" in self.test_coins_list:
self.nmc_addr = callnmcrpc(0, "getnewaddress", ["mining_addr", "bech32"]) self.nmc_addr = callnmcrpc(0, "getnewaddress", ["mining_addr", "bech32"])
num_blocks: int = 500 num_blocks: int = 500
have_blocks: int = callnmcrpc(0, "getblockcount") have_blocks: int = callnmcrpc(0, "getblockcount")
@@ -536,7 +537,22 @@ class BaseTestWithPrepare(unittest.TestCase):
firo_addr = None firo_addr = None
bch_addr = None bch_addr = None
doge_addr = None doge_addr = None
initialised = False test_coins_list = TEST_COINS_LIST
@classmethod
def modifyConfig(cls, test_path, i):
modifyConfig(test_path, i)
@classmethod
def setupNodes(cls):
logging.info(f"Preparing {NUM_NODES} nodes.")
prepare_nodes(
NUM_NODES,
cls.test_coins_list,
True,
{"min_sequence_lock_seconds": 60},
PORT_OFS,
)
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -547,22 +563,18 @@ class BaseTestWithPrepare(unittest.TestCase):
if os.path.exists(test_path) and not RESET_TEST: if os.path.exists(test_path) and not RESET_TEST:
logging.info(f"Continuing with existing directory: {test_path}") logging.info(f"Continuing with existing directory: {test_path}")
else: else:
logging.info(f"Preparing {NUM_NODES} nodes.") cls.setupNodes()
prepare_nodes(
NUM_NODES,
TEST_COINS_LIST,
True,
{"min_sequence_lock_seconds": 60},
PORT_OFS,
)
for i in range(NUM_NODES): for i in range(NUM_NODES):
modifyConfig(test_path, i) cls.modifyConfig(test_path, i)
signal.signal( signal.signal(
signal.SIGINT, lambda signal, frame: signal_handler(cls, signal, frame) signal.SIGINT, lambda signal, frame: signal_handler(cls, signal, frame)
) )
start_processes(cls)
waitForServer(cls.delay_event, UI_PORT + 0)
waitForServer(cls.delay_event, UI_PORT + 1)
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
logging.info("Stopping test") logging.info("Stopping test")
@@ -582,14 +594,6 @@ class BaseTestWithPrepare(unittest.TestCase):
cls.update_thread_dcr = None cls.update_thread_dcr = None
cls.processes = [] cls.processes = []
def setUp(self):
if self.initialised:
return
start_processes(self)
waitForServer(self.delay_event, UI_PORT + 0)
waitForServer(self.delay_event, UI_PORT + 1)
self.initialised = True
class Test(BaseTestWithPrepare): class Test(BaseTestWithPrepare):
def test_persistent(self): def test_persistent(self):

View File

@@ -1579,7 +1579,11 @@ class BasicSwapTest(TestFunctions):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, self.test_fee_rate) result = ci.publishBLockTx(v, S, amount, self.test_fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
addr_out = ci.getNewAddress(True) addr_out = ci.getNewAddress(True)
lock_tx_b_spend_txid = ci.spendBLockTx( lock_tx_b_spend_txid = ci.spendBLockTx(

View File

@@ -0,0 +1,859 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2026 The Basicswap developers
# Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
"""
# Ensure Electrumx is installed to a venv in ELECTRUMX_SRC_DIR/venv
# Example setup with default paths:
The leveldb system package may be required to install plyvel:
sudo pacman -S leveldb
cd ~/tmp/
git clone git@github.com:spesmilo/electrumx.git
cd electrumx
python3 -m venv venv
. venv/bin/activate
pip install ".[ujson]"
# Run test
export TEST_PATH=/tmp/test_electrum
mkdir -p ${TEST_PATH}/bin
cp -r ~/tmp/basicswap_bin/* ${TEST_PATH}/bin
export ELECTRUMX_SRC_DIR="~/tmp/electrumx"
export EXTRA_CONFIG_JSON=$(cat <<EOF | jq -r @json
{
"btc0":["txindex=1","rpcworkqueue=1100"]
}
EOF
)
export TEST_COINS_LIST="bitcoin,monero"
export PYTHONPATH=$(pwd)
pytest -v -s --log-cli-level=DEBUG tests/basicswap/test_electrum.py
# Run select test
pytest -v -s --log-cli-level=DEBUG tests/basicswap/test_electrum.py::Test::test_01_b_full_swap_xmr
# Optionally copy coin releases to permanent storage for faster subsequent startups
cp -r ${TEST_PATH}/bin/* ~/tmp/basicswap_bin/
"""
import json
import logging
import os
import random
import shutil
import subprocess
import sys
import unittest
import basicswap.config as cfg
from basicswap.basicswap_util import (
BidStates,
DebugTypes,
TxLockTypes,
strBidState,
)
from basicswap.chainparams import (
Coins,
chainparams,
getCoinIdFromName,
)
from basicswap.util.daemon import Daemon
from tests.basicswap.common import (
prepare_balance,
stopDaemons,
)
from tests.basicswap.common_xmr import run_prepare, TEST_PATH
from tests.basicswap.extended.test_xmr_persistent import (
BaseTestWithPrepare,
NUM_NODES,
PORT_OFS,
RESET_TEST,
)
from tests.basicswap.mnemonics import mnemonics
from tests.basicswap.util import (
read_json_api,
post_json_api,
)
logger = logging.getLogger()
logger.level = logging.DEBUG
if not len(logger.handlers):
logger.addHandler(logging.StreamHandler(sys.stdout))
def modify_config(test_path, i):
config_path = os.path.join(test_path, f"client{i}", cfg.CONFIG_FILENAME)
with open(config_path) as fp:
settings = json.load(fp)
if i == 1:
settings["debug_ui"] = True
settings.update(
{
"fetchpricesthread": False,
"check_progress_seconds": 2,
"check_watched_seconds": 3,
"check_expired_seconds": 60,
"check_events_seconds": 1,
"check_xmr_swaps_seconds": 1,
"min_delay_event": 1,
"max_delay_event": 4,
"min_delay_event_short": 1,
"max_delay_event_short": 3,
"min_delay_retry": 2,
"max_delay_retry": 10,
}
)
with open(config_path, "w") as fp:
json.dump(settings, fp, indent=4)
def wait_for_bid_state(
delay_event, node_port: int, bid_id: str, state=None, wait_for: int = 30
) -> None:
logger.info(f"TEST: wait_for_bid {bid_id}, node {node_port}, state {state}")
pass_state_strs = []
if isinstance(state, (list, tuple)):
for s in state:
pass_state_strs.append(strBidState(s))
elif state is not None:
pass_state_strs.append(strBidState(state))
for i in range(wait_for):
if delay_event.is_set():
raise ValueError("Test stopped.")
delay_event.wait(1)
try:
rv = read_json_api(node_port, f"bids/{bid_id}")
if rv["bid_state"] in pass_state_strs or state is None:
return
except Exception as e: # noqa: F841
pass
# logger.debug(f"TEST: wait_for_bid {bid_id}, error {e}")
raise ValueError(f"wait_for_bid timed out {bid_id}.")
def wait_for_offer(
delay_event, node_port: int, offer_id: str, state=None, wait_for: int = 30
) -> None:
logger.info(f"TEST: wait_for_offer {offer_id}, node {node_port}, state {state}")
for i in range(wait_for):
if delay_event.is_set():
raise ValueError("Test stopped.")
delay_event.wait(1)
try:
rv = read_json_api(node_port, f"offers/{offer_id}")
if any(offer["offer_id"] == offer_id for offer in rv):
return
except Exception as e: # noqa: F841
pass
# logger.debug(f"TEST: wait_for_offer {offer_id}, error {e}")
raise ValueError(f"wait_for_offer timed out {offer_id}.")
class TestFunctions(BaseTestWithPrepare):
__test__ = False
port_node_0 = 12701
port_node_1 = 12702
def do_test_01_full_swap(
self,
coin_from: Coins,
coin_to: Coins,
port_node_from: int = port_node_0,
port_node_to: int = port_node_1,
) -> None:
logger.info(
f"---------- Test {coin_from.name} ({port_node_from}) to {coin_to.name} ({port_node_to})"
)
ticker_from: str = chainparams[coin_from]["ticker"]
ticker_to: str = chainparams[coin_to]["ticker"]
amt_from_str = f"{random.uniform(0.5, 10.0):.{8}f}"
amt_to_str = f"{random.uniform(0.5, 10.0):.{8}f}"
data = {
"addr_from": "-1",
"coin_from": ticker_from,
"coin_to": ticker_to,
"amt_from": amt_from_str,
"amt_to": amt_to_str,
"lockhrs": "24",
"swap_type": "adaptor_sig",
}
logger.info(
f"Creating offer {amt_from_str} {ticker_from} -> {amt_to_str} {ticker_to}"
)
offer_id: str = post_json_api(port_node_from, "offers/new", data)["offer_id"]
wait_for_offer(self.delay_event, port_node_to, offer_id)
offer = read_json_api(port_node_to, f"offers/{offer_id}")[0]
assert offer["offer_id"] == offer_id
data = {
"offer_id": offer_id,
"amount_from": offer["amount_from"],
"validmins": 60,
}
rv = post_json_api(port_node_to, "bids/new", data)
bid_id: str = rv["bid_id"]
wait_for_bid_state(
self.delay_event, port_node_from, bid_id, BidStates.BID_RECEIVED
)
rv = post_json_api(port_node_from, f"bids/{bid_id}", {"accept": True})
assert rv["bid_state"] in ("Accepted", "Request accepted")
logger.info("Completing swap")
wait_for_bid_state(
self.delay_event, port_node_from, bid_id, BidStates.SWAP_COMPLETED, 240
)
wait_for_bid_state(
self.delay_event, port_node_to, bid_id, BidStates.SWAP_COMPLETED, 240
)
def do_test_02_leader_recover_a_lock_tx(
self,
coin_from: Coins,
coin_to: Coins,
port_node_from: int = port_node_0,
port_node_to: int = port_node_1,
lock_value: int = 12,
) -> None:
logger.info(
f"---------- Test {coin_from.name} ({port_node_from}) to {coin_to.name} ({port_node_to}) leader recovers coin a lock tx"
)
ticker_from: str = chainparams[coin_from]["ticker"]
ticker_to: str = chainparams[coin_to]["ticker"]
reverse_bid: bool = True if coin_from in (Coins.XMR,) else False
port_offerer: int = port_node_from
port_bidder: int = port_node_to
port_leader: int = port_bidder if reverse_bid else port_offerer
port_follower: int = port_offerer if reverse_bid else port_bidder
logger.info(
f"Offerer, bidder, leader, follower: {port_offerer}, {port_bidder}, {port_leader}, {port_follower}"
)
amt_from_str = f"{random.uniform(0.5, 10.0):.{8}f}"
amt_to_str = f"{random.uniform(0.5, 10.0):.{8}f}"
data = {
"addr_from": "-1",
"coin_from": ticker_from,
"coin_to": ticker_to,
"amt_from": amt_from_str,
"amt_to": amt_to_str,
"swap_type": "adaptor_sig",
"lock_type": str(int(TxLockTypes.SEQUENCE_LOCK_BLOCKS)),
"lock_blocks": str(lock_value),
}
logger.info(
f"Creating offer {amt_from_str} {ticker_from} -> {amt_to_str} {ticker_to}"
)
offer_id: str = post_json_api(port_node_from, "offers/new", data)["offer_id"]
wait_for_offer(self.delay_event, port_node_to, offer_id)
offer = read_json_api(port_node_to, f"offers/{offer_id}")[0]
assert offer["offer_id"] == offer_id
data = {
"offer_id": offer_id,
"amount_from": offer["amount_from"],
"validmins": 60,
}
rv = post_json_api(port_node_to, "bids/new", data)
bid_id: str = rv["bid_id"]
wait_for_bid_state(self.delay_event, port_follower, bid_id)
rv = post_json_api(
port_follower,
f"bids/{bid_id}",
{"debugind": DebugTypes.BID_STOP_AFTER_COIN_A_LOCK},
)
assert "bid_state" in rv # Test that the return didn't fail
wait_for_bid_state(
self.delay_event, port_node_from, bid_id, BidStates.BID_RECEIVED
)
rv = post_json_api(port_offerer, f"bids/{bid_id}", {"accept": True})
assert rv["bid_state"] in ("Accepted", "Request accepted")
wait_for_bid_state(
self.delay_event,
port_leader,
bid_id,
BidStates.XMR_SWAP_FAILED_REFUNDED,
240,
)
wait_for_bid_state(
self.delay_event,
port_follower,
bid_id,
[BidStates.BID_STALLED_FOR_TEST, BidStates.XMR_SWAP_FAILED],
240,
)
def do_test_03_follower_recover_a_lock_tx(
self,
coin_from: Coins,
coin_to: Coins,
port_node_from: int = port_node_0,
port_node_to: int = port_node_1,
lock_value: int = 12,
with_mercy: bool = True,
) -> None:
logger.info(
"---------- Test {} ({}) to {} ({}) follower recovers coin a lock tx{}".format(
coin_from.name,
port_node_from,
coin_to.name,
port_node_to,
" (with mercy tx)" if with_mercy else "",
)
)
# Leader is too slow to recover the coin a lock tx and follower swipes it
# Coin B lock tx remains unspent unless a mercy output revealing the follower's keyshare is sent
ticker_from: str = chainparams[coin_from]["ticker"]
ticker_to: str = chainparams[coin_to]["ticker"]
reverse_bid: bool = True if coin_from in (Coins.XMR,) else False
port_offerer: int = port_node_from
port_bidder: int = port_node_to
port_leader: int = port_bidder if reverse_bid else port_offerer
port_follower: int = port_offerer if reverse_bid else port_bidder
logger.info(
f"Offerer, bidder, leader, follower: {port_offerer}, {port_bidder}, {port_leader}, {port_follower}"
)
amt_from_str = f"{random.uniform(0.5, 10.0):.{8}f}"
amt_to_str = f"{random.uniform(0.5, 10.0):.{8}f}"
data = {
"addr_from": "-1",
"coin_from": ticker_from,
"coin_to": ticker_to,
"amt_from": amt_from_str,
"amt_to": amt_to_str,
"swap_type": "adaptor_sig",
"lock_type": str(int(TxLockTypes.SEQUENCE_LOCK_BLOCKS)),
"lock_blocks": str(lock_value),
}
logger.info(
f"Creating offer {amt_from_str} {ticker_from} -> {amt_to_str} {ticker_to}"
)
offer_id: str = post_json_api(port_node_from, "offers/new", data)["offer_id"]
wait_for_offer(self.delay_event, port_node_to, offer_id)
offer = read_json_api(port_node_to, f"offers/{offer_id}")[0]
assert offer["offer_id"] == offer_id
data = {
"offer_id": offer_id,
"amount_from": offer["amount_from"],
"validmins": 60,
}
rv = post_json_api(port_node_to, "bids/new", data)
bid_id: str = rv["bid_id"]
wait_for_bid_state(self.delay_event, port_leader, bid_id)
wait_for_bid_state(self.delay_event, port_follower, bid_id)
rv = post_json_api(
port_leader,
f"bids/{bid_id}",
{"debugind": DebugTypes.BID_DONT_SPEND_COIN_A_LOCK_REFUND2},
)
assert "bid_state" in rv # Test that the return didn't fail
rv = post_json_api(
port_follower,
f"bids/{bid_id}",
{"debugind": DebugTypes.BID_DONT_SPEND_COIN_B_LOCK},
)
assert "bid_state" in rv
for node_port in (port_leader, port_follower):
rv = post_json_api(
port_follower,
f"bids/{bid_id}",
{
"debugind": DebugTypes.BID_DONT_SPEND_COIN_B_LOCK,
"maindebugind": False,
},
)
assert "bid_state" in rv
wait_for_bid_state(
self.delay_event, port_node_from, bid_id, BidStates.BID_RECEIVED
)
rv = post_json_api(port_offerer, f"bids/{bid_id}", {"accept": True})
assert rv["bid_state"] in ("Accepted", "Request accepted")
expect_state = (
(BidStates.XMR_SWAP_NOSCRIPT_TX_REDEEMED, BidStates.SWAP_COMPLETED)
if with_mercy
else (BidStates.BID_STALLED_FOR_TEST, BidStates.XMR_SWAP_FAILED_SWIPED)
)
wait_for_bid_state(
self.delay_event,
port_leader,
bid_id,
expect_state,
240,
)
wait_for_bid_state(
self.delay_event,
port_follower,
bid_id,
[BidStates.XMR_SWAP_FAILED_SWIPED],
240,
)
rv = post_json_api(
port_leader,
f"bids/{bid_id}",
{"show_extra": True, "with_events": True},
)
events = rv["events"]
logger.info(f"Initiator events: {events}")
if with_mercy:
assert any(
event["desc"] == "Lock tx B spend tx published" for event in events
)
rv = post_json_api(
port_follower,
f"bids/{bid_id}",
{"show_extra": True, "with_events": True},
)
events = rv["events"]
logger.info(f"Participant events: {events}")
assert any(
event["desc"] == "Lock tx A refund swipe tx published" for event in events
)
def do_test_04_follower_recover_b_lock_tx(
self,
coin_from: Coins,
coin_to: Coins,
port_node_from: int = port_node_0,
port_node_to: int = port_node_1,
lock_value: int = 16,
) -> None:
logger.info(
f"---------- Test {coin_from.name} ({port_node_from}) to {coin_to.name} ({port_node_to}) follower recovers coin b lock tx"
)
ticker_from: str = chainparams[coin_from]["ticker"]
ticker_to: str = chainparams[coin_to]["ticker"]
reverse_bid: bool = True if coin_from in (Coins.XMR,) else False
port_offerer: int = port_node_from
port_bidder: int = port_node_to
port_leader: int = port_bidder if reverse_bid else port_offerer
port_follower: int = port_offerer if reverse_bid else port_bidder
logger.info(
f"Offerer, bidder, leader, follower: {port_offerer}, {port_bidder}, {port_leader}, {port_follower}"
)
amt_from_str = f"{random.uniform(0.5, 10.0):.{8}f}"
amt_to_str = f"{random.uniform(0.5, 10.0):.{8}f}"
data = {
"addr_from": "-1",
"coin_from": ticker_from,
"coin_to": ticker_to,
"amt_from": amt_from_str,
"amt_to": amt_to_str,
"swap_type": "adaptor_sig",
"lock_type": str(int(TxLockTypes.SEQUENCE_LOCK_BLOCKS)),
"lock_blocks": str(lock_value),
}
logger.info(
f"Creating offer {amt_from_str} {ticker_from} -> {amt_to_str} {ticker_to}"
)
offer_id: str = post_json_api(port_node_from, "offers/new", data)["offer_id"]
wait_for_offer(self.delay_event, port_node_to, offer_id)
offer = read_json_api(port_node_to, f"offers/{offer_id}")[0]
assert offer["offer_id"] == offer_id
data = {
"offer_id": offer_id,
"amount_from": offer["amount_from"],
"validmins": 60,
}
rv = post_json_api(port_node_to, "bids/new", data)
bid_id: str = rv["bid_id"]
wait_for_bid_state(self.delay_event, port_follower, bid_id)
rv = post_json_api(
port_follower,
f"bids/{bid_id}",
{"debugind": DebugTypes.CREATE_INVALID_COIN_B_LOCK},
)
assert "bid_state" in rv
wait_for_bid_state(
self.delay_event, port_node_from, bid_id, BidStates.BID_RECEIVED
)
rv = post_json_api(port_offerer, f"bids/{bid_id}", {"accept": True})
assert rv["bid_state"] in ("Accepted", "Request accepted")
wait_for_bid_state(
self.delay_event,
port_leader,
bid_id,
BidStates.XMR_SWAP_FAILED_REFUNDED,
240,
)
wait_for_bid_state(
self.delay_event,
port_follower,
bid_id,
BidStates.XMR_SWAP_FAILED_REFUNDED,
240,
)
rv = post_json_api(
port_leader,
f"bids/{bid_id}",
{"show_extra": True, "with_events": True},
)
events = rv["events"]
logger.info(f"Initiator events: {events}")
assert any(event["desc"] == "Detected invalid lock Tx B" for event in events)
assert any(
event["desc"] == "Lock tx A refund spend tx published" for event in events
)
rv = post_json_api(
port_follower,
f"bids/{bid_id}",
{"show_extra": True, "with_events": True},
)
events = rv["events"]
logger.info(f"Participant events: {events}")
assert any(event["desc"] == "Lock tx B refund tx published" for event in events)
class Test(TestFunctions):
__test__ = True
update_min = 1.7
daemons = []
test_coin_a = Coins.PART
test_coin_b = Coins.BTC
test_coin_xmr = Coins.XMR
@classmethod
def addElectrumxDaemon(cls, coin_name: str, node_rpc_port: int, services_port: int):
coin_type: Coins = getCoinIdFromName(coin_name)
ticker: str = chainparams[coin_type]["ticker"]
ticker_lc: str = ticker.lower()
logger.info(f"Starting Electrumx for {ticker}")
ELECTRUMX_SRC_DIR = os.path.expanduser(os.getenv("ELECTRUMX_SRC_DIR"))
if ELECTRUMX_SRC_DIR is None:
raise ValueError("Please set ELECTRUMX_SRC_DIR")
ELECTRUMX_VENV = os.getenv(
"ELECTRUMX_VENV", os.path.join(ELECTRUMX_SRC_DIR, "venv")
)
ELECTRUMX_DATADIR = os.getenv(
f"ELECTRUMX_DATADIR_{ticker}", f"/tmp/electrumx_{ticker_lc}"
)
SSL_CERTFILE = f"{ELECTRUMX_DATADIR}/certfile.crt"
SSL_KEYFILE = f"{ELECTRUMX_DATADIR}/keyfile.key"
if os.path.isdir(ELECTRUMX_DATADIR):
if RESET_TEST:
logger.info("Removing " + ELECTRUMX_DATADIR)
shutil.rmtree(ELECTRUMX_DATADIR)
if not os.path.exists(ELECTRUMX_DATADIR):
os.makedirs(os.path.join(ELECTRUMX_DATADIR, "db"))
with open(os.path.join(ELECTRUMX_DATADIR, "banner"), "w") as fp:
fp.write("TEST BANNER")
try:
stdout = subprocess.check_output(
[
"openssl",
"req",
"-nodes",
"-new",
"-x509",
"-keyout",
SSL_KEYFILE,
"-out",
SSL_CERTFILE,
"-subj",
'/C=CA/ST=Quebec/L=Montreal/O="Poutine LLC"/OU=devops/CN=*.poutine.net\n',
],
text=True,
)
logger.info(f"openssl {stdout}")
except subprocess.CalledProcessError as e:
logger.info(f"Error openssl {e.output}")
electrumx_env = {
"COIN": coin_name.capitalize(),
"NET": "regtest",
"LOG_LEVEL": "debug",
"SERVICES": f"tcp://:{services_port},ssl://:{services_port + 1},rpc://",
"CACHE_MB": "400",
"DAEMON_URL": f"http://test_{ticker_lc}_0:test_{ticker_lc}_pwd_0@127.0.0.1:{node_rpc_port}",
"DB_DIRECTORY": f"{ELECTRUMX_DATADIR}/db",
"SSL_CERTFILE": f"{ELECTRUMX_DATADIR}/certfile.crt",
"SSL_KEYFILE": f"{ELECTRUMX_DATADIR}/keyfile.key",
"BANNER_FILE": f"{ELECTRUMX_DATADIR}/banner",
"DAEMON_POLL_INTERVAL_BLOCKS": "1000",
"DAEMON_POLL_INTERVAL_MEMPOOL": "1000",
}
opened_files = []
stdout_dest = open(f"{ELECTRUMX_DATADIR}/electrumx.log", "w")
stderr_dest = stdout_dest
cls.daemons.append(
Daemon(
subprocess.Popen(
[
os.path.join(ELECTRUMX_VENV, "bin", "python"),
os.path.join(ELECTRUMX_SRC_DIR, "electrumx_server"),
],
shell=False,
stdin=subprocess.PIPE,
stdout=stdout_dest,
stderr=stderr_dest,
cwd=ELECTRUMX_SRC_DIR,
env=electrumx_env,
),
[
opened_files,
],
f"electrumx_{ticker_lc}",
)
)
@classmethod
def setUpClass(cls):
cls.addElectrumxDaemon("bitcoin", 32793, 50001)
super(Test, cls).setUpClass()
@classmethod
def modifyConfig(cls, test_path, i):
modify_config(test_path, i)
@classmethod
def setupNodes(cls):
logger.info(f"Preparing {NUM_NODES} nodes.")
bins_path = os.path.join(TEST_PATH, "bin")
for i in range(NUM_NODES):
logger.info(f"Preparing node: {i}.")
client_path = os.path.join(TEST_PATH, f"client{i}")
try:
shutil.rmtree(client_path)
except Exception as ex:
logger.warning(f"setupNodes {ex}")
extra_args = []
if i == 1:
extra_args = [
"--btc-mode=electrum",
"--btc-electrum-server=127.0.0.1:50001",
]
run_prepare(
i,
client_path,
bins_path,
cls.test_coins_list,
mnemonics[i] if i < len(mnemonics) else None,
num_nodes=NUM_NODES,
use_rpcauth=True,
extra_settings={"min_sequence_lock_seconds": 10},
port_ofs=PORT_OFS,
extra_args=extra_args,
)
@classmethod
def tearDownClass(cls):
logger.info("Finalising Test")
super().tearDownClass()
stopDaemons(cls.daemons)
def test_01_a_full_swap_xmr(self):
prepare_balance(
self.delay_event,
self.test_coin_b,
1000,
self.port_node_1,
self.port_node_0,
True,
)
self.do_test_01_full_swap(self.test_coin_a, self.test_coin_b)
def test_01_b_full_swap_xmr(self):
prepare_balance(
self.delay_event,
self.test_coin_b,
100,
self.port_node_1,
self.port_node_0,
True,
)
self.do_test_01_full_swap(self.test_coin_b, self.test_coin_xmr)
def test_01_c_full_swap_xmr_reverse(self):
prepare_balance(
self.delay_event,
self.test_coin_b,
100,
self.port_node_1,
self.port_node_0,
True,
)
prepare_balance(
self.delay_event,
self.test_coin_xmr,
1000,
self.port_node_0,
self.port_node_1,
True,
)
self.do_test_01_full_swap(
self.test_coin_xmr, self.test_coin_b, self.port_node_0, self.port_node_1
)
def test_02_a_leader_recover_a_lock_tx(self):
prepare_balance(
self.delay_event,
self.test_coin_b,
100,
self.port_node_1,
self.port_node_0,
True,
)
prepare_balance(
self.delay_event,
self.test_coin_xmr,
100,
self.port_node_0,
self.port_node_1,
True,
)
self.do_test_02_leader_recover_a_lock_tx(
self.test_coin_b, self.test_coin_xmr, self.port_node_1, self.port_node_0
)
def test_02_b_leader_recover_a_lock_tx_reverse(self):
prepare_balance(
self.delay_event,
self.test_coin_b,
100,
self.port_node_1,
self.port_node_0,
True,
)
prepare_balance(
self.delay_event,
self.test_coin_xmr,
100,
self.port_node_0,
self.port_node_1,
True,
)
self.do_test_02_leader_recover_a_lock_tx(
self.test_coin_xmr, self.test_coin_b, self.port_node_0, self.port_node_1
)
def test_03_a_follower_recover_a_lock_tx(self):
prepare_balance(
self.delay_event,
self.test_coin_b,
100,
self.port_node_1,
self.port_node_0,
True,
)
prepare_balance(
self.delay_event,
self.test_coin_xmr,
100,
self.port_node_0,
self.port_node_1,
True,
)
self.do_test_03_follower_recover_a_lock_tx(
self.test_coin_b, self.test_coin_xmr, self.port_node_1, self.port_node_0
)
def test_03_b_follower_recover_a_lock_tx_reverse(self):
prepare_balance(
self.delay_event,
self.test_coin_b,
100,
self.port_node_1,
self.port_node_0,
True,
)
prepare_balance(
self.delay_event,
self.test_coin_xmr,
100,
self.port_node_0,
self.port_node_1,
True,
)
self.do_test_03_follower_recover_a_lock_tx(
self.test_coin_xmr, self.test_coin_b, self.port_node_0, self.port_node_1
)
def test_04_a_follower_recover_b_lock_tx(self):
prepare_balance(
self.delay_event,
self.test_coin_b,
100,
self.port_node_1,
self.port_node_0,
True,
)
prepare_balance(
self.delay_event,
self.test_coin_xmr,
100,
self.port_node_0,
self.port_node_1,
True,
)
self.do_test_04_follower_recover_b_lock_tx(
self.test_coin_b, self.test_coin_xmr, self.port_node_1, self.port_node_0
)
def test_04_b_follower_recover_b_lock_tx_reverse(self):
prepare_balance(
self.delay_event,
self.test_coin_b,
100,
self.port_node_1,
self.port_node_0,
True,
)
prepare_balance(
self.delay_event,
self.test_coin_xmr,
100,
self.port_node_0,
self.port_node_1,
True,
)
self.do_test_04_follower_recover_b_lock_tx(
self.test_coin_xmr, self.test_coin_b, self.port_node_0, self.port_node_1
)
if __name__ == "__main__":
unittest.main()

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)

View File

@@ -60,6 +60,7 @@ from tests.basicswap.util import (
from tests.basicswap.common import ( from tests.basicswap.common import (
callrpc_cli, callrpc_cli,
prepareDataDir, prepareDataDir,
prepare_balance,
make_rpc_func, make_rpc_func,
checkForks, checkForks,
stopDaemons, stopDaemons,
@@ -1055,54 +1056,13 @@ class BaseTest(unittest.TestCase):
port_take_from_node: int, port_take_from_node: int,
test_balance: bool = True, test_balance: bool = True,
) -> None: ) -> None:
delay_iterations = 100 if coin == Coins.NAV else 20 prepare_balance(
delay_time = 5 if coin == Coins.NAV else 3
if coin == Coins.PART_BLIND:
coin_ticker: str = "PART"
balance_type: str = "blind_balance"
address_type: str = "stealth_address"
type_to: str = "blind"
elif coin == Coins.PART_ANON:
coin_ticker: str = "PART"
balance_type: str = "anon_balance"
address_type: str = "stealth_address"
type_to: str = "anon"
else:
coin_ticker: str = coin.name
balance_type: str = "balance"
address_type: str = "deposit_address"
js_w = read_json_api(port_target_node, "wallets")
current_balance: float = float(js_w[coin_ticker][balance_type])
if test_balance and current_balance >= amount:
return
post_json = {
"value": amount,
"address": js_w[coin_ticker][address_type],
"subfee": False,
}
if coin in (Coins.XMR, Coins.WOW):
post_json["sweepall"] = False
if coin in (Coins.PART_BLIND, Coins.PART_ANON):
post_json["type_to"] = type_to
json_rv = read_json_api(
port_take_from_node,
"wallets/{}/withdraw".format(coin_ticker.lower()),
post_json,
)
assert len(json_rv["txid"]) == 64
wait_for_amount: float = amount
if not test_balance:
wait_for_amount += current_balance
wait_for_balance(
test_delay_event, test_delay_event,
"http://127.0.0.1:{}/json/wallets/{}".format( coin,
port_target_node, coin_ticker.lower() amount,
), port_target_node,
balance_type, port_take_from_node,
wait_for_amount, test_balance,
iterations=delay_iterations,
delay_time=delay_time,
) )
@@ -1258,7 +1218,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, fee_rate) result = ci.publishBLockTx(v, S, amount, fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
addr_out = ci.getNewAddress(True) addr_out = ci.getNewAddress(True)
lock_tx_b_spend_txid = ci.spendBLockTx( lock_tx_b_spend_txid = ci.spendBLockTx(
@@ -1287,7 +1251,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, fee_rate) result = ci.publishBLockTx(v, S, amount, fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
addr_out = ci.getNewAddress(True) addr_out = ci.getNewAddress(True)
for i in range(20): for i in range(20):
@@ -2346,7 +2314,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, fee_rate) result = ci.publishBLockTx(v, S, amount, fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
addr_out = ci.getNewStealthAddress() addr_out = ci.getNewStealthAddress()
lock_tx_b_spend_txid = None lock_tx_b_spend_txid = None