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

spesmilo / electrum / 4749098806411264

27 Mar 2026 06:27PM UTC coverage: 64.841% (-0.002%) from 64.843%
4749098806411264

push

CirrusCI

web-flow
Merge pull request #10547 from SomberNight/202603_umask

set restrictive unix umask application-wide by default

1 of 3 new or added lines in 1 file covered. (33.33%)

24419 of 37660 relevant lines covered (64.84%)

0.65 hits per line

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

46.49
/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
1✔
26
import ast
1✔
27
import errno
1✔
28
import os
1✔
29
import time
1✔
30
import traceback
1✔
31
import sys
1✔
32
import threading
1✔
33
from typing import Dict, Optional, Tuple, Callable, Union, Sequence, Mapping, TYPE_CHECKING
1✔
34
from base64 import b64decode, b64encode
1✔
35
import json
1✔
36
import socket
1✔
37
import stat
1✔
38

39
import aiohttp
1✔
40
from aiohttp import web, client_exceptions
1✔
41
from aiorpcx import ignore_after
1✔
42

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

59
if TYPE_CHECKING:
60
    from electrum import gui
61

62

63
_logger = get_logger(__name__)
1✔
64

65

66
class DaemonNotRunning(Exception):
1✔
67
    pass
1✔
68

69

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

73

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

84

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

88

89
def remove_lockfile(lockfile):
1✔
90
    os.unlink(lockfile)
×
91

92

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

112

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

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

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

167

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

180

181
def get_rpc_credentials(config: SimpleConfig) -> Tuple[str, str]:
1✔
182
    rpc_user = config.RPC_USERNAME or None
×
183
    rpc_password = config.RPC_PASSWORD or None
×
184
    # note: we explicitly forbid empty/unset password, and will generate one now instead
185
    if rpc_user is None or rpc_password is None:
×
186
        rpc_user = 'user'
×
187
        bits = 128
×
188
        nbytes = bits // 8 + (bits % 8 > 0)
×
189
        pw_int = randrange(pow(2, bits))
×
190
        pw_b64 = b64encode(
×
191
            pw_int.to_bytes(nbytes, 'big'), b'-_')
192
        rpc_password = to_string(pw_b64, 'ascii')
×
193
        config.RPC_USERNAME = rpc_user
×
194
        config.RPC_PASSWORD = rpc_password
×
195
    return rpc_user, rpc_password
×
196

197

198
class AuthenticationError(Exception):
1✔
199
    pass
1✔
200

201

202
class AuthenticationInvalidOrMissing(AuthenticationError):
1✔
203
    pass
1✔
204

205

206
class AuthenticationCredentialsInvalid(AuthenticationError):
1✔
207
    pass
1✔
208

209

210
class AuthenticatedServer(Logger):
1✔
211

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

219
    def register_method(self, name: str, f):
1✔
220
        assert name not in self._methods, f"name collision for {name}"
×
221
        self._methods[name] = f
×
222

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

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

287

288
class CommandsServer(AuthenticatedServer):
1✔
289

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

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

318
    async def run(self):
1✔
319
        self.runner = web.AppRunner(self.app)
×
320
        await self.runner.setup()
×
321
        if self.socktype == 'unix':
×
322
            site = web.UnixSite(self.runner, self.sockpath)
×
323
        elif self.socktype == 'tcp':
×
324
            site = web.TCPSite(self.runner, self.host, self.port)
×
325
        else:
326
            raise Exception(f"unknown socktype '{self.socktype!r}'")
×
327
        try:
×
328
            await site.start()
×
329
        except Exception as e:
×
330
            raise Exception(f"failed to start CommandsServer at {self._socket_config_str()}. got exc: {e!r}") from None
×
331
        # now server has started.
NEW
332
        if self.socktype == 'unix':
×
333
            # set restrictive permissions on unix domain socket.
334
            # FIXME race? we are late. should set this during socket-file creation but aiohttp API does not let us.
NEW
335
            os_chmod(self.sockpath, stat.S_IREAD | stat.S_IWRITE)
×
336
        # write server conn details into lockfile fd
337
        if self.socktype == 'unix':
×
338
            addr = self.sockpath
