diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4eb6ec8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + target-branch: "dev" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6be7e1b..bb93fa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,13 @@ jobs: name: Running basicswap-prepare run: | basicswap-prepare --bindir="$BIN_DIR" --preparebinonly --withcoins=particl,bitcoin,monero + - name: Running test_xmr + run: | + export PYTHONPATH=$(pwd) + export PARTICL_BINDIR="$BIN_DIR/particl"; + export BITCOIN_BINDIR="$BIN_DIR/bitcoin"; + export XMR_BINDIR="$BIN_DIR/monero"; + pytest tests/basicswap/test_btc_xmr.py::TestBTC -k "test_003_api or test_02_a_leader_recover_a_lock_tx" - name: Running test_encrypted_xmr_reload run: | export PYTHONPATH=$(pwd) diff --git a/Dockerfile b/Dockerfile index 97c62f0..96253bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ ENV LANG=C.UTF-8 \ RUN apt-get update; \ apt-get install -y --no-install-recommends \ - python3-pip libpython3-dev gnupg pkg-config gcc libc-dev gosu tzdata; + python3-pip libpython3-dev gnupg pkg-config gcc libc-dev gosu tzdata cmake ninja-build; # Install requirements first so as to skip in subsequent rebuilds COPY ./requirements.txt requirements.txt diff --git a/basicswap/__init__.py b/basicswap/__init__.py index 31a8c69..9c12ad2 100644 --- a/basicswap/__init__.py +++ b/basicswap/__init__.py @@ -1,3 +1,3 @@ name = "basicswap" -__version__ = "0.14.2" +__version__ = "0.14.3" diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 2367f04..0e14561 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019-2024 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -140,7 +140,6 @@ from .basicswap_util import ( canAcceptBidState, describeEventEntry, getLastBidState, - getOfferProofOfFundsHash, getVoutByAddress, getVoutByScriptPubKey, inactive_states, @@ -355,7 +354,13 @@ class BasicSwap(BaseApp): # TODO: Set dynamically self.balance_only_coins = (Coins.LTC_MWEB,) - self.scriptless_coins = (Coins.XMR, Coins.WOW, Coins.PART_ANON, Coins.FIRO) + self.scriptless_coins = ( + Coins.XMR, + Coins.WOW, + Coins.PART_ANON, + Coins.FIRO, + Coins.DOGE, + ) self.adaptor_swap_only_coins = self.scriptless_coins + ( Coins.PART_BLIND, Coins.BCH, @@ -822,6 +827,10 @@ class BasicSwap(BaseApp): self.coin_clients[coin], self.chain, self ) return interface + elif coin == Coins.DOGE: + from .interface.doge import DOGEInterface + + return DOGEInterface(self.coin_clients[coin], self.chain, self) elif coin == Coins.DCR: from .interface.dcr import DCRInterface @@ -882,6 +891,7 @@ class BasicSwap(BaseApp): if cc["name"] in ( "bitcoin", "litecoin", + "dogecoin", "namecoin", "dash", "firo", @@ -968,13 +978,13 @@ class BasicSwap(BaseApp): core_version = ci.getDaemonVersion() self.log.info("%s Core version %d", ci.coin_name(), core_version) self.coin_clients[c]["core_version"] = core_version - # thread_func = threadPollXMRChainState if c in (Coins.XMR, Coins.WOW) else threadPollChainState - if c == Coins.XMR: - thread_func = threadPollXMRChainState - elif c == Coins.WOW: - thread_func = threadPollWOWChainState - else: - thread_func = threadPollChainState + + thread_func = { + Coins.XMR: threadPollXMRChainState, + Coins.WOW: threadPollWOWChainState, + }.get( + c, threadPollChainState + ) # default case t = threading.Thread(target=thread_func, args=(self, c)) self.threads.append(t) @@ -1187,11 +1197,16 @@ class BasicSwap(BaseApp): if ci.isWalletLocked(): raise LockedCoinError(Coins.PART) + def isBaseCoinActive(self, c) -> bool: + if c not in chainparams: + return False + if self.coin_clients[c]["connection_type"] == "rpc": + return True + return False + def activeCoins(self): for c in Coins: - if c not in chainparams: - continue - if self.coin_clients[c]["connection_type"] == "rpc": + if self.isBaseCoinActive(c): yield c def getListOfWalletCoins(self): @@ -1274,6 +1289,20 @@ class BasicSwap(BaseApp): finally: self._read_zmq_queue = True + def storeSeedIDForCoin(self, root_key, coin_type, cursor=None) -> None: + ci = self.ci(coin_type) + db_key_coin_name = ci.coin_name().lower() + seed_id = ci.getSeedHash(root_key) + + key_str = "main_wallet_seedid_" + db_key_coin_name + self.setStringKV(key_str, seed_id.hex(), cursor) + + if coin_type == Coins.DCR: + # TODO: How to force getmasterpubkey to always return the new slip44 (42) key + key_str = "main_wallet_seedid_alt_" + db_key_coin_name + legacy_root_hash = ci.getSeedHash(root_key, 20) + self.setStringKV(key_str, legacy_root_hash.hex(), cursor) + def initialiseWallet(self, coin_type, raise_errors: bool = False) -> None: if coin_type == Coins.PART: return @@ -1292,7 +1321,6 @@ class BasicSwap(BaseApp): return root_key = self.getWalletKey(coin_type, 1) - root_hash = ci.getSeedHash(root_key) try: ci.initialiseWallet(root_key) except Exception as e: @@ -1304,18 +1332,9 @@ class BasicSwap(BaseApp): self.log.error(traceback.format_exc()) return - legacy_root_hash = None - if coin_type == Coins.DCR: - legacy_root_hash = ci.getSeedHash(root_key, 20) try: cursor = self.openDB() - key_str = "main_wallet_seedid_" + db_key_coin_name - self.setStringKV(key_str, root_hash.hex(), cursor) - - if coin_type == Coins.DCR: - # TODO: How to force getmasterpubkey to always return the new slip44 (42) key - key_str = "main_wallet_seedid_alt_" + db_key_coin_name - self.setStringKV(key_str, legacy_root_hash.hex(), cursor) + self.storeSeedIDForCoin(root_key, coin_type, cursor) # Clear any saved addresses self.clearStringKV("receive_addr_" + db_key_coin_name, cursor) @@ -1329,39 +1348,43 @@ class BasicSwap(BaseApp): self.closeDB(cursor) def updateIdentityBidState(self, cursor, address: str, bid) -> None: - identity_stats = self.queryOne(KnownIdentity, cursor, {"address": address}) - if not identity_stats: - identity_stats = KnownIdentity( - active_ind=1, address=address, created_at=self.getTime() - ) - - if bid.state == BidStates.SWAP_COMPLETED: - if bid.was_sent: - identity_stats.num_sent_bids_successful = ( - zeroIfNone(identity_stats.num_sent_bids_successful) + 1 + offer = self.getOffer(bid.offer_id, cursor) + addresses_to_update = [offer.addr_from, bid.bid_addr] + for addr in addresses_to_update: + identity_stats = self.queryOne(KnownIdentity, cursor, {"address": addr}) + if not identity_stats: + identity_stats = KnownIdentity( + active_ind=1, address=addr, created_at=self.getTime() ) - else: - identity_stats.num_recv_bids_successful = ( - zeroIfNone(identity_stats.num_recv_bids_successful) + 1 - ) - elif bid.state in ( - BidStates.BID_ERROR, - BidStates.XMR_SWAP_FAILED_REFUNDED, - BidStates.XMR_SWAP_FAILED_SWIPED, - BidStates.XMR_SWAP_FAILED, - BidStates.SWAP_TIMEDOUT, - ): - if bid.was_sent: - identity_stats.num_sent_bids_failed = ( - zeroIfNone(identity_stats.num_sent_bids_failed) + 1 - ) - else: - identity_stats.num_recv_bids_failed = ( - zeroIfNone(identity_stats.num_recv_bids_failed) + 1 - ) - - identity_stats.updated_at = self.getTime() - self.add(identity_stats, cursor, upsert=True) + is_offer_creator = addr == offer.addr_from + if bid.state == BidStates.SWAP_COMPLETED: + if is_offer_creator: + old_value = zeroIfNone(identity_stats.num_recv_bids_successful) + identity_stats.num_recv_bids_successful = old_value + 1 + else: + old_value = zeroIfNone(identity_stats.num_sent_bids_successful) + identity_stats.num_sent_bids_successful = old_value + 1 + elif bid.state in ( + BidStates.BID_ERROR, + BidStates.XMR_SWAP_FAILED_REFUNDED, + BidStates.XMR_SWAP_FAILED_SWIPED, + BidStates.XMR_SWAP_FAILED, + BidStates.SWAP_TIMEDOUT, + ): + if is_offer_creator: + old_value = zeroIfNone(identity_stats.num_recv_bids_failed) + identity_stats.num_recv_bids_failed = old_value + 1 + else: + old_value = zeroIfNone(identity_stats.num_sent_bids_failed) + identity_stats.num_sent_bids_failed = old_value + 1 + elif bid.state == BidStates.BID_REJECTED: + if is_offer_creator: + old_value = zeroIfNone(identity_stats.num_recv_bids_rejected) + identity_stats.num_recv_bids_rejected = old_value + 1 + else: + old_value = zeroIfNone(identity_stats.num_sent_bids_rejected) + identity_stats.num_sent_bids_rejected = old_value + 1 + self.add(identity_stats, cursor, upsert=True) def getPreFundedTx( self, linked_type: int, linked_id: bytes, tx_type: int, cursor=None @@ -1841,8 +1864,8 @@ class BasicSwap(BaseApp): rv = [] for row in q: identity = { - "address": row[0], - "label": row[1], + "address": row[0] if row[0] is not None else "", + "label": row[1] if row[1] is not None else "", "num_sent_bids_successful": zeroIfNone(row[2]), "num_recv_bids_successful": zeroIfNone(row[3]), "num_sent_bids_rejected": zeroIfNone(row[4]), @@ -2093,14 +2116,27 @@ class BasicSwap(BaseApp): msg_buf.fee_rate_to ) # Unused: TODO - Set priority? + ensure_balance: int = int(amount) if coin_from in self.scriptless_coins: - ci_from.ensureFunds(msg_buf.amount_from) - else: - proof_of_funds_hash = getOfferProofOfFundsHash(msg_buf, offer_addr) - proof_addr, proof_sig, proof_utxos = self.getProofOfFunds( - coin_from_t, int(amount), proof_of_funds_hash + # TODO: Better tx size estimate, xmr_swap_b_lock_tx_vsize could be larger than xmr_swap_b_lock_spend_tx_vsize + estimated_fee: int = ( + msg_buf.fee_rate_from + * ci_from.xmr_swap_b_lock_spend_tx_vsize() + / 1000 ) - # TODO: For now proof_of_funds is just a client side check, may need to be sent with offers in future however. + ci_from.ensureFunds(msg_buf.amount_from + estimated_fee) + else: + # If a prefunded txn is not used, check that the wallet balance can cover the tx fee. + if "prefunded_itx" not in extra_options: + pi = self.pi(SwapTypes.XMR_SWAP) + _ = pi.getFundedInitiateTxTemplate(ci_from, ensure_balance, False) + # TODO: Save the prefunded tx so the fee can't change, complicates multiple offers at the same time. + + # TODO: Send proof of funds with offer + # proof_of_funds_hash = getOfferProofOfFundsHash(msg_buf, offer_addr) + # proof_addr, proof_sig, proof_utxos = self.getProofOfFunds( + # coin_from_t, ensure_balance, proof_of_funds_hash + # ) offer_bytes = msg_buf.to_bytes() payload_hex = str.format("{:02x}", MessageTypes.OFFER) + offer_bytes.hex() @@ -2586,9 +2622,21 @@ class BasicSwap(BaseApp): expect_seedid = self.getStringKV("main_wallet_seedid_" + ci.coin_name().lower()) if expect_seedid is None: self.log.warning( - "Can't find expected wallet seed id for coin {}".format(ci.coin_name()) + "Can't find expected wallet seed id for coin {}.".format(ci.coin_name()) ) - return False + _, is_locked = self.getLockedState() + if is_locked is False: + self.log.warning( + "Setting seed id for coin {} from master key.".format( + ci.coin_name() + ) + ) + root_key = self.getWalletKey(c, 1) + self.storeSeedIDForCoin(root_key, c) + else: + self.log.warning("Node is locked.") + return False + if c == Coins.BTC and len(ci.rpc("listwallets")) < 1: self.log.warning("Missing wallet for coin {}".format(ci.coin_name())) return False @@ -5077,6 +5125,13 @@ class BasicSwap(BaseApp): if TxTypes.XMR_SWAP_A_LOCK_REFUND_SWIPE not in bid.txns: try: + if self.haveDebugInd( + bid.bid_id, + DebugTypes.BID_DONT_SPEND_COIN_A_LOCK_REFUND2, + ): + raise TemporaryError( + "Debug: BID_DONT_SPEND_COIN_A_LOCK_REFUND2" + ) txid = ci_from.publishTx(xmr_swap.a_lock_refund_swipe_tx) self.logBidEvent( bid.bid_id, @@ -6145,6 +6200,13 @@ class BasicSwap(BaseApp): self.logBidEvent( bid.bid_id, EventLogTypes.LOCK_TX_A_REFUND_TX_SEEN, "", use_cursor ) + + if TxTypes.XMR_SWAP_A_LOCK_REFUND not in bid.txns: + bid.txns[TxTypes.XMR_SWAP_A_LOCK_REFUND] = SwapTx( + bid_id=bid.bid_id, + tx_type=TxTypes.XMR_SWAP_A_LOCK_REFUND, + txid=xmr_swap.a_lock_refund_tx_id, + ) else: self.setBidError( bid.bid_id, @@ -8766,8 +8828,10 @@ class BasicSwap(BaseApp): b_fee_rate: int = xmr_offer.a_fee_rate if reverse_bid else xmr_offer.b_fee_rate try: - chain_height = ci_to.getChainHeight() - lock_tx_depth = (chain_height - bid.xmr_b_lock_tx.chain_height) + if bid.xmr_b_lock_tx is None: + raise TemporaryError("Chain B lock tx not found.") + chain_height: int = ci_to.getChainHeight() + lock_tx_depth: int = chain_height - bid.xmr_b_lock_tx.chain_height if lock_tx_depth < ci_to.depth_spendable(): raise TemporaryError( f"Chain B lock tx still confirming {lock_tx_depth} / {ci_to.depth_spendable()}." @@ -9500,6 +9564,16 @@ class BasicSwap(BaseApp): ensure(msg["to"] == bid.bid_addr, "Received on incorrect address") ensure(msg["from"] == offer.addr_from, "Sent from incorrect address") + allowed_states = [ + BidStates.BID_REQUEST_SENT, + ] + if bid.was_sent and offer.was_sent: + allowed_states.append(BidStates.BID_REQUEST_ACCEPTED) + ensure( + bid.state in allowed_states, + "Invalid state for bid {}".format(bid.state), + ) + ci_from = self.ci(offer.coin_to) ci_to = self.ci(offer.coin_from) @@ -10307,7 +10381,14 @@ class BasicSwap(BaseApp): elif coin == Coins.NAV: rv["immature"] = walletinfo["immature_balance"] elif coin == Coins.LTC: - rv["mweb_address"] = self.getCachedStealthAddressForCoin(Coins.LTC_MWEB) + try: + rv["mweb_address"] = self.getCachedStealthAddressForCoin( + Coins.LTC_MWEB + ) + except Exception as e: + self.log.warning( + f"getCachedStealthAddressForCoin for {ci.coin_name()} failed with: {e}" + ) rv["mweb_balance"] = walletinfo["mweb_balance"] rv["mweb_pending"] = ( walletinfo["mweb_unconfirmed"] + walletinfo["mweb_immature"] @@ -10412,7 +10493,7 @@ class BasicSwap(BaseApp): for row in q: coin_id = row[0] - if self.coin_clients[coin_id]["connection_type"] != "rpc": + if self.isCoinActive(coin_id) is False: # Skip cached info if coin was disabled continue diff --git a/basicswap/bin/prepare.py b/basicswap/bin/prepare.py old mode 100644 new mode 100755 index 1299942..eb2655d --- a/basicswap/bin/prepare.py +++ b/basicswap/bin/prepare.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019-2024 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -23,6 +23,7 @@ import stat import sys import tarfile import threading +import time import urllib.parse import zipfile @@ -50,15 +51,12 @@ PARTICL_VERSION = os.getenv("PARTICL_VERSION", "23.2.7.0") PARTICL_VERSION_TAG = os.getenv("PARTICL_VERSION_TAG", "") PARTICL_LINUX_EXTRA = os.getenv("PARTICL_LINUX_EXTRA", "nousb") -LITECOIN_VERSION = os.getenv("LITECOIN_VERSION", "0.21.3") +LITECOIN_VERSION = os.getenv("LITECOIN_VERSION", "0.21.4") LITECOIN_VERSION_TAG = os.getenv("LITECOIN_VERSION_TAG", "") -BITCOIN_VERSION = os.getenv("BITCOIN_VERSION", "26.0") +BITCOIN_VERSION = os.getenv("BITCOIN_VERSION", "28.0") BITCOIN_VERSION_TAG = os.getenv("BITCOIN_VERSION_TAG", "") -BITCOINCASH_VERSION = os.getenv("BITCOINCASH_VERSION", "27.1.0") -BITCOINCASH_VERSION_TAG = os.getenv("BITCOINCASH_VERSION_TAG", "") - MONERO_VERSION = os.getenv("MONERO_VERSION", "0.18.3.4") MONERO_VERSION_TAG = os.getenv("MONERO_VERSION_TAG", "") XMR_SITE_COMMIT = ( @@ -74,7 +72,7 @@ WOW_SITE_COMMIT = ( PIVX_VERSION = os.getenv("PIVX_VERSION", "5.6.1") PIVX_VERSION_TAG = os.getenv("PIVX_VERSION_TAG", "") -DASH_VERSION = os.getenv("DASH_VERSION", "21.1.0") +DASH_VERSION = os.getenv("DASH_VERSION", "22.0.0") DASH_VERSION_TAG = os.getenv("DASH_VERSION_TAG", "") FIRO_VERSION = os.getenv("FIRO_VERSION", "0.14.14.0") @@ -86,6 +84,12 @@ NAV_VERSION_TAG = os.getenv("NAV_VERSION_TAG", "") DCR_VERSION = os.getenv("DCR_VERSION", "1.8.1") DCR_VERSION_TAG = os.getenv("DCR_VERSION_TAG", "") +BITCOINCASH_VERSION = os.getenv("BITCOINCASH_VERSION", "27.1.0") +BITCOINCASH_VERSION_TAG = os.getenv("BITCOINCASH_VERSION_TAG", "") + +DOGECOIN_VERSION = os.getenv("DOGECOIN_VERSION", "23.2.1") +DOGECOIN_VERSION_TAG = os.getenv("DOGECOIN_VERSION_TAG", "") + GUIX_SSL_CERT_DIR = None ADD_PUBKEY_URL = os.getenv("ADD_PUBKEY_URL", "") @@ -98,7 +102,6 @@ SKIP_GPG_VALIDATION = toBool(os.getenv("SKIP_GPG_VALIDATION", "false")) known_coins = { "particl": (PARTICL_VERSION, PARTICL_VERSION_TAG, ("tecnovert",)), "bitcoin": (BITCOIN_VERSION, BITCOIN_VERSION_TAG, ("laanwj",)), - "bitcoincash": (BITCOINCASH_VERSION, BITCOINCASH_VERSION_TAG, ("Calin_Culianu",)), "litecoin": (LITECOIN_VERSION, LITECOIN_VERSION_TAG, ("davidburkett38",)), "decred": (DCR_VERSION, DCR_VERSION_TAG, ("decred_release",)), "namecoin": ("0.18.0", "", ("JeremyRand",)), @@ -108,6 +111,8 @@ known_coins = { "dash": (DASH_VERSION, DASH_VERSION_TAG, ("pasta",)), "firo": (FIRO_VERSION, FIRO_VERSION_TAG, ("reuben",)), "navcoin": (NAV_VERSION, NAV_VERSION_TAG, ("nav_builder",)), + "bitcoincash": (BITCOINCASH_VERSION, BITCOINCASH_VERSION_TAG, ("Calin_Culianu",)), + "dogecoin": (DOGECOIN_VERSION, DOGECOIN_VERSION_TAG, ("tecnovert",)), } disabled_coins = [ @@ -123,8 +128,10 @@ expected_key_ids = { "binaryfate": ("F0AF4D462A0BDF92",), "wowario": ("793504B449C69220",), "davidburkett38": ("3620E9D387E55666",), + "xanimo": ("6E8F17C1B1BCDCBE",), + "patricklodder": ("2D3A345B98D0DC1F",), "fuzzbawls": ("C1ABA64407731FD9",), - "pasta": ("52527BEDABE87984",), + "pasta": ("52527BEDABE87984", "E2F3D7916E722D38"), "reuben": ("1290A1D0FA7EE109",), "nav_builder": ("2782262BF6E7FADB",), "decred_release": ("6D897EDF518A031D",), @@ -157,7 +164,11 @@ if not len(logger.handlers): logger.addHandler(logging.StreamHandler(sys.stdout)) BSX_DOCKER_MODE = toBool(os.getenv("BSX_DOCKER_MODE", "false")) +BSX_LOCAL_TOR = toBool(os.getenv("BSX_LOCAL_TOR", "false")) BSX_TEST_MODE = toBool(os.getenv("BSX_TEST_MODE", "false")) +BSX_UPDATE_UNMANAGED = toBool( + os.getenv("BSX_UPDATE_UNMANAGED", "true") +) # Disable updating unmanaged coin cores. UI_HTML_PORT = int(os.getenv("UI_HTML_PORT", 12700)) UI_WS_PORT = int(os.getenv("UI_WS_PORT", 11700)) COINS_RPCBIND_IP = os.getenv("COINS_RPCBIND_IP", "127.0.0.1") @@ -203,13 +214,6 @@ BTC_ONION_PORT = int(os.getenv("BTC_ONION_PORT", 8334)) BTC_RPC_USER = os.getenv("BTC_RPC_USER", "") BTC_RPC_PWD = os.getenv("BTC_RPC_PWD", "") -BCH_RPC_HOST = os.getenv("BCH_RPC_HOST", "127.0.0.1") -BCH_RPC_PORT = int(os.getenv("BCH_RPC_PORT", 19997)) -BCH_ONION_PORT = int(os.getenv("BCH_ONION_PORT", 8335)) -BCH_PORT = int(os.getenv("BCH_PORT", 19798)) -BCH_RPC_USER = os.getenv("BCH_RPC_USER", "") -BCH_RPC_PWD = os.getenv("BCH_RPC_PWD", "") - DCR_RPC_HOST = os.getenv("DCR_RPC_HOST", "127.0.0.1") DCR_RPC_PORT = int(os.getenv("DCR_RPC_PORT", 9109)) DCR_WALLET_RPC_HOST = os.getenv("DCR_WALLET_RPC_HOST", "127.0.0.1") @@ -247,10 +251,28 @@ NAV_ONION_PORT = int(os.getenv("NAV_ONION_PORT", 8334)) # TODO? NAV_RPC_USER = os.getenv("NAV_RPC_USER", "") NAV_RPC_PWD = os.getenv("NAV_RPC_PWD", "") +BCH_RPC_HOST = os.getenv("BCH_RPC_HOST", "127.0.0.1") +BCH_RPC_PORT = int(os.getenv("BCH_RPC_PORT", 19997)) +BCH_ONION_PORT = int(os.getenv("BCH_ONION_PORT", 8335)) +BCH_PORT = int(os.getenv("BCH_PORT", 19798)) +BCH_RPC_USER = os.getenv("BCH_RPC_USER", "") +BCH_RPC_PWD = os.getenv("BCH_RPC_PWD", "") + +DOGE_RPC_HOST = os.getenv("DOGE_RPC_HOST", "127.0.0.1") +DOGE_RPC_PORT = int(os.getenv("DOGE_RPC_PORT", 42069)) +DOGE_ONION_PORT = int(os.getenv("DOGE_ONION_PORT", 6969)) +DOGE_RPC_USER = os.getenv("DOGE_RPC_USER", "") +DOGE_RPC_PWD = os.getenv("DOGE_RPC_PWD", "") + TOR_PROXY_HOST = os.getenv("TOR_PROXY_HOST", "127.0.0.1") TOR_PROXY_PORT = int(os.getenv("TOR_PROXY_PORT", 9050)) TOR_CONTROL_PORT = int(os.getenv("TOR_CONTROL_PORT", 9051)) TOR_DNS_PORT = int(os.getenv("TOR_DNS_PORT", 5353)) +TOR_CONTROL_LISTEN_INTERFACE = os.getenv("TOR_CONTROL_LISTEN_INTERFACE", "127.0.0.1" if BSX_LOCAL_TOR else "0.0.0.0") +TORRC_PROXY_HOST = os.getenv("TORRC_PROXY_HOST", "127.0.0.1" if BSX_LOCAL_TOR else "0.0.0.0") +TORRC_CONTROL_HOST = os.getenv("TORRC_CONTROL_HOST", "127.0.0.1" if BSX_LOCAL_TOR else "0.0.0.0") +TORRC_DNS_HOST = os.getenv("TORRC_DNS_HOST", "127.0.0.1" if BSX_LOCAL_TOR else "0.0.0.0") + TEST_TOR_PROXY = toBool( os.getenv("TEST_TOR_PROXY", "true") ) # Expects a known exit node @@ -268,6 +290,7 @@ BITCOIN_FASTSYNC_FILE = os.getenv( WALLET_ENCRYPTION_PWD = os.getenv("WALLET_ENCRYPTION_PWD", "") use_tor_proxy: bool = False +with_coins_changed: bool = False monerod_proxy_config = [ f"proxy={TOR_PROXY_HOST}:{TOR_PROXY_PORT}", @@ -326,6 +349,11 @@ def shouldManageDaemon(prefix: str) -> bool: return toBool(manage_daemon) +def getKnownVersion(coin_name: str) -> str: + version, version_tag, _ = known_coins[coin_name] + return version + version_tag + + def exitWithError(error_msg: str): sys.stderr.write("Error: {}, exiting.\n".format(error_msg)) sys.exit(1) @@ -759,7 +787,7 @@ def prepareCore(coin, version_data, settings, data_dir, extra_opts={}): arch_name = BIN_ARCH if os_name == "osx" and use_guix: arch_name = "x86_64-apple-darwin" - if coin == "particl": + if coin in ("particl", "dogecoin"): arch_name += "18" release_filename = "{}-{}-{}.{}".format( @@ -802,6 +830,15 @@ def prepareCore(coin, version_data, settings, data_dir, extra_opts={}): "https://raw.githubusercontent.com/litecoin-project/gitian.sigs.ltc/master/%s-%s/%s/%s" % (version, os_dir_name, signing_key_name, assert_filename) ) + elif coin == "dogecoin": + release_url = ( + "https://github.com/tecnovert/dogecoin/releases/download/v{}/{}".format( + version + version_tag, release_filename + ) + ) + assert_filename = "{}-{}-{}-build.assert".format(coin, os_name, version) + assert_url = f"https://raw.githubusercontent.com/tecnovert/guix.sigs/dogecoin/{version}/{signing_key_name}/noncodesigned.SHA256SUMS" + elif coin == "bitcoin": release_url = "https://bitcoincore.org/bin/bitcoin-core-{}/{}".format( version, release_filename @@ -973,6 +1010,8 @@ def prepareCore(coin, version_data, settings, data_dir, extra_opts={}): pubkey_filename = "{}_builder.pgp".format(coin) elif coin in ("decred",): pubkey_filename = "{}_release.pgp".format(coin) + elif coin in ("dogecoin",): + pubkey_filename = "particl_{}.pgp".format(signing_key_name) else: pubkey_filename = "{}_{}.pgp".format(coin, signing_key_name) pubkeyurls = [ @@ -1059,9 +1098,9 @@ def writeTorSettings(fp, coin, coin_settings, tor_control_password): fp.write(f"torcontrol={TOR_PROXY_HOST}:{TOR_CONTROL_PORT}\n") if coin_settings["core_version_group"] >= 21: - fp.write(f"bind=0.0.0.0:{onionport}=onion\n") + fp.write(f"bind={TOR_CONTROL_LISTEN_INTERFACE}:{onionport}=onion\n") else: - fp.write(f"bind=0.0.0.0:{onionport}\n") + fp.write(f"bind={TOR_CONTROL_LISTEN_INTERFACE}:{onionport}\n") def prepareDataDir(coin, settings, chain, particl_mnemonic, extra_opts={}): @@ -1268,14 +1307,20 @@ def prepareDataDir(coin, settings, chain, particl_mnemonic, extra_opts={}): ) elif coin == "litecoin": fp.write("prune=4000\n") - fp.write("blockfilterindex=0\n") - fp.write("peerblockfilters=0\n") if LTC_RPC_USER != "": fp.write( "rpcauth={}:{}${}\n".format( LTC_RPC_USER, salt, password_to_hmac(salt, LTC_RPC_PWD) ) ) + elif coin == "dogecoin": + fp.write("prune=4000\n") + if DOGE_RPC_USER != "": + fp.write( + "rpcauth={}:{}${}\n".format( + DOGE_RPC_USER, salt, password_to_hmac(salt, DOGE_RPC_PWD) + ) + ) elif coin == "bitcoin": fp.write("deprecatedrpc=create_bdb\n") fp.write("prune=2000\n") @@ -1377,9 +1422,9 @@ def write_torrc(data_dir, tor_control_password): tor_control_hash = rfc2440_hash_password(tor_control_password) with open(torrc_path, "w") as fp: - fp.write(f"SocksPort 0.0.0.0:{TOR_PROXY_PORT}\n") - fp.write(f"ControlPort 0.0.0.0:{TOR_CONTROL_PORT}\n") - fp.write(f"DNSPort 0.0.0.0:{TOR_DNS_PORT}\n") + fp.write(f"SocksPort {TORRC_PROXY_HOST}:{TOR_PROXY_PORT}\n") + fp.write(f"ControlPort {TORRC_CONTROL_HOST}:{TOR_CONTROL_PORT}\n") + fp.write(f"DNSPort {TORRC_DNS_HOST}:{TOR_DNS_PORT}\n") fp.write(f"HashedControlPassword {tor_control_hash}\n") @@ -1485,6 +1530,8 @@ def modify_tor_config( default_onionport = PART_ONION_PORT elif coin == "litecoin": default_onionport = LTC_ONION_PORT + elif coin == "dogecoin": + default_onionport = DOGE_ONION_PORT elif coin in ("decred",): pass else: @@ -1517,9 +1564,6 @@ def printVersion(with_coins): if len(with_coins) < 1: return print("Core versions:") - with_coins_changed: bool = ( - False if len(with_coins) == 1 and "particl" in with_coins else True - ) for coin, version in known_coins.items(): if with_coins_changed and coin not in with_coins: continue @@ -1636,7 +1680,7 @@ def test_particl_encryption(data_dir, settings, chain, use_tor_proxy): c = Coins.PART coin_name = "particl" coin_settings = settings["chainclients"][coin_name] - daemon_args += getCoreBinArgs(c, coin_settings) + daemon_args += getCoreBinArgs(c, coin_settings, prepare=True) extra_config = {"stdout_to_file": True} if coin_settings["manage_daemon"]: filename: str = getCoreBinName(c, coin_settings, coin_name + "d") @@ -1697,6 +1741,7 @@ def initialise_wallets( Coins.PART, Coins.BTC, Coins.LTC, + Coins.DOGE, Coins.DCR, Coins.DASH, ) @@ -1748,7 +1793,7 @@ def initialise_wallets( coin_args = ( ["-nofindpeers", "-nostaking"] if c == Coins.PART else [] ) - coin_args += getCoreBinArgs(c, coin_settings) + coin_args += getCoreBinArgs(c, coin_settings, prepare=True) if c == Coins.FIRO: coin_args += [ @@ -1803,7 +1848,7 @@ def initialise_wallets( "Creating wallet.dat for {}.".format(getCoinName(c)) ) - if c in (Coins.BTC, Coins.LTC, Coins.DASH): + if c in (Coins.BTC, Coins.LTC, Coins.DOGE, Coins.DASH): # wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors swap_client.callcoinrpc( c, @@ -1946,7 +1991,7 @@ def ensure_coin_valid(coin: str, test_disabled: bool = True) -> None: def main(): - global use_tor_proxy + global use_tor_proxy, with_coins_changed data_dir = None bin_dir = None port_offset = None @@ -1957,12 +2002,12 @@ def main(): } add_coin = "" disable_coin = "" - coins_changed = False htmlhost = "127.0.0.1" xmr_restore_height = DEFAULT_XMR_RESTORE_HEIGHT wow_restore_height = DEFAULT_WOW_RESTORE_HEIGHT print_versions = False prepare_bin_only = False + upgrade_cores = False no_cores = False enable_tor = False disable_tor = False @@ -2001,6 +2046,9 @@ def main(): if name == "preparebinonly": prepare_bin_only = True continue + if name == "upgradecores": + upgrade_cores = True + continue if name == "nocores": no_cores = True continue @@ -2060,13 +2108,13 @@ def main(): for coin in [s.strip().lower() for s in s[1].split(",")]: ensure_coin_valid(coin) with_coins.add(coin) - coins_changed = True + with_coins_changed = True continue if name in ("withoutcoin", "withoutcoins"): for coin in [s.strip().lower() for s in s[1].split(",")]: ensure_coin_valid(coin, test_disabled=False) with_coins.discard(coin) - coins_changed = True + with_coins_changed = True continue if name == "addcoin": add_coin = s[1].strip().lower() @@ -2212,7 +2260,8 @@ def main(): "blocks_confirmed": 2, "override_feerate": 0.002, "conf_target": 2, - "core_version_group": 21, + "core_version_no": getKnownVersion("particl"), + "core_version_group": 23, }, "bitcoin": { "connection_type": "rpc", @@ -2225,7 +2274,8 @@ def main(): "use_segwit": True, "blocks_confirmed": 1, "conf_target": 2, - "core_version_group": 22, + "core_version_no": getKnownVersion("bitcoin"), + "core_version_group": 28, }, "bitcoincash": { "connection_type": "rpc", @@ -2240,6 +2290,7 @@ def main(): "use_segwit": False, "blocks_confirmed": 1, "conf_target": 2, + "core_version_no": getKnownVersion("bitcoincash"), "core_version_group": 22, }, "litecoin": { @@ -2253,9 +2304,26 @@ def main(): "use_segwit": True, "blocks_confirmed": 2, "conf_target": 2, - "core_version_group": 21, + "core_version_no": getKnownVersion("litecoin"), + "core_version_group": 20, "min_relay_fee": 0.00001, }, + "dogecoin": { + "connection_type": "rpc", + "manage_daemon": shouldManageDaemon("DOGE"), + "rpchost": DOGE_RPC_HOST, + "rpcport": DOGE_RPC_PORT + port_offset, + "onionport": DOGE_ONION_PORT + port_offset, + "datadir": os.getenv("DOGE_DATA_DIR", os.path.join(data_dir, "dogecoin")), + "bindir": os.path.join(bin_dir, "dogecoin"), + "use_segwit": False, + "use_csv": False, + "blocks_confirmed": 2, + "conf_target": 2, + "core_version_no": getKnownVersion("dogecoin"), + "core_version_group": 23, + "min_relay_fee": 0.01, # RECOMMENDED_MIN_TX_FEE + }, "decred": { "connection_type": "rpc", "manage_daemon": shouldManageDaemon("DCR"), @@ -2273,6 +2341,7 @@ def main(): "use_segwit": True, "blocks_confirmed": 2, "conf_target": 2, + "core_version_no": getKnownVersion("decred"), "core_type_group": "dcr", "config_filename": "dcrd.conf", "min_relay_fee": 0.00001, @@ -2288,6 +2357,7 @@ def main(): "use_csv": False, "blocks_confirmed": 1, "conf_target": 2, + "core_version_no": getKnownVersion("namecoin"), "core_version_group": 18, "chain_lookups": "local", }, @@ -2312,6 +2382,7 @@ def main(): "walletrpctimeout": 120, "walletrpctimeoutlong": 600, "wallet_config_filename": "monero_wallet.conf", + "core_version_no": getKnownVersion("monero"), "core_type_group": "xmr", }, "pivx": { @@ -2326,6 +2397,7 @@ def main(): "use_csv": False, "blocks_confirmed": 1, "conf_target": 2, + "core_version_no": getKnownVersion("pivx"), "core_version_group": 17, }, "dash": { @@ -2340,6 +2412,7 @@ def main(): "use_csv": True, "blocks_confirmed": 1, "conf_target": 2, + "core_version_no": getKnownVersion("dash"), "core_version_group": 18, }, "firo": { @@ -2354,6 +2427,7 @@ def main(): "use_csv": False, "blocks_confirmed": 1, "conf_target": 2, + "core_version_no": getKnownVersion("firo"), "core_version_group": 14, "min_relay_fee": 0.00001, }, @@ -2369,6 +2443,7 @@ def main(): "use_csv": True, "blocks_confirmed": 1, "conf_target": 2, + "core_version_no": getKnownVersion("navcoin"), "core_version_group": 18, "chain_lookups": "local", "startup_tries": 40, @@ -2393,6 +2468,7 @@ def main(): "rpctimeout": 60, "walletrpctimeout": 120, "walletrpctimeoutlong": 300, + "core_version_no": getKnownVersion("wownero"), "core_type_group": "xmr", }, } @@ -2403,6 +2479,9 @@ def main(): if LTC_RPC_USER != "": chainclients["litecoin"]["rpcuser"] = LTC_RPC_USER chainclients["litecoin"]["rpcpassword"] = LTC_RPC_PWD + if DOGE_RPC_USER != "": + chainclients["dogecoin"]["rpcuser"] = DOGE_RPC_USER + chainclients["dogecoin"]["rpcpassword"] = DOGE_RPC_PWD if BTC_RPC_USER != "": chainclients["bitcoin"]["rpcuser"] = BTC_RPC_USER chainclients["bitcoin"]["rpcpassword"] = BTC_RPC_PWD @@ -2444,7 +2523,7 @@ def main(): init_coins = settings["chainclients"].keys() logger.info("Active coins: %s", ", ".join(init_coins)) - if coins_changed: + if with_coins_changed: init_coins = with_coins logger.info("Initialising coins: %s", ", ".join(init_coins)) initialise_wallets( @@ -2501,6 +2580,9 @@ def main(): return 0 if disable_coin != "": + if "particl" in disable_coin: + exitWithError("Cannot disable Particl (required for operation)") + logger.info("Disabling coin: %s", disable_coin) settings = load_config(config_path) @@ -2563,7 +2645,7 @@ def main(): if not no_cores: prepareCore(add_coin, known_coins[add_coin], settings, data_dir, extra_opts) - if not prepare_bin_only: + if not (prepare_bin_only or upgrade_cores): prepareDataDir( add_coin, settings, chain, particl_wallet_mnemonic, extra_opts ) @@ -2586,19 +2668,95 @@ def main(): logger.info(f"Done. Coin {add_coin} successfully added.") return 0 - logger.info("With coins: %s", ", ".join(with_coins)) + logger.info( + "With coins: " + + (", ".join(with_coins)) + + ("" if with_coins_changed else " (default)") + ) if os.path.exists(config_path): - if not prepare_bin_only: - exitWithError("{} exists".format(config_path)) - else: + if prepare_bin_only: + settings = load_config(config_path) + + # Add temporary default config for any coins that have not been added + for c in with_coins: + if c not in settings["chainclients"]: + settings["chainclients"][c] = chainclients[c] + elif upgrade_cores: with open(config_path) as fs: settings = json.load(fs) - # Add temporary default config for any coins that have not been added - for c in with_coins: - if c not in settings["chainclients"]: - settings["chainclients"][c] = chainclients[c] + with_coins_start = with_coins + if not with_coins_changed: + for coin_name, coin_settings in settings["chainclients"].items(): + with_coins_start.add(coin_name) + + with_coins = set() + for coin_name in with_coins_start: + if coin_name not in chainclients: + logger.warning(f"Skipping unknown coin: {coin_name}.") + continue + current_coin_settings = chainclients[coin_name] + if coin_name not in settings["chainclients"]: + exitWithError(f"{coin_name} not found in basicswap.json") + coin_settings = settings["chainclients"][coin_name] + + current_version = current_coin_settings["core_version_no"] + have_version = coin_settings.get("core_version_no", "") + + current_version_group = current_coin_settings.get( + "core_version_group", "" + ) + have_version_group = coin_settings.get("core_version_group", "") + + logger.info( + f"{coin_name}: have {have_version}, current {current_version}." + ) + if not BSX_UPDATE_UNMANAGED and not ( + coin_settings.get("manage_daemon", False) + or coin_settings.get("manage_wallet_daemon", False) + ): + logger.info(" Unmanaged.") + elif have_version != current_version: + logger.info(f" Trying to update {coin_name}.") + with_coins.add(coin_name) + elif have_version_group != current_version_group: + logger.info( + f" Trying to update {coin_name}, version group differs." + ) + with_coins.add(coin_name) + + if len(with_coins) < 1: + logger.info("Nothing to do.") + return 0 + + # Run second loop to update, so all versions are logged together. + # Backup settings + old_config_path = config_path[:-5] + "_" + str(int(time.time())) + ".json" + with open(old_config_path, "w") as fp: + json.dump(settings, fp, indent=4) + for c in with_coins: + prepareCore(c, known_coins[c], settings, data_dir, extra_opts) + current_coin_settings = chainclients[c] + current_version = current_coin_settings["core_version_no"] + current_version_group = current_coin_settings.get( + "core_version_group", "" + ) + settings["chainclients"][c]["core_version_no"] = current_version + if current_version_group != "": + settings["chainclients"][c][ + "core_version_group" + ] = current_version_group + with open(config_path, "w") as fp: + json.dump(settings, fp, indent=4) + + logger.info("Done.") + return 0 + else: + exitWithError(f"{config_path} exists") else: + if upgrade_cores: + exitWithError(f"{config_path} not found") + for c in with_coins: withchainclients[c] = chainclients[c] diff --git a/basicswap/bin/run.py b/basicswap/bin/run.py index 592a947..3400941 100755 --- a/basicswap/bin/run.py +++ b/basicswap/bin/run.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019-2024 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -19,7 +19,7 @@ import basicswap.config as cfg from basicswap import __version__ from basicswap.ui.util import getCoinName from basicswap.basicswap import BasicSwap -from basicswap.chainparams import chainparams +from basicswap.chainparams import chainparams, Coins from basicswap.http_server import HttpThread from basicswap.contrib.websocket_server import WebsocketServer @@ -58,23 +58,29 @@ def startDaemon(node_dir, bin_dir, daemon_bin, opts=[], extra_config={}): daemon_bin = os.path.expanduser(os.path.join(bin_dir, daemon_bin)) datadir_path = os.path.expanduser(node_dir) - # Rewrite litecoin.conf for 0.21.3 + # Rewrite litecoin.conf # TODO: Remove + needs_rewrite: bool = False ltc_conf_path = os.path.join(datadir_path, "litecoin.conf") if os.path.exists(ltc_conf_path): - config_to_add = ["blockfilterindex=0", "peerblockfilters=0"] with open(ltc_conf_path) as fp: for line in fp: line = line.strip() - if line in config_to_add: - config_to_add.remove(line) - - if len(config_to_add) > 0: + if line.endswith("=onion"): + needs_rewrite = True + break + if needs_rewrite: logger.info("Rewriting litecoin.conf") shutil.copyfile(ltc_conf_path, ltc_conf_path + ".last") - with open(ltc_conf_path, "a") as fp: - for line in config_to_add: - fp.write(line + "\n") + with ( + open(ltc_conf_path + ".last") as fp_from, + open(ltc_conf_path, "w") as fp_to, + ): + for line in fp_from: + if line.strip().endswith("=onion"): + fp_to.write(line.strip()[:-6] + "\n") + else: + fp_to.write(line) args = [ daemon_bin, @@ -241,12 +247,25 @@ def getWalletBinName(coin_id: int, coin_settings, default_name: str) -> str: ) + (".exe" if os.name == "nt" else "") -def getCoreBinArgs(coin_id: int, coin_settings): +def getCoreBinArgs(coin_id: int, coin_settings, prepare=False, use_tor_proxy=False): extra_args = [] if "config_filename" in coin_settings: extra_args.append("--conf=" + coin_settings["config_filename"]) if "port" in coin_settings: - extra_args.append("--port=" + str(int(coin_settings["port"]))) + if prepare is False and use_tor_proxy: + if coin_id == Coins.BCH: + # Without this BCH (27.1) will bind to the default BTC port, even with proxy set + extra_args.append("--bind=127.0.0.1:" + str(int(coin_settings["port"]))) + else: + extra_args.append("--port=" + str(int(coin_settings["port"]))) + + # BTC versions from v28 fail to start if the onionport is in use. + # As BCH may use port 8334, disable it here. + # When tor is enabled a bind option for the onionport will be added to bitcoin.conf. + # https://github.com/bitcoin/bitcoin/blob/master/doc/release-notes/release-notes-28.0.md?plain=1#L84 + if prepare is False and use_tor_proxy is False and coin_id == Coins.BTC: + port: int = coin_settings.get("port", 8333) + extra_args.append(f"--bind=0.0.0.0:{port}") return extra_args @@ -421,7 +440,7 @@ def runClient(fp, data_dir, chain, start_only_coins): swap_client.log.info(f"Starting {display_name} daemon") filename: str = getCoreBinName(coin_id, v, c + "d") - extra_opts = getCoreBinArgs(coin_id, v) + extra_opts = getCoreBinArgs(coin_id, v, use_tor_proxy=swap_client.use_tor_proxy) daemons.append( startDaemon(v["datadir"], v["bindir"], filename, opts=extra_opts) ) diff --git a/basicswap/chainparams.py b/basicswap/chainparams.py index c1ce0ef..3f3eca4 100644 --- a/basicswap/chainparams.py +++ b/basicswap/chainparams.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019-2024 tecnovert +# Copyright (c) 2024 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -31,6 +32,7 @@ class Coins(IntEnum): LTC_MWEB = 15 # ZANO = 16 BCH = 17 + DOGE = 18 chainparams = { @@ -153,6 +155,44 @@ chainparams = { "max_amount": 10000000 * COIN, }, }, + Coins.DOGE: { + "name": "dogecoin", + "ticker": "DOGE", + "message_magic": "Dogecoin Signed Message:\n", + "blocks_target": 60 * 1, + "decimal_places": 8, + "mainnet": { + "rpcport": 22555, + "pubkey_address": 30, + "script_address": 22, + "key_prefix": 158, + "hrp": "doge", + "bip44": 3, + "min_amount": 100000, # TODO increase above fee + "max_amount": 10000000 * COIN, + }, + "testnet": { + "rpcport": 44555, + "pubkey_address": 113, + "script_address": 196, + "key_prefix": 241, + "hrp": "tdge", + "bip44": 1, + "min_amount": 100000, + "max_amount": 10000000 * COIN, + "name": "testnet4", + }, + "regtest": { + "rpcport": 18332, + "pubkey_address": 111, + "script_address": 196, + "key_prefix": 239, + "hrp": "rdge", + "bip44": 1, + "min_amount": 100000, + "max_amount": 10000000 * COIN, + }, + }, Coins.DCR: { "name": "decred", "ticker": "DCR", diff --git a/basicswap/config.py b/basicswap/config.py index 2ee7648..9c8d06d 100644 --- a/basicswap/config.py +++ b/basicswap/config.py @@ -36,6 +36,10 @@ LITECOIND = os.getenv("LITECOIND", "litecoind" + bin_suffix) LITECOIN_CLI = os.getenv("LITECOIN_CLI", "litecoin-cli" + bin_suffix) LITECOIN_TX = os.getenv("LITECOIN_TX", "litecoin-tx" + bin_suffix) +DOGECOIND = os.getenv("DOGECOIND", "dogecoind" + bin_suffix) +DOGECOIN_CLI = os.getenv("DOGECOIN_CLI", "dogecoin-cli" + bin_suffix) +DOGECOIN_TX = os.getenv("DOGECOIN_TX", "dogecoin-tx" + bin_suffix) + NAMECOIN_BINDIR = os.path.expanduser( os.getenv("NAMECOIN_BINDIR", os.path.join(DEFAULT_TEST_BINDIR, "namecoin")) ) diff --git a/basicswap/interface/base.py b/basicswap/interface/base.py index bb1ae63..be31631 100644 --- a/basicswap/interface/base.py +++ b/basicswap/interface/base.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2024 tecnovert +# Copyright (c) 2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -53,6 +54,10 @@ class CoinInterface: self._mx_wallet = threading.Lock() self._altruistic = True + def interface_type(self) -> int: + # coin_type() returns the base coin type, interface_type() returns the coin+balance type. + return self.coin_type() + def setDefaults(self): self._unknown_wallet_seed = True self._restore_height = None @@ -188,7 +193,7 @@ class Secp256k1Interface(CoinInterface, AdaptorSigInterface): def curve_type(): return Curves.secp256k1 - def getNewSecretKey(self) -> bytes: + def getNewRandomKey(self) -> bytes: return i2b(getSecretInt()) def getPubkey(self, privkey: bytes) -> bytes: diff --git a/basicswap/interface/btc.py b/basicswap/interface/btc.py index 3bd7b6c..4b8bc8b 100644 --- a/basicswap/interface/btc.py +++ b/basicswap/interface/btc.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020-2024 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -1296,7 +1296,7 @@ class BTCInterface(Secp256k1Interface): def getWalletTransaction(self, txid: bytes): try: - return bytes.fromhex(self.rpc_wallet("gettransaction", [txid.hex()])) + return bytes.fromhex(self.rpc_wallet("gettransaction", [txid.hex()])["hex"]) except Exception as e: # noqa: F841 # TODO: filter errors return None @@ -1397,6 +1397,7 @@ class BTCInterface(Secp256k1Interface): cb_swap_value: int, b_fee: int, restore_height: int, + spend_actual_balance: bool = False, lock_tx_vout=None, ) -> bytes: self._log.info( @@ -1466,7 +1467,6 @@ class BTCInterface(Secp256k1Interface): vout: int = -1, ): # Add watchonly address and rescan if required - if not self.isAddressMine(dest_address, or_watch_only=True): self.importWatchOnlyAddress(dest_address, "bid") self._log.info("Imported watch-only addr: {}".format(dest_address)) diff --git a/basicswap/interface/dcr/dcr.py b/basicswap/interface/dcr/dcr.py index 6a8d436..614a73d 100644 --- a/basicswap/interface/dcr/dcr.py +++ b/basicswap/interface/dcr/dcr.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2024 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -1726,6 +1726,7 @@ class DCRInterface(Secp256k1Interface): cb_swap_value: int, b_fee: int, restore_height: int, + spend_actual_balance: bool = False, lock_tx_vout=None, ) -> bytes: self._log.info("spendBLockTx %s:\n", chain_b_lock_txid.hex()) diff --git a/basicswap/interface/doge.py b/basicswap/interface/doge.py new file mode 100644 index 0000000..caa5b49 --- /dev/null +++ b/basicswap/interface/doge.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 The BasicSwap developers +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. + +from .btc import BTCInterface +from basicswap.chainparams import Coins +from basicswap.util.crypto import hash160 + +from basicswap.contrib.test_framework.script import ( + CScript, + OP_DUP, + OP_CHECKSIG, + OP_HASH160, + OP_EQUAL, + OP_EQUALVERIFY, +) + + +class DOGEInterface(BTCInterface): + @staticmethod + def coin_type(): + return Coins.DOGE + + @staticmethod + def xmr_swap_b_lock_spend_tx_vsize() -> int: + return 192 + + def __init__(self, coin_settings, network, swap_client=None): + super(DOGEInterface, self).__init__(coin_settings, network, swap_client) + + def getScriptDest(self, script: bytearray) -> bytearray: + # P2SH + + script_hash = hash160(script) + assert len(script_hash) == 20 + + return CScript([OP_HASH160, script_hash, OP_EQUAL]) + + def getScriptForPubkeyHash(self, pkh: bytes) -> bytearray: + # Return P2PKH + return CScript([OP_DUP, OP_HASH160, pkh, OP_EQUALVERIFY, OP_CHECKSIG]) + + def encodeScriptDest(self, script_dest: bytes) -> str: + # Extract hash from script + script_hash = script_dest[2:-1] + return self.sh_to_address(script_hash) + + def getBLockSpendTxFee(self, tx, fee_rate: int) -> int: + add_bytes = 107 + size = len(tx.serialize_with_witness()) + add_bytes + pay_fee = round(fee_rate * size / 1000) + self._log.info( + f"BLockSpendTx fee_rate, size, fee: {fee_rate}, {size}, {pay_fee}." + ) + return pay_fee diff --git a/basicswap/interface/ltc.py b/basicswap/interface/ltc.py index a6c3ba2..33693f9 100644 --- a/basicswap/interface/ltc.py +++ b/basicswap/interface/ltc.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020-2023 tecnovert +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -52,7 +53,6 @@ class LTCInterface(BTCInterface): def getWalletInfo(self): rv = super(LTCInterface, self).getWalletInfo() - mweb_info = self.rpc_wallet_mweb("getwalletinfo") rv["mweb_balance"] = mweb_info["balance"] rv["mweb_unconfirmed"] = mweb_info["unconfirmed_balance"] @@ -88,8 +88,8 @@ class LTCInterface(BTCInterface): class LTCInterfaceMWEB(LTCInterface): - @staticmethod - def coin_type(): + + def interface_type(self) -> int: return Coins.LTC_MWEB def __init__(self, coin_settings, network, swap_client=None): diff --git a/basicswap/interface/nav.py b/basicswap/interface/nav.py index 4f131e5..ba8f190 100644 --- a/basicswap/interface/nav.py +++ b/basicswap/interface/nav.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2023 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -666,6 +666,7 @@ class NAVInterface(BTCInterface): cb_swap_value: int, b_fee: int, restore_height: int, + spend_actual_balance: bool = False, lock_tx_vout=None, ) -> bytes: self._log.info("spendBLockTx %s:\n", chain_b_lock_txid.hex()) diff --git a/basicswap/interface/part.py b/basicswap/interface/part.py index 49caad2..d7ef2b5 100644 --- a/basicswap/interface/part.py +++ b/basicswap/interface/part.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020-2024 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -187,6 +187,10 @@ class PARTInterface(BTCInterface): class PARTInterfaceBlind(PARTInterface): + + def interface_type(self) -> int: + return Coins.PART_BLIND + @staticmethod def balance_type(): return BalanceTypes.BLIND @@ -240,7 +244,7 @@ class PARTInterfaceBlind(PARTInterface): def createSCLockTx(self, value: int, script: bytearray, vkbv: bytes) -> bytes: # Nonce is derived from vkbv, ephemeral_key isn't used - ephemeral_key = self.getNewSecretKey() + ephemeral_key = self.getNewRandomKey() ephemeral_pubkey = self.getPubkey(ephemeral_key) assert len(ephemeral_pubkey) == 33 nonce = self.getScriptLockTxNonce(vkbv) @@ -257,9 +261,7 @@ class PARTInterfaceBlind(PARTInterface): ] params = [inputs, outputs] rv = self.rpc_wallet("createrawparttransaction", params) - - tx_bytes = bytes.fromhex(rv["hex"]) - return tx_bytes + return bytes.fromhex(rv["hex"]) def fundSCLockTx(self, tx_bytes: bytes, feerate: int, vkbv: bytes) -> bytes: feerate_str = self.format_amount(feerate) @@ -288,7 +290,7 @@ class PARTInterfaceBlind(PARTInterface): "lockUnspents": True, "feeRate": feerate_str, } - rv = self.rpc( + rv = self.rpc_wallet( "fundrawtransactionfrom", ["blind", tx_hex, {}, outputs_info, options] ) return bytes.fromhex(rv["hex"]) @@ -307,7 +309,7 @@ class PARTInterfaceBlind(PARTInterface): lock_tx_obj = self.rpc("decoderawtransaction", [tx_lock_bytes.hex()]) assert self.getTxid(tx_lock_bytes).hex() == lock_tx_obj["txid"] # Nonce is derived from vkbv, ephemeral_key isn't used - ephemeral_key = self.getNewSecretKey() + ephemeral_key = self.getNewRandomKey() ephemeral_pubkey = self.getPubkey(ephemeral_key) assert len(ephemeral_pubkey) == 33 nonce = self.getScriptLockTxNonce(vkbv) @@ -348,7 +350,7 @@ class PARTInterfaceBlind(PARTInterface): dummy_witness_stack = [x.hex() for x in dummy_witness_stack] # Use a junk change pubkey to avoid adding unused keys to the wallet - zero_change_key = self.getNewSecretKey() + zero_change_key = self.getNewRandomKey() zero_change_pubkey = self.getPubkey(zero_change_key) inputs_info = { "0": { @@ -428,7 +430,7 @@ class PARTInterfaceBlind(PARTInterface): dummy_witness_stack = [x.hex() for x in dummy_witness_stack] # Use a junk change pubkey to avoid adding unused keys to the wallet - zero_change_key = self.getNewSecretKey() + zero_change_key = self.getNewRandomKey() zero_change_pubkey = self.getPubkey(zero_change_key) inputs_info = { "0": { @@ -745,7 +747,7 @@ class PARTInterfaceBlind(PARTInterface): dummy_witness_stack = self.getScriptLockTxDummyWitness(script_lock) # Use a junk change pubkey to avoid adding unused keys to the wallet - zero_change_key = self.getNewSecretKey() + zero_change_key = self.getNewRandomKey() zero_change_pubkey = self.getPubkey(zero_change_key) inputs_info = { "0": { @@ -949,7 +951,7 @@ class PARTInterfaceBlind(PARTInterface): dummy_witness_stack = [x.hex() for x in dummy_witness_stack] # Use a junk change pubkey to avoid adding unused keys to the wallet - zero_change_key = self.getNewSecretKey() + zero_change_key = self.getNewRandomKey() zero_change_pubkey = self.getPubkey(zero_change_key) inputs_info = { "0": { @@ -1158,10 +1160,44 @@ class PARTInterfaceBlind(PARTInterface): sub_fee: bool = False, lock_unspents: bool = True, ) -> str: - txn = self.rpc_wallet( - "createrawtransaction", [[], {addr_to: self.format_amount(amount)}] + # Estimate lock tx size / fee + + # self.createSCLockTx + vkbv = self.getNewRandomKey() + ephemeral_key = self.getNewRandomKey() + ephemeral_pubkey = self.getPubkey(ephemeral_key) + assert len(ephemeral_pubkey) == 33 + nonce = self.getScriptLockTxNonce(vkbv) + inputs = [] + outputs = [ + { + "type": "blind", + "amount": self.format_amount(amount), + "address": addr_to, + "nonce": nonce.hex(), + "data": ephemeral_pubkey.hex(), + } + ] + params = [inputs, outputs] + tx_hex = self.rpc_wallet("createrawparttransaction", params)["hex"] + + # self.fundSCLockTx + tx_obj = self.rpc("decoderawtransaction", [tx_hex]) + + assert len(tx_obj["vout"]) == 1 + txo = tx_obj["vout"][0] + blinded_info = self.rpc( + "rewindrangeproof", [txo["rangeproof"], txo["valueCommitment"], nonce.hex()] ) + outputs_info = { + 0: { + "value": blinded_info["amount"], + "blind": blinded_info["blind"], + "nonce": nonce.hex(), + } + } + options = { "lockUnspents": lock_unspents, "conf_target": self._conf_target, @@ -1170,10 +1206,16 @@ class PARTInterfaceBlind(PARTInterface): options["subtractFeeFromOutputs"] = [ 0, ] - return self.rpc_wallet("fundrawtransactionfrom", ["blind", txn, options])["hex"] + return self.rpc_wallet( + "fundrawtransactionfrom", ["blind", tx_hex, {}, outputs_info, options] + )["hex"] class PARTInterfaceAnon(PARTInterface): + + def interface_type(self) -> int: + return Coins.PART_ANON + @staticmethod def balance_type(): return BalanceTypes.ANON diff --git a/basicswap/interface/xmr.py b/basicswap/interface/xmr.py index 8614822..b947643 100644 --- a/basicswap/interface/xmr.py +++ b/basicswap/interface/xmr.py @@ -326,7 +326,7 @@ class XMRInterface(CoinInterface): return float(self.format_amount(fee_per_k_bytes)), "get_fee_estimate" - def getNewSecretKey(self) -> bytes: + def getNewRandomKey(self) -> bytes: # Note: Returned bytes are in big endian order return i2b(edu.get_secret()) diff --git a/basicswap/js_server.py b/basicswap/js_server.py index 16a1a4e..7ffd1e9 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020-2024 tecnovert +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -250,6 +251,8 @@ def js_offers(self, url_split, post_string, is_json, sent=False) -> bytes: "is_expired": o.expire_at <= swap_client.getTime(), "is_own_offer": o.was_sent, "is_revoked": True if o.active_ind == 2 else False, + "is_public": o.addr_to == swap_client.network_addr + or o.addr_to.strip() == "", } if with_extra_info: offer_data["amount_negotiable"] = o.amount_negotiable @@ -704,7 +707,10 @@ def js_identities(self, url_split, post_string: str, is_json: bool) -> bytes: ensure("address" in filters, "Must provide an address to modify data") swap_client.setIdentityData(filters, set_data) - return bytes(json.dumps(swap_client.listIdentities(filters)), "UTF-8") + rv = swap_client.listIdentities(filters) + if "address" in filters: + rv = {} if len(rv) < 1 else rv[0] + return bytes(json.dumps(rv), "UTF-8") def js_automationstrategies(self, url_split, post_string: str, is_json: bool) -> bytes: @@ -829,28 +835,40 @@ def js_getcoinseed(self, url_split, post_string, is_json) -> bytes: raise ValueError("Particl wallet seed is set from the Basicswap mnemonic.") ci = swap_client.ci(coin) + rv = {"coin": ci.ticker()} if coin in (Coins.XMR, Coins.WOW): key_view = swap_client.getWalletKey(coin, 1, for_ed25519=True) key_spend = swap_client.getWalletKey(coin, 2, for_ed25519=True) address = ci.getAddressFromKeys(key_view, key_spend) - return bytes( - json.dumps( - { - "coin": ci.ticker(), - "key_view": ci.encodeKey(key_view), - "key_spend": ci.encodeKey(key_spend), - "address": address, - } - ), - "UTF-8", + + expect_address = swap_client.getCachedMainWalletAddress(ci) + rv.update( + { + "key_view": ci.encodeKey(key_view), + "key_spend": ci.encodeKey(key_spend), + "address": address, + "expected_address": ( + "Unset" if expect_address is None else expect_address + ), + } + ) + else: + seed_key = swap_client.getWalletKey(coin, 1) + seed_id = ci.getSeedHash(seed_key) + expect_seedid = swap_client.getStringKV( + "main_wallet_seedid_" + ci.coin_name().lower() + ) + + rv.update( + { + "seed": seed_key.hex(), + "seed_id": seed_id.hex(), + "expected_seed_id": "Unset" if expect_seedid is None else expect_seedid, + } ) - seed_key = swap_client.getWalletKey(coin, 1) - seed_id = ci.getSeedHash(seed_key) return bytes( - json.dumps( - {"coin": ci.ticker(), "seed": seed_key.hex(), "seed_id": seed_id.hex()} - ), + json.dumps(rv), "UTF-8", ) diff --git a/basicswap/protocols/xmr_swap_1.py b/basicswap/protocols/xmr_swap_1.py index 9620346..e2a7276 100644 --- a/basicswap/protocols/xmr_swap_1.py +++ b/basicswap/protocols/xmr_swap_1.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020-2024 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -15,9 +15,10 @@ from basicswap.chainparams import ( Coins, ) from basicswap.basicswap_util import ( + EventLogTypes, KeyTypes, SwapTypes, - EventLogTypes, + TxTypes, ) from . import ProtocolInterface from basicswap.contrib.test_framework.script import CScript, CScriptOp, OP_CHECKMULTISIG @@ -55,7 +56,7 @@ def recoverNoScriptTxnWithKey(self, bid_id: bytes, encoded_key, cursor=None): ensure(xmr_offer, "Adaptor-sig offer not found: {}.".format(bid.offer_id.hex())) # The no-script coin is always the follower - reverse_bid: bool = self.is_reverse_ads_bid(offer.coin_from) + reverse_bid: bool = self.is_reverse_ads_bid(offer.coin_from, offer.coin_to) ci_from = self.ci(Coins(offer.coin_from)) ci_to = self.ci(Coins(offer.coin_to)) ci_follower = ci_from if reverse_bid else ci_to @@ -89,16 +90,20 @@ def recoverNoScriptTxnWithKey(self, bid_id: bytes, encoded_key, cursor=None): summed_pkbs = ci_follower.getPubkey(vkbs) if summed_pkbs != xmr_swap.pkbs: err_msg: str = "Summed key does not match expected wallet spend pubkey" - have_pk = summed_pkbs.hex() - expect_pk = xmr_swap.pkbs.hex() - self.log.error(f"{err_msg}. Got: {have_pk}, Expect: {expect_pk}") + self.log.error( + f"{err_msg}. Got: {summed_pkbs.hex()}, Expect: {xmr_swap.pkbs.hex()}" + ) raise ValueError(err_msg) - if ci_follower.coin_type() in (Coins.XMR, Coins.WOW): + coin_to: int = ci_follower.interface_type() + base_coin_to: int = ci_follower.coin_type() + if coin_to in (Coins.XMR, Coins.WOW): address_to = self.getCachedMainWalletAddress(ci_follower, use_cursor) + elif coin_to in (Coins.PART_BLIND, Coins.PART_ANON): + address_to = self.getCachedStealthAddressForCoin(base_coin_to, use_cursor) else: - address_to = self.getCachedStealthAddressForCoin( - ci_follower.coin_type(), use_cursor + address_to = self.getReceiveAddressFromPool( + base_coin_to, bid_id, TxTypes.XMR_SWAP_B_LOCK_SPEND, use_cursor ) amount = bid.amount_to lock_tx_vout = bid.getLockTXBVout() @@ -145,10 +150,11 @@ def getChainBSplitKey(swap_client, bid, xmr_swap, offer): was_sent: bool = bid.was_received if reverse_bid else bid.was_sent key_type = KeyTypes.KBSF if was_sent else KeyTypes.KBSL + return ci_follower.encodeKey( swap_client.getPathKey( - ci_leader.coin_type(), - ci_follower.coin_type(), + ci_leader.interface_type(), + ci_follower.interface_type(), bid.created_at, xmr_swap.contract_count, key_type, diff --git a/basicswap/static/css/style.css b/basicswap/static/css/style.css index 96bfb70..fd764b2 100644 --- a/basicswap/static/css/style.css +++ b/basicswap/static/css/style.css @@ -356,4 +356,14 @@ select.disabled-select-enabled { #toggle-auto-refresh[data-enabled="true"] { @apply bg-green-500 hover:bg-green-600 focus:ring-green-300; } + + [data-popper-placement] { + will-change: transform; + transform: translateZ(0); +} + +.tooltip { + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} diff --git a/basicswap/static/images/coins/Doge-20.png b/basicswap/static/images/coins/Dogecoin-20.png similarity index 100% rename from basicswap/static/images/coins/Doge-20.png rename to basicswap/static/images/coins/Dogecoin-20.png diff --git a/basicswap/static/images/coins/Doge.png b/basicswap/static/images/coins/Dogecoin.png similarity index 100% rename from basicswap/static/images/coins/Doge.png rename to basicswap/static/images/coins/Dogecoin.png diff --git a/basicswap/static/images/coins/Litecoin%MWEB.png b/basicswap/static/images/coins/Litecoin%20MWEB.png similarity index 100% rename from basicswap/static/images/coins/Litecoin%MWEB.png rename to basicswap/static/images/coins/Litecoin%20MWEB.png diff --git a/basicswap/static/images/coins/ParticlAnon-20.png b/basicswap/static/images/coins/ParticlAnon-20.png deleted file mode 100644 index d412b84..0000000 Binary files a/basicswap/static/images/coins/ParticlAnon-20.png and /dev/null differ diff --git a/basicswap/static/images/coins/ParticlAnon.png b/basicswap/static/images/coins/ParticlAnon.png deleted file mode 100644 index f2947fe..0000000 Binary files a/basicswap/static/images/coins/ParticlAnon.png and /dev/null differ diff --git a/basicswap/static/images/coins/ParticlBlind-20.png b/basicswap/static/images/coins/ParticlBlind-20.png deleted file mode 100644 index d412b84..0000000 Binary files a/basicswap/static/images/coins/ParticlBlind-20.png and /dev/null differ diff --git a/basicswap/static/images/coins/ParticlBlind.png b/basicswap/static/images/coins/ParticlBlind.png deleted file mode 100644 index f2947fe..0000000 Binary files a/basicswap/static/images/coins/ParticlBlind.png and /dev/null differ diff --git a/basicswap/static/js/offerstable.js b/basicswap/static/js/offerstable.js index 3689d09..078acf3 100644 --- a/basicswap/static/js/offerstable.js +++ b/basicswap/static/js/offerstable.js @@ -1,113 +1,35 @@ +// GLOBAL STATE VARIABLES let latestPrices = null; let lastRefreshTime = null; -let newEntriesCount = 0; -let nextRefreshCountdown = 60; let currentPage = 1; -const itemsPerPage = 50; -let lastAppliedFilters = {}; - -const CACHE_KEY = 'latestPricesCache'; - -const MIN_REFRESH_INTERVAL = 60; // 60 sec - -const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes -const FALLBACK_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours - let jsonData = []; let originalJsonData = []; -let isInitialLoad = true; -let tableRateModule; -const isSentOffers = window.offersTableConfig.isSentOffers; - let currentSortColumn = 0; let currentSortDirection = 'desc'; +let filterTimeout = null; +// CONFIGURATION CONSTANTS + +// Time Constants +const MIN_REFRESH_INTERVAL = 60; // 60 sec +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes +const FALLBACK_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours + +// Application Constants +const itemsPerPage = 50; const PRICE_INIT_RETRIES = 3; const PRICE_INIT_RETRY_DELAY = 2000; +const isSentOffers = window.offersTableConfig.isSentOffers; -const offerCache = { - set: (key, value, customTtl = null) => { - const item = { - value: value, - timestamp: Date.now(), - expiresAt: Date.now() + (customTtl || CACHE_DURATION) - }; - localStorage.setItem(key, JSON.stringify(item)); - console.log(`🟢 Cache set for ${key}, expires in ${(customTtl || CACHE_DURATION) / 1000} seconds`); - console.log('Cached data:', { - key: key, - expiresIn: (customTtl || CACHE_DURATION) / 1000, - dataSize: typeof value === 'object' ? Object.keys(value).length : 'not an object' - }); - }, - - get: (key) => { - const itemStr = localStorage.getItem(key); - if (!itemStr) { - console.log(`🔴 No cache found for ${key}`); - return null; - } - try { - const item = JSON.parse(itemStr); - const now = Date.now(); - if (now < item.expiresAt) { - const remainingTime = (item.expiresAt - now) / 1000; - console.log(`🟢 Cache hit for ${key}, ${remainingTime.toFixed(1)} seconds remaining`); - return { - value: item.value, - remainingTime: item.expiresAt - now - }; - } else { - console.log(`🟡 Cache expired for ${key}`); - localStorage.removeItem(key); - } - } catch (e) { - console.error('❌ Error parsing cache item:', e); - localStorage.removeItem(key); - } - return null; - }, - - isValid: (key) => { - const result = offerCache.get(key) !== null; - console.log(`🔍 Cache validity check for ${key}: ${result ? 'valid' : 'invalid'}`); - return result; - }, - - clear: () => { - let clearedItems = []; - Object.keys(localStorage).forEach(key => { - if (key.startsWith('offers_') || key.startsWith('prices_')) { - clearedItems.push(key); - localStorage.removeItem(key); - } - }); - console.log(`🧹 Cache cleared: ${clearedItems.length} items removed`); - if (clearedItems.length > 0) { - console.log('Cleared items:', clearedItems); - } - }, - - debug: () => { - const cacheItems = {}; - Object.keys(localStorage).forEach(key => { - if (key.startsWith('offers_') || key.startsWith('prices_')) { - try { - const item = JSON.parse(localStorage.getItem(key)); - cacheItems[key] = { - expiresIn: ((item.expiresAt - Date.now()) / 1000).toFixed(1) + ' seconds', - dataSize: typeof item.value === 'object' ? Object.keys(item.value).length : 'not an object' - }; - } catch (e) { - cacheItems[key] = 'invalid cache item'; - } - } - }); - console.log('📊 Current cache status:', cacheItems); - return cacheItems; - } +const offersConfig = { + apiEndpoints: { + coinGecko: 'https://api.coingecko.com/api/v3', + cryptoCompare: 'https://min-api.cryptocompare.com/data' + }, + apiKeys: getAPIKeys() }; +// MAPPING OBJECTS const coinNameToSymbol = { 'Bitcoin': 'bitcoin', 'Particl': 'particl', @@ -153,13 +75,11 @@ const coinNameToDisplayName = { const coinIdToName = { 1: 'particl', 2: 'bitcoin', 3: 'litecoin', 4: 'decred', 6: 'monero', 7: 'particl blind', 8: 'particl anon', - 9: 'wownero', 11: 'pivx', 13: 'firo', 17: 'bitcoincash' + 9: 'wownero', 11: 'pivx', 13: 'firo', 17: 'bitcoincash', + 18: 'dogecoin' }; -const toggleButton = document.getElementById('toggleView'); -const tableView = document.getElementById('tableView'); -const jsonView = document.getElementById('jsonView'); -const jsonContent = document.getElementById('jsonContent'); +// DOM ELEMENT REFERENCES const offersBody = document.getElementById('offers-body'); const filterForm = document.getElementById('filterForm'); const prevPageButton = document.getElementById('prevPage'); @@ -168,7 +88,504 @@ const currentPageSpan = document.getElementById('currentPage'); const totalPagesSpan = document.getElementById('totalPages'); const lastRefreshTimeSpan = document.getElementById('lastRefreshTime'); const newEntriesCountSpan = document.getElementById('newEntriesCount'); -const nextRefreshTimeSpan = document.getElementById('nextRefreshTime'); + +const ScrollOptimizer = { + scrollTimeout: null, + isScrolling: false, + + init() { + document.body.classList.add('optimize-scroll'); + window.addEventListener('scroll', this.handleScroll.bind(this), { passive: true }); + }, + + handleScroll() { + if (!this.isScrolling) { + document.body.classList.add('is-scrolling'); + this.isScrolling = true; + } + + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + } + + this.scrollTimeout = setTimeout(() => { + document.body.classList.remove('is-scrolling'); + this.isScrolling = false; + }, 150); + } +}; + +const scrollStyles = document.createElement('style'); +scrollStyles.textContent = ` + .optimize-scroll { + -webkit-font-smoothing: antialiased; + } + + .is-scrolling .overflow-x-auto { + will-change: transform; + pointer-events: none; + } + + .is-scrolling * { + animation: none !important; + transition: none !important; + } +`; +document.head.appendChild(scrollStyles); + +document.addEventListener('DOMContentLoaded', () => { + ScrollOptimizer.init(); +}); + +let isTableRendering = false; +const tableContainer = document.querySelector('.overflow-x-auto'); + +function startTableRender() { + isTableRendering = true; + if (tableContainer) { + tableContainer.style.overflow = 'hidden'; + } +} + +function finishTableRender() { + isTableRendering = false; + setTimeout(() => { + if (tableContainer) { + tableContainer.style.overflow = 'auto'; + } + }, 100); +} + +// MANAGER OBJECTS +const WebSocketManager = { + ws: null, + messageQueue: [], + processingQueue: false, + debounceTimeout: null, + reconnectTimeout: null, + maxReconnectAttempts: 5, + reconnectAttempts: 0, + reconnectDelay: 5000, + maxQueueSize: 1000, + isIntentionallyClosed: false, + + connectionState: { + isConnecting: false, + lastConnectAttempt: null, + connectTimeout: null, + lastHealthCheck: null, + healthCheckInterval: null + }, + + initialize() { + console.log('Initializing WebSocket Manager'); + this.setupPageVisibilityHandler(); + this.connect(); + this.startHealthCheck(); + }, + + setupPageVisibilityHandler() { + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + this.handlePageHidden(); + } else { + this.handlePageVisible(); + } + }); + }, + + handlePageHidden() { + console.log('📱 Page hidden, suspending operations'); + this.stopHealthCheck(); + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.isIntentionallyClosed = true; + this.ws.close(1000, 'Page hidden'); + } + }, + + handlePageVisible() { + console.log('📱 Page visible, resuming operations'); + this.isIntentionallyClosed = false; + if (!this.isConnected()) { + this.connect(); + } + this.startHealthCheck(); + }, + + startHealthCheck() { + this.stopHealthCheck(); + this.connectionState.healthCheckInterval = setInterval(() => { + this.performHealthCheck(); + }, 30000); + }, + + stopHealthCheck() { + if (this.connectionState.healthCheckInterval) { + clearInterval(this.connectionState.healthCheckInterval); + this.connectionState.healthCheckInterval = null; + } + }, + + performHealthCheck() { + if (!this.isConnected()) { + console.warn('Health check: Connection lost, attempting reconnect'); + this.handleReconnect(); + return; + } + + const now = Date.now(); + const lastCheck = this.connectionState.lastHealthCheck; + if (lastCheck && (now - lastCheck) > 60000) { + console.warn('Health check: Connection stale, refreshing'); + this.handleReconnect(); + return; + } + + this.connectionState.lastHealthCheck = now; + console.log('Health check passed'); + }, + + connect() { + if (this.connectionState.isConnecting || this.isIntentionallyClosed) { + return false; + } + + this.cleanup(); + this.connectionState.isConnecting = true; + this.connectionState.lastConnectAttempt = Date.now(); + + try { + const config = getWebSocketConfig(); + const wsPort = config.port || window.ws_port || '11700'; + + if (!wsPort) { + console.error('WebSocket port not configured'); + this.connectionState.isConnecting = false; + return false; + } + + this.ws = new WebSocket(`ws://${window.location.hostname}:${wsPort}`); + this.setupEventHandlers(); + + this.connectionState.connectTimeout = setTimeout(() => { + if (this.connectionState.isConnecting) { + console.log('⏳ Connection attempt timed out'); + this.cleanup(); + this.handleReconnect(); + } + }, 5000); + + return true; + } catch (error) { + console.error('Error creating WebSocket:', error); + this.connectionState.isConnecting = false; + this.handleReconnect(); + return false; + } + }, + + setupEventHandlers() { + if (!this.ws) return; + + this.ws.onopen = () => { + console.log('🟢 WebSocket connected successfully'); + this.connectionState.isConnecting = false; + this.reconnectAttempts = 0; + clearTimeout(this.connectionState.connectTimeout); + this.connectionState.lastHealthCheck = Date.now(); + window.ws = this.ws; + updateConnectionStatus('connected'); + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + this.handleMessage(message); + } catch (error) { + console.error('Error processing WebSocket message:', error); + updateConnectionStatus('error'); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + updateConnectionStatus('error'); + }; + + this.ws.onclose = (event) => { + console.log('🔴 WebSocket closed:', event.code, event.reason); + this.connectionState.isConnecting = false; + window.ws = null; + updateConnectionStatus('disconnected'); + + if (!this.isIntentionallyClosed) { + this.handleReconnect(); + } + }; +}, + + handleMessage(message) { + if (this.messageQueue.length >= this.maxQueueSize) { + console.warn('Message queue full, dropping oldest message'); + this.messageQueue.shift(); + } + + clearTimeout(this.debounceTimeout); + this.messageQueue.push(message); + + this.debounceTimeout = setTimeout(() => { + this.processMessageQueue(); + }, 200); + }, + + async processMessageQueue() { + if (this.processingQueue || this.messageQueue.length === 0) return; + + this.processingQueue = true; + const endpoint = isSentOffers ? '/json/sentoffers' : '/json/offers'; + + try { + const response = await fetch(endpoint); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const newData = await response.json(); + const fetchedOffers = Array.isArray(newData) ? newData : Object.values(newData); + + jsonData = formatInitialData(fetchedOffers); + originalJsonData = [...jsonData]; + + requestAnimationFrame(() => { + updateOffersTable(); + updateJsonView(); + updatePaginationInfo(); + }); + + this.messageQueue = []; + } catch (error) { + console.error('Error processing message queue:', error); + } finally { + this.processingQueue = false; + } + }, + + handleReconnect() { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + } + + this.reconnectAttempts++; + if (this.reconnectAttempts <= this.maxReconnectAttempts) { + console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`); + + const delay = Math.min( + this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1), + 30000 + ); + + this.reconnectTimeout = setTimeout(() => { + if (!this.isIntentionallyClosed) { + this.connect(); + } + }, delay); + } else { + console.error('Max reconnection attempts reached'); + updateConnectionStatus('error'); + + setTimeout(() => { + this.reconnectAttempts = 0; + this.connect(); + }, 60000); + } + }, + + cleanup() { + console.log('Cleaning up WebSocket resources'); + + clearTimeout(this.debounceTimeout); + clearTimeout(this.reconnectTimeout); + clearTimeout(this.connectionState.connectTimeout); + + this.messageQueue = []; + this.processingQueue = false; + this.connectionState.isConnecting = false; + + if (this.ws) { + this.ws.onopen = null; + this.ws.onmessage = null; + this.ws.onerror = null; + this.ws.onclose = null; + + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.close(1000, 'Cleanup'); + } + this.ws = null; + window.ws = null; + } + }, + + isConnected() { + return this.ws && this.ws.readyState === WebSocket.OPEN; + }, + + disconnect() { + this.isIntentionallyClosed = true; + this.cleanup(); + this.stopHealthCheck(); + } +}; + +const CacheManager = { + maxItems: 100, + maxSize: 5 * 1024 * 1024, // 5MB + + set: function(key, value, customTtl = null) { + try { + this.cleanup(); + + const item = { + value: value, + timestamp: Date.now(), + expiresAt: Date.now() + (customTtl || CACHE_DURATION) + }; + + const itemSize = new Blob([JSON.stringify(item)]).size; + if (itemSize > this.maxSize) { + //console.error(`Cache item exceeds maximum size (${(itemSize/1024/1024).toFixed(2)}MB)`); + return false; + } + + localStorage.setItem(key, JSON.stringify(item)); + return true; + + } catch (error) { + if (error.name === 'QuotaExceededError') { + this.cleanup(true); // Aggressive cleanup + try { + localStorage.setItem(key, JSON.stringify(item)); + return true; + } catch (retryError) { + //console.error('Storage quota exceeded even after cleanup'); + return false; + } + } + //console.error('Cache set error:', error); + return false; + } + }, + + get: function(key) { + try { + const itemStr = localStorage.getItem(key); + if (!itemStr) return null; + + const item = JSON.parse(itemStr); + const now = Date.now(); + + if (now < item.expiresAt) { + return { + value: item.value, + remainingTime: item.expiresAt - now + }; + } + + localStorage.removeItem(key); + } catch (error) { + localStorage.removeItem(key); + } + return null; + }, + + cleanup: function(aggressive = false) { + const now = Date.now(); + let totalSize = 0; + let itemCount = 0; + const items = []; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key.startsWith('offers_') && !key.startsWith('prices_')) continue; + + try { + const itemStr = localStorage.getItem(key); + const size = new Blob([itemStr]).size; + const item = JSON.parse(itemStr); + + if (now >= item.expiresAt) { + localStorage.removeItem(key); + continue; + } + + items.push({ + key, + size, + expiresAt: item.expiresAt, + timestamp: item.timestamp + }); + + totalSize += size; + itemCount++; + } catch (error) { + localStorage.removeItem(key); + } + } + + if (aggressive || totalSize > this.maxSize || itemCount > this.maxItems) { + items.sort((a, b) => b.timestamp - a.timestamp); + + while ((totalSize > this.maxSize || itemCount > this.maxItems) && items.length > 0) { + const item = items.pop(); + localStorage.removeItem(item.key); + totalSize -= item.size; + itemCount--; + } + } + }, + + clear: function() { + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith('offers_') || key.startsWith('prices_')) { + keys.push(key); + } + } + + keys.forEach(key => localStorage.removeItem(key)); + }, + + getStats: function() { + let totalSize = 0; + let itemCount = 0; + let expiredCount = 0; + const now = Date.now(); + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key.startsWith('offers_') && !key.startsWith('prices_')) continue; + + try { + const itemStr = localStorage.getItem(key); + const size = new Blob([itemStr]).size; + const item = JSON.parse(itemStr); + + totalSize += size; + itemCount++; + + if (now >= item.expiresAt) { + expiredCount++; + } + } catch (error) { + } + } + + return { + totalSizeMB: (totalSize / 1024 / 1024).toFixed(2), + itemCount, + expiredCount, + utilization: ((totalSize / this.maxSize) * 100).toFixed(1) + '%' + }; + } +}; window.tableRateModule = { coinNameToSymbol: { @@ -187,7 +604,7 @@ window.tableRateModule = { 'Bitcoin Cash': 'BCH', 'Dogecoin': 'DOGE' }, - + cache: {}, processedOffers: new Set(), @@ -223,7 +640,7 @@ window.tableRateModule = { this.processedOffers.add(offerId); return true; }, - + formatUSD(value) { if (Math.abs(value) < 0.000001) { return value.toExponential(8) + ' USD'; @@ -253,7 +670,7 @@ window.tableRateModule = { document.querySelectorAll('.coinname-value').forEach(coinNameValue => { const coinFullNameOrSymbol = coinNameValue.getAttribute('data-coinname'); if (!coinFullNameOrSymbol || coinFullNameOrSymbol === 'Unknown') { - console.warn('Missing or unknown coin name/symbol in data-coinname attribute'); + //console.warn('Missing or unknown coin name/symbol in data-coinname attribute'); return; } coinNameValue.classList.remove('hidden'); @@ -276,378 +693,116 @@ window.tableRateModule = { }, init() { - console.log('Initializing TableRateModule'); + //console.log('Initializing TableRateModule'); this.initializeTable(); } }; -document.querySelectorAll('th[data-sortable="true"]').forEach(header => { - header.addEventListener('click', () => { - const columnIndex = parseInt(header.getAttribute('data-column-index')); - - if (currentSortColumn === columnIndex) { - currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc'; - } else { +// CORE SYSTEM FUNCTIONS +function initializeWebSocket() { + return WebSocketManager.initialize(); +} - currentSortColumn = columnIndex; - currentSortDirection = 'desc'; - } - - document.querySelectorAll('.sort-icon').forEach(icon => { - icon.classList.remove('text-blue-500'); - icon.textContent = '↓'; - }); - - const sortIcon = document.getElementById(`sort-icon-${columnIndex}`); - if (sortIcon) { - sortIcon.textContent = currentSortDirection === 'asc' ? '↑' : '↓'; - sortIcon.classList.add('text-blue-500'); - } - - document.querySelectorAll('th[data-sortable="true"]').forEach(th => { - const thColumnIndex = parseInt(th.getAttribute('data-column-index')); - if (thColumnIndex === columnIndex) { - th.classList.add('text-blue-500'); - } else { - th.classList.remove('text-blue-500'); - } - }); - - localStorage.setItem('tableSortColumn', currentSortColumn); - localStorage.setItem('tableSortDirection', currentSortDirection); - - applyFilters(); - }); - - header.classList.add('cursor-pointer', 'hover:bg-gray-100', 'dark:hover:bg-gray-700'); -}); - -function makePostRequest(url, headers = {}) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/json/readurl'); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.timeout = 30000; - xhr.ontimeout = () => reject(new Error('Request timed out')); - xhr.onload = () => { - console.log(`Response for ${url}:`, xhr.responseText); - if (xhr.status === 200) { - try { - const response = JSON.parse(xhr.responseText); - if (response.Error) { - console.error(`API Error for ${url}:`, response.Error); - reject(new Error(response.Error)); - } else { - resolve(response); - } - } catch (error) { - console.error(`Invalid JSON response for ${url}:`, xhr.responseText); - reject(new Error(`Invalid JSON response: ${error.message}`)); - } - } else { - console.error(`HTTP Error for ${url}: ${xhr.status} ${xhr.statusText}`); - reject(new Error(`HTTP Error: ${xhr.status} ${xhr.statusText}`)); - } - }; - xhr.onerror = () => reject(new Error('Network error occurred')); - xhr.send(JSON.stringify({ - url: url, - headers: headers - })); - }); +function initializeTableRateModule() { + if (typeof window.tableRateModule !== 'undefined') { + tableRateModule = window.tableRateModule; + //console.log('tableRateModule loaded successfully'); + return true; + } else { + //console.warn('tableRateModule not found. Waiting for it to load...'); + return false; + } } async function initializePriceData() { - console.log('Initializing price data...'); + //console.log('Initializing price data...'); let retryCount = 0; let prices = null; + const PRICES_CACHE_KEY = 'prices_coingecko'; + const cachedPrices = CacheManager.get(PRICES_CACHE_KEY); + if (cachedPrices && cachedPrices.value) { + console.log('Using cached price data'); + latestPrices = cachedPrices.value; + return true; + } + while (retryCount < PRICE_INIT_RETRIES) { try { prices = await fetchLatestPrices(); - - if (prices && Object.keys(prices).length > 0) { - console.log('Successfully fetched initial price data:', prices); - latestPrices = prices; - const PRICES_CACHE_KEY = 'prices_coingecko'; - offerCache.set(PRICES_CACHE_KEY, prices, CACHE_DURATION); - + if (prices && Object.keys(prices).length > 0) { + console.log('Successfully fetched initial price data'); + latestPrices = prices; + CacheManager.set(PRICES_CACHE_KEY, prices, CACHE_DURATION); return true; } - - console.warn(`Attempt ${retryCount + 1}: Price data incomplete, retrying...`); + retryCount++; - + if (retryCount < PRICE_INIT_RETRIES) { await new Promise(resolve => setTimeout(resolve, PRICE_INIT_RETRY_DELAY)); } } catch (error) { console.error(`Error fetching prices (attempt ${retryCount + 1}):`, error); retryCount++; - + if (retryCount < PRICE_INIT_RETRIES) { await new Promise(resolve => setTimeout(resolve, PRICE_INIT_RETRY_DELAY)); } } } - const fallbackPrices = getFallbackPrices(); - if (fallbackPrices && Object.keys(fallbackPrices).length > 0) { - console.log('Using fallback prices:', fallbackPrices); - latestPrices = fallbackPrices; - return true; - } - return false; } -function loadSortPreferences() { - const savedColumn = localStorage.getItem('tableSortColumn'); - const savedDirection = localStorage.getItem('tableSortDirection'); - - if (savedColumn !== null) { - currentSortColumn = parseInt(savedColumn); - currentSortDirection = savedDirection || 'desc'; - } -} - -function escapeHtml(unsafe) { - if (typeof unsafe !== 'string') { - console.warn('escapeHtml received a non-string value:', unsafe); - return ''; - } - return unsafe - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function formatTimeDifference(timestamp) { - const now = Math.floor(Date.now() / 1000); - const diff = Math.abs(now - timestamp); - - if (diff < 60) return `${diff} seconds`; - if (diff < 3600) return `${Math.floor(diff / 60)} minutes`; - if (diff < 86400) return `${Math.floor(diff / 3600)} hours`; - if (diff < 2592000) return `${Math.floor(diff / 86400)} days`; - if (diff < 31536000) return `${Math.floor(diff / 2592000)} months`; - return `${Math.floor(diff / 31536000)} years`; -} - -function formatTimeAgo(timestamp) { - return `${formatTimeDifference(timestamp)} ago`; -} - -function formatTimeLeft(timestamp) { - const now = Math.floor(Date.now() / 1000); - if (timestamp <= now) return "Expired"; - return formatTimeDifference(timestamp); -} - -function getCoinSymbol(fullName) { - const symbolMap = { - 'Bitcoin': 'BTC', 'Litecoin': 'LTC', 'Monero': 'XMR', - 'Particl': 'PART', 'Particl Blind': 'PART', 'Particl Anon': 'PART', - 'PIVX': 'PIVX', 'Firo': 'FIRO', 'Zcoin': 'FIRO', - 'Dash': 'DASH', 'Decred': 'DCR', 'Wownero': 'WOW', - 'Bitcoin Cash': 'BCH' - }; - return symbolMap[fullName] || fullName; -} - -function getDisplayName(coinName) { - if (coinName.toLowerCase() === 'zcoin') { - return 'Firo'; - } - return coinNameToDisplayName[coinName] || coinName; -} - -function getCoinSymbolLowercase(coin) { - if (typeof coin === 'string') { - if (coin.toLowerCase() === 'bitcoin cash') { - return 'bitcoin-cash'; +function continueInitialization() { + updateCoinFilterImages(); + fetchOffers().then(() => { + applyFilters(); + if (!isSentOffers) { } - return (coinNameToSymbol[coin] || coin).toLowerCase(); - } else if (coin && typeof coin === 'object' && coin.symbol) { - return coin.symbol.toLowerCase(); - } else { - console.warn('Invalid coin input:', coin); - return 'unknown'; + }); + + const listingLabel = document.querySelector('span[data-listing-label]'); + if (listingLabel) { + listingLabel.textContent = isSentOffers ? 'Total Listings: ' : 'Network Listings: '; } + //console.log('Initialization completed'); } -function coinMatches(offerCoin, filterCoin) { - if (!offerCoin || !filterCoin) return false; - - offerCoin = offerCoin.toLowerCase(); - filterCoin = filterCoin.toLowerCase(); - - if (offerCoin === filterCoin) return true; - - if ((offerCoin === 'firo' || offerCoin === 'zcoin') && - (filterCoin === 'firo' || filterCoin === 'zcoin')) { - return true; - } - - if ((offerCoin === 'bitcoincash' && filterCoin === 'bitcoin cash') || - (offerCoin === 'bitcoin cash' && filterCoin === 'bitcoincash')) { - return true; - } - - const particlVariants = ['particl', 'particl anon', 'particl blind']; - if (filterCoin === 'particl' && particlVariants.includes(offerCoin)) { - return true; - } - - if (particlVariants.includes(filterCoin)) { - return offerCoin === filterCoin; - } - - return false; -} - -function getCachedPrices() { - const cachedItem = localStorage.getItem(CACHE_KEY); - if (cachedItem) { - const { data, timestamp } = JSON.parse(cachedItem); - if (Date.now() - timestamp < CACHE_DURATION) { - return data; - } - } - return null; -} - -function setCachedPrices(data) { - const cacheItem = { - data: data, - timestamp: Date.now() - }; - localStorage.setItem(CACHE_KEY, JSON.stringify(cacheItem)); -} - -function getButtonProperties(isActuallyExpired, isSentOffers, isTreatedAsSentOffer, isRevoked) { - if (isRevoked) { - return { - buttonClass: 'bg-red-500 text-white hover:bg-red-600 transition duration-200', - buttonText: 'Revoked' - }; - } else if (isActuallyExpired && isSentOffers) { - return { - buttonClass: 'bg-gray-400 text-white dark:border-gray-300 text-white hover:bg-red-700 transition duration-200', - buttonText: 'Expired' - }; - } else if (isTreatedAsSentOffer) { - return { - buttonClass: 'bg-gray-300 bold text-white bold hover:bg-green-600 transition duration-200', - buttonText: 'Edit' - }; - } else { - return { - buttonClass: 'bg-blue-500 text-white hover:bg-green-600 transition duration-200', - buttonText: 'Swap' - }; - } -} - -function getTimerColor(offer) { - const now = Math.floor(Date.now() / 1000); - const timeLeft = offer.expire_at - now; - - if (timeLeft <= 300) { // 5 min or less - return "#9CA3AF"; // Grey - } else if (timeLeft <= 1800) { // 5-30 min - return "#3B82F6"; // Blue - } else { // More than 30 min - return "#10B981"; // Green - } -} - -function getProfitColorClass(percentage) { - const numericPercentage = parseFloat(percentage); - if (numericPercentage > 0) return 'text-green-500'; - if (numericPercentage < 0) return 'text-red-500'; - if (numericPercentage === 0) return 'text-yellow-400'; - return 'text-white'; -} - -function isOfferExpired(offer) { - if (isSentOffers) { +function checkOfferAgainstFilters(offer, filters) { + if (filters.coin_to !== 'any' && !coinMatches(offer.coin_to, filters.coin_to)) { return false; } - const currentTime = Math.floor(Date.now() / 1000); - const isExpired = offer.expire_at <= currentTime; - if (isExpired) { - console.log(`Offer ${offer.offer_id} is expired. Expire time: ${offer.expire_at}, Current time: ${currentTime}`); + if (filters.coin_from !== 'any' && !coinMatches(offer.coin_from, filters.coin_from)) { + return false; } - return isExpired; -} + if (filters.status && filters.status !== 'any') { + const currentTime = Math.floor(Date.now() / 1000); + const isExpired = offer.expire_at <= currentTime; + const isRevoked = Boolean(offer.is_revoked); -function getTimeUntilNextExpiration() { - const currentTime = Math.floor(Date.now() / 1000); - const nextExpiration = jsonData.reduce((earliest, offer) => { - const timeUntilExpiration = offer.expire_at - currentTime; - return timeUntilExpiration > 0 && timeUntilExpiration < earliest ? timeUntilExpiration : earliest; - }, Infinity); - - return Math.max(MIN_REFRESH_INTERVAL, Math.min(nextExpiration, 300)); -} - -function calculateInverseRate(rate) { - return (1 / parseFloat(rate)).toFixed(8); -} - -function hasActiveFilters() { - const formData = new FormData(filterForm); - const filters = Object.fromEntries(formData); - - console.log('Current filters:', filters); - - const hasFilters = filters.coin_to !== 'any' || - filters.coin_from !== 'any' || - (filters.status && filters.status !== 'any'); - - console.log('Has active filters:', hasFilters); - - return hasFilters; -} - -function updateClearFiltersButton() { - const clearButton = document.getElementById('clearFilters'); - if (clearButton) { - clearButton.classList.toggle('opacity-50', !hasActiveFilters()); - clearButton.disabled = !hasActiveFilters(); + switch (filters.status) { + case 'active': + return !isExpired && !isRevoked; + case 'expired': + return isExpired && !isRevoked; + case 'revoked': + return isRevoked; + default: + return true; + } } + return true; } -function setRefreshButtonLoading(isLoading) { - const refreshButton = document.getElementById('refreshOffers'); - const refreshIcon = document.getElementById('refreshIcon'); - const refreshText = document.getElementById('refreshText'); - - refreshButton.disabled = isLoading; - refreshIcon.classList.toggle('animate-spin', isLoading); - refreshText.textContent = isLoading ? 'Refreshing...' : 'Refresh'; - - // Add visual feedback - if (isLoading) { - refreshButton.classList.add('opacity-75'); - refreshButton.classList.add('cursor-wait'); - } else { - refreshButton.classList.remove('opacity-75'); - refreshButton.classList.remove('cursor-wait'); - } -} - - function initializeFlowbiteTooltips() { if (typeof Tooltip === 'undefined') { console.warn('Tooltip is not defined. Make sure the required library is loaded.'); return; } - + const tooltipElements = document.querySelectorAll('[data-tooltip-target]'); tooltipElements.forEach((el) => { const tooltipId = el.getAttribute('data-tooltip-target'); @@ -658,148 +813,13 @@ function initializeFlowbiteTooltips() { }); } -function initializeFooter() { - if (isSentOffers) { - const nextRefreshContainer = document.getElementById('nextRefreshContainer'); - if (nextRefreshContainer) { - nextRefreshContainer.style.display = 'none'; - } - - if (typeof nextRefreshCountdown !== 'undefined') { - clearInterval(nextRefreshCountdown); - } - } -} - -function updateCoinFilterImages() { - const coinToSelect = document.getElementById('coin_to'); - const coinFromSelect = document.getElementById('coin_from'); - const coinToButton = document.getElementById('coin_to_button'); - const coinFromButton = document.getElementById('coin_from_button'); - - function updateButtonImage(select, button) { - const selectedOption = select.options[select.selectedIndex]; - const imagePath = selectedOption.getAttribute('data-image'); - if (imagePath && select.value !== 'any') { - button.style.backgroundImage = `url(${imagePath})`; - button.style.backgroundSize = 'contain'; - button.style.backgroundRepeat = 'no-repeat'; - button.style.backgroundPosition = 'center'; - } else { - button.style.backgroundImage = 'none'; - } - } - - updateButtonImage(coinToSelect, coinToButton); - updateButtonImage(coinFromSelect, coinFromButton); -} - -function applyFilters() { - console.log('Applying filters'); - - setTimeout(() => { - jsonData = filterAndSortData(); - updateOffersTable(); - updateJsonView(); - updatePaginationInfo(); - updateClearFiltersButton(); - console.log('Filters applied, table updated'); - }, 100); -} - -function updateRowTimes() { - const rows = document.querySelectorAll('[data-offer-id]'); - rows.forEach(row => { - const offerId = row.getAttribute('data-offer-id'); - const offer = jsonData.find(o => o.offer_id === offerId); - if (!offer) return; - - const timeColumn = row.querySelector('td:first-child'); - if (!timeColumn) return; - - const postedTime = formatTimeAgo(offer.created_at); - const expiresIn = formatTimeLeft(offer.expire_at); - const timerColor = getTimerColor(offer); - - const svg = timeColumn.querySelector('svg'); - if (svg) { - svg.querySelector('g').setAttribute('stroke', timerColor); - svg.querySelector('polyline').setAttribute('stroke', timerColor); - } - - const textContainer = timeColumn.querySelector('.xl\\:block'); - if (textContainer) { - const postedElement = textContainer.querySelector('.text-xs:first-child'); - const expiresElement = textContainer.querySelector('.text-xs:last-child'); - - if (postedElement) postedElement.textContent = `Posted: ${postedTime}`; - if (expiresElement) expiresElement.textContent = `Expires in: ${expiresIn}`; - } - }); -} - -function updateJsonView() { - jsonContent.textContent = JSON.stringify(jsonData, null, 2); -} - -function updateLastRefreshTime() { - if (lastRefreshTimeSpan) { - lastRefreshTimeSpan.textContent = lastRefreshTime ? new Date(lastRefreshTime).toLocaleTimeString() : 'Never'; - } -} - -function updateNextRefreshTime() { - if (isSentOffers) return; - - const nextRefreshTimeSpan = document.getElementById('nextRefreshTime'); - if (!nextRefreshTimeSpan) { - console.warn('nextRefreshTime element not found'); - return; - } - - const minutes = Math.floor(nextRefreshCountdown / 60); - const seconds = nextRefreshCountdown % 60; - nextRefreshTimeSpan.textContent = `${minutes}m ${seconds.toString().padStart(2, '0')}s`; -} - -function updatePaginationInfo() { - const validOffers = getValidOffers(); - const totalItems = validOffers.length; - const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)); - - currentPage = Math.max(1, Math.min(currentPage, totalPages)); - - currentPageSpan.textContent = currentPage; - totalPagesSpan.textContent = totalPages; - - const showPrev = currentPage > 1; - const showNext = currentPage < totalPages && totalItems > 0; - - prevPageButton.style.display = showPrev ? 'inline-flex' : 'none'; - nextPageButton.style.display = showNext ? 'inline-flex' : 'none'; - - if (lastRefreshTime) { - lastRefreshTimeSpan.textContent = new Date(lastRefreshTime).toLocaleTimeString(); - } - - if (newEntriesCountSpan) { - newEntriesCountSpan.textContent = totalItems; - } -} - -function updatePaginationControls(totalPages) { - prevPageButton.style.display = currentPage > 1 ? 'inline-flex' : 'none'; - nextPageButton.style.display = currentPage < totalPages ? 'inline-flex' : 'none'; - currentPageSpan.textContent = currentPage; - totalPagesSpan.textContent = totalPages; -} - +// DATA PROCESSING FUNCTIONS async function checkExpiredAndFetchNew() { if (isSentOffers) return Promise.resolve(); - + console.log('Starting checkExpiredAndFetchNew'); const OFFERS_CACHE_KEY = 'offers_received'; - + try { const response = await fetch('/json/offers'); const data = await response.json(); @@ -825,12 +845,12 @@ async function checkExpiredAndFetchNew() { newListings = newListings.filter(offer => !isOfferExpired(offer)); originalJsonData = newListings; - offerCache.set(OFFERS_CACHE_KEY, newListings, CACHE_DURATION); + CacheManager.set(OFFERS_CACHE_KEY, newListings, CACHE_DURATION); const currentFilters = new FormData(filterForm); - const hasActiveFilters = currentFilters.get('coin_to') !== 'any' || + const hasActiveFilters = currentFilters.get('coin_to') !== 'any' || currentFilters.get('coin_from') !== 'any'; - + if (hasActiveFilters) { jsonData = filterAndSortData(); } else { @@ -840,51 +860,892 @@ async function checkExpiredAndFetchNew() { updateOffersTable(); updateJsonView(); updatePaginationInfo(); - + if (jsonData.length === 0) { handleNoOffersScenario(); } - nextRefreshCountdown = getTimeUntilNextExpiration(); - console.log(`Next refresh in ${nextRefreshCountdown} seconds`); - return jsonData.length; } catch (error) { - console.error('Error fetching new listings:', error); + //console.error('Error fetching new listings:', error); nextRefreshCountdown = 60; return Promise.reject(error); } } +function getValidOffers() { + if (!jsonData) { + //console.warn('jsonData is undefined or null'); + return []; + } + + const filteredData = filterAndSortData(); + //console.log(`getValidOffers: Found ${filteredData.length} valid offers`); + return filteredData; +} + +function filterAndSortData() { + //console.log('[Debug] Starting filter with data length:', originalJsonData.length); + + const formData = new FormData(filterForm); + const filters = Object.fromEntries(formData); + //console.log('[Debug] Active filters:', filters); + + if (filters.coin_to !== 'any') { + filters.coin_to = coinIdToName[filters.coin_to] || filters.coin_to; + } + if (filters.coin_from !== 'any') { + filters.coin_from = coinIdToName[filters.coin_from] || filters.coin_from; + } + + let filteredData = [...originalJsonData]; + + const sentFromFilter = filters.sent_from || 'any'; + + filteredData = filteredData.filter(offer => { + if (sentFromFilter === 'public') { + return offer.is_public; + } else if (sentFromFilter === 'private') { + return !offer.is_public; + } + return true; + }); + + filteredData = filteredData.filter(offer => { + if (!isSentOffers && isOfferExpired(offer)) { + return false; + } + + const coinFrom = (offer.coin_from || '').toLowerCase(); + const coinTo = (offer.coin_to || '').toLowerCase(); + + if (filters.coin_to !== 'any') { + if (!coinMatches(coinTo, filters.coin_to)) { + return false; + } + } + + if (filters.coin_from !== 'any') { + if (!coinMatches(coinFrom, filters.coin_from)) { + return false; + } + } + + if (isSentOffers && filters.status && filters.status !== 'any') { + const currentTime = Math.floor(Date.now() / 1000); + const isExpired = offer.expire_at <= currentTime; + const isRevoked = Boolean(offer.is_revoked); + + switch (filters.status) { + case 'active': + return !isExpired && !isRevoked; + case 'expired': + return isExpired && !isRevoked; + case 'revoked': + return isRevoked; + default: + return true; + } + } + + return true; + }); + + if (currentSortColumn !== null) { + filteredData.sort((a, b) => { + let comparison = 0; + + switch(currentSortColumn) { + case 0: // Time + comparison = a.created_at - b.created_at; + break; + case 5: // Rate + comparison = parseFloat(a.rate) - parseFloat(b.rate); + break; + case 6: // Market +/- + const aFromSymbol = getCoinSymbolLowercase(a.coin_from); + const aToSymbol = getCoinSymbolLowercase(a.coin_to); + const bFromSymbol = getCoinSymbolLowercase(b.coin_from); + const bToSymbol = getCoinSymbolLowercase(b.coin_to); + + const aFromPrice = latestPrices[aFromSymbol]?.usd || 0; + const aToPrice = latestPrices[aToSymbol]?.usd || 0; + const bFromPrice = latestPrices[bFromSymbol]?.usd || 0; + const bToPrice = latestPrices[bToSymbol]?.usd || 0; + + const aMarketRate = aToPrice / aFromPrice; + const bMarketRate = bToPrice / bFromPrice; + + const aOfferedRate = parseFloat(a.rate); + const bOfferedRate = parseFloat(b.rate); + + const aPercentDiff = ((aOfferedRate - aMarketRate) / aMarketRate) * 100; + const bPercentDiff = ((bOfferedRate - bMarketRate) / bMarketRate) * 100; + + comparison = aPercentDiff - bPercentDiff; + break; + case 7: // Trade + comparison = a.offer_id.localeCompare(b.offer_id); + break; + } + + return currentSortDirection === 'desc' ? -comparison : comparison; + }); + } + + //console.log(`[Debug] Filtered data length: ${filteredData.length}`); + return filteredData; +} + +function calculateProfitLoss(fromCoin, toCoin, fromAmount, toAmount, isOwnOffer) { + return new Promise((resolve) => { + // console.log(`Calculating profit/loss for ${fromAmount} ${fromCoin} to ${toAmount} ${toCoin}, isOwnOffer: ${isOwnOffer}`); + + if (!latestPrices) { + console.error('Latest prices not available. Unable to calculate profit/loss.'); + resolve(null); + return; + } + + const getPriceKey = (coin) => { + const lowerCoin = coin.toLowerCase(); + if (lowerCoin === 'firo' || lowerCoin === 'zcoin') { + return 'zcoin'; + } + if (lowerCoin === 'bitcoin cash') { + return 'bitcoin-cash'; + } + if (lowerCoin === 'particl anon' || lowerCoin === 'particl blind') { + return 'particl'; + } + return coinNameToSymbol[coin] || lowerCoin; + }; + + const fromSymbol = getPriceKey(fromCoin); + const toSymbol = getPriceKey(toCoin); + + const fromPriceUSD = latestPrices[fromSymbol]?.usd; + const toPriceUSD = latestPrices[toSymbol]?.usd; + + if (!fromPriceUSD || !toPriceUSD) { + //console.warn(`Price data missing for ${fromSymbol} (${fromPriceUSD}) or ${toSymbol} (${toPriceUSD})`); + resolve(null); + return; + } + + const fromValueUSD = fromAmount * fromPriceUSD; + const toValueUSD = toAmount * toPriceUSD; + + let percentDiff; + if (isOwnOffer) { + percentDiff = ((toValueUSD / fromValueUSD) - 1) * 100; + } else { + percentDiff = ((fromValueUSD / toValueUSD) - 1) * 100; + } + + // console.log(`Percent difference: ${percentDiff.toFixed(2)}%`); + resolve(percentDiff); + }); +} + +async function getMarketRate(fromCoin, toCoin) { + return new Promise((resolve) => { + //console.log(`Attempting to get market rate for ${fromCoin} to ${toCoin}`); + if (!latestPrices) { + //console.warn('Latest prices object is not available'); + resolve(null); + return; + } + + const getPriceKey = (coin) => { + const lowerCoin = coin.toLowerCase(); + if (lowerCoin === 'firo' || lowerCoin === 'zcoin') { + return 'zcoin'; + } + if (lowerCoin === 'bitcoin cash') { + return 'bitcoin-cash'; + } + return coinNameToSymbol[coin] || lowerCoin; + }; + + const fromSymbol = getPriceKey(fromCoin); + const toSymbol = getPriceKey(toCoin); + + const fromPrice = latestPrices[fromSymbol]?.usd; + const toPrice = latestPrices[toSymbol]?.usd; + if (!fromPrice || !toPrice) { + //console.warn(`Missing price data for ${!fromPrice ? fromCoin : toCoin}`); + resolve(null); + return; + } + const rate = toPrice / fromPrice; + //console.log(`Market rate calculated: ${rate} ${toCoin}/${fromCoin}`); + resolve(rate); + }); +} + +async function fetchLatestPrices() { + const PRICES_CACHE_KEY = 'prices_coingecko'; + const apiKeys = getAPIKeys(); + + const cachedData = CacheManager.get(PRICES_CACHE_KEY); + if (cachedData && cachedData.remainingTime > 60000) { + console.log('Using cached price data (valid for next minute)'); + latestPrices = cachedData.value; + return cachedData.value; + } + + const url = `${offersConfig.apiEndpoints.coinGecko}/simple/price?ids=bitcoin,bitcoin-cash,dash,dogecoin,decred,litecoin,particl,pivx,monero,zano,wownero,zcoin&vs_currencies=USD,BTC&api_key=${offersConfig.apiKeys.coinGecko}`; + + try { + console.log('Initiating fresh price data fetch...'); + const response = await fetch('/json/readurl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + url: url, + headers: {} + }) + }); + + if (!response.ok) { + throw new Error(`HTTP Error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + if (data.Error) { + console.error('API Error:', data.Error); + throw new Error(data.Error); + } + + if (data && Object.keys(data).length > 0) { + console.log('Processing fresh price data...'); + latestPrices = data; + CacheManager.set(PRICES_CACHE_KEY, data, CACHE_DURATION); + const fallbackLog = {}; + Object.entries(data).forEach(([coin, prices]) => { + tableRateModule.setFallbackValue(coin, prices.usd); + fallbackLog[coin] = prices.usd; + }); + + //console.log('Fallback Values Set:', fallbackLog); + + return data; + } else { + console.warn('No price data received'); + return null; + } + } catch (error) { + console.error('Price Fetch Error:', { + message: error.message, + name: error.name, + stack: error.stack + }); + throw error; + } +} + +async function fetchOffers(manualRefresh = false) { + const refreshButton = document.getElementById('refreshOffers'); + const refreshIcon = document.getElementById('refreshIcon'); + const refreshText = document.getElementById('refreshText'); + + try { + refreshButton.disabled = true; + refreshIcon.classList.add('animate-spin'); + refreshText.textContent = 'Refreshing...'; + refreshButton.classList.add('opacity-75', 'cursor-wait'); + + const endpoint = isSentOffers ? '/json/sentoffers' : '/json/offers'; + const response = await fetch(endpoint); + const data = await response.json(); + + jsonData = formatInitialData(data); + originalJsonData = [...jsonData]; + + await updateOffersTable(); + updateJsonView(); + updatePaginationInfo(); + + } catch (error) { + console.error('[Debug] Error fetching offers:', error); + ui.displayErrorMessage('Failed to fetch offers. Please try again later.'); + } finally { + stopRefreshAnimation(); + } +} + +function formatInitialData(data) { + return data.map(offer => ({ + offer_id: String(offer.offer_id || ''), + swap_type: String(offer.swap_type || 'N/A'), + addr_from: String(offer.addr_from || ''), + addr_to: String(offer.addr_to || ''), + coin_from: String(offer.coin_from || ''), + coin_to: String(offer.coin_to || ''), + amount_from: String(offer.amount_from || '0'), + amount_to: String(offer.amount_to || '0'), + rate: String(offer.rate || '0'), + created_at: Number(offer.created_at || 0), + expire_at: Number(offer.expire_at || 0), + is_own_offer: Boolean(offer.is_own_offer), + amount_negotiable: Boolean(offer.amount_negotiable), + is_revoked: Boolean(offer.is_revoked), + is_public: offer.is_public !== undefined ? Boolean(offer.is_public) : false, + unique_id: `${offer.offer_id}_${offer.created_at}_${offer.coin_from}_${offer.coin_to}` + })); +} + +// UI COMPONENT FUNCTIONS +function updateConnectionStatus(status) { + const dot = document.getElementById('status-dot'); + const text = document.getElementById('status-text'); + + if (!dot || !text) { + //console.warn('Status indicators not found in DOM'); + return; + } + + switch(status) { + case 'connected': + dot.className = 'w-2.5 h-2.5 rounded-full bg-green-500 mr-2'; + text.textContent = 'Connected'; + text.className = 'text-sm text-green-500'; + break; + case 'disconnected': + dot.className = 'w-2.5 h-2.5 rounded-full bg-red-500 mr-2'; + text.textContent = 'Disconnected - Reconnecting...'; + text.className = 'text-sm text-red-500'; + break; + case 'error': + dot.className = 'w-2.5 h-2.5 rounded-full bg-yellow-500 mr-2'; + text.textContent = 'Connection Error'; + text.className = 'text-sm text-yellow-500'; + break; + default: + dot.className = 'w-2.5 h-2.5 rounded-full bg-gray-500 mr-2'; + text.textContent = 'Connecting...'; + text.className = 'text-sm text-gray-500'; + } +} + +function updateRowTimes() { + requestAnimationFrame(() => { + const rows = document.querySelectorAll('[data-offer-id]'); + rows.forEach(row => { + const offerId = row.getAttribute('data-offer-id'); + const offer = jsonData.find(o => o.offer_id === offerId); + if (!offer) return; + + const newPostedTime = formatTime(offer.created_at, true); + const newExpiresIn = formatTimeLeft(offer.expire_at); + + const postedElement = row.querySelector('.text-xs:first-child'); + const expiresElement = row.querySelector('.text-xs:last-child'); + + if (postedElement && postedElement.textContent !== `Posted: ${newPostedTime}`) { + postedElement.textContent = `Posted: ${newPostedTime}`; + } + if (expiresElement && expiresElement.textContent !== `Expires in: ${newExpiresIn}`) { + expiresElement.textContent = `Expires in: ${newExpiresIn}`; + } + }); + }); +} + +function updateJsonView() { + jsonContent.textContent = JSON.stringify(jsonData, null, 2); +} + +function updateLastRefreshTime() { + if (lastRefreshTimeSpan) { + lastRefreshTimeSpan.textContent = lastRefreshTime ? new Date(lastRefreshTime).toLocaleTimeString() : 'Never'; + } +} + +function stopRefreshAnimation() { + const refreshButton = document.getElementById('refreshOffers'); + const refreshIcon = document.getElementById('refreshIcon'); + const refreshText = document.getElementById('refreshText'); + + if (refreshButton) { + refreshButton.disabled = false; + refreshButton.classList.remove('opacity-75', 'cursor-wait'); + } + if (refreshIcon) { + refreshIcon.classList.remove('animate-spin'); + } + if (refreshText) { + refreshText.textContent = 'Refresh'; + } +} + +function updatePaginationInfo() { + const validOffers = getValidOffers(); + const totalItems = validOffers.length; + const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)); + + currentPage = Math.max(1, Math.min(currentPage, totalPages)); + + currentPageSpan.textContent = currentPage; + totalPagesSpan.textContent = totalPages; + + const showPrev = currentPage > 1; + const showNext = currentPage < totalPages && totalItems > 0; + + prevPageButton.style.display = showPrev ? 'inline-flex' : 'none'; + nextPageButton.style.display = showNext ? 'inline-flex' : 'none'; + + if (lastRefreshTime) { + lastRefreshTimeSpan.textContent = new Date(lastRefreshTime).toLocaleTimeString(); + } + + if (newEntriesCountSpan) { + newEntriesCountSpan.textContent = totalItems; + } +} + +function updatePaginationControls(totalPages) { + prevPageButton.style.display = currentPage > 1 ? 'inline-flex' : 'none'; + nextPageButton.style.display = currentPage < totalPages ? 'inline-flex' : 'none'; + currentPageSpan.textContent = currentPage; + totalPagesSpan.textContent = totalPages; +} + +function updateProfitLoss(row, fromCoin, toCoin, fromAmount, toAmount, isOwnOffer) { + const profitLossElement = row.querySelector('.profit-loss'); + if (!profitLossElement) { + //console.warn('Profit loss element not found in row'); + return; + } + + if (!fromCoin || !toCoin) { + //console.error(`Invalid coin names: fromCoin=${fromCoin}, toCoin=${toCoin}`); + profitLossElement.textContent = 'Error'; + profitLossElement.className = 'profit-loss text-lg font-bold text-red-500'; + return; + } + + calculateProfitLoss(fromCoin, toCoin, fromAmount, toAmount, isOwnOffer) + .then(percentDiff => { + if (percentDiff === null) { + profitLossElement.textContent = 'N/A'; + profitLossElement.className = 'profit-loss text-lg font-bold text-gray-400'; + return; + } + + const formattedPercentDiff = percentDiff.toFixed(2); + const percentDiffDisplay = formattedPercentDiff === "0.00" ? "0.00" : + (percentDiff > 0 ? `+${formattedPercentDiff}` : formattedPercentDiff); + + const colorClass = getProfitColorClass(percentDiff); + profitLossElement.textContent = `${percentDiffDisplay}%`; + profitLossElement.className = `profit-loss text-lg font-bold ${colorClass}`; + + const tooltipId = `percentage-tooltip-${row.getAttribute('data-offer-id')}`; + const tooltipElement = document.getElementById(tooltipId); + if (tooltipElement) { + const tooltipContent = createTooltipContent(isSentOffers || isOwnOffer, fromCoin, toCoin, fromAmount, toAmount); + tooltipElement.innerHTML = ` +
+ ${tooltipContent} +
+
+ `; + } + }) + .catch(error => { + //console.error('Error in updateProfitLoss:', error); + profitLossElement.textContent = 'Error'; + profitLossElement.className = 'profit-loss text-lg font-bold text-red-500'; + }); +} + +function updateCoinFilterImages() { + const coinToSelect = document.getElementById('coin_to'); + const coinFromSelect = document.getElementById('coin_from'); + const coinToButton = document.getElementById('coin_to_button'); + const coinFromButton = document.getElementById('coin_from_button'); + + function updateButtonImage(select, button) { + const selectedOption = select.options[select.selectedIndex]; + const imagePath = selectedOption.getAttribute('data-image'); + if (imagePath && select.value !== 'any') { + button.style.backgroundImage = `url(${imagePath})`; + button.style.backgroundSize = 'contain'; + button.style.backgroundRepeat = 'no-repeat'; + button.style.backgroundPosition = 'center'; + } else { + button.style.backgroundImage = 'none'; + } + } + + updateButtonImage(coinToSelect, coinToButton); + updateButtonImage(coinFromSelect, coinFromButton); +} + +function updateClearFiltersButton() { + const clearButton = document.getElementById('clearFilters'); + if (clearButton) { + const hasFilters = hasActiveFilters(); + clearButton.classList.toggle('opacity-50', !hasFilters); + clearButton.disabled = !hasFilters; + + if (hasFilters) { + clearButton.classList.add('hover:bg-green-600', 'hover:text-white'); + clearButton.classList.remove('cursor-not-allowed'); + } else { + clearButton.classList.remove('hover:bg-green-600', 'hover:text-white'); + clearButton.classList.add('cursor-not-allowed'); + } + } +} + +function cleanupRow(row) { + const tooltips = row.querySelectorAll('[data-tooltip-target]'); + const count = tooltips.length; + tooltips.forEach(tooltip => { + const tooltipId = tooltip.getAttribute('data-tooltip-target'); + const tooltipElement = document.getElementById(tooltipId); + if (tooltipElement) { + tooltipElement.remove(); + } + }); + //console.log(`Cleaned up ${count} tooltips from row`); +} + +function handleNoOffersScenario() { + const formData = new FormData(filterForm); + const filters = Object.fromEntries(formData); + const hasActiveFilters = filters.coin_to !== 'any' || + filters.coin_from !== 'any' || + (filters.status && filters.status !== 'any'); + + stopRefreshAnimation(); + + const existingRows = offersBody.querySelectorAll('tr'); + existingRows.forEach(row => { + cleanupRow(row); + }); + + if (hasActiveFilters) { + offersBody.innerHTML = ` + + +
+ No offers match the selected filters. Try different filter options or + +
+ + `; + } else { + offersBody.innerHTML = ` + + + No active offers available. + + `; + } +} + +async function updateOffersTable() { + try { + startTableRender(); + + const PRICES_CACHE_KEY = 'prices_coingecko'; + const cachedPrices = CacheManager.get(PRICES_CACHE_KEY); + + if (!cachedPrices || !cachedPrices.remainingTime || cachedPrices.remainingTime < 60000) { + console.log('Fetching fresh price data...'); + const priceData = await fetchLatestPrices(); + if (priceData) { + latestPrices = priceData; + } + } else { + latestPrices = cachedPrices.value; + } + + const validOffers = getValidOffers(); + + if (!isSentOffers) { + const networkOffersSpan = document.querySelector('a[href="/offers"] span.inline-flex.justify-center'); + if (networkOffersSpan) { + networkOffersSpan.textContent = validOffers.length; + } + } + + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = Math.min(startIndex + itemsPerPage, validOffers.length); + const itemsToDisplay = validOffers.slice(startIndex, endIndex); + + const identityPromises = itemsToDisplay.map(offer => + offer.addr_from ? getIdentityData(offer.addr_from) : Promise.resolve(null) + ); + + const identities = await Promise.all(identityPromises); + + if (validOffers.length === 0) { + const existingRows = offersBody.querySelectorAll('tr'); + existingRows.forEach(row => { + cleanupRow(row); + }); + handleNoOffersScenario(); + finishTableRender(); + return; + } + + const totalPages = Math.max(1, Math.ceil(validOffers.length / itemsPerPage)); + currentPage = Math.min(currentPage, totalPages); + + const fragment = document.createDocumentFragment(); + + const existingRows = offersBody.querySelectorAll('tr'); + existingRows.forEach(row => { + cleanupRow(row); + }); + + itemsToDisplay.forEach((offer, index) => { + const identity = identities[index]; + const row = createTableRow(offer, identity); + if (row) { + fragment.appendChild(row); + } + }); + + offersBody.textContent = ''; + offersBody.appendChild(fragment); + + requestAnimationFrame(() => { + initializeFlowbiteTooltips(); + updateRowTimes(); + updatePaginationControls(totalPages); + + if (tableRateModule?.initializeTable) { + tableRateModule.initializeTable(); + } + + finishTableRender(); + }); + + lastRefreshTime = Date.now(); + if (newEntriesCountSpan) { + newEntriesCountSpan.textContent = validOffers.length; + } + if (lastRefreshTimeSpan) { + lastRefreshTimeSpan.textContent = new Date(lastRefreshTime).toLocaleTimeString(); + } + + } catch (error) { + console.error('[Debug] Error in updateOffersTable:', error); + offersBody.innerHTML = ` + + + An error occurred while updating the offers table. Please try again later. + + `; + finishTableRender(); + } +} + +async function getIdentityData(address) { + try { + const response = await fetch(`/json/identities/${address}`); + if (!response.ok) { + return null; + } + return await response.json(); + } catch (error) { + console.error('Error fetching identity:', error); + return null; + } +} + +function getIdentityInfo(address, identity) { + if (!identity) { + return { + displayAddr: address ? `${address.substring(0, 10)}...` : 'Unspecified', + fullAddress: address || '', + label: '', + note: '', + automationOverride: 0, + stats: { + sentBidsSuccessful: 0, + recvBidsSuccessful: 0, + sentBidsRejected: 0, + recvBidsRejected: 0, + sentBidsFailed: 0, + recvBidsFailed: 0 + } + }; + } + + return { + displayAddr: address ? `${address.substring(0, 10)}...` : 'Unspecified', + fullAddress: address || '', + label: identity.label || '', + note: identity.note || '', + automationOverride: identity.automation_override || 0, + stats: { + sentBidsSuccessful: identity.num_sent_bids_successful || 0, + recvBidsSuccessful: identity.num_recv_bids_successful || 0, + sentBidsRejected: identity.num_sent_bids_rejected || 0, + recvBidsRejected: identity.num_recv_bids_rejected || 0, + sentBidsFailed: identity.num_sent_bids_failed || 0, + recvBidsFailed: identity.num_recv_bids_failed || 0 + } + }; +} + +function createTableRow(offer, identity = null) { + const row = document.createElement('tr'); + const uniqueId = `${offer.offer_id}_${offer.created_at}`; + + row.className = 'relative opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600'; + row.setAttribute('data-offer-id', uniqueId); + + const { + coin_from: coinFrom, + coin_to: coinTo, + created_at: createdAt, + expire_at: expireAt, + amount_from: amountFrom, + amount_to: amountTo, + is_own_offer: isOwnOffer, + is_revoked: isRevoked, + is_public: isPublic + } = offer; + + const coinFromSymbol = coinNameToSymbol[coinFrom] || coinFrom.toLowerCase(); + const coinToSymbol = coinNameToSymbol[coinTo] || coinTo.toLowerCase(); + const coinFromDisplay = getDisplayName(coinFrom); + const coinToDisplay = getDisplayName(coinTo); + const postedTime = formatTime(createdAt, true); + const expiresIn = formatTime(expireAt); + + const currentTime = Math.floor(Date.now() / 1000); + const isActuallyExpired = currentTime > expireAt; + const fromAmount = parseFloat(amountFrom) || 0; + const toAmount = parseFloat(amountTo) || 0; + + // Build row content + row.innerHTML = ` + ${!isPublic ? createPrivateIndicator() : ''} + ${createTimeColumn(offer, postedTime, expiresIn)} + ${createDetailsColumn(offer, identity)} + ${createTakerAmountColumn(offer, coinTo, coinFrom)} + ${createSwapColumn(offer, coinFromDisplay, coinToDisplay, coinFromSymbol, coinToSymbol)} + ${createOrderbookColumn(offer, coinFrom, coinTo)} + ${createRateColumn(offer, coinFrom, coinTo)} + ${createPercentageColumn(offer)} + ${createActionColumn(offer, isActuallyExpired)} + ${createTooltips( + offer, + isOwnOffer, + coinFrom, + coinTo, + fromAmount, + toAmount, + postedTime, + expiresIn, + isActuallyExpired, + Boolean(isRevoked), + identity + )} + `; + + updateTooltipTargets(row, uniqueId); + updateProfitLoss(row, coinFrom, coinTo, fromAmount, toAmount, isOwnOffer); + + return row; +} + +function createPrivateIndicator() { + return ` +
+ `; +} + function createTimeColumn(offer, postedTime, expiresIn) { - const timerColor = getTimerColor(offer); + const now = Math.floor(Date.now() / 1000); + const timeLeft = offer.expire_at - now; + + let strokeColor = '#10B981'; // Default green for > 30 min + if (timeLeft <= 300) { + strokeColor = '#9CA3AF'; // Grey for 5 min or less + } else if (timeLeft <= 1800) { + strokeColor = '#3B82F6'; // Blue for 5-30 min + } + return ` - +
- - + + - +
`; } -function createDetailsColumn(offer) { +function shouldShowPublicTag(offers) { + return offers.some(offer => !offer.is_public); +} + +function truncateText(text, maxLength = 15) { + if (typeof text !== 'string') return ''; + return text.length > maxLength + ? text.slice(0, maxLength) + '...' + : text; +} + +function createDetailsColumn(offer, identity = null) { const addrFrom = offer.addr_from || ''; + const identityInfo = getIdentityInfo(addrFrom, identity); + + const showPublicPrivateTags = originalJsonData.some(o => o.is_public !== offer.is_public); + + const tagClass = offer.is_public + ? 'bg-green-600 dark:bg-green-600' + : 'bg-red-500 dark:bg-red-500'; + const tagText = offer.is_public ? 'Public' : 'Private'; + + const displayIdentifier = truncateText( + identityInfo.label || addrFrom || 'Unspecified' + ); + + const identifierTextClass = identityInfo.label + ? 'text-white dark:text-white' + : 'monospace'; + return ` - - Recipient: ${escapeHtml(addrFrom.substring(0, 10))}... - +
+ ${showPublicPrivateTags ? `${tagText} + ` : ''} + + + + + + + ${escapeHtml(displayIdentifier)} + + +
`; } @@ -896,8 +1757,8 @@ function createTakerAmountColumn(offer, coinTo, coinFrom) {
-
-
${fromAmount.toFixed(4)}
+
+
${fromAmount.toFixed(4)}
${coinTo}
@@ -940,8 +1801,8 @@ function createOrderbookColumn(offer, coinFrom, coinTo) {
-
-
${toAmount.toFixed(4)}
+
+
${toAmount.toFixed(4)}
${coinFrom}
@@ -955,7 +1816,7 @@ function createRateColumn(offer, coinFrom, coinTo) { const inverseRate = 1 / rate; const fromSymbol = getCoinSymbol(coinFrom); const toSymbol = getCoinSymbol(coinTo); - + const getPriceKey = (coin) => { const lowerCoin = coin.toLowerCase(); if (lowerCoin === 'firo' || lowerCoin === 'zcoin') { @@ -1004,7 +1865,26 @@ function createPercentageColumn(offer) { `; } -function createActionColumn(offer, buttonClass, buttonText) { +function createActionColumn(offer, isActuallyExpired = false) { + const isRevoked = Boolean(offer.is_revoked); + const isTreatedAsSentOffer = offer.is_own_offer; + + let buttonClass, buttonText; + + if (isRevoked) { + buttonClass = 'bg-red-500 text-white hover:bg-red-600 transition duration-200'; + buttonText = 'Revoked'; + } else if (isActuallyExpired && isSentOffers) { + buttonClass = 'bg-gray-400 text-white dark:border-gray-300 text-white hover:bg-red-700 transition duration-200'; + buttonText = 'Expired'; + } else if (isTreatedAsSentOffer) { + buttonClass = 'bg-gray-300 bold text-white bold hover:bg-green-600 transition duration-200'; + buttonText = 'Edit'; + } else { + buttonClass = 'bg-blue-500 text-white hover:bg-green-600 transition duration-200'; + buttonText = 'Swap'; + } + return `
@@ -1017,61 +1897,29 @@ function createActionColumn(offer, buttonClass, buttonText) { `; } -function updateProfitLoss(row, fromCoin, toCoin, fromAmount, toAmount, isOwnOffer) { - const profitLossElement = row.querySelector('.profit-loss'); - if (!profitLossElement) { - console.warn('Profit loss element not found in row'); - return; - } - - if (!fromCoin || !toCoin) { - console.error(`Invalid coin names: fromCoin=${fromCoin}, toCoin=${toCoin}`); - profitLossElement.textContent = 'Error'; - profitLossElement.className = 'profit-loss text-lg font-bold text-red-500'; - return; - } - - calculateProfitLoss(fromCoin, toCoin, fromAmount, toAmount, isOwnOffer) - .then(percentDiff => { - if (percentDiff === null) { - profitLossElement.textContent = 'N/A'; - profitLossElement.className = 'profit-loss text-lg font-bold text-gray-400'; - return; - } - - const formattedPercentDiff = percentDiff.toFixed(2); - const percentDiffDisplay = formattedPercentDiff === "0.00" ? "0.00" : - (percentDiff > 0 ? `+${formattedPercentDiff}` : formattedPercentDiff); - - const colorClass = getProfitColorClass(percentDiff); - profitLossElement.textContent = `${percentDiffDisplay}%`; - profitLossElement.className = `profit-loss text-lg font-bold ${colorClass}`; - - const tooltipId = `percentage-tooltip-${row.getAttribute('data-offer-id')}`; - const tooltipElement = document.getElementById(tooltipId); - if (tooltipElement) { - const tooltipContent = createTooltipContent(isSentOffers || isOwnOffer, fromCoin, toCoin, fromAmount, toAmount); - tooltipElement.innerHTML = ` -
- ${tooltipContent} -
-
- `; - } - }) - .catch(error => { - console.error('Error in updateProfitLoss:', error); - profitLossElement.textContent = 'Error'; - profitLossElement.className = 'profit-loss text-lg font-bold text-red-500'; - }); -} - -function createTooltips(offer, treatAsSentOffer, coinFrom, coinTo, fromAmount, toAmount, postedTime, expiresIn, isActuallyExpired, isRevoked) { +// TOOLTIP FUNCTIONS +function createTooltips(offer, treatAsSentOffer, coinFrom, coinTo, fromAmount, toAmount, postedTime, expiresIn, isActuallyExpired, isRevoked, identity = null) { const rate = parseFloat(offer.rate); const fromSymbol = getCoinSymbolLowercase(coinFrom); const toSymbol = getCoinSymbolLowercase(coinTo); const uniqueId = `${offer.offer_id}_${offer.created_at}`; - + + const addrFrom = offer.addr_from || ''; + const identityInfo = getIdentityInfo(addrFrom, identity); + + const totalBids = identity ? ( + identityInfo.stats.sentBidsSuccessful + + identityInfo.stats.recvBidsSuccessful + + identityInfo.stats.sentBidsFailed + + identityInfo.stats.recvBidsFailed + + identityInfo.stats.sentBidsRejected + + identityInfo.stats.recvBidsRejected + ) : 0; + + const successRate = totalBids ? ( + ((identityInfo.stats.sentBidsSuccessful + identityInfo.stats.recvBidsSuccessful) / totalBids) * 100 + ).toFixed(1) : 0; + const fromPriceUSD = latestPrices[fromSymbol]?.usd || 0; const toPriceUSD = latestPrices[toSymbol]?.usd || 0; const rateInUSD = rate * toPriceUSD; @@ -1120,19 +1968,14 @@ function createTooltips(offer, treatAsSentOffer, coinFrom, coinTo, fromAmount, t
- - - -