• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

spesmilo / electrum / 5558179037184000

18 Feb 2025 02:59PM UTC coverage: 60.523% (+0.02%) from 60.502%
5558179037184000

push

CirrusCI

ecdsa
simplify history-related commands:
 - reduce number of methods
 - use nametuples instead of dicts
 - only two types: OnchainHistoryItem and LightningHistoryItem
 - channel open/closes are groups
 - move capital gains into separate RPC

34 of 102 new or added lines in 5 files covered. (33.33%)

4 existing lines in 2 files now uncovered.

20266 of 33485 relevant lines covered (60.52%)

3.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

25.24
/electrum/submarine_swaps.py
1
import asyncio
5✔
2
import json
5✔
3
import os
5✔
4
import ssl
5✔
5
from typing import TYPE_CHECKING, Optional, Dict, Union, Sequence, Tuple, Iterable
5✔
6
from decimal import Decimal
5✔
7
import math
5✔
8
import time
5✔
9

10
import attr
5✔
11
import aiohttp
5✔
12

13
import electrum_ecc as ecc
5✔
14
from electrum_ecc import ECPrivkey
5✔
15

16
import electrum_aionostr as aionostr
5✔
17
from electrum_aionostr.event import Event
5✔
18
from electrum_aionostr.util import to_nip19
5✔
19

20
from collections import defaultdict
5✔
21

22

23
from . import lnutil
5✔
24
from .crypto import sha256, hash_160
5✔
25
from .bitcoin import (script_to_p2wsh, opcodes,
5✔
26
                      construct_witness)
27
from .transaction import PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint
5✔
28
from .transaction import script_GetOp, match_script_against_template, OPPushDataGeneric, OPPushDataPubkey
5✔
29
from .util import (log_exceptions, ignore_exceptions, BelowDustLimit, OldTaskGroup, age, ca_path,
5✔
30
                   gen_nostr_ann_pow, get_nostr_ann_pow_amount)
31
from .lnutil import REDEEM_AFTER_DOUBLE_SPENT_DELAY
5✔
32
from .bitcoin import dust_threshold, DummyAddress
5✔
33
from .logging import Logger
5✔
34
from .lnutil import hex_to_bytes
5✔
35
from .lnaddr import lndecode
5✔
36
from .json_db import StoredObject, stored_in
5✔
37
from . import constants
5✔
38
from .address_synchronizer import TX_HEIGHT_LOCAL
5✔
39
from .i18n import _
5✔
40

41
from .bitcoin import construct_script
5✔
42
from .crypto import ripemd
5✔
43
from .invoices import Invoice
5✔
44
from .network import TxBroadcastError
5✔
45
from .lnonion import OnionRoutingFailure, OnionFailureCode
5✔
46

47

48
if TYPE_CHECKING:
5✔
49
    from .network import Network
×
50
    from .wallet import Abstract_Wallet
×
51
    from .lnwatcher import LNWalletWatcher
×
52
    from .lnworker import LNWallet
×
53
    from .lnchannel import Channel
×
54
    from .simple_config import SimpleConfig
×
55

56

57

58
CLAIM_FEE_SIZE = 136
5✔
59
LOCKUP_FEE_SIZE = 153 # assuming 1 output, 2 outputs
5✔
60

61
MIN_LOCKTIME_DELTA = 60
5✔
62
LOCKTIME_DELTA_REFUND = 70
5✔
63
MAX_LOCKTIME_DELTA = 100
5✔
64
MIN_FINAL_CLTV_DELTA_FOR_CLIENT = 3 * 144  # note: put in invoice, but is not enforced by receiver in lnpeer.py
5✔
65
assert MIN_LOCKTIME_DELTA <= LOCKTIME_DELTA_REFUND <= MAX_LOCKTIME_DELTA
5✔
66
assert MAX_LOCKTIME_DELTA < lnutil.MIN_FINAL_CLTV_DELTA_ACCEPTED
5✔
67
assert MAX_LOCKTIME_DELTA < lnutil.MIN_FINAL_CLTV_DELTA_FOR_INVOICE
5✔
68
assert MAX_LOCKTIME_DELTA < MIN_FINAL_CLTV_DELTA_FOR_CLIENT
5✔
69

70

71
# The script of the reverse swaps has one extra check in it to verify
72
# that the length of the preimage is 32. This is required because in
73
# the reverse swaps the preimage is generated by the user and to
74
# settle the hold invoice, you need a preimage with 32 bytes . If that
75
# check wasn't there the user could generate a preimage with a
76
# different length which would still allow for claiming the onchain
77
# coins but the invoice couldn't be settled
78

79
WITNESS_TEMPLATE_REVERSE_SWAP = [
5✔
80
    opcodes.OP_SIZE,
81
    OPPushDataGeneric(None),
82
    opcodes.OP_EQUAL,
83
    opcodes.OP_IF,
84
    opcodes.OP_HASH160,
85
    OPPushDataGeneric(lambda x: x == 20),
86
    opcodes.OP_EQUALVERIFY,
87
    OPPushDataPubkey,
88
    opcodes.OP_ELSE,
89
    opcodes.OP_DROP,
90
    OPPushDataGeneric(None),
91
    opcodes.OP_CHECKLOCKTIMEVERIFY,
92
    opcodes.OP_DROP,
93
    OPPushDataPubkey,
94
    opcodes.OP_ENDIF,
95
    opcodes.OP_CHECKSIG
96
]
97

98

99
def check_reverse_redeem_script(
5✔
100
    *,
101
    redeem_script: bytes,
102
    lockup_address: str,
103
    payment_hash: bytes,
104
    locktime: int,
105
    refund_pubkey: bytes = None,
106
    claim_pubkey: bytes = None,
107
) -> None:
108
    parsed_script = [x for x in script_GetOp(redeem_script)]
×
109
    if not match_script_against_template(redeem_script, WITNESS_TEMPLATE_REVERSE_SWAP):
×
110
        raise Exception("rswap check failed: scriptcode does not match template")
×
111
    if script_to_p2wsh(redeem_script) != lockup_address:
×
112
        raise Exception("rswap check failed: inconsistent scriptcode and address")
×
113
    if ripemd(payment_hash) != parsed_script[5][1]:
×
114
        raise Exception("rswap check failed: our preimage not in script")
×
115
    if claim_pubkey and claim_pubkey != parsed_script[7][1]:
×
116
        raise Exception("rswap check failed: our pubkey not in script")
×
117
    if refund_pubkey and refund_pubkey != parsed_script[13][1]:
×
118
        raise Exception("rswap check failed: our pubkey not in script")
×
119
    if locktime != int.from_bytes(parsed_script[10][1], byteorder='little'):
×
120
        raise Exception("rswap check failed: inconsistent locktime and script")
×
121

122

123
class SwapServerError(Exception):
5✔
124
    def __str__(self):
5✔
125
        return _("The swap server errored or is unreachable.")
×
126

127
def now():
5✔
128
    return int(time.time())
×
129

130
@attr.s
5✔
131
class SwapFees:
5✔
132
    percentage = attr.ib(type=int)
5✔
133
    normal_fee = attr.ib(type=int)
5✔
134
    lockup_fee = attr.ib(type=int)
5✔
135
    claim_fee = attr.ib(type=int)
5✔
136
    min_amount = attr.ib(type=int)
5✔
137
    max_amount = attr.ib(type=int)
5✔
138

139
@stored_in('submarine_swaps')
5✔
140
@attr.s
5✔
141
class SwapData(StoredObject):
5✔
142
    is_reverse = attr.ib(type=bool)  # for whoever is running code (PoV of client or server)
5✔
143
    locktime = attr.ib(type=int)
5✔
144
    onchain_amount = attr.ib(type=int)  # in sats
5✔
145
    lightning_amount = attr.ib(type=int)  # in sats
5✔
146
    redeem_script = attr.ib(type=bytes, converter=hex_to_bytes)
5✔
147
    preimage = attr.ib(type=Optional[bytes], converter=hex_to_bytes)
5✔
148
    prepay_hash = attr.ib(type=Optional[bytes], converter=hex_to_bytes)
5✔
149
    privkey = attr.ib(type=bytes, converter=hex_to_bytes)
5✔
150
    lockup_address = attr.ib(type=str)
5✔
151
    receive_address = attr.ib(type=str)
5✔
152
    funding_txid = attr.ib(type=Optional[str])
5✔
153
    spending_txid = attr.ib(type=Optional[str])
5✔
154
    is_redeemed = attr.ib(type=bool)
5✔
155

156
    _funding_prevout = None  # type: Optional[TxOutpoint]  # for RBF
5✔
157
    _payment_hash = None
5✔
158
    _zeroconf = False
5✔
159

160
    @property
5✔
161
    def payment_hash(self) -> bytes:
5✔
162
        return self._payment_hash
×
163

164
    def is_funded(self) -> bool:
5✔
165
        return self.funding_txid is not None
×
166

167

168
def create_claim_tx(
5✔
169
        *,
170
        txin: PartialTxInput,
171
        swap: SwapData,
172
        config: 'SimpleConfig',
173
) -> PartialTransaction:
174
    """Create tx to either claim successful reverse-swap,
175
    or to get refunded for timed-out forward-swap.
176
    """
177
    # FIXME the mining fee should depend on swap.is_reverse.
178
    #       the txs are not the same size...
179
    amount_sat = txin.value_sats() - SwapManager._get_fee(size=CLAIM_FEE_SIZE, config=config)
5✔
180
    if amount_sat < dust_threshold():
5✔
181
        raise BelowDustLimit()
×
182
    txin, locktime = SwapManager.create_claim_txin(txin=txin, swap=swap, config=config)
5✔
183
    txout = PartialTxOutput.from_address_and_value(swap.receive_address, amount_sat)
5✔
184
    tx = PartialTransaction.from_io([txin], [txout], version=2, locktime=locktime)
5✔
185
    sig = tx.sign_txin(0, txin.privkey)
5✔
186
    txin.script_sig = b''
5✔
187
    txin.witness = txin.make_witness(sig)
5✔
188
    assert tx.is_complete()
5✔
189
    return tx
5✔
190

191

192
class SwapManager(Logger):
5✔
193

194
    network: Optional['Network'] = None
5✔
195
    lnwatcher: Optional['LNWalletWatcher'] = None
5✔
196

197
    def __init__(self, *, wallet: 'Abstract_Wallet', lnworker: 'LNWallet'):
5✔
198
        Logger.__init__(self)
5✔
199
        self.normal_fee = None
5✔
200
        self.lockup_fee = None
5✔
201
        self.claim_fee = None # part of the boltz prococol, not used by Electrum
5✔
202
        self.percentage = None
5✔
203
        self._min_amount = None
5✔
204
        self._max_amount = None
5✔
205

206
        self.wallet = wallet
5✔
207
        self.config = wallet.config
5✔
208
        self.lnworker = lnworker
5✔
209
        self.config = wallet.config
5✔
210
        self.taskgroup = OldTaskGroup()
5✔
211
        self.dummy_address = DummyAddress.SWAP
5✔
212

213
        self.swaps = self.wallet.db.get_dict('submarine_swaps')  # type: Dict[str, SwapData]
5✔
214
        self._swaps_by_funding_outpoint = {}  # type: Dict[TxOutpoint, SwapData]
5✔
215
        self._swaps_by_lockup_address = {}  # type: Dict[str, SwapData]
5✔
216
        for payment_hash_hex, swap in self.swaps.items():
5✔
217
            payment_hash = bytes.fromhex(payment_hash_hex)
×
218
            swap._payment_hash = payment_hash
×
219
            self._add_or_reindex_swap(swap)
×
220
            if not swap.is_reverse and not swap.is_redeemed:
×
221
                self.lnworker.register_hold_invoice(payment_hash, self.hold_invoice_callback)
×
222

223
        self.prepayments = {}  # type: Dict[bytes, bytes] # fee_rhash -> rhash
5✔
224
        for k, swap in self.swaps.items():
5✔
225
            if swap.prepay_hash is not None:
×
226
                self.prepayments[swap.prepay_hash] = bytes.fromhex(k)
×
227
        self.is_server = False # overriden by swapserver plugin if enabled
5✔
228
        self.is_initialized = asyncio.Event()
5✔
229

230
    def start_network(self, network: 'Network'):
5✔
231
        assert network
×
232
        if self.network is not None:
×
233
            self.logger.info('start_network: already started')
×
234
            return
×
235
        self.logger.info('start_network: starting main loop')
×
236
        self.network = network
×
237
        self.lnwatcher = self.lnworker.lnwatcher
×
238
        for k, swap in self.swaps.items():
×
239
            if swap.is_redeemed:
×
240
                continue
×
241
            self.add_lnwatcher_callback(swap)
×
242
        asyncio.run_coroutine_threadsafe(self.main_loop(), self.network.asyncio_loop)
×
243

244
    @log_exceptions
5✔
245
    async def run_nostr_server(self):
5✔
246
        await self.set_nostr_proof_of_work()
×
247
        with NostrTransport(self.config, self, self.lnworker.nostr_keypair) as transport:
×
248
            await transport.is_connected.wait()
×
249
            self.logger.info(f'nostr is connected')
×
250
            while True:
×
251
                # todo: publish everytime fees have changed
252
                self.server_update_pairs()
×
253
                await transport.publish_offer(self)
×
254
                await asyncio.sleep(transport.OFFER_UPDATE_INTERVAL_SEC)
×
255

256
    @log_exceptions
5✔
257
    async def main_loop(self):
5✔
258
        tasks = [self.pay_pending_invoices()]
×
259
        if self.is_server:
×
260
            # nostr and http are not mutually exclusive
261
            if self.config.SWAPSERVER_PORT:
×
262
                tasks.append(self.http_server.run())
×
263
            if self.config.NOSTR_RELAYS:
×
264
                tasks.append(self.run_nostr_server())
×
265

266
        async with self.taskgroup as group:
×
267
            for task in tasks:
×
268
                await group.spawn(task)
×
269

270
    async def stop(self):
5✔
271
        await self.taskgroup.cancel_remaining()
×
272

273
    def create_transport(self) -> 'SwapServerTransport':
5✔
274
        from .lnutil import generate_random_keypair
×
275
        if self.config.SWAPSERVER_URL:
×
276
            return HttpTransport(self.config, self)
×
277
        else:
278
            keypair = self.lnworker.nostr_keypair if self.is_server else generate_random_keypair()
×
279
            return NostrTransport(self.config, self, keypair)
×
280

281
    async def set_nostr_proof_of_work(self) -> None:
5✔
282
        current_pow = get_nostr_ann_pow_amount(
×
283
            self.lnworker.nostr_keypair.pubkey[1:],
284
            self.config.SWAPSERVER_ANN_POW_NONCE
285
        )
286
        if current_pow >= self.config.SWAPSERVER_POW_TARGET:
×
287
            self.logger.debug(f"Reusing existing PoW nonce for nostr announcement.")
×
288
            return
×
289

290
        self.logger.info(f"Generating PoW for nostr announcement. Target: {self.config.SWAPSERVER_POW_TARGET}")
×
291
        nonce, pow_amount = await gen_nostr_ann_pow(
×
292
            self.lnworker.nostr_keypair.pubkey[1:],  # pubkey without prefix
293
            self.config.SWAPSERVER_POW_TARGET,
294
        )
295
        self.logger.debug(f"Found {pow_amount} bits of work for Nostr announcement.")
×
296
        self.config.SWAPSERVER_ANN_POW_NONCE = nonce
×
297

298
    async def pay_invoice(self, key):
5✔
299
        self.logger.info(f'trying to pay invoice {key}')
×
300
        self.invoices_to_pay[key] = 1000000000000 # lock
×
301
        try:
×
302
            invoice = self.wallet.get_invoice(key)
×
303
            success, log = await self.lnworker.pay_invoice(invoice.lightning_invoice, attempts=10)
×
304
        except Exception as e:
×
305
            self.logger.info(f'exception paying {key}, will not retry')
×
306
            self.invoices_to_pay.pop(key, None)
×
307
            return
×
308
        if not success:
×
309
            self.logger.info(f'failed to pay {key}, will retry in 10 minutes')
×
310
            self.invoices_to_pay[key] = now() + 600
×
311
        else:
312
            self.logger.info(f'paid invoice {key}')
×
313
            self.invoices_to_pay.pop(key, None)
×
314

315
    async def pay_pending_invoices(self):
5✔
316
        self.invoices_to_pay = {}
×
317
        while True:
×
318
            await asyncio.sleep(5)
×
319
            for key, not_before in list(self.invoices_to_pay.items()):
×
320
                if now() < not_before:
×
321
                    continue
×
322
                await self.taskgroup.spawn(self.pay_invoice(key))
×
323

324
    def cancel_normal_swap(self, swap: SwapData):
5✔
325
        """ we must not have broadcast the funding tx """
326
        if swap is None:
×
327
            return
×
328
        if swap.is_funded():
×
329
            self.logger.info(f'cannot cancel swap {swap.payment_hash.hex()}: already funded')
×
330
            return
×
331
        self._fail_swap(swap, 'user cancelled')
×
332

333
    def _fail_swap(self, swap: SwapData, reason: str):
5✔
334
        self.logger.info(f'failing swap {swap.payment_hash.hex()}: {reason}')
×
335
        if not swap.is_reverse and swap.payment_hash in self.lnworker.hold_invoice_callbacks:
×
336
            self.lnworker.unregister_hold_invoice(swap.payment_hash)
×
337
            payment_secret = self.lnworker.get_payment_secret(swap.payment_hash)
×
338
            payment_key = swap.payment_hash + payment_secret
×
339
            e = OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'')
×
340
            self.lnworker.save_forwarding_failure(payment_key.hex(), failure_message=e)
×
341
        self.lnwatcher.remove_callback(swap.lockup_address)
×
342
        if not swap.is_funded():
×
343
            self.swaps.pop(swap.payment_hash.hex())
×
344

345
    @log_exceptions
5✔
346
    async def _claim_swap(self, swap: SwapData) -> None:
5✔
347
        assert self.network
×
348
        assert self.lnwatcher
×
349
        if not self.lnwatcher.adb.is_up_to_date():
×
350
            return
×
351
        current_height = self.network.get_local_height()
×
352
        remaining_time = swap.locktime - current_height
×
353
        txos = self.lnwatcher.adb.get_addr_outputs(swap.lockup_address)
×
354

355
        for txin in txos.values():
×
356
            if swap.is_reverse and txin.value_sats() < swap.onchain_amount:
×
357
                # amount too low, we must not reveal the preimage
358
                continue
×
359
            break
×
360
        else:
361
            # swap not funded.
362
            txin = None
×
363
            # if it is a normal swap, we might have double spent the funding tx
364
            # in that case we need to fail the HTLCs
365
            if remaining_time <= 0:
×
366
                self._fail_swap(swap, 'expired')
×
367

368
        if txin:
×
369
            # the swap is funded
370
            # note: swap.funding_txid can change due to RBF, it will get updated here:
371
            swap.funding_txid = txin.prevout.txid.hex()
×
372
            swap._funding_prevout = txin.prevout
×
373
            self._add_or_reindex_swap(swap)  # to update _swaps_by_funding_outpoint
×
374
            funding_height = self.lnwatcher.adb.get_tx_height(txin.prevout.txid.hex())
×
375
            spent_height = txin.spent_height
×
376
            should_bump_fee = False
×
377
            if spent_height is not None:
×
378
                swap.spending_txid = txin.spent_txid
×
379
                if spent_height > 0:
×
380
                    if current_height - spent_height > REDEEM_AFTER_DOUBLE_SPENT_DELAY:
×
381
                        self.logger.info(f'stop watching swap {swap.lockup_address}')
×
382
                        self.lnwatcher.remove_callback(swap.lockup_address)
×
383
                        swap.is_redeemed = True
×
384
                elif spent_height == TX_HEIGHT_LOCAL:
×
385
                    if funding_height.conf > 0 or (swap.is_reverse and swap._zeroconf):
×
386
                        tx = self.lnwatcher.adb.get_transaction(txin.spent_txid)
×
387
                        try:
×
388
                            await self.network.broadcast_transaction(tx)
×
389
                        except TxBroadcastError:
×
390
                            self.logger.info(f'error broadcasting claim tx {txin.spent_txid}')
×
391
                    elif funding_height.height == TX_HEIGHT_LOCAL:
×
392
                        # the funding tx was double spent.
393
                        # this will remove both funding and child (spending tx) from adb
394
                        self.lnwatcher.adb.remove_transaction(swap.funding_txid)
×
395
                        swap.funding_txid = None
×
396
                        swap.spending_txid = None
×
397
                else:
398
                    # spending tx is in mempool
399
                    pass
×
400

401
            if not swap.is_reverse:
×
402
                if swap.preimage is None and spent_height is not None:
×
403
                    # extract the preimage, add it to lnwatcher
404
                    claim_tx = self.lnwatcher.adb.get_transaction(txin.spent_txid)
×
405
                    preimage = claim_tx.inputs()[0].witness_elements()[1]
×
406
                    if sha256(preimage) == swap.payment_hash:
×
407
                        swap.preimage = preimage
×
408
                        self.logger.info(f'found preimage: {preimage.hex()}')
×
409
                        self.lnworker.preimages[swap.payment_hash.hex()] = preimage.hex()
×
410
                        # note: we must check the payment secret before we broadcast the funding tx
411
                    else:
412
                        # this is our refund tx
413
                        if spent_height > 0:
×
414
                            self.logger.info(f'refund tx confirmed: {txin.spent_txid} {spent_height}')
×
415
                            self._fail_swap(swap, 'refund tx confirmed')
×
416
                            return
×
417
                        else:
418
                            claim_tx.add_info_from_wallet(self.wallet)
×
419
                            claim_tx_fee = claim_tx.get_fee()
×
420
                            recommended_fee = self.get_claim_fee()
×
421
                            if claim_tx_fee * 1.1 < recommended_fee:
×
422
                                should_bump_fee = True
×
423
                                self.logger.info(f'claim tx fee too low {claim_tx_fee} < {recommended_fee}. we will bump the fee')
×
424

425
                if remaining_time > 0:
×
426
                    # too early for refund
427
                    return
×
428
                if swap.preimage:
×
429
                    # we have been paid. do not try to get refund.
430
                    return
×
431
            else:
432
                if swap.preimage is None:
×
433
                    swap.preimage = self.lnworker.get_preimage(swap.payment_hash)
×
434
                if swap.preimage is None:
×
435
                    if funding_height.conf <= 0:
×
436
                        return
×
437
                    key = swap.payment_hash.hex()
×
438
                    if remaining_time <= MIN_LOCKTIME_DELTA:
×
439
                        if key in self.invoices_to_pay:
×
440
                            # fixme: should consider cltv of ln payment
441
                            self.logger.info(f'locktime too close {key} {remaining_time}')
×
442
                            self.invoices_to_pay.pop(key, None)
×
443
                        return
×
444
                    if key not in self.invoices_to_pay:
×
445
                        self.invoices_to_pay[key] = 0
×
446
                    return
×
447

448
                if self.network.config.TEST_SWAPSERVER_REFUND:
×
449
                    # for testing: do not create claim tx
450
                    return
×
451

452
            if spent_height is not None and not should_bump_fee:
×
453
                return
×
454
            try:
×
455
                tx = create_claim_tx(txin=txin, swap=swap, config=self.wallet.config)
×
456
            except BelowDustLimit:
×
457
                self.logger.info('utxo value below dust threshold')
×
458
                return
×
459
            self.logger.info(f'adding claim tx {tx.txid()}')
×
460
            self.wallet.adb.add_transaction(tx)
×
461
            swap.spending_txid = tx.txid()
×
462
            if funding_height.conf > 0 or (swap.is_reverse and swap._zeroconf):
×
463
                try:
×
464
                    await self.network.broadcast_transaction(tx)
×
465
                except TxBroadcastError:
×
466
                    self.logger.info(f'error broadcasting claim tx {txin.spent_txid}')
×
467

468
    def get_claim_fee(self):
5✔
469
        return self.get_fee(CLAIM_FEE_SIZE)
×
470

471
    def get_fee(self, size):
5✔
472
        # note: 'size' is in vbytes
473
        return self._get_fee(size=size, config=self.wallet.config)
×
474

475
    @classmethod
5✔
476
    def _get_fee(cls, *, size, config: 'SimpleConfig'):
5✔
477
        return config.estimate_fee(size, allow_fallback_to_static_rates=True)
5✔
478

479
    def get_swap(self, payment_hash: bytes) -> Optional[SwapData]:
5✔
480
        # for history
481
        swap = self.swaps.get(payment_hash.hex())
×
482
        if swap:
×
483
            return swap
×
484
        payment_hash = self.prepayments.get(payment_hash)
×
485
        if payment_hash:
×
486
            return self.swaps.get(payment_hash.hex())
×
487

488
    def add_lnwatcher_callback(self, swap: SwapData) -> None:
5✔
489
        callback = lambda: self._claim_swap(swap)
×
490
        self.lnwatcher.add_callback(swap.lockup_address, callback)
×
491

492
    async def hold_invoice_callback(self, payment_hash: bytes) -> None:
5✔
493
        # note: this assumes the wallet has been unlocked
494
        key = payment_hash.hex()
×
495
        if key in self.swaps:
×
496
            swap = self.swaps[key]
×
497
            if not swap.is_funded():
×
498
                password = self.wallet.get_unlocked_password()
×
499
                for batch_rbf in [False]:
×
500
                    # FIXME: tx batching is disabled, because extra logic is needed to handle
501
                    # the case where the base tx gets mined.
502
                    tx = self.create_funding_tx(swap, None, password=password)
×
503
                    self.logger.info(f'adding funding_tx {tx.txid()}')
×
504
                    self.wallet.adb.add_transaction(tx)
×
505
                    try:
×
506
                        await self.broadcast_funding_tx(swap, tx)
×
507
                    except TxBroadcastError:
×
508
                        self.wallet.adb.remove_transaction(tx.txid())
×
509
                        continue
×
510
                    break
×
511

512
    def create_normal_swap(self, *, lightning_amount_sat: int, payment_hash: bytes, their_pubkey: bytes = None):
5✔
513
        """ server method """
514
        assert lightning_amount_sat
×
515
        locktime = self.network.get_local_height() + LOCKTIME_DELTA_REFUND
×
516
        our_privkey = os.urandom(32)
×
517
        our_pubkey = ECPrivkey(our_privkey).get_public_key_bytes(compressed=True)
×
518
        onchain_amount_sat = self._get_recv_amount(lightning_amount_sat, is_reverse=True) # what the client is going to receive
×
519
        redeem_script = construct_script(
×
520
            WITNESS_TEMPLATE_REVERSE_SWAP,
521
            {1:32, 5:ripemd(payment_hash), 7:their_pubkey, 10:locktime, 13:our_pubkey}
522
        )
523
        swap, invoice, prepay_invoice = self.add_normal_swap(
×
524
            redeem_script=redeem_script,
525
            locktime=locktime,
526
            onchain_amount_sat=onchain_amount_sat,
527
            lightning_amount_sat=lightning_amount_sat,
528
            payment_hash=payment_hash,
529
            our_privkey=our_privkey,
530
            prepay=True,
531
        )
532
        self.lnworker.register_hold_invoice(payment_hash, self.hold_invoice_callback)
×
533
        return swap, invoice, prepay_invoice
×
534

535
    def add_normal_swap(
5✔
536
            self, *,
537
            redeem_script: bytes,
538
            locktime: int,  # onchain
539
            onchain_amount_sat: int,
540
            lightning_amount_sat: int,
541
            payment_hash: bytes,
542
            our_privkey: bytes,
543
            prepay: bool,
544
            channels: Optional[Sequence['Channel']] = None,
545
            min_final_cltv_expiry_delta: Optional[int] = None,
546
    ) -> Tuple[SwapData, str, Optional[str]]:
547
        """creates a hold invoice"""
548
        if prepay:
×
549
            prepay_amount_sat = self.get_claim_fee() * 2
×
550
            invoice_amount_sat = lightning_amount_sat - prepay_amount_sat
×
551
        else:
552
            invoice_amount_sat = lightning_amount_sat
×
553

554
        _, invoice = self.lnworker.get_bolt11_invoice(
×
555
            payment_hash=payment_hash,
556
            amount_msat=invoice_amount_sat * 1000,
557
            message='Submarine swap',
558
            expiry=300,
559
            fallback_address=None,
560
            channels=channels,
561
            min_final_cltv_expiry_delta=min_final_cltv_expiry_delta,
562
        )
563
        # add payment info to lnworker
564
        self.lnworker.add_payment_info_for_hold_invoice(payment_hash, invoice_amount_sat)
×
565

566
        if prepay:
×
567
            prepay_hash = self.lnworker.create_payment_info(amount_msat=prepay_amount_sat*1000)
×
568
            _, prepay_invoice = self.lnworker.get_bolt11_invoice(
×
569
                payment_hash=prepay_hash,
570
                amount_msat=prepay_amount_sat * 1000,
571
                message='Submarine swap mining fees',
572
                expiry=300,
573
                fallback_address=None,
574
                channels=channels,
575
                min_final_cltv_expiry_delta=min_final_cltv_expiry_delta,
576
            )
577
            self.lnworker.bundle_payments([payment_hash, prepay_hash])
×
578
            self.prepayments[prepay_hash] = payment_hash
×
579
        else:
580
            prepay_invoice = None
×
581
            prepay_hash = None
×
582

583
        lockup_address = script_to_p2wsh(redeem_script)
×
584
        receive_address = self.wallet.get_receiving_address()
×
585
        swap = SwapData(
×
586
            redeem_script=redeem_script,
587
            locktime = locktime,
588
            privkey = our_privkey,
589
            preimage = None,
590
            prepay_hash = prepay_hash,
591
            lockup_address = lockup_address,
592
            onchain_amount = onchain_amount_sat,
593
            receive_address = receive_address,
594
            lightning_amount = lightning_amount_sat,
595
            is_reverse = False,
596
            is_redeemed = False,
597
            funding_txid = None,
598
            spending_txid = None,
599
        )
600
        swap._payment_hash = payment_hash
×
601
        self._add_or_reindex_swap(swap)
×
602
        self.add_lnwatcher_callback(swap)
×
603
        return swap, invoice, prepay_invoice
×
604

605
    def create_reverse_swap(self, *, lightning_amount_sat: int, their_pubkey: bytes) -> SwapData:
5✔
606
        """ server method. """
607
        assert lightning_amount_sat is not None
×
608
        locktime = self.network.get_local_height() + LOCKTIME_DELTA_REFUND
×
609
        privkey = os.urandom(32)
×
610
        our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
×
611
        onchain_amount_sat = self._get_send_amount(lightning_amount_sat, is_reverse=False)
×
612
        preimage = os.urandom(32)
×
613
        payment_hash = sha256(preimage)
×
614
        redeem_script = construct_script(
×
615
            WITNESS_TEMPLATE_REVERSE_SWAP,
616
            {1:32, 5:ripemd(payment_hash), 7:our_pubkey, 10:locktime, 13:their_pubkey}
617
        )
618
        swap = self.add_reverse_swap(
×
619
            redeem_script=redeem_script,
620
            locktime=locktime,
621
            privkey=privkey,
622
            preimage=preimage,
623
            payment_hash=payment_hash,
624
            prepay_hash=None,
625
            onchain_amount_sat=onchain_amount_sat,
626
            lightning_amount_sat=lightning_amount_sat)
627
        return swap
×
628

629
    def add_reverse_swap(
5✔
630
        self,
631
        *,
632
        redeem_script: bytes,
633
        locktime: int,  # onchain
634
        privkey: bytes,
635
        lightning_amount_sat: int,
636
        onchain_amount_sat: int,
637
        preimage: bytes,
638
        payment_hash: bytes,
639
        prepay_hash: Optional[bytes] = None,
640
    ) -> SwapData:
641
        lockup_address = script_to_p2wsh(redeem_script)
×
642
        receive_address = self.wallet.get_receiving_address()
×
643
        swap = SwapData(
×
644
            redeem_script = redeem_script,
645
            locktime = locktime,
646
            privkey = privkey,
647
            preimage = preimage,
648
            prepay_hash = prepay_hash,
649
            lockup_address = lockup_address,
650
            onchain_amount = onchain_amount_sat,
651
            receive_address = receive_address,
652
            lightning_amount = lightning_amount_sat,
653
            is_reverse = True,
654
            is_redeemed = False,
655
            funding_txid = None,
656
            spending_txid = None,
657
        )
658
        if prepay_hash:
×
659
            self.prepayments[prepay_hash] = payment_hash
×
660
        swap._payment_hash = payment_hash
×
661
        self._add_or_reindex_swap(swap)
×
662
        self.add_lnwatcher_callback(swap)
×
663
        return swap
×
664

665
    def server_add_swap_invoice(self, request):
5✔
666
        invoice = request['invoice']
×
667
        invoice = Invoice.from_bech32(invoice)
×
668
        key = invoice.rhash
×
669
        payment_hash = bytes.fromhex(key)
×
670
        assert key in self.swaps
×
671
        swap = self.swaps[key]
×
672
        assert swap.lightning_amount == int(invoice.get_amount_sat())
×
673
        self.wallet.save_invoice(invoice)
×
674
        # check that we have the preimage
675
        assert sha256(swap.preimage) == payment_hash
×
676
        assert swap.spending_txid is None
×
677
        self.invoices_to_pay[key] = 0
×
678
        return {}
×
679

680
    async def normal_swap(
5✔
681
            self,
682
            *,
683
            lightning_amount_sat: int,
684
            expected_onchain_amount_sat: int,
685
            password,
686
            tx: PartialTransaction = None,
687
            channels = None,
688
    ) -> Optional[str]:
689
        """send on-chain BTC, receive on Lightning
690

691
        Old (removed) flow:
692
        - User generates an LN invoice with RHASH, and knows preimage.
693
        - User creates on-chain output locked to RHASH.
694
        - Server pays LN invoice. User reveals preimage.
695
        - Server spends the on-chain output using preimage.
696
        cltv safety requirement: (onchain_locktime > LN_locktime),   otherwise server is vulnerable
697

698
        New flow:
699
         - User requests swap
700
         - Server creates preimage, sends RHASH to user
701
         - User creates hold invoice, sends it to server
702
         - Server sends HTLC, user holds it
703
         - User creates on-chain output locked to RHASH
704
         - Server spends the on-chain output using preimage (revealing the preimage)
705
         - User fulfills HTLC using preimage
706
        cltv safety requirement: (onchain_locktime < LN_locktime),   otherwise client is vulnerable
707
        """
708
        assert self.network
×
709
        assert self.lnwatcher
×
710
        swap, invoice = await self.request_normal_swap(
×
711
            lightning_amount_sat=lightning_amount_sat,
712
            expected_onchain_amount_sat=expected_onchain_amount_sat,
713
            channels=channels,
714
        )
715
        tx = self.create_funding_tx(swap, tx, password=password)
×
716
        return await self.wait_for_htlcs_and_broadcast(swap=swap, invoice=invoice, tx=tx)
×
717

718
    async def request_normal_swap(
5✔
719
            self, transport,
720
        *,
721
        lightning_amount_sat: int,
722
        expected_onchain_amount_sat: int,
723
        channels: Optional[Sequence['Channel']] = None,
724
    ) -> Tuple[SwapData, str]:
725
        await self.is_initialized.wait() # add timeout
×
726
        refund_privkey = os.urandom(32)
×
727
        refund_pubkey = ECPrivkey(refund_privkey).get_public_key_bytes(compressed=True)
×
728
        self.logger.info('requesting preimage hash for swap')
×
729
        request_data = {
×
730
            "invoiceAmount": lightning_amount_sat,
731
            "refundPublicKey": refund_pubkey.hex()
732
        }
733
        data = await transport.send_request_to_server('createnormalswap', request_data)
×
734
        payment_hash = bytes.fromhex(data["preimageHash"])
×
735

736
        zeroconf = data["acceptZeroConf"]
×
737
        onchain_amount = data["expectedAmount"]
×
738
        locktime = data["timeoutBlockHeight"]
×
739
        lockup_address = data["address"]
×
740
        redeem_script = bytes.fromhex(data["redeemScript"])
×
741
        # verify redeem_script is built with our pubkey and preimage
742
        check_reverse_redeem_script(
×
743
            redeem_script=redeem_script,
744
            lockup_address=lockup_address,
745
            payment_hash=payment_hash,
746
            locktime=locktime,
747
            refund_pubkey=refund_pubkey,
748
        )
749

750
        # check that onchain_amount is not more than what we estimated
751
        if onchain_amount > expected_onchain_amount_sat:
×
752
            raise Exception(f"fswap check failed: onchain_amount is more than what we estimated: "
×
753
                            f"{onchain_amount} > {expected_onchain_amount_sat}")
754
        # verify that they are not locking up funds for too long
755
        if locktime - self.network.get_local_height() > MAX_LOCKTIME_DELTA:
×
756
            raise Exception("fswap check failed: locktime too far in future")
×
757

758
        swap, invoice, _ = self.add_normal_swap(
×
759
            redeem_script=redeem_script,
760
            locktime=locktime,
761
            lightning_amount_sat=lightning_amount_sat,
762
            onchain_amount_sat=onchain_amount,
763
            payment_hash=payment_hash,
764
            our_privkey=refund_privkey,
765
            prepay=False,
766
            channels=channels,
767
            # When the client is doing a normal swap, we create a ln-invoice with larger than usual final_cltv_delta.
768
            # If the user goes offline after broadcasting the funding tx (but before it is mined and
769
            # the server claims it), they need to come back online before the held ln-htlc expires (see #8940).
770
            # If the held ln-htlc expires, and the funding tx got confirmed, the server will have claimed the onchain
771
            # funds, and the ln-htlc will be timed out onchain (and channel force-closed). i.e. the user loses the swap
772
            # amount. Increasing the final_cltv_delta the user puts in the invoice extends this critical window.
773
            min_final_cltv_expiry_delta=MIN_FINAL_CLTV_DELTA_FOR_CLIENT,
774
        )
775
        return swap, invoice
×
776

777
    async def wait_for_htlcs_and_broadcast(
5✔
778
            self, transport,
779
        *,
780
        swap: SwapData,
781
        invoice: str,
782
        tx: Transaction,
783
    ) -> Optional[str]:
784
        await transport.is_connected.wait()
×
785
        payment_hash = swap.payment_hash
×
786
        refund_pubkey = ECPrivkey(swap.privkey).get_public_key_bytes(compressed=True)
×
787
        async def callback(payment_hash):
×
788
            # FIXME what if this raises, e.g. TxBroadcastError?
789
            #       We will never retry the hold-invoice-callback.
790
            await self.broadcast_funding_tx(swap, tx)
×
791

792
        self.lnworker.register_hold_invoice(payment_hash, callback)
×
793

794
        # send invoice to server and wait for htlcs
795
        request_data = {
×
796
            "preimageHash": payment_hash.hex(),
797
            "invoice": invoice,
798
            "refundPublicKey": refund_pubkey.hex(),
799
        }
800
        data = await transport.send_request_to_server('addswapinvoice', request_data)
×
801
        # wait for funding tx
802
        lnaddr = lndecode(invoice)
×
803
        while swap.funding_txid is None and not lnaddr.is_expired():
×
804
            await asyncio.sleep(0.1)
×
805
        return swap.funding_txid
×
806

807
    def create_funding_output(self, swap):
5✔
808
        return PartialTxOutput.from_address_and_value(swap.lockup_address, swap.onchain_amount)
×
809

810
    def create_funding_tx(
5✔
811
        self,
812
        swap: SwapData,
813
        tx: Optional[PartialTransaction],
814
        *,
815
        password,
816
    ) -> PartialTransaction:
817
        # create funding tx
818
        # note: rbf must not decrease payment
819
        # this is taken care of in wallet._is_rbf_allowed_to_touch_tx_output
820
        if tx is None:
×
821
            funding_output = self.create_funding_output(swap)
×
822
            tx = self.wallet.create_transaction(
×
823
                outputs=[funding_output],
824
                rbf=True,
825
                password=password,
826
            )
827
        else:
828
            tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address)
×
829
            tx.set_rbf(True)
×
830
            self.wallet.sign_transaction(tx, password)
×
831
        return tx
×
832

833
    @log_exceptions
5✔
834
    async def request_swap_for_tx(self, transport, tx: 'PartialTransaction') -> Optional[Tuple[SwapData, str, PartialTransaction]]:
5✔
835
        for o in tx.outputs():
×
836
            if o.address == self.dummy_address:
×
837
                change_amount = o.value
×
838
                break
×
839
        else:
840
            return
×
841
        await self.is_initialized.wait()
×
842
        lightning_amount_sat = self.get_recv_amount(change_amount, is_reverse=False)
×
843
        swap, invoice = await self.request_normal_swap(
×
844
            transport,
845
            lightning_amount_sat = lightning_amount_sat,
846
            expected_onchain_amount_sat=change_amount)
847
        tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address)
×
848
        return swap, invoice, tx
×
849

850
    @log_exceptions
5✔
851
    async def broadcast_funding_tx(self, swap: SwapData, tx: Transaction) -> None:
5✔
852
        swap.funding_txid = tx.txid()
×
853
        await self.network.broadcast_transaction(tx)
×
854

855
    async def reverse_swap(
5✔
856
            self, transport,
857
            *,
858
            lightning_amount_sat: int,
859
            expected_onchain_amount_sat: int,
860
            zeroconf: bool=False,
861
            channels: Optional[Sequence['Channel']] = None,
862
    ) -> Optional[str]:
863
        """send on Lightning, receive on-chain
864

865
        - User generates preimage, RHASH. Sends RHASH to server.
866
        - Server creates an LN invoice for RHASH.
867
        - User pays LN invoice - except server needs to hold the HTLC as preimage is unknown.
868
            - if the server requested a fee prepayment (using 'minerFeeInvoice'),
869
              the server will have the preimage for that. The user will send HTLCs for both the main RHASH,
870
              and for the fee prepayment. Once both MPP sets arrive at the server, the server will fulfill
871
              the HTLCs for the fee prepayment (before creating the on-chain output).
872
        - Server creates on-chain output locked to RHASH.
873
        - User spends on-chain output, revealing preimage.
874
        - Server fulfills HTLC using preimage.
875

876
        Note: expected_onchain_amount_sat is BEFORE deducting the on-chain claim tx fee.
877
        """
878
        assert self.network
×
879
        assert self.lnwatcher
×
880
        privkey = os.urandom(32)
×
881
        our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
×
882
        preimage = os.urandom(32)
×
883
        payment_hash = sha256(preimage)
×
884
        request_data = {
×
885
            "type": "reversesubmarine",
886
            "pairId": "BTC/BTC",
887
            "orderSide": "buy",
888
            "invoiceAmount": lightning_amount_sat,
889
            "preimageHash": payment_hash.hex(),
890
            "claimPublicKey": our_pubkey.hex()
891
        }
892
        self.logger.debug(f'rswap: sending request for {lightning_amount_sat}')
×
893
        data = await transport.send_request_to_server('createswap', request_data)
×
894
        invoice = data['invoice']
×
895
        fee_invoice = data.get('minerFeeInvoice')
×
896
        lockup_address = data['lockupAddress']
×
897
        redeem_script = bytes.fromhex(data['redeemScript'])
×
898
        locktime = data['timeoutBlockHeight']
×
899
        onchain_amount = data["onchainAmount"]
×
900
        response_id = data['id']
×
901
        self.logger.debug(f'rswap: {response_id=}')
×
902
        # verify redeem_script is built with our pubkey and preimage
903
        check_reverse_redeem_script(
×
904
            redeem_script=redeem_script,
905
            lockup_address=lockup_address,
906
            payment_hash=payment_hash,
907
            locktime=locktime,
908
            refund_pubkey=None,
909
            claim_pubkey=our_pubkey,
910
        )
911
        # check that the onchain amount is what we expected
912
        if onchain_amount < expected_onchain_amount_sat:
×
913
            raise Exception(f"rswap check failed: onchain_amount is less than what we expected: "
×
914
                            f"{onchain_amount} < {expected_onchain_amount_sat}")
915
        # verify that we will have enough time to get our tx confirmed
916
        if locktime - self.network.get_local_height() <= MIN_LOCKTIME_DELTA:
×
917
            raise Exception("rswap check failed: locktime too close")
×
918
        # verify invoice payment_hash
919
        lnaddr = self.lnworker._check_invoice(invoice)
×
920
        invoice_amount = int(lnaddr.get_amount_sat())
×
921
        if lnaddr.paymenthash != payment_hash:
×
922
            raise Exception("rswap check failed: inconsistent RHASH and invoice")
×
923
        # check that the lightning amount is what we requested
924
        if fee_invoice:
×
925
            fee_lnaddr = self.lnworker._check_invoice(fee_invoice)
×
926
            invoice_amount += fee_lnaddr.get_amount_sat()
×
927
            prepay_hash = fee_lnaddr.paymenthash
×
928
        else:
929
            prepay_hash = None
×
930
        if int(invoice_amount) != lightning_amount_sat:
×
931
            raise Exception(f"rswap check failed: invoice_amount ({invoice_amount}) "
×
932
                            f"not what we requested ({lightning_amount_sat})")
933
        # save swap data to wallet file
934
        swap = self.add_reverse_swap(
×
935
            redeem_script=redeem_script,
936
            locktime=locktime,
937
            privkey=privkey,
938
            preimage=preimage,
939
            payment_hash=payment_hash,
940
            prepay_hash=prepay_hash,
941
            onchain_amount_sat=onchain_amount,
942
            lightning_amount_sat=lightning_amount_sat)
943
        swap._zeroconf = zeroconf
×
944
        # initiate fee payment.
945
        if fee_invoice:
×
946
            asyncio.ensure_future(self.lnworker.pay_invoice(fee_invoice))
×
947
        # we return if we detect funding
948
        async def wait_for_funding(swap):
×
949
            while swap.funding_txid is None:
×
950
                await asyncio.sleep(1)
×
951
        # initiate main payment
952
        tasks = [asyncio.create_task(self.lnworker.pay_invoice(invoice, channels=channels)), asyncio.create_task(wait_for_funding(swap))]
×
953
        await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
×
954
        return swap.funding_txid
×
955

956
    def _add_or_reindex_swap(self, swap: SwapData) -> None:
5✔
957
        if swap.payment_hash.hex() not in self.swaps:
×
958
            self.swaps[swap.payment_hash.hex()] = swap
×
959
        if swap._funding_prevout:
×
960
            self._swaps_by_funding_outpoint[swap._funding_prevout] = swap
×
961
        self._swaps_by_lockup_address[swap.lockup_address] = swap
×
962

963
    def server_update_pairs(self) -> None:
5✔
964
        """ for server """
965
        self.percentage = float(self.config.SWAPSERVER_FEE_MILLIONTHS) / 10000
×
966
        self._min_amount = 20000
×
967
        self._max_amount = 10000000
×
968
        self.normal_fee = self.get_fee(CLAIM_FEE_SIZE)
×
969
        self.lockup_fee = self.get_fee(LOCKUP_FEE_SIZE)
×
970
        self.claim_fee = self.get_fee(CLAIM_FEE_SIZE)
×
971

972
    def update_pairs(self, pairs):
5✔
973
        self.logger.info(f'updating fees {pairs}')
×
974
        self.normal_fee = pairs.normal_fee
×
975
        self.lockup_fee = pairs.lockup_fee
×
976
        self.claim_fee = pairs.claim_fee
×
977
        self.percentage = pairs.percentage
×
978
        self._min_amount = pairs.min_amount
×
979
        self._max_amount = pairs.max_amount
×
980
        self.is_initialized.set()
×
981

982
    def get_max_amount(self) -> int:
5✔
983
        """in satoshis"""
984
        return self._max_amount
×
985

986
    def get_min_amount(self) -> int:
5✔
987
        """in satoshis"""
988
        return self._min_amount
×
989

990
    def check_invoice_amount(self, x) -> bool:
5✔
991
        return self.get_min_amount() <= x <= self.get_max_amount()
×
992

993
    def _get_recv_amount(self, send_amount: Optional[int], *, is_reverse: bool) -> Optional[int]:
5✔
994
        """For a given swap direction and amount we send, returns how much we will receive.
995

996
        Note: in the reverse direction, the mining fee for the on-chain claim tx is NOT accounted for.
997
        In the reverse direction, the result matches what the swap server returns as response["onchainAmount"].
998
        """
999
        if send_amount is None:
×
1000
            return
×
1001
        x = Decimal(send_amount)
