1import json
2import threading
3import time
4import os
5import stat
6import ssl
7from decimal import Decimal
8from typing import Union, Optional, Dict, Sequence, Tuple
9from numbers import Real
10
11from copy import deepcopy
12from aiorpcx import NetAddress
13
14from . import util
15from . import constants
16from .util import base_units, base_unit_name_to_decimal_point, decimal_point_to_base_unit_name, UnknownBaseUnit, DECIMAL_POINT_DEFAULT
17from .util import format_satoshis, format_fee_satoshis
18from .util import user_dir, make_dir, NoDynamicFeeEstimates, quantize_feerate
19from .i18n import _
20from .logging import get_logger, Logger
21
22
23FEE_ETA_TARGETS = [25, 10, 5, 2]
24FEE_DEPTH_TARGETS = [10000000, 5000000, 2000000, 1000000, 500000, 200000, 100000]
25FEE_LN_ETA_TARGET = 2  # note: make sure the network is asking for estimates for this target
26
27# satoshi per kbyte
28FEERATE_MAX_DYNAMIC = 1500000
29FEERATE_WARNING_HIGH_FEE = 600000
30FEERATE_FALLBACK_STATIC_FEE = 150000
31FEERATE_DEFAULT_RELAY = 1000
32FEERATE_MAX_RELAY = 50000
33FEERATE_STATIC_VALUES = [1000, 2000, 5000, 10000, 20000, 30000,
34                         50000, 70000, 100000, 150000, 200000, 300000]
35FEERATE_REGTEST_HARDCODED = 180000  # for eclair compat
36
37FEE_RATIO_HIGH_WARNING = 0.05  # warn user if fee/amount for on-chain tx is higher than this
38
39
40_logger = get_logger(__name__)
41
42
43FINAL_CONFIG_VERSION = 3
44
45
46class SimpleConfig(Logger):
47    """
48    The SimpleConfig class is responsible for handling operations involving
49    configuration files.
50
51    There are two different sources of possible configuration values:
52        1. Command line options.
53        2. User configuration (in the user's config directory)
54    They are taken in order (1. overrides config options set in 2.)
55    """
56
57    def __init__(self, options=None, read_user_config_function=None,
58                 read_user_dir_function=None):
59        if options is None:
60            options = {}
61
62        Logger.__init__(self)
63
64        # This lock needs to be acquired for updating and reading the config in
65        # a thread-safe way.
66        self.lock = threading.RLock()
67
68        self.mempool_fees = None  # type: Optional[Sequence[Tuple[Union[float, int], int]]]
69        self.fee_estimates = {}  # type: Dict[int, int]
70        self.last_time_fee_estimates_requested = 0  # zero ensures immediate fees
71
72        # The following two functions are there for dependency injection when
73        # testing.
74        if read_user_config_function is None:
75            read_user_config_function = read_user_config
76        if read_user_dir_function is None:
77            self.user_dir = user_dir
78        else:
79            self.user_dir = read_user_dir_function
80
81        # The command line options
82        self.cmdline_options = deepcopy(options)
83        # don't allow to be set on CLI:
84        self.cmdline_options.pop('config_version', None)
85
86        # Set self.path and read the user config
87        self.user_config = {}  # for self.get in electrum_path()
88        self.path = self.electrum_path()
89        self.user_config = read_user_config_function(self.path)
90        if not self.user_config:
91            # avoid new config getting upgraded
92            self.user_config = {'config_version': FINAL_CONFIG_VERSION}
93
94        self._not_modifiable_keys = set()
95
96        # config "upgrade" - CLI options
97        self.rename_config_keys(
98            self.cmdline_options, {'auto_cycle': 'auto_connect'}, True)
99
100        # config upgrade - user config
101        if self.requires_upgrade():
102            self.upgrade()
103
104        self._check_dependent_keys()
105
106        # units and formatting
107        self.decimal_point = self.get('decimal_point', DECIMAL_POINT_DEFAULT)
108        try:
109            decimal_point_to_base_unit_name(self.decimal_point)
110        except UnknownBaseUnit:
111            self.decimal_point = DECIMAL_POINT_DEFAULT
112        self.num_zeros = int(self.get('num_zeros', 0))
113
114    def electrum_path(self):
115        # Read electrum_path from command line
116        # Otherwise use the user's default data directory.
117        path = self.get('electrum_path')
118        if path is None:
119            path = self.user_dir()
120
121        make_dir(path, allow_symlink=False)
122        if self.get('testnet'):
123            path = os.path.join(path, 'testnet')
124            make_dir(path, allow_symlink=False)
125        elif self.get('regtest'):
126            path = os.path.join(path, 'regtest')
127            make_dir(path, allow_symlink=False)
128        elif self.get('simnet'):
129            path = os.path.join(path, 'simnet')
130            make_dir(path, allow_symlink=False)
131        elif self.get('signet'):
132            path = os.path.join(path, 'signet')
133            make_dir(path, allow_symlink=False)
134
135        self.logger.info(f"electrum directory {path}")
136        return path
137
138    def rename_config_keys(self, config, keypairs, deprecation_warning=False):
139        """Migrate old key names to new ones"""
140        updated = False
141        for old_key, new_key in keypairs.items():
142            if old_key in config:
143                if new_key not in config:
144                    config[new_key] = config[old_key]
145                    if deprecation_warning:
146                        self.logger.warning('Note that the {} variable has been deprecated. '
147                                            'You should use {} instead.'.format(old_key, new_key))
148                del config[old_key]
149                updated = True
150        return updated
151
152    def set_key(self, key, value, save=True):
153        if not self.is_modifiable(key):
154            self.logger.warning(f"not changing config key '{key}' set on the command line")
155            return
156        try:
157            json.dumps(key)
158            json.dumps(value)
159        except:
160            self.logger.info(f"json error: cannot save {repr(key)} ({repr(value)})")
161            return
162        self._set_key_in_user_config(key, value, save)
163
164    def _set_key_in_user_config(self, key, value, save=True):
165        with self.lock:
166            if value is not None:
167                self.user_config[key] = value
168            else:
169                self.user_config.pop(key, None)
170            if save:
171                self.save_user_config()
172
173    def get(self, key, default=None):
174        with self.lock:
175            out = self.cmdline_options.get(key)
176            if out is None:
177                out = self.user_config.get(key, default)
178        return out
179
180    def _check_dependent_keys(self) -> None:
181        if self.get('serverfingerprint'):
182            if not self.get('server'):
183                raise Exception("config key 'serverfingerprint' requires 'server' to also be set")
184            self.make_key_not_modifiable('server')
185
186    def requires_upgrade(self):
187        return self.get_config_version() < FINAL_CONFIG_VERSION
188
189    def upgrade(self):
190        with self.lock:
191            self.logger.info('upgrading config')
192
193            self.convert_version_2()
194            self.convert_version_3()
195
196            self.set_key('config_version', FINAL_CONFIG_VERSION, save=True)
197
198    def convert_version_2(self):
199        if not self._is_upgrade_method_needed(1, 1):
200            return
201
202        self.rename_config_keys(self.user_config, {'auto_cycle': 'auto_connect'})
203
204        try:
205            # change server string FROM host:port:proto TO host:port:s
206            server_str = self.user_config.get('server')
207            host, port, protocol = str(server_str).rsplit(':', 2)
208            assert protocol in ('s', 't')
209            int(port)  # Throw if cannot be converted to int
210            server_str = '{}:{}:s'.format(host, port)
211            self._set_key_in_user_config('server', server_str)
212        except BaseException:
213            self._set_key_in_user_config('server', None)
214
215        self.set_key('config_version', 2)
216
217    def convert_version_3(self):
218        if not self._is_upgrade_method_needed(2, 2):
219            return
220
221        base_unit = self.user_config.get('base_unit')
222        if isinstance(base_unit, str):
223            self._set_key_in_user_config('base_unit', None)
224            map_ = {'btc':8, 'mbtc':5, 'ubtc':2, 'bits':2, 'sat':0}
225            decimal_point = map_.get(base_unit.lower())
226            self._set_key_in_user_config('decimal_point', decimal_point)
227
228        self.set_key('config_version', 3)
229
230    def _is_upgrade_method_needed(self, min_version, max_version):
231        cur_version = self.get_config_version()
232        if cur_version > max_version:
233            return False
234        elif cur_version < min_version:
235            raise Exception(
236                ('config upgrade: unexpected version %d (should be %d-%d)'
237                 % (cur_version, min_version, max_version)))
238        else:
239            return True
240
241    def get_config_version(self):
242        config_version = self.get('config_version', 1)
243        if config_version > FINAL_CONFIG_VERSION:
244            self.logger.warning('config version ({}) is higher than latest ({})'
245                                .format(config_version, FINAL_CONFIG_VERSION))
246        return config_version
247
248    def is_modifiable(self, key) -> bool:
249        return (key not in self.cmdline_options
250                and key not in self._not_modifiable_keys)
251
252    def make_key_not_modifiable(self, key) -> None:
253        self._not_modifiable_keys.add(key)
254
255    def save_user_config(self):
256        if self.get('forget_config'):
257            return
258        if not self.path:
259            return
260        path = os.path.join(self.path, "config")
261        s = json.dumps(self.user_config, indent=4, sort_keys=True)
262        try:
263            with open(path, "w", encoding='utf-8') as f:
264                f.write(s)
265            os.chmod(path, stat.S_IREAD | stat.S_IWRITE)
266        except FileNotFoundError:
267            # datadir probably deleted while running...
268            if os.path.exists(self.path):  # or maybe not?
269                raise
270
271    def get_backup_dir(self):
272        # this is used to save a backup everytime a channel is created
273        # on Android, the export backup button uses android_backup_dir()
274        if 'ANDROID_DATA' in os.environ:
275            return None
276        else:
277            return self.get('backup_dir')
278
279    def get_wallet_path(self, *, use_gui_last_wallet=False):
280        """Set the path of the wallet."""
281
282        # command line -w option
283        if self.get('wallet_path'):
284            return os.path.join(self.get('cwd', ''), self.get('wallet_path'))
285
286        if use_gui_last_wallet:
287            path = self.get('gui_last_wallet')
288            if path and os.path.exists(path):
289                return path
290
291        # default path
292        util.assert_datadir_available(self.path)
293        dirpath = os.path.join(self.path, "wallets")
294        make_dir(dirpath, allow_symlink=False)
295
296        new_path = os.path.join(self.path, "wallets", "default_wallet")
297
298        # default path in pre 1.9 versions
299        old_path = os.path.join(self.path, "electrum.dat")
300        if os.path.exists(old_path) and not os.path.exists(new_path):
301            os.rename(old_path, new_path)
302
303        return new_path
304
305    def remove_from_recently_open(self, filename):
306        recent = self.get('recently_open', [])
307        if filename in recent:
308            recent.remove(filename)
309            self.set_key('recently_open', recent)
310
311    def set_session_timeout(self, seconds):
312        self.logger.info(f"session timeout -> {seconds} seconds")
313        self.set_key('session_timeout', seconds)
314
315    def get_session_timeout(self):
316        return self.get('session_timeout', 300)
317
318    def save_last_wallet(self, wallet):
319        if self.get('wallet_path') is None:
320            path = wallet.storage.path
321            self.set_key('gui_last_wallet', path)
322
323    def impose_hard_limits_on_fee(func):
324        def get_fee_within_limits(self, *args, **kwargs):
325            fee = func(self, *args, **kwargs)
326            if fee is None:
327                return fee
328            fee = min(FEERATE_MAX_DYNAMIC, fee)
329            fee = max(FEERATE_DEFAULT_RELAY, fee)
330            return fee
331        return get_fee_within_limits
332
333    def eta_to_fee(self, slider_pos) -> Optional[int]:
334        """Returns fee in sat/kbyte."""
335        slider_pos = max(slider_pos, 0)
336        slider_pos = min(slider_pos, len(FEE_ETA_TARGETS))
337        if slider_pos < len(FEE_ETA_TARGETS):
338            num_blocks = FEE_ETA_TARGETS[int(slider_pos)]
339            fee = self.eta_target_to_fee(num_blocks)
340        else:
341            fee = self.eta_target_to_fee(1)
342        return fee
343
344    @impose_hard_limits_on_fee
345    def eta_target_to_fee(self, num_blocks: int) -> Optional[int]:
346        """Returns fee in sat/kbyte."""
347        if num_blocks == 1:
348            fee = self.fee_estimates.get(2)
349            if fee is not None:
350                fee += fee / 2
351                fee = int(fee)
352        else:
353            fee = self.fee_estimates.get(num_blocks)
354            if fee is not None:
355                fee = int(fee)
356        return fee
357
358    def fee_to_depth(self, target_fee: Real) -> Optional[int]:
359        """For a given sat/vbyte fee, returns an estimate of how deep
360        it would be in the current mempool in vbytes.
361        Pessimistic == overestimates the depth.
362        """
363        if self.mempool_fees is None:
364            return None
365        depth = 0
366        for fee, s in self.mempool_fees:
367            depth += s
368            if fee <= target_fee:
369                break
370        return depth
371
372    def depth_to_fee(self, slider_pos) -> Optional[int]:
373        """Returns fee in sat/kbyte."""
374        target = self.depth_target(slider_pos)
375        return self.depth_target_to_fee(target)
376
377    @impose_hard_limits_on_fee
378    def depth_target_to_fee(self, target: int) -> Optional[int]:
379        """Returns fee in sat/kbyte.
380        target: desired mempool depth in vbytes
381        """
382        if self.mempool_fees is None:
383            return None
384        depth = 0
385        for fee, s in self.mempool_fees:
386            depth += s
387            if depth > target:
388                break
389        else:
390            return 0
391        # add one sat/byte as currently that is
392        # the max precision of the histogram
393        # (well, in case of ElectrumX at least. not for electrs)
394        fee += 1
395        # convert to sat/kbyte
396        return int(fee * 1000)
397
398    def depth_target(self, slider_pos: int) -> int:
399        """Returns mempool depth target in bytes for a fee slider position."""
400        slider_pos = max(slider_pos, 0)
401        slider_pos = min(slider_pos, len(FEE_DEPTH_TARGETS)-1)
402        return FEE_DEPTH_TARGETS[slider_pos]
403
404    def eta_target(self, slider_pos: int) -> int:
405        """Returns 'num blocks' ETA target for a fee slider position."""
406        if slider_pos == len(FEE_ETA_TARGETS):
407            return 1
408        return FEE_ETA_TARGETS[slider_pos]
409
410    def fee_to_eta(self, fee_per_kb: Optional[int]) -> int:
411        """Returns 'num blocks' ETA estimate for given fee rate,
412        or -1 for low fee.
413        """
414        import operator
415        lst = list(self.fee_estimates.items())
416        next_block_fee = self.eta_target_to_fee(1)
417        if next_block_fee is not None:
418            lst += [(1, next_block_fee)]
419        if not lst or fee_per_kb is None:
420            return -1
421        dist = map(lambda x: (x[0], abs(x[1] - fee_per_kb)), lst)
422        min_target, min_value = min(dist, key=operator.itemgetter(1))
423        if fee_per_kb < self.fee_estimates.get(FEE_ETA_TARGETS[0])/2:
424            min_target = -1
425        return min_target
426
427    def depth_tooltip(self, depth: Optional[int]) -> str:
428        """Returns text tooltip for given mempool depth (in vbytes)."""
429        if depth is None:
430            return "unknown from tip"
431        return "%.1f MB from tip" % (depth/1_000_000)
432
433    def eta_tooltip(self, x):
434        if x < 0:
435            return _('Low fee')
436        elif x == 1:
437            return _('In the next block')
438        else:
439            return _('Within {} blocks').format(x)
440
441    def get_fee_target(self):
442        dyn = self.is_dynfee()
443        mempool = self.use_mempool_fees()
444        pos = self.get_depth_level() if mempool else self.get_fee_level()
445        fee_rate = self.fee_per_kb()
446        target, tooltip = self.get_fee_text(pos, dyn, mempool, fee_rate)
447        return target, tooltip, dyn
448
449    def get_fee_status(self):
450        target, tooltip, dyn = self.get_fee_target()
451        return tooltip + '  [%s]'%target if dyn else target + '  [Static]'
452
453    def get_fee_text(
454            self,
455            slider_pos: int,
456            dyn: bool,
457            mempool: bool,
458            fee_per_kb: Optional[int],
459    ):
460        """Returns (text, tooltip) where
461        text is what we target: static fee / num blocks to confirm in / mempool depth
462        tooltip is the corresponding estimate (e.g. num blocks for a static fee)
463
464        fee_rate is in sat/kbyte
465        """
466        if fee_per_kb is None:
467            rate_str = 'unknown'
468            fee_per_byte = None
469        else:
470            fee_per_byte = fee_per_kb/1000
471            rate_str = format_fee_satoshis(fee_per_byte) + ' sat/byte'
472
473        if dyn:
474            if mempool:
475                depth = self.depth_target(slider_pos)
476                text = self.depth_tooltip(depth)
477            else:
478                eta = self.eta_target(slider_pos)
479                text = self.eta_tooltip(eta)
480            tooltip = rate_str
481        else:  # using static fees
482            assert fee_per_kb is not None
483            assert fee_per_byte is not None
484            text = rate_str
485            if mempool and self.has_fee_mempool():
486                depth = self.fee_to_depth(fee_per_byte)
487                tooltip = self.depth_tooltip(depth)
488            elif not mempool and self.has_fee_etas():
489                eta = self.fee_to_eta(fee_per_kb)
490                tooltip = self.eta_tooltip(eta)
491            else:
492                tooltip = ''
493        return text, tooltip
494
495    def get_depth_level(self):
496        maxp = len(FEE_DEPTH_TARGETS) - 1
497        return min(maxp, self.get('depth_level', 2))
498
499    def get_fee_level(self):
500        maxp = len(FEE_ETA_TARGETS)  # not (-1) to have "next block"
501        return min(maxp, self.get('fee_level', 2))
502
503    def get_fee_slider(self, dyn, mempool) -> Tuple[int, int, Optional[int]]:
504        if dyn:
505            if mempool:
506                pos = self.get_depth_level()
507                maxp = len(FEE_DEPTH_TARGETS) - 1
508                fee_rate = self.depth_to_fee(pos)
509            else:
510                pos = self.get_fee_level()
511                maxp = len(FEE_ETA_TARGETS)  # not (-1) to have "next block"
512                fee_rate = self.eta_to_fee(pos)
513        else:
514            fee_rate = self.fee_per_kb(dyn=False)
515            pos = self.static_fee_index(fee_rate)
516            maxp = len(FEERATE_STATIC_VALUES) - 1
517        return maxp, pos, fee_rate
518
519    def static_fee(self, i):
520        return FEERATE_STATIC_VALUES[i]
521
522    def static_fee_index(self, fee_per_kb: Optional[int]) -> int:
523        if fee_per_kb is None:
524            raise TypeError('static fee cannot be None')
525        dist = list(map(lambda x: abs(x - fee_per_kb), FEERATE_STATIC_VALUES))
526        return min(range(len(dist)), key=dist.__getitem__)
527
528    def has_fee_etas(self):
529        return len(self.fee_estimates) == 4
530
531    def has_fee_mempool(self) -> bool:
532        return self.mempool_fees is not None
533
534    def has_dynamic_fees_ready(self):
535        if self.use_mempool_fees():
536            return self.has_fee_mempool()
537        else:
538            return self.has_fee_etas()
539
540    def is_dynfee(self):
541        return bool(self.get('dynamic_fees', True))
542
543    def use_mempool_fees(self):
544        return bool(self.get('mempool_fees', False))
545
546    def _feerate_from_fractional_slider_position(self, fee_level: float, dyn: bool,
547                                                 mempool: bool) -> Union[int, None]:
548        fee_level = max(fee_level, 0)
549        fee_level = min(fee_level, 1)
550        if dyn:
551            max_pos = (len(FEE_DEPTH_TARGETS) - 1) if mempool else len(FEE_ETA_TARGETS)
552            slider_pos = round(fee_level * max_pos)
553            fee_rate = self.depth_to_fee(slider_pos) if mempool else self.eta_to_fee(slider_pos)
554        else:
555            max_pos = len(FEERATE_STATIC_VALUES) - 1
556            slider_pos = round(fee_level * max_pos)
557            fee_rate = FEERATE_STATIC_VALUES[slider_pos]
558        return fee_rate
559
560    def fee_per_kb(self, dyn: bool=None, mempool: bool=None, fee_level: float=None) -> Optional[int]:
561        """Returns sat/kvB fee to pay for a txn.
562        Note: might return None.
563
564        fee_level: float between 0.0 and 1.0, representing fee slider position
565        """
566        if constants.net is constants.BitcoinRegtest:
567            return FEERATE_REGTEST_HARDCODED
568        if dyn is None:
569            dyn = self.is_dynfee()
570        if mempool is None:
571            mempool = self.use_mempool_fees()
572        if fee_level is not None:
573            return self._feerate_from_fractional_slider_position(fee_level, dyn, mempool)
574        # there is no fee_level specified; will use config.
575        # note: 'depth_level' and 'fee_level' in config are integer slider positions,
576        # unlike fee_level here, which (when given) is a float in [0.0, 1.0]
577        if dyn:
578            if mempool:
579                fee_rate = self.depth_to_fee(self.get_depth_level())
580            else:
581                fee_rate = self.eta_to_fee(self.get_fee_level())
582        else:
583            fee_rate = self.get('fee_per_kb', FEERATE_FALLBACK_STATIC_FEE)
584        if fee_rate is not None:
585            fee_rate = int(fee_rate)
586        return fee_rate
587
588    def fee_per_byte(self):
589        """Returns sat/vB fee to pay for a txn.
590        Note: might return None.
591        """
592        fee_per_kb = self.fee_per_kb()
593        return fee_per_kb / 1000 if fee_per_kb is not None else None
594
595    def estimate_fee(self, size: Union[int, float, Decimal], *,
596                     allow_fallback_to_static_rates: bool = False) -> int:
597        fee_per_kb = self.fee_per_kb()
598        if fee_per_kb is None:
599            if allow_fallback_to_static_rates:
600                fee_per_kb = FEERATE_FALLBACK_STATIC_FEE
601            else:
602                raise NoDynamicFeeEstimates()
603        return self.estimate_fee_for_feerate(fee_per_kb, size)
604
605    @classmethod
606    def estimate_fee_for_feerate(cls, fee_per_kb: Union[int, float, Decimal],
607                                 size: Union[int, float, Decimal]) -> int:
608        size = Decimal(size)
609        fee_per_kb = Decimal(fee_per_kb)
610        fee_per_byte = fee_per_kb / 1000
611        # to be consistent with what is displayed in the GUI,
612        # the calculation needs to use the same precision:
613        fee_per_byte = quantize_feerate(fee_per_byte)
614        return round(fee_per_byte * size)
615
616    def update_fee_estimates(self, nblock_target: int, fee_per_kb: int):
617        assert isinstance(nblock_target, int), f"expected int, got {nblock_target!r}"
618        assert isinstance(fee_per_kb, int), f"expected int, got {fee_per_kb!r}"
619        self.fee_estimates[nblock_target] = fee_per_kb
620
621    def is_fee_estimates_update_required(self):
622        """Checks time since last requested and updated fee estimates.
623        Returns True if an update should be requested.
624        """
625        now = time.time()
626        return now - self.last_time_fee_estimates_requested > 60
627
628    def requested_fee_estimates(self):
629        self.last_time_fee_estimates_requested = time.time()
630
631    def get_video_device(self):
632        device = self.get("video_device", "default")
633        if device == 'default':
634            device = ''
635        return device
636
637    def get_ssl_context(self):
638        ssl_keyfile = self.get('ssl_keyfile')
639        ssl_certfile = self.get('ssl_certfile')
640        if ssl_keyfile and ssl_certfile:
641            ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
642            ssl_context.load_cert_chain(ssl_certfile, ssl_keyfile)
643            return ssl_context
644
645    def get_ssl_domain(self):
646        from .paymentrequest import check_ssl_config
647        if self.get('ssl_keyfile') and self.get('ssl_certfile'):
648            SSL_identity = check_ssl_config(self)
649        else:
650            SSL_identity = None
651        return SSL_identity
652
653    def get_netaddress(self, key: str) -> Optional[NetAddress]:
654        text = self.get(key)
655        if text:
656            try:
657                return NetAddress.from_string(text)
658            except:
659                pass
660
661    def format_amount(self, x, is_diff=False, whitespaces=False):
662        return format_satoshis(
663            x,
664            num_zeros=self.num_zeros,
665            decimal_point=self.decimal_point,
666            is_diff=is_diff,
667            whitespaces=whitespaces,
668        )
669
670    def format_amount_and_units(self, amount):
671        return self.format_amount(amount) + ' '+ self.get_base_unit()
672
673    def format_fee_rate(self, fee_rate):
674        return format_fee_satoshis(fee_rate/1000, num_zeros=self.num_zeros) + ' sat/byte'
675
676    def get_base_unit(self):
677        return decimal_point_to_base_unit_name(self.decimal_point)
678
679    def set_base_unit(self, unit):
680        assert unit in base_units.keys()
681        self.decimal_point = base_unit_name_to_decimal_point(unit)
682        self.set_key('decimal_point', self.decimal_point, True)
683
684    def get_decimal_point(self):
685        return self.decimal_point
686
687
688def read_user_config(path):
689    """Parse and store the user config settings in electrum.conf into user_config[]."""
690    if not path:
691        return {}
692    config_path = os.path.join(path, "config")
693    if not os.path.exists(config_path):
694        return {}
695    try:
696        with open(config_path, "r", encoding='utf-8') as f:
697            data = f.read()
698        result = json.loads(data)
699    except:
700        _logger.warning(f"Cannot read config file. {config_path}")
701        return {}
702    if not type(result) is dict:
703        return {}
704    return result
705