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

spesmilo / electrum / 5766584628674560

20 Aug 2025 05:57PM UTC coverage: 61.507% (-0.03%) from 61.535%
5766584628674560

Pull #10159

CirrusCI

SomberNight
logging: add config.LOGS_MAX_TOTAL_SIZE_BYTES: to limit size on disk
Pull Request #10159: logging: add config.LOGS_MAX_TOTAL_SIZE_BYTES: to limit size on disk

7 of 31 new or added lines in 2 files covered. (22.58%)

125 existing lines in 47 files now uncovered.

22810 of 37085 relevant lines covered (61.51%)

3.07 hits per line

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

43.92
/electrum/daemon.py
1
#!/usr/bin/env python
2
#
3
# Electrum - lightweight Bitcoin client
4
# Copyright (C) 2015 Thomas Voegtlin
5
#
6
# Permission is hereby granted, free of charge, to any person
7
# obtaining a copy of this software and associated documentation files
8
# (the "Software"), to deal in the Software without restriction,
9
# including without limitation the rights to use, copy, modify, merge,
10
# publish, distribute, sublicense, and/or sell copies of the Software,
11
# and to permit persons to whom the Software is furnished to do so,
12
# subject to the following conditions:
13
#
14
# The above copyright notice and this permission notice shall be
15
# included in all copies or substantial portions of the Software.
16
#
17
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
21
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
22
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
23
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
# SOFTWARE.
25
import asyncio
5✔
26
import ast
5✔
27
import errno
5✔
28
import os
5✔
29
import time
5✔
30
import traceback
5✔
31
import sys
5✔
32
import threading
5✔
33
from typing import Dict, Optional, Tuple, Callable, Union, Sequence, Mapping, TYPE_CHECKING
5✔
34
from base64 import b64decode, b64encode
5✔
35
import json
5✔
36
import socket
5✔
37

38
import aiohttp
5✔
39
from aiohttp import web, client_exceptions
5✔
40
from aiorpcx import ignore_after
5✔
41

42
from . import util
5✔
43
from .network import Network
5✔
44
from .util import (
5✔
45
    json_decode, to_bytes, to_string, profiler, standardize_path, constant_time_compare, InvalidPassword,
46
    log_exceptions, randrange, OldTaskGroup, UserFacingException, JsonRPCError
47
)
48
from .wallet import Wallet, Abstract_Wallet
5✔
49
from .storage import WalletStorage
5✔
50
from .wallet_db import WalletDB, WalletUnfinished
5✔
51
from .commands import known_commands, Commands
5✔
52
from .simple_config import SimpleConfig
5✔
53
from .exchange_rate import FxThread
5✔
54
from .logging import get_logger, Logger
5✔
55
from . import GuiImportError
5✔
56
from .plugin import run_hook, Plugins
5✔
57

58
if TYPE_CHECKING:
2✔
UNCOV
59
    from electrum import gui
60

61

62
_logger = get_logger(__name__)
5✔
63

64

65
class DaemonNotRunning(Exception):
5✔
66
    pass
5✔
67

68

69
def get_rpcsock_defaultpath(config: SimpleConfig):
5✔
70
    return os.path.join(config.path, 'daemon_rpc_socket')
×
71

72

73
def get_rpcsock_default_type(config: SimpleConfig):
5✔
74
    if config.RPC_PORT:
×
75
        return 'tcp'
×
76
    # Use unix domain sockets when available,
77
    # with the extra paranoia that in case windows "implements" them,
78
    # we want to test it before making it the default there.
79
    if hasattr(socket, 'AF_UNIX') and sys.platform != 'win32':
×
80
        return 'unix'
×
81
    return 'tcp'
×
82

83

84
def get_lockfile(config: SimpleConfig):
5✔
85
    return os.path.join(config.path, 'daemon')
×
86

87

88
def remove_lockfile(lockfile):
5✔
89
    os.unlink(lockfile)
×
90

91

92
def get_file_descriptor(config: SimpleConfig):
5✔
93
    '''Tries to create the lockfile, using O_EXCL to
94
    prevent races.  If it succeeds, it returns the FD.
95
    Otherwise, try and connect to the server specified in the lockfile.
96
    If this succeeds, the server is returned.  Otherwise, remove the
97
    lockfile and try again.'''
98
    lockfile = get_lockfile(config)
