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

spesmilo / electrum / 5897570209234944

05 Mar 2026 09:53AM UTC coverage: 63.849% (-0.005%) from 63.854%
5897570209234944

Pull #10511

CirrusCI

f321x
plugin: nwc: add update timer to connection list
Pull Request #10511: plugin: nwc: followup #10505, include fees in budget

24548 of 38447 relevant lines covered (63.85%)

0.64 hits per line

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

67.04
/electrum/util.py
1
# Electrum - lightweight Bitcoin client
2
# Copyright (C) 2011 Thomas Voegtlin
3
#
4
# Permission is hereby granted, free of charge, to any person
5
# obtaining a copy of this software and associated documentation files
6
# (the "Software"), to deal in the Software without restriction,
7
# including without limitation the rights to use, copy, modify, merge,
8
# publish, distribute, sublicense, and/or sell copies of the Software,
9
# and to permit persons to whom the Software is furnished to do so,
10
# subject to the following conditions:
11
#
12
# The above copyright notice and this permission notice shall be
13
# included in all copies or substantial portions of the Software.
14
#
15
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
19
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
20
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
21
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
# SOFTWARE.
23
import concurrent.futures
1✔
24
import copy
1✔
25
from dataclasses import dataclass
1✔
26
import logging
1✔
27
import os
1✔
28
import sys
1✔
29
import re
1✔
30
from collections import defaultdict, OrderedDict
1✔
31
from concurrent.futures.process import ProcessPoolExecutor
1✔
32
from typing import (
1✔
33
    NamedTuple, Union, TYPE_CHECKING, Tuple, Optional, Callable, Any, Sequence, Dict, Generic, TypeVar, List, Iterable,
34
    Set, Awaitable
35
)
36
from types import MappingProxyType
1✔
37
from datetime import datetime, timezone, timedelta
1✔
38
import decimal
1✔
39
from decimal import Decimal
1✔
40
import threading
1✔
41
import hmac
1✔
42
import hashlib
1✔
43
import stat
1✔
44
import asyncio
1✔
45
import builtins
1✔
46
import json
1✔
47
import time
1✔
48
import ssl
1✔
49
import ipaddress
1✔
50
from ipaddress import IPv4Address, IPv6Address
1✔
51
import random
1✔
52
import secrets
1✔
53
import functools
1✔
54
from functools import partial
1✔
55
from abc import abstractmethod, ABC
1✔
56
import enum
1✔
57
from contextlib import nullcontext, suppress
1✔
58
import traceback
1✔
59
import inspect
1✔
60
import weakref
1✔
61

62
import aiohttp
1✔
63
from aiohttp_socks import ProxyConnector, ProxyType
1✔
64
import aiorpcx
1✔
65
import certifi
1✔
66
import dns.asyncresolver
1✔
67

68
from .i18n import _
1✔
69
from .logging import get_logger, Logger
1✔
70

71
if TYPE_CHECKING:
72
    from .network import Network, ProxySettings
73
    from .interface import Interface
74
    from .simple_config import SimpleConfig
75

76

77
_logger = get_logger(__name__)
1✔
78

79

80
def inv_dict(d):
1✔
81
    return {v: k for k, v in d.items()}
1✔
82

83

84
def all_subclasses(cls) -> Set:
1✔
85
    """Return all (transitive) subclasses of cls."""
86
    res = set(cls.__subclasses__())
1✔
87
    for sub in res.copy():
1✔
88
        res |= all_subclasses(sub)
1✔
89
    return res
1✔
90

91

92
ca_path = certifi.where()
1✔
93

94

95
base_units = {'BTC':8, 'mBTC':5, 'bits':2, 'sat':0}
1✔
96
base_units_inverse = inv_dict(base_units)
1✔
97
base_units_list = ['BTC', 'mBTC', 'bits', 'sat']  # list(dict) does not guarantee order
1✔
98

99
DECIMAL_POINT_DEFAULT = 5  # mBTC
1✔
100

101

102
class UnknownBaseUnit(Exception): pass
1✔
103

104

105
def decimal_point_to_base_unit_name(dp: int) -> str:
1✔
106
    # e.g. 8 -> "BTC"
107
    try:
1✔
108
        return base_units_inverse[dp]
1✔
109
    except KeyError:
×
110
        raise UnknownBaseUnit(dp) from None
×
111

112

113
def base_unit_name_to_decimal_point(unit_name: str) -> int:
1✔
114
    """Returns the max number of digits allowed after the decimal point."""
115
    # e.g. "BTC" -> 8
116
    try:
1✔
117
        return base_units[unit_name]
1✔
118
    except KeyError:
×
119
        raise UnknownBaseUnit(unit_name) from None
×
120

121
def parse_max_spend(amt: Any) -> Optional[int]:
1✔
122
    """Checks if given amount is "spend-max"-like.
123
    Returns None or the positive integer weight for "max". Never raises.
124

125
    When creating invoices and on-chain txs, the user can specify to send "max".
126
    This is done by setting the amount to '!'. Splitting max between multiple
127
    tx outputs is also possible, and custom weights (positive ints) can also be used.
128
    For example, to send 40% of all coins to address1, and 60% to address2:
129
    ```
130
    address1, 2!
131
    address2, 3!
132
    ```
133
    """
134
    if not (isinstance(amt, str) and amt and amt[-1] == '!'):
1✔
135
        return None
1✔
136
    if amt == '!':
1✔
137
        return 1
1✔
138
    x = amt[:-1]
1✔
139
    try:
1✔
140
        x = int(x)
1✔
141
    except ValueError:
×
142
        return None
×
143
    if x > 0:
1✔
144
        return x
1✔
145
    return None
×
146

147
class NotEnoughFunds(Exception):
1✔
148
    def __str__(self):
1✔
149
        return _("Insufficient funds")
×
150

151

152
class UneconomicFee(Exception):
1✔
153
    def __str__(self):
1✔
154
        return _("The fee for the transaction is higher than the funds gained from it.")
×
155

156

157
class NoDynamicFeeEstimates(Exception):
1✔
158
    def __str__(self):
1✔
159
        return _('Dynamic fee estimates not available')
×
160

161

162
class BelowDustLimit(Exception):
1✔
163
    pass
1✔
164

165

166
class InvalidPassword(Exception):
1✔
167
    def __init__(self, message: Optional[str] = None):
1✔
168
        self.message = message
1✔
169

170
    def __str__(self):
1✔
171
        if self.message is None:
×
172
            return _("Incorrect password")
×
173
        else:
174
            return str(self.message)
×
175

176

177
class AddTransactionException(Exception):
1✔
178
    pass
1✔
179

180

181
class UnrelatedTransactionException(AddTransactionException):
1✔
182
    def __str__(self):
1✔
183
        return _("Transaction is unrelated to this wallet.")
×
184

185

186
class FileImportFailed(Exception):
1✔
187
    def __init__(self, message=''):
1✔
188
        self.message = str(message)
×
189

190
    def __str__(self):
1✔
191
        return _("Failed to import from file.") + "\n" + self.message
×
192

193

194
class FileExportFailed(Exception):
1✔
195
    def __init__(self, message=''):
1✔
196
        self.message = str(message)
×
197

198
    def __str__(self):
1✔
199
        return _("Failed to export to file.") + "\n" + self.message
×
200

201

202
class WalletFileException(Exception):
1✔
203
    def __init__(self, message='', *, should_report_crash: bool = False):
1✔
204
        Exception.__init__(self, message)
1✔
205
        self.should_report_crash = should_report_crash
1✔
206

207

208
class BitcoinException(Exception): pass
1✔
209

210

211
class UserFacingException(Exception):
1✔
212
    """Exception that contains information intended to be shown to the user."""
213

214

215
class InvoiceError(UserFacingException): pass
1✔
216

217

218
class NetworkOfflineException(UserFacingException):
1✔
219
    """Can be raised if we are running in offline mode (--offline flag)
220
    and the user requests an operation that requires the network.
221
    """
222
    def __str__(self):
1✔
223
        return _("You are offline.")
×
224

225

226
# Throw this exception to unwind the stack like when an error occurs.
227
# However unlike other exceptions the user won't be informed.
228
class UserCancelled(Exception):
1✔
229
    '''An exception that is suppressed from the user'''
230
    pass
1✔
231

232

233
def to_decimal(x: Union[str, float, int, Decimal]) -> Decimal:
1✔
234
    # helper function mainly for float->Decimal conversion, i.e.:
235
    #   >>> Decimal(41754.681)
236
    #   Decimal('41754.680999999996856786310672760009765625')
237
    #   >>> Decimal("41754.681")
238
    #   Decimal('41754.681')
239
    if isinstance(x, Decimal):
1✔
240
        return x
1✔
241
    if isinstance(x, int):
1✔
242
        return Decimal(x)
1✔
243
    return Decimal(str(x))
1✔
244

245

246
# note: this is not a NamedTuple as then its json encoding cannot be customized
247
class Satoshis(object):
1✔
248
    __slots__ = ('value',)
1✔
249

250
    def __new__(cls, value):
1✔
251
        self = super(Satoshis, cls).__new__(cls)
1✔
252
        # note: 'value' sometimes has msat precision
253
        assert isinstance(value, (int, Decimal)), f"unexpected type for {value=!r}"
1✔
254
        self.value = value
1✔
255
        return self
1✔
256

257
    def __repr__(self):
1✔
258
        return f'Satoshis({self.value})'
×
259

260
    def __str__(self):
1✔
261
        # note: precision is truncated to satoshis here
262
        return format_satoshis(self.value)
1✔
263

264
    def __eq__(self, other):
1✔
265
        return self.value == other.value
×
266

267
    def __ne__(self, other):
1✔
268
        return not (self == other)
×
269

270
    def __add__(self, other):
1✔
271
        return Satoshis(self.value + other.value)
1✔
272

273

274
# note: this is not a NamedTuple as then its json encoding cannot be customized
275
class Fiat(object):
1✔
276
    __slots__ = ('value', 'ccy')
1✔
277

278
    def __new__(cls, value: Optional[Decimal], ccy: str):
1✔
279
        self = super(Fiat, cls).__new__(cls)
1✔
280
        self.ccy = ccy
1✔
281
        if not isinstance(value, (Decimal, type(None))):
1✔
282
            raise TypeError(f"value should be Decimal or None, not {type(value)}")
×
283
        self.value = value
1✔
284
        return self
1✔
285

286
    def __repr__(self):
1✔
287
        return 'Fiat(%s)'% self.__str__()
×
288

289
    def __str__(self):
1✔
290
        if self.value is None or self.value.is_nan():
1✔
291
            return _('No Data')
×
292
        else:
293
            return "{:.2f}".format(self.value)
1✔
294

295
    def to_ui_string(self):
1✔
296
        if self.value is None or self.value.is_nan():
×
297
            return _('No Data')
×
298
        else:
299
            return "{:.2f}".format(self.value) + ' ' + self.ccy
×
300

301
    def __eq__(self, other):
1✔
302
        if not isinstance(other, Fiat):
×
303
            return False
×
304
        if self.ccy != other.ccy:
×
305
            return False
×
306
        if isinstance(self.value, Decimal) and isinstance(other.value, Decimal) \
×
307
                and self.value.is_nan() and other.value.is_nan():
308
            return True
×
309
        return self.value == other.value
×
310

311
    def __ne__(self, other):
1✔
312
        return not (self == other)
×
313

314
    def __add__(self, other):
1✔
315
        assert self.ccy == other.ccy
1✔
316
        return Fiat(self.value + other.value, self.ccy)
1✔
317

318

319
class MyEncoder(json.JSONEncoder):
1✔
320
    def default(self, obj):
1✔
321
        # note: this does not get called for namedtuples :(  https://bugs.python.org/issue30343
322
        from .transaction import Transaction, TxOutput
1✔
323
        if isinstance(obj, Transaction):
1✔
324
            return obj.serialize()
1✔
325
        if isinstance(obj, TxOutput):
1✔
326
            return obj.to_legacy_tuple()
1✔
327
        if isinstance(obj, Satoshis):
1✔
328
            return str(obj)
1✔
329
        if isinstance(obj, Fiat):
1✔
330
            return str(obj)
1✔
331
        if isinstance(obj, Decimal):
1✔
332
            return str(obj)
×
333
        if isinstance(obj, datetime):
1✔
334
            # note: if there is a timezone specified, this will include the offset
335
            return obj.isoformat(' ', timespec="minutes")
1✔
336
        if isinstance(obj, (set, frozenset)):
1✔
337
            return list(obj)
1✔
338
        if isinstance(obj, bytes): # for nametuples in lnchannel
1✔
339
            return obj.hex()
1✔
340
        if hasattr(obj, 'to_json') and callable(obj.to_json):
1✔
341
            return obj.to_json()
1✔
342
        return super(MyEncoder, self).default(obj)
×
343

344

345
class ThreadJob(Logger):
1✔
346
    """A job that is run periodically from a thread's main loop.  run() is
347
    called from that thread's context.
348
    """
349

350
    def __init__(self):
1✔
351
        Logger.__init__(self)
1✔
352

353
    def run(self):
1✔
354
        """Called periodically from the thread"""
355
        pass
×
356

357

358
class DaemonThread(threading.Thread, Logger):
1✔
359
    """ daemon thread that terminates cleanly """
360

361
    def __init__(self):
1✔
362
        threading.Thread.__init__(self)
1✔
363
        Logger.__init__(self)
1✔
364
        self.parent_thread = threading.current_thread()
1✔
365
        self.running = False
1✔
366
        self.running_lock = threading.Lock()
1✔
367
        self.job_lock = threading.Lock()
1✔
368
        self.jobs = []
1✔
369
        self.stopped_event = threading.Event()        # set when fully stopped
1✔
370
        self.stopped_event_async = asyncio.Event()    # set when fully stopped