×
339
        elif self.socktype == 'tcp':
×
340
            socket = site._server.sockets[0]
×
341
            addr = socket.getsockname()
×
342
        else:
343
            raise Exception(f"impossible socktype ({self.socktype!r})")
×
344
        os.write(self.fd, bytes(repr((self.socktype, addr, time.time())), 'utf8'))
×
345
        os.close(self.fd)
×
346
        self.logger.info(f"now running and listening. socktype={self.socktype}, addr={addr}")
×
347

348
    async def ping(self):
1✔
349
        return True
×
350

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

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

391

392
class Daemon(Logger):
1✔
393

394
    network: Optional[Network] = None
1✔
395
    gui_object: Optional['gui.BaseElectrumGui'] = None
1✔
396

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

422
        self._stop_entered = False
1✔
423
        self._stopping_soon_or_errored = threading.Event()
1✔
424
        self._stopped_event = threading.Event()
1✔
425

426
        self.taskgroup = OldTaskGroup()
1✔
427
        asyncio.run_coroutine_threadsafe(self._run(), self.asyncio_loop)
1✔
428
        if start_network and self.network:
1✔
429
            self.start_network()
×
430
        # Setup commands server
431
        self.commands_server = None
1✔
432
        if listen_jsonrpc:
1✔
433
            self.commands_server = CommandsServer(self, fd)
×
434
            asyncio.run_coroutine_threadsafe(self.taskgroup.spawn(self.commands_server.run()), self.asyncio_loop)
×
435

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

451
    def start_network(self):
1✔
452
        self.logger.info(f"starting network.")
×
453
        assert not self.config.NETWORK_OFFLINE
×
454
        assert self.network
×
455
        self.network.start(jobs=[self.fx.run])
×
456
        # prepare lightning functionality, also load channel db early
457
        if self.config.LIGHTNING_USE_GOSSIP:
×
458
            self.network.start_gossip()
×
459

460
    @staticmethod
1✔
461
    def _wallet_key_from_path(path) -> str:
1✔
462
        """This does stricter path standardization than 'standardize_path'.
463
        It is used for keying the _wallets dict,
464
        but MUST NOT be used as a *path* for the actual filesystem operations. (see #8495)
465
        """
466
        path = standardize_path(path)
1✔
467
        # The extra normalisation makes it even harder to open the same wallet file multiple times simultaneously.
468
        # - "realpath" resolves symlinks:
469
        #   note: the path returned by realpath has been observed NOT to work for FS operations!
470
        #         (e.g. for Cryptomator WinFSP/FUSE mounts, see #8495).
471
        #         It is okay for us to use it for computing a canonical wallet *key*, but cannot be used as a path!
472
        try:
1✔
473
            path = os.path.realpath(path, strict=False)
1✔
474
        except OSError as e:  # see #10182
×
475
            _logger.warning(f"could not parse {path!r}: {e!r}")
×
476
            path = path
×
477
        # - "normcase" does Windows-specific case and slash normalisation:
478
        path = os.path.normcase(path)
1✔
479
        # - prepend header to break usage of wallet keys as fs paths
480
        header = "WALLETKEY-"
1✔
481
        return header + str(path)
1✔
482

483
    def with_wallet_lock(func):
1✔
484
        def func_wrapper(self: 'Daemon', *args, **kwargs):
1✔
485
            with self._wallet_lock:
1✔
486
                return func(self, *args, **kwargs)
1✔
487
        return func_wrapper
1✔
488

489
    @with_wallet_lock
1✔
490
    def load_wallet(
1✔
491
        self,
492
        path,
493
        password: Optional[str],
494
        *,
495
        upgrade: bool = False,
496
        force_check_password: bool = False,
497
    ) -> Optional[Abstract_Wallet]:
498
        """
499
        force_check_password: if False, the password arg is only used if it needed to decrypt the storage.
500
                              if True, the password arg is always validated.
501
        """
502
        assert password != ''
1✔
503
        path = standardize_path(path)
1✔
504
        wallet_key = self._wallet_key_from_path(path)
1✔
505
        # wizard will be launched if we return
506
        if wallet := self._wallets.get(wallet_key):
1✔
507
            if force_check_password:
1✔
508
                wallet.check_password(password)
1✔
509
            if self.config.get('wallet_path') is None:
1✔
510
                self.config.CURRENT_WALLET = path
1✔
511
            return wallet
1✔
512
        wallet = self._load_wallet(
1✔
513
            path, password, upgrade=upgrade, config=self.config, force_check_password=force_check_password)
514
        if self.network:
1✔
515
            wallet.start_network(self.network)
×
516
        elif wallet.lnworker:
1✔
517
            # in offline mode, we need to trigger callbacks
518
            coro = wallet.lnworker.lnwatcher.trigger_callbacks(requires_synchronizer=False)
1✔
519
            asyncio.run_coroutine_threadsafe(coro, self.asyncio_loop)
1✔
520
        self.add_wallet(wallet)
1✔
521
        if self.config.get('wallet_path') is None:
1✔
522
            self.config.CURRENT_WALLET = path
1✔
523
        self.update_recently_opened_wallets(path)
1✔
524
        return wallet
1✔
525

526

527
    @staticmethod
1✔
528
    @profiler
1✔
529
    def _load_wallet(
1✔
530
            path,
531
            password: Optional[str],
532
            *,
533
            upgrade: bool = False,
534
            config: SimpleConfig,
535
            force_check_password: bool = False,  # if set, always validate password
536
    ) -> Optional[Abstract_Wallet]:
537
        path = standardize_path(path)
1✔
538
        storage = WalletStorage(path, allow_partial_writes=config.WALLET_PARTIAL_WRITES)
1✔
539
        if not storage.file_exists():
1✔
540
            raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), path)
×
541
        if storage.is_encrypted():
1✔
542
            if not password:
1✔
543
                raise InvalidPassword('No password given')
×
544
            storage.decrypt(password)
1✔
545
        # read data, pass it to db
546
        db = WalletDB(storage.read(), storage=storage, upgrade=upgrade)
1✔
547
        if db.get_action():
1✔
548
            raise WalletUnfinished(db)
×
549
        wallet = Wallet(db, config=config)
1✔
550
        if force_check_password:
1✔
551
            wallet.check_password(password)
1✔
552
        return wallet
1✔
553

554
    @with_wallet_lock
1✔
555
    def add_wallet(self, wallet: Abstract_Wallet) -> None:
1✔
556
        path = wallet.storage.path
1✔
557
        wallet_key = self._wallet_key_from_path(path)
1✔
558
        self._wallets[wallet_key] = wallet
1✔
559
        run_hook('daemon_wallet_loaded', self, wallet)
1✔
560

561
    def get_wallet(self, path: str) -> Optional[Abstract_Wallet]:
1✔
562
        wallet_key = self._wallet_key_from_path(path)
1✔
563
        return self._wallets.get(wallet_key)
1✔
564

565
    @with_wallet_lock
1✔
566
    def get_wallets(self) -> Dict[str, Abstract_Wallet]:
1✔
567
        return dict(self._wallets)  # copy
×
568

569
    def delete_wallet(self, path: str) -> bool:
1✔
570
        self.stop_wallet(path)
×
571
        if os.path.exists(path):
×
572
            os.unlink(path)
×
573
            self.update_recently_opened_wallets(path, remove=True)
×
574
            if self.config.CURRENT_WALLET == path:
×
575
                self.config.CURRENT_WALLET = None
×
576
            return True
×
577
        return False
×
578

579
    def stop_wallet(self, path: str) -> bool:
1✔
580
        """Returns True iff a wallet was found."""
581
        assert util.get_running_loop() != util.get_asyncio_loop(), 'must not be called from asyncio thread'
×
582
        fut = asyncio.run_coroutine_threadsafe(self._stop_wallet(path), self.asyncio_loop)
×
583
        return fut.result()
×
584

585
    @with_wallet_lock
1✔
586
    async def _stop_wallet(self, path: str) -> bool:
1✔
587
        """Returns True iff a wallet was found."""
588
        path = standardize_path(path)
1✔
589
        wallet_key = self._wallet_key_from_path(path)
1✔
590
        wallet = self._wallets.pop(wallet_key, None)
1✔
591
        if not wallet:
1✔
592
            return False
×
593
        await wallet.stop()
1✔
594
        if self.config.get('wallet_path') is None:
1✔
595
            wallet_paths = [w.db.storage.path for w in self._wallets.values()
1✔
596
                            if w.db.storage and w.db.storage.path]
597
            if self.config.CURRENT_WALLET == path and wallet_paths:
1✔
598
                self.config.CURRENT_WALLET = wallet_paths[0]
×
599
        return True
1✔
600

601
    def run_daemon(self):
1✔
602
        if 'wallet_path' in self.config.cmdline_options:
×
603
            self.logger.warning("Ignoring parameter 'wallet_path' for daemon. "
×
604
                                "Use the load_wallet command instead.")
605
        # init plugins
606
        self._plugins = Plugins(self.config, 'cmdline')
×
607
        # block until we are stopping
608
        try:
×
609
            self._stopping_soon_or_errored.wait()
×
610
        except KeyboardInterrupt:
×
611
            self.logger.info("got KeyboardInterrupt")
×
612
        # we either initiate shutdown now,
613
        # or it has already been initiated (in which case this is a no-op):
614
        self.logger.info("run_daemon is calling stop()")
×
615
        asyncio.run_coroutine_threadsafe(self.stop(), self.asyncio_loop).result()
×
616
        # wait until "stop" finishes:
617
        self._stopped_event.wait()
×
618

619
    async def stop(self):
1✔
620
        if self._stop_entered:
1✔
621
            return
×
622
        self._stop_entered = True
1✔
623
        self._stopping_soon_or_errored.set()
1✔
624
        self.logger.info("stop() entered. initiating shutdown")
1✔
625
        try:
1✔
626
            if self.gui_object:
1✔
627
                self.gui_object.stop()
×
628
            self.logger.info("stopping all wallets")
1✔
629
            async with OldTaskGroup() as group:
1✔
630
                for k, wallet in self._wallets.items():
1✔
631
                    await group.spawn(wallet.stop())
1✔
632
            self.logger.info("stopping network and taskgroup")
1✔
633
            async with ignore_after(2):
1✔
634
                async with OldTaskGroup() as group:
1✔
635
                    if self.network:
1✔
636
                        await group.spawn(self.network.stop(full_shutdown=True))
×
637
                    await group.spawn(self.taskgroup.cancel_remaining())
1✔
638
            if self._plugins:
1✔
639
                self.logger.info("stopping plugins")
×
640
                self._plugins.stop()
×
641
                async with ignore_after(1):
×
642
                    await self._plugins.stopped_event_async.wait()
×
643
        finally:
644
            if self.listen_jsonrpc:
1✔
645
                self.logger.info("removing lockfile")
×
646
                remove_lockfile(get_lockfile(self.config))
×
647
            self.logger.info("stopped")
1✔
648
            self._stopped_event.set()
1✔
649

650
    def run_gui(self) -> None:
1✔
651
        assert self.config
×
652
        threading.current_thread().name = 'GUI'
×
653
        gui_name = self.config.GUI_NAME
×
654
        if gui_name in ['lite', 'classic']:
×
655
            gui_name = 'qt'
×
656
        self._plugins = Plugins(self.config, gui_name)  # init plugins
×
657
        self.logger.info(f'launching GUI: {gui_name}')
×
658
        try:
×
659
            try:
×
660
                gui = __import__('electrum.gui.' + gui_name, fromlist=['electrum'])
×
661
            except GuiImportError as e:
×
662
                sys.exit(str(e))
×
663
            self.gui_object = gui.ElectrumGui(config=self.config, daemon=self, plugins=self._plugins)
×
664
            if not self._stop_entered:
×
665
                self.gui_object.main()
×
666
            else:
667
                # If daemon.stop() was called before gui_object got created, stop gui now.
668
                self.gui_object.stop()
×
669
        except BaseException as e:
×
670
            self.logger.error(f'GUI raised exception: {repr(e)}. shutting down.')
×
671
            raise
×
672
        finally:
673
            # app will exit now
674
            asyncio.run_coroutine_threadsafe(self.stop(), self.asyncio_loop).result()
×
675

676
    @with_wallet_lock
1✔
677
    def check_password_for_directory(self, *, old_password, new_password=None, wallet_dir: str) -> Tuple[bool, bool, list[str]]:
1✔
678
        """Checks password against all wallets (in dir), returns whether they can be unified and whether they are already.
679
        If new_password is not None, update all wallet passwords to new_password.
680
        """
681
        assert os.path.exists(wallet_dir), f"path {wallet_dir!r} does not exist"
1✔
682
        succeeded = []
1✔
683
        failed = []
1✔
684
        is_unified = True
1✔
685
        for filename in os.listdir(wallet_dir):
1✔
686
            path = os.path.join(wallet_dir, filename)
1✔
687
            path = standardize_path(path)
1✔
688
            if not os.path.isfile(path):
1✔
689
                continue
×
690
            wallet = self.get_wallet(path)
1✔
691
            # note: we only create a new wallet object if one was not loaded into the daemon already.
692
            #       This is to avoid having two wallet objects contending for the same file.
693
            #       Take care: this only works if the daemon knows about all wallet objects.
694
            #                  if other code already has created a Wallet() for a file but did not tell the daemon,
695
            #                  hard-to-understand bugs will follow...
696
            if wallet is None:
1✔
697
                try:
1✔
698
                    wallet = self._load_wallet(path, old_password, upgrade=True, config=self.config)
1✔
699
                except util.InvalidPassword:
1✔
700
                    pass
1✔
701
                except Exception:
×
702
                    self.logger.exception(f'failed to load wallet at {path!r}:')
×
703
            if wallet is None:
1✔
704
                failed.append(path)
1✔
705
                continue
1✔
706
            if not wallet.storage.is_encrypted():
1✔
707
                is_unified = False
1✔
708
            try:
1✔
709
                try:
1✔
710
                    wallet.check_password(old_password)
1✔
711
                    old_password_real = old_password
1✔
712
                except util.InvalidPassword:
1✔
713
                    wallet.check_password(None)
1✔
714
                    old_password_real = None
1✔
715
            except Exception:
1✔
716
                failed.append(path)
1✔
717
                continue
1✔
718
            if new_password:
1✔
719
                self.logger.info(f'updating password for wallet: {path!r}')
1✔
720
                wallet.update_password(old_password_real, new_password, encrypt_storage=True)
1✔
721
            succeeded.append(path)
1✔
722

723
        can_be_unified = failed == []
1✔
724
        is_unified = can_be_unified and is_unified
1✔
725
        return can_be_unified, is_unified, succeeded
1✔
726

727
    @with_wallet_lock
1✔
728
    def update_password_for_directory(
1✔
729
            self,
730
            *,
731
            old_password,
732
            new_password,
733
            wallet_dir: Optional[str] = None,
734
    ) -> bool:
735
        """returns whether password is unified"""
736
        if new_password is None:
1✔
737
            # we opened a non-encrypted wallet
738
            return False
×
739
        if wallet_dir is None:
1✔
740
            wallet_dir = os.path.dirname(self.config.get_wallet_path())
1✔
741
        can_be_unified, is_unified, _ = self.check_password_for_directory(
1✔
742
            old_password=old_password, new_password=None, wallet_dir=wallet_dir)
743
        if not can_be_unified:
1✔
744
            return False
1✔
745
        if is_unified and old_password == new_password:
1✔
746
            return True
1✔
747
        self.check_password_for_directory(
1✔
748
            old_password=old_password, new_password=new_password, wallet_dir=wallet_dir)
749
        return True
1✔
750

751
    def update_recently_opened_wallets(self, wallet_path, *, remove: bool = False):
1✔
752
        recent = self.config.RECENTLY_OPEN_WALLET_FILES or []
1✔
753
        if wallet_path in recent:
1✔
754
            recent.remove(wallet_path)
1✔
755
        if not remove:
1✔
756
            recent.insert(0, wallet_path)
1✔
757
            recent = [path for path in recent if os.path.exists(path)]
1✔
758
            recent = recent[:5]
1✔
759
        self.config.RECENTLY_OPEN_WALLET_FILES = recent
1✔
760
        util.trigger_callback('recently_opened_wallets_update')
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