×
99
    while True:
×
100
        try:
×
101
            return os.open(lockfile, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
×
102
        except OSError:
×
103
            pass
×
104
        try:
×
105
            request(config, 'ping')
×
106
            return None
×
107
        except DaemonNotRunning:
×
108
            # Couldn't connect; remove lockfile and try again.
109
            remove_lockfile(lockfile)
×
110

111

112
def request(config: SimpleConfig, endpoint, args=(), timeout: Union[float, int] = 60):
5✔
113
    lockfile = get_lockfile(config)
×
114
    for attempt in range(5):
×
115
        create_time = None  # type: Optional[float | int]
×
116
        path = None
×
117
        try:
×
118
            with open(lockfile) as f:
×
119
                socktype, address, create_time = ast.literal_eval(f.read())
×
120
                int(create_time)  # raise if not numeric
×
121
                if socktype == 'unix':
×
122
                    path = address
×
123
                    (host, port) = "127.0.0.1", 0
×
124
                    # We still need a host and port for e.g. HTTP Host header
125
                elif socktype == 'tcp':
×
126
                    (host, port) = address
×
127
                else:
128
                    raise Exception(f"corrupt lockfile; socktype={socktype!r}")
×
129
        except Exception:
×
130
            raise DaemonNotRunning()
×
131
        rpc_user, rpc_password = get_rpc_credentials(config)
×
132
        server_url = 'http://%s:%d' % (host, port)
×
133
        auth = aiohttp.BasicAuth(login=rpc_user, password=rpc_password)
×
134
        loop = util.get_asyncio_loop()
×
135

136
        async def request_coroutine(
×
137
            *, socktype=socktype, path=path, auth=auth, server_url=server_url, endpoint=endpoint,
138
        ):
139
            if socktype == 'unix':
×
140
                connector = aiohttp.UnixConnector(path=path)
×
141
            elif socktype == 'tcp':
×
142
                connector = None # This will transform into TCP.
×
143
            else:
144
                raise Exception(f"impossible socktype ({socktype!r})")
×
145
            async with aiohttp.ClientSession(auth=auth, connector=connector) as session:
×
146
                c = util.JsonRPCClient(session, server_url)
×
147
                return await c.request(endpoint, *args)
×
148

149
        try:
×
150
            fut = asyncio.run_coroutine_threadsafe(request_coroutine(), loop)
×
151
            return fut.result(timeout=timeout)
×
152
        except aiohttp.client_exceptions.ClientConnectorError as e:
×
153
            _logger.info(f"failed to connect to JSON-RPC server {e}")
×
154
            # We cannot communicate with the daemon.
155
            # If daemon's creation time is very recent, it might still be starting up.
156
            # In any other case, we raise: - too old create_time means daemon is likely dead,
157
            #                              - create_time in future means our clock cannot be trusted.
158
            if not (create_time <= time.time() <= create_time + 1.0):
×
159
                raise DaemonNotRunning()
×
160
        # Sleep a bit and try again; daemon might have just been started
161
        time.sleep(1.0)
×
162
    # how did we even get here?! the clock must be going haywire.
163
    _logger.error(f"Failed to connect to JSON-RPC server. Exhausted all attempts.")
×
164
    raise DaemonNotRunning()
×
165

166

167
def wait_until_daemon_becomes_ready(*, config: SimpleConfig, timeout=5) -> bool:
5✔
168
    t0 = time.monotonic()
×
169
    while True:
×
170
        if time.monotonic() > t0 + timeout:
×
171
            return False  # timeout
×
172
        try:
×
173
            request(config, 'ping')
×
174
            return True  # success
×
175
        except DaemonNotRunning:
×
176
            time.sleep(0.05)
×
177
            continue
×
178

179

180
def get_rpc_credentials(config: SimpleConfig) -> Tuple[str, str]:
5✔
181
    rpc_user = config.RPC_USERNAME or None
×
182
    rpc_password = config.RPC_PASSWORD or None
×
183
    if rpc_user is None or rpc_password is None:
×
184
        rpc_user = 'user'
×
185
        bits = 128
×
186
        nbytes = bits // 8 + (bits % 8 > 0)
×
187
        pw_int = randrange(pow(2, bits))
×
188
        pw_b64 = b64encode(
×
189
            pw_int.to_bytes(nbytes, 'big'), b'-_')
190
        rpc_password = to_string(pw_b64, 'ascii')
×
191
        config.RPC_USERNAME = rpc_user
×
192
        config.RPC_PASSWORD = rpc_password
×
193
    return rpc_user, rpc_password
×
194

195

196
class AuthenticationError(Exception):
5✔
197
    pass
5✔
198

199

200
class AuthenticationInvalidOrMissing(AuthenticationError):
5✔
201
    pass
5✔
202

203

204
class AuthenticationCredentialsInvalid(AuthenticationError):
5✔
205
    pass
5✔
206

207

208
class AuthenticatedServer(Logger):
5✔
209

210
    def __init__(self, rpc_user, rpc_password):
5✔
211
        Logger.__init__(self)
×
212
        self.rpc_user = rpc_user
×
213
        self.rpc_password = rpc_password
×
214
        self.auth_lock = asyncio.Lock()
×
215
        self._methods = {}  # type: Dict[str, Callable]
×
216

217
    def register_method(self, f):
5✔
218
        assert f.__name__ not in self._methods, f"name collision for {f.__name__}"
×
219
        self._methods[f.__name__] = f
×
220

221
    async def authenticate(self, headers):
5✔
222
        if self.rpc_password == '':
×
223
            # RPC authentication is disabled
224
            return
×
225
        auth_string = headers.get('Authorization', None)
×
226
        if auth_string is None:
×
227
            raise AuthenticationInvalidOrMissing('CredentialsMissing')
×
228
        basic, _, encoded = auth_string.partition(' ')
×
229
        if basic != 'Basic':
×
230
            raise AuthenticationInvalidOrMissing('UnsupportedType')
×
231
        encoded = to_bytes(encoded, 'utf8')
×
232
        credentials = to_string(b64decode(encoded, validate=True), 'utf8')
×
233
        username, _, password = credentials.partition(':')
×
234
        if not (constant_time_compare(username, self.rpc_user)
×
235
                and constant_time_compare(password, self.rpc_password)):
236
            await asyncio.sleep(0.050)
×
237
            raise AuthenticationCredentialsInvalid('Invalid Credentials')
×
238

239
    async def handle(self, request):
5✔
240
        async with self.auth_lock:
×
241
            try:
×
242
                await self.authenticate(request.headers)
×
243
            except AuthenticationInvalidOrMissing:
×
244
                return web.Response(headers={"WWW-Authenticate": "Basic realm=Electrum"},
×
245
                                    text='Unauthorized', status=401)
246
            except AuthenticationCredentialsInvalid:
×
247
                return web.Response(text='Forbidden', status=403)
×
248
        try:
×
249
            request = await request.text()
×
250
            request = json.loads(request)
×
251
            method = request['method']
×
252
            _id = request['id']
×
253
            params = request.get('params', [])  # type: Union[Sequence, Mapping]
×
254
            if method not in self._methods:
×
255
                raise Exception(f"attempting to use unregistered method: {method}")
×
256
            f = self._methods[method]
×
257
        except Exception as e:
×
258
            self.logger.exception("invalid request")
×
259
            return web.Response(text='Invalid Request', status=500)
×
260
        response = {
×
261
            'id': _id,
262
            'jsonrpc': '2.0',
263
        }
264
        try:
×
265
            if isinstance(params, dict):
×
266
                response['result'] = await f(**params)
×
267
            else:
268
                response['result'] = await f(*params)
×
269
        except UserFacingException as e:
×
270
            response['error'] = {
×
271
                'code': JsonRPCError.Codes.USERFACING,
272
                'message': str(e),
273
            }
274
        except BaseException as e:
×
275
            self.logger.exception("internal error while executing RPC")
×
276
            response['error'] = {
×
277
                'code': JsonRPCError.Codes.INTERNAL,
278
                'message': "internal error while executing RPC",
279
                'data': {
280
                    "exception": repr(e),
281
                    "traceback": "".join(traceback.format_exception(e)),
282
                },
283
            }
284
        return web.json_response(response)
×
285

286

287
class CommandsServer(AuthenticatedServer):
5✔
288

289
    def __init__(self, daemon: 'Daemon', fd):
5✔
290
        rpc_user, rpc_password = get_rpc_credentials(daemon.config)
×
291
        AuthenticatedServer.__init__(self, rpc_user, rpc_password)
×
292
        self.daemon = daemon
×
293
        self.fd = fd
×
294
        self.config = daemon.config
×
295
        sockettype = self.config.RPC_SOCKET_TYPE
×
296
        self.socktype = sockettype if sockettype != 'auto' else get_rpcsock_default_type(self.config)
×
297
        self.sockpath = self.config.RPC_SOCKET_FILEPATH or get_rpcsock_defaultpath(self.config)
×
298
        self.host = self.config.RPC_HOST
×
299
        self.port = self.config.RPC_PORT
×
300
        self.app = web.Application()
×
301
        self.app.router.add_post("/", self.handle)
×
302
        self.register_method(self.ping)
×
303
        self.register_method(self.gui)
×
304
        self.cmd_runner = Commands(config=self.config, network=self.daemon.network, daemon=self.daemon)
×
305
        for cmdname in known_commands:
×
306
            self.register_method(getattr(self.cmd_runner, cmdname))
×
307
        self.register_method(self.run_cmdline)
×
308

309
    def _socket_config_str(self) -> str:
5✔
310
        if self.socktype == 'unix':
×
311
            return f"<socket type={self.socktype}, path={self.sockpath}>"
×
312
        elif self.socktype == 'tcp':
×
313
            return f"<socket type={self.socktype}, host={self.host}, port={self.port}>"
×
314
        else:
315
            raise Exception(f"unknown socktype '{self.socktype!r}'")
×
316

317
    async def run(self):
5✔
318
        self.runner = web.AppRunner(self.app)
×
319
        await self.runner.setup()
×
320
        if self.socktype == 'unix':
×
321
            site = web.UnixSite(self.runner, self.sockpath)
×
322
        elif self.socktype == 'tcp':
×
323
            site = web.TCPSite(self.runner, self.host, self.port)
×
324
        else:
325
            raise Exception(f"unknown socktype '{self.socktype!r}'")
×
326
        try:
×
327
            await site.start()
×
328
        except Exception as e:
×
329
            raise Exception(f"failed to start CommandsServer at {self._socket_config_str()}. got exc: {e!r}") from None
×
330
        socket = site._server.sockets[0]
×
331
        if self.socktype == 'unix':
×
332
            addr = self.sockpath
×
333
        elif self.socktype == 'tcp':
×
334
            addr = socket.getsockname()
×
335
        else:
336
            raise Exception(f"impossible socktype ({self.socktype!r})")
×
337
        os.write(self.fd, bytes(repr((self.socktype, addr, time.time())), 'utf8'))
×
338
        os.close(self.fd)
×
339
        self.logger.info(f"now running and listening. socktype={self.socktype}, addr={addr}")
×
340

341
    async def ping(self):
5✔
342
        return True
×
343

344
    async def gui(self, config_options):
5✔
345
        # note: "config_options" is coming from the short-lived CLI-invocation,
346
        #        while self.config is the config of the long-lived daemon process.
347
        #       "config_options" should have priority.
348
        if self.daemon.gui_object:
×
349
            if hasattr(self.daemon.gui_object, 'new_window'):
×
350
                if config_options.get(SimpleConfig.NETWORK_OFFLINE.key()) and not self.config.NETWORK_OFFLINE:
×
351
                    raise UserFacingException(
×
352
                        "error: current GUI is running online, so it cannot open a new wallet offline.")
353
                path = config_options.get('wallet_path') or self.config.get_wallet_path()
×
354
                self.daemon.gui_object.new_window(path, config_options.get('url'))
×
355
                return True
×
356
            else:
357
                raise UserFacingException("error: current GUI does not support multiple windows")
×
358
        else:
359
            raise UserFacingException("error: Electrum is running in daemon mode. Please stop the daemon first.")
×
360

361
    async def run_cmdline(self, config_options):
5✔
362
        cmdname = config_options['cmd']
×
363
        cmd = known_commands.get(cmdname)
×
364
        if not cmd:
×
365
            return f"unknown command: {cmdname}"
×
366
        # arguments passed to function
367
        args = [config_options.get(x) for x in cmd.params]
×
368
        # decode json arguments
369
        args = [json_decode(i) for i in args]
×
370
        # options
371
        kwargs = {}
×
372
        for x in cmd.options:
×
373
            kwargs[x] = config_options.get(x)
×
374
        if 'wallet_path' in cmd.options or 'wallet' in cmd.options:
×
375
            wallet_path = config_options.get('wallet_path')
×
376
            if len(self.daemon._wallets) > 1 and wallet_path is None:
×
377
                raise UserFacingException("error: wallet not specified")
×
378
            kwargs['wallet_path'] = wallet_path
×
379
        func = getattr(self.cmd_runner, cmd.name)
×
380
        # execute requested command now.  note: cmd can raise, the caller (self.handle) will wrap it.
381
        result = await func(*args, **kwargs)
×
382
        return result
×
383

384

385
class Daemon(Logger):
5✔
386

387
    network: Optional[Network] = None
5✔
388
    gui_object: Optional['gui.BaseElectrumGui'] = None
5✔
389

390
    @profiler
5✔
391
    def __init__(
5✔
392
        self,
393
        config: SimpleConfig,
394
        fd=None,
395
        *,
396
        listen_jsonrpc: bool = True,
397
        start_network: bool = True,  # setting to False allows customising network settings before starting it
398
    ):
399
        Logger.__init__(self)
5✔
400
        self.config = config
5✔
401
        self.listen_jsonrpc = listen_jsonrpc
5✔
402
        if fd is None and listen_jsonrpc:
5✔
403
            fd = get_file_descriptor(config)
×
404
            if fd is None:
×
405
                raise Exception('failed to lock daemon; already running?')
×
406
        self._plugins = None  # type: Optional[Plugins]
5✔
407
        self.asyncio_loop = util.get_asyncio_loop()
5✔
408
        if not self.config.NETWORK_OFFLINE:
5✔
409
            self.network = Network(config, daemon=self)
×
410
        self.fx = FxThread(config=config)
5✔
411
        # wallet_key -> wallet
412
        self._wallets = {}  # type: Dict[str, Abstract_Wallet]
5✔
413
        self._wallet_lock = threading.RLock()
5✔
414

415
        self._stop_entered = False
5✔
416
        self._stopping_soon_or_errored = threading.Event()
5✔
417
        self._stopped_event = threading.Event()
5✔
418

419
        self.taskgroup = OldTaskGroup()
5✔
420
        asyncio.run_coroutine_threadsafe(self._run(), self.asyncio_loop)
5✔
421
        if start_network and self.network:
5✔
422
            self.start_network()
×
423
        # Setup commands server
424
        self.commands_server = None
5✔
425
        if listen_jsonrpc:
5✔
426
            self.commands_server = CommandsServer(self, fd)
×
427
            asyncio.run_coroutine_threadsafe(self.taskgroup.spawn(self.commands_server.run()), self.asyncio_loop)
×
428

429
    @log_exceptions
5✔
430
    async def _run(self):
5✔
431
        self.logger.info("starting taskgroup.")
5✔
432
        try:
5✔
433
            async with self.taskgroup as group:
5✔
434
                await group.spawn(asyncio.Event().wait)  # run forever (until cancel)
5✔
435
        except Exception as e:
×
436
            self.logger.exception("taskgroup died.")
×
437
            util.send_exception_to_crash_reporter(e)
×
438
        finally:
439
            self.logger.info("taskgroup stopped.")
5✔
440
            # note: we could just "await self.stop()", but in that case GUI users would
441
            #       not see the exception (especially if the GUI did not start yet).
442
            self._stopping_soon_or_errored.set()
5✔
443

444
    def start_network(self):
5✔
445
        self.logger.info(f"starting network.")
×
446
        assert not self.config.NETWORK_OFFLINE
×
447
        assert self.network
×
448
        self.network.start(jobs=[self.fx.run])
×
449
        # prepare lightning functionality, also load channel db early
450
        if self.config.LIGHTNING_USE_GOSSIP:
×
451
            self.network.start_gossip()
×
452

453
    @staticmethod
5✔
454
    def _wallet_key_from_path(path) -> str:
5✔
455
        """This does stricter path standardization than 'standardize_path'.
456
        It is used for keying the _wallets dict,
457
        but MUST NOT be used as a *path* for the actual filesystem operations. (see #8495)
458
        """
459
        path = standardize_path(path)
5✔
460
        # The extra normalisation makes it even harder to open the same wallet file multiple times simultaneously.
461
        # - "realpath" resolves symlinks:
462
        #   note: the path returned by realpath has been observed NOT to work for FS operations!
463
        #         (e.g. for Cryptomator WinFSP/FUSE mounts, see #8495).
464
        #         It is okay for us to use it for computing a canonical wallet *key*, but cannot be used as a path!
465
        path = os.path.realpath(path)
5✔
466
        # - "normcase" does Windows-specific case and slash normalisation:
467
        path = os.path.normcase(path)
5✔
468
        # - prepend header to break usage of wallet keys as fs paths
469
        header = "WALLETKEY-"
5✔
470
        return header + str(path)
5✔
471

472
    def with_wallet_lock(func):
5✔
473
        def func_wrapper(self: 'Daemon', *args, **kwargs):
5✔
474
            with self._wallet_lock:
5✔
475
                return func(self, *args, **kwargs)
5✔
476
        return func_wrapper
5✔
477

478
    @with_wallet_lock
5✔
479
    def load_wallet(self, path, password, *, upgrade=False) -> Optional[Abstract_Wallet]:
5✔
480
        assert password != ''
5✔
481
        path = standardize_path(path)
5✔
482
        wallet_key = self._wallet_key_from_path(path)
5✔
483
        # wizard will be launched if we return
484
        if wallet := self._wallets.get(wallet_key):
5✔
485
            return wallet
×
486
        wallet = self._load_wallet(path, password, upgrade=upgrade, config=self.config)
5✔
487
        if self.network:
5✔
488
            wallet.start_network(self.network)
×
489
        elif wallet.lnworker:
5✔
490
            # in offline mode, we need to trigger callbacks
491
            coro = wallet.lnworker.lnwatcher.trigger_callbacks(requires_synchronizer=False)
5✔
492
            asyncio.run_coroutine_threadsafe(coro, self.asyncio_loop)
5✔
493
        self.add_wallet(wallet)
5✔
494
        if self.config.get('wallet_path') is None:
5✔
495
            self.config.CURRENT_WALLET = path
5✔
496
        self.update_recently_opened_wallets(path)
5✔
497
        return wallet
5✔
498

499

500
    @staticmethod
5✔
501
    @profiler
5✔
502
    def _load_wallet(
5✔
503
            path,
504
            password,
505
            *,
506
            upgrade: bool = False,
507
            config: SimpleConfig,
508
    ) -> Optional[Abstract_Wallet]:
509
        path = standardize_path(path)
5✔
510
        storage = WalletStorage(path, allow_partial_writes=config.WALLET_PARTIAL_WRITES)
5✔
511
        if not storage.file_exists():
5✔
512
            raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), path)