1✔
371
        self.wake_up_event = threading.Event()  # for perf optimisation of polling in run()
1✔
372

373
    def add_jobs(self, jobs):
1✔
374
        with self.job_lock:
1✔
375
            self.jobs.extend(jobs)
1✔
376

377
    def run_jobs(self):
1✔
378
        # Don't let a throwing job disrupt the thread, future runs of
379
        # itself, or other jobs.  This is useful protection against
380
        # malformed or malicious server responses
381
        with self.job_lock:
1✔
382
            for job in self.jobs:
1✔
383
                start = time.perf_counter()
1✔
384
                try:
1✔
385
                    job.run()
1✔
386
                except Exception as e:
×
387
                    self.logger.exception('')
×
388
                duration = time.perf_counter() - start
1✔
389
                if duration > 0.5:
1✔
390
                    self.logger.warning(f"thread job {job} blocked {self} DaemonThread for {duration:.2f} s")
×
391

392
    def remove_jobs(self, jobs):
1✔
393
        with self.job_lock:
×
394
            for job in jobs:
×
395
                self.jobs.remove(job)
×
396

397
    def start(self):
1✔
398
        with self.running_lock:
1✔
399
            self.running = True
1✔
400
        return threading.Thread.start(self)
1✔
401

402
    def is_running(self):
1✔
403
        with self.running_lock:
1✔
404
            return self.running and self.parent_thread.is_alive()
1✔
405

406
    def stop(self):
1✔
407
        with self.running_lock:
1✔
408
            self.running = False
1✔
409
            self.wake_up_event.set()
1✔
410
            self.wake_up_event.clear()
1✔
411

412
    def on_stop(self):
1✔
413
        if 'ANDROID_DATA' in os.environ:
1✔
414
            import jnius
×
415
            jnius.detach()
×
416
            self.logger.info("jnius detach")
×
417
        self.logger.info("stopped")
1✔
418
        self.stopped_event.set()
1✔
419
        loop = get_asyncio_loop()
1✔
420
        loop.call_soon_threadsafe(self.stopped_event_async.set)
1✔
421

422

423
def print_stderr(*args):
1✔
424
    args = [str(item) for item in args]
×
425
    sys.stderr.write(" ".join(args) + "\n")
×
426
    sys.stderr.flush()
×
427

428

429
def print_msg(*args):
1✔
430
    # Stringify args
431
    args = [str(item) for item in args]
×
432
    sys.stdout.write(" ".join(args) + "\n")
×
433
    sys.stdout.flush()
×
434

435

436
def json_encode(obj):
1✔
437
    try:
1✔
438
        s = json.dumps(obj, sort_keys = True, indent = 4, cls=MyEncoder)
1✔
439
    except TypeError:
×
440
        s = repr(obj)
×
441
    return s
1✔
442

443

444
def json_decode(x):
1✔
445
    try:
1✔
446
        return json.loads(x, parse_float=Decimal)
1✔
447
    except Exception:
1✔
448
        return x
1✔
449

450

451
def json_normalize(x):
1✔
452
    # note: The return value of commands, when going through the JSON-RPC interface,
453
    #       is json-encoded. The encoder used there cannot handle some types, e.g. electrum.util.Satoshis.
454
    # note: We should not simply do "json_encode(x)" here, as then later x would get doubly json-encoded.
455
    # see #5868
456
    return json_decode(json_encode(x))
1✔
457

458

459
# taken from Django Source Code
460
def constant_time_compare(val1, val2):
1✔
461
    """Return True if the two strings are equal, False otherwise."""
462
    return hmac.compare_digest(to_bytes(val1, 'utf8'), to_bytes(val2, 'utf8'))
1✔
463

464

465
_profiler_logger = _logger.getChild('profiler')
1✔
466

467

468
def profiler(func=None, *, min_threshold: Union[int, float, None] = None):
1✔
469
    """Function decorator that logs execution time.
470

471
    min_threshold: if set, only log if time taken is higher than threshold
472
    """
473
    if func is None:  # to make "@profiler(...)" work. (in addition to bare "@profiler")
1✔
474
        return partial(profiler, min_threshold=min_threshold)
1✔
475
    t0 = None  # type: Optional[float]
1✔
476

477
    def timer_start():
1✔
478
        nonlocal t0
479
        t0 = time.time()
1✔
480

481
    def timer_done():
1✔
482
        t = time.time() - t0
1✔
483
        if min_threshold is None or t > min_threshold:
1✔
484
            _profiler_logger.debug(f"{func.__qualname__} {t:,.4f} sec")
1✔
485

486
    if inspect.iscoroutinefunction(func):
1✔
487
        async def do_profile(*args, **kw_args):
×
488
            timer_start()
×
489
            o = await func(*args, **kw_args)
×
490
            timer_done()
×
491
            return o
×
492
    else:
493
        def do_profile(*args, **kw_args):
1✔
494
            timer_start()
1✔
495
            o = func(*args, **kw_args)
1✔
496
            timer_done()
1✔
497
            return o
1✔
498
    return do_profile
1✔
499

500

501
class AsyncHangDetector:
1✔
502
    """Context manager that logs every `n` seconds if encapsulated context still has not exited."""
503

504
    def __init__(
1✔
505
        self,
506
        *,
507
        period_sec: int = 15,
508
        message: str,
509
        logger: logging.Logger = None,
510
    ):
511
        self.period_sec = period_sec
1✔
512
        self.message = message
1✔
513
        self.logger = logger or _logger
1✔
514

515
    async def _monitor(self):
1✔
516
        # note: this assumes that the event loop itself is not blocked
517
        t0 = time.monotonic()
1✔
518
        while True:
1✔
519
            await asyncio.sleep(self.period_sec)
1✔
520
            t1 = time.monotonic()
×
521
            self.logger.info(f"{self.message} (after {t1 - t0:.2f} sec)")
×
522

523
    async def __aenter__(self):
1✔
524
        self.mtask = asyncio.create_task(self._monitor())
1✔
525

526
    async def __aexit__(self, exc_type, exc, tb):
1✔
527
        self.mtask.cancel()
1✔
528

529

530
def android_ext_dir():
1✔
531
    from android.storage import primary_external_storage_path
×
532
    return primary_external_storage_path()
×
533

534

535
def android_backup_dir():
1✔
536
    pkgname = get_android_package_name()
×
537
    d = os.path.join(android_ext_dir(), pkgname)
×
538
    if not os.path.exists(d):
×
539
        os.mkdir(d)
×
540
    return d
×
541

542

543
def android_data_dir():
1✔
544
    import jnius
×
545
    PythonActivity = jnius.autoclass('org.kivy.android.PythonActivity')
×
546
    return PythonActivity.mActivity.getFilesDir().getPath() + '/data'
×
547

548

549
def ensure_sparse_file(filename):
1✔
550
    # On modern Linux, no need to do anything.
551
    # On Windows, need to explicitly mark file.
552
    if os.name == "nt":
1✔
553
        try:
×
554
            os.system('fsutil sparse setflag "{}" 1'.format(filename))
×
555
        except Exception as e:
×
556
            _logger.info(f'error marking file {filename} as sparse: {e}')
×
557

558

559
def get_headers_dir(config):
1✔
560
    return config.path
1✔
561

562

563
def assert_datadir_available(config_path):
1✔
564
    path = config_path
1✔
565
    if os.path.exists(path):
1✔
566
        return
1✔
567
    else:
568
        raise FileNotFoundError(
×
569
            'Electrum datadir does not exist. Was it deleted while running?' + '\n' +
570
            'Should be at {}'.format(path))
571

572

573
def assert_file_in_datadir_available(path, config_path):
1✔
574
    if os.path.exists(path):
×
575
        return
×
576
    else:
577
        assert_datadir_available(config_path)
×
578
        raise FileNotFoundError(
×
579
            'Cannot find file but datadir is there.' + '\n' +
580
            'Should be at {}'.format(path))
581

582

583
def standardize_path(path):
1✔
584
    # note: os.path.realpath() is not used, as on Windows it can return non-working paths (see #8495).
585
    #       This means that we don't resolve symlinks!
586
    return os.path.normcase(
1✔
587
                os.path.abspath(
588
                    os.path.expanduser(
589
                        path
590
    )))
591

592

593
def get_new_wallet_name(wallet_folder: str) -> str:
1✔
594
    """Returns a file basename for a new wallet to be used.
595
    Can raise OSError.
596
    """
597
    i = 1
1✔
598
    while True:
1✔
599
        filename = "wallet_%d" % i
1✔
600
        if filename in os.listdir(wallet_folder):
1✔
601
            i += 1
1✔
602
        else:
603
            break
1✔
604
    return filename
1✔
605

606

607
def is_android_debug_apk() -> bool:
1✔
608
    is_android = 'ANDROID_DATA' in os.environ
×
609
    if not is_android:
×
610
        return False
×
611
    from jnius import autoclass
×
612
    pkgname = get_android_package_name()
×
613
    build_config = autoclass(f"{pkgname}.BuildConfig")
×
614
    return bool(build_config.DEBUG)
×
615

616

617
def get_android_package_name() -> str:
1✔
618
    is_android = 'ANDROID_DATA' in os.environ
×
619
    assert is_android
×
620
    from jnius import autoclass
×
621
    from android.config import ACTIVITY_CLASS_NAME
×
622
    activity = autoclass(ACTIVITY_CLASS_NAME).mActivity
×
623
    pkgname = str(activity.getPackageName())
×
624
    return pkgname
×
625

626

627
def assert_bytes(*args):
1✔
628
    """
629
    porting helper, assert args type
630
    """
631
    try:
1✔
632
        for x in args:
1✔
633
            assert isinstance(x, (bytes, bytearray))
1✔
634
    except Exception:
×
635
        print('assert bytes failed', list(map(type, args)))
×
636
        raise
×
637

638

639
def assert_str(*args):
1✔
640
    """
641
    porting helper, assert args type
642
    """
643
    for x in args:
×
644
        assert isinstance(x, str)
×
645

646

647
def to_string(x, enc) -> str:
1✔
648
    if isinstance(x, (bytes, bytearray)):
1✔
649
        return x.decode(enc)
1✔
650
    if isinstance(x, str):
×
651
        return x
×
652
    else:
653
        raise TypeError("Not a string or bytes like object")
×
654

655

656
def to_bytes(something, encoding='utf8') -> bytes:
1✔
657
    """
658
    cast string to bytes() like object, but for python2 support it's bytearray copy
659
    """
660
    if isinstance(something, bytes):
1✔
661
        return something
1✔
662
    if isinstance(something, str):
1✔
663
        return something.encode(encoding)
1✔
664
    elif isinstance(something, bytearray):
1✔
665
        return bytes(something)
1✔
666
    else:
667
        raise TypeError("Not a string or bytes like object")
1✔
668

669

670
bfh = bytes.fromhex
1✔
671

672

673
def xor_bytes(a: bytes, b: bytes) -> bytes:
1✔
674
    size = min(len(a), len(b))
1✔
675
    return ((int.from_bytes(a[:size], "big") ^ int.from_bytes(b[:size], "big"))
1✔
676
            .to_bytes(size, "big"))
677

678

679
def user_dir():
1✔
680
    if "ELECTRUMDIR" in os.environ:
1✔
681
        return os.environ["ELECTRUMDIR"]
×
682
    elif 'ANDROID_DATA' in os.environ:
1✔
683
        return android_data_dir()
×
684
    elif os.name == 'posix':
1✔
685
        return os.path.join(os.environ["HOME"], ".electrum")
1✔
686
    elif "APPDATA" in os.environ:
×
687
        return os.path.join(os.environ["APPDATA"], "Electrum")
×
688
    elif "LOCALAPPDATA" in os.environ:
×
689
        return os.path.join(os.environ["LOCALAPPDATA"], "Electrum")
×
690
    else:
691
        #raise Exception("No home directory found in environment variables.")
692
        return
×
693

694

695
def resource_path(*parts):
1✔
696
    return os.path.join(pkg_dir, *parts)
1✔
697

698

699
# absolute path to python package folder of electrum ("lib")
700
pkg_dir = os.path.split(os.path.realpath(__file__))[0]
1✔
701

702

703
def is_valid_email(s):
1✔
704
    regexp = r"[^@]+@[^@]+\.[^@]+"
×
705
    return re.match(regexp, s) is not None
×
706

707

708
def is_valid_websocket_url(url: str) -> bool:
1✔
709
    """
710
    uses this django url validation regex:
711
    https://github.com/django/django/blob/2c6906a0c4673a7685817156576724aba13ad893/django/core/validators.py#L45C1-L52C43
712
    Note: this is not perfect, urls and their parsing can get very complex (see recent django code).
713
    however its sufficient for catching weird user input in the gui dialog
714
    """
715
    # stores the compiled regex in the function object itself to avoid recompiling it every call
716
    if not hasattr(is_valid_websocket_url, "regex"):
×
717
        is_valid_websocket_url.regex = re.compile(
×
718
            r'^(?:ws|wss)://'  # ws:// or wss://
719
            r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|'  # domain...
720
            r'localhost|'  # localhost...
721
            r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|'  # ...or ipv4
722
            r'\[?[A-F0-9]*:[A-F0-9:]+\]?)'  # ...or ipv6
723
            r'(?::\d+)?'  # optional port
724
            r'(?:/?|[/?]\S+)$', re.IGNORECASE)
725
    try:
×
726
        return re.match(is_valid_websocket_url.regex, url) is not None
×
727
    except Exception:
×
728
        return False
×
729

730

731
def is_hash256_str(text: Any) -> bool:
1✔
732
    if not isinstance(text, str): return False
1✔
733
    if len(text) != 64: return False
1✔
734
    return is_hex_str(text)
1✔
735

736

737
def is_hex_str(text: Any) -> bool:
1✔
738
    if not isinstance(text, str): return False
1✔
739
    try:
1✔
740
        b = bytes.fromhex(text)
1✔
741
    except Exception:
1✔
742
        return False
1✔
743
    # forbid whitespaces in text:
744
    if len(text) != 2 * len(b):
1✔
745
        return False
1✔
746
    return True
1✔
747

748

749
def is_integer(val: Any) -> bool:
1✔
750
    return isinstance(val, int)
1✔
751

752

753
def is_non_negative_integer(val: Any) -> bool:
1✔
754
    if is_integer(val):
1✔
755
        return val >= 0
1✔
756
    return False
1✔
757

758

759
def is_int_or_float(val: Any) -> bool:
1✔
760
    return isinstance(val, (int, float))
1✔
761

762

763
def is_non_negative_int_or_float(val: Any) -> bool:
1✔
764
    if is_int_or_float(val):
1✔
765
        return val >= 0
1✔
766
    return False
1✔
767

768

769
def chunks(items, size: int):
1✔
770
    """Break up items, an iterable, into chunks of length size."""
771
    if size < 1:
1✔
772
        raise ValueError(f"size must be positive, not {repr(size)}")
1✔
773
    for i in range(0, len(items), size):
1✔
774
        yield items[i: i + size]
1✔
775

776

777
def format_satoshis_plain(
1✔
778
        x: Union[int, float, Decimal, str],  # amount in satoshis,
779
        *,
780
        decimal_point: int = 8,  # how much to shift decimal point to left (default: sat->BTC)
781
        is_max_allowed: bool = True,
782
) -> str:
783
    """Display a satoshi amount scaled.  Always uses a '.' as a decimal
784
    point and has no thousands separator"""
785
    if is_max_allowed and parse_max_spend(x):
1✔
786
        return f'max({x})'
×
787
    assert isinstance(x, (int, float, Decimal)), f"{x!r} should be a number"
1✔
788
    # TODO(ghost43) just hard-fail if x is a float. do we even use floats for money anywhere?
789
    x = to_decimal(x)
1✔
790
    scale_factor = pow(10, decimal_point)
1✔
791
    return "{:.8f}".format(x / scale_factor).rstrip('0').rstrip('.')
1✔
792

793

794
# Check that Decimal precision is sufficient.
795
# We need at the very least ~20, as we deal with msat amounts, and
796
# log10(21_000_000 * 10**8 * 1000) ~= 18.3
797
# decimal.DefaultContext.prec == 28 by default, but it is mutable.
798
# We enforce that we have at least that available.
799
assert decimal.getcontext().prec >= 28, f"PyDecimal precision too low: {decimal.getcontext().prec}"
1✔
800

801
# DECIMAL_POINT = locale.localeconv()['decimal_point']  # type: str
802
DECIMAL_POINT = "."
1✔
803
THOUSANDS_SEP = " "
1✔
804
assert len(DECIMAL_POINT) == 1, f"DECIMAL_POINT has unexpected len. {DECIMAL_POINT!r}"
1✔
805
assert len(THOUSANDS_SEP) == 1, f"THOUSANDS_SEP has unexpected len. {THOUSANDS_SEP!r}"
1✔
806

807

808
def format_satoshis(
1✔
809
        x: Union[int, float, Decimal, str, None],  # amount in satoshis
810
        *,
811
        num_zeros: int = 0,
812
        decimal_point: int = 8,  # how much to shift decimal point to left (default: sat->BTC)
813
        precision: int = 0,  # extra digits after satoshi precision
814
        is_diff: bool = False,  # if True, enforce a leading sign (+/-)
815
        whitespaces: bool = False,  # if True, add whitespaces, to align numbers in a column
816
        add_thousands_sep: bool = False,  # if True, add whitespaces, for better readability of the numbers
817
) -> str:
818
    if x is None:
1✔
819
        return 'unknown'
×
820
    if parse_max_spend(x):
1✔
821
        return f'max({x})'
×
822
    assert isinstance(x, (int, float, Decimal)), f"{x!r} should be a number"
1✔
823
    # TODO(ghost43) just hard-fail if x is a float. do we even use floats for money anywhere?
824
    x = to_decimal(x)
1✔
825
    # lose redundant precision
826
    x = x.quantize(Decimal(10) ** (-precision))
1✔
827
    # format string
828
    overall_precision = decimal_point + precision  # max digits after final decimal point
1✔
829
    decimal_format = "." + str(overall_precision) if overall_precision > 0 else ""
1✔
830
    if is_diff:
1✔
831
        decimal_format = '+' + decimal_format
1✔
832
    # initial result
833
    scale_factor = pow(10, decimal_point)
1✔
834
    result = ("{:" + decimal_format + "f}").format(x / scale_factor)
1✔
835
    if "." not in result: result += "."
1✔
836
    result = result.rstrip('0')
1✔
837
    # add extra decimal places (zeros)
838
    integer_part, fract_part = result.split(".")
1✔
839
    if len(fract_part) < num_zeros:
1✔
840
        fract_part += "0" * (num_zeros - len(fract_part))
1✔
841
    # add whitespaces as thousands' separator for better readability of numbers
842
    if add_thousands_sep:
1✔
843
        sign = integer_part[0] if integer_part[0] in ("+", "-") else ""
1✔
844
        if sign == "-":
1✔
845
            integer_part = integer_part[1:]
1✔
846
        integer_part = "{:,}".format(int(integer_part)).replace(',', THOUSANDS_SEP)
1✔
847
        integer_part = sign + integer_part
1✔
848
        fract_part = THOUSANDS_SEP.join(fract_part[i:i+3] for i in range(0, len(fract_part), 3))
1✔
849
    result = integer_part + DECIMAL_POINT + fract_part
1✔
850
    # add leading/trailing whitespaces so that numbers can be aligned in a column
851
    if whitespaces:
1✔
852
        target_fract_len = overall_precision
1✔
853
        target_integer_len = 14 - decimal_point  # should be enough for up to unsigned 999999 BTC
1✔
854
        if add_thousands_sep:
