From 48ea745cc225eff9f42e6e0a586917b3c1100a15 Mon Sep 17 00:00:00 2001 From: tecnovert Date: Thu, 28 May 2026 16:48:13 +0200 Subject: [PATCH 1/4] feat: automatically verify feerate --- basicswap/basicswap.py | 11 +++ basicswap/interface/base.py | 5 +- basicswap/interface/bch.py | 9 +- basicswap/interface/btc.py | 16 ++-- basicswap/interface/dash.py | 9 +- basicswap/interface/dcr/dcr.py | 17 ++-- basicswap/interface/doge.py | 9 +- basicswap/interface/firo.py | 9 +- basicswap/interface/ltc.py | 18 +++- basicswap/interface/nav.py | 9 +- basicswap/interface/part.py | 13 ++- basicswap/interface/passthrough_btc.py | 9 +- basicswap/interface/pivx.py | 9 +- basicswap/interface/utils.py | 111 +++++++++++++++++++++++++ basicswap/interface/xmr.py | 12 ++- tests/basicswap/extended/test_dcr.py | 2 +- 16 files changed, 230 insertions(+), 38 deletions(-) create mode 100644 basicswap/interface/utils.py diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 337d3ba..e5ce59b 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -4126,6 +4126,9 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): msg_buf.fee_rate_to = ci_to.make_int(fee_rate) if swap_type == SwapTypes.XMR_SWAP: + ci_from.validateFeeRate(msg_buf.fee_rate_from) + ci_to.validateFeeRate(msg_buf.fee_rate_to) + xmr_offer = XmrOffer() chain_a_ci = ci_to if reverse_bid else ci_from @@ -6028,6 +6031,9 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if offer.swap_type != SwapTypes.XMR_SWAP: raise ValueError(f"TODO: Unknown swap type {offer.swap_type.name}") + ci_from.validateFeeRate(xmr_offer.a_fee_rate) + ci_to.validateFeeRate(xmr_offer.b_fee_rate) + if not (self.debug and extra_options.get("debug_skip_validation", False)): self.validateBidValidTime( offer.swap_type, coin_from, coin_to, valid_for_seconds @@ -10033,6 +10039,10 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): ensure(len(offer_data.proof_signature) == 0, "Unexpected data") ensure(len(offer_data.pkhash_seller) == 0, "Unexpected data") ensure(len(offer_data.secret_hash) == 0, "Unexpected data") + + ci_from.validateFeeRate(offer_data.fee_rate_from) + ci_to.validateFeeRate(offer_data.fee_rate_to) + else: raise ValueError("Unknown swap type {}.".format(offer_data.swap_type)) @@ -10058,6 +10068,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): # Check for sent existing_offer = self.getOffer(offer_id, cursor=cursor) if existing_offer is None: + bid_reversed: bool = ( offer_data.swap_type == SwapTypes.XMR_SWAP and self.is_reverse_ads_bid( diff --git a/basicswap/interface/base.py b/basicswap/interface/base.py index 9ed10ef..092d918 100644 --- a/basicswap/interface/base.py +++ b/basicswap/interface/base.py @@ -50,7 +50,7 @@ class CoinInterface: def compareFeeRates(a, b) -> bool: return abs(a - b) < 20 - def __init__(self, network): + def __init__(self, network, **kwargs): self.setDefaults() self._network = network self._mx_wallet = threading.Lock() @@ -195,6 +195,9 @@ class AdaptorSigInterface: class Secp256k1Interface(CoinInterface, AdaptorSigInterface): + def __init__(self, **kwargs): + super().__init__(**kwargs) + @staticmethod def curve_type(): return Curves.secp256k1 diff --git a/basicswap/interface/bch.py b/basicswap/interface/bch.py index a1f5d03..40fa27f 100644 --- a/basicswap/interface/bch.py +++ b/basicswap/interface/bch.py @@ -71,8 +71,13 @@ class BCHInterface(BTCInterface): # TODO: BCH Watchonly: Remove when BCH watchonly works. return True - def __init__(self, coin_settings, network, swap_client=None): - super(BCHInterface, self).__init__(coin_settings, network, swap_client) + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) self.swap_client = swap_client def has_segwit(self) -> bool: diff --git a/basicswap/interface/btc.py b/basicswap/interface/btc.py index 5704c76..4b8e3e9 100644 --- a/basicswap/interface/btc.py +++ b/basicswap/interface/btc.py @@ -27,6 +27,7 @@ from basicswap.basicswap_util import ( getVoutByScriptPubKey, ) from basicswap.interface.base import Secp256k1Interface +from basicswap.interface.utils import FeeValidator from basicswap.util import ( b2i, ensure, @@ -184,7 +185,7 @@ def extractScriptLockRefundScriptValues(script_bytes: bytes): return pk1, pk2, csv_val, pk3 -class BTCInterface(Secp256k1Interface): +class BTCInterface(FeeValidator, Secp256k1Interface): _scantxoutset_lock = threading.Lock() _MAX_SCANTXOUTSET_RETRIES = 3 @@ -278,8 +279,15 @@ class BTCInterface(Secp256k1Interface): def depth_spendable() -> int: return 0 - def __init__(self, coin_settings, network, swap_client=None): - super().__init__(network) + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + self._sc = swap_client + self._log = self._sc.log if self._sc and self._sc.log else logging + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) self._rpc_host = coin_settings.get("rpchost", "127.0.0.1") self._rpcport = coin_settings["rpcport"] self._rpcauth = coin_settings["rpcauth"] @@ -304,8 +312,6 @@ class BTCInterface(Secp256k1Interface): self.setConfTarget(coin_settings["conf_target"]) self._use_segwit = coin_settings["use_segwit"] self._connection_type = coin_settings["connection_type"] - self._sc = swap_client - self._log = self._sc.log if self._sc and self._sc.log else logging self._expect_seedid_hex = None self._altruistic = coin_settings.get("altruistic", True) self._use_descriptors = coin_settings.get("use_descriptors", False) diff --git a/basicswap/interface/dash.py b/basicswap/interface/dash.py index 1cfac85..7c833c3 100644 --- a/basicswap/interface/dash.py +++ b/basicswap/interface/dash.py @@ -24,8 +24,13 @@ class DASHInterface(BTCInterface): def coin_type(): return Coins.DASH - def __init__(self, coin_settings, network, swap_client=None): - super().__init__(coin_settings, network, swap_client) + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) self._wallet_passphrase = "" self._have_checked_seed = False diff --git a/basicswap/interface/dcr/dcr.py b/basicswap/interface/dcr/dcr.py index c200c2d..b69a892 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-2025 The Basicswap developers +# Copyright (c) 2024-2026 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -20,9 +20,8 @@ from basicswap.chainparams import Coins from basicswap.contrib.test_framework.script import ( CScriptNum, ) -from basicswap.interface.base import ( - Secp256k1Interface, -) +from basicswap.interface.base import Secp256k1Interface +from basicswap.interface.utils import FeeValidator from basicswap.interface.btc import ( extractScriptLockScriptValues, extractScriptLockRefundScriptValues, @@ -181,7 +180,7 @@ def extract_sig_and_pk(sig_script: bytes) -> (bytes, bytes): return sig, pk -class DCRInterface(Secp256k1Interface): +class DCRInterface(FeeValidator, Secp256k1Interface): @staticmethod def coin_type(): @@ -258,13 +257,13 @@ class DCRInterface(Secp256k1Interface): def depth_spendable() -> int: return 0 - def __init__(self, coin_settings, network, swap_client=None): - super().__init__(network) + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + self._sc = swap_client + self._log = self._sc.log if self._sc and self._sc.log else logging + super().__init__(coin_settings=coin_settings, network=network, **kwargs) self._rpc_host = coin_settings.get("rpchost", "127.0.0.1") self._rpcport = coin_settings["rpcport"] self._rpcauth = coin_settings["rpcauth"] - self._sc = swap_client - self._log = self._sc.log if self._sc and self._sc.log else logging self.rpc = make_rpc_func(self._rpcport, self._rpcauth, host=self._rpc_host) if "walletrpcport" in coin_settings: self._walletrpcport = coin_settings["walletrpcport"] diff --git a/basicswap/interface/doge.py b/basicswap/interface/doge.py index 4ef829d..51feb98 100644 --- a/basicswap/interface/doge.py +++ b/basicswap/interface/doge.py @@ -32,8 +32,13 @@ class DOGEInterface(BTCInterface): 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 __init__(self, coin_settings, network, swap_client=None, **kwargs): + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) def getScriptDest(self, script: bytearray) -> bytearray: # P2SH diff --git a/basicswap/interface/firo.py b/basicswap/interface/firo.py index a46da88..4bd55a0 100644 --- a/basicswap/interface/firo.py +++ b/basicswap/interface/firo.py @@ -38,8 +38,13 @@ class FIROInterface(BTCInterface): def coin_type(): return Coins.FIRO - def __init__(self, coin_settings, network, swap_client=None): - super(FIROInterface, self).__init__(coin_settings, network, swap_client) + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) # No multiwallet support self.rpc_wallet = make_rpc_func( self._rpcport, self._rpcauth, host=self._rpc_host diff --git a/basicswap/interface/ltc.py b/basicswap/interface/ltc.py index 63b6217..7b02167 100644 --- a/basicswap/interface/ltc.py +++ b/basicswap/interface/ltc.py @@ -16,8 +16,13 @@ class LTCInterface(BTCInterface): def coin_type(): return Coins.LTC - def __init__(self, coin_settings, network, swap_client=None): - super(LTCInterface, self).__init__(coin_settings, network, swap_client) + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) self._rpc_wallet_mweb = coin_settings.get("mweb_wallet_name", "mweb") self.rpc_wallet_mweb = make_rpc_func( self._rpcport, @@ -265,8 +270,13 @@ class LTCInterfaceMWEB(LTCInterface): def interface_type(self) -> int: return Coins.LTC_MWEB - def __init__(self, coin_settings, network, swap_client=None): - super(LTCInterfaceMWEB, self).__init__(coin_settings, network, swap_client) + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) self._rpc_wallet = coin_settings.get("mweb_wallet_name", "mweb") self.rpc_wallet = make_rpc_func( self._rpcport, self._rpcauth, host=self._rpc_host, wallet=self._rpc_wallet diff --git a/basicswap/interface/nav.py b/basicswap/interface/nav.py index 71bd386..a7592f2 100644 --- a/basicswap/interface/nav.py +++ b/basicswap/interface/nav.py @@ -73,8 +73,13 @@ class NAVInterface(BTCInterface): def txoType(): return CTxOut - def __init__(self, coin_settings, network, swap_client=None): - super(NAVInterface, self).__init__(coin_settings, network, swap_client) + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) # No multiwallet support self.rpc_wallet = make_rpc_func( self._rpcport, self._rpcauth, host=self._rpc_host diff --git a/basicswap/interface/part.py b/basicswap/interface/part.py index 88ad3ae..3ebc16e 100644 --- a/basicswap/interface/part.py +++ b/basicswap/interface/part.py @@ -81,8 +81,17 @@ class PARTInterface(BTCInterface): def txoType(): return CTxOutPart - def __init__(self, coin_settings, network, swap_client=None): - super().__init__(coin_settings, network, swap_client) + @staticmethod + def defaultMaxFeeRate() -> int: + return PARTInterface.COIN() // 2 + + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) self.setAnonTxRingSize(int(coin_settings.get("anon_tx_ring_size", 12))) def use_tx_vsize(self) -> bool: diff --git a/basicswap/interface/passthrough_btc.py b/basicswap/interface/passthrough_btc.py index 551e986..79f964a 100644 --- a/basicswap/interface/passthrough_btc.py +++ b/basicswap/interface/passthrough_btc.py @@ -10,8 +10,13 @@ from basicswap.contrib.test_framework.messages import CTxOut class PassthroughBTCInterface(BTCInterface): - def __init__(self, coin_settings, network): - super().__init__(coin_settings, network) + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) self.txoType = CTxOut self._network = network self.blocks_confirmed = coin_settings["blocks_confirmed"] diff --git a/basicswap/interface/pivx.py b/basicswap/interface/pivx.py index 8d80d0e..df9162d 100644 --- a/basicswap/interface/pivx.py +++ b/basicswap/interface/pivx.py @@ -27,8 +27,13 @@ class PIVXInterface(BTCInterface): def coin_type(): return Coins.PIVX - def __init__(self, coin_settings, network, swap_client=None): - super(PIVXInterface, self).__init__(coin_settings, network, swap_client) + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) # No multiwallet support self.rpc_wallet = make_rpc_func( self._rpcport, self._rpcauth, host=self._rpc_host diff --git a/basicswap/interface/utils.py b/basicswap/interface/utils.py new file mode 100644 index 0000000..409f667 --- /dev/null +++ b/basicswap/interface/utils.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2026 The Basicswap developers +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. + +from basicswap.contrib.test_framework.messages import COIN + + +class FeeValidator: + @staticmethod + def defaultMaxFeeRate() -> int: + return COIN // 10 + + def makeIntFromSetting( + self, settings: dict, setting_name: str, default: int + ) -> int: + # Return make_int(setting), or already integer default + if setting_name in settings: + return self.make_int(settings[setting_name]) + return default + + def __init__(self, **kwargs): + default_low_fee_conf_target: int = 24 + default_low_fee_rate: int = 0 + default_high_estimated_feerate_multiplier: float = 2.0 + default_high_fee_rate: int = self.defaultMaxFeeRate() + if self._sc: + chain_client_settings = self._sc.getChainClientSettings( + self.coin_type() + ) # basicswap.json + settings = self._sc.settings + default_low_fee_conf_target = int( + settings.get("low_fee_conf_target", default_low_fee_conf_target) + ) + default_low_fee_rate = self.makeIntFromSetting( + settings, "low_feerate", default_low_fee_rate + ) + default_high_estimated_feerate_multiplier = float( + settings.get( + "high_estimated_feerate_multiplier", + default_high_estimated_feerate_multiplier, + ) + ) + default_high_fee_rate = self.makeIntFromSetting( + settings, "high_feerate", default_high_fee_rate + ) + else: + if kwargs.get("network") != "regtest": + raise ValueError("swapclient unset") + chain_client_settings = {} + + self._low_fee_conf_target = int( + chain_client_settings.get( + "low_fee_conf_target", default_low_fee_conf_target + ) + ) + self._low_feerate = self.makeIntFromSetting( + chain_client_settings, "low_feerate", default_low_fee_rate + ) + + # Set below 1.0 to disable estimating the max feerate and use max_feerate + self._high_estimated_feerate_multiplier = float( + chain_client_settings.get( + "high_estimated_feerate_multiplier", + default_high_estimated_feerate_multiplier, + ) + ) + self._high_feerate = self.makeIntFromSetting( + chain_client_settings, "high_feerate", default_high_fee_rate + ) + + super().__init__(**kwargs) + + def validateFeeRate(self, feerate: int) -> None: + if self._low_feerate > 0: + min_feerate_src = "set_value" + min_feerate = self._low_feerate + else: + min_feerate, min_feerate_src = self.get_fee_rate(self._low_fee_conf_target) + min_feerate = self.make_int(min_feerate) + + if self._high_estimated_feerate_multiplier >= 1.0: + max_feerate, max_feerate_src = self.get_fee_rate() + max_feerate = ( + self.make_int(max_feerate) * self._high_estimated_feerate_multiplier + ) + else: + max_feerate_src = "set_value" + max_feerate = self._high_feerate + + if max_feerate_src in ("estimatesmartfee", "electrum"): + if max_feerate > self._high_feerate: + max_feerate_src = "clamped_to_set_value" + max_feerate = self._high_feerate + + self._log.debug( + f"Verify {self.ticker()} fee rate {feerate}, min {min_feerate} {min_feerate_src}, max {max_feerate} {max_feerate_src}" + ) + if feerate < min_feerate: + err_msg: str = ( + f"Fee rate too low, {feerate} < {min_feerate}, {min_feerate_src}" + ) + self._log.error(err_msg) + raise ValueError(err_msg) + if feerate > max_feerate: + err_msg: str = ( + f"Fee rate too high, {feerate} > {max_feerate}, {max_feerate_src}" + ) + self._log.error(err_msg) + raise ValueError(err_msg) diff --git a/basicswap/interface/xmr.py b/basicswap/interface/xmr.py index d30e93a..a784f65 100644 --- a/basicswap/interface/xmr.py +++ b/basicswap/interface/xmr.py @@ -101,8 +101,13 @@ class XMRInterface(CoinInterface): return True return super().is_transient_error(ex) - def __init__(self, coin_settings, network, swap_client=None): - super().__init__(network) + def __init__(self, coin_settings, network, swap_client=None, **kwargs): + super().__init__( + coin_settings=coin_settings, + network=network, + swap_client=swap_client, + **kwargs, + ) self._addr_prefix = self.chainparams_network()["address_prefix"] @@ -857,3 +862,6 @@ class XMRInterface(CoinInterface): except Exception as e: self._log.error(f"listWalletTransactions failed: {e}") return [] + + def validateFeeRate(self, fee_rate: int) -> bool: + pass # Fee rate isn't used diff --git a/tests/basicswap/extended/test_dcr.py b/tests/basicswap/extended/test_dcr.py index 1f33800..6630dd5 100644 --- a/tests/basicswap/extended/test_dcr.py +++ b/tests/basicswap/extended/test_dcr.py @@ -741,7 +741,7 @@ class Test(BaseTest): ci0 = cls.swap_clients[0].ci(cls.test_coin) if not cls.restore_instance: dcr_mining_addr = ci0.rpc_wallet("getnewaddress") - assert dcr_mining_addr in cls.dcr_mining_addrs + assert dcr_mining_addr == cls.dcr_mining_addr cls.dcr_ticket_account = ci0.rpc_wallet( "getaccount", [ From fa063e5f01e176e339d8e456f9f18b281f1a20e9 Mon Sep 17 00:00:00 2001 From: tecnovert Date: Fri, 29 May 2026 01:15:45 +0200 Subject: [PATCH 2/4] test: add tests for automatic feerate validation --- .github/workflows/ci.yml | 2 +- tests/basicswap/test_btc_xmr.py | 156 ++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a60d72..f0ad72b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: 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" + pytest tests/basicswap/test_btc_xmr.py::TestBTC -k "test_003_api or test_02_a_leader_recover_a_lock_tx or test_11_fee_validation" - name: Run test_encrypted_xmr_reload id: test_encrypted_xmr_reload run: | diff --git a/tests/basicswap/test_btc_xmr.py b/tests/basicswap/test_btc_xmr.py index 9609237..7d5c863 100644 --- a/tests/basicswap/test_btc_xmr.py +++ b/tests/basicswap/test_btc_xmr.py @@ -2329,6 +2329,162 @@ class BasicSwapTest(TestFunctions): "TODO" ) # Build without xmr first for quicker test iterations + def test_11_fee_validation(self): + coin_from, coin_to = (self.test_coin_from, Coins.XMR) + logging.info( + f"---------- Test {coin_from.name} to {coin_to.name} expires bid stuck on accepted" + ) + + swap_clients = self.swap_clients + ci_from = swap_clients[0].ci(coin_from) + ci_to = swap_clients[0].ci(coin_to) + + ci1_from = swap_clients[1].ci(coin_from) + ci1_to = swap_clients[1].ci(coin_to) + + amt_swap = ci_from.make_int(random.uniform(0.1, 2.0), r=1) + rate_swap = ci_to.make_int(random.uniform(0.2, 20.0), r=1) + + offer_id = swap_clients[0].postOffer( + coin_from, + coin_to, + amt_swap, + rate_swap, + amt_swap, + SwapTypes.XMR_SWAP, + ) + + amt_swap_reverse = ci1_to.make_int(random.uniform(0.1, 2.0), r=1) + offer_reverse_id = swap_clients[1].postOffer( + coin_to, + coin_from, + amt_swap_reverse, + rate_swap, + amt_swap_reverse, + SwapTypes.XMR_SWAP, + ) + + ci_from_settings = swap_clients[0].getChainClientSettings(coin_from) + old_override_feerate = ci_from_settings.get("override_feerate", None) + ci1_from_settings = swap_clients[1].getChainClientSettings(coin_from) + old_override_feerate1 = ci1_from_settings.get("override_feerate", None) + + networkinfo = ci_from.rpc("getnetworkinfo") + assert ci_from.make_int(networkinfo["relayfee"]) == ci_from.make_int( + ci_from.get_fee_rate()[0] + ) + try: + # Set override_feerate to increase feerate from get_fee_rate() + ci_from_settings["override_feerate"] = ci_from.format_amount(120) + ci1_from_settings["override_feerate"] = ci1_from.format_amount(120) + try: + swap_clients[0].postXmrBid(offer_id, amt_swap) + except Exception as e: + assert "Fee rate too low, 100 < 120, override_feerate" in str(e) + else: + assert False, "Should fail" + + # Test reverse bid, low fee + try: + swap_clients[1].postXmrBid(offer_reverse_id, amt_swap_reverse) + except Exception as e: + assert "Fee rate too low, 100 < 120, override_feerate" in str(e) + else: + assert False, "Should fail" + + # Clear override_feerate (get_fee_rate()), set low_feerate (validateFeeRate()) + ci_from_settings["override_feerate"] = None + ci1_from_settings["override_feerate"] = None + ci_from._low_feerate = 120 + ci1_from._low_feerate = 120 + try: + swap_clients[0].postOffer( + coin_from, + coin_to, + amt_swap, + rate_swap, + amt_swap, + SwapTypes.XMR_SWAP, + ) + except Exception as e: + assert "Fee rate too low, 100 < 120, set_value" in str(e) + else: + assert False, "Should fail" + + # Test reverse offer, low fee + try: + swap_clients[1].postOffer( + coin_to, + coin_from, + amt_swap_reverse, + rate_swap, + amt_swap_reverse, + SwapTypes.XMR_SWAP, + ) + except Exception as e: + assert "Fee rate too low, 100 < 120, set_value" in str(e) + else: + assert False, "Should fail" + + ci_from._low_feerate = 0 + ci1_from._low_feerate = 0 + ci_from._high_estimated_feerate_multiplier = ( + 0 # Disable high fee from estimate + ) + ci_from._high_feerate = ( + 80 # ci_from_settings["high_feerate"] = ci_from.format_amount(80) + ) + logging.info(f"[rm] ci_from.get_fee_rate() {ci_from.get_fee_rate()}") + try: + swap_clients[0].postXmrBid(offer_id, amt_swap) + except Exception as e: + assert "Fee rate too high, 100 > 80, set_value" in str(e) + else: + assert False, "Should fail" + + # Test reverse bid, high fee + ci1_from._high_estimated_feerate_multiplier = 0 + ci1_from._high_feerate = 80 + try: + swap_clients[1].postXmrBid(offer_reverse_id, amt_swap_reverse) + except Exception as e: + assert "Fee rate too high, 100 > 80, set_value" in str(e) + else: + assert False, "Should fail" + + try: + swap_clients[0].postOffer( + coin_from, + coin_to, + amt_swap, + rate_swap, + amt_swap, + SwapTypes.XMR_SWAP, + ) + except Exception as e: + assert "Fee rate too high, 100 > 80, set_value" in str(e) + else: + assert False, "Should fail" + + # Test reverse offer, high fee + try: + swap_clients[1].postOffer( + coin_to, + coin_from, + amt_swap_reverse, + rate_swap, + amt_swap_reverse, + SwapTypes.XMR_SWAP, + ) + except Exception as e: + assert "Fee rate too high, 100 > 80, set_value" in str(e) + else: + assert False, "Should fail" + + finally: + ci_from_settings["override_feerate"] = old_override_feerate + ci1_from_settings["override_feerate"] = old_override_feerate1 + class TestBTC(BasicSwapTest): __test__ = True From f5249448bbd909d1669f278a726400bd2ec966ce Mon Sep 17 00:00:00 2001 From: tecnovert Date: Sat, 30 May 2026 17:07:27 +0200 Subject: [PATCH 3/4] doc: update release notes --- doc/release-notes.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/doc/release-notes.md b/doc/release-notes.md index cba6eaf..d8207cf 100644 --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -1,12 +1,20 @@ 0.16.3 ============== +- Automatic fee validation. + - Prevent sending bids to offers + - Reject received offers, and + - Prevent sending offers where the chain feerates are out of range. + - Valid feerate range is the nodes estimated feerate for confirmation in 24 blocks to 2x the estimated feerate. + - The minimum feerate confirmation can be adjusted with the "low_fee_conf_target" setting. + - If "low_feerate" is set above 0 it is used instead of the dynamic feerate with "low_fee_conf_target" + - The maximum feerate multiplier can be adjusted with the "high_estimated_feerate_multiplier" setting. + - If "high_estimated_feerate_multiplier" is set below 1.0 the max feerate can be set with the "high_feerate" setting. - New setting "startup_delay" - Adjusts the time waited for coin daemons to start between "startup_tries". - Valid as a base setting and can be overridden per coin with chainclients settings. - 0.14.5 ============== From 119a116918d663470d93c699479a3ffa3d616b48 Mon Sep 17 00:00:00 2001 From: tecnovert Date: Sat, 30 May 2026 17:42:06 +0200 Subject: [PATCH 4/4] test: add codespell extra dictionary and add config to .toml --- .github/workflows/ci.yml | 2 +- pyproject.toml | 8 ++++++++ tests/lint/spelling.extra_dictionary.txt | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 tests/lint/spelling.extra_dictionary.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0ad72b..15871d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: flake8 --ignore=E203,E501,W503 --exclude=basicswap/contrib,basicswap/interface/contrib,.eggs,.tox,bin/install_certifi.py - name: Run codespell run: | - codespell --check-filenames --disable-colors --quiet-level=7 --ignore-words=tests/lint/spelling.ignore-words.txt -S .git,.eggs,.tox,pgp,*.pyc,*basicswap/contrib,*basicswap/interface/contrib,*mnemonics.py,bin/install_certifi.py,*basicswap/static + codespell - name: Run black run: | black --check --diff --exclude="contrib" . diff --git a/pyproject.toml b/pyproject.toml index 1271a37..c7230ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,3 +48,11 @@ allow-direct-references = true [tool.ruff] exclude = ["basicswap/contrib","basicswap/interface/contrib"] + +[tool.codespell] +check-filenames = true +disable-colors = true +quiet-level = 7 +dictionary = "tests/lint/spelling.extra_dictionary.txt,-" +ignore-words = "tests/lint/spelling.ignore-words.txt" +skip = ".git,.eggs,.tox,pgp,*.pyc,*basicswap/contrib,*basicswap/interface/contrib,*mnemonics.py,bin/install_certifi.py,*basicswap/static" diff --git a/tests/lint/spelling.extra_dictionary.txt b/tests/lint/spelling.extra_dictionary.txt new file mode 100644 index 0000000..48a0cff --- /dev/null +++ b/tests/lint/spelling.extra_dictionary.txt @@ -0,0 +1 @@ +confimration->confirmation