×
513
        if storage.is_encrypted():
5✔
514
            if not password:
5✔
515
                raise InvalidPassword('No password given')
×
516
            storage.decrypt(password)
5✔
517
        # read data, pass it to db
518
        db = WalletDB(storage.read(), storage=storage, upgrade=upgrade)
5✔
519
        if db.get_action():
5✔
520
            raise WalletUnfinished(db)
×
521
        wallet = Wallet(db, config=config)
5✔
522
        return wallet
5✔
523

524
    @with_wallet_lock
5✔
525
    def add_wallet(self, wallet: Abstract_Wallet) -> None:
5✔
526
        path = wallet.storage.path
5✔
527
        wallet_key = self._wallet_key_from_path(path)
5✔
528
        self._wallets[wallet_key] = wallet
5✔
529
        run_hook('daemon_wallet_loaded', self, wallet)
5✔
530

531
    def get_wallet(self, path: str) -> Optional[Abstract_Wallet]:
5✔
532
        wallet_key = self._wallet_key_from_path(path)
5✔
533
        return self._wallets.get(wallet_key)
5✔
534

535
    @with_wallet_lock
5✔
536
    def get_wallets(self) -> Dict[str, Abstract_Wallet]:
5✔
537
        return dict(self._wallets)  # copy
×
538

