1
0

announce actual available liquidity as swap provider

This commit is contained in:
f321x
2025-03-18 16:56:17 +01:00
parent f76218ea83
commit 17a9a91e1f
5 changed files with 114 additions and 44 deletions

View File

@@ -30,7 +30,7 @@ class QESwapServerNPubListModel(QAbstractListModel):
_logger = get_logger(__name__)
# define listmodel rolemap
_ROLE_NAMES= ('npub', 'timestamp', 'percentage_fee', 'mining_fee', 'min_amount', 'max_amount')
_ROLE_NAMES= ('npub', 'timestamp', 'percentage_fee', 'mining_fee', 'min_amount', 'max_forward_amount', 'max_reverse_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]))
@@ -71,7 +71,8 @@ class QESwapServerNPubListModel(QAbstractListModel):
'percentage_fee': x['percentage_fee'],
'mining_fee': x['mining_fee'],
'min_amount': x['min_amount'],
'max_amount': x['max_amount'],
'max_forward_amount': x['max_forward_amount'],
'max_reverse_amount': x['max_reverse_amount'],
'timestamp': age(x['timestamp']),
} for x in items]
self.endInsertRows()
@@ -430,12 +431,12 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener):
except AttributeError: # happens if there are no utxos
max_onchain_spend = 0
reverse = int(min(lnworker.num_sats_can_send(),
swap_manager.get_max_amount()))
max_recv_amt_ln = min(swap_manager.get_max_amount(), int(lnworker.num_sats_can_receive()))
swap_manager.get_provider_max_forward_amount()))
max_recv_amt_ln = min(swap_manager.get_provider_max_reverse_amount(), int(lnworker.num_sats_can_receive()))
max_recv_amt_oc = swap_manager.get_send_amount(max_recv_amt_ln, is_reverse=False) or 0
forward = int(min(max_recv_amt_oc,
# maximally supported swap amount by provider
swap_manager.get_max_amount(),
swap_manager.get_provider_max_reverse_amount(),
max_onchain_spend))
# we expect range to adjust the value of the swap slider to be in the
# correct range, i.e., to correct an overflow when reducing the limits
@@ -597,9 +598,9 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener):
coins = self._wallet.wallet.get_spendable_coins()
if onchain_amount == '!':
max_amount = sum(c.value_sats() for c in coins)
max_swap_amount = self._wallet.wallet.lnworker.swap_manager.max_amount_forward_swap()
max_swap_amount = self._wallet.wallet.lnworker.swap_manager.client_max_amount_forward_swap()
if max_swap_amount is None:
raise InvalidSwapParameters("swap_manager.max_amount_forward_swap() is None")
raise InvalidSwapParameters("swap_manager.client_max_amount_forward_swap() is None")
if max_amount > max_swap_amount:
onchain_amount = max_swap_amount
outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)]

View File

@@ -177,7 +177,7 @@ class SwapDialog(WindowModalDialog, QtEventListener):
self.max_button.setChecked(False)
def _spend_max_reverse_swap(self) -> None:
amount = min(self.lnworker.num_sats_can_send(), self.swap_manager.get_max_amount())
amount = min(self.lnworker.num_sats_can_send(), self.swap_manager.get_provider_max_forward_amount())
amount = int(amount) # round down msats
self.send_amount_e.setAmount(amount)
@@ -295,9 +295,9 @@ class SwapDialog(WindowModalDialog, QtEventListener):
coins = self.window.get_coins()
if onchain_amount == '!':
max_amount = sum(c.value_sats() for c in coins)
max_swap_amount = self.swap_manager.max_amount_forward_swap()
max_swap_amount = self.swap_manager.client_max_amount_forward_swap()
if max_swap_amount is None:
raise InvalidSwapParameters("swap_manager.max_amount_forward_swap() is None")
raise InvalidSwapParameters("swap_manager.client_max_amount_forward_swap() is None")
if max_amount > max_swap_amount:
onchain_amount = max_swap_amount
outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)]

View File

@@ -242,7 +242,7 @@ class UTXOList(MyTreeView):
return False
value = sum(x.value_sats() for x in coins)
min_amount = self.wallet.lnworker.swap_manager.get_min_amount()
max_amount = self.wallet.lnworker.swap_manager.max_amount_forward_swap()
max_amount = self.wallet.lnworker.swap_manager.client_max_amount_forward_swap()
if min_amount is None or max_amount is None:
# we need to fetch data from swap server
return True

View File

@@ -66,7 +66,9 @@ class HttpSwapServer(Logger, EventListener):
"BTC/BTC": {
"rate": 1,
"limits": {
"maximal": sm._max_amount,
"maximal": min(sm._max_forward, sm._max_reverse), # legacy
"max_forward_amount": sm._max_forward, # new version, uses 2 separate limits
"max_reverse_amount": sm._max_reverse,
"minimal": sm._min_amount,
},
"fees": {

View File

@@ -26,8 +26,8 @@ from .bitcoin import (script_to_p2wsh, opcodes,
from .transaction import PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint
from .transaction import script_GetOp, match_script_against_template, OPPushDataGeneric, OPPushDataPubkey
from .util import (log_exceptions, ignore_exceptions, BelowDustLimit, OldTaskGroup, age, ca_path,
gen_nostr_ann_pow, get_nostr_ann_pow_amount, make_aiohttp_proxy_connector, get_running_loop,
get_asyncio_loop)
gen_nostr_ann_pow, get_nostr_ann_pow_amount, make_aiohttp_proxy_connector,
get_running_loop, get_asyncio_loop, wait_for2)
from .lnutil import REDEEM_AFTER_DOUBLE_SPENT_DELAY
from .bitcoin import dust_threshold, DummyAddress
from .logging import Logger
@@ -134,7 +134,8 @@ class SwapFees:
percentage = attr.ib(type=int)
mining_fee = attr.ib(type=int)
min_amount = attr.ib(type=int)
max_amount = attr.ib(type=int)
max_forward = attr.ib(type=int)
max_reverse = attr.ib(type=int)
@stored_in('submarine_swaps')
@attr.s
@@ -175,7 +176,8 @@ class SwapManager(Logger):
self.mining_fee = None
self.percentage = None
self._min_amount = None
self._max_amount = None
self._max_forward = None
self._max_reverse = None
self.wallet = wallet
self.config = wallet.config
@@ -202,6 +204,7 @@ class SwapManager(Logger):
self.is_server = False # overriden by swapserver plugin if enabled
self.is_initialized = asyncio.Event()
self.pairs_updated = asyncio.Event()
self._liquidity_changed = asyncio.Event()
def start_network(self, network: 'Network'):
assert network
@@ -220,13 +223,22 @@ class SwapManager(Logger):
async def run_nostr_server(self):
await self.set_nostr_proof_of_work()
with NostrTransport(self.config, self, self.lnworker.nostr_keypair) as transport:
# wait a bit so we don't publish 0 liquidity on startup if channels are not yet reestablished
await asyncio.sleep(10)
await transport.is_connected.wait()
self.logger.info(f'nostr is connected')
# will publish a new announcement if liquidity changed or every OFFER_UPDATE_INTERVAL_SEC
while True:
# todo: publish everytime fees have changed
self.server_update_pairs()
await transport.publish_offer(self)
await asyncio.sleep(transport.OFFER_UPDATE_INTERVAL_SEC)
try:
await wait_for2(
self._liquidity_changed.wait(),
timeout=transport.OFFER_UPDATE_INTERVAL_SEC
)
except asyncio.TimeoutError:
continue
@log_exceptions
async def main_loop(self):
@@ -427,6 +439,7 @@ class SwapManager(Logger):
except BelowDustLimit:
self.logger.info('utxo value below dust threshold')
return
self.server_maybe_trigger_liquidity_update()
def get_swap_tx_fee(self):
return self._get_tx_fee(self.config.FEE_POLICY)
@@ -470,6 +483,8 @@ class SwapManager(Logger):
our_privkey = os.urandom(32)
our_pubkey = ECPrivkey(our_privkey).get_public_key_bytes(compressed=True)
onchain_amount_sat = self._get_recv_amount(lightning_amount_sat, is_reverse=True) # what the client is going to receive
if not onchain_amount_sat:
raise Exception("no onchain amount")
redeem_script = construct_script(
WITNESS_TEMPLATE_REVERSE_SWAP,
values={1:32, 5:ripemd(payment_hash), 7:their_pubkey, 10:locktime, 13:our_pubkey}
@@ -563,6 +578,8 @@ class SwapManager(Logger):
privkey = os.urandom(32)
our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
onchain_amount_sat = self._get_send_amount(lightning_amount_sat, is_reverse=False)
if not onchain_amount_sat:
raise Exception("no onchain amount")
preimage = os.urandom(32)
payment_hash = sha256(preimage)
redeem_script = construct_script(
@@ -907,17 +924,35 @@ class SwapManager(Logger):
def server_update_pairs(self) -> None:
""" for server """
self.percentage = float(self.config.SWAPSERVER_FEE_MILLIONTHS) / 10000
self.percentage = float(self.config.SWAPSERVER_FEE_MILLIONTHS) / 10000 # type: ignore
self._min_amount = 20000
self._max_amount = 10000000
anchor_reserve = self.config.LN_UTXO_RESERVE \
if (any(chan.has_anchors() and not chan.is_redeemed()
for chan in self.lnworker.channels.values())) else 0
oc_balance = max(sum([coin.value_sats() for coin in self.wallet.get_spendable_coins()])
- anchor_reserve, 0)
max_forward: int = min(int(self.lnworker.num_sats_can_receive()), oc_balance, 10000000)
max_reverse: int = min(int(self.lnworker.num_sats_can_send()), 10000000)
self._max_forward: int = self._keep_leading_digits(max_forward, 2)
self._max_reverse: int = self._keep_leading_digits(max_reverse, 2)
self.mining_fee = self.get_fee_for_txbatcher()
@staticmethod
def _keep_leading_digits(num: int, digits: int) -> int:
"""Reduces precision of num to `digits` leading digits."""
if num <= 0:
return 0
num_str = str(num)
zeroed_num_str = f"{num_str[:digits]}{(len(num_str[digits:])) * '0'}"
return int(zeroed_num_str)
def update_pairs(self, pairs):
self.logger.info(f'updating fees {pairs}')
self.mining_fee = pairs.mining_fee
self.percentage = pairs.percentage
self._min_amount = pairs.min_amount
self._max_amount = pairs.max_amount
self._max_forward = pairs.max_forward
self._max_reverse = pairs.max_reverse
self.trigger_pairs_updated_threadsafe()
def trigger_pairs_updated_threadsafe(self):
@@ -928,16 +963,41 @@ class SwapManager(Logger):
loop = get_asyncio_loop()
loop.call_soon_threadsafe(trigger)
def get_max_amount(self) -> int:
"""in satoshis"""
return self._max_amount
def server_maybe_trigger_liquidity_update(self) -> None:
"""
To be called when the available liquidity changes so the new liquidity is announced.
(ln in/out, onchain in/out)
"""
if not self.is_server:
return
assert get_running_loop() == get_asyncio_loop(), "Events must be set in the asyncio thread"
previous_max_forward = self._max_forward
previous_max_reverse = self._max_reverse
self.server_update_pairs()
# if liquidity really changed the event is triggered so a new provider announcement is published
if self._max_forward != previous_max_forward or self._max_reverse != previous_max_reverse:
self.logger.debug(f"liquidity changed, updating announcement")
self._liquidity_changed.set()
self._liquidity_changed.clear()
def get_provider_max_forward_amount(self) -> int:
"""in sat"""
return self._max_forward
def get_provider_max_reverse_amount(self) -> int:
"""in sat"""
return self._max_reverse
def get_min_amount(self) -> int:
"""in satoshis"""
return self._min_amount
def check_invoice_amount(self, x) -> bool:
return self.get_min_amount() <= x <= self.get_max_amount()
def check_invoice_amount(self, x, is_reverse: bool) -> bool:
if is_reverse:
max_amount = self.get_provider_max_forward_amount()
else:
max_amount = self.get_provider_max_reverse_amount()
return self.get_min_amount() <= x <= max_amount
def _get_recv_amount(self, send_amount: Optional[int], *, is_reverse: bool) -> Optional[int]:
"""For a given swap direction and amount we send, returns how much we will receive.
@@ -946,12 +1006,12 @@ class SwapManager(Logger):
In the reverse direction, the result matches what the swap server returns as response["onchainAmount"].
"""
if send_amount is None:
return
return None
x = Decimal(send_amount)
percentage = Decimal(self.percentage)
if is_reverse:
if not self.check_invoice_amount(x):
return
if not self.check_invoice_amount(x, is_reverse):
return None
# see/ref:
# https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L948
percentage_fee = math.ceil(percentage * x / 100)
@@ -959,13 +1019,13 @@ class SwapManager(Logger):
x -= percentage_fee + base_fee
x = math.floor(x)
if x < dust_threshold():
return
return None
else:
x -= self.mining_fee
percentage_fee = math.ceil(x * percentage / (100 + percentage))
x -= percentage_fee
if not self.check_invoice_amount(x):
return
if not self.check_invoice_amount(x, is_reverse):
return None
x = int(x)
return x
@@ -976,7 +1036,7 @@ class SwapManager(Logger):
In the forward direction, the result matches what the swap server returns as response["expectedAmount"].
"""
if not recv_amount:
return
return None
x = Decimal(recv_amount)
percentage = Decimal(self.percentage)
if is_reverse:
@@ -986,11 +1046,11 @@ class SwapManager(Logger):
base_fee = self.mining_fee
x += base_fee
x = math.ceil(x / ((100 - percentage) / 100))
if not self.check_invoice_amount(x):
return
if not self.check_invoice_amount(x, is_reverse):
return None
else:
if not self.check_invoice_amount(x):
return
if not self.check_invoice_amount(x, is_reverse):
return None
# see/ref:
# 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
@@ -1036,7 +1096,7 @@ class SwapManager(Logger):
swaps.append(swap)
return swaps
def get_swaps_by_claim_tx(self, tx: Transaction) -> Iterable[SwapData]:
def get_swaps_by_claim_tx(self, tx: Transaction) -> Iterable[Tuple[int, SwapData]]:
swaps = []
for i, txin in enumerate(tx.inputs()):
if swap := self.get_swap_by_claim_txin(txin):
@@ -1085,9 +1145,9 @@ class SwapManager(Logger):
txin.make_witness = make_witness
return txin, locktime
def max_amount_forward_swap(self) -> Optional[int]:
def client_max_amount_forward_swap(self) -> Optional[int]:
""" returns None if we cannot swap """
max_swap_amt_ln = self.get_max_amount()
max_swap_amt_ln = self.get_provider_max_reverse_amount()
if max_swap_amt_ln is None:
return None
max_recv_amt_ln = int(self.lnworker.num_sats_can_receive())
@@ -1261,7 +1321,8 @@ class HttpTransport(SwapServerTransport):
percentage=fees['percentage'],
mining_fee=fees['minerFees']['baseAsset']['mining_fee'],
min_amount=limits['minimal'],
max_amount=limits['maximal'],
max_forward=limits['max_forward_amount'],
max_reverse=limits['max_reverse_amount'],
)
self.sm.update_pairs(pairs)
@@ -1274,7 +1335,7 @@ class NostrTransport(SwapServerTransport):
EPHEMERAL_REQUEST = 25582
USER_STATUS_NIP38 = 30315
NOSTR_EVENT_VERSION = 4
NOSTR_EVENT_VERSION = 5
OFFER_UPDATE_INTERVAL_SEC = 60 * 10
def __init__(self, config, sm, keypair):
@@ -1376,18 +1437,23 @@ class NostrTransport(SwapServerTransport):
percentage=offer['percentage_fee'],
mining_fee=offer['mining_fee'],
min_amount=offer['min_amount'],
max_amount=offer['max_amount'],
max_forward=offer['max_forward_amount'],
max_reverse=offer['max_reverse_amount'],
)
@ignore_exceptions
@log_exceptions
async def publish_offer(self, sm):
async def publish_offer(self, sm) -> None:
assert self.sm.is_server
if sm._max_forward < sm._min_amount and sm._max_reverse < sm._min_amount:
self.logger.warning(f"not publishing swap offer, no liquidity available: {sm._max_forward=}, {sm._max_reverse=}")
return
offer = {
'percentage_fee': sm.percentage,
'mining_fee': sm.mining_fee,
'min_amount': sm._min_amount,
'max_amount': sm._max_amount,
'max_forward_amount': sm._max_forward,
'max_reverse_amount': sm._max_reverse,
'relays': sm.config.NOSTR_RELAYS,
'pow_nonce': hex(sm.config.SWAPSERVER_ANN_POW_NONCE),
}
@@ -1544,6 +1610,7 @@ class NostrTransport(SwapServerTransport):
r['reply_to'] = event_id
self.logger.debug(f'sending response id={event_id}')
await self.send_direct_message(event_pubkey, json.dumps(r))
self.sm.server_maybe_trigger_liquidity_update()
def _store_last_swapserver_relays(self, relays: Sequence[str]):
self._last_swapserver_relays = relays