×
1002
        percentage = Decimal(self.percentage)
×
1003
        if is_reverse:
×
1004
            if not self.check_invoice_amount(x):
×
1005
                return
×
1006
            # see/ref:
1007
            # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L948
1008
            percentage_fee = math.ceil(percentage * x / 100)
×
1009
            base_fee = self.lockup_fee
×
1010
            x -= percentage_fee + base_fee
×
1011
            x = math.floor(x)
×
1012
            if x < dust_threshold():
×
1013
                return
×
1014
        else:
1015
            x -= self.normal_fee
×
1016
            percentage_fee = math.ceil(x * percentage / (100 + percentage))
×
1017
            x -= percentage_fee
×
1018
            if not self.check_invoice_amount(x):
×
1019
                return
×
1020
        x = int(x)
×
1021
        return x
×
1022

1023
    def _get_send_amount(self, recv_amount: Optional[int], *, is_reverse: bool) -> Optional[int]:
5✔
1024
        """For a given swap direction and amount we want to receive, returns how much we will need to send.
1025

1026
        Note: in the reverse direction, the mining fee for the on-chain claim tx is NOT accounted for.
1027
        In the forward direction, the result matches what the swap server returns as response["expectedAmount"].
1028
        """
1029
        if not recv_amount:
×
1030
            return
×
1031
        x = Decimal(recv_amount)
×
1032
        percentage = Decimal(self.percentage)
×
1033
        if is_reverse:
×
1034
            # see/ref:
1035
            # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L928
1036
            # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L958
1037
            base_fee = self.lockup_fee
×
1038
            x += base_fee
×
1039
            x = math.ceil(x / ((100 - percentage) / 100))
×
1040
            if not self.check_invoice_amount(x):
×
1041
                return
×
1042
        else:
1043
            if not self.check_invoice_amount(x):
×
1044
                return
×
1045
            # see/ref:
1046
            # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L708
1047
            # https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/rates/FeeProvider.ts#L90
1048
            percentage_fee = math.ceil(percentage * x / 100)
×
1049
            x += percentage_fee + self.normal_fee
×
1050
        x = int(x)
×
1051
        return x
×
1052

1053
    def get_recv_amount(self, send_amount: Optional[int], *, is_reverse: bool) -> Optional[int]:
5✔
1054
        # first, add percentage fee
1055
        recv_amount = self._get_recv_amount(send_amount, is_reverse=is_reverse)
×
1056
        # sanity check calculation can be inverted
1057
        if recv_amount is not None:
×
1058
            inverted_send_amount = self._get_send_amount(recv_amount, is_reverse=is_reverse)
×
1059
            # accept off-by ones as amt_rcv = recv_amt(send_amt(amt_rcv)) only up to +-1
1060
            if abs(send_amount - inverted_send_amount) > 1:
×
1061
                raise Exception(f"calc-invert-sanity-check failed. is_reverse={is_reverse}. "
×
1062
                                f"send_amount={send_amount} -> recv_amount={recv_amount} -> inverted_send_amount={inverted_send_amount}")
1063
        # second, add on-chain claim tx fee
1064
        if is_reverse and recv_amount is not None:
×
1065
            recv_amount -= self.get_claim_fee()
×
1066
        return recv_amount
×
1067

1068
    def get_send_amount(self, recv_amount: Optional[int], *, is_reverse: bool) -> Optional[int]:
5✔
1069
        # first, add on-chain claim tx fee
1070
        if is_reverse and recv_amount is not None:
×
1071
            recv_amount += self.get_claim_fee()
×
1072
        # second, add percentage fee
1073
        send_amount = self._get_send_amount(recv_amount, is_reverse=is_reverse)
×
1074
        # sanity check calculation can be inverted
1075
        if send_amount is not None:
×
1076
            inverted_recv_amount = self._get_recv_amount(send_amount, is_reverse=is_reverse)
×
1077
            if recv_amount != inverted_recv_amount:
×
1078
                raise Exception(f"calc-invert-sanity-check failed. is_reverse={is_reverse}. "
×
1079
                                f"recv_amount={recv_amount} -> send_amount={send_amount} -> inverted_recv_amount={inverted_recv_amount}")
1080
        return send_amount
×
1081

1082
    def get_swaps_by_funding_tx(self, tx: Transaction) -> Iterable[SwapData]:
5✔
1083
        swaps = []
×
1084
        for txout_idx, _txo in enumerate(tx.outputs()):
×
1085
            prevout = TxOutpoint(txid=bytes.fromhex(tx.txid()), out_idx=txout_idx)
×
1086
            if swap := self._swaps_by_funding_outpoint.get(prevout):
×
1087
                swaps.append(swap)
×
1088
        return swaps
×
1089

1090
    def get_swap_by_claim_tx(self, tx: Transaction) -> Optional[SwapData]:
5✔
1091
        # note: we don't batch claim txs atm (batch_rbf cannot combine them
1092
        #       as the inputs do not belong to the wallet)
1093
        if not (len(tx.inputs()) == 1 and len(tx.outputs()) == 1):
×
1094
            return None
×
1095
        txin = tx.inputs()[0]
×
1096
        return self.get_swap_by_claim_txin(txin)
×
1097

1098
    def get_swap_by_claim_txin(self, txin: TxInput) -> Optional[SwapData]:
5✔
1099
        return self._swaps_by_funding_outpoint.get(txin.prevout)
×
1100

1101
    def is_lockup_address_for_a_swap(self, addr: str) -> bool:
5✔
1102
        return bool(self._swaps_by_lockup_address.get(addr))
×
1103

1104
    @classmethod
5✔
1105
    def add_txin_info(cls, swap, txin: PartialTxInput) -> None:
5✔
1106
        """Add some info to a claim txin.
1107
        note: even without signing, this is useful for tx size estimation.
1108
        """
1109
        preimage = swap.preimage if swap.is_reverse else 0
5✔
1110
        witness_script = swap.redeem_script
5✔
1111
        txin.script_sig = b''
5✔
1112
        txin.witness_script = witness_script
5✔
1113
        sig_dummy = b'\x00' * 71  # DER-encoded ECDSA sig, with low S and low R
5✔
1114
        witness = [sig_dummy, preimage, witness_script]
5✔
1115
        txin.witness_sizehint = len(construct_witness(witness))
5✔
1116
        txin.nsequence = 0xffffffff - 2
5✔
1117

1118
    @classmethod
5✔
1119
    def create_claim_txin(
5✔
1120
        cls,
1121
        *,
1122
        txin: PartialTxInput,
1123
        swap: SwapData,
1124
        config: 'SimpleConfig',
1125
    ) -> PartialTransaction:
1126
        if swap.is_reverse:  # successful reverse swap
5✔
1127
            locktime = 0
5✔
1128
            # preimage will be set in sign_tx
1129
        else:  # timing out forward swap
1130
            locktime = swap.locktime
5✔
1131
        cls.add_txin_info(swap, txin)
5✔
1132
        txin.privkey = swap.privkey
5✔
1133
        def make_witness(sig):
5✔
1134
            # preimae not known yet
1135
            preimage = swap.preimage if swap.is_reverse else 0
5✔
1136
            witness_script = swap.redeem_script
5✔
1137
            return construct_witness([sig, preimage, witness_script])
5✔
1138
        txin.make_witness = make_witness
5✔
1139
        return txin, locktime
5✔
1140

1141
    def max_amount_forward_swap(self) -> Optional[int]:
5✔
1142
        """ returns None if we cannot swap """
1143
        max_swap_amt_ln = self.get_max_amount()
×
1144
        if max_swap_amt_ln is None:
×
1145
            return None
×
1146
        max_recv_amt_ln = int(self.lnworker.num_sats_can_receive())
×
1147
        max_amt_ln = int(min(max_swap_amt_ln, max_recv_amt_ln))
×
1148
        max_amt_oc = self.get_send_amount(max_amt_ln, is_reverse=False) or 0
×
1149
        min_amt_oc = self.get_send_amount(self.get_min_amount(), is_reverse=False) or 0
×
1150
        return max_amt_oc if max_amt_oc >= min_amt_oc else None
×
1151

1152
    def server_create_normal_swap(self, request):
5✔
1153
        # normal for client, reverse for server
1154
        #request = await r.json()
1155
        lightning_amount_sat = request['invoiceAmount']
×
1156
        their_pubkey = bytes.fromhex(request['refundPublicKey'])
×
1157
        assert len(their_pubkey) == 33
×
1158
        swap = self.create_reverse_swap(
×
1159
            lightning_amount_sat=lightning_amount_sat,
1160
            their_pubkey=their_pubkey,
1161
        )
1162
        response = {
×
1163
            "id": swap.payment_hash.hex(),
1164
            'preimageHash': swap.payment_hash.hex(),
1165
            "acceptZeroConf": False,
1166
            "expectedAmount": swap.onchain_amount,
1167
            "timeoutBlockHeight": swap.locktime,
1168
            "address": swap.lockup_address,
1169
            "redeemScript": swap.redeem_script.hex(),
1170
        }
1171
        return response
×
1172

1173
    def server_create_swap(self, request):
5✔
1174
        # reverse for client, forward for server
1175
        # requesting a normal swap (old protocol) will raise an exception
1176
        #request = await r.json()
1177
        req_type = request['type']
×
1178
        assert request['pairId'] == 'BTC/BTC'
×
1179
        if req_type == 'reversesubmarine':
×
1180
            lightning_amount_sat=request['invoiceAmount']
×
1181
            payment_hash=bytes.fromhex(request['preimageHash'])
×
1182
            their_pubkey=bytes.fromhex(request['claimPublicKey'])
×
1183
            assert len(payment_hash) == 32
×
1184
            assert len(their_pubkey) == 33
×
1185
            swap, invoice, prepay_invoice = self.create_normal_swap(
×
1186
                lightning_amount_sat=lightning_amount_sat,
1187
                payment_hash=payment_hash,
1188
                their_pubkey=their_pubkey
1189
            )
1190
            response = {
×
1191
                'id': payment_hash.hex(),
1192
                'invoice': invoice,
1193
                'minerFeeInvoice': prepay_invoice,
1194
                'lockupAddress': swap.lockup_address,
1195
                'redeemScript': swap.redeem_script.hex(),
1196
                'timeoutBlockHeight': swap.locktime,
1197
                "onchainAmount": swap.onchain_amount,
1198
            }
1199
        elif req_type == 'submarine':
×
1200
            raise Exception('Deprecated API. Please upgrade your version of Electrum')
×
1201
        else:
1202
            raise Exception('unsupported request type:' + req_type)
×
1203
        return response
×
1204

1205
    def get_groups_for_onchain_history(self):
5✔
1206
        current_height = self.wallet.adb.get_local_height()
×
1207
        d = {}
×
1208
        # add info about submarine swaps
1209
        settled_payments = self.lnworker.get_payments(status='settled')
×
1210
        for payment_hash_hex, swap in self.swaps.items():
×
1211
            txid = swap.spending_txid if swap.is_reverse else swap.funding_txid
×
1212
            if txid is None:
×
1213
                continue
×
1214
            payment_hash = bytes.fromhex(payment_hash_hex)
×
1215
            if payment_hash in settled_payments:
×
1216
                plist = settled_payments[payment_hash]
×
1217
                info = self.lnworker.get_payment_info(payment_hash)
×
1218
                direction, amount_msat, fee_msat, timestamp = self.lnworker.get_payment_value(info, plist)
×
1219
            else:
1220
                amount_msat = 0
×
1221

1222
            if swap.is_reverse:
×
1223
                group_label = 'Reverse swap' + ' ' + self.config.format_amount_and_units(swap.lightning_amount)
×
1224
            else:
1225
                group_label = 'Forward swap' + ' ' + self.config.format_amount_and_units(swap.onchain_amount)
×
1226

1227
            label = _('Claim transaction') if swap.is_reverse else _('Funding transaction')
×
1228
            delta = current_height - swap.locktime
×
1229
            if self.wallet.adb.is_mine(swap.lockup_address):
×
1230
                tx_height = self.wallet.adb.get_tx_height(swap.funding_txid)
×
1231
                if swap.is_reverse and tx_height.height <= 0:
×
1232
                    label += ' (%s)' % _('waiting for funding tx confirmation')
×
1233
                if not swap.is_reverse and not swap.is_redeemed and swap.spending_txid is None and delta < 0:
×
1234
                    label += f' (refundable in {-delta} blocks)' # fixme: only if unspent
×
1235
            d[txid] = {
×
1236
                'group_id': txid,
1237
                'label': label,
1238
                'group_label': group_label,
1239
            }
1240
            if not swap.is_reverse:
×
1241
                # if the spending_tx is in the wallet, this will add it
1242
                # to the group (see wallet.get_full_history)
1243
                d[swap.spending_txid] = {
×
1244
                    'group_id': txid,
1245
                    'group_label': group_label,
1246
                    'label': _('Refund transaction'),
1247
                }
1248
        return d
×
1249

1250
    def get_group_id_for_payment_hash(self, payment_hash):
5✔
1251
        # add group_id to swap transactions
1252
        swap = self.get_swap(payment_hash)
×
1253
        if swap:
×
NEW
1254
            return swap.spending_txid if swap.is_reverse else swap.funding_txid
×
1255

1256

1257
class SwapServerTransport(Logger):
5✔
1258

1259
    def __init__(self, *, config: 'SimpleConfig', sm: 'SwapManager'):
5✔
1260
        Logger.__init__(self)
×
1261
        self.sm = sm
×
1262
        self.network = sm.network
×
1263
        self.config = config
×
1264
        self.is_connected = asyncio.Event()
×
1265

1266
    def __enter__(self):
5✔
1267
        pass
×
1268

1269
    def __exit__(self, ex_type, ex, tb):
5✔
1270
        pass
×
1271

1272
    async def send_request_to_server(self, method: str, request_data: Optional[dict]) -> dict:
5✔
1273
        pass
×
1274

1275
    async def get_pairs(self) -> None:
5✔
1276
        pass
×
1277

1278

1279
class HttpTransport(SwapServerTransport):
5✔
1280

1281
    def __init__(self, config, sm):
5✔
1282
        SwapServerTransport.__init__(self, config=config, sm=sm)
×
1283
        self.api_url = config.SWAPSERVER_URL
×
1284
        self.is_connected.set()
×
1285

1286
    def __enter__(self):
5✔
1287
        asyncio.run_coroutine_threadsafe(self.get_pairs(), self.network.asyncio_loop)
×
1288
        return self
×
1289

1290
    def __exit__(self, ex_type, ex, tb):
5✔
1291
        pass
×
1292

1293
    async def send_request_to_server(self, method, request_data):
5✔
1294
        response = await self.network.async_send_http_on_proxy(
×
1295
            'post' if request_data else 'get',
1296
            self.api_url + '/' + method,
1297
            json=request_data,
1298
            timeout=30)
1299
        return json.loads(response)
×
1300

1301
    async def get_pairs(self) -> None:
5✔
1302
        """Might raise SwapServerError."""
1303
        try:
×
1304
            response = await self.send_request_to_server('getpairs', None)
×
1305
        except aiohttp.ClientError as e:
×
1306
            self.logger.error(f"Swap server errored: {e!r}")
×
1307
            raise SwapServerError() from e
×
1308
        assert response.get('htlcFirst') is True
×
1309
        fees = response['pairs']['BTC/BTC']['fees']
×
1310
        limits = response['pairs']['BTC/BTC']['limits']
×
1311
        pairs = SwapFees(
×
1312
            percentage = fees['percentage'],
1313
            normal_fee = fees['minerFees']['baseAsset']['normal'],
1314
            lockup_fee = fees['minerFees']['baseAsset']['reverse']['lockup'],
1315
            claim_fee = fees['minerFees']['baseAsset']['reverse']['claim'],
1316
            min_amount = limits['minimal'],
1317
            max_amount = limits['maximal'],
1318
        )
1319
        self.sm.update_pairs(pairs)
×
1320

1321

1322
class NostrTransport(SwapServerTransport):
5✔
1323
    # uses nostr:
1324
    #  - to advertise servers
1325
    #  - for client-server RPCs (using DMs)
1326
    #     (todo: we should use onion messages for that)
1327

1328
    NOSTR_DM = 4
5✔
1329
    USER_STATUS_NIP38 = 30315
5✔
1330
    NOSTR_EVENT_VERSION = 2
5✔
1331
    OFFER_UPDATE_INTERVAL_SEC = 60 * 10
5✔
1332

1333
    def __init__(self, config, sm, keypair):
5✔
1334
        SwapServerTransport.__init__(self, config=config, sm=sm)
×
1335
        self._offers = {}  # type: Dict[str, Dict]
×
1336
        self.private_key = keypair.privkey
×
1337
        self.nostr_private_key = to_nip19('nsec', keypair.privkey.hex())
×
1338
        self.nostr_pubkey = keypair.pubkey.hex()[2:]
×
1339
        self.dm_replies = defaultdict(asyncio.Future)  # type: Dict[bytes, asyncio.Future]
×
1340
        ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path)
×
1341
        self.relay_manager = aionostr.Manager(self.relays, private_key=self.nostr_private_key, log=self.logger, ssl_context=ssl_context)
×
1342
        self.taskgroup = OldTaskGroup()
×
1343
        self.server_relays = None
×
1344

1345
    def __enter__(self):
5✔
1346
        asyncio.run_coroutine_threadsafe(self.main_loop(), self.network.asyncio_loop)
×
1347
        return self
×
1348

1349
    def __exit__(self, ex_type, ex, tb):
5✔
1350
        fut = asyncio.run_coroutine_threadsafe(self.stop(), self.network.asyncio_loop)
×
1351
        fut.result(timeout=5)
×
1352

1353
    @log_exceptions
5✔
1354
    async def main_loop(self):
5✔
1355
        self.logger.info(f'starting nostr transport with pubkey: {self.nostr_pubkey}')
×
1356
        self.logger.info(f'nostr relays: {self.relays}')
×
1357
        await self.relay_manager.connect()
×
1358
        connected_relays = self.relay_manager.relays
×
1359
        self.logger.info(f'connected relays: {[relay.url for relay in connected_relays]}')
×
1360
        if connected_relays:
×
1361
            self.is_connected.set()
×
1362
        if self.sm.is_server:
×
1363
            tasks = [
×
1364
                self.check_direct_messages(),
1365
            ]
1366
        else:
1367
            tasks = [
×
1368
                self.check_direct_messages(),
1369
                self.receive_offers(),
1370
                self.get_pairs(),
1371
            ]
1372
        try:
×
1373
            async with self.taskgroup as group:
×
1374
                for task in tasks:
×
1375
                    await group.spawn(task)
×
1376
        except Exception as e:
×
1377
            self.logger.exception("taskgroup died.")
×
1378
        finally:
1379
            self.logger.info("taskgroup stopped.")
×
1380

1381
    @log_exceptions
5✔
1382
    async def stop(self):
5✔
1383
        self.logger.info("shutting down nostr transport")
×
1384
        self.sm.is_initialized.clear()
×
1385
        await self.taskgroup.cancel_remaining()
×
1386
        await self.relay_manager.close()
×
1387
        self.logger.info("nostr transport shut down")
×
1388

1389
    @property
5✔
1390
    def relays(self):
5✔
1391
        return self.network.config.NOSTR_RELAYS.split(',')
×
1392

1393
    def get_offer(self, pubkey):
5✔
1394
        offer = self._offers.get(pubkey)
×
1395
        return self._parse_offer(offer)
×
1396

1397
    def get_recent_offers(self) -> Sequence[Dict]:
5✔
1398
        # filter to fresh timestamps
1399
        now = int(time.time())
×
1400
        recent_offers = [x for x in self._offers.values() if now - x['timestamp'] < 3600]
×
1401
        # sort by proof-of-work
1402
        recent_offers = sorted(recent_offers, key=lambda x: x['pow_bits'], reverse=True)
×
1403
        # cap list size
1404
        recent_offers = recent_offers[:20]
×
1405
        return recent_offers
×
1406

1407
    def _parse_offer(self, offer):
5✔
1408
        return SwapFees(
×
1409
            percentage = offer['percentage_fee'],
1410
            normal_fee = offer['normal_mining_fee'],
1411
            lockup_fee = offer['reverse_mining_fee'],
1412
            claim_fee = offer['claim_mining_fee'],
1413
            min_amount = offer['min_amount'],
1414
            max_amount = offer['max_amount'],
1415
        )
1416

1417
    @ignore_exceptions
5✔
1418
    @log_exceptions
5✔
1419
    async def publish_offer(self, sm):
5✔
1420
        assert self.sm.is_server
×
1421
        offer = {
×
1422
            'percentage_fee': sm.percentage,
1423
            'normal_mining_fee': sm.normal_fee,
1424
            'reverse_mining_fee': sm.lockup_fee,
1425
            'claim_mining_fee': sm.claim_fee,
1426
            'min_amount': sm._min_amount,
1427
            'max_amount': sm._max_amount,
1428
            'relays': sm.config.NOSTR_RELAYS,
1429
            'pow_nonce': hex(sm.config.SWAPSERVER_ANN_POW_NONCE),
1430
        }
1431
        # the first value of a single letter tag is indexed and can be filtered for
1432
        tags = [['d', f'electrum-swapserver-{self.NOSTR_EVENT_VERSION}'],
×
1433
                ['r', 'net:' + constants.net.NET_NAME],
1434
                ['expiration', str(int(time.time() + self.OFFER_UPDATE_INTERVAL_SEC + 10))]]
1435
        event_id = await aionostr._add_event(
×
1436
            self.relay_manager,
1437
            kind=self.USER_STATUS_NIP38,
1438
            tags=tags,
1439
            content=json.dumps(offer),
1440
            private_key=self.nostr_private_key)
1441
        self.logger.info(f"published offer {event_id}")
×
1442

1443
    async def send_direct_message(self, pubkey: str, relays, content: str) -> str:
5✔
1444
        event_id = await aionostr._add_event(
×
1445
            self.relay_manager,
1446
            kind=self.NOSTR_DM,
1447
            content=content,
1448
            private_key=self.nostr_private_key,
1449
            direct_message=pubkey)
1450
        return event_id
×
1451

1452
    @log_exceptions
5✔
1453
    async def send_request_to_server(self, method: str, request_data: dict) -> dict:
5✔
1454
        request_data['method'] = method
×
1455
        request_data['relays'] = self.config.NOSTR_RELAYS
×
1456
        server_pubkey = self.config.SWAPSERVER_NPUB
×
1457
        event_id = await self.send_direct_message(server_pubkey, self.server_relays, json.dumps(request_data))
×
1458
        response = await self.dm_replies[event_id]
×
1459
        return response
×
1460

1461
    async def receive_offers(self):
5✔
1462
        await self.is_connected.wait()
×
1463
        query = {
×
1464
            "kinds": [self.USER_STATUS_NIP38],
1465
            "limit":10,
1466
            "#d": [f"electrum-swapserver-{self.NOSTR_EVENT_VERSION}"],
1467
            "#r": [f"net:{constants.net.NET_NAME}"],
1468
            "since": int(time.time()) - self.OFFER_UPDATE_INTERVAL_SEC
1469
        }
1470
        async for event in self.relay_manager.get_events(query, single_event=False, only_stored=False):
×
1471
            try:
×
1472
                content = json.loads(event.content)
×
1473
                tags = {k: v for k, v in event.tags}
×
1474
            except Exception as e:
×
1475
                continue
×
1476
            if tags.get('d') != f"electrum-swapserver-{self.NOSTR_EVENT_VERSION}":
×
1477
                continue
×
1478
            if tags.get('r') != f"net:{constants.net.NET_NAME}":
×
1479
                continue
×
1480
            # check if this is the most recent event for this pubkey
1481
            pubkey = event.pubkey
×
1482
            ts = self._offers.get(pubkey, {}).get('timestamp', 0)
×
1483
            if event.created_at <= ts:
×
1484
                #print('skipping old event', pubkey[0:10], event.id)
1485
                continue
×
1486
            try:
×
1487
                pow_bits = get_nostr_ann_pow_amount(
×
1488
                    bytes.fromhex(pubkey),
1489
                    int(content.get('pow_nonce', "0"), 16)
1490
                )
1491
            except ValueError:
×
1492
                continue
×
1493
            if pow_bits < self.config.SWAPSERVER_POW_TARGET:
×
1494
                self.logger.debug(f"too low pow: {pubkey}: pow: {pow_bits} nonce: {content.get('pow_nonce', 0)}")
×
1495
                continue
×
1496
            content['pow_bits'] = pow_bits
×
1497
            content['pubkey'] = pubkey
×
1498
            content['timestamp'] = event.created_at
×
1499
            self._offers[pubkey] = content
×
1500
            # mirror event to other relays
1501
            server_relays = content['relays'].split(',') if 'relays' in content else []
×
1502
            await self.taskgroup.spawn(self.rebroadcast_event(event, server_relays))
×
1503

1504
    async def get_pairs(self):
5✔
1505
        if self.config.SWAPSERVER_NPUB is None:
×
1506
            return
×
1507
        query = {
×
1508
            "kinds": [self.USER_STATUS_NIP38],
1509
            "authors": [self.config.SWAPSERVER_NPUB],
1510
            "#d": [f"electrum-swapserver-{self.NOSTR_EVENT_VERSION}"],
1511
            "#r": [f"net:{constants.net.NET_NAME}"],
1512
            "since": int(time.time()) - self.OFFER_UPDATE_INTERVAL_SEC,
1513
            "limit": 1
1514
        }
1515
        async for event in self.relay_manager.get_events(query, single_event=True, only_stored=False):
×
1516
            try:
×
1517
                content = json.loads(event.content)
×
1518
                tags = {k: v for k, v in event.tags}
×
1519
            except Exception:
×
1520
                continue
×
1521
            if tags.get('d') != f"electrum-swapserver-{self.NOSTR_EVENT_VERSION}":
×
1522
                continue
×
1523
            if tags.get('r') != f"net:{constants.net.NET_NAME}":
×
1524
                continue
×
1525
            # check if this is the most recent event for this pubkey
1526
            pubkey = event.pubkey
×
1527
            content['pubkey'] = pubkey
×
1528
            content['timestamp'] = event.created_at
×
1529
            self.logger.info(f'received offer from {age(event.created_at)}')
×
1530
            pairs = self._parse_offer(content)
×
1531
            self.sm.update_pairs(pairs)
×
1532
            self.server_relays = content['relays'].split(',')
×
1533

1534
    async def rebroadcast_event(self, event: Event, server_relays: Sequence[str]):
5✔
1535
        """If the relays of the origin server are different from our relays we rebroadcast the
1536
        event to our relays so it gets spread more widely."""
1537
        if not server_relays:
×
1538
            return
×
1539
        rebroadcast_relays = [relay for relay in self.relay_manager.relays if
×
1540
                              relay.url not in server_relays]
1541
        for relay in rebroadcast_relays:
×
1542
            try:
×
1543
                res = await relay.add_event(event, check_response=True)
×
1544
            except Exception as e:
×
1545
                self.logger.debug(f"failed to rebroadcast event to {relay.url}: {e}")
×
1546
                continue
×
1547
            self.logger.debug(f"rebroadcasted event to {relay.url}: {res}")
×
1548

1549
    @log_exceptions
5✔
1550
    async def check_direct_messages(self):
5✔
1551
        privkey = aionostr.key.PrivateKey(self.private_key)
×
1552
        query = {"kinds": [self.NOSTR_DM], "limit":0, "#p": [self.nostr_pubkey]}
×
1553
        async for event in self.relay_manager.get_events(query, single_event=False, only_stored=False):
×
1554
            try:
×
1555
                content = privkey.decrypt_message(event.content, event.pubkey)
×
1556
                content = json.loads(content)
×
1557
            except Exception:
×
1558
                continue
×
1559
            content['event_id'] = event.id
×
1560
            content['event_pubkey'] = event.pubkey
×
1561
            if 'reply_to' in content:
×
1562
                self.dm_replies[content['reply_to']].set_result(content)
×
1563
            elif self.sm.is_server and 'method' in content:
×
1564
                await self.handle_request(content)
×
1565
            else:
1566
                self.logger.info(f'unknown message {content}')
×
1567

1568
    @log_exceptions
5✔
1569
    async def handle_request(self, request):
5✔
1570
        assert self.sm.is_server
×
1571
        # todo: remember event_id of already processed requests
1572
        method = request.pop('method')
×
1573
        event_id = request.pop('event_id')
×
1574
        event_pubkey = request.pop('event_pubkey')
×
1575
        self.logger.info(f'handle_request: id={event_id} {method} {request}')
×
1576
        relays = request.pop('relays').split(',')
×
1577
        if method == 'addswapinvoice':
×
1578
            r = self.sm.server_add_swap_invoice(request)
×
1579
        elif method == 'createswap':
×
1580
            r = self.sm.server_create_swap(request)
×
1581
        elif method == 'createnormalswap':
×
1582
            r = self.sm.server_create_normal_swap(request)
×
1583
        else:
1584
            raise Exception(method)
×
1585
        r['reply_to'] = event_id
×
1586
        self.logger.info(f'sending response id={event_id}')
×
1587
        await self.send_direct_message(event_pubkey, relays, json.dumps(r))
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc