diff --git a/electrum/bitcoin.py b/electrum/bitcoin.py index 13af035c7..f716ac67b 100644 --- a/electrum/bitcoin.py +++ b/electrum/bitcoin.py @@ -318,7 +318,7 @@ def construct_script(items: Sequence[Union[str, int, bytes, opcodes]], values=No def relayfee(network: 'Network' = None) -> int: """Returns feerate in sat/kbyte.""" - from .simple_config import FEERATE_DEFAULT_RELAY, FEERATE_MAX_RELAY + from .fee_policy import FEERATE_DEFAULT_RELAY, FEERATE_MAX_RELAY if network and network.relay_fee is not None: fee = network.relay_fee else: diff --git a/electrum/commands.py b/electrum/commands.py index fa70106be..b131f1807 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -70,6 +70,7 @@ from .plugin import run_hook, DeviceMgr, Plugins from .version import ELECTRUM_VERSION from .simple_config import SimpleConfig from .invoices import Invoice +from .fee_policy import FeePolicy from . import submarine_swaps from . import GuiImportError from . import crypto @@ -237,7 +238,7 @@ class Commands(Logger): 'auto_connect': net_params.auto_connect, 'version': ELECTRUM_VERSION, 'default_wallet': self.config.get_wallet_path(), - 'fee_per_kb': self.config.fee_per_kb(), + 'fee_estimates': self.network.fee_estimates.get_data() } return response @@ -728,21 +729,20 @@ class Commands(Logger): return out['address'] @command('n') - async def sweep(self, privkey, destination, fee=None, nocheck=False, imax=100): + async def sweep(self, privkey, destination, fee=None, feerate=None, nocheck=False, imax=100): """Sweep private keys. Returns a transaction that spends UTXOs from privkey to a destination address. The transaction is not broadcasted.""" from .wallet import sweep - tx_fee = satoshis(fee) + fee_policy = self._get_fee_policy(fee, feerate) privkeys = privkey.split() self.nocheck = nocheck #dest = self._resolver(destination) tx = await sweep( privkeys, network=self.network, - config=self.config, to_address=destination, - fee=tx_fee, + fee_policy=fee_policy, imax=imax, ) return tx.serialize() if tx else None @@ -761,12 +761,25 @@ class Commands(Logger): message = util.to_bytes(message) return bitcoin.verify_usermessage_with_address(address, sig, message) + def _get_fee_policy(self, fee, feerate): + if fee is not None and feerate is not None: + raise Exception('Cannot set both fee and feerate') + if fee is not None: + fee_sats = satoshis(fee) + fee_policy = FeePolicy(f'fixed:{fee_sats}') + elif feerate is not None: + feerate_per_byte = 1000 * feerate + fee_policy = FeePolicy(f'feerate:{feerate_per_byte}') + else: + fee_policy = FeePolicy(self.config.FEE_POLICY) + return fee_policy + @command('wp') async def payto(self, destination, amount, fee=None, feerate=None, from_addr=None, from_coins=None, change_addr=None, nocheck=False, unsigned=False, rbf=True, password=None, locktime=None, addtransaction=False, wallet: Abstract_Wallet = None): """Create a transaction. """ self.nocheck = nocheck - tx_fee = satoshis(fee) + fee_policy = self._get_fee_policy(fee, feerate) domain_addr = from_addr.split(',') if from_addr else None domain_coins = from_coins.split(',') if from_coins else None change_addr = self._resolver(change_addr, wallet) @@ -775,8 +788,7 @@ class Commands(Logger): outputs = [PartialTxOutput.from_address_and_value(destination, amount_sat)] tx = wallet.create_transaction( outputs, - fee=tx_fee, - feerate=feerate, + fee_policy=fee_policy, change_addr=change_addr, domain_addr=domain_addr, domain_coins=domain_coins, @@ -794,7 +806,7 @@ class Commands(Logger): nocheck=False, unsigned=False, rbf=True, password=None, locktime=None, addtransaction=False, wallet: Abstract_Wallet = None): """Create a multi-output transaction. """ self.nocheck = nocheck - tx_fee = satoshis(fee) + fee_policy = self._get_fee_policy(fee, feerate) domain_addr = from_addr.split(',') if from_addr else None domain_coins = from_coins.split(',') if from_coins else None change_addr = self._resolver(change_addr, wallet) @@ -806,8 +818,7 @@ class Commands(Logger): final_outputs.append(PartialTxOutput.from_address_and_value(address, amount_sat)) tx = wallet.create_transaction( final_outputs, - fee=tx_fee, - feerate=feerate, + fee_policy=fee_policy, change_addr=change_addr, domain_addr=domain_addr, domain_coins=domain_coins, @@ -1142,23 +1153,23 @@ class Commands(Logger): """ return wallet synchronization status """ return wallet.is_up_to_date() - @command('') + @command('n') async def getfeerate(self): - """Return current fee rate settings and current estimate (in sat/kvByte). """ - method, value, feerate, tooltip = self.config.getfeerate() + Return current fee estimate given network conditions (in sat/kvByte). + To change the fee policy, use 'getconfig/setconfig fee_policy' + """ + fee_policy = FeePolicy(self.config.FEE_POLICY) + description = fee_policy.get_target_text() + feerate = fee_policy.fee_per_kb(self.network) + tooltip = fee_policy.get_estimate_text(self.network) return { - 'method': method, - 'value': value, + 'policy': fee_policy.get_descriptor(), + 'description': description, 'sat/kvB': feerate, 'tooltip': tooltip, } - @command('') - async def setfeerate(self, method, value): - """Set fee rate estimation method and value""" - self.config.setfeerate(method, value) - @command('w') async def removelocaltx(self, txid, wallet: Abstract_Wallet = None): """Remove a 'local' transaction from the wallet, and its dependent diff --git a/electrum/fee_policy.py b/electrum/fee_policy.py new file mode 100644 index 000000000..972986de9 --- /dev/null +++ b/electrum/fee_policy.py @@ -0,0 +1,414 @@ +from typing import Optional, Sequence, Tuple, Union, TYPE_CHECKING, Dict +from decimal import Decimal +from numbers import Real +from enum import IntEnum + +from .i18n import _ +from .util import NoDynamicFeeEstimates, quantize_feerate, format_fee_satoshis +from . import util +from . import constants +from .logging import Logger + +if TYPE_CHECKING: + from .network import Network + +FEE_ETA_TARGETS = [25, 10, 5, 2, 1] +FEE_DEPTH_TARGETS = [10_000_000, 5_000_000, 2_000_000, 1_000_000, + 800_000, 600_000, 400_000, 250_000, 100_000] +FEERATE_STATIC_VALUES = [1000, 2000, 5000, 10000, 20000, 30000, + 50000, 70000, 100000, 150000, 200000, 300000] + +# satoshi per kbyte +FEERATE_MAX_DYNAMIC = 1500000 +FEERATE_WARNING_HIGH_FEE = 600000 +FEERATE_FALLBACK_STATIC_FEE = 150000 +FEERATE_DEFAULT_RELAY = 1000 +FEERATE_MAX_RELAY = 50000 + +# warn user if fee/amount for on-chain tx is higher than this +FEE_RATIO_HIGH_WARNING = 0.05 + +FEE_LN_ETA_TARGET = 2 # note: make sure the network is asking for estimates for this target +FEE_LN_LOW_ETA_TARGET = 25 # note: make sure the network is asking for estimates for this target + + +# The min feerate_per_kw that can be used in lightning so that +# the resulting onchain tx pays the min relay fee. +# This would be FEERATE_DEFAULT_RELAY / 4 if not for rounding errors, +# see https://github.com/ElementsProject/lightning/commit/2e687b9b352c9092b5e8bd4a688916ac50b44af0 +FEERATE_PER_KW_MIN_RELAY_LIGHTNING = 253 + + +def closest_index(value, array) -> int: + dist = list(map(lambda x: abs(x - value), array)) + return min(range(len(dist)), key=dist.__getitem__) + + +class FeeMethod(IntEnum): + # note: careful changing these names! they appear in the config files. + FIXED = 0 # fixed absolute fee + FEERATE = 1 # fixed fee rate + ETA = 2 # dynamic, ETA based + MEMPOOL = 3 # dynamic, mempool based + + @classmethod + def slider_values(cls): + return [FeeMethod.FEERATE, FeeMethod.ETA, FeeMethod.MEMPOOL] + + def name_for_GUI(self): + names = { + FeeMethod.FEERATE: _('Feerate'), + FeeMethod.ETA:_('ETA'), + FeeMethod.MEMPOOL :_('Mempool') + } + return names[self] + + @classmethod + def slider_index_of_method(cls, method): + i = FeeMethod.slider_values().index(method) + assert i is not None + return i + + +class FeePolicy(Logger): + # object associated to a fee slider + + def __init__(self, descriptor: str): + Logger.__init__(self) + try: + name, value = descriptor.split(':') + self.method = FeeMethod[name.upper()] + self.value = int(value) # target (e.g. num blocks, nbytes from mempool tip, sat/kbyte) + except Exception: + self.logger.warning(f"Could not parse fee policy descriptor '{descriptor}'. Falling back to 'eta:2'") + self.method = FeeMethod.ETA + self.value = 2 + + def __repr__(self): + return self.get_descriptor() + + def get_descriptor(self) -> str: + return self.method.name.lower() + ':' + str(self.value) + + def set_method(self, method: FeeMethod): + assert isinstance(method, FeeMethod) + self.method = method + # default values + if self.method == FeeMethod.MEMPOOL: + self.value = 1000000 # 1 mb from tip + elif self.method == FeeMethod.ETA: + self.value = 2 # 2 blocks + elif self.method == FeeMethod.FEERATE: + self.value = 5000 # sats per vkb + else: + self.value = 10 # sats + + def _get_array(self) -> Sequence[int]: + if self.method == FeeMethod.MEMPOOL: + return FEE_DEPTH_TARGETS + elif self.method == FeeMethod.ETA: + return FEE_ETA_TARGETS + elif self.method == FeeMethod.FEERATE: + return FEERATE_STATIC_VALUES + else: + raise Exception('') + + def set_value_from_slider_pos(self, slider_pos: int): + array = self._get_array() + slider_pos = max(0, min(slider_pos, len(array)-1)) + self.value = array[slider_pos] + + def get_slider_pos(self) -> int: + array = self._get_array() + return closest_index(self.value, array) + + def get_slider_max(self) -> int: + array = self._get_array() + maxp = len(array) - 1 + return maxp + + @property + def use_dynamic_estimates(self): + return self.method in [FeeMethod.ETA, FeeMethod.MEMPOOL] + + @classmethod + def depth_target(self, slider_pos: int) -> int: + """Returns mempool depth target in bytes for a fee slider position.""" + slider_pos = max(slider_pos, 0) + slider_pos = min(slider_pos, len(FEE_DEPTH_TARGETS)-1) + return FEE_DEPTH_TARGETS[slider_pos] + + def eta_target(self, slider_pos: int) -> int: + """Returns 'num blocks' ETA target for a fee slider position.""" + return FEE_ETA_TARGETS[slider_pos] + + @classmethod + def eta_tooltip(self, x): + if x < 0: + return _('Low fee') + elif x == 1: + return _('In the next block') + else: + return _('Within {} blocks').format(x) + + def get_target_text(self): + """ Description of what the target is: static fee / num blocks to confirm in / mempool depth """ + if self.method == FeeMethod.ETA: + return self.eta_tooltip(self.value) + elif self.method == FeeMethod.MEMPOOL: + return self.depth_tooltip(self.value) + elif self.method == FeeMethod.FEERATE: + fee_per_byte = self.value/1000 + return format_fee_satoshis(fee_per_byte) + f" {util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE}" + + def get_estimate_text(self, network: 'Network') -> str: + """ + Description of the current fee estimate corresponding to the target + """ + fee_per_kb = self.fee_per_kb(network) + fee_per_byte = fee_per_kb/1000 if fee_per_kb is not None else None + tooltip = '' + if self.use_dynamic_estimates: + if fee_per_byte is not None: + tooltip = format_fee_satoshis(fee_per_byte) + f" {util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE}" + elif self.method == FeeMethod.FEERATE: + assert fee_per_kb is not None + assert fee_per_byte is not None + if network and network.mempool_fees.has_data(): + depth = network.mempool_fees.fee_to_depth(fee_per_byte) + tooltip = self.depth_tooltip(depth) + if network and network.fee_estimates.has_data(): + eta = network.fee_estimates.fee_to_eta(fee_per_kb) + tooltip += '\n' + self.eta_tooltip(eta) + return tooltip + + def get_tooltip(self, network: 'Network'): + target = self.get_target_text() + estimate = self.get_estimate_text(network) + if self.use_dynamic_estimates: + return _('Target') + ': ' + target + '\n' + _('Current rate') + ': ' + estimate + else: + return _('Fixed rate') + ': ' + target + '\n' + _('Estimate') + ': ' + estimate + + @classmethod + def depth_tooltip(self, depth: Optional[int]) -> str: + """Returns text tooltip for given mempool depth (in vbytes).""" + if depth is None: + return "unknown from tip" + depth_mb = self.get_depth_mb_str(depth) + return _("{} from tip").format(depth_mb) + + @classmethod + def get_depth_mb_str(self, depth: int) -> str: + # e.g. 500_000 -> "0.50 MB" + depth_mb = "{:.2f}".format(depth / 1_000_000) # maybe .rstrip("0") ? + return f"{depth_mb} {util.UI_UNIT_NAME_MEMPOOL_MB}" + + def fee_per_kb(self, network: 'Network') -> Optional[int]: + """Returns sat/kvB fee to pay for a txn. + Note: might return None. + """ + if self.use_dynamic_estimates and constants.net is constants.BitcoinRegtest: + return FEERATE_FALLBACK_STATIC_FEE + + if self.method == FeeMethod.FEERATE: + fee_rate = self.value + elif self.method == FeeMethod.MEMPOOL: + if network and network.mempool_fees.has_data(): + fee_rate = network.mempool_fees.depth_to_fee(self.get_slider_pos()) + else: + fee_rate = None + elif self.method == FeeMethod.ETA: + if network and network.fee_estimates.has_data(): + fee_rate = network.fee_estimates.eta_to_fee(self.get_slider_pos()) + else: + fee_rate = None + else: + raise Exception(self.method) + if fee_rate is not None: + fee_rate = int(fee_rate) + return fee_rate + + def fee_per_byte(self, network: 'Network') -> Optional[int]: + """Returns sat/vB fee to pay for a txn. + Note: might return None. + """ + fee_per_kb = self.fee_per_kb(network) + return fee_per_kb / 1000 if fee_per_kb is not None else None + + def estimate_fee( + self, size: Union[int, float, Decimal], *, + network: 'Network' = None, + allow_fallback_to_static_rates: bool = False, + ) -> int: + if self.method == FeeMethod.FIXED: + return self.value + if network is None and self.use_dynamic_estimates: + if allow_fallback_to_static_rates: + fee_per_kb = FEERATE_FALLBACK_STATIC_FEE + else: + raise NoDynamicFeeEstimates() + else: + fee_per_kb = self.fee_per_kb(network) + + return self.estimate_fee_for_feerate(fee_per_kb, size) + + @classmethod + def estimate_fee_for_feerate(cls, fee_per_kb: Union[int, float, Decimal], + size: Union[int, float, Decimal]) -> int: + # note: 'size' is in vbytes + size = Decimal(size) + fee_per_kb = Decimal(fee_per_kb) + fee_per_byte = fee_per_kb / 1000 + # to be consistent with what is displayed in the GUI, + # the calculation needs to use the same precision: + fee_per_byte = quantize_feerate(fee_per_byte) + return round(fee_per_byte * size) + +class FixedFeePolicy(FeePolicy): + def __init__(self, fee): + FeePolicy.__init__(self, 'fixed:%d'%fee) + + +def impose_hard_limits_on_fee(func): + def get_fee_within_limits(self, *args, **kwargs): + fee = func(self, *args, **kwargs) + if fee is None: + return fee + fee = min(FEERATE_MAX_DYNAMIC, fee) + fee = max(FEERATE_DEFAULT_RELAY, fee) + return fee + return get_fee_within_limits + + +class FeeHistogram: + + def __init__(self): + self._data = None # type: Optional[Sequence[Tuple[Union[float, int], int]]] + + def has_data(self) -> bool: + return self._data is not None + + def set_data(self, data): + self._data = data + + def fee_to_depth(self, target_fee: Real) -> Optional[int]: + """For a given sat/vbyte fee, returns an estimate of how deep + it would be in the current mempool in vbytes. + Pessimistic == overestimates the depth. + """ + if self._data is None: + return None + depth = 0 + for fee, s in self._data: + depth += s + if fee <= target_fee: + break + return depth + + @impose_hard_limits_on_fee + def depth_target_to_fee(self, target: int) -> Optional[int]: + """Returns fee in sat/kbyte. + target: desired mempool depth in vbytes + """ + if self._data is None: + return None + depth = 0 + for fee, s in self._data: + depth += s + if depth > target: + break + else: + return 0 + # add one sat/byte as currently that is the max precision of the histogram + # note: precision depends on server. + # old ElectrumX <1.16 has 1 s/b prec, >=1.16 has 0.1 s/b prec. + # electrs seems to use untruncated double-precision floating points. + # # TODO decrease this to 0.1 s/b next time we bump the required protocol version + fee += 1 + # convert to sat/kbyte + return int(fee * 1000) + + def depth_to_fee(self, slider_pos) -> Optional[int]: + """Returns fee in sat/kbyte.""" + target = FeePolicy.depth_target(slider_pos) + return self.depth_target_to_fee(target) + + def get_capped_data(self): + """ used by QML """ + data = self._data or [[FEERATE_DEFAULT_RELAY/1000,1]] + # cap the histogram to a limited number of megabytes + bytes_limit = 10*1000*1000 + bytes_current = 0 + capped_histogram = [] + for item in sorted(data, key=lambda x: x[0], reverse=True): + if bytes_current >= bytes_limit: + break + slot = min(item[1], bytes_limit-bytes_current) + bytes_current += slot + capped_histogram.append([ + max(FEERATE_DEFAULT_RELAY/1000, item[0]), # clamped to [FEERATE_DEFAULT_RELAY/1000,inf[ + slot, # width of bucket + bytes_current, # cumulative depth at far end of bucket + ]) + return data, bytes_current + + +class FeeTimeEstimates: + + def __init__(self): + self.data = {} # type: Dict[int, int] + + def get_data(self): + return self.data + + def has_data(self): + # we do not request estimate for next block fee + return len(self.data) == len(FEE_ETA_TARGETS) - 1 + + def set_data(self, nblock_target: int, fee_per_kb: int): + assert isinstance(nblock_target, int), f"expected int, got {nblock_target!r}" + assert isinstance(fee_per_kb, int), f"expected int, got {fee_per_kb!r}" + self.data[nblock_target] = fee_per_kb + + def fee_to_eta(self, fee_per_kb: Optional[int]) -> int: + """Returns 'num blocks' ETA estimate for given fee rate, + or -1 for low fee. + """ + import operator + lst = list(self.data.items()) + next_block_fee = self.eta_target_to_fee(1) + if next_block_fee is not None: + lst += [(1, next_block_fee)] + if not lst or fee_per_kb is None: + return -1 + dist = map(lambda x: (x[0], abs(x[1] - fee_per_kb)), lst) + min_target, min_value = min(dist, key=operator.itemgetter(1)) + if fee_per_kb < self.data.get(FEE_ETA_TARGETS[0])/2: + min_target = -1 + return min_target + + def eta_to_fee(self, slider_pos) -> Optional[int]: + """Returns fee in sat/kbyte.""" + slider_pos = max(slider_pos, 0) + slider_pos = min(slider_pos, len(FEE_ETA_TARGETS) - 1) + if slider_pos < len(FEE_ETA_TARGETS) - 1: + num_blocks = FEE_ETA_TARGETS[int(slider_pos)] + fee = self.eta_target_to_fee(num_blocks) + else: + fee = self.eta_target_to_fee(1) + return fee + + @impose_hard_limits_on_fee + def eta_target_to_fee(self, num_blocks: int) -> Optional[int]: + """Returns fee in sat/kbyte.""" + if num_blocks == 1: + fee = self.data.get(2) + if fee is not None: + fee += fee / 2 + fee = int(fee) + else: + fee = self.data.get(num_blocks) + if fee is not None: + fee = int(fee) + return fee diff --git a/electrum/gui/qml/components/controls/FeeMethodComboBox.qml b/electrum/gui/qml/components/controls/FeeMethodComboBox.qml index 56cbb829f..5c0927df3 100644 --- a/electrum/gui/qml/components/controls/FeeMethodComboBox.qml +++ b/electrum/gui/qml/components/controls/FeeMethodComboBox.qml @@ -12,9 +12,9 @@ ElComboBox { valueRole: 'value' model: [ - { text: qsTr('ETA'), value: 1 }, - { text: qsTr('Mempool'), value: 2 }, - { text: qsTr('Static'), value: 0 } + { text: qsTr('ETA'), value: FeeSlider.FSMethod.ETA }, + { text: qsTr('Mempool'), value: FeeSlider.FSMethod.MEMPOOL }, + { text: qsTr('Feerate'), value: FeeSlider.FSMethod.FEERATE } ] onCurrentValueChanged: { if (activeFocus) diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index b6dfb2f1f..dde6bb3cb 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -30,7 +30,7 @@ from .qeqr import QEQRParser, QEQRImageProvider, QEQRImageProviderHelper from .qeqrscanner import QEQRScanner from .qebitcoin import QEBitcoin from .qefx import QEFX -from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller, QETxSweepFinalizer +from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller, QETxSweepFinalizer, FeeSlider from .qeinvoice import QEInvoice, QEInvoiceParser from .qerequestdetails import QERequestDetails from .qetypes import QEAmount @@ -408,7 +408,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QETxCanceller, 'org.electrum', 1, 0, 'TxCanceller') qmlRegisterType(QETxSweepFinalizer, 'org.electrum', 1, 0, 'SweepFinalizer') qmlRegisterType(QEBip39RecoveryListModel, 'org.electrum', 1, 0, 'Bip39RecoveryListModel') - + qmlRegisterType(FeeSlider, 'org.electrum', 1, 0, 'FeeSlider') # TODO QT6: these were declared as uncreatable, but that doesn't seem to work for pyqt6 # qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') # qmlRegisterUncreatableType(QENewWalletWizard, 'org.electrum', 1, 0, 'QNewWalletWizard', 'QNewWalletWizard can only be used as property') diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index ced8f27be..bc574af76 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -5,7 +5,7 @@ from PyQt6.QtCore import pyqtProperty, pyqtSignal, QObject from electrum.logging import get_logger from electrum import constants from electrum.interface import ServerAddr -from electrum.simple_config import FEERATE_DEFAULT_RELAY +from electrum.fee_policy import FEERATE_DEFAULT_RELAY from .util import QtEventListener, event_listener from .qeserverlistmodel import QEServerListModel @@ -135,23 +135,7 @@ class QENetwork(QObject, QtEventListener): self.update_histogram(histogram) def update_histogram(self, histogram): - if not histogram: - histogram = [[FEERATE_DEFAULT_RELAY/1000,1]] - # cap the histogram to a limited number of megabytes - bytes_limit = 10*1000*1000 - bytes_current = 0 - capped_histogram = [] - for item in sorted(histogram, key=lambda x: x[0], reverse=True): - if bytes_current >= bytes_limit: - break - slot = min(item[1], bytes_limit-bytes_current) - bytes_current += slot - capped_histogram.append([ - max(FEERATE_DEFAULT_RELAY/1000, item[0]), # clamped to [FEERATE_DEFAULT_RELAY/1000,inf[ - slot, # width of bucket - bytes_current, # cumulative depth at far end of bucket - ]) - + capped_histogram, bytes_current = histogram.get_capped_data() # add clamping attributes for the GUI self._fee_histogram = { 'histogram': capped_histogram, diff --git a/electrum/gui/qml/qetxdetails.py b/electrum/gui/qml/qetxdetails.py index 4b8e71a68..453dab089 100644 --- a/electrum/gui/qml/qetxdetails.py +++ b/electrum/gui/qml/qetxdetails.py @@ -10,6 +10,7 @@ from electrum.transaction import tx_from_any, Transaction, PartialTxInput, Sigha from electrum.network import Network from electrum.address_synchronizer import TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE from electrum.wallet import TxSighashDanger +from electrum.fee_policy import FeePolicy from .qewallet import QEWallet from .qetypes import QEAmount @@ -327,7 +328,7 @@ class QETxDetails(QObject, QtEventListener): self.update_mined_status(txinfo.tx_mined_status) else: if txinfo.tx_mined_status.height in [TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT]: - self._mempool_depth = self._wallet.wallet.config.depth_tooltip(txinfo.mempool_depth_bytes) + self._mempool_depth = FeePolicy.depth_tooltip(txinfo.mempool_depth_bytes) self._in_mempool = True elif txinfo.tx_mined_status.height == TX_HEIGHT_FUTURE: self._lock_delay = txinfo.tx_mined_status.wanted_height - self._wallet.wallet.adb.get_local_height() diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index b3ff2491c..8e3d3ed1a 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -1,10 +1,11 @@ import copy +from enum import IntEnum import threading from decimal import Decimal from typing import Optional, TYPE_CHECKING from functools import partial -from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum from electrum.logging import get_logger from electrum.i18n import _ @@ -14,6 +15,7 @@ from electrum.util import NotEnoughFunds, profiler, quantize_feerate, UserFacing from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx, CannotCPFP, BumpFeeStrategy, sweep_preparations from electrum import keystore from electrum.plugin import run_hook +from electrum.fee_policy import FeePolicy, FeeMethod from .qewallet import QEWallet from .qetypes import QEAmount @@ -24,13 +26,35 @@ if TYPE_CHECKING: class FeeSlider(QObject): + + @pyqtEnum + class FSMethod(IntEnum): + FEERATE = 0 + ETA = 1 + MEMPOOL = 2 + + def to_fee_method(self) -> 'FeeMethod': + return { + self.FEERATE: FeeMethod.FEERATE, + self.ETA: FeeMethod.ETA, + self.MEMPOOL: FeeMethod.MEMPOOL, + }[self] + + @classmethod + def from_fee_method(cls, fm: FeeMethod) -> 'FeeSlider.FSMethod': + return { + FeeMethod.FEERATE: cls.FEERATE, + FeeMethod.ETA: cls.ETA, + FeeMethod.MEMPOOL: cls.MEMPOOL, + }[fm] + def __init__(self, parent=None): super().__init__(parent) self._wallet = None # type: Optional[QEWallet] self._sliderSteps = 0 self._sliderPos = 0 - self._method = -1 + self._fee_policy = None self._target = '' self._config = None # type: Optional[SimpleConfig] @@ -66,22 +90,20 @@ class FeeSlider(QObject): methodChanged = pyqtSignal() @pyqtProperty(int, notify=methodChanged) - def method(self): - return self._method + def method(self) -> int: + fsmethod = self.FSMethod.from_fee_method(self._fee_policy.method) + return int(fsmethod) @method.setter - def method(self, method): - if self._method != method: - self._method = method + def method(self, method: int): + fsmethod = self.FSMethod(method) + method = fsmethod.to_fee_method() + if self._fee_policy.method != method: + self._fee_policy.set_method(method) self.update_slider() self.methodChanged.emit() self.save_config() - def get_method(self): - dynfees = self._method > 0 - mempool = self._method == 2 - return dynfees, mempool - targetChanged = pyqtSignal() @pyqtProperty(str, notify=targetChanged) def target(self): @@ -94,21 +116,16 @@ class FeeSlider(QObject): self.targetChanged.emit() def update_slider(self): - dynfees, mempool = self.get_method() - maxp, pos, fee_rate = self._config.get_fee_slider(dynfees, mempool) - self._sliderSteps = maxp - self._sliderPos = pos + self._sliderSteps = self._fee_policy.get_slider_max() + self._sliderPos = self._fee_policy.get_slider_pos() self.sliderStepsChanged.emit() self.sliderPosChanged.emit() def update_target(self): - target, tooltip, dyn = self._config.get_fee_target() - self.target = target + self.target = self._fee_policy.get_target_text() def read_config(self): - mempool = self._config.use_mempool_fees() - dynfees = self._config.is_dynfee() - self._method = (2 if mempool else 1) if dynfees else 0 + self._fee_policy = FeePolicy(self._config.FEE_POLICY) self.update_slider() self.methodChanged.emit() self.update_target() @@ -116,16 +133,8 @@ class FeeSlider(QObject): def save_config(self): value = int(self._sliderPos) - dynfees, mempool = self.get_method() - self._config.FEE_EST_DYNAMIC = dynfees - self._config.FEE_EST_USE_MEMPOOL = mempool - if dynfees: - if mempool: - self._config.FEE_EST_DYNAMIC_MEMPOOL_SLIDERPOS = value - else: - self._config.FEE_EST_DYNAMIC_ETA_SLIDERPOS = value - else: - self._config.FEE_EST_STATIC_FEERATE = self._config.static_fee(value) + self._fee_policy.set_value_from_slider_pos(value) + self._config.FEE_POLICY = self._fee_policy.get_descriptor() self.update_target() self.update() @@ -362,7 +371,11 @@ class QETxFinalizer(TxFeeSlider): # default impl coins = self._wallet.wallet.get_spendable_coins(None) outputs = [PartialTxOutput.from_address_and_value(self.address, amount)] - tx = self._wallet.wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=None, rbf=self._rbf) + tx = self._wallet.wallet.make_unsigned_transaction( + coins=coins, + outputs=outputs, + fee_policy=self._fee_policy, + rbf=self._rbf) self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs()))) @@ -587,7 +600,7 @@ class QETxRbfFeeBumper(TxFeeSlider, TxMonMixin): # not initialized yet return - fee_per_kb = self._config.fee_per_kb() + fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network) if fee_per_kb is None: # dynamic method and no network self._logger.debug('no fee_per_kb') @@ -684,7 +697,7 @@ class QETxCanceller(TxFeeSlider, TxMonMixin): # not initialized yet return - fee_per_kb = self._config.fee_per_kb() + fee_per_kb = self._fee_policy.fee_per_kb() if fee_per_kb is None: # dynamic method and no network self._logger.debug('no fee_per_kb') @@ -826,7 +839,7 @@ class QETxCpfpFeeBumper(TxFeeSlider, TxMonMixin): self.validChanged.emit() self.warning = '' - fee_per_kb = self._config.fee_per_kb() + fee_per_kb = self._fee_policy.fee_per_kb() if fee_per_kb is None: # dynamic method and no network self._logger.debug('no fee_per_kb') diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index 9abed8ea1..7a13cbeb8 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -40,6 +40,7 @@ from electrum.transaction import Transaction, PartialTransaction from electrum.wallet import InternalAddressCorruption from electrum.simple_config import SimpleConfig from electrum.bitcoin import DummyAddress +from electrum.fee_policy import FeePolicy, FixedFeePolicy from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, WWLabel, read_QIcon) @@ -71,6 +72,8 @@ class TxEditor(WindowModalDialog): self.error = '' # set by side effect self.config = window.config + self.network = window.network + self.fee_policy = FeePolicy(self.config.FEE_POLICY) self.wallet = window.wallet self.feerounding_sats = 0 self.not_enough_funds = False @@ -124,23 +127,14 @@ class TxEditor(WindowModalDialog): def stop_editor_updates(self): self.main_window.gui_object.timer.timeout.disconnect(self.timer_actions) - def set_fee_config(self, dyn, pos, fee_rate): - if dyn: - if self.config.use_mempool_fees(): - self.config.cv.FEE_EST_DYNAMIC_MEMPOOL_SLIDERPOS.set(pos, save=False) - else: - self.config.cv.FEE_EST_DYNAMIC_ETA_SLIDERPOS.set(pos, save=False) - else: - self.config.cv.FEE_EST_STATIC_FEERATE.set(fee_rate, save=False) - def update_tx(self, *, fallback_to_zero_fee: bool = False): # expected to set self.tx, self.message and self.error raise NotImplementedError() def update_fee_target(self): - text = self.fee_slider.get_dynfee_target() + text = self.fee_slider.fee_policy.get_target_text() self.fee_target.setText(text) - self.fee_target.setVisible(bool(text)) # hide in static mode + # self.fee_target.setVisible(self.fee_slider.fee_policy.use_dynamic_estimates) # hide in static mode def update_feerate_label(self): self.feerate_label.setText(self.feerate_e.text() + ' ' + self.feerate_e.base_unit()) @@ -164,7 +158,7 @@ class TxEditor(WindowModalDialog): self.fiat_fee_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet()) self.feerate_e = FeerateEdit(lambda: 0) - self.feerate_e.setAmount(self.config.fee_per_byte()) + self.feerate_e.setAmount(self.fee_policy.fee_per_byte(self.network)) self.feerate_e.textEdited.connect(partial(self.on_fee_or_feerate, self.feerate_e, False)) self.feerate_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.feerate_e, True)) self.update_feerate_label() @@ -180,7 +174,7 @@ class TxEditor(WindowModalDialog): self.feerate_e.textChanged.connect(self.entry_changed) self.fee_target = QLabel('') - self.fee_slider = FeeSlider(self, self.config, self.fee_slider_callback) + self.fee_slider = FeeSlider(self, self.fee_policy, self.fee_slider_callback) self.fee_combo = FeeComboBox(self.fee_slider) self.fee_combo.setFocusPolicy(Qt.FocusPolicy.NoFocus) @@ -229,8 +223,8 @@ class TxEditor(WindowModalDialog): self._update_widgets() self.needs_update = True - def fee_slider_callback(self, dyn, pos, fee_rate): - self.set_fee_config(dyn, pos, fee_rate) + def fee_slider_callback(self, fee_rate): + self.config.FEE_POLICY = self.fee_policy.get_descriptor() self.fee_slider.activate() if fee_rate: fee_rate = Decimal(fee_rate) @@ -258,13 +252,13 @@ class TxEditor(WindowModalDialog): # because that event is emitted when we press OK self.trigger_update() - def is_send_fee_frozen(self): + def is_send_fee_frozen(self) -> bool: return self.fee_e.isVisible() and self.fee_e.isModified() \ - and (self.fee_e.text() or self.fee_e.hasFocus()) + and (bool(self.fee_e.text()) or self.fee_e.hasFocus()) - def is_send_feerate_frozen(self): + def is_send_feerate_frozen(self) -> bool: return self.feerate_e.isVisible() and self.feerate_e.isModified() \ - and (self.feerate_e.text() or self.feerate_e.hasFocus()) + and (bool(self.feerate_e.text()) or self.feerate_e.hasFocus()) def feerounding_text(self): return (_('Additional {} satoshis are going to be added.').format(self.feerounding_sats)) @@ -274,17 +268,17 @@ class TxEditor(WindowModalDialog): self.feerounding_icon.setIcon(read_QIcon('info.png') if b else QIcon()) self.feerounding_icon.setEnabled(b) - def get_fee_estimator(self): - if self.is_send_fee_frozen() and self.fee_e.get_amount() is not None: - fee_estimator = self.fee_e.get_amount() - elif self.is_send_feerate_frozen() and self.feerate_e.get_amount() is not None: - amount = self.feerate_e.get_amount() # sat/byte feerate - amount = 0 if amount is None else amount * 1000 # sat/kilobyte feerate - fee_estimator = partial( - SimpleConfig.estimate_fee_for_feerate, amount) + def get_fee_policy(self): + feerate = self.feerate_e.get_amount() + fee_amount = self.fee_e.get_amount() + if self.is_send_fee_frozen() and fee_amount is not None: + fee_policy = FixedFeePolicy(fee_amount) + elif self.is_send_feerate_frozen() and feerate is not None: + feerate_per_kb = int(feerate * 1000) + fee_policy = FeePolicy(f'static:{feerate_per_kb}') else: - fee_estimator = None - return fee_estimator + fee_policy = self.fee_slider.get_policy() + return fee_policy def entry_changed(self): # blue color denotes auto-filled values @@ -635,10 +629,10 @@ class ConfirmTxDialog(TxEditor): self.amount_label.setText(amount_str) def update_tx(self, *, fallback_to_zero_fee: bool = False): - fee_estimator = self.get_fee_estimator() + fee_policy = self.get_fee_policy() confirmed_only = self.config.WALLET_SPEND_CONFIRMED_ONLY try: - self.tx = self.make_tx(fee_estimator, confirmed_only=confirmed_only) + self.tx = self.make_tx(fee_policy, confirmed_only=confirmed_only) self.not_enough_funds = False self.no_dynfee_estimates = False except NotEnoughFunds: @@ -646,16 +640,17 @@ class ConfirmTxDialog(TxEditor): self.tx = None if fallback_to_zero_fee: try: - self.tx = self.make_tx(0, confirmed_only=confirmed_only) + self.tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only) except BaseException: return else: return except NoDynamicFeeEstimates: + # is this still needed? self.no_dynfee_estimates = True self.tx = None try: - self.tx = self.make_tx(0, confirmed_only=confirmed_only) + self.tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only) except NotEnoughFunds: self.not_enough_funds = True return @@ -670,7 +665,7 @@ class ConfirmTxDialog(TxEditor): def can_pay_assuming_zero_fees(self, confirmed_only) -> bool: # called in send_tab.py try: - tx = self.make_tx(0, confirmed_only=confirmed_only) + tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only) except NotEnoughFunds: return False else: diff --git a/electrum/gui/qt/fee_slider.py b/electrum/gui/qt/fee_slider.py index eabedb9be..350d38cc8 100644 --- a/electrum/gui/qt/fee_slider.py +++ b/electrum/gui/qt/fee_slider.py @@ -5,84 +5,68 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QSlider, QToolTip, QComboBox from electrum.i18n import _ +from electrum.fee_policy import FeeMethod class FeeComboBox(QComboBox): def __init__(self, fee_slider): QComboBox.__init__(self) - self.config = fee_slider.config self.fee_slider = fee_slider - self.addItems([_('Static'), _('ETA'), _('Mempool')]) - self.setCurrentIndex((2 if self.config.use_mempool_fees() else 1) if self.config.is_dynfee() else 0) + self.addItems([x.name_for_GUI() for x in FeeMethod.slider_values()]) + index = FeeMethod.slider_index_of_method(self.fee_slider.fee_policy.method) + self.setCurrentIndex(index) self.currentIndexChanged.connect(self.on_fee_type) self.help_msg = '\n'.join([ - _('Static: the fee slider uses static values'), + _('Feerate: the fee slider uses static feerate values'), _('ETA: fee rate is based on average confirmation time estimates'), _('Mempool based: fee rate is targeting a depth in the memory pool') ] ) def on_fee_type(self, x): - self.config.FEE_EST_USE_MEMPOOL = (x == 2) - self.config.FEE_EST_DYNAMIC = (x > 0) - self.fee_slider.update() + method = FeeMethod.slider_values()[x] + self.fee_slider.fee_policy.set_method(method) + self.fee_slider.update(is_initialized=True) class FeeSlider(QSlider): - def __init__(self, window, config, callback): + def __init__(self, window, fee_policy, callback): QSlider.__init__(self, Qt.Orientation.Horizontal) - self.config = config self.window = window + self.network = window.network self.callback = callback - self.dyn = False + self.fee_policy = fee_policy self.lock = threading.RLock() - self.update() + self.update(is_initialized=False) self.valueChanged.connect(self.moved) self._active = True - def get_fee_rate(self, pos): - if self.dyn: - fee_rate = self.config.depth_to_fee(pos) if self.config.use_mempool_fees() else self.config.eta_to_fee(pos) - else: - fee_rate = self.config.static_fee(pos) - return fee_rate + @property + def dyn(self): + return self.fee_policy.use_dynamic_estimates + + def get_policy(self): + return self.fee_policy def moved(self, pos): with self.lock: - fee_rate = self.get_fee_rate(pos) - tooltip = self.get_tooltip(pos, fee_rate) + self.fee_policy.set_value_from_slider_pos(pos) + fee_rate = self.fee_policy.fee_per_kb(self.network) + tooltip = self.fee_policy.get_tooltip(self.network) QToolTip.showText(QCursor.pos(), tooltip, self) self.setToolTip(tooltip) - self.callback(self.dyn, pos, fee_rate) + self.callback(fee_rate) - def get_tooltip(self, pos, fee_rate): - mempool = self.config.use_mempool_fees() - target, estimate = self.config.get_fee_text(pos, self.dyn, mempool, fee_rate) - if self.dyn: - return _('Target') + ': ' + target + '\n' + _('Current rate') + ': ' + estimate - else: - return _('Fixed rate') + ': ' + target + '\n' + _('Estimate') + ': ' + estimate - - def get_dynfee_target(self): - if not self.dyn: - return '' - pos = self.value() - fee_rate = self.get_fee_rate(pos) - mempool = self.config.use_mempool_fees() - target, estimate = self.config.get_fee_text(pos, True, mempool, fee_rate) - return target - - def update(self): + def update(self, *, is_initialized: bool): with self.lock: - self.dyn = self.config.is_dynfee() - mempool = self.config.use_mempool_fees() - maxp, pos, fee_rate = self.config.get_fee_slider(self.dyn, mempool) + pos = self.fee_policy.get_slider_pos() + maxp = self.fee_policy.get_slider_max() self.setRange(0, maxp) self.setValue(pos) - tooltip = self.get_tooltip(pos, fee_rate) - self.setToolTip(tooltip) + if is_initialized: + self.moved(pos) def activate(self): self._active = True diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 6d9a1e9a4..5d0416069 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -72,6 +72,7 @@ from electrum.logging import Logger from electrum.lntransport import extract_nodeid, ConnStringFormatError from electrum.lnaddr import lndecode from electrum.submarine_swaps import SwapServerTransport, NostrTransport +from electrum.fee_policy import FeePolicy from .rate_limiter import rate_limited from .exception_window import Exception_Hook @@ -1384,11 +1385,11 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): WaitingDialog(self, msg, task, on_success, on_failure) def mktx_for_open_channel(self, *, funding_sat, node_id): - make_tx = lambda fee_est, *, confirmed_only=False: self.wallet.lnworker.mktx_for_open_channel( + make_tx = lambda fee_policy, *, confirmed_only=False: self.wallet.lnworker.mktx_for_open_channel( coins = self.get_coins(nonlocal_only=True, confirmed_only=confirmed_only), funding_sat=funding_sat, node_id=node_id, - fee_est=fee_est) + fee_policy=fee_policy) return make_tx def open_channel(self, connect_str, funding_sat, push_amt): @@ -2692,15 +2693,16 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): fee = min(max_fee, fee) fee = max(total_size, fee) # pay at least 1 sat/byte for combined size return fee - suggested_feerate = self.config.fee_per_kb() + fee_policy = FeePolicy(self.config.FEE_POLICY) + suggested_feerate = fee_policy.fee_per_kb(self.network) fee = get_child_fee_from_total_feerate(suggested_feerate) fee_e.setAmount(fee) grid.addWidget(QLabel(_('Fee for child') + ':'), 3, 0) grid.addWidget(fee_e, 3, 1) - def on_rate(dyn, pos, fee_rate): + def on_rate(fee_rate): fee = get_child_fee_from_total_feerate(fee_rate) fee_e.setAmount(fee) - fee_slider = FeeSlider(self, self.config, on_rate) + fee_slider = FeeSlider(self, fee_policy, on_rate) fee_combo = FeeComboBox(fee_slider) fee_slider.update() grid.addWidget(fee_slider, 4, 1) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index ab2cd7541..597d278c8 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -266,10 +266,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): outputs = pi.get_onchain_outputs('!') if not outputs: return - make_tx = lambda fee_est, *, confirmed_only=False: self.wallet.make_unsigned_transaction( + make_tx = lambda fee_policy, *, confirmed_only=False: self.wallet.make_unsigned_transaction( + fee_policy=fee_policy, coins=self.window.get_coins(), outputs=outputs, - fee=fee_est, is_sweep=False) try: try: @@ -325,10 +325,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): # we call get_coins inside make_tx, so that inputs can be changed dynamically if get_coins is None: get_coins = self.window.get_coins - make_tx = lambda fee_est, *, confirmed_only=False: self.wallet.make_unsigned_transaction( + make_tx = lambda fee_policy, *, confirmed_only=False: self.wallet.make_unsigned_transaction( coins=get_coins(nonlocal_only=nonlocal_only, confirmed_only=confirmed_only), + fee_policy=fee_policy, outputs=outputs, - fee=fee_est, is_sweep=is_sweep) output_values = [x.value for x in outputs] is_max = any(parse_max_spend(outval) for outval in output_values) @@ -667,7 +667,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger): lnworker = self.wallet.lnworker if lnworker is None or not lnworker.can_pay_invoice(invoice): coins = self.window.get_coins(nonlocal_only=True) - can_pay_onchain = invoice.can_be_paid_onchain() and self.wallet.can_pay_onchain(invoice.get_outputs(), coins=coins) can_pay_with_new_channel = False can_pay_with_swap = False can_rebalance = False @@ -695,19 +694,11 @@ class SendTab(QWidget, MessageBoxMixin, Logger): _('You will be able to pay once the swap is confirmed.') ]) choices.append(('swap', msg)) - if can_pay_onchain: - msg = ''.join([ - _('Pay onchain'), '\n', - _('Funds will be sent to the invoice fallback address.') - ]) - choices.append(('onchain', msg)) msg = _('You cannot pay that invoice using Lightning.') if lnworker and lnworker.channels: num_sats_can_send = int(lnworker.num_sats_can_send()) msg += '\n' + _('Your channels can send {}.').format(self.format_amount(num_sats_can_send) + ' ' + self.base_unit()) if not choices: - if not can_pay_onchain: - msg += '\n' + _('Also, you have insufficient funds to pay on-chain.') self.window.show_error(msg) return r = self.window.query_choice(msg, choices) diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py index 3e6faff4a..bb7214a70 100644 --- a/electrum/gui/qt/swap_dialog.py +++ b/electrum/gui/qt/swap_dialog.py @@ -7,6 +7,7 @@ from electrum.i18n import _ from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, UserCancelled from electrum.bitcoin import DummyAddress from electrum.transaction import PartialTxOutput, PartialTransaction +from electrum.fee_policy import FeePolicy from electrum.gui import messages from . import util @@ -76,7 +77,8 @@ class SwapDialog(WindowModalDialog, QtEventListener): self.send_amount_e.setEnabled(recv_amount_sat is None) self.recv_amount_e.setEnabled(recv_amount_sat is None) self.max_button.setEnabled(recv_amount_sat is None) - fee_slider = FeeSlider(self.window, self.config, self.fee_slider_callback) + self.fee_policy = FeePolicy(self.config.FEE_POLICY) + fee_slider = FeeSlider(self.window, self.fee_policy, self.fee_slider_callback) fee_combo = FeeComboBox(fee_slider) fee_slider.update() self.fee_label = QLabel() @@ -147,14 +149,8 @@ class SwapDialog(WindowModalDialog, QtEventListener): recv_amount_sat = max(recv_amount_sat, self.swap_manager.get_min_amount()) self.recv_amount_e.setAmount(recv_amount_sat) - def fee_slider_callback(self, dyn, pos, fee_rate): - if dyn: - if self.config.use_mempool_fees(): - self.config.cv.FEE_EST_DYNAMIC_MEMPOOL_SLIDERPOS.set(pos, save=False) - else: - self.config.cv.FEE_EST_DYNAMIC_ETA_SLIDERPOS.set(pos, save=False) - else: - self.config.cv.FEE_EST_STATIC_FEERATE.set(fee_rate, save=False) + def fee_slider_callback(self, fee_rate): + self.config.FEE_POLICY = self.fee_policy.get_descriptor() if self.send_follows: self.on_recv_edited() else: @@ -319,6 +315,7 @@ class SwapDialog(WindowModalDialog, QtEventListener): outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)] try: tx = self.window.wallet.make_unsigned_transaction( + fee_policy=self.fee_policy, coins=coins, outputs=outputs, send_change_to_lightning=False, diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index 152afa2c8..fc7d9f92f 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -843,7 +843,8 @@ class TxDialog(QDialog, MessageBoxMixin): self.date_label.setText(_("Date: {}").format(time_str)) self.date_label.show() elif exp_n is not None: - self.date_label.setText(_('Position in mempool: {}').format(self.config.depth_tooltip(exp_n))) + from electrum.fee_policy import FeePolicy + self.date_label.setText(_('Position in mempool: {}').format(FeePolicy.depth_tooltip(exp_n))) self.date_label.show() else: self.date_label.hide() diff --git a/electrum/gui/text.py b/electrum/gui/text.py index 5eeef8828..277a4d897 100644 --- a/electrum/gui/text.py +++ b/electrum/gui/text.py @@ -764,14 +764,14 @@ class ElectrumGui(BaseElectrumGui, EventListener): self.network.run_from_another_thread(self.network.set_parameters(net_params)) def settings_dialog(self): - fee = str(Decimal(self.config.fee_per_kb()) / COIN) + from electrum.fee_policy import FeePolicy out = self.run_dialog('Settings', [ - {'label':'Default fee', 'type':'satoshis', 'value': fee} - ], buttons = 1) + {'label':'Fee policy', 'type':'str', 'value': self.config.FEE_POLICY} + ], buttons = 1) if out: - if out.get('Default fee'): - fee = int(Decimal(out['Default fee']) * COIN) - self.config.FEE_EST_STATIC_FEERATE = fee + if descr := out.get('Fee policy'): + fee_policy = FeePolicy(descr) + self.config.FEE_POLICY = fee_policy.get_descriptor() def password_dialog(self): out = self.run_dialog('Password', [ diff --git a/electrum/interface.py b/electrum/interface.py index 25c3ef6c3..8649652b9 100644 --- a/electrum/interface.py +++ b/electrum/interface.py @@ -58,6 +58,7 @@ from . import constants from .i18n import _ from .logging import Logger from .transaction import Transaction +from .fee_policy import FEE_ETA_TARGETS if TYPE_CHECKING: from .network import Network @@ -736,11 +737,10 @@ class Interface(Logger): await self.session.send_request('server.ping') async def request_fee_estimates(self): - from .simple_config import FEE_ETA_TARGETS while True: async with OldTaskGroup() as group: fee_tasks = [] - for i in FEE_ETA_TARGETS: + for i in FEE_ETA_TARGETS[0:-1]: fee_tasks.append((i, await group.spawn(self.get_estimatefee(i)))) for nblock_target, task in fee_tasks: fee = task.result() diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index ae1a6f187..6039dcdbe 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -65,7 +65,7 @@ from .address_synchronizer import TX_HEIGHT_LOCAL from .lnutil import CHANNEL_OPENING_TIMEOUT from .lnutil import ChannelBackupStorage, ImportedChannelBackupStorage, OnchainChannelBackupStorage from .lnutil import format_short_channel_id -from .simple_config import FEERATE_PER_KW_MIN_RELAY_LIGHTNING +from .fee_policy import FEERATE_PER_KW_MIN_RELAY_LIGHTNING if TYPE_CHECKING: from .lnworker import LNWallet @@ -1510,15 +1510,17 @@ class Channel(AbstractChannel): def create_sweeptxs_for_watchtower(self, ctn: int) -> List[Transaction]: from .lnsweep import sweep_their_ctx_watchtower + from .fee_policy import FeePolicy from .transaction import PartialTxOutput, PartialTransaction secret, ctx = self.get_secret_and_commitment(REMOTE, ctn=ctn) txs = [] txins = sweep_their_ctx_watchtower(self, ctx, secret) + fee_policy = FeePolicy('eta:2') for txin in txins: output_idx = txin.prevout.out_idx value = ctx.outputs()[output_idx].value tx_size_bytes = 121 - fee = self.lnworker.config.estimate_fee(tx_size_bytes, allow_fallback_to_static_rates=True) + fee = fee_policy.estimate_fee(tx_size_bytes, network=self.lnworker.network, allow_fallback_to_static_rates=True) outvalue = value - fee sweep_outputs = [PartialTxOutput.from_address_and_value(self.get_sweep_address(), outvalue)] sweep_tx = PartialTransaction.from_io([txin], sweep_outputs, version=2) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 16d263c3d..2f24587d9 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -55,7 +55,7 @@ from .interface import GracefulDisconnect from .lnrouter import fee_for_edge_msat from .json_db import StoredDict from .invoices import PR_PAID -from .simple_config import FEE_LN_ETA_TARGET, FEERATE_PER_KW_MIN_RELAY_LIGHTNING +from .fee_policy import FEE_LN_ETA_TARGET, FEERATE_PER_KW_MIN_RELAY_LIGHTNING from .trampoline import decode_routing_info if TYPE_CHECKING: @@ -2705,9 +2705,10 @@ class Peer(Logger, EventListener): if config.TEST_SHUTDOWN_FEE: our_fee = config.TEST_SHUTDOWN_FEE else: - fee_rate_per_kb = config.eta_target_to_fee(FEE_LN_ETA_TARGET) + fee_rate_per_kb = self.network.fee_estimates.eta_target_to_fee(FEE_LN_ETA_TARGET) if fee_rate_per_kb is None: # fallback - fee_rate_per_kb = self.network.config.fee_per_kb() + from .fee_policy import FeePolicy + fee_rate_per_kb = FeePolicy(config.FEE_POLICY).fee_per_kb(self.network) if fee_rate_per_kb is not None: our_fee = fee_rate_per_kb * closing_tx.estimated_size() // 1000 # TODO: anchors: remove this, as commitment fee rate can be below chain head fee rate? diff --git a/electrum/lnsweep.py b/electrum/lnsweep.py index eca638326..c0e3229b2 100644 --- a/electrum/lnsweep.py +++ b/electrum/lnsweep.py @@ -8,7 +8,7 @@ import electrum_ecc as ecc from .util import bfh, UneconomicFee from .crypto import privkey_to_pubkey -from .bitcoin import redeem_script_to_address, dust_threshold, construct_witness +from .bitcoin import redeem_script_to_address, construct_witness from . import descriptor from . import bitcoin @@ -23,7 +23,6 @@ from .lnutil import (make_commitment_output_to_remote_address, make_commitment_o derive_multisig_funding_key_if_we_opened, derive_multisig_funding_key_if_they_opened) from .transaction import (Transaction, TxInput, PartialTxInput, PartialTxOutput, TxOutpoint, script_GetOp, match_script_against_template) -from .simple_config import SimpleConfig from .logging import get_logger, Logger if TYPE_CHECKING: @@ -79,7 +78,7 @@ def sweep_their_ctx_watchtower( witness_script=witness_script, privkey=watcher_revocation_privkey, is_revocation=True, - config=chan.lnworker.config) + ) if txin: txins.append(txin) @@ -107,7 +106,6 @@ def sweep_their_ctx_watchtower( privkey=watcher_revocation_privkey, is_revocation=True, cltv_abs=cltv_abs, - config=chan.lnworker.config, has_anchors=chan.has_anchors() ) htlc_to_ctx_output_idx_map = map_htlcs_to_ctx_output_idxs( @@ -150,7 +148,7 @@ def sweep_their_ctx_watchtower( htlctx_witness_script=htlc_tx_witness_script, privkey=watcher_revocation_privkey, is_revocation=True, - config=chan.lnworker.config) + ) htlc_to_ctx_output_idx_map = map_htlcs_to_ctx_output_idxs( chan=chan, @@ -195,7 +193,7 @@ def sweep_their_ctx_justice( witness_script=witness_script, privkey=other_revocation_privkey, is_revocation=True, - config=chan.lnworker.config) + ) return sweep_txin return None @@ -243,7 +241,6 @@ def sweep_their_htlctx_justice( htlctx_witness_script=witness_script, privkey=other_revocation_privkey, is_revocation=True, - config=chan.lnworker.config ) index_to_sweepinfo = {} for output_idx in htlc_outputs_idxs: @@ -345,7 +342,6 @@ def sweep_our_ctx( privkey=our_localdelayed_privkey.get_secret_bytes(), is_revocation=False, to_self_delay=to_self_delay, - config=chan.lnworker.config, ): prevout = ctx.txid() + ':%d'%output_idx txs[prevout] = SweepInfo( @@ -401,7 +397,6 @@ def sweep_our_ctx( htlctx_witness_script=htlctx_witness_script, privkey=our_localdelayed_privkey.get_secret_bytes(), is_revocation=False, - config=chan.lnworker.config ): txs[actual_htlc_tx.txid() + f':{output_idx}'] = SweepInfo( name=f'second-stage-htlc:{output_idx}', @@ -559,7 +554,6 @@ def sweep_their_ctx_to_remote_backup( ctx=ctx, output_idx=output_idx, our_payment_privkey=our_payment_privkey, - config=chan.lnworker.config, has_anchors=True ): txs[prevout] = SweepInfo( @@ -660,7 +654,6 @@ def sweep_their_ctx( ctx=ctx, output_idx=output_idx, our_payment_privkey=our_payment_privkey, - config=chan.lnworker.config, has_anchors=chan.has_anchors() ): txs[prevout] = SweepInfo( @@ -700,7 +693,6 @@ def sweep_their_ctx( privkey=our_revocation_privkey if is_revocation else our_htlc_privkey.get_secret_bytes(), is_revocation=is_revocation, cltv_abs=cltv_abs, - config=chan.lnworker.config, has_anchors=chan.has_anchors(), ): txs[prevout] = SweepInfo( @@ -778,7 +770,6 @@ def sweep_their_ctx_htlc( preimage: Optional[bytes], output_idx: int, privkey: bytes, is_revocation: bool, cltv_abs: int, - config: SimpleConfig, has_anchors: bool, ) -> Optional[PartialTxInput]: """Deals with normal (non-CSV timelocked) HTLC output sweeps.""" @@ -792,11 +783,6 @@ def sweep_their_ctx_htlc( txin.witness_script = witness_script txin.script_sig = b'' txin.nsequence = 1 if has_anchors else 0xffffffff - 2 - tx_size_bytes = 200 # TODO (depends on offered/received and is_revocation) - fee = config.estimate_fee(tx_size_bytes, allow_fallback_to_static_rates=True) - outvalue = val - fee - if outvalue <= dust_threshold(): - return None txin.privkey = privkey if not is_revocation: txin.make_witness = lambda sig: construct_witness([sig, preimage, witness_script]) @@ -810,7 +796,6 @@ def sweep_their_ctx_htlc( def sweep_their_ctx_to_remote( ctx: Transaction, output_idx: int, our_payment_privkey: ecc.ECPrivkey, - config: SimpleConfig, has_anchors: bool, ) -> Optional[PartialTxInput]: assert has_anchors is True @@ -826,11 +811,6 @@ def sweep_their_ctx_to_remote( txin.script_sig = b'' txin.witness_script = witness_script txin.nsequence = 1 - tx_size_bytes = 196 # approx size of p2wsh->p2wpkh - fee = config.estimate_fee(tx_size_bytes, allow_fallback_to_static_rates=True) - outvalue = val - fee - if outvalue <= dust_threshold(): - return None txin.privkey = our_payment_privkey.get_secret_bytes() txin.make_witness = lambda sig: construct_witness([sig, witness_script]) return txin @@ -859,7 +839,7 @@ def sweep_ctx_anchor(*, ctx: Transaction, multisig_key: Keypair) -> Optional[Par def sweep_ctx_to_local( *, ctx: Transaction, output_idx: int, witness_script: bytes, - privkey: bytes, is_revocation: bool, config: SimpleConfig, + privkey: bytes, is_revocation: bool, to_self_delay: int = None) -> Optional[PartialTxInput]: """Create a txin that sweeps the 'to_local' output of a commitment transaction into our wallet. @@ -877,11 +857,6 @@ def sweep_ctx_to_local( if not is_revocation: assert isinstance(to_self_delay, int) txin.nsequence = to_self_delay - tx_size_bytes = 121 # approx size of to_local -> p2wpkh - fee = config.estimate_fee(tx_size_bytes, allow_fallback_to_static_rates=True) - outvalue = val - fee - if outvalue <= dust_threshold(): - return None txin.privkey = privkey assert txin.witness_script txin.make_witness = lambda sig: construct_witness([sig, int(is_revocation), witness_script]) @@ -895,7 +870,7 @@ def sweep_htlctx_output( privkey: bytes, is_revocation: bool, to_self_delay: int = None, - config: SimpleConfig) -> Optional[PartialTxInput]: +) -> Optional[PartialTxInput]: """Create a txn that sweeps the output of a first stage htlc tx (i.e. sweeps from an HTLC-Timeout or an HTLC-Success tx). """ @@ -908,5 +883,4 @@ def sweep_htlctx_output( privkey=privkey, is_revocation=is_revocation, to_self_delay=to_self_delay, - config=config, ) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 3c7814c25..3caa208c1 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -34,6 +34,7 @@ from .lnaddr import lndecode from .bip32 import BIP32Node, BIP32_PRIME from .transaction import BCDataStream, OPPushDataGeneric from .logging import get_logger +from .fee_policy import FEERATE_PER_KW_MIN_RELAY_LIGHTNING if TYPE_CHECKING: @@ -206,7 +207,6 @@ class ChannelConfig(StoredObject): raise Exception( "both to_local and to_remote amounts for the initial commitment " "transaction are less than or equal to channel_reserve_satoshis") - from .simple_config import FEERATE_PER_KW_MIN_RELAY_LIGHTNING if initial_feerate_per_kw < FEERATE_PER_KW_MIN_RELAY_LIGHTNING: raise Exception(f"feerate lower than min relay fee. {initial_feerate_per_kw} sat/kw.") diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py index b0078af1d..f54d08395 100644 --- a/electrum/lnwatcher.py +++ b/electrum/lnwatcher.py @@ -3,18 +3,19 @@ # file LICENCE or http://www.opensource.org/licenses/mit-license.php from typing import NamedTuple, Iterable, TYPE_CHECKING -import os import copy import asyncio from enum import IntEnum, auto from typing import NamedTuple, Dict from . import util -from .wallet_db import WalletDB -from .util import bfh, log_exceptions, ignore_exceptions, TxMinedInfo, random_shuffled_copy +from .util import log_exceptions, ignore_exceptions, TxMinedInfo +from .util import EventListener, event_listener from .address_synchronizer import AddressSynchronizer, TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE from .transaction import Transaction, TxOutpoint, PartialTransaction from .logging import Logger +from .bitcoin import dust_threshold +from .fee_policy import FeePolicy if TYPE_CHECKING: @@ -36,10 +37,6 @@ class TxMinedDepth(IntEnum): FREE = auto() - - -from .util import EventListener, event_listener - class LNWatcher(Logger, EventListener): LOGGING_SHORTCUT = 'W' @@ -54,6 +51,7 @@ class LNWatcher(Logger, EventListener): self.register_callbacks() # status gets populated when we run self.channel_status = {} + self.fee_policy = FeePolicy('eta:2') async def stop(self): self.unregister_callbacks() @@ -219,6 +217,18 @@ class LNWalletWatcher(LNWatcher): keep_watching=keep_watching) await self.lnworker.handle_onchain_state(chan) + def is_dust(self, sweep_info): + if sweep_info.name in ['local_anchor', 'remote_anchor']: + return False + if sweep_info.txout is not None: + return False + value = sweep_info.txin._trusted_value_sats + witness_size = len(sweep_info.txin.make_witness(71*b'\x00')) + tx_size_vbytes = 84 + witness_size//4 # assumes no batching, sweep to p2wpkh + self.logger.info(f'{sweep_info.name} size = {tx_size_vbytes}') + fee = self.fee_policy.estimate_fee(tx_size_vbytes, network=self.network, allow_fallback_to_static_rates=True) + return value - fee <= dust_threshold() + @log_exceptions async def sweep_commitment_transaction(self, funding_outpoint, closing_tx) -> bool: """This function is called when a channel was closed. In this case @@ -229,7 +239,6 @@ class LNWalletWatcher(LNWatcher): chan = self.lnworker.channel_by_txo(funding_outpoint) if not chan: return False - chan_id_for_log = chan.get_id_for_log() # detect who closed and get information about how to claim outputs sweep_info_dict = chan.sweep_ctx(closing_tx) self.logger.info(f"do_breach_remedy: {[x.name for x in sweep_info_dict.values()]}") @@ -237,6 +246,8 @@ class LNWalletWatcher(LNWatcher): # create and broadcast transactions for prevout, sweep_info in sweep_info_dict.items(): + if self.is_dust(sweep_info): + continue prev_txid, prev_index = prevout.split(':') name = sweep_info.name + ' ' + chan.get_id_for_log() self.lnworker.wallet.set_default_label(prevout, name) @@ -290,6 +301,7 @@ class LNWalletWatcher(LNWatcher): # password is needed for 1st stage htlc tx with anchors because we add inputs password = self.lnworker.wallet.get_unlocked_password() new_tx = self.lnworker.wallet.create_transaction( + fee_policy = self.fee_policy, inputs = inputs, outputs = outputs, password = password, diff --git a/electrum/lnworker.py b/electrum/lnworker.py index e37180012..2147fcc8f 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -38,6 +38,8 @@ from .util import ( make_aiohttp_session, timestamp_to_datetime, random_shuffled_copy, is_private_netaddress, UnrelatedTransactionException, LightningHistoryItem ) +from .fee_policy import FeePolicy, FixedFeePolicy +from .fee_policy import FEERATE_FALLBACK_STATIC_FEE, FEE_LN_ETA_TARGET, FEE_LN_LOW_ETA_TARGET, FEERATE_PER_KW_MIN_RELAY_LIGHTNING from .invoices import Invoice, PR_UNPAID, PR_PAID, PR_INFLIGHT, PR_FAILED, LN_EXPIRY_NEVER, BaseInvoice from .bitcoin import COIN, opcodes, make_op_return, address_to_scripthash, DummyAddress from .bip32 import BIP32Node @@ -1300,11 +1302,12 @@ class LNWallet(LNWorker): self.wallet.unlock(password) coins = self.wallet.get_spendable_coins(None) node_id = peer.pubkey + fee_policy = FeePolicy(self.config.FEE_POLICY) funding_tx = self.mktx_for_open_channel( coins=coins, funding_sat=funding_sat, node_id=node_id, - fee_est=None) + fee_policy=fee_policy) chan, funding_tx = await self._open_channel_coroutine( peer=peer, funding_tx=funding_tx, @@ -1387,7 +1390,8 @@ class LNWallet(LNWorker): coins: Sequence[PartialTxInput], funding_sat: int, node_id: bytes, - fee_est=None) -> PartialTransaction: + fee_policy: FeePolicy, + ) -> PartialTransaction: from .wallet import get_locktime_for_new_transaction outputs = [PartialTxOutput.from_address_and_value(DummyAddress.CHANNEL, funding_sat)] @@ -1397,7 +1401,7 @@ class LNWallet(LNWorker): tx = self.wallet.make_unsigned_transaction( coins=coins, outputs=outputs, - fee=fee_est) + fee_policy=fee_policy) tx.set_rbf(False) # rm randomness from locktime, as we use the locktime as entropy for deriving the funding_privkey # (and it would be confusing to get a collision as a consequence of the randomness) @@ -1413,16 +1417,16 @@ class LNWallet(LNWorker): min_funding_sat = max(min_funding_sat, 100_000) # at least 1mBTC if min_funding_sat > self.config.LIGHTNING_MAX_FUNDING_SAT: return - fee_est = partial(self.config.estimate_fee, allow_fallback_to_static_rates=True) # to avoid NoDynamicFeeEstimates + fee_policy = FeePolicy(f'feerate:{FEERATE_FALLBACK_STATIC_FEE}') try: - self.mktx_for_open_channel(coins=coins, funding_sat=min_funding_sat, node_id=bytes(32), fee_est=fee_est) + self.mktx_for_open_channel(coins=coins, funding_sat=min_funding_sat, node_id=bytes(32), fee_policy=fee_policy) funding_sat = min_funding_sat except NotEnoughFunds: return # if available, suggest twice that amount: if 2 * min_funding_sat <= self.config.LIGHTNING_MAX_FUNDING_SAT: try: - self.mktx_for_open_channel(coins=coins, funding_sat=2*min_funding_sat, node_id=bytes(32), fee_est=fee_est) + self.mktx_for_open_channel(coins=coins, funding_sat=2*min_funding_sat, node_id=bytes(32), fee_policy=fee_policy) funding_sat = 2 * min_funding_sat except NotEnoughFunds: pass @@ -2966,23 +2970,17 @@ class LNWallet(LNWorker): await self.taskgroup.spawn(self.reestablish_peer_for_given_channel(chan)) def current_target_feerate_per_kw(self) -> int: - from .simple_config import FEE_LN_ETA_TARGET, FEERATE_FALLBACK_STATIC_FEE - from .simple_config import FEERATE_PER_KW_MIN_RELAY_LIGHTNING - if constants.net is constants.BitcoinRegtest: - feerate_per_kvbyte = self.network.config.FEE_EST_STATIC_FEERATE + if self.network.fee_estimates.has_data(): + feerate_per_kvbyte = self.network.fee_estimates.eta_target_to_fee(FEE_LN_ETA_TARGET) else: - feerate_per_kvbyte = self.network.config.eta_target_to_fee(FEE_LN_ETA_TARGET) - if feerate_per_kvbyte is None: - feerate_per_kvbyte = FEERATE_FALLBACK_STATIC_FEE + feerate_per_kvbyte = FEERATE_FALLBACK_STATIC_FEE return max(FEERATE_PER_KW_MIN_RELAY_LIGHTNING, feerate_per_kvbyte // 4) def current_low_feerate_per_kw(self) -> int: - from .simple_config import FEE_LN_LOW_ETA_TARGET - from .simple_config import FEERATE_PER_KW_MIN_RELAY_LIGHTNING if constants.net is constants.BitcoinRegtest: feerate_per_kvbyte = 0 else: - feerate_per_kvbyte = self.network.config.eta_target_to_fee(FEE_LN_LOW_ETA_TARGET) or 0 + feerate_per_kvbyte = self.network.fee_estimates.eta_target_to_fee(FEE_LN_LOW_ETA_TARGET) or 0 low_feerate_per_kw = max(FEERATE_PER_KW_MIN_RELAY_LIGHTNING, feerate_per_kvbyte // 4) # make sure this is never higher than the target feerate: low_feerate_per_kw = min(low_feerate_per_kw, self.current_target_feerate_per_kw()) diff --git a/electrum/network.py b/electrum/network.py index 48b824e82..5eaa8b1ea 100644 --- a/electrum/network.py +++ b/electrum/network.py @@ -62,6 +62,8 @@ from .interface import (Interface, PREFERRED_NETWORK_PROTOCOL, from .version import PROTOCOL_VERSION from .i18n import _ from .logging import get_logger, Logger +from .fee_policy import FeeHistogram, FeeTimeEstimates, FEE_ETA_TARGETS + if TYPE_CHECKING: from collections.abc import Coroutine @@ -364,6 +366,10 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): self._has_ever_managed_to_connect_to_server = False self._was_started = False + self.mempool_fees = FeeHistogram() + self.fee_estimates = FeeTimeEstimates() + self.last_time_fee_estimates_requested = 0 # zero ensures immediate fees + def has_internet_connection(self) -> bool: """Our guess whether the device has Internet-connectivity.""" @@ -497,11 +503,27 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): await group.spawn(self._request_fee_estimates(interface)) async def _request_fee_estimates(self, interface): - self.config.requested_fee_estimates() + self.requested_fee_estimates() histogram = await interface.get_fee_histogram() - self.config.mempool_fees = histogram + self.mempool_fees.set_data(histogram) self.logger.info(f'fee_histogram {len(histogram)}') - util.trigger_callback('fee_histogram', self.config.mempool_fees) + util.trigger_callback('fee_histogram', self.mempool_fees) + + def is_fee_estimates_update_required(self): + """Checks time since last requested and updated fee estimates. + Returns True if an update should be requested. + """ + now = time.time() + return now - self.last_time_fee_estimates_requested > 60 + + def has_fee_etas(self): + return self.fee_estimates.has_data() + + def has_fee_mempool(self) -> bool: + return self.mempool_fees.has_data() + + def requested_fee_estimates(self): + self.last_time_fee_estimates_requested = time.time() def get_parameters(self) -> NetworkParameters: return NetworkParameters(server=self.default_server, @@ -532,11 +554,10 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): def get_fee_estimates(self): from statistics import median - from .simple_config import FEE_ETA_TARGETS if self.auto_connect: with self.interfaces_lock: out = {} - for n in FEE_ETA_TARGETS: + for n in FEE_ETA_TARGETS[0:-1]: try: out[n] = int(median(filter(None, [i.fee_estimates_eta.get(n) for i in self.interfaces.values()]))) except Exception: @@ -551,11 +572,12 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): if fee_est is None: fee_est = self.get_fee_estimates() for nblock_target, fee in fee_est.items(): - self.config.update_fee_estimates(nblock_target, fee) + self.fee_estimates.set_data(nblock_target, fee) if not hasattr(self, "_prev_fee_est") or self._prev_fee_est != fee_est: self._prev_fee_est = copy.copy(fee_est) self.logger.info(f'fee_estimates {fee_est}') - util.trigger_callback('fee', self.config.fee_estimates) + util.trigger_callback('fee', self.fee_estimates) + @with_recent_servers_lock def get_servers(self): @@ -1404,7 +1426,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): async def maintain_main_interface(): await self._ensure_there_is_a_main_interface() if self.is_connected(): - if self.config.is_fee_estimates_update_required(): + if self.is_fee_estimates_update_required(): await self.interface.taskgroup.spawn(self._request_fee_estimates, self.interface) while True: diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 26042b30b..4a13708f9 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -15,34 +15,13 @@ from . import constants from . import invoices from .util import base_units, base_unit_name_to_decimal_point, decimal_point_to_base_unit_name, UnknownBaseUnit, DECIMAL_POINT_DEFAULT from .util import format_satoshis, format_fee_satoshis, os_chmod -from .util import user_dir, make_dir, NoDynamicFeeEstimates, quantize_feerate +from .util import user_dir, make_dir from .lnutil import LN_MAX_FUNDING_SAT_LEGACY from .i18n import _ from .logging import get_logger, Logger -FEE_ETA_TARGETS = [25, 10, 5, 2] -FEE_DEPTH_TARGETS = [10_000_000, 5_000_000, 2_000_000, 1_000_000, - 800_000, 600_000, 400_000, 250_000, 100_000] -FEE_LN_ETA_TARGET = 2 # note: make sure the network is asking for estimates for this target -FEE_LN_LOW_ETA_TARGET = 25 # note: make sure the network is asking for estimates for this target -# satoshi per kbyte -FEERATE_MAX_DYNAMIC = 1500000 -FEERATE_WARNING_HIGH_FEE = 600000 -FEERATE_FALLBACK_STATIC_FEE = 150000 -FEERATE_DEFAULT_RELAY = 1000 -FEERATE_MAX_RELAY = 50000 -FEERATE_STATIC_VALUES = [1000, 2000, 5000, 10000, 20000, 30000, - 50000, 70000, 100000, 150000, 200000, 300000] - -# The min feerate_per_kw that can be used in lightning so that -# the resulting onchain tx pays the min relay fee. -# This would be FEERATE_DEFAULT_RELAY / 4 if not for rounding errors, -# see https://github.com/ElementsProject/lightning/commit/2e687b9b352c9092b5e8bd4a688916ac50b44af0 -FEERATE_PER_KW_MIN_RELAY_LIGHTNING = 253 - -FEE_RATIO_HIGH_WARNING = 0.05 # warn user if fee/amount for on-chain tx is higher than this @@ -195,9 +174,6 @@ class SimpleConfig(Logger): # a thread-safe way. self.lock = threading.RLock() - self.mempool_fees = None # type: Optional[Sequence[Tuple[Union[float, int], int]]] - self.fee_estimates = {} # type: Dict[int, int] - self.last_time_fee_estimates_requested = 0 # zero ensures immediate fees # The following two functions are there for dependency injection when # testing. @@ -492,366 +468,6 @@ class SimpleConfig(Logger): path = wallet.storage.path self.GUI_LAST_WALLET = path - def impose_hard_limits_on_fee(func): - def get_fee_within_limits(self, *args, **kwargs): - fee = func(self, *args, **kwargs) - if fee is None: - return fee - fee = min(FEERATE_MAX_DYNAMIC, fee) - fee = max(FEERATE_DEFAULT_RELAY, fee) - return fee - return get_fee_within_limits - - def eta_to_fee(self, slider_pos) -> Optional[int]: - """Returns fee in sat/kbyte.""" - slider_pos = max(slider_pos, 0) - slider_pos = min(slider_pos, len(FEE_ETA_TARGETS)) - if slider_pos < len(FEE_ETA_TARGETS): - num_blocks = FEE_ETA_TARGETS[int(slider_pos)] - fee = self.eta_target_to_fee(num_blocks) - else: - fee = self.eta_target_to_fee(1) - return fee - - @impose_hard_limits_on_fee - def eta_target_to_fee(self, num_blocks: int) -> Optional[int]: - """Returns fee in sat/kbyte.""" - if num_blocks == 1: - fee = self.fee_estimates.get(2) - if fee is not None: - fee += fee / 2 - fee = int(fee) - else: - fee = self.fee_estimates.get(num_blocks) - if fee is not None: - fee = int(fee) - return fee - - def fee_to_depth(self, target_fee: Real) -> Optional[int]: - """For a given sat/vbyte fee, returns an estimate of how deep - it would be in the current mempool in vbytes. - Pessimistic == overestimates the depth. - """ - if self.mempool_fees is None: - return None - depth = 0 - for fee, s in self.mempool_fees: - depth += s - if fee <= target_fee: - break - return depth - - def depth_to_fee(self, slider_pos) -> Optional[int]: - """Returns fee in sat/kbyte.""" - target = self.depth_target(slider_pos) - return self.depth_target_to_fee(target) - - @impose_hard_limits_on_fee - def depth_target_to_fee(self, target: int) -> Optional[int]: - """Returns fee in sat/kbyte. - target: desired mempool depth in vbytes - """ - if self.mempool_fees is None: - return None - depth = 0 - for fee, s in self.mempool_fees: - depth += s - if depth > target: - break - else: - return 0 - # add one sat/byte as currently that is the max precision of the histogram - # note: precision depends on server. - # old ElectrumX <1.16 has 1 s/b prec, >=1.16 has 0.1 s/b prec. - # electrs seems to use untruncated double-precision floating points. - # # TODO decrease this to 0.1 s/b next time we bump the required protocol version - fee += 1 - # convert to sat/kbyte - return int(fee * 1000) - - def depth_target(self, slider_pos: int) -> int: - """Returns mempool depth target in bytes for a fee slider position.""" - slider_pos = max(slider_pos, 0) - slider_pos = min(slider_pos, len(FEE_DEPTH_TARGETS)-1) - return FEE_DEPTH_TARGETS[slider_pos] - - def eta_target(self, slider_pos: int) -> int: - """Returns 'num blocks' ETA target for a fee slider position.""" - if slider_pos == len(FEE_ETA_TARGETS): - return 1 - return FEE_ETA_TARGETS[slider_pos] - - def fee_to_eta(self, fee_per_kb: Optional[int]) -> int: - """Returns 'num blocks' ETA estimate for given fee rate, - or -1 for low fee. - """ - import operator - lst = list(self.fee_estimates.items()) - next_block_fee = self.eta_target_to_fee(1) - if next_block_fee is not None: - lst += [(1, next_block_fee)] - if not lst or fee_per_kb is None: - return -1 - dist = map(lambda x: (x[0], abs(x[1] - fee_per_kb)), lst) - min_target, min_value = min(dist, key=operator.itemgetter(1)) - if fee_per_kb < self.fee_estimates.get(FEE_ETA_TARGETS[0])/2: - min_target = -1 - return min_target - - def get_depth_mb_str(self, depth: int) -> str: - # e.g. 500_000 -> "0.50 MB" - depth_mb = "{:.2f}".format(depth / 1_000_000) # maybe .rstrip("0") ? - return f"{depth_mb} {util.UI_UNIT_NAME_MEMPOOL_MB}" - - def depth_tooltip(self, depth: Optional[int]) -> str: - """Returns text tooltip for given mempool depth (in vbytes).""" - if depth is None: - return "unknown from tip" - depth_mb = self.get_depth_mb_str(depth) - return _("{} from tip").format(depth_mb) - - def eta_tooltip(self, x): - if x < 0: - return _('Low fee') - elif x == 1: - return _('In the next block') - else: - return _('Within {} blocks').format(x) - - def get_fee_target(self): - dyn = self.is_dynfee() - mempool = self.use_mempool_fees() - pos = self.get_depth_level() if mempool else self.get_fee_level() - fee_rate = self.fee_per_kb() - target, tooltip = self.get_fee_text(pos, dyn, mempool, fee_rate) - return target, tooltip, dyn - - def get_fee_status(self): - target, tooltip, dyn = self.get_fee_target() - return tooltip + ' [%s]'%target if dyn else target + ' [Static]' - - def get_fee_text( - self, - slider_pos: int, - dyn: bool, - mempool: bool, - fee_per_kb: Optional[int], - ): - """Returns (text, tooltip) where - text is what we target: static fee / num blocks to confirm in / mempool depth - tooltip is the corresponding estimate (e.g. num blocks for a static fee) - - fee_rate is in sat/kbyte - """ - if fee_per_kb is None: - rate_str = 'unknown' - fee_per_byte = None - else: - fee_per_byte = fee_per_kb/1000 - rate_str = format_fee_satoshis(fee_per_byte) + f" {util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE}" - - if dyn: - if mempool: - depth = self.depth_target(slider_pos) - text = self.depth_tooltip(depth) - else: - eta = self.eta_target(slider_pos) - text = self.eta_tooltip(eta) - tooltip = rate_str - else: # using static fees - assert fee_per_kb is not None - assert fee_per_byte is not None - text = rate_str - if mempool and self.has_fee_mempool(): - depth = self.fee_to_depth(fee_per_byte) - tooltip = self.depth_tooltip(depth) - elif not mempool and self.has_fee_etas(): - eta = self.fee_to_eta(fee_per_kb) - tooltip = self.eta_tooltip(eta) - else: - tooltip = '' - return text, tooltip - - def get_depth_level(self) -> int: - maxp = len(FEE_DEPTH_TARGETS) - 1 - return min(maxp, self.FEE_EST_DYNAMIC_MEMPOOL_SLIDERPOS) - - def get_fee_level(self) -> int: - maxp = len(FEE_ETA_TARGETS) # not (-1) to have "next block" - return min(maxp, self.FEE_EST_DYNAMIC_ETA_SLIDERPOS) - - def get_fee_slider(self, dyn, mempool) -> Tuple[int, int, Optional[int]]: - if dyn: - if mempool: - pos = self.get_depth_level() - maxp = len(FEE_DEPTH_TARGETS) - 1 - fee_rate = self.depth_to_fee(pos) - else: - pos = self.get_fee_level() - maxp = len(FEE_ETA_TARGETS) # not (-1) to have "next block" - fee_rate = self.eta_to_fee(pos) - else: - fee_rate = self.fee_per_kb(dyn=False) - pos = self.static_fee_index(fee_rate) - maxp = len(FEERATE_STATIC_VALUES) - 1 - return maxp, pos, fee_rate - - def static_fee(self, i): - return FEERATE_STATIC_VALUES[i] - - def static_fee_index(self, fee_per_kb: Optional[int]) -> int: - if fee_per_kb is None: - raise TypeError('static fee cannot be None') - dist = list(map(lambda x: abs(x - fee_per_kb), FEERATE_STATIC_VALUES)) - return min(range(len(dist)), key=dist.__getitem__) - - def has_fee_etas(self): - return len(self.fee_estimates) == 4 - - def has_fee_mempool(self) -> bool: - return self.mempool_fees is not None - - def has_dynamic_fees_ready(self): - if self.use_mempool_fees(): - return self.has_fee_mempool() - else: - return self.has_fee_etas() - - def is_dynfee(self) -> bool: - return self.FEE_EST_DYNAMIC - - def use_mempool_fees(self) -> bool: - return self.FEE_EST_USE_MEMPOOL - - def _feerate_from_fractional_slider_position(self, fee_level: float, dyn: bool, - mempool: bool) -> Union[int, None]: - fee_level = max(fee_level, 0) - fee_level = min(fee_level, 1) - if dyn: - max_pos = (len(FEE_DEPTH_TARGETS) - 1) if mempool else len(FEE_ETA_TARGETS) - slider_pos = round(fee_level * max_pos) - fee_rate = self.depth_to_fee(slider_pos) if mempool else self.eta_to_fee(slider_pos) - else: - max_pos = len(FEERATE_STATIC_VALUES) - 1 - slider_pos = round(fee_level * max_pos) - fee_rate = FEERATE_STATIC_VALUES[slider_pos] - return fee_rate - - def fee_per_kb(self, dyn: bool=None, mempool: bool=None, fee_level: float=None) -> Optional[int]: - """Returns sat/kvB fee to pay for a txn. - Note: might return None. - - fee_level: float between 0.0 and 1.0, representing fee slider position - """ - if constants.net is constants.BitcoinRegtest: - return self.FEE_EST_STATIC_FEERATE - if dyn is None: - dyn = self.is_dynfee() - if mempool is None: - mempool = self.use_mempool_fees() - if fee_level is not None: - return self._feerate_from_fractional_slider_position(fee_level, dyn, mempool) - # there is no fee_level specified; will use config. - # note: 'depth_level' and 'fee_level' in config are integer slider positions, - # unlike fee_level here, which (when given) is a float in [0.0, 1.0] - if dyn: - if mempool: - fee_rate = self.depth_to_fee(self.get_depth_level()) - else: - fee_rate = self.eta_to_fee(self.get_fee_level()) - else: - fee_rate = self.FEE_EST_STATIC_FEERATE - if fee_rate is not None: - fee_rate = int(fee_rate) - return fee_rate - - def getfeerate(self) -> Tuple[str, int, Optional[int], str]: - dyn = self.is_dynfee() - mempool = self.use_mempool_fees() - if dyn: - if mempool: - method = 'mempool' - fee_level = self.get_depth_level() - value = self.depth_target(fee_level) - fee_rate = self.depth_to_fee(fee_level) - tooltip = self.depth_tooltip(value) - else: - method = 'ETA' - fee_level = self.get_fee_level() - value = self.eta_target(fee_level) - fee_rate = self.eta_to_fee(fee_level) - tooltip = self.eta_tooltip(value) - else: - method = 'static' - value = self.FEE_EST_STATIC_FEERATE - fee_rate = value - tooltip = 'static feerate' - - return method, value, fee_rate, tooltip - - def setfeerate(self, fee_method: str, value: int): - if fee_method == 'mempool': - if value not in FEE_DEPTH_TARGETS: - raise Exception(f"Error: fee_level must be in {FEE_DEPTH_TARGETS}") - self.FEE_EST_USE_MEMPOOL = True - self.FEE_EST_DYNAMIC = True - self.FEE_EST_DYNAMIC_MEMPOOL_SLIDERPOS = FEE_DEPTH_TARGETS.index(value) - elif fee_method == 'ETA': - if value not in FEE_ETA_TARGETS: - raise Exception(f"Error: fee_level must be in {FEE_ETA_TARGETS}") - self.FEE_EST_USE_MEMPOOL = False - self.FEE_EST_DYNAMIC = True - self.FEE_EST_DYNAMIC_ETA_SLIDERPOS = FEE_ETA_TARGETS.index(value) - elif fee_method == 'static': - self.FEE_EST_DYNAMIC = False - self.FEE_EST_STATIC_FEERATE = value - else: - raise Exception(f"Invalid parameter: {fee_method}. Valid methods are: ETA, mempool, static.") - - def fee_per_byte(self): - """Returns sat/vB fee to pay for a txn. - Note: might return None. - """ - fee_per_kb = self.fee_per_kb() - return fee_per_kb / 1000 if fee_per_kb is not None else None - - def estimate_fee(self, size: Union[int, float, Decimal], *, - allow_fallback_to_static_rates: bool = False) -> int: - fee_per_kb = self.fee_per_kb() - if fee_per_kb is None: - if allow_fallback_to_static_rates: - fee_per_kb = FEERATE_FALLBACK_STATIC_FEE - else: - raise NoDynamicFeeEstimates() - return self.estimate_fee_for_feerate(fee_per_kb, size) - - @classmethod - def estimate_fee_for_feerate(cls, fee_per_kb: Union[int, float, Decimal], - size: Union[int, float, Decimal]) -> int: - # note: 'size' is in vbytes - size = Decimal(size) - fee_per_kb = Decimal(fee_per_kb) - fee_per_byte = fee_per_kb / 1000 - # to be consistent with what is displayed in the GUI, - # the calculation needs to use the same precision: - fee_per_byte = quantize_feerate(fee_per_byte) - return round(fee_per_byte * size) - - def update_fee_estimates(self, nblock_target: int, fee_per_kb: int): - assert isinstance(nblock_target, int), f"expected int, got {nblock_target!r}" - assert isinstance(fee_per_kb, int), f"expected int, got {fee_per_kb!r}" - self.fee_estimates[nblock_target] = fee_per_kb - - def is_fee_estimates_update_required(self): - """Checks time since last requested and updated fee estimates. - Returns True if an update should be requested. - """ - now = time.time() - return now - self.last_time_fee_estimates_requested > 60 - - def requested_fee_estimates(self): - self.last_time_fee_estimates_requested = time.time() - def get_video_device(self): device = self.VIDEO_DEVICE_PATH if device == 'default': @@ -1065,11 +681,7 @@ Warning: setting this to too low will result in lots of payment failures."""), TEST_SHUTDOWN_FEE_RANGE = ConfigVar('test_shutdown_fee_range', default=None) TEST_SHUTDOWN_LEGACY = ConfigVar('test_shutdown_legacy', default=False, type_=bool) - FEE_EST_DYNAMIC = ConfigVar('dynamic_fees', default=True, type_=bool) - FEE_EST_USE_MEMPOOL = ConfigVar('mempool_fees', default=False, type_=bool) - FEE_EST_STATIC_FEERATE = ConfigVar('fee_per_kb', default=FEERATE_FALLBACK_STATIC_FEE, type_=int) - FEE_EST_DYNAMIC_ETA_SLIDERPOS = ConfigVar('fee_level', default=2, type_=int) - FEE_EST_DYNAMIC_MEMPOOL_SLIDERPOS = ConfigVar('depth_level', default=2, type_=int) + FEE_POLICY = ConfigVar('fee_policy', default='eta:2', type_=str) RPC_USERNAME = ConfigVar('rpcuser', default=None, type_=str) RPC_PASSWORD = ConfigVar('rpcpassword', default=None, type_=str) diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 9ea5f9d04..7392103d6 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -37,6 +37,7 @@ from .json_db import StoredObject, stored_in from . import constants from .address_synchronizer import TX_HEIGHT_LOCAL from .i18n import _ +from .fee_policy import FeePolicy from .bitcoin import construct_script from .crypto import ripemd @@ -166,17 +167,18 @@ def create_claim_tx( *, txin: PartialTxInput, swap: SwapData, - config: 'SimpleConfig', + network: 'Network', + fee_policy: FeePolicy, ) -> PartialTransaction: """Create tx to either claim successful reverse-swap, or to get refunded for timed-out forward-swap. """ # FIXME the mining fee should depend on swap.is_reverse. # the txs are not the same size... - amount_sat = txin.value_sats() - SwapManager._get_fee(size=SWAP_TX_SIZE, config=config) + amount_sat = txin.value_sats() - SwapManager._get_fee(size=SWAP_TX_SIZE, fee_policy=fee_policy, network=network) if amount_sat < dust_threshold(): raise BelowDustLimit() - txin, locktime = SwapManager.create_claim_txin(txin=txin, swap=swap, config=config) + txin, locktime = SwapManager.create_claim_txin(txin=txin, swap=swap) txout = PartialTxOutput.from_address_and_value(swap.receive_address, amount_sat) tx = PartialTransaction.from_io([txin], [txout], version=2, locktime=locktime) sig = tx.sign_txin(0, txin.privkey) @@ -200,6 +202,7 @@ class SwapManager(Logger): self.wallet = wallet self.config = wallet.config + self.fee_policy = FeePolicy(wallet.config.FEE_POLICY) self.lnworker = lnworker self.config = wallet.config self.taskgroup = OldTaskGroup() @@ -447,7 +450,7 @@ class SwapManager(Logger): if spent_height is not None and not should_bump_fee: return try: - tx = create_claim_tx(txin=txin, swap=swap, config=self.wallet.config) + tx = create_claim_tx(txin=txin, swap=swap, fee_policy=self.fee_policy, network=self.network) except BelowDustLimit: self.logger.info('utxo value below dust threshold') return @@ -465,11 +468,11 @@ class SwapManager(Logger): def get_fee(self, size): # note: 'size' is in vbytes - return self._get_fee(size=size, config=self.wallet.config) + return self._get_fee(size=size, fee_policy=self.fee_policy, network=self.network) @classmethod - def _get_fee(cls, *, size, config: 'SimpleConfig'): - return config.estimate_fee(size, allow_fallback_to_static_rates=True) + def _get_fee(cls, *, size, fee_policy: FeePolicy, network: 'Network'): + return fee_policy.estimate_fee(size, network=network, allow_fallback_to_static_rates=True) def get_swap(self, payment_hash: bytes) -> Optional[SwapData]: # for history @@ -818,6 +821,7 @@ class SwapManager(Logger): outputs=[funding_output], rbf=True, password=password, + fee_policy=self.fee_policy, ) else: tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address) @@ -1101,11 +1105,10 @@ class SwapManager(Logger): @classmethod def create_claim_txin( - cls, - *, - txin: PartialTxInput, - swap: SwapData, - config: 'SimpleConfig', + cls, + *, + txin: PartialTxInput, + swap: SwapData, ) -> PartialTransaction: if swap.is_reverse: # successful reverse swap locktime = 0 diff --git a/electrum/wallet.py b/electrum/wallet.py index bbb5c83b6..596a53d52 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -59,7 +59,8 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, OldTaskGroup, ignore WalletFileException, BitcoinException, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, Fiat, bfh, TxMinedInfo, quantize_feerate, OrderedDictWithIndex) -from .simple_config import SimpleConfig, FEE_RATIO_HIGH_WARNING, FEERATE_WARNING_HIGH_FEE +from .simple_config import SimpleConfig +from .fee_policy import FeePolicy, FeeMethod, FEE_RATIO_HIGH_WARNING, FEERATE_WARNING_HIGH_FEE from .bitcoin import COIN, TYPE_ADDRESS from .bitcoin import is_address, address_to_script, is_minikey, relayfee, dust_threshold from .bitcoin import DummyAddress, DummyAddressUsedInTxException @@ -177,30 +178,24 @@ async def sweep( privkeys: Iterable[str], *, network: 'Network', - config: 'SimpleConfig', to_address: str, - fee: int = None, + fee_policy: FeePolicy, imax=100, locktime=None, tx_version=None) -> PartialTransaction: inputs, keypairs = await sweep_preparations(privkeys, network, imax) total = sum(txin.value_sats() for txin in inputs) - if fee is None: - outputs = [PartialTxOutput(scriptpubkey=bitcoin.address_to_script(to_address), - value=total)] - tx = PartialTransaction.from_io(inputs, outputs) - fee = config.estimate_fee(tx.estimated_size()) + outputs = [PartialTxOutput(scriptpubkey=bitcoin.address_to_script(to_address), value=total)] + tx = PartialTransaction.from_io(inputs, outputs) + fee = fee_policy.estimate_fee(tx.estimated_size(), network=network) if total - fee < 0: raise Exception(_('Not enough funds on address.') + '\nTotal: %d satoshis\nFee: %d'%(total, fee)) if total - fee < dust_threshold(network): raise Exception(_('Not enough funds on address.') + '\nTotal: %d satoshis\nFee: %d\nDust Threshold: %d'%(total, fee, dust_threshold(network))) - - outputs = [PartialTxOutput(scriptpubkey=bitcoin.address_to_script(to_address), - value=total - fee)] + outputs = [PartialTxOutput(scriptpubkey=bitcoin.address_to_script(to_address), value=total - fee)] if locktime is None: locktime = get_locktime_for_new_transaction(network) - tx = PartialTransaction.from_io(inputs, outputs, locktime=locktime, version=tx_version) tx.set_rbf(True) tx.sign(keypairs) @@ -941,10 +936,10 @@ class Abstract_Wallet(ABC, Logger, EventListener): status = _('Unconfirmed') if fee is None: fee = self.adb.get_tx_fee(tx_hash) - if fee and self.network and self.config.has_fee_mempool(): + if fee and self.network and self.network.has_fee_mempool(): size = tx.estimated_size() fee_per_byte = fee / size - exp_n = self.config.fee_to_depth(fee_per_byte) + exp_n = self.network.mempool_fees.fee_to_depth(fee_per_byte) can_bump = (is_any_input_ismine or is_swap) and self.can_rbf_tx(tx) can_dscancel = (is_any_input_ismine and self.can_rbf_tx(tx, is_dscancel=True) and not all([self.is_mine(txout.address) for txout in tx.outputs()])) @@ -1697,10 +1692,10 @@ class Abstract_Wallet(ABC, Logger, EventListener): fee_per_byte = fee / size extra.append(format_fee_satoshis(fee_per_byte) + f" {util.UI_UNIT_NAME_FEERATE_SAT_PER_VB}") if fee is not None and height in (TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED) \ - and self.config.has_fee_mempool(): - exp_n = self.config.fee_to_depth(fee_per_byte) + and self.network and self.network.has_fee_mempool(): + exp_n = self.network.mempool_fees.fee_to_depth(fee_per_byte) if exp_n is not None: - extra.append(self.config.get_depth_mb_str(exp_n)) + extra.append(FeePolicy.get_depth_mb_str(exp_n)) if height == TX_HEIGHT_LOCAL: status = 3 elif height == TX_HEIGHT_UNCONF_PARENT: @@ -1823,24 +1818,13 @@ class Abstract_Wallet(ABC, Logger, EventListener): assert is_address(selected_addr), f"not valid bitcoin address: {selected_addr}" return selected_addr - def can_pay_onchain(self, outputs, coins=None): - fee = partial(self.config.estimate_fee, allow_fallback_to_static_rates=True) # to avoid NoDynamicFeeEstimates - try: - self.make_unsigned_transaction( - coins=coins, - outputs=outputs, - fee=fee) - except NotEnoughFunds: - return False - return True - @profiler(min_threshold=0.1) def make_unsigned_transaction( self, *, coins: Sequence[PartialTxInput], outputs: List[PartialTxOutput], inputs: Optional[List[PartialTxInput]] = None, - fee=None, + fee_policy: FeePolicy = None, change_addr: str = None, is_sweep: bool = False, # used by Wallet_2fa subclass rbf: Optional[bool] = True, @@ -1876,7 +1860,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): i_max_sum += weight i_max.append((weight, i)) - if fee is None and self.config.fee_per_kb() is None: + if fee_policy.method is not FeeMethod.FIXED and fee_policy.fee_per_kb(self.network) is None: raise NoDynamicFeeEstimates() for txin in coins: @@ -1884,15 +1868,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): nSequence = 0xffffffff - (2 if rbf else 1) txin.nsequence = nSequence - # Fee estimator - if fee is None: - fee_estimator = self.config.estimate_fee - elif isinstance(fee, Number): - fee_estimator = lambda size: fee - elif callable(fee): - fee_estimator = fee - else: - raise Exception(f'Invalid argument fee: {fee}') + fee_estimator = partial(fee_policy.estimate_fee, network=self.network) # set if we merge with another transaction rbf_merge_txid = None @@ -2217,7 +2193,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): for item in coins: self.add_input_info(item) def fee_estimator(size): - return self.config.estimate_fee_for_feerate(fee_per_kb=new_fee_rate*1000, size=size) + return FeePolicy.estimate_fee_for_feerate(fee_per_kb=new_fee_rate*1000, size=size) coin_chooser = coinchooser.get_coin_chooser(self.config) try: return coin_chooser.make_tx( @@ -3100,41 +3076,33 @@ class Abstract_Wallet(ABC, Logger, EventListener): pass def create_transaction( - self, - outputs, - *, - fee=None, - feerate=None, - change_addr=None, - domain_addr=None, - domain_coins=None, - sign=True, - rbf=True, - password=None, - locktime=None, - tx_version: Optional[int] = None, - base_tx: Optional[PartialTransaction] = None, - inputs: Optional[List[PartialTxInput]] = None, - send_change_to_lightning: Optional[bool] = None, - nonlocal_only: bool = False, - BIP69_sort: bool = True, + self, + outputs, + *, + fee_policy: FeePolicy=None, + change_addr=None, + domain_addr=None, + domain_coins=None, + sign=True, + rbf=True, + password=None, + locktime=None, + tx_version: Optional[int] = None, + base_tx: Optional[PartialTransaction] = None, + inputs: Optional[List[PartialTxInput]] = None, + send_change_to_lightning: Optional[bool] = None, + nonlocal_only: bool = False, + BIP69_sort: bool = True, ) -> PartialTransaction: """Helper function for make_unsigned_transaction.""" - if fee is not None and feerate is not None: - raise UserFacingException("Cannot specify both 'fee' and 'feerate' at the same time!") coins = self.get_spendable_coins(domain_addr, nonlocal_only=nonlocal_only) if domain_coins is not None: coins = [coin for coin in coins if (coin.prevout.to_str() in domain_coins)] - if feerate is not None: - fee_per_kb = 1000 * Decimal(feerate) - fee_estimator = partial(SimpleConfig.estimate_fee_for_feerate, fee_per_kb) - else: - fee_estimator = fee tx = self.make_unsigned_transaction( coins=coins, inputs=inputs, outputs=outputs, - fee=fee_estimator, + fee_policy=fee_policy, change_addr=change_addr, base_tx=base_tx, send_change_to_lightning=send_change_to_lightning, diff --git a/tests/test_fee_policy.py b/tests/test_fee_policy.py new file mode 100644 index 000000000..ee24dad69 --- /dev/null +++ b/tests/test_fee_policy.py @@ -0,0 +1,55 @@ +from electrum.fee_policy import FeeHistogram + +from . import ElectrumTestCase + + +class Test_FeeHistogram(ElectrumTestCase): + + def setUp(self): + super(Test_FeeHistogram, self).setUp() + + def tearDown(self): + super(Test_FeeHistogram, self).tearDown() + + def test_depth_target_to_fee(self): + mempool_fees = FeeHistogram() + mempool_fees.set_data([[49, 100110], [10, 121301], [6, 153731], [5, 125872], [1, 36488810]]) + self.assertEqual( 2 * 1000, mempool_fees.depth_target_to_fee(1000000)) + self.assertEqual( 6 * 1000, mempool_fees.depth_target_to_fee( 500000)) + self.assertEqual( 7 * 1000, mempool_fees.depth_target_to_fee( 250000)) + self.assertEqual(11 * 1000, mempool_fees.depth_target_to_fee( 200000)) + self.assertEqual(50 * 1000, mempool_fees.depth_target_to_fee( 100000)) + mempool_fees.set_data([]) + self.assertEqual( 1 * 1000, mempool_fees.depth_target_to_fee(10 ** 5)) + self.assertEqual( 1 * 1000, mempool_fees.depth_target_to_fee(10 ** 6)) + self.assertEqual( 1 * 1000, mempool_fees.depth_target_to_fee(10 ** 7)) + mempool_fees.set_data([[1, 36488810]]) + self.assertEqual( 2 * 1000, mempool_fees.depth_target_to_fee(10 ** 5)) + self.assertEqual( 2 * 1000, mempool_fees.depth_target_to_fee(10 ** 6)) + self.assertEqual( 2 * 1000, mempool_fees.depth_target_to_fee(10 ** 7)) + self.assertEqual( 1 * 1000, mempool_fees.depth_target_to_fee(10 ** 8)) + mempool_fees.set_data([[5, 125872], [1, 36488810]]) + self.assertEqual( 6 * 1000, mempool_fees.depth_target_to_fee(10 ** 5)) + self.assertEqual( 2 * 1000, mempool_fees.depth_target_to_fee(10 ** 6)) + self.assertEqual( 2 * 1000, mempool_fees.depth_target_to_fee(10 ** 7)) + self.assertEqual( 1 * 1000, mempool_fees.depth_target_to_fee(10 ** 8)) + mempool_fees.set_data([]) + self.assertEqual(1 * 1000, mempool_fees.depth_target_to_fee(10 ** 5)) + mempool_fees.set_data(None) + self.assertEqual(None, mempool_fees.depth_target_to_fee(10 ** 5)) + + def test_fee_to_depth(self): + mempool_fees = FeeHistogram() + mempool_fees.set_data([[49, 100000], [10, 120000], [6, 150000], [5, 125000], [1, 36000000]]) + self.assertEqual(100000, mempool_fees.fee_to_depth(500)) + self.assertEqual(100000, mempool_fees.fee_to_depth(50)) + self.assertEqual(100000, mempool_fees.fee_to_depth(49)) + self.assertEqual(220000, mempool_fees.fee_to_depth(48)) + self.assertEqual(220000, mempool_fees.fee_to_depth(10)) + self.assertEqual(370000, mempool_fees.fee_to_depth(9)) + self.assertEqual(370000, mempool_fees.fee_to_depth(6.5)) + self.assertEqual(370000, mempool_fees.fee_to_depth(6)) + self.assertEqual(495000, mempool_fees.fee_to_depth(5.5)) + self.assertEqual(36495000, mempool_fees.fee_to_depth(0.5)) + + diff --git a/tests/test_invoices.py b/tests/test_invoices.py index 7011dac4d..20accb917 100644 --- a/tests/test_invoices.py +++ b/tests/test_invoices.py @@ -9,7 +9,7 @@ from electrum.invoices import PR_UNPAID, PR_PAID, PR_UNCONFIRMED, BaseInvoice, I from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED from electrum.transaction import Transaction, PartialTxOutput from electrum.util import TxMinedInfo, InvoiceError - +from electrum.fee_policy import FixedFeePolicy class TestWalletPaymentRequests(ElectrumTestCase): """test 'incoming' invoices""" @@ -72,7 +72,7 @@ class TestWalletPaymentRequests(ElectrumTestCase): # get paid onchain wallet2 = self.create_wallet2() # type: Standard_Wallet outputs = [PartialTxOutput.from_address_and_value(pr.get_address(), pr.get_amount_sat())] - tx = wallet2.create_transaction(outputs=outputs, fee=5000) + tx = wallet2.create_transaction(outputs=outputs, fee_policy=FixedFeePolicy(5000)) wallet1.adb.receive_tx_callback(tx, TX_HEIGHT_UNCONFIRMED) self.assertEqual(PR_UNCONFIRMED, wallet1.get_invoice_status(pr)) # tx gets mined @@ -102,7 +102,7 @@ class TestWalletPaymentRequests(ElectrumTestCase): # get paid onchain wallet2 = self.create_wallet2() # type: Standard_Wallet outputs = [PartialTxOutput.from_address_and_value(pr.get_address(), pr.get_amount_sat())] - tx = wallet2.create_transaction(outputs=outputs, fee=5000) + tx = wallet2.create_transaction(outputs=outputs, fee_policy=FixedFeePolicy(5000)) wallet1.adb.receive_tx_callback(tx, TX_HEIGHT_UNCONFIRMED) self.assertEqual(PR_UNCONFIRMED, wallet1.get_invoice_status(pr)) # tx gets mined @@ -132,7 +132,7 @@ class TestWalletPaymentRequests(ElectrumTestCase): # get paid onchain wallet2 = self.create_wallet2() # type: Standard_Wallet outputs = [PartialTxOutput.from_address_and_value(pr.get_address(), pr.get_amount_sat())] - tx = wallet2.create_transaction(outputs=outputs, fee=5000) + tx = wallet2.create_transaction(outputs=outputs, fee_policy=FixedFeePolicy(5000)) wallet1.adb.receive_tx_callback(tx, TX_HEIGHT_UNCONFIRMED) self.assertEqual(PR_UNCONFIRMED, wallet1.get_invoice_status(pr)) # tx mined in the past (before invoice creation) @@ -201,7 +201,7 @@ class TestWalletPaymentRequests(ElectrumTestCase): # pr2 gets paid onchain wallet2 = self.create_wallet2() # type: Standard_Wallet outputs = [PartialTxOutput.from_address_and_value(pr2.get_address(), pr2.get_amount_sat())] - tx = wallet2.create_transaction(outputs=outputs, fee=5000) + tx = wallet2.create_transaction(outputs=outputs, fee_policy=FixedFeePolicy(5000)) wallet1.adb.receive_tx_callback(tx, TX_HEIGHT_UNCONFIRMED) self.assertEqual(PR_UNCONFIRMED, wallet1.get_invoice_status(pr2)) self.assertEqual(pr2, wallet1.get_request_by_addr(addr1)) diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 1ce1dfcc7..f952f091b 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -44,7 +44,7 @@ from electrum.lnutil import LOCAL, REMOTE from electrum.invoices import PR_PAID, PR_UNPAID from electrum.interface import GracefulDisconnect from electrum.simple_config import SimpleConfig - +from electrum.fee_policy import FeeTimeEstimates from .test_lnchannel import create_test_channels @@ -68,6 +68,7 @@ class MockNetwork: self.callbacks = defaultdict(list) self.lnwatcher = None self.interface = None + self.fee_estimates = FeeTimeEstimates() self.config = config self.asyncio_loop = util.get_asyncio_loop() self.channel_db = ChannelDB(self) @@ -1282,10 +1283,8 @@ class TestPeerDirect(TestPeer): bob_channel.config[HTLCOwner.LOCAL].upfront_shutdown_script = b'' p1, p2, w1, w2, q1, q2 = self.prepare_peers(alice_channel, bob_channel) - w1.network.config.FEE_EST_DYNAMIC = False - w2.network.config.FEE_EST_DYNAMIC = False - w1.network.config.FEE_EST_STATIC_FEERATE = 5000 - w2.network.config.FEE_EST_STATIC_FEERATE = 1000 + w1.network.config.FEE_POLICY = 'feerate:5000' + w2.network.config.FEE_POLICY = 'feerate:1000' async def test(): async def close(): @@ -1316,10 +1315,8 @@ class TestPeerDirect(TestPeer): bob_channel.config[HTLCOwner.LOCAL].upfront_shutdown_script = bob_uss p1, p2, w1, w2, q1, q2 = self.prepare_peers(alice_channel, bob_channel) - w1.network.config.FEE_EST_DYNAMIC = False - w2.network.config.FEE_EST_DYNAMIC = False - w1.network.config.FEE_EST_STATIC_FEERATE = 5000 - w2.network.config.FEE_EST_STATIC_FEERATE = 1000 + w1.network.config.FEE_POLICY = 'feerate:5000' + w2.network.config.FEE_POLICY = 'feerate:1000' async def test(): async def close(): diff --git a/tests/test_simple_config.py b/tests/test_simple_config.py index dc79b21bb..e87d197ce 100644 --- a/tests/test_simple_config.py +++ b/tests/test_simple_config.py @@ -203,46 +203,6 @@ class Test_SimpleConfig(ElectrumTestCase): with self.assertRaises(KeyError): config.cv.from_key("server333") - def test_depth_target_to_fee(self): - config = SimpleConfig(self.options) - config.mempool_fees = [[49, 100110], [10, 121301], [6, 153731], [5, 125872], [1, 36488810]] - self.assertEqual( 2 * 1000, config.depth_target_to_fee(1000000)) - self.assertEqual( 6 * 1000, config.depth_target_to_fee( 500000)) - self.assertEqual( 7 * 1000, config.depth_target_to_fee( 250000)) - self.assertEqual(11 * 1000, config.depth_target_to_fee( 200000)) - self.assertEqual(50 * 1000, config.depth_target_to_fee( 100000)) - config.mempool_fees = [] - self.assertEqual( 1 * 1000, config.depth_target_to_fee(10 ** 5)) - self.assertEqual( 1 * 1000, config.depth_target_to_fee(10 ** 6)) - self.assertEqual( 1 * 1000, config.depth_target_to_fee(10 ** 7)) - config.mempool_fees = [[1, 36488810]] - self.assertEqual( 2 * 1000, config.depth_target_to_fee(10 ** 5)) - self.assertEqual( 2 * 1000, config.depth_target_to_fee(10 ** 6)) - self.assertEqual( 2 * 1000, config.depth_target_to_fee(10 ** 7)) - self.assertEqual( 1 * 1000, config.depth_target_to_fee(10 ** 8)) - config.mempool_fees = [[5, 125872], [1, 36488810]] - self.assertEqual( 6 * 1000, config.depth_target_to_fee(10 ** 5)) - self.assertEqual( 2 * 1000, config.depth_target_to_fee(10 ** 6)) - self.assertEqual( 2 * 1000, config.depth_target_to_fee(10 ** 7)) - self.assertEqual( 1 * 1000, config.depth_target_to_fee(10 ** 8)) - config.mempool_fees = [] - self.assertEqual(1 * 1000, config.depth_target_to_fee(10 ** 5)) - config.mempool_fees = None - self.assertEqual(None, config.depth_target_to_fee(10 ** 5)) - - def test_fee_to_depth(self): - config = SimpleConfig(self.options) - config.mempool_fees = [[49, 100000], [10, 120000], [6, 150000], [5, 125000], [1, 36000000]] - self.assertEqual(100000, config.fee_to_depth(500)) - self.assertEqual(100000, config.fee_to_depth(50)) - self.assertEqual(100000, config.fee_to_depth(49)) - self.assertEqual(220000, config.fee_to_depth(48)) - self.assertEqual(220000, config.fee_to_depth(10)) - self.assertEqual(370000, config.fee_to_depth(9)) - self.assertEqual(370000, config.fee_to_depth(6.5)) - self.assertEqual(370000, config.fee_to_depth(6)) - self.assertEqual(495000, config.fee_to_depth(5.5)) - self.assertEqual(36495000, config.fee_to_depth(0.5)) class TestUserConfig(ElectrumTestCase): diff --git a/tests/test_sswaps.py b/tests/test_sswaps.py index 22fd8c115..ecae7b6d6 100644 --- a/tests/test_sswaps.py +++ b/tests/test_sswaps.py @@ -2,6 +2,7 @@ from electrum import SimpleConfig from electrum.util import bfh from electrum.transaction import PartialTxInput, TxOutpoint from electrum.submarine_swaps import SwapData, create_claim_tx +from electrum.fee_policy import FeePolicy from . import ElectrumTestCase @@ -13,8 +14,7 @@ class TestSwapTxs(ElectrumTestCase): super().setUp() self.maxDiff = None self.config = SimpleConfig({'electrum_path': self.electrum_path}) - self.config.FEE_EST_DYNAMIC = False - self.config.FEE_EST_STATIC_FEERATE = 1000 + self.fee_policy = FeePolicy('feerate:1000') def test_claim_tx_for_successful_reverse_swap(self): swap_data = SwapData( @@ -39,7 +39,8 @@ class TestSwapTxs(ElectrumTestCase): tx = create_claim_tx( txin=txin, swap=swap_data, - config=self.config, + fee_policy=self.fee_policy, + network=None, ) self.assertEqual( "02000000000101f9db8580febd5c0f85b6f1576c83f7739109e3a2d772743e3217e9537fea7e890000000000fdffffff019007030000000000160014fbfad1ca8741ce640a3ea130bd4478fdd8a2dd8f03473044022025506044aba4939f4f2faa94710673ca65530a621f1fa538a3d046dc98bb685e02205f8d463dc6f81e1083f26fa963e581dabc80ea42f8cd59c9e31f3bf531168a9c0120f1939b5723155713855d7ebea6e174f77d41d669269e7f138856c3de190e7a366a8201208763a914d7a62ef0270960fe23f0f351b28caadab62c21838821030bfd61153816df786036ea293edce851d3a4b9f4a1c66bdc1a17f00ffef3d6b167750334ef24b1752102fc8128f17f9e666ea281c702171ab16c1dd2a4337b71f08970f5aa10c608a93268ac00000000", @@ -69,7 +70,8 @@ class TestSwapTxs(ElectrumTestCase): tx = create_claim_tx( txin=txin, swap=swap_data, - config=self.config, + fee_policy=self.fee_policy, + network=None, ) self.assertEqual( "0200000000010106871505e5f6dc76f406f38e34e29b54908c6b54da978c28c18fb39ab1dcec080000000000fdffffff013afb01000000000016001497b4b718e7d06c9c43cd3bcf37905041b718b81f0347304402200ae708af1393f785c541bbc4d7351791b76a53077a292b71cb2a25ad13a15f9902206b7b91c414ec0d6e5098a1acc26de4b47f3aac414b7a49741e8f27cc6a967a19010065a914b12bd886ef4fd9ef1c03e899123f2c4b96cec0878763210267ca676c2ed05bb6c380880f1e50b6ef91025dfa963dc49d6c5cb9848f2acf7d670339ef24b1752103d8190cdfcc7dd929a583b7ea8fa8eb1d8463195d336be2f2df94f950ce8b659968ac39ef2400", diff --git a/tests/test_wallet_vertical.py b/tests/test_wallet_vertical.py index 2b3fde780..77e2d2528 100644 --- a/tests/test_wallet_vertical.py +++ b/tests/test_wallet_vertical.py @@ -16,6 +16,7 @@ from electrum.wallet import (sweep, Multisig_Wallet, Standard_Wallet, Imported_W TransactionPotentiallyDangerousException, TransactionDangerousException, TxSighashRiskLevel) from electrum.util import bfh, NotEnoughFunds, UnrelatedTransactionException, UserFacingException, TxMinedInfo +from electrum.fee_policy import FixedFeePolicy from electrum.transaction import Transaction, PartialTxOutput, tx_from_any, Sighash from electrum.mnemonic import calc_seed_type from electrum.network import Network @@ -872,7 +873,7 @@ class TestWalletSending(ElectrumTestCase): # wallet1 -> wallet2 outputs = [PartialTxOutput.from_address_and_value(wallet2.get_receiving_address(), 250000)] - tx = wallet1.create_transaction(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False) + tx = wallet1.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), tx_version=1, rbf=False) self.assertTrue(tx.is_complete()) self.assertTrue(tx.is_segwit()) @@ -891,7 +892,7 @@ class TestWalletSending(ElectrumTestCase): # wallet2 -> wallet1 outputs = [PartialTxOutput.from_address_and_value(wallet1.get_receiving_address(), 100000)] - tx = wallet2.create_transaction(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False) + tx = wallet2.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), tx_version=1, rbf=False) self.assertTrue(tx.is_complete()) self.assertFalse(tx.is_segwit()) @@ -944,7 +945,7 @@ class TestWalletSending(ElectrumTestCase): # wallet1 -> wallet2 outputs = [PartialTxOutput.from_address_and_value(wallet2.get_receiving_address(), 370000)] - tx = wallet1a.create_transaction(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False) + tx = wallet1a.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), tx_version=1, rbf=False) partial_tx = tx.serialize_as_bytes().hex() self.assertEqual("70736274ff01007501000000017120d4e1f2cdfe7df000d632cff74167fb354f0546d5cfc228e5c98756d55cb20100000000feffffff0250a50500000000001976a9149cd3dfb0d87a861770ae4e268e74b45335cf00ab88ac2862b1000000000017a9142e517854aa54668128c0e9a3fdd4dec13ad571368700000000000100e0010000000001014121f99dc02f0364d2dab3d08905ff4c36fc76c55437fd90b769c35cc18618280100000000fdffffff02d4c22d00000000001600143fd1bc5d32245850c8cb5be5b09c73ccbb9a0f75001bb7000000000017a91480c2353f6a7bc3c71e99e062655b19adb3dd2e4887024830450221008781c78df0c9d4b5ea057333195d5d76bc29494d773f14fa80e27d2f288b2c360220762531614799b6f0fb8d539b18cb5232ab4253dd4385435157b28a44ff63810d0121033de77d21926e09efd04047ae2d39dbd3fb9db446e8b7ed53e0f70f9c9478f735dac11300220202afb4af9a91264e1c6dce3ebe5312801723270ac0ba8134b7b49129328fcb0f284730440220751ee3599e59debb8b2aeef61bb5f574f26379cd961caf382d711a507bc632390220598d53e62557c4a5ab8cfb2f8948f37cca06a861714b55c781baf2c3d7a580b501010469522102afb4af9a91264e1c6dce3ebe5312801723270ac0ba8134b7b49129328fcb0f2821030b482838721a38d94847699fed8818b5c5f56500ef72f13489e365b65e5749cf2103e5db7969ae2f2576e6a061bf3bb2db16571e77ffb41e0b27170734359235cbce53ae220602afb4af9a91264e1c6dce3ebe5312801723270ac0ba8134b7b49129328fcb0f280c0036e9ac00000000000000002206030b482838721a38d94847699fed8818b5c5f56500ef72f13489e365b65e5749cf0c48adc7a00000000000000000220603e5db7969ae2f2576e6a061bf3bb2db16571e77ffb41e0b27170734359235cbce0cdb692427000000000000000000000100695221022ec6f62b0f3b7c2446f44346bff0a6f06b5fdbc27368be8a36478e0287fe47be21024238f21f90527dc87e945f389f3d1711943b06f0a738d5baab573fc0ab6c98582102b7139e93747d7c77f62af5a38b8a2b009f3456aa94dea9bf21f73a6298c867a253ae2202022ec6f62b0f3b7c2446f44346bff0a6f06b5fdbc27368be8a36478e0287fe47be0cdb69242701000000000000002202024238f21f90527dc87e945f389f3d1711943b06f0a738d5baab573fc0ab6c98580c0036e9ac0100000000000000220202b7139e93747d7c77f62af5a38b8a2b009f3456aa94dea9bf21f73a6298c867a20c48adc7a0010000000000000000", partial_tx) @@ -969,7 +970,7 @@ class TestWalletSending(ElectrumTestCase): # wallet2 -> wallet1 outputs = [PartialTxOutput.from_address_and_value(wallet1a.get_receiving_address(), 100000)] - tx = wallet2.create_transaction(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False, sign=False) + tx = wallet2.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), tx_version=1, rbf=False, sign=False) self.assertEqual( "pkh(045f7ba332df2a7b4f5d13f246e307c9174cfa9b8b05f3b83410a3c23ef8958d610be285963d67c7bc1feb082f168fa9877c25999963ff8b56b242a852b23e25ed)", tx.inputs()[0].script_descriptor.to_string_no_checksum()) @@ -1043,7 +1044,7 @@ class TestWalletSending(ElectrumTestCase): # wallet1 -> wallet2 outputs = [PartialTxOutput.from_address_and_value(wallet2a.get_receiving_address(), 165000)] - tx = wallet1a.create_transaction(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False, sign=False) + tx = wallet1a.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), tx_version=1, rbf=False, sign=False) self.assertEqual((0, 2), tx.signature_count()) self.assertEqual( "wsh(sortedmulti(2,[b2e35a7d/1h]tpubD9aPYLPPYw8MxU3cD57LwpV5v7GomHxdv62MSbPcRkp47zwXx69ACUFsKrj8xzuzRrij9FWVhfvkvNqtqsr8ZtefkDsGZ9GLuHzoS6bXyk1/0/0,[53b77ddb/1h]tpubD8spLJysN7v7V1KHvkZ7AwjnXShKafopi7Vu3Ahs2S46FxBPTode8DgGxDo55k4pJvETGScZFwnM5f2Y31EUjteJdhxR73sjr9ieydgah2U/0/0,[43067d63/1h]tpubD8khd1g1tzFeKeaU59QV811hyvhwn9KDfy5sqFJ5m2wJLw6rUt4AZviqutRPXTUAK4SpU2we3y2WBP916Ma8Em4qFGcbYkFvXVfpGYV3oZR/0/0))", @@ -1078,7 +1079,7 @@ class TestWalletSending(ElectrumTestCase): # wallet2 -> wallet1 outputs = [PartialTxOutput.from_address_and_value(wallet1a.get_receiving_address(), 100000)] - tx = wallet2a.create_transaction(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False) + tx = wallet2a.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), tx_version=1, rbf=False) self.assertEqual((1, 2), tx.signature_count()) self.assertEqual( "sh(wsh(sortedmulti(2,[d1dbcc21]tpubDDsv4RpsGViZeEVwivuj3aaKhFQSv1kYsz64mwRoHkqBfw8qBSYEmc8TtyVGotJb44V3pviGzefP9m9hidRg9dPPaDWL2yoRpMW3hdje3Rk/0/0,[17cea914]tpubDCZU2kACPGACYDvAXvZUXQ7cE7msFfCtpah5QCuaz8iarKMLTgR4c2u8RGKdFhbb3YJxzmktDd1rCtF58ksyVgFw28pchY55uwkDiXjY9hU/0/0)))", @@ -1140,7 +1141,7 @@ class TestWalletSending(ElectrumTestCase): # wallet1 -> wallet2 outputs = [PartialTxOutput.from_address_and_value(wallet2.get_receiving_address(), 1000000)] - tx = wallet1a.create_transaction(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False) + tx = wallet1a.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), tx_version=1, rbf=False) self.assertTrue(tx.is_complete()) self.assertFalse(tx.is_segwit()) @@ -1159,7 +1160,7 @@ class TestWalletSending(ElectrumTestCase): # wallet2 -> wallet1 outputs = [PartialTxOutput.from_address_and_value(wallet1a.get_receiving_address(), 300000)] - tx = wallet2.create_transaction(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False) + tx = wallet2.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), tx_version=1, rbf=False) self.assertTrue(tx.is_complete()) self.assertTrue(tx.is_segwit()) @@ -1266,7 +1267,7 @@ class TestWalletSending(ElectrumTestCase): # create tx outputs = [PartialTxOutput.from_address_and_value('2N1VTMMFb91SH9SNRAkT7z8otP5eZEct4KL', 2500000)] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 1325501 tx.version = 1 @@ -1526,7 +1527,7 @@ class TestWalletSending(ElectrumTestCase): # create tx outputs = [PartialTxOutput.from_address_and_value('2N1VTMMFb91SH9SNRAkT7z8otP5eZEct4KL', 2500000)] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 1325499 tx.version = 1 @@ -1706,7 +1707,7 @@ class TestWalletSending(ElectrumTestCase): # create tx outputs = [PartialTxOutput.from_address_and_value('tb1q7rl9cxr85962ztnsze089zs8ycv52hk43f3m9n', '!')] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 1325499 if simulate_moving_txs: @@ -1767,7 +1768,7 @@ class TestWalletSending(ElectrumTestCase): # create tx outputs = [PartialTxOutput.from_address_and_value('2N1VTMMFb91SH9SNRAkT7z8otP5eZEct4KL', '!')] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 1325499 tx.version = 1 @@ -1831,7 +1832,7 @@ class TestWalletSending(ElectrumTestCase): # create tx outputs = [PartialTxOutput.from_address_and_value('2N1VTMMFb91SH9SNRAkT7z8otP5eZEct4KL', '!')] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 1325499 tx.version = 1 @@ -1904,7 +1905,7 @@ class TestWalletSending(ElectrumTestCase): # create tx outputs = [PartialTxOutput.from_address_and_value('2N1VTMMFb91SH9SNRAkT7z8otP5eZEct4KL', 2_500_000)] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 1325499 tx.version = 1 @@ -1943,7 +1944,7 @@ class TestWalletSending(ElectrumTestCase): # no new input will be needed. just a new output, and change decreased. outputs = [PartialTxOutput.from_address_and_value('tb1qy6xmdj96v5dzt3j08hgc05yk3kltqsnmw4r6ry', 2_500_000)] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=20000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(20000)) tx.set_rbf(True) tx.locktime = 1325499 tx.version = 1 @@ -1974,7 +1975,7 @@ class TestWalletSending(ElectrumTestCase): # new input will be needed! outputs = [PartialTxOutput.from_address_and_value('2NCVwbmEpvaXKHpXUGJfJr9iB5vtRN3vcut', 6_000_000)] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=100_000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(100000)) tx.set_rbf(True) tx.locktime = 1325499 tx.version = 1 @@ -2028,7 +2029,7 @@ class TestWalletSending(ElectrumTestCase): self.assertEqual(2, len(coins)) wallet.config.WALLET_BATCH_RBF = batch_rbf - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=1000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(1000)) tx.set_rbf(True) tx.locktime = 2423302 tx.version = 2 @@ -2051,7 +2052,7 @@ class TestWalletSending(ElectrumTestCase): # first payment to dest_addr outputs1 = [PartialTxOutput.from_address_and_value(dest_addr, 200_000)] coins = wallet.get_spendable_coins(domain=None) - tx1 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs1, fee=2000) + tx1 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs1, fee_policy=FixedFeePolicy(2000)) tx1.set_rbf(True) tx1.locktime = 2534850 tx1.version = 2 @@ -2066,7 +2067,7 @@ class TestWalletSending(ElectrumTestCase): # second payment to dest_addr (merged) outputs2 = [PartialTxOutput.from_address_and_value(dest_addr, 100_000)] coins = wallet.get_spendable_coins(domain=None) - tx2 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs2, fee=3000) + tx2 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs2, fee_policy=FixedFeePolicy(3000)) tx2.set_rbf(True) tx2.locktime = 2534850 tx2.version = 2 @@ -2085,7 +2086,7 @@ class TestWalletSending(ElectrumTestCase): # second payment to dest_addr (not merged, just duplicate outputs) outputs2 = [PartialTxOutput.from_address_and_value(dest_addr, 100_000)] coins = wallet.get_spendable_coins(domain=None) - tx3 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs2, fee=3000) + tx3 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs2, fee_policy=FixedFeePolicy(3000)) tx3.set_rbf(True) tx3.locktime = 2534850 tx3.version = 2 @@ -2165,7 +2166,7 @@ class TestWalletSending(ElectrumTestCase): privkeys = ['93NQ7CFbwTPyKDJLXe97jczw33fiLijam2SCZL3Uinz1NSbHrTu',] network = NetworkMock() dest_addr = 'tb1q3ws2p0qjk5vrravv065xqlnkckvzcpclk79eu2' - tx = await sweep(privkeys, network=network, config=self.config, to_address=dest_addr, fee=5000, locktime=1325785, tx_version=1) + tx = await sweep(privkeys, network=network, to_address=dest_addr, fee_policy=FixedFeePolicy(5000), locktime=1325785, tx_version=1) tx_copy = tx_from_any(tx.serialize()) self.assertEqual('010000000129349e5641d79915e9d0282fdbaee8c3df0b6731bab9d70bf626e8588bde24ac010000004847304402206bf0d0a93abae0d5873a62ebf277a5dd2f33837821e8b93e74d04e19d71b578002201a6d729bc159941ef5c4c9e5fe13ece9fc544351ba531b00f68ba549c8b38a9a01fdffffff01b82e0f00000000001600148ba0a0bc12b51831f58c7ea8607e76c5982c071fd93a1400', @@ -2190,7 +2191,7 @@ class TestWalletSending(ElectrumTestCase): privkeys = ['cUygTZe4jZLVwE4G44NznCPTeGvgsgassqucUHkAJxGC71Rst2kH',] network = NetworkMock() dest_addr = 'tb1q5uy5xjcn55gwdkmghht8yp3vwz3088f6e3e0em' - tx = await sweep(privkeys, network=network, config=self.config, to_address=dest_addr, fee=5000, locktime=2420006, tx_version=2) + tx = await sweep(privkeys, network=network, to_address=dest_addr, fee_policy=FixedFeePolicy(5000), locktime=2420006, tx_version=2) tx_copy = tx_from_any(tx.serialize()) self.assertEqual('02000000015eb359ccfcd67c3e6b10bb937a796807007708c1f413840d0e627a3f94a1a48401000000484730440220043fc85a43e918ac41e494e309fdf204ca245d260cb5ea09108b196ca65d8a09022056f852f0f521e79ab2124d7e9f779c7290329ce5628ef8e92601980b065d3eb501fdffffff017f9e010000000000160014a709434b13a510e6db68bdd672062c70a2f39d3a26ed2400', @@ -2215,7 +2216,7 @@ class TestWalletSending(ElectrumTestCase): privkeys = ['p2pkh:91gxDahzHiJ63HXmLP7pvZrkF8i5gKBXk4VqWfhbhJjtf6Ni5NU',] network = NetworkMock() dest_addr = 'tb1q3ws2p0qjk5vrravv065xqlnkckvzcpclk79eu2' - tx = await sweep(privkeys, network=network, config=self.config, to_address=dest_addr, fee=5000, locktime=2420010, tx_version=2) + tx = await sweep(privkeys, network=network, to_address=dest_addr, fee_policy=FixedFeePolicy(5000), locktime=2420010, tx_version=2) tx_copy = tx_from_any(tx.serialize()) self.assertEqual('02000000010615142d6c296f276c7da9fb2b09f655d594f73b76740404f1424c66c78ca715000000008a47304402206d2dae571ca2f51e0d4a8ce6a6335fa25ac09f4bbed26439124d93f035bdbb130220249dc2039f1da338a40679f0e79c25a2dc2983688e6c04753348f2aa8435e375014104b875ab889006d4a9be8467c9256cf54e1073f7f9a037604f571cc025bbf47b2987b4c862d5b687bb5328adccc69e67a17b109b6328228695a1c384573acd6199fdffffff0186500300000000001600148ba0a0bc12b51831f58c7ea8607e76c5982c071f2aed2400', @@ -2240,7 +2241,7 @@ class TestWalletSending(ElectrumTestCase): privkeys = ['p2pkh:cN3LiXmurmGRF5xngYd8XS2ZsP2KeXFUh4SH7wpC8uJJzw52JPq1',] network = NetworkMock() dest_addr = 'tb1q782f750ekkxysp2rrscr6yknmn634e2pv8lktu' - tx = await sweep(privkeys, network=network, config=self.config, to_address=dest_addr, fee=1000, locktime=2420010, tx_version=2) + tx = await sweep(privkeys, network=network, to_address=dest_addr, fee_policy=FixedFeePolicy(1000), locktime=2420010, tx_version=2) tx_copy = tx_from_any(tx.serialize()) self.assertEqual('02000000016717835a2e1e152a69e7528a0f1346c1d37ee6e76c5e23b5d1c5a5b40241768a000000006a473044022038ad38003943bfd3ed39ba4340d545753fcad632a8fe882d01e4f0140ddb3cfb022019498260e29f5fbbcde9176bfb3553b7acec5fe284a9a3a33547a2d082b60355012103b875ab889006d4a9be8467c9256cf54e1073f7f9a037604f571cc025bbf47b29fdffffff0158de010000000000160014f1d49f51f9b58c4805431c303d12d3dcf51ae5412aed2400', @@ -2265,7 +2266,7 @@ class TestWalletSending(ElectrumTestCase): privkeys = ['p2wpkh-p2sh:cQMRGsiEsFX5YoxVZaMEzBruAkCWnoFf1SG7SRm2tLHDEN165TrA',] network = NetworkMock() dest_addr = 'tb1qu7n2tzm90a3f29kvxlhzsc7t40ddk075ut5w44' - tx = await sweep(privkeys, network=network, config=self.config, to_address=dest_addr, fee=500, locktime=2420010, tx_version=2) + tx = await sweep(privkeys, network=network, to_address=dest_addr, fee_policy=FixedFeePolicy(500), locktime=2420010, tx_version=2) tx_copy = tx_from_any(tx.serialize()) self.assertEqual('020000000001011d1725072a6e60687a59b878ecaf940ea0385880613d9d5502102bd78ef97b9a0000000017160014e7a6a58b657f629516cc37ee2863cbabdadb3fd4fdffffff01fc47020000000000160014e7a6a58b657f629516cc37ee2863cbabdadb3fd402473044022048ea4c558fd374f5d5066440a7f4933393cb377802cb949e3039fedf0378a29402204b4a58c591117cc1e37f07b03cc03cc6198dbf547e2bff813e2e2102bd2057e00121029f46ba81b3c6ad84e52841364dc54ca1097d0c30a68fb529766504c4b1c599352aed2400', @@ -2290,7 +2291,7 @@ class TestWalletSending(ElectrumTestCase): privkeys = ['p2wpkh:cV2BvgtpLNX328m4QrhqycBGA6EkZUFfHM9kKjVXjfyD53uNfC4q',] network = NetworkMock() dest_addr = 'tb1qhuy2e45lrdcp9s4ezeptx5kwxcnahzgpar9scc' - tx = await sweep(privkeys, network=network, config=self.config, to_address=dest_addr, fee=500, locktime=2420010, tx_version=2) + tx = await sweep(privkeys, network=network, to_address=dest_addr, fee_policy=FixedFeePolicy(500), locktime=2420010, tx_version=2) tx_copy = tx_from_any(tx.serialize()) self.assertEqual('02000000000101e328aeb4f9dc1b85a2709ce59b0478a15ed9fb5e7f84fb62422f99b8cd6ad7010000000000fdffffff01087e010000000000160014bf08acd69f1b7012c2b91642b352ce3627db89010247304402204993099c4663d92ef4c9a28b3f45a40a6585754fe22ecfdc0a76c43fda7c9d04022006a75e0fd3ad1862d8e81015a71d2a1489ec7a9264e6e63b8fe6bb90c27e799b0121038ca94e7c715152fd89803c2a40a934c7c4035fb87b3cba981cd1e407369cfe312aed2400', @@ -2325,7 +2326,7 @@ class TestWalletSending(ElectrumTestCase): # wallet1 creates tx1, with output back to himself outputs = [PartialTxOutput.from_address_and_value("tb1qhye4wfp26kn0l7ynpn5a4hvt539xc3zf0n76t3", 10_000_000)] - tx1 = wallet1.create_transaction(outputs=outputs, fee=5000, tx_version=2, rbf=True, sign=False) + tx1 = wallet1.create_transaction(outputs=outputs, fee_policy=FixedFeePolicy(5000), tx_version=2, rbf=True, sign=False) tx1.locktime = 1607022 partial_tx1 = tx1.serialize_as_bytes().hex() self.assertEqual("70736274ff0100710200000001d5bd4f8ebe63f0521f94e2d174b95d4327757a7e74fda3c9ff5c08796318f8d80000000000fdffffff02b82e0f0000000000160014250dbabd5761d7e0773d6147699938dd08ec2eb88096980000000000160014b93357242ad5a6fff8930ce9dadd8ba44a6c44496e8518000001011fc0d8a70000000000160014aba1c9faecc3f8882e641583e8734a3f9d01b15a0100df0200000000010162ecbac2f0c8662f53505d9410fdc56c84c5642ddbd3358d9a27d564e26731130200000000fdffffff02c0d8a70000000000160014aba1c9faecc3f8882e641583e8734a3f9d01b15ab89ed5000000000016001470afbd97b2dc351bd167f714e294b2fd3b60aedf02483045022100c93449989510e279eb14a0193d5c262ae93034b81376a1f6be259c6080d3ba5d0220536ab394f7c20f301d7ec2ef11be6e7b6d492053dce56458931c1b54218ec0fd012103b8f5a11df8e68cf335848e83a41fdad3c7413dc42148248a3799b58c93919ca01085180022060205e8db1b1906219782fadb18e763c0874a3118a17ce931e01707cbde194e041510775087560000008000000000000000000022020240ef5d2efee3b04b313a254df1b13a0b155451581e73943b21f3346bf6e1ba351077508756000000800100000000000000002202024a410b1212e88573561887b2bc38c90c074e4be425b9f3d971a9207825d9d3c8107750875600000080000000000100000000", @@ -2337,7 +2338,7 @@ class TestWalletSending(ElectrumTestCase): # wallet2 creates tx2, with output back to himself outputs = [PartialTxOutput.from_address_and_value("tb1qufnj5k2rrsnpjq7fg6d2pq3q9um6skdyyehw5m", 10_000_000)] - tx2 = wallet2.create_transaction(outputs=outputs, fee=5000, tx_version=2, rbf=True, sign=False) + tx2 = wallet2.create_transaction(outputs=outputs, fee_policy=FixedFeePolicy(5000), tx_version=2, rbf=True, sign=False) tx2.locktime = 1607023 partial_tx2 = tx2.serialize_as_bytes().hex() self.assertEqual("70736274ff0100710200000001e546bc0a7c9736e82a07df5c24fe6d05df58a310dc376cf09302842ca7264f930100000000fdffffff02988d07000000000016001453675a59be834aa6d139c3ebea56646a9b160c4c8096980000000000160014e2672a59431c261903c9469aa082202f37a859a46f8518000001011fa037a000000000001600140719d12228c61cab793ecd659c09cfe565a845c30100df02000000000101d5bd4f8ebe63f0521f94e2d174b95d4327757a7e74fda3c9ff5c08796318f8d80100000000fdffffff025066350000000000160014e3aa82aa2e754507d5585c0b6db06cc0cb4927b7a037a000000000001600140719d12228c61cab793ecd659c09cfe565a845c302483045022100f42e27519bd2379c22951c16b038fa6d49164fe6802854f2fdc7ee87fe31a8bc02204ea71e9324781b44bf7fea2f318caf3bedc5b497cbd1b4313fa71f833500bcb7012103a7853e1ee02a1629c8e870ec694a1420aeb98e6f5d071815257028f62d6f784169851800220602275b4fba18bb34e5198a9cfb3e940306658839079b3bda50d504a9cf2bae36f41067f36697000000800000000001000000002202036e4d0a5fb845b2f1c3c868c2ce7212b155b73e91c05be1b7a77c48830831ba4f1067f366970000008001000000000000000022020200062fdea2b0a056b17fa6b91dd87f5b5d838fe1ee84d636a5022f9a340eebcc1067f3669700000080000000000000000000", @@ -2411,7 +2412,7 @@ class TestWalletSending(ElectrumTestCase): # create tx outputs = [PartialTxOutput.from_address_and_value('tb1qfrlx5pza9vmez6vpx7swt8yp0nmgz3qa7jjkuf', 100_000)] coins = wallet_2of2.get_spendable_coins(domain=None) - tx = wallet_2of2.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet_2of2.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 1665628 @@ -2472,7 +2473,7 @@ class TestWalletSending(ElectrumTestCase): # create tx outputs = [PartialTxOutput.from_address_and_value('miFLSDZBXUo4on8PGhTRTAufUn4mP61uoH', '!')] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 1859362 tx.version = 2 @@ -2517,7 +2518,7 @@ class TestWalletSending(ElectrumTestCase): # create tx outputs = [PartialTxOutput.from_address_and_value('2N1VTMMFb91SH9SNRAkT7z8otP5eZEct4KL', 2500000)] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 1325499 tx.version = 1 @@ -2582,7 +2583,7 @@ class TestWalletSending(ElectrumTestCase): # create tx outputs = [PartialTxOutput.from_address_and_value('2N1VTMMFb91SH9SNRAkT7z8otP5eZEct4KL', '!')] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 1325499 tx.version = 1 @@ -2704,7 +2705,7 @@ class TestWalletSending(ElectrumTestCase): # create tx1 outputs = [PartialTxOutput.from_address_and_value('tb1qsfcddwf7yytl62e3catwv8hpl2hs9e36g2cqxl', 100000)] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=190) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(190)) tx.set_rbf(True) tx.locktime = 1938861 tx.version = 2 @@ -2717,7 +2718,7 @@ class TestWalletSending(ElectrumTestCase): # create tx2, which spends from unsigned tx1 outputs = [PartialTxOutput.from_address_and_value('tb1qq0lm9esmq6pfjc3jls7v6twy93lnqcs85wlth3', '!')] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 1938863 tx.version = 2 @@ -2741,7 +2742,7 @@ class TestWalletSending(ElectrumTestCase): outputs = [PartialTxOutput.from_address_and_value('tb1qsfcddwf7yytl62e3catwv8hpl2hs9e36g2cqxl', '!')] coins = wallet.get_spendable_coins(domain=None) with self.assertRaises(NotEnoughFunds): - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=0) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(0)) # bootstrap wallet funding_tx = Transaction('0200000000010132515e6aade1b79ec7dd3bac0896d8b32c56195d23d07d48e21659cef24301560100000000fdffffff0112841e000000000016001477fe6d2a27e8860c278d4d2cd90bad716bb9521a02473044022041ed68ef7ef122813ac6a5e996b8284f645c53fbe6823b8e430604a8915a867802203233f5f4d347a687eb19b2aa570829ab12aeeb29a24cc6d6d20b8b3d79e971ae012102bee0ee043817e50ac1bb31132770f7c41e35946ccdcb771750fb9696bdd1b307ad951d00') @@ -2752,7 +2753,7 @@ class TestWalletSending(ElectrumTestCase): with self.subTest(msg="funded wallet, zero output value, zero fee"): outputs = [PartialTxOutput.from_address_and_value('tb1qsfcddwf7yytl62e3catwv8hpl2hs9e36g2cqxl', 0)] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=0) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(0)) self.assertEqual(1, len(tx.inputs())) self.assertEqual(2, len(tx.outputs())) @@ -2775,7 +2776,7 @@ class TestWalletSending(ElectrumTestCase): outputs = [PartialTxOutput.from_address_and_value('tb1qq4pypzwxf5uanfyckmsu3ejxxf6rrvjqchza3v', 49646)] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=1000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(1000)) tx.set_rbf(True) tx.locktime = 2004420 tx.version = 2 @@ -2810,7 +2811,7 @@ class TestWalletSending(ElectrumTestCase): outputs = [PartialTxOutput.from_address_and_value('tb1qq4pypzwxf5uanfyckmsu3ejxxf6rrvjqchza3v', 49646)] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=1000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(1000)) tx.set_rbf(True) tx.locktime = 2004420 tx.version = 2 @@ -2855,7 +2856,7 @@ class TestWalletSending(ElectrumTestCase): outputs = [PartialTxOutput.from_address_and_value('tb1qq4pypzwxf5uanfyckmsu3ejxxf6rrvjqchza3v', 49646)] coins = wallet.get_spendable_coins(domain=None) - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=1000) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(1000)) tx.set_rbf(True) tx.locktime = 2004420 tx.version = 2 @@ -2936,7 +2937,7 @@ class TestWalletSending(ElectrumTestCase): coins = wallet.get_spendable_coins(domain=None) # create spending tx - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000, rbf=True) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000), rbf=True) tx.version = 2 tx.locktime = 2378363 self.assertEqual("04cf670cc809560ab6b1a362c119dbd59ea6a7621d00a4a05c0ef1839e65c035", tx.txid()) @@ -2966,7 +2967,7 @@ class TestWalletSending(ElectrumTestCase): # create spending tx again, but now we have full key origin info wallet.get_keystores()[0].add_key_origin(derivation_prefix="m/48'/1'/0'/2'", root_fingerprint="30cf1be5") - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000, rbf=True) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000), rbf=True) tx.version = 2 tx.locktime = 2378363 self.assertEqual("04cf670cc809560ab6b1a362c119dbd59ea6a7621d00a4a05c0ef1839e65c035", tx.txid()) @@ -3007,7 +3008,7 @@ class TestWalletSending(ElectrumTestCase): coins = wallet.get_spendable_coins(domain=None) # create spending tx - tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000, rbf=True) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000), rbf=True) tx.version = 2 tx.locktime = 2378367 self.assertEqual("5c0d5eea8c2c12a383406bb37e6158167e44bfe6cd1ad590b7d97002cdfc9fff", tx.txid()) @@ -3073,7 +3074,7 @@ class TestWalletSending(ElectrumTestCase): # cosignerA creates and signs the tx outputs = [PartialTxOutput.from_address_and_value("tb1qgacvp0zvgtk3etggjayuezrc2mkql8veshv4xw", 200_000)] coins = wallet1a.get_spendable_coins(domain=None) - tx = wallet1a.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet1a.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) tx.set_rbf(True) tx.locktime = 2429212 tx.version = 2 @@ -3146,10 +3147,10 @@ class TestWalletSending(ElectrumTestCase): outputs = [PartialTxOutput.from_address_and_value(bitcoin.DummyAddress.CHANNEL, 250000)] with self.assertRaises(bitcoin.DummyAddressUsedInTxException): - tx = wallet1.create_transaction(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False) + tx = wallet1.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), tx_version=1, rbf=False) coins = wallet1.get_spendable_coins(domain=None) - tx = wallet1.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000) + tx = wallet1.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(5000)) with self.assertRaises(bitcoin.DummyAddressUsedInTxException): wallet1.sign_transaction(tx, password=None) @@ -3167,7 +3168,7 @@ class TestWalletSending(ElectrumTestCase): outputs = [PartialTxOutput.from_address_and_value('tb1qgacvp0zvgtk3etggjayuezrc2mkql8veshv4xw', '!')] coins = wallet1.get_spendable_coins(domain=None) - tx = wallet1.make_unsigned_transaction(coins=coins, outputs=outputs, fee=1000) + tx = wallet1.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(1000)) self.assertEqual(2, len(tx.inputs())) tx.inputs()[0].sighash = Sighash.NONE @@ -3226,7 +3227,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('tb1qyw3c0rvn6kk2c688y3dygvckn57525y8qnxt3a', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1446655 tx.version = 1 @@ -3274,7 +3275,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('tb1qp0mv2sxsyxxfj5gl0332f9uyez93su9cf26757', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325340 tx.version = 1 @@ -3329,7 +3330,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('tb1qp0mv2sxsyxxfj5gl0332f9uyez93su9cf26757', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325341 tx.version = 1 @@ -3374,7 +3375,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('tb1qp0mv2sxsyxxfj5gl0332f9uyez93su9cf26757', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325341 tx.version = 1 @@ -3432,7 +3433,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('tb1qp0mv2sxsyxxfj5gl0332f9uyez93su9cf26757', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325341 tx.version = 1 @@ -3491,7 +3492,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('tb1quk7ahlhr3qmjndy0uvu9y9hxfesrtahtta9ghm', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325340 tx.version = 1 @@ -3528,7 +3529,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('tb1quk7ahlhr3qmjndy0uvu9y9hxfesrtahtta9ghm', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325340 tx.version = 1 @@ -3568,7 +3569,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('tb1quk7ahlhr3qmjndy0uvu9y9hxfesrtahtta9ghm', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325340 tx.version = 1 @@ -3609,7 +3610,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('tb1quk7ahlhr3qmjndy0uvu9y9hxfesrtahtta9ghm', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325340 tx.version = 1 @@ -3653,7 +3654,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('tb1quk7ahlhr3qmjndy0uvu9y9hxfesrtahtta9ghm', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325340 tx.version = 1 @@ -3694,7 +3695,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('tb1quk7ahlhr3qmjndy0uvu9y9hxfesrtahtta9ghm', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325340 tx.version = 1 @@ -3748,7 +3749,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('2MuCQQHJNnrXzQzuqfUCfAwAjPqpyEHbgue', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325503 tx.version = 1 @@ -3815,7 +3816,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('2N8CtJRwxb2GCaiWWdSHLZHHLoZy53CCyxf', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325504 tx.version = 1 @@ -3885,7 +3886,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # create unsigned tx outputs = [PartialTxOutput.from_address_and_value('2MyoZVy8T1t94yLmyKu8DP1SmbWvnxbkwRA', 2500000)] - tx = wallet_online.create_transaction(outputs=outputs, password=None, fee=5000, rbf=True) + tx = wallet_online.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), rbf=True) tx.locktime = 1325505 tx.version = 1 @@ -4208,7 +4209,7 @@ class TestWalletHistory_HelperFns(ElectrumTestCase): # wallet1 -> wallet2 outputs = [PartialTxOutput.from_address_and_value("2MuUcGmQ2mLN3vjTuqDSgZpk4LPKDsuPmhN", 165000)] - tx = wallet1.create_transaction(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False, sign=False) + tx = wallet1.create_transaction(outputs=outputs, password=None, fee_policy=FixedFeePolicy(5000), tx_version=1, rbf=False, sign=False) self.assertEqual( "wsh(sortedmulti(2,[b2e35a7d/1h]tpubD9aPYLPPYw8MxU3cD57LwpV5v7GomHxdv62MSbPcRkp47zwXx69ACUFsKrj8xzuzRrij9FWVhfvkvNqtqsr8ZtefkDsGZ9GLuHzoS6bXyk1/0/0,[53b77ddb/1h]tpubD8spLJysN7v7V1KHvkZ7AwjnXShKafopi7Vu3Ahs2S46FxBPTode8DgGxDo55k4pJvETGScZFwnM5f2Y31EUjteJdhxR73sjr9ieydgah2U/0/0,[43067d63/1h]tpubD8khd1g1tzFeKeaU59QV811hyvhwn9KDfy5sqFJ5m2wJLw6rUt4AZviqutRPXTUAK4SpU2we3y2WBP916Ma8Em4qFGcbYkFvXVfpGYV3oZR/0/0))", tx.inputs()[0].script_descriptor.to_string_no_checksum())