539
    def delete_wallet(self, path: str) -> bool:
5✔
540
        self.stop_wallet(path)
×
541
        if os.path.exists(path):
×
542
            os.unlink(path)
×
543
            self.update_recently_opened_wallets(path, remove=True)
×
544
            return True
×
545
        return False
×
546

547
    def stop_wallet(self, path: str) -> bool:
5✔
548
        """Returns True iff a wallet was found."""
549
        # note: this must not be called from the event loop. # TODO raise if so
550
        fut = asyncio.run_coroutine_threadsafe(self._stop_wallet(path), self.asyncio_loop)
×
551
        return fut.result()
×
552

553
    @with_wallet_lock
5✔
554
    async def _stop_wallet(self, path: str) -> bool:
5✔
555
        """Returns True iff a wallet was found."""
556
        path = standardize_path(path)
×
557
        wallet_key = self._wallet_key_from_path(path)
×
558
        wallet = self._wallets.pop(wallet_key, None)
×
559
        if not wallet:
×
560
            return False
×
561
        await wallet.stop()
×
562
        if self.config.get('wallet_path') is None:
×
563
            wallet_paths = [w.db.storage.path for w in self._wallets.values()
×
564
                            if w.db.storage and w.db.storage.path]
565
            if self.config.CURRENT_WALLET == path and wallet_paths:
×
566
                self.config.CURRENT_WALLET = wallet_paths[0]
×
567
        return True
×
568

569
    def run_daemon(self):
5✔
570
        if 'wallet_path' in self.config.cmdline_options:
×
571
            self.logger.warning("Ignoring parameter 'wallet_path' for daemon. "
×
572
                                "Use the load_wallet command instead.")
573
        # init plugins
574
        self._plugins = Plugins(self.config, 'cmdline')
×
575
        # block until we are stopping
576
        try:
×
577
            self._stopping_soon_or_errored.wait()
×
578
        except KeyboardInterrupt:
×
579
            self.logger.info("got KeyboardInterrupt")
×
580
        # we either initiate shutdown now,
581
        # or it has already been initiated (in which case this is a no-op):
582
        self.logger.info("run_daemon is calling stop()")
×
583
        asyncio.run_coroutine_threadsafe(self.stop(), self.asyncio_loop).result()
×
584
        # wait until "stop" finishes:
585
        self._stopped_event.wait()
×
586

587
    async def stop(self):
5✔
588
        if self._stop_entered:
5✔
589
            return
×
590
        self._stop_entered = True
5✔
591
        self._stopping_soon_or_errored.set()
5✔
592
        self.logger.info("stop() entered. initiating shutdown")
5✔
593
        try:
5✔
594
            if self.gui_object:
5✔
595
                self.gui_object.stop()
×
596
            self.logger.info("stopping all wallets")
5✔
597
            async with OldTaskGroup() as group:
5✔
598
                for k, wallet in self._wallets.items():
5✔
599
                    await group.spawn(wallet.stop())
5✔
600
            self.logger.info("stopping network and taskgroup")
5✔
601
            async with ignore_after(2):
5✔
602
                async with OldTaskGroup() as group:
5✔
603
                    if self.network:
5✔
604
                        await group.spawn(self.network.stop(full_shutdown=True))
×
605
                    await group.spawn(self.taskgroup.cancel_remaining())
5✔
606
            if self._plugins:
5✔
607
                self.logger.info("stopping plugins")
×
608
                self._plugins.stop()
×
609
                async with ignore_after(1):
×
610
                    await self._plugins.stopped_event_async.wait()
×
611
        finally:
612
            if self.listen_jsonrpc:
5✔
613
                self.logger.info("removing lockfile")
×
614
                remove_lockfile(get_lockfile(self.config))
×
615
            self.logger.info("stopped")
5✔
616
            self._stopped_event.set()
5✔
617

618
    def run_gui(self) -> None:
5✔
619
        assert self.config
×
620
        threading.current_thread().name = 'GUI'
×
621
        gui_name = self.config.GUI_NAME
×
622
        if gui_name in ['lite', 'classic']:
×
623
            gui_name = 'qt'
×
624
        self._plugins = Plugins(self.config, gui_name)  # init plugins
×
625
        self.logger.info(f'launching GUI: {gui_name}')
×
626
        try:
×
627
            try:
×
628
                gui = __import__('electrum.gui.' + gui_name, fromlist=['electrum'])
×
629
            except GuiImportError as e:
×
630
                sys.exit(str(e))
×
631
            self.gui_object = gui.ElectrumGui(config=self.config, daemon=self, plugins=self._plugins)
×
632
            if not self._stop_entered:
×
633
                self.gui_object.main()
×
634
            else:
635
                # If daemon.stop() was called before gui_object got created, stop gui now.
636
                self.gui_object.stop()
×
637
        except BaseException as e:
×
638
            self.logger.error(f'GUI raised exception: {repr(e)}. shutting down.')
×
639
            raise
×
640
        finally:
641
            # app will exit now
642
            asyncio.run_coroutine_threadsafe(self.stop(), self.asyncio_loop).result()
×
643

644
    @with_wallet_lock
5✔
645
    def _check_password_for_directory(self, *, old_password, new_password=None, wallet_dir: str) -> Tuple[bool, bool]:
5✔
646
        """Checks password against all wallets (in dir), returns whether they can be unified and whether they are already.
647
        If new_password is not None, update all wallet passwords to new_password.
648
        """
649
        assert os.path.exists(wallet_dir), f"path {wallet_dir!r} does not exist"
5✔
650
        failed = []
5✔
651
        is_unified = True
5✔
652
        for filename in os.listdir(wallet_dir):
5✔
653
            path = os.path.join(wallet_dir, filename)
5✔
654
            path = standardize_path(path)
5✔
655
            if not os.path.isfile(path):
5✔
656
                continue
×
657
            wallet = self.get_wallet(path)
5✔
658
            # note: we only create a new wallet object if one was not loaded into the wallet already.
659
            #       This is to avoid having two wallet objects contending for the same file.
660
            #       Take care: this only works if the daemon knows about all wallet objects.
661
            #                  if other code already has created a Wallet() for a file but did not tell the daemon,
662
            #                  hard-to-understand bugs will follow...
663
            if wallet is None:
5✔
664
                try:
5✔
665
                    wallet = self._load_wallet(path, old_password, upgrade=True, config=self.config)
5✔
666
                except util.InvalidPassword:
5✔
667
                    pass
5✔
668
                except Exception:
×
669
                    self.logger.exception(f'failed to load wallet at {path!r}:')
×
670
            if wallet is None:
5✔
671
                failed.append(path)
5✔
672
                continue
5✔
673
            if not wallet.storage.is_encrypted():
5✔
674
                is_unified = False
5✔
675
            try:
5✔
676
                try:
5✔
677
                    wallet.check_password(old_password)
5✔
678
                    old_password_real = old_password
5✔
679
                except util.InvalidPassword:
5✔
680
                    wallet.check_password(None)
5✔
681
                    old_password_real = None
5✔
682
            except Exception:
5✔
683
                failed.append(path)
5✔
684
                continue
5✔
685
            if new_password:
5✔
686
                self.logger.info(f'updating password for wallet: {path!r}')
5✔
687
                wallet.update_password(old_password_real, new_password, encrypt_storage=True)
5✔
688
        can_be_unified = failed == []
5✔
689
        is_unified = can_be_unified and is_unified
5✔
690
        return can_be_unified, is_unified
5✔
691

692
    @with_wallet_lock
5✔
693
    def update_password_for_directory(
5✔
694
            self,
695
            *,
696
            old_password,
697
            new_password,
698
            wallet_dir: Optional[str] = None,
699
    ) -> bool:
700
        """returns whether password is unified"""
701
        if new_password is None:
5✔
702
            # we opened a non-encrypted wallet
703
            return False
×
704
        if wallet_dir is None:
5✔
705
            wallet_dir = os.path.dirname(self.config.get_wallet_path())
5✔
706
        can_be_unified, is_unified = self._check_password_for_directory(
5✔
707
            old_password=old_password, new_password=None, wallet_dir=wallet_dir)
708
        if not can_be_unified:
5✔
709
            return False
5✔
710
        if is_unified and old_password == new_password:
5✔
711
            return True
5✔
712
        self._check_password_for_directory(
5✔
713
            old_password=old_password, new_password=new_password, wallet_dir=wallet_dir)
714
        return True
5✔
715

716
    def update_recently_opened_wallets(self, wallet_path, *, remove: bool = False):
5✔
717
        recent = self.config.RECENTLY_OPEN_WALLET_FILES or []
5✔
718
        if wallet_path in recent:
5✔
719
            recent.remove(wallet_path)
×
720
        if not remove:
5✔
721
            recent.insert(0, wallet_path)
5✔
722
            recent = [path for path in recent if os.path.exists(path)]
5✔
723
            recent = recent[:5]
5✔
724
        self.config.RECENTLY_OPEN_WALLET_FILES = recent
5✔
725
        util.trigger_callback('recently_opened_wallets_update')
5✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc