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