1✔
855
            target_fract_len += max(0, (target_fract_len - 1) // 3)
1✔
856
            target_integer_len += max(0, (target_integer_len - 1) // 3)
1✔
857
        # add trailing whitespaces
858
        result += " " * (target_fract_len - len(fract_part))
1✔
859
        # add leading whitespaces
860
        target_total_len = target_integer_len + 1 + target_fract_len
1✔
861
        result = " " * (target_total_len - len(result)) + result
1✔
862
    return result
1✔
863

864

865
FEERATE_PRECISION = 1  # num fractional decimal places for sat/byte fee rates
1✔
866
_feerate_quanta = Decimal(10) ** (-FEERATE_PRECISION)
1✔
867
UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE = "sat/vbyte"
1✔
868
UI_UNIT_NAME_FEERATE_SAT_PER_VB = "sat/vB"
1✔
869
UI_UNIT_NAME_TXSIZE_VBYTES = "vbytes"
1✔
870
UI_UNIT_NAME_MEMPOOL_MB = "vMB"
1✔
871
UI_UNIT_NAME_FIXED_SAT = "sat"
1✔
872

873

874
def format_fee_satoshis(fee, *, num_zeros=0, precision=None):
1✔
875
    if precision is None:
1✔
876
        precision = FEERATE_PRECISION
1✔
877
    num_zeros = min(num_zeros, FEERATE_PRECISION)  # no more zeroes than available prec
1✔
878
    return format_satoshis(fee, num_zeros=num_zeros, decimal_point=0, precision=precision)
1✔
879

880

881
def quantize_feerate(fee) -> Union[None, Decimal, int]:
1✔
882
    """Strip sat/byte fee rate of excess precision."""
883
    if fee is None:
1✔
884
        return None
×
885
    return Decimal(fee).quantize(_feerate_quanta, rounding=decimal.ROUND_HALF_DOWN)
1✔
886

887

888
DEFAULT_TIMEZONE = None  # type: timezone | None  # None means local OS timezone
1✔
889
def timestamp_to_datetime(timestamp: Union[int, float, None], *, utc: bool = False) -> Optional[datetime]:
1✔
890
    if timestamp is None:
1✔
891
        return None
1✔
892
    tz = DEFAULT_TIMEZONE
1✔
893
    if utc:
1✔
894
        tz = timezone.utc
×
895
    return datetime.fromtimestamp(timestamp, tz=tz)
1✔
896

897

898
def format_time(timestamp: Union[int, float, None]) -> str:
1✔
899
    date = timestamp_to_datetime(timestamp)
×
900
    return date.isoformat(' ', timespec="minutes") if date else _("Unknown")
×
901

902

903
def age(
1✔
904
    from_date: Union[int, float, None],  # POSIX timestamp
905
    *,
906
    since_date: datetime = None,
907
    target_tz=None,
908
    include_seconds: bool = False,
909
) -> str:
910
    """Takes a timestamp and returns a string with the approximation of the age"""
911
    if from_date is None:
1✔
912
        return _("Unknown")
1✔
913
    from_date = datetime.fromtimestamp(from_date)
1✔
914
    if since_date is None:
1✔
915
        since_date = datetime.now(target_tz)
×
916
    distance_in_time = from_date - since_date
1✔
917
    is_in_past = from_date < since_date
1✔
918
    s = delta_time_str(distance_in_time, include_seconds=include_seconds)
1✔
919
    return _("{} ago").format(s) if is_in_past else _("in {}").format(s)
1✔
920

921

922
def delta_time_str(distance_in_time: timedelta, *, include_seconds: bool = False) -> str:
1✔
923
    distance_in_seconds = int(round(abs(distance_in_time.days * 86400 + distance_in_time.seconds)))
1✔
924
    distance_in_minutes = int(round(distance_in_seconds / 60))
1✔
925
    if distance_in_minutes == 0:
1✔
926
        if include_seconds:
1✔
927
            return _("{} seconds").format(distance_in_seconds)
1✔
928
        else:
929
            return _("less than a minute")
1✔
930
    elif distance_in_minutes < 45:
1✔
931
        return _("about {} minutes").format(distance_in_minutes)
1✔
932
    elif distance_in_minutes < 90:
1✔
933
        return _("about 1 hour")
1✔
934
    elif distance_in_minutes < 1440:
1✔
935
        return _("about {} hours").format(round(distance_in_minutes / 60.0))
1✔
936
    elif distance_in_minutes < 2880:
1✔
937
        return _("about 1 day")
1✔
938
    elif distance_in_minutes < 43220:
1✔
939
        return _("about {} days").format(round(distance_in_minutes / 1440))
1✔
940
    elif distance_in_minutes < 86400:
1✔
941
        return _("about 1 month")
1✔
942
    elif distance_in_minutes < 525600:
1✔
943
        return _("about {} months").format(round(distance_in_minutes / 43200))
1✔
944
    elif distance_in_minutes < 1051200:
1✔
945
        return _("about 1 year")
1✔
946
    else:
947
        return _("over {} years").format(round(distance_in_minutes / 525600))
1✔
948

949

950
mainnet_block_explorers = {
1✔
951
    '3xpl.com': ('https://3xpl.com/bitcoin/',
952
                        {'tx': 'transaction/', 'addr': 'address/'}),
953
    'Bitflyer.jp': ('https://chainflyer.bitflyer.jp/',
954
                        {'tx': 'Transaction/', 'addr': 'Address/'}),
955
    'Blockchain.info': ('https://blockchain.com/btc/',
956
                        {'tx': 'tx/', 'addr': 'address/'}),
957
    'Blockstream.info': ('https://blockstream.info/',
958
                        {'tx': 'tx/', 'addr': 'address/'}),
959
    'Bitaps.com': ('https://btc.bitaps.com/',
960
                        {'tx': '', 'addr': ''}),
961
    'BTC.com': ('https://btc.com/',
962
                        {'tx': '', 'addr': ''}),
963
    'Chain.so': ('https://www.chain.so/',
964
                        {'tx': 'tx/BTC/', 'addr': 'address/BTC/'}),
965
    'Insight.is': ('https://insight.bitpay.com/',
966
                        {'tx': 'tx/', 'addr': 'address/'}),
967
    'BlockCypher.com': ('https://live.blockcypher.com/btc/',
968
                        {'tx': 'tx/', 'addr': 'address/'}),
969
    'Blockchair.com': ('https://blockchair.com/bitcoin/',
970
                        {'tx': 'transaction/', 'addr': 'address/'}),
971
    'blockonomics.co': ('https://www.blockonomics.co/',
972
                        {'tx': 'api/tx?txid=', 'addr': '#/search?q='}),
973
    'mempool.space': ('https://mempool.space/',
974
                        {'tx': 'tx/', 'addr': 'address/'}),
975
    'mempool.emzy.de': ('https://mempool.emzy.de/',
976
                        {'tx': 'tx/', 'addr': 'address/'}),
977
    'OXT.me': ('https://oxt.me/',
978
                        {'tx': 'transaction/', 'addr': 'address/'}),
979
    'mynode.local': ('http://mynode.local:3002/',
980
                        {'tx': 'tx/', 'addr': 'address/'}),
981
    'system default': ('blockchain:/',
982
                        {'tx': 'tx/', 'addr': 'address/'}),
983
}
984

985
testnet_block_explorers = {
1✔
986
    'Bitaps.com': ('https://tbtc.bitaps.com/',
987
                       {'tx': '', 'addr': ''}),
988
    'BlockCypher.com': ('https://live.blockcypher.com/btc-testnet/',
989
                       {'tx': 'tx/', 'addr': 'address/'}),
990
    'Blockchain.info': ('https://www.blockchain.com/btc-testnet/',
991
                       {'tx': 'tx/', 'addr': 'address/'}),
992
    'Blockstream.info': ('https://blockstream.info/testnet/',
993
                        {'tx': 'tx/', 'addr': 'address/'}),
994
    'mempool.space': ('https://mempool.space/testnet/',
995
                        {'tx': 'tx/', 'addr': 'address/'}),
996
    'smartbit.com.au': ('https://testnet.smartbit.com.au/',
997
                       {'tx': 'tx/', 'addr': 'address/'}),
998
    'system default': ('blockchain://000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943/',
999
                       {'tx': 'tx/', 'addr': 'address/'}),
1000
}
1001

1002
testnet4_block_explorers = {
1✔
1003
    'mempool.space': ('https://mempool.space/testnet4/',
1004
                        {'tx': 'tx/', 'addr': 'address/'}),
1005
    'wakiyamap.dev': ('https://testnet4-explorer.wakiyamap.dev/',
1006
                       {'tx': 'tx/', 'addr': 'address/'}),
1007
}
1008

1009
signet_block_explorers = {
1✔
1010
    'bc-2.jp': ('https://explorer.bc-2.jp/',
1011
                        {'tx': 'tx/', 'addr': 'address/'}),
1012
    'mempool.space': ('https://mempool.space/signet/',
1013
                        {'tx': 'tx/', 'addr': 'address/'}),
1014
    'bitcoinexplorer.org': ('https://signet.bitcoinexplorer.org/',
1015
                       {'tx': 'tx/', 'addr': 'address/'}),
1016
    'wakiyamap.dev': ('https://signet-explorer.wakiyamap.dev/',
1017
                       {'tx': 'tx/', 'addr': 'address/'}),
1018
    'ex.signet.bublina.eu.org': ('https://ex.signet.bublina.eu.org/',
1019
                       {'tx': 'tx/', 'addr': 'address/'}),
1020
    'system default': ('blockchain:/',
1021
                       {'tx': 'tx/', 'addr': 'address/'}),
1022
}
1023

1024
_block_explorer_default_api_loc = {'tx': 'tx/', 'addr': 'address/'}
1✔
1025

1026

1027
def block_explorer_info():
1✔
1028
    from . import constants
×
1029
    if constants.net.NET_NAME == "testnet":
×
1030
        return testnet_block_explorers
×
1031
    elif constants.net.NET_NAME == "testnet4":
×
1032
        return testnet4_block_explorers
×
1033
    elif constants.net.NET_NAME == "signet":
×
1034
        return signet_block_explorers
×
1035
    return mainnet_block_explorers
×
1036

1037

1038
def block_explorer(config: 'SimpleConfig') -> Optional[str]:
1✔
1039
    """Returns name of selected block explorer,
1040
    or None if a custom one (not among hardcoded ones) is configured.
1041
    """
1042
    if config.BLOCK_EXPLORER_CUSTOM is not None:
×
1043
        return None
×
1044
    be_key = config.BLOCK_EXPLORER
×
1045
    be_tuple = block_explorer_info().get(be_key)
×
1046
    if be_tuple is None:
×
1047
        be_key = config.cv.BLOCK_EXPLORER.get_default_value()
×
1048
    assert isinstance(be_key, str), f"{be_key!r} should be str"
×
1049
    return be_key
×
1050

1051

1052
def block_explorer_tuple(config: 'SimpleConfig') -> Optional[Tuple[str, dict]]:
1✔
1053
    custom_be = config.BLOCK_EXPLORER_CUSTOM
×
1054
    if custom_be:
×
1055
        if isinstance(custom_be, str):
×
1056
            return custom_be, _block_explorer_default_api_loc
×
1057
        if isinstance(custom_be, (tuple, list)) and len(custom_be) == 2:
×
1058
            return tuple(custom_be)
×
1059
        _logger.warning(f"not using {config.cv.BLOCK_EXPLORER_CUSTOM.key()!r} from config. "
×
1060
                        f"expected a str or a pair but got {custom_be!r}")
1061
        return None
×
1062
    else:
1063
        # using one of the hardcoded block explorers
1064
        return block_explorer_info().get(block_explorer(config))
×
1065

1066

1067
def block_explorer_URL(config: 'SimpleConfig', kind: str, item: str) -> Optional[str]:
1✔
1068
    be_tuple = block_explorer_tuple(config)
×
1069
    if not be_tuple:
×
1070
        return
×
1071
    explorer_url, explorer_dict = be_tuple
×
1072
    kind_str = explorer_dict.get(kind)
×
1073
    if kind_str is None:
×
1074
        return
×
1075
    if explorer_url[-1] != "/":
×
1076
        explorer_url += "/"
×
1077
    url_parts = [explorer_url, kind_str, item]
×
1078
    return ''.join(url_parts)
×
1079

1080

1081
# Python bug (http://bugs.python.org/issue1927) causes raw_input
1082
# to be redirected improperly between stdin/stderr on Unix systems
1083
#TODO: py3
1084
def raw_input(prompt=None):
1✔
1085
    if prompt:
×
1086
        sys.stdout.write(prompt)
×
1087
    return builtin_raw_input()
×
1088

1089

1090
builtin_raw_input = builtins.input
1✔
1091
builtins.input = raw_input
1✔
1092

1093

1094
def parse_json(message):
1✔
1095
    # TODO: check \r\n pattern
1096
    n = message.find(b'\n')
×
1097
    if n == -1:
×
1098
        return None, message
×
1099
    try:
×
1100
        j = json.loads(message[0:n].decode('utf8'))
×
1101
    except Exception:
×
1102
        j = None
×
1103
    return j, message[n+1:]
×
1104

1105

1106
def setup_thread_excepthook():
1✔
1107
    """
1108
    Workaround for `sys.excepthook` thread bug from:
1109
    http://bugs.python.org/issue1230540
1110

1111
    Call once from the main thread before creating any threads.
1112
    """
1113

1114
    init_original = threading.Thread.__init__
×
1115

1116
    def init(self, *args, **kwargs):
×
1117

1118
        init_original(self, *args, **kwargs)
×
1119
        run_original = self.run
×
1120

1121
        def run_with_except_hook(*args2, **kwargs2):
×
1122
            try:
×
1123
                run_original(*args2, **kwargs2)
×
1124
            except Exception:
×
1125
                sys.excepthook(*sys.exc_info())
×
1126

1127
        self.run = run_with_except_hook
×
1128

1129
    threading.Thread.__init__ = init
×
1130

1131

1132
def send_exception_to_crash_reporter(e: BaseException):
1✔
1133
    from .base_crash_reporter import send_exception_to_crash_reporter
×
1134
    send_exception_to_crash_reporter(e)
×
1135

1136

1137
def versiontuple(v):
1✔
1138
    return tuple(map(int, (v.split("."))))
1✔
1139

1140

1141
def read_json_file(path):
1✔
1142
    try:
1✔
1143
        with open(path, 'r', encoding='utf-8') as f:
1✔
1144
            data = json.loads(f.read())
1✔
1145
    except json.JSONDecodeError:
×
1146
        _logger.exception('')
×
1147
        raise FileImportFailed(_("Invalid JSON code."))
×
1148
    except BaseException as e:
×
1149
        _logger.exception('')
×
1150
        raise FileImportFailed(e)
×
1151
    return data
1✔
1152

1153

1154
def write_json_file(path, data):
1✔
1155
    try:
×
1156
        with open(path, 'w+', encoding='utf-8') as f:
×
1157
            json.dump(data, f, indent=4, sort_keys=True, cls=MyEncoder)
×
1158
    except (IOError, os.error) as e:
×
1159
        _logger.exception('')
×
1160
        raise FileExportFailed(e)
×
1161

1162

1163
def os_chmod(path, mode):
1✔
1164
    """os.chmod aware of tmpfs"""
1165
    try:
1✔
1166
        os.chmod(path, mode)
1✔
1167
    except OSError as e:
×
1168
        xdg_runtime_dir = os.environ.get("XDG_RUNTIME_DIR", None)
×
1169
        if xdg_runtime_dir and is_subpath(path, xdg_runtime_dir):
×
1170
            _logger.info(f"Tried to chmod in tmpfs. Skipping... {e!r}")
×
1171
        else:
1172
            raise
×
1173

1174

1175
def make_dir(path, *, allow_symlink=True):
1✔
1176
    """Makes directory if it does not yet exist.
1177
    Also sets sane 0700 permissions on the dir.
1178
    """
1179
    if not os.path.exists(path):
1✔
1180
        if not allow_symlink and os.path.islink(path):
1✔
1181
            raise Exception('Dangling link: ' + path)
×
1182
        try:
1✔
1183
            os.mkdir(path)
1✔
1184
        except FileExistsError:
×
1185
            # this can happen in a multiprocess race, e.g. when an electrum daemon
1186
            # and an electrum cli command are launched in rapid fire
1187
            pass
×
1188
        os_chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
1✔
1189
        assert os.path.exists(path)
1✔
1190

1191

1192
def is_subpath(long_path: str, short_path: str) -> bool:
1✔
1193
    """Returns whether long_path is a sub-path of short_path."""
1194
    try:
1✔
1195
        common = os.path.commonpath([long_path, short_path])
1✔
1196
    except ValueError:
1✔
1197
        return False
1✔
1198
    short_path = standardize_path(short_path)
1✔
1199
    common     = standardize_path(common)
1✔
1200
    return short_path == common
1✔
1201

1202

1203
def log_exceptions(func):
1✔
1204
    """Decorator to log AND re-raise exceptions."""
1205
    assert inspect.iscoroutinefunction(func), 'func needs to be a coroutine'
1✔
1206

1207
    @functools.wraps(func)
1✔
1208
    async def wrapper(*args, **kwargs):
1✔
1209
        self = args[0] if len(args) > 0 else None
1✔
1210
        try:
1✔
1211
            return await func(*args, **kwargs)
1✔
1212
        except asyncio.CancelledError as e:
1✔
1213
            raise
1✔
1214
        except BaseException as e:
1✔
1215
            mylogger = self.logger if hasattr(self, 'logger') else _logger
1✔
1216
            try:
1✔
1217
                mylogger.exception(f"Exception in {func.__name__}: {repr(e)}")
1✔
1218
            except BaseException as e2:
×
1219
                print(f"logging exception raised: {repr(e2)}... orig exc: {repr(e)} in {func.__name__}")
×
1220
            raise
1✔
1221
    return wrapper
1✔
1222

1223

1224
def ignore_exceptions(func):
1✔
1225
    """Decorator to silently swallow all exceptions."""
1226
    assert inspect.iscoroutinefunction(func), 'func needs to be a coroutine'
1✔
1227

1228
    @functools.wraps(func)
1✔
1229
    async def wrapper(*args, **kwargs):
1✔
1230
        try:
1✔
1231
            return await func(*args, **kwargs)
1✔
1232
        except Exception as e:
1✔
1233
            pass
×
1234
    return wrapper
1✔
1235

1236

1237
def with_lock(func):
1✔
1238
    """Decorator to enforce a lock on a function call."""
1239
    @functools.wraps(func)
1✔
1240
    def func_wrapper(self, *args, **kwargs):
1✔
1241
        with self.lock:
1✔
1242
            return func(self, *args, **kwargs)
1✔
1243
    return func_wrapper
1✔
1244

1245

1246
@dataclass(frozen=True, kw_only=True)
1✔
1247
class TxMinedInfo:
1✔
1248
    _height: int                       # height of block that mined tx
1✔
1249
    conf: Optional[int] = None         # number of confirmations, SPV verified. >=0, or None (None means unknown)
1✔
1250
    timestamp: Optional[int] = None    # timestamp of block that mined tx
1✔
1251
    txpos: Optional[int] = None        # position of tx in serialized block
1✔
1252
    header_hash: Optional[str] = None  # hash of block that mined tx
1✔
1253
    wanted_height: Optional[int] = None  # in case of timelock, min abs block height
1✔
1254

1255
    def height(self) -> int:
1✔
1256
        """Treat unverified heights as unconfirmed."""
1257
        h = self._height
1✔
1258
        if h > 0:
1✔
1259
            if self.conf is not None and self.conf >= 1:
1✔
1260
                return h
1✔
1261
            return 0  # treat it as unconfirmed until SPV-ed
1✔
1262
        else:  # h <= 0
1263
            return h
1✔
1264

1265
    def short_id(self) -> Optional[str]:
1✔
1266
        if self.txpos is not None and self.txpos >= 0:
×
1267
            assert self.height() > 0
×
1268
            return f"{self.height()}x{self.txpos}"
×
1269
        return None
×
1270

1271
    def is_local_like(self) -> bool:
1✔
1272
        """Returns whether the tx is local-like (LOCAL/FUTURE)."""
1273
        from .address_synchronizer import TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT
×
1274
        if self.height() > 0:
×
1275
            return False
×
1276
        if self.height() in (TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT):
×
1277
            return False
×
1278
        return True
×
1279

1280

1281
class ShortID(bytes):
1✔
1282

1283
    def __repr__(self):
1✔
1284
        return f"<ShortID: {format_short_id(self)}>"
1✔
1285

1286
    def __str__(self):
1✔
1287
        return format_short_id(self)
1✔
1288

1289
    @classmethod
1✔
1290
    def from_components(cls, block_height: int, tx_pos_in_block: int, output_index: int) -> 'ShortID':
1✔
1291
        bh = block_height.to_bytes(3, byteorder='big')
1✔
1292
        tpos = tx_pos_in_block.to_bytes(3, byteorder='big')
1✔
1293
        oi = output_index.to_bytes(2, byteorder='big')
1✔
1294
        return ShortID(bh + tpos + oi)
1✔
1295

1296
    @classmethod
1✔
1297
    def from_str(cls, scid: str) -> 'ShortID':
1✔
1298
        """Parses a formatted scid str, e.g. '643920x356x0'."""
1299
        components = scid.split("x")
1✔
1300
        if len(components) != 3:
1✔
1301
            raise ValueError(f"failed to parse ShortID: {scid!r}")
×
1302
        try:
1✔
1303
            components = [int(x) for x in components]
1✔
1304
        except ValueError:
×
1305
            raise ValueError(f"failed to parse ShortID: {scid!r}") from None
×
1306
        return ShortID.from_components(*components)
1✔
1307

1308
    @classmethod
1✔
1309
    def normalize(cls, data: Union[None, str, bytes, 'ShortID']) -> Optional['ShortID']:
1✔
1310
        if isinstance(data, ShortID) or data is None:
1✔
1311
            return data
1✔
1312
        if isinstance(data, str):
1✔
1313
            assert len(data) == 16
1✔
1314
            return ShortID.fromhex(data)
1✔
1315
        if isinstance(data, (bytes, bytearray)):
1✔
1316
            assert len(data) == 8
1✔
1317
            return ShortID(data)
1✔
1318

1319
    @property
1✔
1320
    def block_height(self) -> int:
1✔
1321
        return int.from_bytes(self[:3], byteorder='big')
1✔
1322

1323
    @property
1✔
1324
    def txpos(self) -> int:
1✔
1325
        return int.from_bytes(self[3:6], byteorder='big')
1✔
1326

1327
    @property
1✔
1328
    def output_index(self) -> int:
1✔
1329
        return int.from_bytes(self[6:8], byteorder='big')
1✔
1330

1331

1332
def format_short_id(short_channel_id: Optional[bytes]):
1✔
1333
    if not short_channel_id:
1✔
1334
        return _('Not yet available')
×
1335
    return str(int.from_bytes(short_channel_id[:3], 'big')) \
1✔
1336
        + 'x' + str(int.from_bytes(short_channel_id[3:6], 'big')) \
1337
        + 'x' + str(int.from_bytes(short_channel_id[6:], 'big'))
1338

1339

1340
def make_aiohttp_proxy_connector(proxy: 'ProxySettings', ssl_context: Optional[ssl.SSLContext] = None) -> ProxyConnector:
1✔
1341
    return ProxyConnector(
×
1342
        proxy_type=ProxyType.SOCKS5 if proxy.mode == 'socks5' else ProxyType.SOCKS4,
1343
        host=proxy.host,
1344
        port=int(proxy.port),
1345
        username=proxy.user,
1346
        password=proxy.password,
1347
        rdns=True,  # needed to prevent DNS leaks over proxy
1348
        ssl=ssl_context,
1349
    )
1350

1351

1352
def make_aiohttp_session(proxy: Optional['ProxySettings'], headers=None, timeout=None):
1✔
1353
    if headers is None:
×
1354
        headers = {'User-Agent': 'Electrum'}
×
1355
    if timeout is None:
×
1356
        # The default timeout is high intentionally.
1357
        # DNS on some systems can be really slow, see e.g. #5337
1358
        timeout = aiohttp.ClientTimeout(total=45)
×
1359
    elif isinstance(timeout, (int, float)):
×
1360
        timeout = aiohttp.ClientTimeout(total=timeout)
×
1361
    ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path)
×
1362

1363
    if proxy and proxy.enabled:
×
1364
        connector = make_aiohttp_proxy_connector(proxy, ssl_context)
×
1365
    else:
1366
        connector = aiohttp.TCPConnector(ssl=ssl_context)
×
1367

1368
    return aiohttp.ClientSession(headers=headers, timeout=timeout, connector=connector)
×
1369

1370

1371
class OldTaskGroup(aiorpcx.TaskGroup):
1✔
1372
    """Automatically raises exceptions on join; as in aiorpcx prior to version 0.20.
1373
    That is, when using TaskGroup as a context manager, if any task encounters an exception,
1374
    we would like that exception to be re-raised (propagated out). For the wait=all case,
1375
    the OldTaskGroup class is emulating the following code-snippet:
1376
    ```
1377
    async with TaskGroup() as group:
1378
        await group.spawn(task1())
1379
        await group.spawn(task2())
1380

1381
        async for task in group:
1382
            if not task.cancelled():
1383
                task.result()
1384
    ```
1385
    So instead of the above, one can just write:
1386
    ```
1387
    async with OldTaskGroup() as group:
1388
        await group.spawn(task1())
1389
        await group.spawn(task2())
1390
    ```
1391
    # TODO see if we can migrate to asyncio.timeout, introduced in python 3.11, and use stdlib instead of aiorpcx.curio...
1392
    """
1393
    async def join(self):
1✔
1394
        if self._wait is all:
1✔
1395
            exc = False
1✔
1396
            try:
1✔
1397
                async for task in self:
1✔
1398
                    if not task.cancelled():
1✔
1399
                        task.result()
1✔
1400
            except BaseException:  # including asyncio.CancelledError
1✔
1401
                exc = True
1✔
1402
                raise
1✔
1403
            finally:
1404
                if exc:
1✔
1405
                    await self.cancel_remaining()
1✔
1406
                await super().join()
1✔
1407
        else:
1408
            await super().join()
1✔
1409
            if self.completed:
1✔
1410
                self.completed.result()
1✔
1411

1412

1413
# We monkey-patch aiorpcx TimeoutAfter (used by timeout_after and ignore_after API),
1414
# to fix a timing issue present in asyncio as a whole re timing out tasks.
1415
# To see the issue we are trying to fix, consider example:
1416
#     async def outer_task():
1417
#         async with timeout_after(0.1):
1418
#             await inner_task()
1419
# When the 0.1 sec timeout expires, inner_task will get cancelled by timeout_after (=internal cancellation).
1420
# If around the same time (in terms of event loop iterations) another coroutine
1421
# cancels outer_task (=external cancellation), there will be a race.
1422
# Both cancellations work by propagating a CancelledError out to timeout_after, which then
1423
# needs to decide (in TimeoutAfter.__aexit__) whether it's due to an internal or external cancellation.
1424
# AFAICT asyncio provides no reliable way of distinguishing between the two.
1425
# This patch tries to always give priority to external cancellations.
1426
# see https://github.com/kyuupichan/aiorpcX/issues/44
1427
# see https://github.com/aio-libs/async-timeout/issues/229
1428
# see https://bugs.python.org/issue42130 and https://bugs.python.org/issue45098
1429
# TODO see if we can migrate to asyncio.timeout, introduced in python 3.11, and use stdlib instead of aiorpcx.curio...
1430
def _aiorpcx_monkeypatched_set_new_deadline(task, deadline):
1✔
1431
    def timeout_task():
1✔
1432
        task._orig_cancel()
1✔
1433
        task._timed_out = None if getattr(task, "_externally_cancelled", False) else deadline
1✔
1434

1435
    def mycancel(*args, **kwargs):
1✔
1436
        task._orig_cancel(*args, **kwargs)
1✔
1437
        task._externally_cancelled = True
1✔
1438
        task._timed_out = None
1✔
1439

1440
    if not hasattr(task, "_orig_cancel"):
1✔
1441
        task._orig_cancel = task.cancel
1✔
1442
        task.cancel = mycancel
1✔
1443
    task._deadline_handle = task._loop.call_at(deadline, timeout_task)
1✔
1444

1445

1446
def _aiorpcx_monkeypatched_set_task_deadline(task, deadline):
1✔
1447
    ret = _aiorpcx_orig_set_task_deadline(task, deadline)
1✔
1448
    task._externally_cancelled = None
1✔
1449
    return ret
1✔
1450

1451

1452
def _aiorpcx_monkeypatched_unset_task_deadline(task):
1✔
1453
    if hasattr(task, "_orig_cancel"):
1✔
1454
        task.cancel = task._orig_cancel
1✔
1455
        del task._orig_cancel
1✔
1456
    return _aiorpcx_orig_unset_task_deadline(task)
1✔
1457

1458

1459
_aiorpcx_orig_set_task_deadline    = aiorpcx.curio._set_task_deadline
1✔
1460
_aiorpcx_orig_unset_task_deadline  = aiorpcx.curio._unset_task_deadline
1✔
1461

1462
aiorpcx.curio._set_new_deadline    = _aiorpcx_monkeypatched_set_new_deadline
1✔
1463
aiorpcx.curio._set_task_deadline   = _aiorpcx_monkeypatched_set_task_deadline
1✔
1464
aiorpcx.curio._unset_task_deadline = _aiorpcx_monkeypatched_unset_task_deadline
1✔
1465

1466

1467
async def wait_for2(fut: Awaitable, timeout: Union[int, float, None]):
1✔
1468
    """Replacement for asyncio.wait_for,
1469
     due to bugs: https://bugs.python.org/issue42130 and https://github.com/python/cpython/issues/86296 ,
1470
     which are only fixed in python 3.12+.
1471
     """
1472
    if sys.version_info[:3] >= (3, 12):
1✔
1473
        return await asyncio.wait_for(fut, timeout)
×
1474
    else:
1475
        async with async_timeout(timeout):
1✔
1476
            return await asyncio.ensure_future(fut, loop=get_running_loop())
1✔
1477

1478

1479
if hasattr(asyncio, 'timeout'):  # python 3.11+
1✔
1480
    async_timeout = asyncio.timeout
×
1481
else:
1482
    class TimeoutAfterAsynciolike(aiorpcx.curio.TimeoutAfter):
1✔
1483
        async def __aexit__(self, exc_type, exc_value, tb):
1✔
1484
            try:
1✔
1485
                await super().__aexit__(exc_type, exc_value, tb)
1✔
1486
            except (aiorpcx.TaskTimeout, aiorpcx.UncaughtTimeoutError):
1✔
1487
                raise asyncio.TimeoutError from None
1✔
1488
            except aiorpcx.TimeoutCancellationError:
×
1489
                raise asyncio.CancelledError from None
×
1490

1491
    def async_timeout(delay: Union[int, float, None]):
1✔
1492
        if delay is None:
1✔
1493
            return nullcontext()
1✔
1494
        return TimeoutAfterAsynciolike(delay)
1✔
1495

1496

1497
class NetworkJobOnDefaultServer(Logger, ABC):
1✔
1498
    """An abstract base class for a job that runs on the main network
1499
    interface. Every time the main interface changes, the job is
1500
    restarted, and some of its internals are reset.
1501
    """
1502
    def __init__(self, network: 'Network'):
1✔
1503
        Logger.__init__(self)
1✔
1504
        self.network = network
1✔
1505
        self.interface = None  # type: Interface
1✔
1506
        self._restart_lock = asyncio.Lock()
1✔
1507
        # Ensure fairness between NetworkJobs. e.g. if multiple wallets
1508
        # are open, a large wallet's Synchronizer should not starve the small wallets:
1509
        self._network_request_semaphore = asyncio.Semaphore(100)
1✔
1510

1511
        self._reset()
1✔
1512
        # every time the main interface changes, restart:
1513
        register_callback(self._restart, ['default_server_changed'])
1✔
1514
        # also schedule a one-off restart now, as there might already be a main interface:
1515
        asyncio.run_coroutine_threadsafe(self._restart(), network.asyncio_loop)
1✔
1516

1517
    def _reset(self):
1✔
1518
        """Initialise fields. Called every time the underlying
1519
        server connection changes.
1520
        """
1521
        self.taskgroup = OldTaskGroup()
1✔
1522
        self.reset_request_counters()
1✔
1523

1524
    async def _start(self, interface: 'Interface'):
1✔
1525
        self.logger.debug(f"starting. interface.server={repr(str(interface.server))}")
1✔
1526
        self.interface = interface
1✔
1527

1528
        taskgroup = self.taskgroup
1✔
1529

1530
        async def run_tasks_wrapper():
1✔
1531
            self.logger.debug(f"starting taskgroup ({hex(id(taskgroup))}).")
1✔
1532
            try:
1✔
1533
                await self._run_tasks(taskgroup=taskgroup)
1✔
1534
            except Exception as e:
1✔
1535
                self.logger.error(f"taskgroup died ({hex(id(taskgroup))}). exc={e!r}")
×
1536
                raise
×
1537
            finally:
1538
                self.logger.debug(f"taskgroup stopped ({hex(id(taskgroup))}).")
1✔
1539
        await interface.taskgroup.spawn(run_tasks_wrapper)
1✔
1540

1541
    @abstractmethod
1✔
1542
    async def _run_tasks(self, *, taskgroup: OldTaskGroup) -> None:
1✔
1543
        """Start tasks in taskgroup. Called every time the underlying
1544
        server connection changes.
1545
        """
1546
        # If self.taskgroup changed, don't start tasks. This can happen if we have
1547
        # been restarted *just now*, i.e. after the _run_tasks coroutine object was created.
1548
        if taskgroup != self.taskgroup:
1✔
1549
            raise asyncio.CancelledError()
×
1550

1551
    async def stop(self, *, full_shutdown: bool = True):
1✔
1552
        self.logger.debug(f"stopping. {full_shutdown=}")
1✔
1553
        if full_shutdown:
1✔
1554
            unregister_callback(self._restart)
×
1555
        await self.taskgroup.cancel_remaining()
1✔
1556

1557
    @log_exceptions
1✔
1558
    async def _restart(self, *args):
1✔
1559
        interface = self.network.interface
1✔
1560
        if interface is None:
1✔
1561
            return  # we should get called again soon
1✔
1562

1563
        async with self._restart_lock:
1✔
1564
            await self.stop(full_shutdown=False)
1✔
1565
            self._reset()
1✔
1566
            await self._start(interface)
1✔
1567

1568
    def reset_request_counters(self):
1✔
1569
        self._requests_sent = 0
1✔
1570
        self._requests_answered = 0
1✔
1571

1572
    def num_requests_sent_and_answered(self) -> Tuple[int, int]:
1✔
1573
        return self._requests_sent, self._requests_answered
×
1574

1575
    @property
1✔
1576
    def session(self):
1✔
1577
        s = self.interface.session
1✔
1578
        assert s is not None
1✔
1579
        return s
1✔
1580

1581

1582
async def detect_tor_socks_proxy() -> Optional[Tuple[str, int]]:
1✔
1583
    # Probable ports for Tor to listen at
1584
    candidates = [
×
1585
        ("127.0.0.1", 9050),
1586
        ("127.0.0.1", 9051),
1587
        ("127.0.0.1", 9150),
1588
    ]
1589

1590
    proxy_addr = None
×
1591

1592
    async def test_net_addr(net_addr):
×
1593
        is_tor = await is_tor_socks_port(*net_addr)
×
1594
        # set result, and cancel remaining probes
1595
        if is_tor:
×
1596
            nonlocal proxy_addr
1597
            proxy_addr = net_addr
×
1598
            await group.cancel_remaining()
×
1599

1600
    async with OldTaskGroup() as group:
×
1601
        for net_addr in candidates:
×
1602
            await group.spawn(test_net_addr(net_addr))
×
1603
    return proxy_addr
×
1604

1605

1606
@log_exceptions
1✔
1607
async def is_tor_socks_port(host: str, port: int) -> bool:
1✔
1608
    # mimic "tor-resolve 0.0.0.0".
1609
    # see https://github.com/spesmilo/electrum/issues/7317#issuecomment-1369281075
1610
    # > this is a socks5 handshake, followed by a socks RESOLVE request as defined in
1611
    # > [tor's socks extension spec](https://github.com/torproject/torspec/blob/7116c9cdaba248aae07a3f1d0e15d9dd102f62c5/socks-extensions.txt#L63),
1612
    # > resolving 0.0.0.0, which being an IP, tor resolves itself without needing to ask a relay.
1613
    writer = None
×
1614
    try:
×
1615
        async with async_timeout(10):
×
1616
            reader, writer = await asyncio.open_connection(host, port)
×
1617
            writer.write(b'\x05\x01\x00\x05\xf0\x00\x03\x070.0.0.0\x00\x00')
×
1618
            await writer.drain()
×
1619
            data = await reader.read(1024)
×
1620
            if data == b'\x05\x00\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00':
×
1621
                return True
×
1622
            return False
×
1623
    except (OSError, asyncio.TimeoutError):
×
1624
        return False
×
1625
    finally:
1626
        if writer:
×
1627
            writer.close()
×
1628

1629

1630
AS_LIB_USER_I_WANT_TO_MANAGE_MY_OWN_ASYNCIO_LOOP = False  # used by unit tests
1✔
1631

1632
_asyncio_event_loop = None  # type: Optional[asyncio.AbstractEventLoop]
1✔
1633

1634

1635
def get_asyncio_loop() -> asyncio.AbstractEventLoop:
1✔
1636
    """Returns the global asyncio event loop we use."""
1637
    if loop := _asyncio_event_loop:
1✔
1638
        return loop
1✔
1639
    if AS_LIB_USER_I_WANT_TO_MANAGE_MY_OWN_ASYNCIO_LOOP:
1✔
1640
        if loop := get_running_loop():
1✔
1641
            return loop
1✔
1642
    raise Exception("event loop not created yet")
×
1643

1644

1645
def create_and_start_event_loop() -> Tuple[asyncio.AbstractEventLoop,
1✔
1646
                                           asyncio.Future,
1647
                                           threading.Thread]:
1648
    global _asyncio_event_loop
1649
    if _asyncio_event_loop is not None:
×
1650
        raise Exception("there is already a running event loop")
×
1651

1652
    # asyncio.get_event_loop() became deprecated in python3.10. (see https://github.com/python/cpython/issues/83710)
1653
    # We set a custom event loop policy purely to be compatible with code that
1654
    # relies on asyncio.get_event_loop().
1655
    # - in python 3.8-3.9, asyncio.Event.__init__, asyncio.Lock.__init__,
1656
    #   and similar, calls get_event_loop. see https://github.com/python/cpython/pull/23420
1657
    class MyEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
×
1658
        def get_event_loop(self):
×
1659
            # In case electrum is being used as a library, there might be other
1660
            # event loops in use besides ours. To minimise interfering with those,
1661
            # if there is a loop running in the current thread, return that:
1662
            running_loop = get_running_loop()
×
1663
            if running_loop is not None:
×
1664
                return running_loop
×
1665
            # Otherwise, return our global loop:
1666
            return get_asyncio_loop()
×
1667
    asyncio.set_event_loop_policy(MyEventLoopPolicy())
×
1668

1669
    loop = asyncio.new_event_loop()
×
1670
    _asyncio_event_loop = loop
×
1671

1672
    def on_exception(loop, context):
×
1673
        """Suppress spurious messages it appears we cannot control."""
1674
        SUPPRESS_MESSAGE_REGEX = re.compile('SSL handshake|Fatal read error on|'
×
1675
                                            'SSL error in data received')
1676
        message = context.get('message')
×
1677
        if message and SUPPRESS_MESSAGE_REGEX.match(message):
×
1678
            return
×
1679
        loop.default_exception_handler(context)
×
1680

1681
    def run_event_loop():
×
1682
        try:
×
1683
            loop.run_until_complete(stopping_fut)
×
1684
        finally:
1685
            # clean-up
1686
            try:
×
1687
                pending_tasks = asyncio.gather(*asyncio.all_tasks(loop), return_exceptions=True)
×
1688
                pending_tasks.cancel()
×
1689
                with suppress(asyncio.CancelledError):
×
1690
                    loop.run_until_complete(pending_tasks)
×
1691
                loop.run_until_complete(loop.shutdown_asyncgens())
×
1692
                if isinstance(loop, asyncio.BaseEventLoop):
×
1693
                    loop.run_until_complete(loop.shutdown_default_executor())
×
1694
            except Exception as e:
×
1695
                _logger.debug(f"exception when cleaning up asyncio event loop: {e}")
×
1696

1697
            global _asyncio_event_loop
1698
            _asyncio_event_loop = None
×
1699
            loop.close()
×
1700

1701
    loop.set_exception_handler(on_exception)
×
1702
    _set_custom_task_factory(loop)
×
1703
    # loop.set_debug(True)
1704
    stopping_fut = loop.create_future()
×
1705
    loop_thread = threading.Thread(
×
1706
        target=run_event_loop,
1707
        name='EventLoop',
1708
    )
1709
    loop_thread.start()
×
1710
    # Wait until the loop actually starts.
1711
    # On a slow PC, or with a debugger attached, this can take a few dozens of ms,
1712
    # and if we returned without a running loop, weird things can happen...
1713
    t0 = time.monotonic()
×
1714
    while not loop.is_running():
×
1715
        time.sleep(0.01)
×
1716
        if time.monotonic() - t0 > 5:
×
1717
            raise Exception("been waiting for 5 seconds but asyncio loop would not start!")
×
1718
    return loop, stopping_fut, loop_thread
×
1719

1720

1721
_running_asyncio_tasks = set()  # type: Set[asyncio.Future]
1✔
1722

1723

1724
def _set_custom_task_factory(loop: asyncio.AbstractEventLoop):
1✔
1725
    """Wrap task creation to track pending and running tasks.
1726
    When tasks are created, asyncio only maintains a weak reference to them.
1727
    Hence, the garbage collector might destroy the task mid-execution.
1728
    To avoid this, we store a strong reference for the task until it completes.
1729

1730
    Without this, a lot of APIs are basically Heisenbug-generators... e.g.:
1731
    - "asyncio.create_task"
1732
    - "loop.create_task"
1733
    - "asyncio.ensure_future"
1734
    - "asyncio.run_coroutine_threadsafe"
1735

1736
    related:
1737
        - https://bugs.python.org/issue44665
1738
        - https://github.com/python/cpython/issues/88831
1739
        - https://github.com/python/cpython/issues/91887
1740
        - https://textual.textualize.io/blog/2023/02/11/the-heisenbug-lurking-in-your-async-code/
1741
        - https://github.com/python/cpython/issues/91887#issuecomment-1434816045
1742
        - "Task was destroyed but it is pending!"
1743
    """
1744

1745
    platform_task_factory = loop.get_task_factory()
1✔
1746

1747
    def factory(loop_, coro, **kwargs):
1✔
1748
        if platform_task_factory is not None:
1✔
1749
            task = platform_task_factory(loop_, coro, **kwargs)
×
1750
        else:
1751
            task = asyncio.Task(coro, loop=loop_, **kwargs)
1✔
1752
        _running_asyncio_tasks.add(task)
1✔
1753
        task.add_done_callback(_running_asyncio_tasks.discard)
1✔
1754
        return task
1✔
1755

1756
    loop.set_task_factory(factory)
1✔
1757

1758

1759
def run_sync_function_on_asyncio_thread(func: Callable[[], Any], *, block: bool) -> None:
1✔
1760
    """Run a non-async fn on the asyncio thread. Can be called from any thread.
1761

1762
    If the current thread is already the asyncio thread, func is guaranteed
1763
    to have been completed when this method returns.
1764

1765
    For any other thread, we only wait for completion if `block` is True.
1766
    """
1767
    assert not inspect.iscoroutinefunction(func), "func must be a non-async function"
1✔
1768
    asyncio_loop = get_asyncio_loop()
1✔
1769
    if get_running_loop() == asyncio_loop:  # we are running on the asyncio thread
1✔
1770
        func()
1✔
1771
    else:  # non-asyncio thread
1772
        async def wrapper():
×
1773
            return func()
×
1774
        fut = asyncio.run_coroutine_threadsafe(wrapper(), loop=asyncio_loop)
×
1775
        if block:
×
1776
            fut.result()
×
1777
        else:
1778
            # add explicit logging of exceptions, otherwise they might get lost
1779
            tb1 = traceback.format_stack()[:-1]
×
1780
            tb1_str = "".join(tb1)
×
1781

1782
            def on_done(fut_: concurrent.futures.Future):
×
1783
                assert fut_.done()
×
1784
                if fut_.cancelled():
×
1785
                    _logger.debug(f"func cancelled. {func=}.")
×
1786
                elif exc := fut_.exception():
×
1787
                    # note: We explicitly log the first part of the traceback, tb1_str.
1788
                    #       The second part gets logged by setting "exc_info".
1789
                    _logger.error(
×
1790
                        f"func errored. {func=}. {exc=}"
1791
                        f"\n{tb1_str}", exc_info=exc)
1792
            fut.add_done_callback(on_done)
×
1793

1794

1795
class OrderedDictWithIndex(OrderedDict):
1✔
1796
    """An OrderedDict that keeps track of the positions of keys.
1797

1798
    Note: very inefficient to modify contents, except to add new items.
1799
    """
1800

1801
    def __init__(self):
1✔
1802
        super().__init__()
1✔
1803
        self._key_to_pos = {}
1✔
1804
        self._pos_to_key = {}
1✔
1805

1806
    def _recalc_index(self):
1✔
1807
        self._key_to_pos = {key: pos for (pos, key) in enumerate(self.keys())}
×
1808
        self._pos_to_key = {pos: key for (pos, key) in enumerate(self.keys())}
×
1809

1810
    def pos_from_key(self, key):
1✔
1811
        return self._key_to_pos[key]
×
1812

1813
    def value_from_pos(self, pos):
1✔
1814
        key = self._pos_to_key[pos]
×
1815
        return self[key]
×
1816

1817
    def popitem(self, *args, **kwargs):
1✔
1818
        ret = super().popitem(*args, **kwargs)
×
1819
        self._recalc_index()
×
1820
        return ret
×
1821

1822
    def move_to_end(self, *args, **kwargs):
1✔
1823
        ret = super().move_to_end(*args, **kwargs)
×
1824
        self._recalc_index()
×
1825
        return ret
×
1826

1827
    def clear(self):
1✔
1828
        ret = super().clear()
×
1829
        self._recalc_index()
×
1830
        return ret
×
1831

1832
    def pop(self, *args, **kwargs):
1✔
1833
        ret = super().pop(*args, **kwargs)
×
1834
        self._recalc_index()
×
1835
        return ret
×
1836

1837
    def update(self, *args, **kwargs):
1✔
1838
        ret = super().update(*args, **kwargs)
×
1839
        self._recalc_index()
×
1840
        return ret
×
1841

1842
    def __delitem__(self, *args, **kwargs):
1✔
1843
        ret = super().__delitem__(*args, **kwargs)
×
1844
        self._recalc_index()
×
1845
        return ret
×
1846

1847
    def __setitem__(self, key, *args, **kwargs):
1✔
1848
        is_new_key = key not in self
1✔
1849
        ret = super().__setitem__(key, *args, **kwargs)
1✔
1850
        if is_new_key:
1✔
1851
            pos = len(self) - 1
1✔
1852
            self._key_to_pos[key] = pos
1✔
1853
            self._pos_to_key[pos] = key
1✔
1854
        return ret
1✔
1855

1856

1857
def make_object_immutable(obj):
1✔
1858
    """Makes the passed object immutable recursively."""
1859
    allowed_types = (
1✔
1860
        dict, MappingProxyType, list, tuple, set, frozenset, str, int, float, bool, bytes, type(None)
1861
    )
1862
    assert isinstance(obj, allowed_types), f"{type(obj)=} cannot be made immutable"
1✔
1863
    if isinstance(obj, (dict, MappingProxyType)):
1✔
1864
        return MappingProxyType({k: make_object_immutable(v) for k, v in obj.items()})
1✔
1865
    elif isinstance(obj, (list, tuple)):
1✔
1866
        return tuple(make_object_immutable(item) for item in obj)
1✔
1867
    elif isinstance(obj, (set, frozenset)):
1✔
1868
        return frozenset(make_object_immutable(item) for item in obj)
×
1869
    return obj
1✔
1870

1871

1872
def multisig_type(wallet_type):
1✔
1873
    """If wallet_type is mofn multi-sig, return [m, n],
1874
    otherwise return None."""
1875
    if not wallet_type:
1✔
1876
        return None
×
1877
    match = re.match(r'(\d+)of(\d+)', wallet_type)
1✔
1878
    if match:
1✔
1879
        match = [int(x) for x in match.group(1, 2)]
1✔
1880
    return match
1✔
1881

1882

1883
def is_ip_address(x: Union[str, bytes]) -> bool:
1✔
1884
    if isinstance(x, bytes):
1✔
1885
        x = x.decode("utf-8")
×
1886
    try:
1✔
1887
        ipaddress.ip_address(x)
1✔
1888
        return True
1✔
1889
    except ValueError:
1✔
1890
        return False
1✔
1891

1892

1893
def is_localhost(host: str) -> bool:
1✔
1894
    if str(host) in ('localhost', 'localhost.',):
1✔
1895
        return True
1✔
1896
    if host[0] == '[' and host[-1] == ']':  # IPv6
1✔
1897
        host = host[1:-1]
1✔
1898
    try:
1✔
1899
        ip_addr = ipaddress.ip_address(host)  # type: Union[IPv4Address, IPv6Address]
1✔
1900
        return ip_addr.is_loopback
1✔
1901
    except ValueError:
1✔
1902
        pass  # not an IP
1✔
1903
    return False
1✔
1904

1905

1906
def is_private_netaddress(host: str) -> bool:
1✔
1907
    if is_localhost(host):
1✔
1908
        return True
1✔
1909
    if host[0] == '[' and host[-1] == ']':  # IPv6
1✔
1910
        host = host[1:-1]
1✔
1911
    try:
1✔
1912
        ip_addr = ipaddress.ip_address(host)  # type: Union[IPv4Address, IPv6Address]
1✔
1913
        return ip_addr.is_private
1✔
1914
    except ValueError:
1✔
1915
        pass  # not an IP
1✔
1916
    return False
1✔
1917

1918

1919
def list_enabled_bits(x: int) -> Sequence[int]:
1✔
1920
    """e.g. 77 (0b1001101) --> (0, 2, 3, 6)"""
1921
    binary = bin(x)[2:]
1✔
1922
    rev_bin = reversed(binary)
1✔
1923
    return tuple(i for i, b in enumerate(rev_bin) if b == '1')
1✔
1924

1925

1926
async def resolve_dns_srv(host: str):
1✔
1927
    # FIXME this method is not using the network proxy. (although the proxy might not support UDP?)
1928
    srv_records = await dns.asyncresolver.resolve(host, 'SRV')
×
1929
    # priority: prefer lower
1930
    # weight: tie breaker; prefer higher
1931
    srv_records = sorted(srv_records, key=lambda x: (x.priority, -x.weight))
×
1932

1933
    def dict_from_srv_record(srv):
×
1934
        return {
×
1935
            'host': str(srv.target),
1936
            'port': srv.port,
1937
        }
1938
    return [dict_from_srv_record(srv) for srv in srv_records]
×
1939

1940

1941
def randrange(bound: int) -> int:
1✔
1942
    """Return a random integer k such that 1 <= k < bound, uniformly
1943
    distributed across that range.
1944
    This is guaranteed to be cryptographically strong.
1945
    """
1946
    # secrets.randbelow(bound) returns a random int: 0 <= r < bound,
1947
    # hence transformations:
1948
    return secrets.randbelow(bound - 1) + 1
1✔
1949

1950

1951
class CallbackManager(Logger):
1✔
1952
    # callbacks set by the GUI or any thread
1953
    # guarantee: the callbacks will always get triggered from the asyncio thread.
1954

1955
    # FIXME: There should be a way to prevent circular callbacks.
1956
    # At the very least, we need a distinction between callbacks that
1957
    # are for the GUI and callbacks between wallet components
1958

1959
    def __init__(self):
1✔
1960
        Logger.__init__(self)
1✔
1961
        self.callback_lock = threading.RLock()
1✔
1962
        self._wcallbacks = defaultdict(set)  # type: Dict[str, Set[weakref.ref[Callable]]]  # note: needs self.callback_lock
1✔
1963

1964
    @staticmethod
1✔
1965
    def _wcb_from_any_callback(cb: Callable) -> weakref.ref[Callable]:
1✔
1966
        assert callable(cb), type(cb)
1✔
1967
        if isinstance(cb, weakref.ref):  # no-op
1✔
1968
            return cb
×
1969
        elif inspect.ismethod(cb):  # instance method, such as for a subclass of EventListener
1✔
1970
            return WeakMethodProper(cb)
1✔
1971
        else:  # proper function? e.g. used by lnpeer unit tests
1972
            return weakref.ref(cb)
1✔
1973

1974
    def register_callback(self, cb: Callable, events: Sequence[str]) -> None:
1✔
1975
        wcb = self._wcb_from_any_callback(cb)
1✔
1976
        with self.callback_lock:
1✔
1977
            for event in events:
1✔
1978
                self._wcallbacks[event].add(wcb)
1✔
1979

1980
    def unregister_callback(self, cb: Callable) -> None:
1✔
1981
        wcb = self._wcb_from_any_callback(cb)
1✔
1982
        with self.callback_lock:
1✔
1983
            # note: ^ callback_lock needs to be re-entrant, as we can now trigger __del__, which also takes the lock
1984
            for callbacks in self._wcallbacks.values():
1✔
1985
                if wcb in callbacks:
1✔
1986
                    callbacks.remove(wcb)
1✔
1987

1988
    def count_all_callbacks(self) -> int:
1✔
1989
        with self.callback_lock:
1✔
1990
            return sum(len(cbs) for cbs in self._wcallbacks.values())
1✔
1991

1992
    def clear_all_callbacks(self) -> None:
1✔
1993
        with self.callback_lock:
1✔
1994
            self._wcallbacks.clear()
1✔
1995

1996
    def trigger_callback(self, event: str, *args) -> None:
1✔
1997
        """Trigger a callback with given arguments.
1998
        Can be called from any thread. The callback itself will get scheduled
1999
        on the event loop.
2000
        """
2001
        loop = get_asyncio_loop()
1✔
2002
        assert loop.is_running(), "event loop not running"
1✔
2003
        with self.callback_lock:
1✔
2004
            wcallbacks = copy.copy(self._wcallbacks[event])
1✔
2005
        for wcb in wcallbacks:
1✔
2006
            callback = wcb()
1✔
2007
            if callback is None:
1✔
2008
                continue
1✔
2009
            if inspect.iscoroutinefunction(callback):  # async cb
1✔
2010
                fut = asyncio.run_coroutine_threadsafe(callback(*args), loop)
1✔
2011

2012
                def on_done(fut_: concurrent.futures.Future):
1✔
2013
                    assert fut_.done()
1✔
2014
                    if fut_.cancelled():
1✔
2015
                        self.logger.debug(f"cb cancelled. {event=}.")
×
2016
                    elif exc := fut_.exception():
1✔
2017
                        self.logger.error(f"cb errored. {event=}. {exc=}", exc_info=exc)
×
2018
                fut.add_done_callback(on_done)
1✔
2019
            else:  # non-async cb
2020
                run_sync_function_on_asyncio_thread(partial(callback, *args), block=False)
1✔
2021

2022

2023
callback_mgr = CallbackManager()
1✔
2024
trigger_callback = callback_mgr.trigger_callback
1✔
2025
register_callback = callback_mgr.register_callback
1✔
2026
unregister_callback = callback_mgr.unregister_callback
1✔
2027
_event_listeners = defaultdict(set)  # type: Dict[str, Set[str]]
1✔
2028

2029

2030
class WeakMethodProper(weakref.WeakMethod):
1✔
2031
    """Unlike weakref.WeakMethod, this class has an __eq__ I can trust."""
2032
    def __init__(self, *args, **kwargs):
1✔
2033
        super().__init__(*args, **kwargs)
1✔
2034
        meth = self()
1✔
2035
        self._my_id = (id(meth.__self__), id(meth.__func__))
1✔
2036

2037
    def __hash__(self):
1✔
2038
        return hash(self._my_id)
1✔
2039

2040
    def __eq__(self, other):
1✔
2041
        if not isinstance(other, WeakMethodProper):
1✔
2042
            return False
×
2043
        return self._my_id == other._my_id
1✔
2044

2045

2046
class EventListener:
1✔
2047
    """Use as a mixin for a class that has methods to be triggered on events.
2048
    - Methods that receive the callbacks should be named "on_event_*" and decorated with @event_listener.
2049
    - register_callbacks() should be called once per instance of EventListener, e.g. in __init__
2050
    - unregister_callbacks() should be called at least once, e.g. when the instance is destroyed
2051
        - as fallback, __del__() also calls unregister_callbacks()
2052
    """
2053

2054
    def _list_callbacks(self):
1✔
2055
        for c in self.__class__.__mro__:
1✔
2056
            classpath = f"{c.__module__}.{c.__name__}"
1✔
2057
            for method_name in _event_listeners[classpath]:
1✔
2058
                method = getattr(self, method_name)
1✔
2059
                assert callable(method)
1✔
2060
                assert method_name.startswith('on_event_')
1✔
2061
                yield method_name[len('on_event_'):], method
1✔
2062

2063
    def register_callbacks(self):
1✔
2064
        for name, method in self._list_callbacks():
1✔
2065
            #_logger.debug(f'registering callback {method}')
2066
            register_callback(method, [name])
1✔
2067

2068
    def unregister_callbacks(self):
1✔
2069
        for name, method in self._list_callbacks():
1✔
2070
            #_logger.debug(f'unregistering callback {method}')
2071
            unregister_callback(method)
1✔
2072

2073
    def __del__(self):
1✔
2074
        self.unregister_callbacks()
1✔
2075

2076

2077
def event_listener(func):
1✔
2078
    """To be used in subclasses of EventListener only. (how to enforce this programmatically?)"""
2079
    classname, method_name = func.__qualname__.split('.')
1✔
2080
    assert method_name.startswith('on_event_')
1✔
2081
    classpath = f"{func.__module__}.{classname}"
1✔
2082
    _event_listeners[classpath].add(method_name)
1✔
2083
    return func
1✔
2084

2085

2086
_NetAddrType = TypeVar("_NetAddrType")
1✔
2087
# requirements for _NetAddrType:
2088
# - reasonable __hash__() implementation (e.g. based on host/port of remote endpoint)
2089

2090

2091
class NetworkRetryManager(Generic[_NetAddrType]):
1✔
2092
    """Truncated Exponential Backoff for network connections."""
2093

2094
    def __init__(
1✔
2095
            self, *,
2096
            max_retry_delay_normal: float,
2097
            init_retry_delay_normal: float,
2098
            max_retry_delay_urgent: float = None,
2099
            init_retry_delay_urgent: float = None,
2100
    ):
2101
        self._last_tried_addr = {}  # type: Dict[_NetAddrType, Tuple[float, int]]  # (unix ts, num_attempts)
1✔
2102

2103
        # note: these all use "seconds" as unit
2104
        if max_retry_delay_urgent is None:
1✔
2105
            max_retry_delay_urgent = max_retry_delay_normal
×
2106
        if init_retry_delay_urgent is None:
1✔
2107
            init_retry_delay_urgent = init_retry_delay_normal
×
2108
        self._max_retry_delay_normal = max_retry_delay_normal
1✔
2109
        self._init_retry_delay_normal = init_retry_delay_normal
1✔
2110
        self._max_retry_delay_urgent = max_retry_delay_urgent
1✔
2111
        self._init_retry_delay_urgent = init_retry_delay_urgent
1✔
2112

2113
    def _trying_addr_now(self, addr: _NetAddrType) -> None:
1✔
2114
        last_time, num_attempts = self._last_tried_addr.get(addr, (0, 0))
×
2115
        # we add up to 1 second of noise to the time, so that clients are less likely
2116
        # to get synchronised and bombard the remote in connection waves:
2117
        cur_time = time.time() + random.random()
×
2118
        self._last_tried_addr[addr] = cur_time, num_attempts + 1
×
2119

2120
    def _on_connection_successfully_established(self, addr: _NetAddrType) -> None:
1✔
2121
        self._last_tried_addr[addr] = time.time(), 0
×
2122

2123
    def _can_retry_addr(self, addr: _NetAddrType, *,
1✔
2124
                        now: float = None, urgent: bool = False) -> bool:
2125
        if now is None:
×
2126
            now = time.time()
×
2127
        last_time, num_attempts = self._last_tried_addr.get(addr, (0, 0))
×
2128
        if urgent:
×
2129
            max_delay = self._max_retry_delay_urgent
×
2130
            init_delay = self._init_retry_delay_urgent
×
2131
        else:
2132
            max_delay = self._max_retry_delay_normal
×
2133
            init_delay = self._init_retry_delay_normal
×
2134
        delay = self.__calc_delay(multiplier=init_delay, max_delay=max_delay, num_attempts=num_attempts)
×
2135
        next_time = last_time + delay
×
2136
        return next_time < now
×
2137

2138
    @classmethod
1✔
2139
    def __calc_delay(cls, *, multiplier: float, max_delay: float,
1✔
2140
                     num_attempts: int) -> float:
2141
        num_attempts = min(num_attempts, 100_000)
×
2142
        try:
×
2143
            res = multiplier * 2 ** num_attempts
×
2144
        except OverflowError:
×
2145
            return max_delay
×
2146
        return max(0, min(max_delay, res))
×
2147

2148
    def _clear_addr_retry_times(self) -> None:
1✔
2149
        self._last_tried_addr.clear()
1✔
2150

2151

2152
class ESocksProxy(aiorpcx.SOCKSProxy):
1✔
2153
    # note: proxy will not leak DNS as create_connection()
2154
    # sets (local DNS) resolve=False by default
2155

2156
    async def open_connection(self, host=None, port=None, **kwargs):
1✔
2157
        loop = asyncio.get_running_loop()
×
2158
        reader = asyncio.StreamReader(loop=loop)
×
2159
        protocol = asyncio.StreamReaderProtocol(reader, loop=loop)
×
2160
        transport, _ = await self.create_connection(
×
2161
            lambda: protocol, host, port, **kwargs)
2162
        writer = asyncio.StreamWriter(transport, protocol, reader, loop)
×
2163
        return reader, writer
×
2164

2165
    @classmethod
1✔
2166
    def from_network_settings(cls, network: Optional['Network']) -> Optional['ESocksProxy']:
1✔
2167
        if not network or not network.proxy or not network.proxy.enabled:
1✔
2168
            return None
1✔
2169
        proxy = network.proxy
×
2170
        username, pw = proxy.user, proxy.password
×
2171
        if not username or not pw:
×
2172
            # is_proxy_tor is tri-state; None indicates it is still probing the proxy to test for TOR
2173
            if network.is_proxy_tor:
×
2174
                auth = aiorpcx.socks.SOCKSRandomAuth()
×
2175
            else:
2176
                auth = None
×
2177
        else:
2178
            auth = aiorpcx.socks.SOCKSUserAuth(username, pw)
×
2179
        addr = aiorpcx.NetAddress(proxy.host, proxy.port)
×
2180
        if proxy.mode == "socks4":
×
2181
            ret = cls(addr, aiorpcx.socks.SOCKS4a, auth)
×
2182
        elif proxy.mode == "socks5":
×
2183
            ret = cls(addr, aiorpcx.socks.SOCKS5, auth)
×
2184
        else:
2185
            raise NotImplementedError  # http proxy not available with aiorpcx
×
2186
        return ret
×
2187

2188

2189
class JsonRPCError(Exception):
1✔
2190

2191
    class Codes(enum.IntEnum):
1✔
2192
        # application-specific error codes
2193
        USERFACING = 1
1✔
2194
        INTERNAL = 2
1✔
2195

2196
    def __init__(self, *, code: int, message: str, data: Optional[dict] = None):
1✔
2197
        Exception.__init__(self)
×
2198
        self.code = code
×
2199
        self.message = message
×
2200
        self.data = data
×
2201

2202

2203
class JsonRPCClient:
1✔
2204

2205
    def __init__(self, session: aiohttp.ClientSession, url: str):
1✔
2206
        self.session = session
×
2207
        self.url = url
×
2208
        self._id = 0
×
2209

2210
    async def request(self, endpoint, *args):
1✔
2211
        """Send request to server, parse and return result.
2212
        note: parsing code is naive, the server is assumed to be well-behaved.
2213
              Up to the caller to handle exceptions, including those arising from parsing errors.
2214
        """
2215
        self._id += 1
×
2216
        data = ('{"jsonrpc": "2.0", "id":"%d", "method": "%s", "params": %s }'
×
2217
                % (self._id, endpoint, json.dumps(args)))
2218
        async with self.session.post(self.url, data=data) as resp:
×
2219
            if resp.status == 200:
×
2220
                r = await resp.json()
×
2221
                result = r.get('result')
×
2222
                error = r.get('error')
×
2223
                if error:
×
2224
                    raise JsonRPCError(code=error["code"], message=error["message"], data=error.get("data"))
×
2225
                else:
2226
                    return result
×
2227
            else:
2228
                text = await resp.text()
×
2229
                return 'Error: ' + str(text)
×
2230

2231
    def add_method(self, endpoint):
1✔
2232
        async def coro(*args):
×
2233
            return await self.request(endpoint, *args)
×
2234
        setattr(self, endpoint, coro)
×
2235

2236

2237
T = TypeVar('T')
1✔
2238

2239

2240
def random_shuffled_copy(x: Iterable[T]) -> List[T]:
1✔
2241
    """Returns a shuffled copy of the input."""
2242
    x_copy = list(x)  # copy
1✔
2243
    random.shuffle(x_copy)  # shuffle in-place
1✔
2244
    return x_copy
1✔
2245

2246

2247
def test_read_write_permissions(path) -> None:
1✔
2248
    # note: There might already be a file at 'path'.
2249
    #       Make sure we do NOT overwrite/corrupt that!
2250
    temp_path = "%s.tmptest.%s" % (path, os.getpid())
1✔
2251
    echo = "fs r/w test"
1✔
2252
    try:
1✔
2253
        # test READ permissions for actual path
2254
        if os.path.exists(path):
1✔
2255
            with open(path, "rb") as f:
1✔
2256
                f.read(1)  # read 1 byte
1✔
2257
        # test R/W sanity for "similar" path
2258
        with open(temp_path, "w", encoding='utf-8') as f:
1✔
2259
            f.write(echo)
1✔
2260
        with open(temp_path, "r", encoding='utf-8') as f:
1✔
2261
            echo2 = f.read()
1✔
2262
        os.remove(temp_path)
1✔
2263
    except Exception as e:
×
2264
        raise IOError(e) from e
×
2265
    if echo != echo2:
1✔
2266
        raise IOError('echo sanity-check failed')
×
2267

2268

2269
class classproperty(property):
1✔
2270
    """~read-only class-level @property
2271
    from https://stackoverflow.com/a/13624858 by denis-ryzhkov
2272
    """
2273
    def __get__(self, owner_self, owner_cls):
1✔
2274
        return self.fget(owner_cls)
1✔
2275

2276

2277
def sticky_property(val):
1✔
2278
    """Creates a 'property' whose value cannot be changed and that cannot be deleted.
2279
    Attempts to change the value are silently ignored.
2280

2281
    >>> class C: pass
2282
    ...
2283
    >>> setattr(C, 'x', sticky_property(3))
2284
    >>> c = C()
2285
    >>> c.x
2286
    3
2287
    >>> c.x = 2
2288
    >>> c.x
2289
    3
2290
    >>> del c.x
2291
    >>> c.x
2292
    3
2293
    """
2294
    return property(
1✔
2295
        fget=lambda self: val,
2296
        fset=lambda *args, **kwargs: None,
2297
        fdel=lambda *args, **kwargs: None,
2298
    )
2299

2300

2301
def get_running_loop() -> Optional[asyncio.AbstractEventLoop]:
1✔
2302
    """Returns the asyncio event loop that is *running in this thread*, if any."""
2303
    try:
1✔
2304
        return asyncio.get_running_loop()
1✔
2305
    except RuntimeError:
×
2306
        return None
×
2307

2308

2309
def error_text_str_to_safe_str(err: str, *, max_len: Optional[int] = 500) -> str:
1✔
2310
    """Converts an untrusted error string to a sane printable ascii str.
2311
    Never raises.
2312
    """
2313
    text = error_text_bytes_to_safe_str(
1✔
2314
        err.encode("ascii", errors='backslashreplace'),
2315
        max_len=None)
2316
    return truncate_text(text, max_len=max_len)
1✔
2317

2318

2319
def error_text_bytes_to_safe_str(err: bytes, *, max_len: Optional[int] = 500) -> str:
1✔
2320
    """Converts an untrusted error bytes text to a sane printable ascii str.
2321
    Never raises.
2322

2323
    Note that naive ascii conversion would be insufficient. Fun stuff:
2324
    >>> b = b"my_long_prefix_blabla" + 21 * b"\x08" + b"malicious_stuff"
2325
    >>> s = b.decode("ascii")
2326
    >>> print(s)
2327
    malicious_stuffblabla
2328
    """
2329
    # convert to ascii, to get rid of unicode stuff
2330
    ascii_text = err.decode("ascii", errors='backslashreplace')
1✔
2331
    # do repr to handle ascii special chars (especially when printing/logging the str)
2332
    text = repr(ascii_text)
1✔
2333
    return truncate_text(text, max_len=max_len)
1✔
2334

2335

2336
def truncate_text(text: str, *, max_len: Optional[int]) -> str:
1✔
2337
    if max_len is None or len(text) <= max_len:
1✔
2338
        return text
1✔
2339
    else:
2340
        return text[:max_len] + f"... (truncated. orig_len={len(text)})"
1✔
2341

2342

2343
def nostr_pow_worker(nonce, nostr_pubk, target_bits, hash_function, hash_len_bits, shutdown):
1✔
2344
    """Function to generate PoW for Nostr, to be spawned in a ProcessPoolExecutor."""
2345
    hash_preimage = b'electrum-' + nostr_pubk
×
2346
    while True:
×
2347
        # we cannot check is_set on each iteration as it has a lot of overhead, this way we can check
2348
        # it with low overhead (just the additional range counter)
2349
        for i in range(1000000):
×
2350
            digest = hash_function(hash_preimage + nonce.to_bytes(32, 'big')).digest()
×
2351
            if int.from_bytes(digest, 'big') < (1 << (hash_len_bits - target_bits)):
×
2352
                shutdown.set()
×
2353
                return hash, nonce
×
2354
            nonce += 1
×
2355
        if shutdown.is_set():
×
2356
            return None, None
×
2357

2358

2359
async def gen_nostr_ann_pow(nostr_pubk: bytes, target_bits: int) -> Tuple[int, int]:
1✔
2360
    """Generate a PoW for a Nostr announcement. The PoW is hash[b'electrum-'+pubk+nonce]"""
2361
    import multiprocessing  # not available on Android, so we import it here
×
2362
    hash_function = hashlib.sha256
×
2363
    hash_len_bits = 256
×
2364
    max_nonce: int = (1 << (32 * 8)) - 1  # 32-byte nonce
×
2365
    start_nonce = 0
×
2366

2367
    max_workers = max(multiprocessing.cpu_count() - 1, 1)  # use all but one CPU
×
2368
    manager = multiprocessing.Manager()
×
2369
    shutdown = manager.Event()
×
2370
    with ProcessPoolExecutor(max_workers=max_workers) as executor:
×
2371
        tasks = []
×
2372
        loop = asyncio.get_running_loop()
×
2373
        for task in range(0, max_workers):
×
2374
            task = loop.run_in_executor(
×
2375
                executor,
2376
                nostr_pow_worker,
2377
                start_nonce,
2378
                nostr_pubk,
2379
                target_bits,
2380
                hash_function,
2381
                hash_len_bits,
2382
                shutdown
2383
            )
2384
            tasks.append(task)
×
2385
            start_nonce += max_nonce // max_workers  # split the nonce range between the processes
×
2386
            if start_nonce > max_nonce:  # make sure we don't go over the max_nonce
×
2387
                start_nonce = random.randint(0, int(max_nonce * 0.75))
×
2388

2389
        done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
×
2390
        hash_res, nonce_res = done.pop().result()
×
2391
        executor.shutdown(wait=False, cancel_futures=True)
×
2392

2393
    return nonce_res, get_nostr_ann_pow_amount(nostr_pubk, nonce_res)
×
2394

2395

2396
def get_nostr_ann_pow_amount(nostr_pubk: bytes, nonce: Optional[int]) -> int:
1✔
2397
    """Return the amount of leading zero bits for a nostr announcement PoW."""
2398
    if not nonce:
×
2399
        return 0
×
2400
    hash_function = hashlib.sha256
×
2401
    hash_len_bits = 256
×
2402
    hash_preimage = b'electrum-' + nostr_pubk
×
2403

2404
    digest = hash_function(hash_preimage + nonce.to_bytes(32, 'big')).digest()
×
2405
    digest = int.from_bytes(digest, 'big')
×
2406
    return hash_len_bits - digest.bit_length()
×
2407

2408

2409
class OnchainHistoryItem(NamedTuple):
1✔
2410
    txid: str
1✔
2411
    amount_sat: int
1✔
2412
    fee_sat: int
1✔
2413
    balance_sat: int
1✔
2414
    tx_mined_status: TxMinedInfo
1✔
2415
    group_id: Optional[str]
1✔
2416
    label: Optional[str]
1✔
2417
    monotonic_timestamp: int
1✔
2418
    group_id: Optional[str]
1✔
2419
    def to_dict(self):
1✔
2420
        return {
1✔
2421
            'txid': self.txid,
2422
            'amount_sat': self.amount_sat,
2423
            'fee_sat': self.fee_sat,
2424
            'height': self.tx_mined_status.height(),
2425
            'confirmations': self.tx_mined_status.conf,
2426
            'timestamp': self.tx_mined_status.timestamp,
2427
            'monotonic_timestamp': self.monotonic_timestamp,
2428
            'incoming': True if self.amount_sat>0 else False,
2429
            'bc_value': Satoshis(self.amount_sat),
2430
            'bc_balance': Satoshis(self.balance_sat),
2431
            'date': timestamp_to_datetime(self.tx_mined_status.timestamp),
2432
            'txpos_in_block': self.tx_mined_status.txpos,
2433
            'wanted_height': self.tx_mined_status.wanted_height,
2434
            'label': self.label,
2435
            'group_id': self.group_id,
2436
        }
2437

2438

2439
class LightningHistoryItem(NamedTuple):
1✔
2440
    payment_hash: Optional[str]
1✔
2441
    preimage: Optional[str]
1✔
2442
    amount_msat: int
1✔
2443
    fee_msat: Optional[int]
1✔
2444
    type: str
1✔
2445
    group_id: Optional[str]
1✔
2446
    timestamp: int
1✔
2447
    label: Optional[str]
1✔
2448
    direction: Optional[int]
1✔
2449
    def to_dict(self):
1✔
2450
        return {
1✔
2451
            'type': self.type,
2452
            'label': self.label,
2453
            'timestamp': self.timestamp or 0,
2454
            'date': timestamp_to_datetime(self.timestamp),
2455
            'amount_msat': self.amount_msat,
2456
            'fee_msat': self.fee_msat,
2457
            'payment_hash': self.payment_hash,
2458
            'preimage': self.preimage,
2459
            'group_id': self.group_id,
2460
            'ln_value': Satoshis(Decimal(self.amount_msat) / 1000),
2461
            'direction': self.direction,
2462
        }
2463

2464

2465
@dataclass(kw_only=True, slots=True)
1✔
2466
class ChoiceItem:
1✔
2467
    key: Any
1✔
2468
    label: str  # user facing string
1✔
2469
    extra_data: Any = None
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc