From 6f97b7b5f9753f8bbbf942d1b2585f5ec34a3b3e Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 19 Feb 2025 17:39:56 +0100 Subject: [PATCH] simplify submarine swap onchain fee model to single base fee --- electrum/commands.py | 2 +- electrum/gui/qml/qeswaphelper.py | 12 ++---- electrum/gui/qt/main_window.py | 2 +- electrum/gui/qt/swap_dialog.py | 6 +-- electrum/plugins/swapserver/server.py | 18 +++++---- electrum/submarine_swaps.py | 57 ++++++++++----------------- tests/test_sswaps.py | 5 ++- 7 files changed, 43 insertions(+), 59 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 436c1e99d..3554fac8d 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1414,7 +1414,7 @@ class Commands: funding_txid = None else: lightning_amount_sat = satoshis(lightning_amount) - claim_fee = sm.get_claim_fee() + claim_fee = sm.get_swap_tx_fee() onchain_amount_sat = satoshis(onchain_amount) + claim_fee funding_txid = await wallet.lnworker.swap_manager.reverse_swap( transport, diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py index 1e27955a2..6bbebf92e 100644 --- a/electrum/gui/qml/qeswaphelper.py +++ b/electrum/gui/qml/qeswaphelper.py @@ -29,8 +29,7 @@ class QESwapServerNPubListModel(QAbstractListModel): _logger = get_logger(__name__) # define listmodel rolemap - _ROLE_NAMES= ('npub', 'timestamp', 'percentage_fee', 'normal_mining_fee', 'reverse_mining_fee', 'claim_mining_fee', - 'min_amount', 'max_amount') + _ROLE_NAMES= ('npub', 'timestamp', 'percentage_fee', 'mining_fee', 'min_amount', 'max_amount') _ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) @@ -69,9 +68,7 @@ class QESwapServerNPubListModel(QAbstractListModel): self._services = [{ 'npub': x['pubkey'], 'percentage_fee': x['percentage_fee'], - 'normal_mining_fee': x['normal_mining_fee'], - 'reverse_mining_fee': x['reverse_mining_fee'], - 'claim_mining_fee': x['claim_mining_fee'], + 'mining_fee': x['mining_fee'], 'min_amount': x['min_amount'], 'max_amount': x['max_amount'], 'timestamp': age(x['timestamp']), @@ -378,7 +375,6 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener): self._logger.error(str(e)) except RuntimeError: pass - if isinstance(transport, NostrTransport): if not swap_manager.is_initialized.is_set(): if not transport.is_connected.is_set(): @@ -502,7 +498,7 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener): self.toreceive = QEAmount(amount_sat=self._receive_amount) # fee breakdown self.serverfeeperc = f'{swap_manager.percentage:0.1f}%' - server_miningfee = swap_manager.lockup_fee if self.isReverse else swap_manager.normal_fee + server_miningfee = swap_manager.mining_fee self.serverMiningfee = QEAmount(amount_sat=server_miningfee) if self.isReverse: self.check_valid(self._send_amount, self._receive_amount) @@ -626,7 +622,7 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener): return await swap_manager.reverse_swap( transport, lightning_amount_sat=lightning_amount, - expected_onchain_amount_sat=onchain_amount + swap_manager.get_claim_fee(), + expected_onchain_amount_sat=onchain_amount + swap_manager.get_swap_tx_fee(), ) try: fut = asyncio.run_coroutine_threadsafe(coro(), loop) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 104afe4c8..6d9a1e9a4 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -1247,7 +1247,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): def descr(x): last_seen = util.age(x['timestamp']) return (f"pubkey={x['pubkey'][0:10]}, " - f"fee={x['percentage_fee']}% + {x['reverse_mining_fee']} sats, " + f"fee={x['percentage_fee']}% + {x['mining_fee']} sats, " f"last_seen: {last_seen}") server_keys = [(x['pubkey'], descr(x)) for x in recent_offers] msg = '\n'.join([ diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py index 900b9a696..3e6faff4a 100644 --- a/electrum/gui/qt/swap_dialog.py +++ b/electrum/gui/qt/swap_dialog.py @@ -239,7 +239,7 @@ class SwapDialog(WindowModalDialog, QtEventListener): self.recv_label.setIcon(recv_icon) self.description_label.setText(self.get_description()) self.description_label.repaint() # macOS hack for #6269 - server_mining_fee = sm.lockup_fee if self.is_reverse else sm.normal_fee + server_mining_fee = sm.mining_fee server_fee_str = '%.2f'%sm.percentage + '% + ' + self.window.format_amount(server_mining_fee) + ' ' + self.window.base_unit() self.server_fee_label.setText(server_fee_str) self.server_fee_label.repaint() # macOS hack for #6269 @@ -249,7 +249,7 @@ class SwapDialog(WindowModalDialog, QtEventListener): """Updates self.fee_label. No other side-effects.""" if self.is_reverse: sm = self.swap_manager - fee = sm.get_claim_fee() + fee = sm.get_swap_tx_fee() else: fee = tx.get_fee() if tx else None fee_text = self.window.format_amount(fee) + ' ' + self.window.base_unit() if fee else '' @@ -269,7 +269,7 @@ class SwapDialog(WindowModalDialog, QtEventListener): coro = sm.reverse_swap( transport, lightning_amount_sat=lightning_amount, - expected_onchain_amount_sat=onchain_amount + self.swap_manager.get_claim_fee(), + expected_onchain_amount_sat=onchain_amount + self.swap_manager.get_swap_tx_fee(), zeroconf=self.zeroconf, ) try: diff --git a/electrum/plugins/swapserver/server.py b/electrum/plugins/swapserver/server.py index cd538ba9a..42551617e 100644 --- a/electrum/plugins/swapserver/server.py +++ b/electrum/plugins/swapserver/server.py @@ -77,18 +77,20 @@ class HttpSwapServer(Logger, EventListener): "percentage": sm.percentage, "minerFees": { "baseAsset": { - "normal": sm.normal_fee, + "normal": sm.mining_fee, "reverse": { - "claim": sm.claim_fee, - "lockup": sm.lockup_fee - } + "claim": sm.mining_fee, + "lockup": sm.mining_fee + }, + "mining_fee": sm.mining_fee }, "quoteAsset": { - "normal": sm.normal_fee, + "normal": sm.mining_fee, "reverse": { - "claim": sm.claim_fee, - "lockup": sm.lockup_fee - } + "claim": sm.mining_fee, + "lockup": sm.mining_fee + }, + "mining_fee": sm.mining_fee } } } diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index b07d51c42..09159ebb2 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -55,8 +55,7 @@ if TYPE_CHECKING: -CLAIM_FEE_SIZE = 136 -LOCKUP_FEE_SIZE = 153 # assuming 1 output, 2 outputs +SWAP_TX_SIZE = 150 # default tx size, used for mining fee estimation MIN_LOCKTIME_DELTA = 60 LOCKTIME_DELTA_REFUND = 70 @@ -130,9 +129,7 @@ def now(): @attr.s class SwapFees: percentage = attr.ib(type=int) - normal_fee = attr.ib(type=int) - lockup_fee = attr.ib(type=int) - claim_fee = attr.ib(type=int) + mining_fee = attr.ib(type=int) min_amount = attr.ib(type=int) max_amount = attr.ib(type=int) @@ -176,7 +173,7 @@ def create_claim_tx( """ # 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=CLAIM_FEE_SIZE, config=config) + amount_sat = txin.value_sats() - SwapManager._get_fee(size=SWAP_TX_SIZE, config=config) if amount_sat < dust_threshold(): raise BelowDustLimit() txin, locktime = SwapManager.create_claim_txin(txin=txin, swap=swap, config=config) @@ -196,9 +193,7 @@ class SwapManager(Logger): def __init__(self, *, wallet: 'Abstract_Wallet', lnworker: 'LNWallet'): Logger.__init__(self) - self.normal_fee = None - self.lockup_fee = None - self.claim_fee = None # part of the boltz prococol, not used by Electrum + self.mining_fee = None self.percentage = None self._min_amount = None self._max_amount = None @@ -417,7 +412,7 @@ class SwapManager(Logger): else: claim_tx.add_info_from_wallet(self.wallet) claim_tx_fee = claim_tx.get_fee() - recommended_fee = self.get_claim_fee() + recommended_fee = self.get_swap_tx_fee() if claim_tx_fee * 1.1 < recommended_fee: should_bump_fee = True self.logger.info(f'claim tx fee too low {claim_tx_fee} < {recommended_fee}. we will bump the fee') @@ -465,8 +460,8 @@ class SwapManager(Logger): except TxBroadcastError: self.logger.info(f'error broadcasting claim tx {txin.spent_txid}') - def get_claim_fee(self): - return self.get_fee(CLAIM_FEE_SIZE) + def get_swap_tx_fee(self): + return self.get_fee(SWAP_TX_SIZE) def get_fee(self, size): # note: 'size' is in vbytes @@ -546,7 +541,7 @@ class SwapManager(Logger): ) -> Tuple[SwapData, str, Optional[str]]: """creates a hold invoice""" if prepay: - prepay_amount_sat = self.get_claim_fee() * 2 + prepay_amount_sat = self.get_swap_tx_fee() * 2 invoice_amount_sat = lightning_amount_sat - prepay_amount_sat else: invoice_amount_sat = lightning_amount_sat @@ -965,15 +960,11 @@ class SwapManager(Logger): self.percentage = float(self.config.SWAPSERVER_FEE_MILLIONTHS) / 10000 self._min_amount = 20000 self._max_amount = 10000000 - self.normal_fee = self.get_fee(CLAIM_FEE_SIZE) - self.lockup_fee = self.get_fee(LOCKUP_FEE_SIZE) - self.claim_fee = self.get_fee(CLAIM_FEE_SIZE) + self.mining_fee = self.get_fee(SWAP_TX_SIZE) def update_pairs(self, pairs): self.logger.info(f'updating fees {pairs}') - self.normal_fee = pairs.normal_fee - self.lockup_fee = pairs.lockup_fee - self.claim_fee = pairs.claim_fee + self.mining_fee = pairs.mining_fee self.percentage = pairs.percentage self._min_amount = pairs.min_amount self._max_amount = pairs.max_amount @@ -1006,13 +997,13 @@ class SwapManager(Logger): # see/ref: # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L948 percentage_fee = math.ceil(percentage * x / 100) - base_fee = self.lockup_fee + base_fee = self.mining_fee x -= percentage_fee + base_fee x = math.floor(x) if x < dust_threshold(): return else: - x -= self.normal_fee + x -= self.mining_fee percentage_fee = math.ceil(x * percentage / (100 + percentage)) x -= percentage_fee if not self.check_invoice_amount(x): @@ -1034,7 +1025,7 @@ class SwapManager(Logger): # see/ref: # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L928 # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L958 - base_fee = self.lockup_fee + base_fee = self.mining_fee x += base_fee x = math.ceil(x / ((100 - percentage) / 100)) if not self.check_invoice_amount(x): @@ -1046,7 +1037,7 @@ class SwapManager(Logger): # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L708 # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/rates/FeeProvider.ts#L90 percentage_fee = math.ceil(percentage * x / 100) - x += percentage_fee + self.normal_fee + x += percentage_fee + self.mining_fee x = int(x) return x @@ -1062,13 +1053,13 @@ class SwapManager(Logger): f"send_amount={send_amount} -> recv_amount={recv_amount} -> inverted_send_amount={inverted_send_amount}") # second, add on-chain claim tx fee if is_reverse and recv_amount is not None: - recv_amount -= self.get_claim_fee() + recv_amount -= self.get_swap_tx_fee() return recv_amount def get_send_amount(self, recv_amount: Optional[int], *, is_reverse: bool) -> Optional[int]: # first, add on-chain claim tx fee if is_reverse and recv_amount is not None: - recv_amount += self.get_claim_fee() + recv_amount += self.get_swap_tx_fee() # second, add percentage fee send_amount = self._get_send_amount(recv_amount, is_reverse=is_reverse) # sanity check calculation can be inverted @@ -1310,9 +1301,7 @@ class HttpTransport(SwapServerTransport): limits = response['pairs']['BTC/BTC']['limits'] pairs = SwapFees( percentage = fees['percentage'], - normal_fee = fees['minerFees']['baseAsset']['normal'], - lockup_fee = fees['minerFees']['baseAsset']['reverse']['lockup'], - claim_fee = fees['minerFees']['baseAsset']['reverse']['claim'], + mining_fee = fees['minerFees']['baseAsset']['mining_fee'], min_amount = limits['minimal'], max_amount = limits['maximal'], ) @@ -1327,7 +1316,7 @@ class NostrTransport(SwapServerTransport): NOSTR_DM = 4 USER_STATUS_NIP38 = 30315 - NOSTR_EVENT_VERSION = 2 + NOSTR_EVENT_VERSION = 3 OFFER_UPDATE_INTERVAL_SEC = 60 * 10 def __init__(self, config, sm, keypair): @@ -1407,9 +1396,7 @@ class NostrTransport(SwapServerTransport): def _parse_offer(self, offer): return SwapFees( percentage = offer['percentage_fee'], - normal_fee = offer['normal_mining_fee'], - lockup_fee = offer['reverse_mining_fee'], - claim_fee = offer['claim_mining_fee'], + mining_fee = offer['mining_fee'], min_amount = offer['min_amount'], max_amount = offer['max_amount'], ) @@ -1420,9 +1407,7 @@ class NostrTransport(SwapServerTransport): assert self.sm.is_server offer = { 'percentage_fee': sm.percentage, - 'normal_mining_fee': sm.normal_fee, - 'reverse_mining_fee': sm.lockup_fee, - 'claim_mining_fee': sm.claim_fee, + 'mining_fee': sm.mining_fee, 'min_amount': sm._min_amount, 'max_amount': sm._max_amount, 'relays': sm.config.NOSTR_RELAYS, @@ -1502,7 +1487,7 @@ class NostrTransport(SwapServerTransport): await self.taskgroup.spawn(self.rebroadcast_event(event, server_relays)) async def get_pairs(self): - if self.config.SWAPSERVER_NPUB is None: + if not self.config.SWAPSERVER_NPUB: return query = { "kinds": [self.USER_STATUS_NIP38], diff --git a/tests/test_sswaps.py b/tests/test_sswaps.py index d14eb3e2a..22fd8c115 100644 --- a/tests/test_sswaps.py +++ b/tests/test_sswaps.py @@ -11,6 +11,7 @@ class TestSwapTxs(ElectrumTestCase): def setUp(self): 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 @@ -41,7 +42,7 @@ class TestSwapTxs(ElectrumTestCase): config=self.config, ) self.assertEqual( - "02000000000101f9db8580febd5c0f85b6f1576c83f7739109e3a2d772743e3217e9537fea7e890000000000fdffffff019e07030000000000160014fbfad1ca8741ce640a3ea130bd4478fdd8a2dd8f034730440220156d62534a4e8247eef6bb185c89c4013353c017e45d41ce634976b9d7122c6202202ddb593983fd789cf2166038411425c119d087bc37ec7f8b51bebf603e428fbb0120f1939b5723155713855d7ebea6e174f77d41d669269e7f138856c3de190e7a366a8201208763a914d7a62ef0270960fe23f0f351b28caadab62c21838821030bfd61153816df786036ea293edce851d3a4b9f4a1c66bdc1a17f00ffef3d6b167750334ef24b1752102fc8128f17f9e666ea281c702171ab16c1dd2a4337b71f08970f5aa10c608a93268ac00000000", + "02000000000101f9db8580febd5c0f85b6f1576c83f7739109e3a2d772743e3217e9537fea7e890000000000fdffffff019007030000000000160014fbfad1ca8741ce640a3ea130bd4478fdd8a2dd8f03473044022025506044aba4939f4f2faa94710673ca65530a621f1fa538a3d046dc98bb685e02205f8d463dc6f81e1083f26fa963e581dabc80ea42f8cd59c9e31f3bf531168a9c0120f1939b5723155713855d7ebea6e174f77d41d669269e7f138856c3de190e7a366a8201208763a914d7a62ef0270960fe23f0f351b28caadab62c21838821030bfd61153816df786036ea293edce851d3a4b9f4a1c66bdc1a17f00ffef3d6b167750334ef24b1752102fc8128f17f9e666ea281c702171ab16c1dd2a4337b71f08970f5aa10c608a93268ac00000000", str(tx) ) @@ -71,7 +72,7 @@ class TestSwapTxs(ElectrumTestCase): config=self.config, ) self.assertEqual( - "0200000000010106871505e5f6dc76f406f38e34e29b54908c6b54da978c28c18fb39ab1dcec080000000000fdffffff0148fb01000000000016001497b4b718e7d06c9c43cd3bcf37905041b718b81f034730440220254e054fc195801aca3d62641a0f27d888f44d1dd66760ae5c3418502e82c141022014305da98daa27d665310115845d2fa6d4dc612d910a186db2624aa558bff9fe010065a914b12bd886ef4fd9ef1c03e899123f2c4b96cec0878763210267ca676c2ed05bb6c380880f1e50b6ef91025dfa963dc49d6c5cb9848f2acf7d670339ef24b1752103d8190cdfcc7dd929a583b7ea8fa8eb1d8463195d336be2f2df94f950ce8b659968ac39ef2400", + "0200000000010106871505e5f6dc76f406f38e34e29b54908c6b54da978c28c18fb39ab1dcec080000000000fdffffff013afb01000000000016001497b4b718e7d06c9c43cd3bcf37905041b718b81f0347304402200ae708af1393f785c541bbc4d7351791b76a53077a292b71cb2a25ad13a15f9902206b7b91c414ec0d6e5098a1acc26de4b47f3aac414b7a49741e8f27cc6a967a19010065a914b12bd886ef4fd9ef1c03e899123f2c4b96cec0878763210267ca676c2ed05bb6c380880f1e50b6ef91025dfa963dc49d6c5cb9848f2acf7d670339ef24b1752103d8190cdfcc7dd929a583b7ea8fa8eb1d8463195d336be2f2df94f950ce8b659968ac39ef2400", str(tx) )