You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
3345 lines
136 KiB
3345 lines
136 KiB
# Electrum - lightweight Bitcoin client
|
|
# Copyright (C) 2015 Thomas Voegtlin
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person
|
|
# obtaining a copy of this software and associated documentation files
|
|
# (the "Software"), to deal in the Software without restriction,
|
|
# including without limitation the rights to use, copy, modify, merge,
|
|
# publish, distribute, sublicense, and/or sell copies of the Software,
|
|
# and to permit persons to whom the Software is furnished to do so,
|
|
# subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
|
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
# Wallet classes:
|
|
# - Imported_Wallet: imported addresses or single keys, 0 or 1 keystore
|
|
# - Standard_Wallet: one HD keystore, P2PKH-like scripts
|
|
# - Multisig_Wallet: several HD keystores, M-of-N OP_CHECKMULTISIG scripts
|
|
|
|
import os
|
|
import sys
|
|
import random
|
|
import time
|
|
import json
|
|
import copy
|
|
import errno
|
|
import traceback
|
|
import operator
|
|
import math
|
|
from functools import partial
|
|
from collections import defaultdict
|
|
from numbers import Number
|
|
from decimal import Decimal
|
|
from typing import TYPE_CHECKING, List, Optional, Tuple, Union, NamedTuple, Sequence, Dict, Any, Set
|
|
from abc import ABC, abstractmethod
|
|
import itertools
|
|
import threading
|
|
import enum
|
|
|
|
from aiorpcx import TaskGroup, timeout_after, TaskTimeout, ignore_after
|
|
|
|
from .i18n import _
|
|
from .bip32 import BIP32Node, convert_bip32_intpath_to_strpath, convert_bip32_path_to_list_of_uint32
|
|
from .crypto import sha256
|
|
from . import util
|
|
from .util import (NotEnoughFunds, UserCancelled, profiler,
|
|
format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates,
|
|
WalletFileException, BitcoinException, MultipleSpendMaxTxOutputs,
|
|
InvalidPassword, format_time, timestamp_to_datetime, Satoshis,
|
|
Fiat, bfh, bh2u, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex)
|
|
from .simple_config import SimpleConfig, FEE_RATIO_HIGH_WARNING, FEERATE_WARNING_HIGH_FEE
|
|
from .bitcoin import COIN, TYPE_ADDRESS
|
|
from .bitcoin import is_address, address_to_script, is_minikey, relayfee, dust_threshold
|
|
from .crypto import sha256d
|
|
from . import keystore
|
|
from .keystore import (load_keystore, Hardware_KeyStore, KeyStore, KeyStoreWithMPK,
|
|
AddressIndexGeneric, CannotDerivePubkey)
|
|
from .util import multisig_type
|
|
from .storage import StorageEncryptionVersion, WalletStorage
|
|
from .wallet_db import WalletDB
|
|
from . import transaction, bitcoin, coinchooser, paymentrequest, ecc, bip32
|
|
from .transaction import (Transaction, TxInput, UnknownTxinType, TxOutput,
|
|
PartialTransaction, PartialTxInput, PartialTxOutput, TxOutpoint)
|
|
from .plugin import run_hook
|
|
from .address_synchronizer import (AddressSynchronizer, TX_HEIGHT_LOCAL,
|
|
TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE)
|
|
from .invoices import Invoice, OnchainInvoice, LNInvoice
|
|
from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_UNCONFIRMED, PR_TYPE_ONCHAIN, PR_TYPE_LN
|
|
from .contacts import Contacts
|
|
from .interface import NetworkException
|
|
from .mnemonic import Mnemonic
|
|
from .logging import get_logger
|
|
from .lnworker import LNWallet
|
|
from .paymentrequest import PaymentRequest
|
|
from .util import read_json_file, write_json_file, UserFacingException
|
|
|
|
if TYPE_CHECKING:
|
|
from .network import Network
|
|
from .exchange_rate import FxThread
|
|
|
|
|
|
_logger = get_logger(__name__)
|
|
|
|
TX_STATUS = [
|
|
_('Unconfirmed'),
|
|
_('Unconfirmed parent'),
|
|
_('Not Verified'),
|
|
_('Local'),
|
|
]
|
|
|
|
|
|
class BumpFeeStrategy(enum.Enum):
|
|
COINCHOOSER = enum.auto()
|
|
DECREASE_CHANGE = enum.auto()
|
|
DECREASE_PAYMENT = enum.auto()
|
|
|
|
|
|
async def _append_utxos_to_inputs(*, inputs: List[PartialTxInput], network: 'Network',
|
|
pubkey: str, txin_type: str, imax: int) -> None:
|
|
if txin_type in ('p2pkh', 'p2wpkh', 'p2wpkh-p2sh'):
|
|
address = bitcoin.pubkey_to_address(txin_type, pubkey)
|
|
scripthash = bitcoin.address_to_scripthash(address)
|
|
elif txin_type == 'p2pk':
|
|
script = bitcoin.public_key_to_p2pk_script(pubkey)
|
|
scripthash = bitcoin.script_to_scripthash(script)
|
|
else:
|
|
raise Exception(f'unexpected txin_type to sweep: {txin_type}')
|
|
|
|
async def append_single_utxo(item):
|
|
prev_tx_raw = await network.get_transaction(item['tx_hash'])
|
|
prev_tx = Transaction(prev_tx_raw)
|
|
prev_txout = prev_tx.outputs()[item['tx_pos']]
|
|
if scripthash != bitcoin.script_to_scripthash(prev_txout.scriptpubkey.hex()):
|
|
raise Exception('scripthash mismatch when sweeping')
|
|
prevout_str = item['tx_hash'] + ':%d' % item['tx_pos']
|
|
prevout = TxOutpoint.from_str(prevout_str)
|
|
txin = PartialTxInput(prevout=prevout)
|
|
txin.utxo = prev_tx
|
|
txin.block_height = int(item['height'])
|
|
txin.script_type = txin_type
|
|
txin.pubkeys = [bfh(pubkey)]
|
|
txin.num_sig = 1
|
|
if txin_type == 'p2wpkh-p2sh':
|
|
txin.redeem_script = bfh(bitcoin.p2wpkh_nested_script(pubkey))
|
|
inputs.append(txin)
|
|
|
|
u = await network.listunspent_for_scripthash(scripthash)
|
|
async with TaskGroup() as group:
|
|
for item in u:
|
|
if len(inputs) >= imax:
|
|
break
|
|
await group.spawn(append_single_utxo(item))
|
|
|
|
|
|
async def sweep_preparations(privkeys, network: 'Network', imax=100):
|
|
|
|
async def find_utxos_for_privkey(txin_type, privkey, compressed):
|
|
pubkey = ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed)
|
|
await _append_utxos_to_inputs(
|
|
inputs=inputs,
|
|
network=network,
|
|
pubkey=pubkey,
|
|
txin_type=txin_type,
|
|
imax=imax)
|
|
keypairs[pubkey] = privkey, compressed
|
|
|
|
inputs = [] # type: List[PartialTxInput]
|
|
keypairs = {}
|
|
async with TaskGroup() as group:
|
|
for sec in privkeys:
|
|
txin_type, privkey, compressed = bitcoin.deserialize_privkey(sec)
|
|
await group.spawn(find_utxos_for_privkey(txin_type, privkey, compressed))
|
|
# do other lookups to increase support coverage
|
|
if is_minikey(sec):
|
|
# minikeys don't have a compressed byte
|
|
# we lookup both compressed and uncompressed pubkeys
|
|
await group.spawn(find_utxos_for_privkey(txin_type, privkey, not compressed))
|
|
elif txin_type == 'p2pkh':
|
|
# WIF serialization does not distinguish p2pkh and p2pk
|
|
# we also search for pay-to-pubkey outputs
|
|
await group.spawn(find_utxos_for_privkey('p2pk', privkey, compressed))
|
|
if not inputs:
|
|
raise UserFacingException(_('No inputs found.'))
|
|
return inputs, keypairs
|
|
|
|
|
|
async def sweep(
|
|
privkeys,
|
|
*,
|
|
network: 'Network',
|
|
config: 'SimpleConfig',
|
|
to_address: str,
|
|
fee: int = None,
|
|
imax=100,
|
|
locktime=None,
|
|
tx_version=None) -> PartialTransaction:
|
|
|
|
inputs, keypairs = await sweep_preparations(privkeys, network, imax)
|
|
total = sum(txin.value_sats() for txin in inputs)
|
|
if fee is None:
|
|
outputs = [PartialTxOutput(scriptpubkey=bfh(bitcoin.address_to_script(to_address)),
|
|
value=total)]
|
|
tx = PartialTransaction.from_io(inputs, outputs)
|
|
fee = config.estimate_fee(tx.estimated_size())
|
|
if total - fee < 0:
|
|
raise Exception(_('Not enough funds on address.') + '\nTotal: %d satoshis\nFee: %d'%(total, fee))
|
|
if total - fee < dust_threshold(network):
|
|
raise Exception(_('Not enough funds on address.') + '\nTotal: %d satoshis\nFee: %d\nDust Threshold: %d'%(total, fee, dust_threshold(network)))
|
|
|
|
outputs = [PartialTxOutput(scriptpubkey=bfh(bitcoin.address_to_script(to_address)),
|
|
value=total - fee)]
|
|
if locktime is None:
|
|
locktime = get_locktime_for_new_transaction(network)
|
|
|
|
tx = PartialTransaction.from_io(inputs, outputs, locktime=locktime, version=tx_version)
|
|
rbf = bool(config.get('use_rbf', True))
|
|
tx.set_rbf(rbf)
|
|
tx.sign(keypairs)
|
|
return tx
|
|
|
|
|
|
def get_locktime_for_new_transaction(network: 'Network') -> int:
|
|
# if no network or not up to date, just set locktime to zero
|
|
if not network:
|
|
return 0
|
|
chain = network.blockchain()
|
|
if chain.is_tip_stale():
|
|
return 0
|
|
# discourage "fee sniping"
|
|
locktime = chain.height()
|
|
# sometimes pick locktime a bit further back, to help privacy
|
|
# of setups that need more time (offline/multisig/coinjoin/...)
|
|
if random.randint(0, 9) == 0:
|
|
locktime = max(0, locktime - random.randint(0, 99))
|
|
return locktime
|
|
|
|
|
|
|
|
class CannotBumpFee(Exception):
|
|
def __str__(self):
|
|
return _('Cannot bump fee') + ':\n\n' + Exception.__str__(self)
|
|
|
|
class CannotDoubleSpendTx(Exception):
|
|
def __str__(self):
|
|
return _('Cannot cancel transaction') + ':\n\n' + Exception.__str__(self)
|
|
|
|
class CannotCPFP(Exception):
|
|
def __str__(self):
|
|
return _('Cannot create child transaction') + ':\n\n' + Exception.__str__(self)
|
|
|
|
class InternalAddressCorruption(Exception):
|
|
def __str__(self):
|
|
return _("Wallet file corruption detected. "
|
|
"Please restore your wallet from seed, and compare the addresses in both files")
|
|
|
|
|
|
class TxWalletDetails(NamedTuple):
|
|
txid: Optional[str]
|
|
status: str
|
|
label: str
|
|
can_broadcast: bool
|
|
can_bump: bool
|
|
can_cpfp: bool
|
|
can_dscancel: bool # whether user can double-spend to self
|
|
can_save_as_local: bool
|
|
amount: Optional[int]
|
|
fee: Optional[int]
|
|
tx_mined_status: TxMinedInfo
|
|
mempool_depth_bytes: Optional[int]
|
|
can_remove: bool # whether user should be allowed to delete tx
|
|
is_lightning_funding_tx: bool
|
|
|
|
|
|
class Abstract_Wallet(AddressSynchronizer, ABC):
|
|
"""
|
|
Wallet classes are created to handle various address generation methods.
|
|
Completion states (watching-only, single account, no seed, etc) are handled inside classes.
|
|
"""
|
|
|
|
LOGGING_SHORTCUT = 'w'
|
|
max_change_outputs = 3
|
|
gap_limit_for_change = 10
|
|
|
|
txin_type: str
|
|
wallet_type: str
|
|
lnworker: Optional['LNWallet']
|
|
|
|
def __init__(self, db: WalletDB, storage: Optional[WalletStorage], *, config: SimpleConfig):
|
|
if not db.is_ready_to_be_used_by_wallet():
|
|
raise Exception("storage not ready to be used by Abstract_Wallet")
|
|
|
|
self.config = config
|
|
assert self.config is not None, "config must not be None"
|
|
self.db = db
|
|
self.storage = storage
|
|
# load addresses needs to be called before constructor for sanity checks
|
|
db.load_addresses(self.wallet_type)
|
|
self.keystore = None # type: Optional[KeyStore] # will be set by load_keystore
|
|
AddressSynchronizer.__init__(self, db)
|
|
|
|
# saved fields
|
|
self.use_change = db.get('use_change', True)
|
|
self.multiple_change = db.get('multiple_change', False)
|
|
self._labels = db.get_dict('labels')
|
|
self._frozen_addresses = set(db.get('frozen_addresses', []))
|
|
self._frozen_coins = db.get_dict('frozen_coins') # type: Dict[str, bool]
|
|
self.fiat_value = db.get_dict('fiat_value')
|
|
self.receive_requests = db.get_dict('payment_requests') # type: Dict[str, Invoice]
|
|
self.invoices = db.get_dict('invoices') # type: Dict[str, Invoice]
|
|
self._reserved_addresses = set(db.get('reserved_addresses', []))
|
|
|
|
self._freeze_lock = threading.Lock() # for mutating/iterating frozen_{addresses,coins}
|
|
|
|
self._prepare_onchain_invoice_paid_detection()
|
|
self.calc_unused_change_addresses()
|
|
# save wallet type the first time
|
|
if self.db.get('wallet_type') is None:
|
|
self.db.put('wallet_type', self.wallet_type)
|
|
self.contacts = Contacts(self.db)
|
|
self._coin_price_cache = {}
|
|
|
|
self.lnworker = None
|
|
|
|
def save_db(self):
|
|
if self.storage:
|
|
self.db.write(self.storage)
|
|
|
|
def save_backup(self, backup_dir):
|
|
new_db = WalletDB(self.db.dump(), manual_upgrades=False)
|
|
|
|
if self.lnworker:
|
|
channel_backups = new_db.get_dict('channel_backups')
|
|
for chan_id, chan in self.lnworker.channels.items():
|
|
channel_backups[chan_id.hex()] = self.lnworker.create_channel_backup(chan_id)
|
|
new_db.put('channels', None)
|
|
new_db.put('lightning_xprv', None)
|
|
new_db.put('lightning_privkey2', None)
|
|
|
|
new_path = os.path.join(backup_dir, self.basename() + '.backup')
|
|
new_storage = WalletStorage(new_path)
|
|
new_storage._encryption_version = self.storage._encryption_version
|
|
new_storage.pubkey = self.storage.pubkey
|
|
new_db.set_modified(True)
|
|
new_db.write(new_storage)
|
|
return new_path
|
|
|
|
def has_lightning(self) -> bool:
|
|
return bool(self.lnworker)
|
|
|
|
def can_have_lightning(self) -> bool:
|
|
# we want static_remotekey to be a wallet address
|
|
return self.txin_type == 'p2wpkh'
|
|
|
|
def can_have_deterministic_lightning(self) -> bool:
|
|
if not self.can_have_lightning():
|
|
return False
|
|
if not self.keystore:
|
|
return False
|
|
return self.keystore.can_have_deterministic_lightning_xprv()
|
|
|
|
def init_lightning(self, *, password) -> None:
|
|
assert self.can_have_lightning()
|
|
assert self.db.get('lightning_xprv') is None
|
|
assert self.db.get('lightning_privkey2') is None
|
|
if self.can_have_deterministic_lightning():
|
|
assert isinstance(self.keystore, keystore.BIP32_KeyStore)
|
|
ln_xprv = self.keystore.get_lightning_xprv(password)
|
|
self.db.put('lightning_xprv', ln_xprv)
|
|
else:
|
|
seed = os.urandom(32)
|
|
node = BIP32Node.from_rootseed(seed, xtype='standard')
|
|
ln_xprv = node.to_xprv()
|
|
self.db.put('lightning_privkey2', ln_xprv)
|
|
if self.network:
|
|
self.network.run_from_another_thread(self.stop())
|
|
self.lnworker = LNWallet(self, ln_xprv)
|
|
if self.network:
|
|
self.start_network(self.network)
|
|
|
|
async def stop(self):
|
|
"""Stop all networking and save DB to disk."""
|
|
try:
|
|
async with ignore_after(5):
|
|
await super().stop()
|
|
if self.network:
|
|
if self.lnworker:
|
|
await self.lnworker.stop()
|
|
self.lnworker = None
|
|
finally: # even if we get cancelled
|
|
if any([ks.is_requesting_to_be_rewritten_to_wallet_file for ks in self.get_keystores()]):
|
|
self.save_keystore()
|
|
self.save_db()
|
|
|
|
def set_up_to_date(self, b):
|
|
super().set_up_to_date(b)
|
|
if b: self.save_db()
|
|
|
|
def clear_history(self):
|
|
super().clear_history()
|
|
self.save_db()
|
|
|
|
def start_network(self, network):
|
|
AddressSynchronizer.start_network(self, network)
|
|
if network:
|
|
if self.lnworker:
|
|
self.lnworker.start_network(network)
|
|
# only start gossiping when we already have channels
|
|
if self.db.get('channels'):
|
|
self.network.start_gossip()
|
|
|
|
def load_and_cleanup(self):
|
|
self.load_keystore()
|
|
self.test_addresses_sanity()
|
|
super().load_and_cleanup()
|
|
|
|
@abstractmethod
|
|
def load_keystore(self) -> None:
|
|
pass
|
|
|
|
def diagnostic_name(self):
|
|
return self.basename()
|
|
|
|
def __str__(self):
|
|
return self.basename()
|
|
|
|
def get_master_public_key(self):
|
|
return None
|
|
|
|
def get_master_public_keys(self):
|
|
return []
|
|
|
|
def basename(self) -> str:
|
|
return self.storage.basename() if self.storage else 'no name'
|
|
|
|
def test_addresses_sanity(self) -> None:
|
|
addrs = self.get_receiving_addresses()
|
|
if len(addrs) > 0:
|
|
addr = str(addrs[0])
|
|
if not bitcoin.is_address(addr):
|
|
neutered_addr = addr[:5] + '..' + addr[-2:]
|
|
raise WalletFileException(f'The addresses in this wallet are not bitcoin addresses.\n'
|
|
f'e.g. {neutered_addr} (length: {len(addr)})')
|
|
|
|
def check_returned_address_for_corruption(func):
|
|
def wrapper(self, *args, **kwargs):
|
|
addr = func(self, *args, **kwargs)
|
|
self.check_address_for_corruption(addr)
|
|
return addr
|
|
return wrapper
|
|
|
|
def calc_unused_change_addresses(self) -> Sequence[str]:
|
|
"""Returns a list of change addresses to choose from, for usage in e.g. new transactions.
|
|
The caller should give priority to earlier ones in the list.
|
|
"""
|
|
with self.lock:
|
|
# We want a list of unused change addresses.
|
|
# As a performance optimisation, to avoid checking all addresses every time,
|
|
# we maintain a list of "not old" addresses ("old" addresses have deeply confirmed history),
|
|
# and only check those.
|
|
if not hasattr(self, '_not_old_change_addresses'):
|
|
self._not_old_change_addresses = self.get_change_addresses()
|
|
self._not_old_change_addresses = [addr for addr in self._not_old_change_addresses
|
|
if not self.address_is_old(addr)]
|
|
unused_addrs = [addr for addr in self._not_old_change_addresses
|
|
if not self.is_used(addr) and not self.is_address_reserved(addr)]
|
|
return unused_addrs
|
|
|
|
def is_deterministic(self) -> bool:
|
|
return self.keystore.is_deterministic()
|
|
|
|
def _set_label(self, key: str, value: Optional[str]) -> None:
|
|
with self.lock:
|
|
if value is None:
|
|
self._labels.pop(key, None)
|
|
else:
|
|
self._labels[key] = value
|
|
|
|
def set_label(self, name: str, text: str = None) -> bool:
|
|
if not name:
|
|
return False
|
|
changed = False
|
|
with self.lock:
|
|
old_text = self._labels.get(name)
|
|
if text:
|
|
text = text.replace("\n", " ")
|
|
if old_text != text:
|
|
self._labels[name] = text
|
|
changed = True
|
|
else:
|
|
if old_text is not None:
|
|
self._labels.pop(name)
|
|
changed = True
|
|
if changed:
|
|
run_hook('set_label', self, name, text)
|
|
return changed
|
|
|
|
def import_labels(self, path):
|
|
data = read_json_file(path)
|
|
for key, value in data.items():
|
|
self.set_label(key, value)
|
|
|
|
def export_labels(self, path):
|
|
write_json_file(path, self.get_all_labels())
|
|
|
|
def set_fiat_value(self, txid, ccy, text, fx, value_sat):
|
|
if not self.db.get_transaction(txid):
|
|
return
|
|
# since fx is inserting the thousands separator,
|
|
# and not util, also have fx remove it
|
|
text = fx.remove_thousands_separator(text)
|
|
def_fiat = self.default_fiat_value(txid, fx, value_sat)
|
|
formatted = fx.ccy_amount_str(def_fiat, commas=False)
|
|
def_fiat_rounded = Decimal(formatted)
|
|
reset = not text
|
|
if not reset:
|
|
try:
|
|
text_dec = Decimal(text)
|
|
text_dec_rounded = Decimal(fx.ccy_amount_str(text_dec, commas=False))
|
|
reset = text_dec_rounded == def_fiat_rounded
|
|
except:
|
|
# garbage. not resetting, but not saving either
|
|
return False
|
|
if reset:
|
|
d = self.fiat_value.get(ccy, {})
|
|
if d and txid in d:
|
|
d.pop(txid)
|
|
else:
|
|
# avoid saving empty dict
|
|
return True
|
|
else:
|
|
if ccy not in self.fiat_value:
|
|
self.fiat_value[ccy] = {}
|
|
self.fiat_value[ccy][txid] = text
|
|
return reset
|
|
|
|
def get_fiat_value(self, txid, ccy):
|
|
fiat_value = self.fiat_value.get(ccy, {}).get(txid)
|
|
try:
|
|
return Decimal(fiat_value)
|
|
except:
|
|
return
|
|
|
|
def is_mine(self, address) -> bool:
|
|
if not address: return False
|
|
return bool(self.get_address_index(address))
|
|
|
|
def is_change(self, address) -> bool:
|
|
if not self.is_mine(address):
|
|
return False
|
|
return self.get_address_index(address)[0] == 1
|
|
|
|
@abstractmethod
|
|
def get_address_index(self, address: str) -> Optional[AddressIndexGeneric]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_address_path_str(self, address: str) -> Optional[str]:
|
|
"""Returns derivation path str such as "m/0/5" to address,
|
|
or None if not applicable.
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_redeem_script(self, address: str) -> Optional[str]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_witness_script(self, address: str) -> Optional[str]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_txin_type(self, address: str) -> str:
|
|
"""Return script type of wallet address."""
|
|
pass
|
|
|
|
def export_private_key(self, address: str, password: Optional[str]) -> str:
|
|
if self.is_watching_only():
|
|
raise Exception(_("This is a watching-only wallet"))
|
|
if not is_address(address):
|
|
raise Exception(f"Invalid bitcoin address: {address}")
|
|
if not self.is_mine(address):
|
|
raise Exception(_('Address not in wallet.') + f' {address}')
|
|
index = self.get_address_index(address)
|
|
pk, compressed = self.keystore.get_private_key(index, password)
|
|
txin_type = self.get_txin_type(address)
|
|
serialized_privkey = bitcoin.serialize_privkey(pk, compressed, txin_type)
|
|
return serialized_privkey
|
|
|
|
def export_private_key_for_path(self, path: Union[Sequence[int], str], password: Optional[str]) -> str:
|
|
raise Exception("this wallet is not deterministic")
|
|
|
|
@abstractmethod
|
|
def get_public_keys(self, address: str) -> Sequence[str]:
|
|
pass
|
|
|
|
def get_public_keys_with_deriv_info(self, address: str) -> Dict[bytes, Tuple[KeyStoreWithMPK, Sequence[int]]]:
|
|
"""Returns a map: pubkey -> (keystore, derivation_suffix)"""
|
|
return {}
|
|
|
|
def get_tx_info(self, tx: Transaction) -> TxWalletDetails:
|
|
tx_wallet_delta = self.get_wallet_delta(tx)
|
|
is_relevant = tx_wallet_delta.is_relevant
|
|
is_any_input_ismine = tx_wallet_delta.is_any_input_ismine
|
|
fee = tx_wallet_delta.fee
|
|
exp_n = None
|
|
can_broadcast = False
|
|
can_bump = False
|
|
can_cpfp = False
|
|
tx_hash = tx.txid() # note: txid can be None! e.g. when called from GUI tx dialog
|
|
is_lightning_funding_tx = False
|
|
if self.has_lightning() and tx_hash is not None:
|
|
is_lightning_funding_tx = any([chan.funding_outpoint.txid == tx_hash
|
|
for chan in self.lnworker.channels.values()])
|
|
tx_we_already_have_in_db = self.db.get_transaction(tx_hash)
|
|
can_save_as_local = (is_relevant and tx.txid() is not None
|
|
and (tx_we_already_have_in_db is None or not tx_we_already_have_in_db.is_complete()))
|
|
label = ''
|
|
tx_mined_status = self.get_tx_height(tx_hash)
|
|
can_remove = ((tx_mined_status.height in [TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL])
|
|
# otherwise 'height' is unreliable (typically LOCAL):
|
|
and is_relevant
|
|
# don't offer during common signing flow, e.g. when watch-only wallet starts creating a tx:
|
|
and bool(tx_we_already_have_in_db))
|
|
can_dscancel = False
|
|
if tx.is_complete():
|
|
if tx_we_already_have_in_db:
|
|
label = self.get_label_for_txid(tx_hash)
|
|
if tx_mined_status.height > 0:
|
|
if tx_mined_status.conf:
|
|
status = _("{} confirmations").format(tx_mined_status.conf)
|
|
else:
|
|
status = _('Not verified')
|
|
elif tx_mined_status.height in (TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED):
|
|
status = _('Unconfirmed')
|
|
if fee is None:
|
|
fee = self.get_tx_fee(tx_hash)
|
|
if fee and self.network and self.config.has_fee_mempool():
|
|
size = tx.estimated_size()
|
|
fee_per_byte = fee / size
|
|
exp_n = self.config.fee_to_depth(fee_per_byte)
|
|
can_bump = is_any_input_ismine and not tx.is_final()
|
|
can_dscancel = (is_any_input_ismine and not tx.is_final()
|
|
and not all([self.is_mine(txout.address) for txout in tx.outputs()]))
|
|
try:
|
|
self.cpfp(tx, 0)
|
|
can_cpfp = True
|
|
except:
|
|
can_cpfp = False
|
|
else:
|
|
status = _('Local')
|
|
can_broadcast = self.network is not None
|
|
can_bump = is_any_input_ismine and not tx.is_final()
|
|
else:
|
|
status = _("Signed")
|
|
can_broadcast = self.network is not None
|
|
else:
|
|
assert isinstance(tx, PartialTransaction)
|
|
s, r = tx.signature_count()
|
|
status = _("Unsigned") if s == 0 else _('Partially signed') + ' (%d/%d)'%(s,r)
|
|
|
|
if is_relevant:
|
|
if tx_wallet_delta.is_all_input_ismine:
|
|
assert fee is not None
|
|
amount = tx_wallet_delta.delta + fee
|
|
else:
|
|
amount = tx_wallet_delta.delta
|
|
else:
|
|
amount = None
|
|
|
|
if is_lightning_funding_tx:
|
|
can_bump = False # would change txid
|
|
|
|
return TxWalletDetails(
|
|
txid=tx_hash,
|
|
status=status,
|
|
label=label,
|
|
can_broadcast=can_broadcast,
|
|
can_bump=can_bump,
|
|
can_cpfp=can_cpfp,
|
|
can_dscancel=can_dscancel,
|
|
can_save_as_local=can_save_as_local,
|
|
amount=amount,
|
|
fee=fee,
|
|
tx_mined_status=tx_mined_status,
|
|
mempool_depth_bytes=exp_n,
|
|
can_remove=can_remove,
|
|
is_lightning_funding_tx=is_lightning_funding_tx,
|
|
)
|
|
|
|
def get_spendable_coins(self, domain, *, nonlocal_only=False) -> Sequence[PartialTxInput]:
|
|
confirmed_only = self.config.get('confirmed_only', False)
|
|
with self._freeze_lock:
|
|
frozen_addresses = self._frozen_addresses.copy()
|
|
utxos = self.get_utxos(domain,
|
|
excluded_addresses=frozen_addresses,
|
|
mature_only=True,
|
|
confirmed_funding_only=confirmed_only,
|
|
nonlocal_only=nonlocal_only)
|
|
utxos = [utxo for utxo in utxos if not self.is_frozen_coin(utxo)]
|
|
return utxos
|
|
|
|
@abstractmethod
|
|
def get_receiving_addresses(self, *, slice_start=None, slice_stop=None) -> Sequence[str]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_change_addresses(self, *, slice_start=None, slice_stop=None) -> Sequence[str]:
|
|
pass
|
|
|
|
def dummy_address(self):
|
|
# first receiving address
|
|
return self.get_receiving_addresses(slice_start=0, slice_stop=1)[0]
|
|
|
|
def get_frozen_balance(self):
|
|
with self._freeze_lock:
|
|
frozen_addresses = self._frozen_addresses.copy()
|
|
# note: for coins, use is_frozen_coin instead of _frozen_coins,
|
|
# as latter only contains *manually* frozen ones
|
|
frozen_coins = {utxo.prevout.to_str() for utxo in self.get_utxos()
|
|
if self.is_frozen_coin(utxo)}
|
|
if not frozen_coins: # shortcut
|
|
return self.get_balance(frozen_addresses)
|
|
c1, u1, x1 = self.get_balance()
|
|
c2, u2, x2 = self.get_balance(
|
|
excluded_addresses=frozen_addresses,
|
|
excluded_coins=frozen_coins,
|
|
)
|
|
return c1-c2, u1-u2, x1-x2
|
|
|
|
def balance_at_timestamp(self, domain, target_timestamp):
|
|
# we assume that get_history returns items ordered by block height
|
|
# we also assume that block timestamps are monotonic (which is false...!)
|
|
h = self.get_history(domain=domain)
|
|
balance = 0
|
|
for hist_item in h:
|
|
balance = hist_item.balance
|
|
if hist_item.tx_mined_status.timestamp is None or hist_item.tx_mined_status.timestamp > target_timestamp:
|
|
return balance - hist_item.delta
|
|
# return last balance
|
|
return balance
|
|
|
|
def get_onchain_history(self, *, domain=None):
|
|
monotonic_timestamp = 0
|
|
for hist_item in self.get_history(domain=domain):
|
|
monotonic_timestamp = max(monotonic_timestamp, (hist_item.tx_mined_status.timestamp or 999_999_999_999))
|
|
yield {
|
|
'txid': hist_item.txid,
|
|
'fee_sat': hist_item.fee,
|
|
'height': hist_item.tx_mined_status.height,
|
|
'confirmations': hist_item.tx_mined_status.conf,
|
|
'timestamp': hist_item.tx_mined_status.timestamp,
|
|
'monotonic_timestamp': monotonic_timestamp,
|
|
'incoming': True if hist_item.delta>0 else False,
|
|
'bc_value': Satoshis(hist_item.delta),
|
|
'bc_balance': Satoshis(hist_item.balance),
|
|
'date': timestamp_to_datetime(hist_item.tx_mined_status.timestamp),
|
|
'label': self.get_label_for_txid(hist_item.txid),
|
|
'txpos_in_block': hist_item.tx_mined_status.txpos,
|
|
}
|
|
|
|
def create_invoice(self, *, outputs: List[PartialTxOutput], message, pr, URI) -> Invoice:
|
|
height=self.get_local_height()
|
|
if pr:
|
|
return OnchainInvoice.from_bip70_payreq(pr, height)
|
|
if '!' in (x.value for x in outputs):
|
|
amount = '!'
|
|
else:
|
|
amount = sum(x.value for x in outputs)
|
|
timestamp = None
|
|
exp = None
|
|
if URI:
|
|
timestamp = URI.get('time')
|
|
exp = URI.get('exp')
|
|
timestamp = timestamp or int(time.time())
|
|
exp = exp or 0
|
|
_id = bh2u(sha256d(repr(outputs) + "%d"%timestamp))[0:10]
|
|
invoice = OnchainInvoice(
|
|
type=PR_TYPE_ONCHAIN,
|
|
amount_sat=amount,
|
|
outputs=outputs,
|
|
message=message,
|
|
id=_id,
|
|
time=timestamp,
|
|
exp=exp,
|
|
bip70=None,
|
|
requestor=None,
|
|
height=height,
|
|
)
|
|
return invoice
|
|
|
|
def save_invoice(self, invoice: Invoice) -> None:
|
|
key = self.get_key_for_outgoing_invoice(invoice)
|
|
if not invoice.is_lightning():
|
|
assert isinstance(invoice, OnchainInvoice)
|
|
if self.is_onchain_invoice_paid(invoice, 0):
|
|
self.logger.info("saving invoice... but it is already paid!")
|
|
with self.transaction_lock:
|
|
for txout in invoice.outputs:
|
|
self._invoices_from_scriptpubkey_map[txout.scriptpubkey].add(key)
|
|
self.invoices[key] = invoice
|
|
self.save_db()
|
|
|
|
def clear_invoices(self):
|
|
self.invoices = {}
|
|
self.save_db()
|
|
|
|
def clear_requests(self):
|
|
self.receive_requests = {}
|
|
self.save_db()
|
|
|
|
def get_invoices(self):
|
|
out = list(self.invoices.values())
|
|
out.sort(key=lambda x:x.time)
|
|
return out
|
|
|
|
def get_unpaid_invoices(self):
|
|
invoices = self.get_invoices()
|
|
return [x for x in invoices if self.get_invoice_status(x) != PR_PAID]
|
|
|
|
def get_invoice(self, key):
|
|
return self.invoices.get(key)
|
|
|
|
def import_requests(self, path):
|
|
data = read_json_file(path)
|
|
for x in data:
|
|
req = Invoice.from_json(x)
|
|
self.add_payment_request(req)
|
|
|
|
def export_requests(self, path):
|
|
write_json_file(path, list(self.receive_requests.values()))
|
|
|
|
def import_invoices(self, path):
|
|
data = read_json_file(path)
|
|
for x in data:
|
|
invoice = Invoice.from_json(x)
|
|
self.save_invoice(invoice)
|
|
|
|
def export_invoices(self, path):
|
|
write_json_file(path, list(self.invoices.values()))
|
|
|
|
def _get_relevant_invoice_keys_for_tx(self, tx: Transaction) -> Set[str]:
|
|
relevant_invoice_keys = set()
|
|
with self.transaction_lock:
|
|
for txout in tx.outputs():
|
|
for invoice_key in self._invoices_from_scriptpubkey_map.get(txout.scriptpubkey, set()):
|
|
# note: the invoice might have been deleted since, so check now:
|
|
if invoice_key in self.invoices:
|
|
relevant_invoice_keys.add(invoice_key)
|
|
return relevant_invoice_keys
|
|
|
|
def get_relevant_invoices_for_tx(self, tx: Transaction) -> Sequence[OnchainInvoice]:
|
|
invoice_keys = self._get_relevant_invoice_keys_for_tx(tx)
|
|
invoices = [self.get_invoice(key) for key in invoice_keys]
|
|
invoices = [inv for inv in invoices if inv] # filter out None
|
|
for inv in invoices:
|
|
assert isinstance(inv, OnchainInvoice), f"unexpected type {type(inv)}"
|
|
return invoices
|
|
|
|
def _prepare_onchain_invoice_paid_detection(self):
|
|
# scriptpubkey -> list(invoice_keys)
|
|
self._invoices_from_scriptpubkey_map = defaultdict(set) # type: Dict[bytes, Set[str]]
|
|
for invoice_key, invoice in self.invoices.items():
|
|
if invoice.type == PR_TYPE_ONCHAIN:
|
|
assert isinstance(invoice, OnchainInvoice)
|
|
for txout in invoice.outputs:
|
|
self._invoices_from_scriptpubkey_map[txout.scriptpubkey].add(invoice_key)
|
|
|
|
def _is_onchain_invoice_paid(self, invoice: Invoice, conf: int) -> Tuple[bool, Sequence[str]]:
|
|
"""Returns whether on-chain invoice is satisfied, and list of relevant TXIDs."""
|
|
assert invoice.type == PR_TYPE_ONCHAIN
|
|
assert isinstance(invoice, OnchainInvoice)
|
|
invoice_amounts = defaultdict(int) # type: Dict[bytes, int] # scriptpubkey -> value_sats
|
|
for txo in invoice.outputs: # type: PartialTxOutput
|
|
invoice_amounts[txo.scriptpubkey] += 1 if txo.value == '!' else txo.value
|
|
relevant_txs = []
|
|
with self.transaction_lock:
|
|
for invoice_scriptpubkey, invoice_amt in invoice_amounts.items():
|
|
scripthash = bitcoin.script_to_scripthash(invoice_scriptpubkey.hex())
|
|
prevouts_and_values = self.db.get_prevouts_by_scripthash(scripthash)
|
|
total_received = 0
|
|
for prevout, v in prevouts_and_values:
|
|
tx_height = self.get_tx_height(prevout.txid.hex())
|
|
if tx_height.height > 0 and tx_height.height <= invoice.height:
|
|
continue
|
|
if tx_height.conf < conf:
|
|
continue
|
|
total_received += v
|
|
relevant_txs.append(prevout.txid.hex())
|
|
# check that there is at least one TXO, and that they pay enough.
|
|
# note: "at least one TXO" check is needed for zero amount invoice (e.g. OP_RETURN)
|
|
if len(prevouts_and_values) == 0:
|
|
return False, []
|
|
if total_received < invoice_amt:
|
|
return False, []
|
|
return True, relevant_txs
|
|
|
|
def is_onchain_invoice_paid(self, invoice: Invoice, conf: int) -> bool:
|
|
return self._is_onchain_invoice_paid(invoice, conf)[0]
|
|
|
|
def _maybe_set_tx_label_based_on_invoices(self, tx: Transaction) -> bool:
|
|
# note: this is not done in 'get_default_label' as that would require deserializing each tx
|
|
tx_hash = tx.txid()
|
|
labels = []
|
|
for invoice in self.get_relevant_invoices_for_tx(tx):
|
|
if invoice.message:
|
|
labels.append(invoice.message)
|
|
if labels and not self._labels.get(tx_hash, ''):
|
|
self.set_label(tx_hash, "; ".join(labels))
|
|
return bool(labels)
|
|
|
|
def add_transaction(self, tx, *, allow_unrelated=False):
|
|
is_known = bool(self.db.get_transaction(tx.txid()))
|
|
tx_was_added = super().add_transaction(tx, allow_unrelated=allow_unrelated)
|
|
if tx_was_added and not is_known:
|
|
self._maybe_set_tx_label_based_on_invoices(tx)
|
|
if self.lnworker:
|
|
self.lnworker.maybe_add_backup_from_tx(tx)
|
|
return tx_was_added
|
|
|
|
@profiler
|
|
def get_full_history(self, fx=None, *, onchain_domain=None, include_lightning=True):
|
|
transactions_tmp = OrderedDictWithIndex()
|
|
# add on-chain txns
|
|
onchain_history = self.get_onchain_history(domain=onchain_domain)
|
|
for tx_item in onchain_history:
|
|
txid = tx_item['txid']
|
|
transactions_tmp[txid] = tx_item
|
|
# add lnworker onchain transactions
|
|
lnworker_history = self.lnworker.get_onchain_history() if self.lnworker and include_lightning else {}
|
|
for txid, item in lnworker_history.items():
|
|
if txid in transactions_tmp:
|
|
tx_item = transactions_tmp[txid]
|
|
tx_item['group_id'] = item.get('group_id') # for swaps
|
|
tx_item['label'] = item['label']
|
|
tx_item['type'] = item['type']
|
|
ln_value = Decimal(item['amount_msat']) / 1000 # for channel open/close tx
|
|
tx_item['ln_value'] = Satoshis(ln_value)
|
|
else:
|
|
if item['type'] == 'swap':
|
|
# swap items do not have all the fields. We can skip skip them
|
|
# because they will eventually be in onchain_history
|
|
# TODO: use attr.s objects instead of dicts
|
|
continue
|
|
transactions_tmp[txid] = item
|
|
ln_value = Decimal(item['amount_msat']) / 1000 # for channel open/close tx
|
|
item['ln_value'] = Satoshis(ln_value)
|
|
# add lightning_transactions
|
|
lightning_history = self.lnworker.get_lightning_history() if self.lnworker and include_lightning else {}
|
|
for tx_item in lightning_history.values():
|
|
txid = tx_item.get('txid')
|
|
ln_value = Decimal(tx_item['amount_msat']) / 1000
|
|
tx_item['lightning'] = True
|
|
tx_item['ln_value'] = Satoshis(ln_value)
|
|
key = tx_item.get('txid') or tx_item['payment_hash']
|
|
transactions_tmp[key] = tx_item
|
|
# sort on-chain and LN stuff into new dict, by timestamp
|
|
# (we rely on this being a *stable* sort)
|
|
transactions = OrderedDictWithIndex()
|
|
for k, v in sorted(list(transactions_tmp.items()),
|
|
key=lambda x: x[1].get('monotonic_timestamp') or x[1].get('timestamp') or float('inf')):
|
|
transactions[k] = v
|
|
now = time.time()
|
|
balance = 0
|
|
for item in transactions.values():
|
|
# add on-chain and lightning values
|
|
value = Decimal(0)
|
|
if item.get('bc_value'):
|
|
value += item['bc_value'].value
|
|
if item.get('ln_value'):
|
|
value += item.get('ln_value').value
|
|
# note: 'value' and 'balance' has msat precision (as LN has msat precision)
|
|
item['value'] = Satoshis(value)
|
|
balance += value
|
|
item['balance'] = Satoshis(balance)
|
|
if fx and fx.is_enabled() and fx.get_history_config():
|
|
txid = item.get('txid')
|
|
if not item.get('lightning') and txid:
|
|
fiat_fields = self.get_tx_item_fiat(tx_hash=txid, amount_sat=value, fx=fx, tx_fee=item['fee_sat'])
|
|
item.update(fiat_fields)
|
|
else:
|
|
timestamp = item['timestamp'] or now
|
|
fiat_value = value / Decimal(bitcoin.COIN) * fx.timestamp_rate(timestamp)
|
|
item['fiat_value'] = Fiat(fiat_value, fx.ccy)
|
|
item['fiat_default'] = True
|
|
return transactions
|
|
|
|
@profiler
|
|
def get_detailed_history(
|
|
self,
|
|
from_timestamp=None,
|
|
to_timestamp=None,
|
|
fx=None,
|
|
show_addresses=False,
|
|
from_height=None,
|
|
to_height=None):
|
|
# History with capital gains, using utxo pricing
|
|
# FIXME: Lightning capital gains would requires FIFO
|
|
if (from_timestamp is not None or to_timestamp is not None) \
|
|
and (from_height is not None or to_height is not None):
|
|
raise Exception('timestamp and block height based filtering cannot be used together')
|
|
|
|
show_fiat = fx and fx.is_enabled() and fx.get_history_config()
|
|
out = []
|
|
income = 0
|
|
expenditures = 0
|
|
capital_gains = Decimal(0)
|
|
fiat_income = Decimal(0)
|
|
fiat_expenditures = Decimal(0)
|
|
now = time.time()
|
|
for item in self.get_onchain_history():
|
|
timestamp = item['timestamp']
|
|
if from_timestamp and (timestamp or now) < from_timestamp:
|
|
continue
|
|
if to_timestamp and (timestamp or now) >= to_timestamp:
|
|
continue
|
|
height = item['height']
|
|
if from_height is not None and from_height > height > 0:
|
|
continue
|
|
if to_height is not None and (height >= to_height or height <= 0):
|
|
continue
|
|
tx_hash = item['txid']
|
|
tx = self.db.get_transaction(tx_hash)
|
|
tx_fee = item['fee_sat']
|
|
item['fee'] = Satoshis(tx_fee) if tx_fee is not None else None
|
|
if show_addresses:
|
|
item['inputs'] = list(map(lambda x: x.to_json(), tx.inputs()))
|
|
item['outputs'] = list(map(lambda x: {'address': x.get_ui_address_str(), 'value': Satoshis(x.value)},
|
|
tx.outputs()))
|
|
# fixme: use in and out values
|
|
value = item['bc_value'].value
|
|
if value < 0:
|
|
expenditures += -value
|
|
else:
|
|
income += value
|
|
# fiat computations
|
|
if show_fiat:
|
|
fiat_fields = self.get_tx_item_fiat(tx_hash=tx_hash, amount_sat=value, fx=fx, tx_fee=tx_fee)
|
|
fiat_value = fiat_fields['fiat_value'].value
|
|
item.update(fiat_fields)
|
|
if value < 0:
|
|
capital_gains += fiat_fields['capital_gain'].value
|
|
fiat_expenditures += -fiat_value
|
|
else:
|
|
fiat_income += fiat_value
|
|
out.append(item)
|
|
# add summary
|
|
if out:
|
|
first_item = out[0]
|
|
last_item = out[-1]
|
|
if from_height or to_height:
|
|
start_height = from_height
|
|
end_height = to_height
|
|
else:
|
|
start_height = first_item['height'] - 1
|
|
end_height = last_item['height']
|
|
|
|
b = first_item['bc_balance'].value
|
|
v = first_item['bc_value'].value
|
|
start_balance = None if b is None or v is None else b - v
|
|
end_balance = last_item['bc_balance'].value
|
|
|
|
if from_timestamp is not None and to_timestamp is not None:
|
|
start_timestamp = from_timestamp
|
|
end_timestamp = to_timestamp
|
|
else:
|
|
start_timestamp = first_item['timestamp']
|
|
end_timestamp = last_item['timestamp']
|
|
|
|
start_coins = self.get_utxos(
|
|
domain=None,
|
|
block_height=start_height,
|
|
confirmed_funding_only=True,
|
|
confirmed_spending_only=True,
|
|
nonlocal_only=True)
|
|
end_coins = self.get_utxos(
|
|
domain=None,
|
|
block_height=end_height,
|
|
confirmed_funding_only=True,
|
|
confirmed_spending_only=True,
|
|
nonlocal_only=True)
|
|
|
|
def summary_point(timestamp, height, balance, coins):
|
|
date = timestamp_to_datetime(timestamp)
|
|
out = {
|
|
'date': date,
|
|
'block_height': height,
|
|
'BTC_balance': Satoshis(balance),
|
|
}
|
|
if show_fiat:
|
|
ap = self.acquisition_price(coins, fx.timestamp_rate, fx.ccy)
|
|
lp = self.liquidation_price(coins, fx.timestamp_rate, timestamp)
|
|
out['acquisition_price'] = Fiat(ap, fx.ccy)
|
|
out['liquidation_price'] = Fiat(lp, fx.ccy)
|
|
out['unrealized_gains'] = Fiat(lp - ap, fx.ccy)
|
|
out['fiat_balance'] = Fiat(fx.historical_value(balance, date), fx.ccy)
|
|
out['BTC_fiat_price'] = Fiat(fx.historical_value(COIN, date), fx.ccy)
|
|
return out
|
|
|
|
summary_start = summary_point(start_timestamp, start_height, start_balance, start_coins)
|
|
summary_end = summary_point(end_timestamp, end_height, end_balance, end_coins)
|
|
flow = {
|
|
'BTC_incoming': Satoshis(income),
|
|
'BTC_outgoing': Satoshis(expenditures)
|
|
}
|
|
if show_fiat:
|
|
flow['fiat_currency'] = fx.ccy
|
|
flow['fiat_incoming'] = Fiat(fiat_income, fx.ccy)
|
|
flow['fiat_outgoing'] = Fiat(fiat_expenditures, fx.ccy)
|
|
flow['realized_capital_gains'] = Fiat(capital_gains, fx.ccy)
|
|
summary = {
|
|
'begin': summary_start,
|
|
'end': summary_end,
|
|
'flow': flow,
|
|
}
|
|
|
|
else:
|
|
summary = {}
|
|
return {
|
|
'transactions': out,
|
|
'summary': summary
|
|
}
|
|
|
|
def acquisition_price(self, coins, price_func, ccy):
|
|
return Decimal(sum(self.coin_price(coin.prevout.txid.hex(), price_func, ccy, self.get_txin_value(coin)) for coin in coins))
|
|
|
|
def liquidation_price(self, coins, price_func, timestamp):
|
|
p = price_func(timestamp)
|
|
return sum([coin.value_sats() for coin in coins]) * p / Decimal(COIN)
|
|
|
|
def default_fiat_value(self, tx_hash, fx, value_sat):
|
|
return value_sat / Decimal(COIN) * self.price_at_timestamp(tx_hash, fx.timestamp_rate)
|
|
|
|
def get_tx_item_fiat(
|
|
self,
|
|
*,
|
|
tx_hash: str,
|
|
amount_sat: int,
|
|
fx: 'FxThread',
|
|
tx_fee: Optional[int],
|
|
) -> Dict[str, Any]:
|
|
item = {}
|
|
fiat_value = self.get_fiat_value(tx_hash, fx.ccy)
|
|
fiat_default = fiat_value is None
|
|
fiat_rate = self.price_at_timestamp(tx_hash, fx.timestamp_rate)
|
|
fiat_value = fiat_value if fiat_value is not None else self.default_fiat_value(tx_hash, fx, amount_sat)
|
|
fiat_fee = tx_fee / Decimal(COIN) * fiat_rate if tx_fee is not None else None
|
|
item['fiat_currency'] = fx.ccy
|
|
item['fiat_rate'] = Fiat(fiat_rate, fx.ccy)
|
|
item['fiat_value'] = Fiat(fiat_value, fx.ccy)
|
|
item['fiat_fee'] = Fiat(fiat_fee, fx.ccy) if fiat_fee is not None else None
|
|
item['fiat_default'] = fiat_default
|
|
if amount_sat < 0:
|
|
acquisition_price = - amount_sat / Decimal(COIN) * self.average_price(tx_hash, fx.timestamp_rate, fx.ccy)
|
|
liquidation_price = - fiat_value
|
|
item['acquisition_price'] = Fiat(acquisition_price, fx.ccy)
|
|
cg = liquidation_price - acquisition_price
|
|
item['capital_gain'] = Fiat(cg, fx.ccy)
|
|
return item
|
|
|
|
def get_label(self, key: str) -> str:
|
|
# key is typically: address / txid / LN-payment-hash-hex
|
|
return self._labels.get(key) or ''
|
|
|
|
def get_label_for_txid(self, tx_hash: str) -> str:
|
|
return self._labels.get(tx_hash) or self._get_default_label_for_txid(tx_hash)
|
|
|
|
def _get_default_label_for_txid(self, tx_hash: str) -> str:
|
|
# if no inputs are ismine, concat labels of output addresses
|
|
if not self.db.get_txi_addresses(tx_hash):
|
|
labels = []
|
|
for addr in self.db.get_txo_addresses(tx_hash):
|
|
label = self._labels.get(addr)
|
|
if label:
|
|
labels.append(label)
|
|
return ', '.join(labels)
|
|
return ''
|
|
|
|
def get_all_labels(self) -> Dict[str, str]:
|
|
with self.lock:
|
|
return copy.copy(self._labels)
|
|
|
|
def get_tx_status(self, tx_hash, tx_mined_info: TxMinedInfo):
|
|
extra = []
|
|
height = tx_mined_info.height
|
|
conf = tx_mined_info.conf
|
|
timestamp = tx_mined_info.timestamp
|
|
if height == TX_HEIGHT_FUTURE:
|
|
assert conf < 0, conf
|
|
num_blocks_remainining = -conf
|
|
return 2, f'in {num_blocks_remainining} blocks'
|
|
if conf == 0:
|
|
tx = self.db.get_transaction(tx_hash)
|
|
if not tx:
|
|
return 2, 'unknown'
|
|
is_final = tx and tx.is_final()
|
|
if not is_final:
|
|
extra.append('rbf')
|
|
fee = self.get_tx_fee(tx_hash)
|
|
if fee is not None:
|
|
size = tx.estimated_size()
|
|
fee_per_byte = fee / size
|
|
extra.append(format_fee_satoshis(fee_per_byte) + ' sat/b')
|
|
if fee is not None and height in (TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED) \
|
|
and self.config.has_fee_mempool():
|
|
exp_n = self.config.fee_to_depth(fee_per_byte)
|
|
if exp_n is not None:
|
|
extra.append('%.2f MB'%(exp_n/1000000))
|
|
if height == TX_HEIGHT_LOCAL:
|
|
status = 3
|
|
elif height == TX_HEIGHT_UNCONF_PARENT:
|
|
status = 1
|
|
elif height == TX_HEIGHT_UNCONFIRMED:
|
|
status = 0
|
|
else:
|
|
status = 2 # not SPV verified
|
|
else:
|
|
status = 3 + min(conf, 6)
|
|
time_str = format_time(timestamp) if timestamp else _("unknown")
|
|
status_str = TX_STATUS[status] if status < 4 else time_str
|
|
if extra:
|
|
status_str += ' [%s]'%(', '.join(extra))
|
|
return status, status_str
|
|
|
|
def relayfee(self):
|
|
return relayfee(self.network)
|
|
|
|
def dust_threshold(self):
|
|
return dust_threshold(self.network)
|
|
|
|
def get_unconfirmed_base_tx_for_batching(self) -> Optional[Transaction]:
|
|
candidate = None
|
|
for hist_item in self.get_history():
|
|
# tx should not be mined yet
|
|
if hist_item.tx_mined_status.conf > 0: continue
|
|
# conservative future proofing of code: only allow known unconfirmed types
|
|
if hist_item.tx_mined_status.height not in (TX_HEIGHT_UNCONFIRMED,
|
|
TX_HEIGHT_UNCONF_PARENT,
|
|
TX_HEIGHT_LOCAL):
|
|
continue
|
|
# tx should be "outgoing" from wallet
|
|
if hist_item.delta >= 0:
|
|
continue
|
|
tx = self.db.get_transaction(hist_item.txid)
|
|
if not tx:
|
|
continue
|
|
# is_mine outputs should not be spent yet
|
|
# to avoid cancelling our own dependent transactions
|
|
txid = tx.txid()
|
|
if any([self.is_mine(o.address) and self.db.get_spent_outpoint(txid, output_idx)
|
|
for output_idx, o in enumerate(tx.outputs())]):
|
|
continue
|
|
# all inputs should be is_mine
|
|
if not all([self.is_mine(self.get_txin_address(txin)) for txin in tx.inputs()]):
|
|
continue
|
|
# prefer txns already in mempool (vs local)
|
|
if hist_item.tx_mined_status.height == TX_HEIGHT_LOCAL:
|
|
candidate = tx
|
|
continue
|
|
# tx must have opted-in for RBF
|
|
if tx.is_final(): continue
|
|
return tx
|
|
return candidate
|
|
|
|
def get_change_addresses_for_new_transaction(
|
|
self, preferred_change_addr=None, *, allow_reuse: bool = True,
|
|
) -> List[str]:
|
|
change_addrs = []
|
|
if preferred_change_addr:
|
|
if isinstance(preferred_change_addr, (list, tuple)):
|
|
change_addrs = list(preferred_change_addr)
|
|
else:
|
|
change_addrs = [preferred_change_addr]
|
|
elif self.use_change:
|
|
# Recalc and get unused change addresses
|
|
addrs = self.calc_unused_change_addresses()
|
|
# New change addresses are created only after a few
|
|
# confirmations.
|
|
if addrs:
|
|
# if there are any unused, select all
|
|
change_addrs = addrs
|
|
else:
|
|
# if there are none, take one randomly from the last few
|
|
if not allow_reuse:
|
|
return []
|
|
addrs = self.get_change_addresses(slice_start=-self.gap_limit_for_change)
|
|
change_addrs = [random.choice(addrs)] if addrs else []
|
|
for addr in change_addrs:
|
|
assert is_address(addr), f"not valid bitcoin address: {addr}"
|
|
# note that change addresses are not necessarily ismine
|
|
# in which case this is a no-op
|
|
self.check_address_for_corruption(addr)
|
|
max_change = self.max_change_outputs if self.multiple_change else 1
|
|
return change_addrs[:max_change]
|
|
|
|
def get_single_change_address_for_new_transaction(
|
|
self, preferred_change_addr=None, *, allow_reuse: bool = True,
|
|
) -> Optional[str]:
|
|
addrs = self.get_change_addresses_for_new_transaction(
|
|
preferred_change_addr=preferred_change_addr,
|
|
allow_reuse=allow_reuse,
|
|
)
|
|
if addrs:
|
|
return addrs[0]
|
|
return None
|
|
|
|
@check_returned_address_for_corruption
|
|
def get_new_sweep_address_for_channel(self) -> str:
|
|
# Recalc and get unused change addresses
|
|
addrs = self.calc_unused_change_addresses()
|
|
if addrs:
|
|
selected_addr = addrs[0]
|
|
else:
|
|
# if there are none, take one randomly from the last few
|
|
addrs = self.get_change_addresses(slice_start=-self.gap_limit_for_change)
|
|
if addrs:
|
|
selected_addr = random.choice(addrs)
|
|
else: # fallback for e.g. imported wallets
|
|
selected_addr = self.get_receiving_address()
|
|
assert is_address(selected_addr), f"not valid bitcoin address: {selected_addr}"
|
|
return selected_addr
|
|
|
|
def make_unsigned_transaction(
|
|
self, *,
|
|
coins: Sequence[PartialTxInput],
|
|
outputs: List[PartialTxOutput],
|
|
fee=None,
|
|
change_addr: str = None,
|
|
is_sweep=False,
|
|
rbf=False) -> PartialTransaction:
|
|
|
|
if not coins: # any bitcoin tx must have at least 1 input by consensus
|
|
raise NotEnoughFunds()
|
|
if any([c.already_has_some_signatures() for c in coins]):
|
|
raise Exception("Some inputs already contain signatures!")
|
|
|
|
# prevent side-effect with '!'
|
|
outputs = copy.deepcopy(outputs)
|
|
|
|
# check outputs
|
|
i_max = None
|
|
for i, o in enumerate(outputs):
|
|
if o.value == '!':
|
|
if i_max is not None:
|
|
raise MultipleSpendMaxTxOutputs()
|
|
i_max = i
|
|
|
|
if fee is None and self.config.fee_per_kb() is None:
|
|
raise NoDynamicFeeEstimates()
|
|
|
|
for item in coins:
|
|
self.add_input_info(item)
|
|
|
|
# Fee estimator
|
|
if fee is None:
|
|
fee_estimator = self.config.estimate_fee
|
|
elif isinstance(fee, Number):
|
|
fee_estimator = lambda size: fee
|
|
elif callable(fee):
|
|
fee_estimator = fee
|
|
else:
|
|
raise Exception(f'Invalid argument fee: {fee}')
|
|
|
|
if i_max is None:
|
|
# Let the coin chooser select the coins to spend
|
|
coin_chooser = coinchooser.get_coin_chooser(self.config)
|
|
# If there is an unconfirmed RBF tx, merge with it
|
|
base_tx = self.get_unconfirmed_base_tx_for_batching()
|
|
if self.config.get('batch_rbf', False) and base_tx:
|
|
# make sure we don't try to spend change from the tx-to-be-replaced:
|
|
coins = [c for c in coins if c.prevout.txid.hex() != base_tx.txid()]
|
|
is_local = self.get_tx_height(base_tx.txid()).height == TX_HEIGHT_LOCAL
|
|
base_tx = PartialTransaction.from_tx(base_tx)
|
|
base_tx.add_info_from_wallet(self)
|
|
base_tx_fee = base_tx.get_fee()
|
|
relayfeerate = Decimal(self.relayfee()) / 1000
|
|
original_fee_estimator = fee_estimator
|
|
def fee_estimator(size: Union[int, float, Decimal]) -> int:
|
|
size = Decimal(size)
|
|
lower_bound = base_tx_fee + round(size * relayfeerate)
|
|
lower_bound = lower_bound if not is_local else 0
|
|
return int(max(lower_bound, original_fee_estimator(size)))
|
|
txi = base_tx.inputs()
|
|
txo = list(filter(lambda o: not self.is_change(o.address), base_tx.outputs()))
|
|
old_change_addrs = [o.address for o in base_tx.outputs() if self.is_change(o.address)]
|
|
else:
|
|
txi = []
|
|
txo = []
|
|
old_change_addrs = []
|
|
# change address. if empty, coin_chooser will set it
|
|
change_addrs = self.get_change_addresses_for_new_transaction(change_addr or old_change_addrs)
|
|
tx = coin_chooser.make_tx(
|
|
coins=coins,
|
|
inputs=txi,
|
|
outputs=list(outputs) + txo,
|
|
change_addrs=change_addrs,
|
|
fee_estimator_vb=fee_estimator,
|
|
dust_threshold=self.dust_threshold())
|
|
else:
|
|
# "spend max" branch
|
|
# note: This *will* spend inputs with negative effective value (if there are any).
|
|
# Given as the user is spending "max", and so might be abandoning the wallet,
|
|
# try to include all UTXOs, otherwise leftover might remain in the UTXO set
|
|
# forever. see #5433
|
|
# note: Actually it might be the case that not all UTXOs from the wallet are
|
|
# being spent if the user manually selected UTXOs.
|
|
sendable = sum(map(lambda c: c.value_sats(), coins))
|
|
outputs[i_max].value = 0
|
|
tx = PartialTransaction.from_io(list(coins), list(outputs))
|
|
fee = fee_estimator(tx.estimated_size())
|
|
amount = sendable - tx.output_value() - fee
|
|
if amount < 0:
|
|
raise NotEnoughFunds()
|
|
outputs[i_max].value = amount
|
|
tx = PartialTransaction.from_io(list(coins), list(outputs))
|
|
|
|
# Timelock tx to current height.
|
|
tx.locktime = get_locktime_for_new_transaction(self.network)
|
|
|
|
tx.set_rbf(rbf)
|
|
tx.add_info_from_wallet(self)
|
|
run_hook('make_unsigned_transaction', self, tx)
|
|
return tx
|
|
|
|
def mktx(self, *,
|
|
outputs: List[PartialTxOutput],
|
|
password=None, fee=None, change_addr=None,
|
|
domain=None, rbf=False, nonlocal_only=False,
|
|
tx_version=None, sign=True) -> PartialTransaction:
|
|
coins = self.get_spendable_coins(domain, nonlocal_only=nonlocal_only)
|
|
tx = self.make_unsigned_transaction(
|
|
coins=coins,
|
|
outputs=outputs,
|
|
fee=fee,
|
|
change_addr=change_addr,
|
|
rbf=rbf)
|
|
if tx_version is not None:
|
|
tx.version = tx_version
|
|
if sign:
|
|
self.sign_transaction(tx, password)
|
|
return tx
|
|
|
|
def is_frozen_address(self, addr: str) -> bool:
|
|
return addr in self._frozen_addresses
|
|
|
|
def is_frozen_coin(self, utxo: PartialTxInput) -> bool:
|
|
prevout_str = utxo.prevout.to_str()
|
|
frozen = self._frozen_coins.get(prevout_str, None)
|
|
# note: there are three possible states for 'frozen':
|
|
# True/False if the user explicitly set it,
|
|
# None otherwise
|
|
if frozen is None:
|
|
return self._is_coin_small_and_unconfirmed(utxo)
|
|
return bool(frozen)
|
|
|
|
def _is_coin_small_and_unconfirmed(self, utxo: PartialTxInput) -> bool:
|
|
"""If true, the coin should not be spent.
|
|
The idea here is that an attacker might send us a UTXO in a
|
|
large low-fee unconfirmed tx that will ~never confirm. If we
|
|
spend it as part of a tx ourselves, that too will not confirm
|
|
(unless we use a high fee but that might not be worth it for
|
|
a small value UTXO).
|
|
In particular, this test triggers for large "dusting transactions"
|
|
that are used for advertising purposes by some entities.
|
|
see #6960
|
|
"""
|
|
# confirmed UTXOs are fine; check this first for performance:
|
|
block_height = utxo.block_height
|
|
assert block_height is not None
|
|
if block_height > 0:
|
|
return False
|
|
# exempt large value UTXOs
|
|
value_sats = utxo.value_sats()
|
|
assert value_sats is not None
|
|
threshold = self.config.get('unconf_utxo_freeze_threshold', 5_000)
|
|
if value_sats >= threshold:
|
|
return False
|
|
# if funding tx has any is_mine input, then UTXO is fine
|
|
funding_tx = self.db.get_transaction(utxo.prevout.txid.hex())
|
|
if funding_tx is None:
|
|
# we should typically have the funding tx available;
|
|
# might not have it e.g. while not up_to_date
|
|
return True
|
|
if any(self.is_mine(self.get_txin_address(txin))
|
|
for txin in funding_tx.inputs()):
|
|
return False
|
|
return True
|
|
|
|
def set_frozen_state_of_addresses(self, addrs: Sequence[str], freeze: bool) -> bool:
|
|
"""Set frozen state of the addresses to FREEZE, True or False"""
|
|
if all(self.is_mine(addr) for addr in addrs):
|
|
with self._freeze_lock:
|
|
if freeze:
|
|
self._frozen_addresses |= set(addrs)
|
|
else:
|
|
self._frozen_addresses -= set(addrs)
|
|
self.db.put('frozen_addresses', list(self._frozen_addresses))
|
|
return True
|
|
return False
|
|
|
|
def set_frozen_state_of_coins(self, utxos: Sequence[str], freeze: bool) -> None:
|
|
"""Set frozen state of the utxos to FREEZE, True or False"""
|
|
# basic sanity check that input is not garbage: (see if raises)
|
|
[TxOutpoint.from_str(utxo) for utxo in utxos]
|
|
with self._freeze_lock:
|
|
for utxo in utxos:
|
|
self._frozen_coins[utxo] = bool(freeze)
|
|
|
|
def is_address_reserved(self, addr: str) -> bool:
|
|
# note: atm 'reserved' status is only taken into consideration for 'change addresses'
|
|
return addr in self._reserved_addresses
|
|
|
|
def set_reserved_state_of_address(self, addr: str, *, reserved: bool) -> None:
|
|
if not self.is_mine(addr):
|
|
return
|
|
with self.lock:
|
|
if reserved:
|
|
self._reserved_addresses.add(addr)
|
|
else:
|
|
self._reserved_addresses.discard(addr)
|
|
self.db.put('reserved_addresses', list(self._reserved_addresses))
|
|
|
|
def can_export(self):
|
|
return not self.is_watching_only() and hasattr(self.keystore, 'get_private_key')
|
|
|
|
def address_is_old(self, address: str, *, req_conf: int = 3) -> bool:
|
|
"""Returns whether address has any history that is deeply confirmed.
|
|
Used for reorg-safe(ish) gap limit roll-forward.
|
|
"""
|
|
max_conf = -1
|
|
h = self.db.get_addr_history(address)
|
|
needs_spv_check = not self.config.get("skipmerklecheck", False)
|
|
for tx_hash, tx_height in h:
|
|
if needs_spv_check:
|
|
tx_age = self.get_tx_height(tx_hash).conf
|
|
else:
|
|
if tx_height <= 0:
|
|
tx_age = 0
|
|
else:
|
|
tx_age = self.get_local_height() - tx_height + 1
|
|
max_conf = max(max_conf, tx_age)
|
|
return max_conf >= req_conf
|
|
|
|
def bump_fee(
|
|
self,
|
|
*,
|
|
tx: Transaction,
|
|
txid: str = None,
|
|
new_fee_rate: Union[int, float, Decimal],
|
|
coins: Sequence[PartialTxInput] = None,
|
|
strategies: Sequence[BumpFeeStrategy] = None,
|
|
) -> PartialTransaction:
|
|
"""Increase the miner fee of 'tx'.
|
|
'new_fee_rate' is the target min rate in sat/vbyte
|
|
'coins' is a list of UTXOs we can choose from as potential new inputs to be added
|
|
"""
|
|
txid = txid or tx.txid()
|
|
assert txid
|
|
assert tx.txid() in (None, txid)
|
|
if not isinstance(tx, PartialTransaction):
|
|
tx = PartialTransaction.from_tx(tx)
|
|
assert isinstance(tx, PartialTransaction)
|
|
tx.remove_signatures()
|
|
if tx.is_final():
|
|
raise CannotBumpFee(_('Transaction is final'))
|
|
new_fee_rate = quantize_feerate(new_fee_rate) # strip excess precision
|
|
try:
|
|
# note: this might download input utxos over network
|
|
tx.add_info_from_wallet(self, ignore_network_issues=False)
|
|
except NetworkException as e:
|
|
raise CannotBumpFee(repr(e))
|
|
old_tx_size = tx.estimated_size()
|
|
old_fee = tx.get_fee()
|
|
assert old_fee is not None
|
|
old_fee_rate = old_fee / old_tx_size # sat/vbyte
|
|
if new_fee_rate <= old_fee_rate:
|
|
raise CannotBumpFee(_("The new fee rate needs to be higher than the old fee rate."))
|
|
|
|
if not strategies:
|
|
strategies = [BumpFeeStrategy.COINCHOOSER, BumpFeeStrategy.DECREASE_CHANGE]
|
|
tx_new = None
|
|
exc = None
|
|
for strat in strategies:
|
|
try:
|
|
if strat == BumpFeeStrategy.COINCHOOSER:
|
|
tx_new = self._bump_fee_through_coinchooser(
|
|
tx=tx,
|
|
txid=txid,
|
|
new_fee_rate=new_fee_rate,
|
|
coins=coins,
|
|
)
|
|
elif strat == BumpFeeStrategy.DECREASE_CHANGE:
|
|
tx_new = self._bump_fee_through_decreasing_change(
|
|
tx=tx, new_fee_rate=new_fee_rate)
|
|
elif strat == BumpFeeStrategy.DECREASE_PAYMENT:
|
|
tx_new = self._bump_fee_through_decreasing_payment(
|
|
tx=tx, new_fee_rate=new_fee_rate)
|
|
else:
|
|
raise NotImplementedError(f"unexpected strategy: {strat}")
|
|
except CannotBumpFee as e:
|
|
exc = e
|
|
else:
|
|
strat_used = strat
|
|
break
|
|
if tx_new is None:
|
|
assert exc
|
|
raise exc # all strategies failed, re-raise last exception
|
|
|
|
target_min_fee = new_fee_rate * tx_new.estimated_size()
|
|
actual_fee = tx_new.get_fee()
|
|
if actual_fee + 1 < target_min_fee:
|
|
raise CannotBumpFee(
|
|
f"bump_fee fee target was not met (strategy: {strat_used}). "
|
|
f"got {actual_fee}, expected >={target_min_fee}. "
|
|
f"target rate was {new_fee_rate}")
|
|
tx_new.locktime = get_locktime_for_new_transaction(self.network)
|
|
tx_new.set_rbf(True)
|
|
tx_new.add_info_from_wallet(self)
|
|
return tx_new
|
|
|
|
def _bump_fee_through_coinchooser(
|
|
self,
|
|
*,
|
|
tx: PartialTransaction,
|
|
txid: str,
|
|
new_fee_rate: Union[int, Decimal],
|
|
coins: Sequence[PartialTxInput] = None,
|
|
) -> PartialTransaction:
|
|
"""Increase the miner fee of 'tx'.
|
|
|
|
- keeps all inputs
|
|
- keeps all not is_mine outputs,
|
|
- allows adding new inputs
|
|
"""
|
|
assert txid
|
|
tx = copy.deepcopy(tx)
|
|
tx.add_info_from_wallet(self)
|
|
assert tx.get_fee() is not None
|
|
old_inputs = list(tx.inputs())
|
|
old_outputs = list(tx.outputs())
|
|
# change address
|
|
old_change_addrs = [o.address for o in old_outputs if self.is_change(o.address)]
|
|
change_addrs = self.get_change_addresses_for_new_transaction(old_change_addrs)
|
|
# which outputs to keep?
|
|
if old_change_addrs:
|
|
fixed_outputs = list(filter(lambda o: not self.is_change(o.address), old_outputs))
|
|
else:
|
|
if all(self.is_mine(o.address) for o in old_outputs):
|
|
# all outputs are is_mine and none of them are change.
|
|
# we bail out as it's unclear what the user would want!
|
|
# the coinchooser bump fee method is probably not a good idea in this case
|
|
raise CannotBumpFee(_('All outputs are non-change is_mine'))
|
|
old_not_is_mine = list(filter(lambda o: not self.is_mine(o.address), old_outputs))
|
|
if old_not_is_mine:
|
|
fixed_outputs = old_not_is_mine
|
|
else:
|
|
fixed_outputs = old_outputs
|
|
if not fixed_outputs:
|
|
raise CannotBumpFee(_('Could not figure out which outputs to keep'))
|
|
|
|
if coins is None:
|
|
coins = self.get_spendable_coins(None)
|
|
# make sure we don't try to spend output from the tx-to-be-replaced:
|
|
coins = [c for c in coins if c.prevout.txid.hex() != txid]
|
|
for item in coins:
|
|
self.add_input_info(item)
|
|
def fee_estimator(size):
|
|
return self.config.estimate_fee_for_feerate(fee_per_kb=new_fee_rate*1000, size=size)
|
|
coin_chooser = coinchooser.get_coin_chooser(self.config)
|
|
try:
|
|
return coin_chooser.make_tx(
|
|
coins=coins,
|
|
inputs=old_inputs,
|
|
outputs=fixed_outputs,
|
|
change_addrs=change_addrs,
|
|
fee_estimator_vb=fee_estimator,
|
|
dust_threshold=self.dust_threshold())
|
|
except NotEnoughFunds as e:
|
|
raise CannotBumpFee(e)
|
|
|
|
def _bump_fee_through_decreasing_change(
|
|
self,
|
|
*,
|
|
tx: PartialTransaction,
|
|
new_fee_rate: Union[int, Decimal],
|
|
) -> PartialTransaction:
|
|
"""Increase the miner fee of 'tx'.
|
|
|
|
- keeps all inputs
|
|
- no new inputs are added
|
|
- allows decreasing and removing outputs (change is decreased first)
|
|
This is less "safe" than "coinchooser" method as it might end up decreasing
|
|
e.g. a payment to a merchant; but e.g. if the user has sent "Max" previously,
|
|
this is the only way to RBF.
|
|
"""
|
|
tx = copy.deepcopy(tx)
|
|
tx.add_info_from_wallet(self)
|
|
assert tx.get_fee() is not None
|
|
inputs = tx.inputs()
|
|
outputs = tx._outputs # note: we will mutate this directly
|
|
|
|
# use own outputs
|
|
s = list(filter(lambda o: self.is_mine(o.address), outputs))
|
|
# ... unless there is none
|
|
if not s:
|
|
s = outputs
|
|
x_fee = run_hook('get_tx_extra_fee', self, tx)
|
|
if x_fee:
|
|
x_fee_address, x_fee_amount = x_fee
|
|
s = list(filter(lambda o: o.address != x_fee_address, s))
|
|
if not s:
|
|
raise CannotBumpFee('No outputs at all??')
|
|
|
|
# prioritize low value outputs, to get rid of dust
|
|
s = sorted(s, key=lambda o: o.value)
|
|
for o in s:
|
|
target_fee = int(math.ceil(tx.estimated_size() * new_fee_rate))
|
|
delta = target_fee - tx.get_fee()
|
|
i = outputs.index(o)
|
|
if o.value - delta >= self.dust_threshold():
|
|
new_output_value = o.value - delta
|
|
assert isinstance(new_output_value, int)
|
|
outputs[i].value = new_output_value
|
|
delta = 0
|
|
break
|
|
else:
|
|
del outputs[i]
|
|
# note: we mutated the outputs of tx, which will affect
|
|
# tx.estimated_size() in the next iteration
|
|
if delta > 0:
|
|
raise CannotBumpFee(_('Could not find suitable outputs'))
|
|
|
|
return PartialTransaction.from_io(inputs, outputs)
|
|
|
|
def _bump_fee_through_decreasing_payment(
|
|
self,
|
|
*,
|
|
tx: PartialTransaction,
|
|
new_fee_rate: Union[int, Decimal],
|
|
) -> PartialTransaction:
|
|
"""Increase the miner fee of 'tx'.
|
|
|
|
- keeps all inputs
|
|
- no new inputs are added
|
|
- decreases payment outputs (not change!). Each non-ismine output is decreased
|
|
proportionally to their byte-size.
|
|
"""
|
|
tx = copy.deepcopy(tx)
|
|
tx.add_info_from_wallet(self)
|
|
assert tx.get_fee() is not None
|
|
inputs = tx.inputs()
|
|
outputs = tx.outputs()
|
|
|
|
# select non-ismine outputs
|
|
s = [(idx, out) for (idx, out) in enumerate(outputs)
|
|
if not self.is_mine(out.address)]
|
|
# exempt 2fa fee output if present
|
|
x_fee = run_hook('get_tx_extra_fee', self, tx)
|
|
if x_fee:
|
|
x_fee_address, x_fee_amount = x_fee
|
|
s = [(idx, out) for (idx, out) in s if out.address != x_fee_address]
|
|
if not s:
|
|
raise CannotBumpFee("Cannot find payment output")
|
|
|
|
del_out_idxs = set()
|
|
tx_size = tx.estimated_size()
|
|
cur_fee = tx.get_fee()
|
|
# Main loop. Each iteration decreases value of all selected outputs.
|
|
# The number of iterations is bounded by len(s) as only the final iteration
|
|
# can *not remove* any output.
|
|
for __ in range(len(s) + 1):
|
|
target_fee = int(math.ceil(tx_size * new_fee_rate))
|
|
delta_total = target_fee - cur_fee
|
|
if delta_total <= 0:
|
|
break
|
|
out_size_total = sum(Transaction.estimated_output_size_for_script(out.scriptpubkey.hex())
|
|
for (idx, out) in s if idx not in del_out_idxs)
|
|
for idx, out in s:
|
|
out_size = Transaction.estimated_output_size_for_script(out.scriptpubkey.hex())
|
|
delta = int(math.ceil(delta_total * out_size / out_size_total))
|
|
if out.value - delta >= self.dust_threshold():
|
|
new_output_value = out.value - delta
|
|
assert isinstance(new_output_value, int)
|
|
outputs[idx].value = new_output_value
|
|
cur_fee += delta
|
|
else: # remove output
|
|
tx_size -= out_size
|
|
cur_fee += out.value
|
|
del_out_idxs.add(idx)
|
|
if delta_total > 0:
|
|
raise CannotBumpFee(_('Could not find suitable outputs'))
|
|
|
|
outputs = [out for (idx, out) in enumerate(outputs) if idx not in del_out_idxs]
|
|
return PartialTransaction.from_io(inputs, outputs)
|
|
|
|
def cpfp(self, tx: Transaction, fee: int) -> Optional[PartialTransaction]:
|
|
txid = tx.txid()
|
|
for i, o in enumerate(tx.outputs()):
|
|
address, value = o.address, o.value
|
|
if self.is_mine(address):
|
|
break
|
|
else:
|
|
raise CannotCPFP(_("Could not find suitable output"))
|
|
coins = self.get_addr_utxo(address)
|
|
item = coins.get(TxOutpoint.from_str(txid+':%d'%i))
|
|
if not item:
|
|
raise CannotCPFP(_("Could not find coins for output"))
|
|
inputs = [item]
|
|
out_address = (self.get_single_change_address_for_new_transaction(allow_reuse=False)
|
|
or self.get_unused_address()
|
|
or address)
|
|
output_value = value - fee
|
|
if output_value < self.dust_threshold():
|
|
raise CannotCPFP(_("The output value remaining after fee is too low."))
|
|
outputs = [PartialTxOutput.from_address_and_value(out_address, output_value)]
|
|
locktime = get_locktime_for_new_transaction(self.network)
|
|
tx_new = PartialTransaction.from_io(inputs, outputs, locktime=locktime)
|
|
tx_new.set_rbf(True)
|
|
tx_new.add_info_from_wallet(self)
|
|
return tx_new
|
|
|
|
def dscancel(
|
|
self, *, tx: Transaction, new_fee_rate: Union[int, float, Decimal]
|
|
) -> PartialTransaction:
|
|
"""Double-Spend-Cancel: cancel an unconfirmed tx by double-spending
|
|
its inputs, paying ourselves.
|
|
'new_fee_rate' is the target min rate in sat/vbyte
|
|
"""
|
|
if not isinstance(tx, PartialTransaction):
|
|
tx = PartialTransaction.from_tx(tx)
|
|
assert isinstance(tx, PartialTransaction)
|
|
tx.remove_signatures()
|
|
|
|
if tx.is_final():
|
|
raise CannotDoubleSpendTx(_('Transaction is final'))
|
|
new_fee_rate = quantize_feerate(new_fee_rate) # strip excess precision
|
|
try:
|
|
# note: this might download input utxos over network
|
|
tx.add_info_from_wallet(self, ignore_network_issues=False)
|
|
except NetworkException as e:
|
|
raise CannotDoubleSpendTx(repr(e))
|
|
old_tx_size = tx.estimated_size()
|
|
old_fee = tx.get_fee()
|
|
assert old_fee is not None
|
|
old_fee_rate = old_fee / old_tx_size # sat/vbyte
|
|
if new_fee_rate <= old_fee_rate:
|
|
raise CannotDoubleSpendTx(_("The new fee rate needs to be higher than the old fee rate."))
|
|
# grab all ismine inputs
|
|
inputs = [txin for txin in tx.inputs()
|
|
if self.is_mine(self.get_txin_address(txin))]
|
|
value = sum([txin.value_sats() for txin in inputs])
|
|
# figure out output address
|
|
old_change_addrs = [o.address for o in tx.outputs() if self.is_mine(o.address)]
|
|
out_address = (self.get_single_change_address_for_new_transaction(old_change_addrs)
|
|
or self.get_receiving_address())
|
|
locktime = get_locktime_for_new_transaction(self.network)
|
|
outputs = [PartialTxOutput.from_address_and_value(out_address, value)]
|
|
tx_new = PartialTransaction.from_io(inputs, outputs, locktime=locktime)
|
|
new_tx_size = tx_new.estimated_size()
|
|
new_fee = max(
|
|
new_fee_rate * new_tx_size,
|
|
old_fee + self.relayfee() * new_tx_size / Decimal(1000), # BIP-125 rules 3 and 4
|
|
)
|
|
new_fee = int(math.ceil(new_fee))
|
|
output_value = value - new_fee
|
|
if output_value < self.dust_threshold():
|
|
raise CannotDoubleSpendTx(_("The output value remaining after fee is too low."))
|
|
outputs = [PartialTxOutput.from_address_and_value(out_address, value - new_fee)]
|
|
tx_new = PartialTransaction.from_io(inputs, outputs, locktime=locktime)
|
|
tx_new.set_rbf(True)
|
|
tx_new.add_info_from_wallet(self)
|
|
return tx_new
|
|
|
|
@abstractmethod
|
|
def _add_input_sig_info(self, txin: PartialTxInput, address: str, *, only_der_suffix: bool) -> None:
|
|
pass
|
|
|
|
def _add_txinout_derivation_info(self, txinout: Union[PartialTxInput, PartialTxOutput],
|
|
address: str, *, only_der_suffix: bool) -> None:
|
|
pass # implemented by subclasses
|
|
|
|
def _add_input_utxo_info(
|
|
self,
|
|
txin: PartialTxInput,
|
|
*,
|
|
address: str = None,
|
|
ignore_network_issues: bool = True,
|
|
) -> None:
|
|
# We prefer to include UTXO (full tx) for every input.
|
|
# We cannot include UTXO if the prev tx is not signed yet though (chain of unsigned txs),
|
|
# in which case we might include a WITNESS_UTXO.
|
|
address = address or txin.address
|
|
if txin.witness_utxo is None and txin.is_segwit() and address:
|
|
received, spent = self.get_addr_io(address)
|
|
item = received.get(txin.prevout.to_str())
|
|
if item:
|
|
txin_value = item[1]
|
|
txin.witness_utxo = TxOutput.from_address_and_value(address, txin_value)
|
|
if txin.utxo is None:
|
|
txin.utxo = self.get_input_tx(txin.prevout.txid.hex(), ignore_network_issues=ignore_network_issues)
|
|
txin.ensure_there_is_only_one_utxo()
|
|
|
|
def _learn_derivation_path_for_address_from_txinout(self, txinout: Union[PartialTxInput, PartialTxOutput],
|
|
address: str) -> bool:
|
|
"""Tries to learn the derivation path for an address (potentially beyond gap limit)
|
|
using data available in given txin/txout.
|
|
Returns whether the address was found to be is_mine.
|
|
"""
|
|
return False # implemented by subclasses
|
|
|
|
def add_input_info(
|
|
self,
|
|
txin: PartialTxInput,
|
|
*,
|
|
only_der_suffix: bool = False,
|
|
ignore_network_issues: bool = True,
|
|
) -> None:
|
|
address = self.get_txin_address(txin)
|
|
# note: we add input utxos regardless of is_mine
|
|
self._add_input_utxo_info(txin, ignore_network_issues=ignore_network_issues, address=address)
|
|
if not self.is_mine(address):
|
|
is_mine = self._learn_derivation_path_for_address_from_txinout(txin, address)
|
|
if not is_mine:
|
|
return
|
|
# set script_type first, as later checks might rely on it:
|
|
txin.script_type = self.get_txin_type(address)
|
|
txin.num_sig = self.m if isinstance(self, Multisig_Wallet) else 1
|
|
if txin.redeem_script is None:
|
|
try:
|
|
redeem_script_hex = self.get_redeem_script(address)
|
|
txin.redeem_script = bfh(redeem_script_hex) if redeem_script_hex else None
|
|
except UnknownTxinType:
|
|
pass
|
|
if txin.witness_script is None:
|
|
try:
|
|
witness_script_hex = self.get_witness_script(address)
|
|
txin.witness_script = bfh(witness_script_hex) if witness_script_hex else None
|
|
except UnknownTxinType:
|
|
pass
|
|
self._add_input_sig_info(txin, address, only_der_suffix=only_der_suffix)
|
|
|
|
def can_sign(self, tx: Transaction) -> bool:
|
|
if not isinstance(tx, PartialTransaction):
|
|
return False
|
|
if tx.is_complete():
|
|
return False
|
|
# add info to inputs if we can; otherwise we might return a false negative:
|
|
tx.add_info_from_wallet(self)
|
|
for txin in tx.inputs():
|
|
# note: is_mine check needed to avoid false positives.
|
|
# just because keystore could sign, txin does not necessarily belong to wallet.
|
|
# Example: we have p2pkh-like addresses and txin is a multisig that involves our pubkey.
|
|
if not self.is_mine(txin.address):
|
|
continue
|
|
for k in self.get_keystores():
|
|
if k.can_sign_txin(txin):
|
|
return True
|
|
return False
|
|
|
|
def get_input_tx(self, tx_hash: str, *, ignore_network_issues=False) -> Optional[Transaction]:
|
|
# First look up an input transaction in the wallet where it
|
|
# will likely be. If co-signing a transaction it may not have
|
|
# all the input txs, in which case we ask the network.
|
|
tx = self.db.get_transaction(tx_hash)
|
|
if not tx and self.network and self.network.has_internet_connection():
|
|
try:
|
|
raw_tx = self.network.run_from_another_thread(
|
|
self.network.get_transaction(tx_hash, timeout=10))
|
|
except NetworkException as e:
|
|
self.logger.info(f'got network error getting input txn. err: {repr(e)}. txid: {tx_hash}. '
|
|
f'if you are intentionally offline, consider using the --offline flag')
|
|
if not ignore_network_issues:
|
|
raise e
|
|
else:
|
|
tx = Transaction(raw_tx)
|
|
if not tx and not ignore_network_issues:
|
|
raise NetworkException('failed to get prev tx from network')
|
|
return tx
|
|
|
|
def add_output_info(self, txout: PartialTxOutput, *, only_der_suffix: bool = False) -> None:
|
|
address = txout.address
|
|
if not self.is_mine(address):
|
|
is_mine = self._learn_derivation_path_for_address_from_txinout(txout, address)
|
|
if not is_mine:
|
|
return
|
|
txout.script_type = self.get_txin_type(address)
|
|
txout.is_mine = True
|
|
txout.is_change = self.is_change(address)
|
|
if isinstance(self, Multisig_Wallet):
|
|
txout.num_sig = self.m
|
|
self._add_txinout_derivation_info(txout, address, only_der_suffix=only_der_suffix)
|
|
if txout.redeem_script is None:
|
|
try:
|
|
redeem_script_hex = self.get_redeem_script(address)
|
|
txout.redeem_script = bfh(redeem_script_hex) if redeem_script_hex else None
|
|
except UnknownTxinType:
|
|
pass
|
|
if txout.witness_script is None:
|
|
try:
|
|
witness_script_hex = self.get_witness_script(address)
|
|
txout.witness_script = bfh(witness_script_hex) if witness_script_hex else None
|
|
except UnknownTxinType:
|
|
pass
|
|
|
|
def sign_transaction(self, tx: Transaction, password) -> Optional[PartialTransaction]:
|
|
if self.is_watching_only():
|
|
return
|
|
if not isinstance(tx, PartialTransaction):
|
|
return
|
|
# add info to a temporary tx copy; including xpubs
|
|
# and full derivation paths as hw keystores might want them
|
|
tmp_tx = copy.deepcopy(tx)
|
|
tmp_tx.add_info_from_wallet(self, include_xpubs=True)
|
|
# sign. start with ready keystores.
|
|
for k in sorted(self.get_keystores(), key=lambda ks: ks.ready_to_sign(), reverse=True):
|
|
try:
|
|
if k.can_sign(tmp_tx):
|
|
k.sign_transaction(tmp_tx, password)
|
|
except UserCancelled:
|
|
continue
|
|
# remove sensitive info; then copy back details from temporary tx
|
|
tmp_tx.remove_xpubs_and_bip32_paths()
|
|
tx.combine_with_other_psbt(tmp_tx)
|
|
tx.add_info_from_wallet(self, include_xpubs=False)
|
|
return tx
|
|
|
|
def try_detecting_internal_addresses_corruption(self) -> None:
|
|
pass
|
|
|
|
def check_address_for_corruption(self, addr: str) -> None:
|
|
pass
|
|
|
|
def get_unused_addresses(self) -> Sequence[str]:
|
|
domain = self.get_receiving_addresses()
|
|
# TODO we should index receive_requests by id
|
|
in_use_by_request = [k for k in self.receive_requests.keys()
|
|
if self.get_request_status(k) != PR_EXPIRED]
|
|
in_use_by_request = set(in_use_by_request)
|
|
return [addr for addr in domain if not self.is_used(addr)
|
|
and addr not in in_use_by_request]
|
|
|
|
@check_returned_address_for_corruption
|
|
def get_unused_address(self) -> Optional[str]:
|
|
"""Get an unused receiving address, if there is one.
|
|
Note: there might NOT be one available!
|
|
"""
|
|
addrs = self.get_unused_addresses()
|
|
if addrs:
|
|
return addrs[0]
|
|
|
|
@check_returned_address_for_corruption
|
|
def get_receiving_address(self) -> str:
|
|
"""Get a receiving address. Guaranteed to always return an address."""
|
|
unused_addr = self.get_unused_address()
|
|
if unused_addr:
|
|
return unused_addr
|
|
domain = self.get_receiving_addresses()
|
|
if not domain:
|
|
raise Exception("no receiving addresses in wallet?!")
|
|
choice = domain[0]
|
|
for addr in domain:
|
|
if not self.is_used(addr):
|
|
if addr not in self.receive_requests.keys():
|
|
return addr
|
|
else:
|
|
choice = addr
|
|
return choice
|
|
|
|
def create_new_address(self, for_change: bool = False):
|
|
raise Exception("this wallet cannot generate new addresses")
|
|
|
|
def import_address(self, address: str) -> str:
|
|
raise Exception("this wallet cannot import addresses")
|
|
|
|
def import_addresses(self, addresses: List[str], *,
|
|
write_to_disk=True) -> Tuple[List[str], List[Tuple[str, str]]]:
|
|
raise Exception("this wallet cannot import addresses")
|
|
|
|
def delete_address(self, address: str) -> None:
|
|
raise Exception("this wallet cannot delete addresses")
|
|
|
|
def get_onchain_request_status(self, r):
|
|
address = r.get_address()
|
|
amount = r.get_amount_sat()
|
|
received, sent = self.get_addr_io(address)
|
|
l = []
|
|
for txo, x in received.items():
|
|
h, v, is_cb = x
|
|
txid, n = txo.split(':')
|
|
tx_height = self.get_tx_height(txid)
|
|
height = tx_height.height
|
|
if height > 0 and height <= r.height:
|
|
continue
|
|
conf = tx_height.conf
|
|
l.append((conf, v))
|
|
vsum = 0
|
|
for conf, v in reversed(sorted(l)):
|
|
vsum += v
|
|
if vsum >= amount:
|
|
return True, conf
|
|
return False, None
|
|
|
|
def get_request_URI(self, req: OnchainInvoice) -> str:
|
|
addr = req.get_address()
|
|
message = self.get_label(addr)
|
|
amount = req.amount_sat
|
|
extra_query_params = {}
|
|
if req.time:
|
|
extra_query_params['time'] = str(int(req.time))
|
|
if req.exp:
|
|
extra_query_params['exp'] = str(int(req.exp))
|
|
#if req.get('name') and req.get('sig'):
|
|
# sig = bfh(req.get('sig'))
|
|
# sig = bitcoin.base_encode(sig, base=58)
|
|
# extra_query_params['name'] = req['name']
|
|
# extra_query_params['sig'] = sig
|
|
uri = create_bip21_uri(addr, amount, message, extra_query_params=extra_query_params)
|
|
return str(uri)
|
|
|
|
def check_expired_status(self, r: Invoice, status):
|
|
if r.is_lightning() and r.exp == 0:
|
|
status = PR_EXPIRED # for BOLT-11 invoices, exp==0 means 0 seconds
|
|
if status == PR_UNPAID and r.exp > 0 and r.time + r.exp < time.time():
|
|
status = PR_EXPIRED
|
|
return status
|
|
|
|
def get_invoice_status(self, invoice: Invoice):
|
|
if invoice.is_lightning():
|
|
status = self.lnworker.get_invoice_status(invoice) if self.lnworker else PR_UNKNOWN
|
|
else:
|
|
if self.is_onchain_invoice_paid(invoice, 1):
|
|
status =PR_PAID
|
|
elif self.is_onchain_invoice_paid(invoice, 0):
|
|
status = PR_UNCONFIRMED
|
|
else:
|
|
status = PR_UNPAID
|
|
return self.check_expired_status(invoice, status)
|
|
|
|
def get_request_status(self, key):
|
|
r = self.get_request(key)
|
|
if r is None:
|
|
return PR_UNKNOWN
|
|
if r.is_lightning():
|
|
assert isinstance(r, LNInvoice)
|
|
status = self.lnworker.get_payment_status(bfh(r.rhash)) if self.lnworker else PR_UNKNOWN
|
|
else:
|
|
assert isinstance(r, OnchainInvoice)
|
|
paid, conf = self.get_onchain_request_status(r)
|
|
if not paid:
|
|
status = PR_UNPAID
|
|
elif conf == 0:
|
|
status = PR_UNCONFIRMED
|
|
else:
|
|
status = PR_PAID
|
|
return self.check_expired_status(r, status)
|
|
|
|
def get_request(self, key):
|
|
return self.receive_requests.get(key)
|
|
|
|
def get_formatted_request(self, key):
|
|
x = self.receive_requests.get(key)
|
|
if x:
|
|
return self.export_request(x)
|
|
|
|
def export_request(self, x: Invoice) -> Dict[str, Any]:
|
|
key = self.get_key_for_receive_request(x)
|
|
status = self.get_request_status(key)
|
|
status_str = x.get_status_str(status)
|
|
is_lightning = x.is_lightning()
|
|
d = {
|
|
'is_lightning': is_lightning,
|
|
'amount_BTC': format_satoshis(x.get_amount_sat()),
|
|
'message': x.message,
|
|
'timestamp': x.time,
|
|
'expiration': x.exp,
|
|
'status': status,
|
|
'status_str': status_str,
|
|
}
|
|
if is_lightning:
|
|
assert isinstance(x, LNInvoice)
|
|
d['rhash'] = x.rhash
|
|
d['invoice'] = x.invoice
|
|
d['amount_msat'] = x.get_amount_msat()
|
|
if self.lnworker and status == PR_UNPAID:
|
|
d['can_receive'] = self.lnworker.can_receive_invoice(x)
|
|
else:
|
|
assert isinstance(x, OnchainInvoice)
|
|
paid, conf = self.get_onchain_request_status(x)
|
|
d['amount_sat'] = x.get_amount_sat()
|
|
d['address'] = x.get_address()
|
|
d['URI'] = self.get_request_URI(x)
|
|
if conf is not None:
|
|
d['confirmations'] = conf
|
|
# add URL if we are running a payserver
|
|
payserver = self.config.get_netaddress('payserver_address')
|
|
if payserver:
|
|
root = self.config.get('payserver_root', '/r')
|
|
use_ssl = bool(self.config.get('ssl_keyfile'))
|
|
protocol = 'https' if use_ssl else 'http'
|
|
base = '%s://%s:%d'%(protocol, payserver.host, payserver.port)
|
|
d['view_url'] = base + root + '/pay?id=' + key
|
|
if use_ssl and 'URI' in d:
|
|
request_url = base + '/bip70/' + key + '.bip70'
|
|
d['bip70_url'] = request_url
|
|
return d
|
|
|
|
def export_invoice(self, x: Invoice) -> Dict[str, Any]:
|
|
status = self.get_invoice_status(x)
|
|
status_str = x.get_status_str(status)
|
|
is_lightning = x.is_lightning()
|
|
d = {
|
|
'is_lightning': is_lightning,
|
|
'amount_BTC': format_satoshis(x.get_amount_sat()),
|
|
'message': x.message,
|
|
'timestamp': x.time,
|
|
'expiration': x.exp,
|
|
'status': status,
|
|
'status_str': status_str,
|
|
}
|
|
if is_lightning:
|
|
assert isinstance(x, LNInvoice)
|
|
d['invoice'] = x.invoice
|
|
d['amount_msat'] = x.get_amount_msat()
|
|
if self.lnworker and status == PR_UNPAID:
|
|
d['can_pay'] = self.lnworker.can_pay_invoice(x)
|
|
else:
|
|
assert isinstance(x, OnchainInvoice)
|
|
amount_sat = x.get_amount_sat()
|
|
assert isinstance(amount_sat, (int, str, type(None)))
|
|
d['amount_sat'] = amount_sat
|
|
d['outputs'] = [y.to_legacy_tuple() for y in x.outputs]
|
|
if x.bip70:
|
|
d['bip70'] = x.bip70
|
|
d['requestor'] = x.requestor
|
|
return d
|
|
|
|
def receive_tx_callback(self, tx_hash, tx, tx_height):
|
|
super().receive_tx_callback(tx_hash, tx, tx_height)
|
|
for txo in tx.outputs():
|
|
addr = self.get_txout_address(txo)
|
|
if addr in self.receive_requests:
|
|
status = self.get_request_status(addr)
|
|
util.trigger_callback('request_status', self, addr, status)
|
|
|
|
def make_payment_request(self, address, amount_sat, message, expiration):
|
|
# TODO maybe merge with wallet.create_invoice()...
|
|
# note that they use incompatible "id"
|
|
amount_sat = amount_sat or 0
|
|
timestamp = int(time.time())
|
|
_id = bh2u(sha256d(address + "%d"%timestamp))[0:10]
|
|
expiration = expiration or 0
|
|
return OnchainInvoice(
|
|
type=PR_TYPE_ONCHAIN,
|
|
outputs=[(TYPE_ADDRESS, address, amount_sat)],
|
|
message=message,
|
|
time=timestamp,
|
|
amount_sat=amount_sat,
|
|
exp=expiration,
|
|
id=_id,
|
|
bip70=None,
|
|
requestor=None,
|
|
height=self.get_local_height(),
|
|
)
|
|
|
|
def sign_payment_request(self, key, alias, alias_addr, password): # FIXME this is broken
|
|
req = self.receive_requests.get(key)
|
|
assert isinstance(req, OnchainInvoice)
|
|
alias_privkey = self.export_private_key(alias_addr, password)
|
|
pr = paymentrequest.make_unsigned_request(req)
|
|
paymentrequest.sign_request_with_alias(pr, alias, alias_privkey)
|
|
req.bip70 = pr.raw.hex()
|
|
req['name'] = pr.pki_data
|
|
req['sig'] = bh2u(pr.signature)
|
|
self.receive_requests[key] = req
|
|
|
|
@classmethod
|
|
def get_key_for_outgoing_invoice(cls, invoice: Invoice) -> str:
|
|
"""Return the key to use for this invoice in self.invoices."""
|
|
if invoice.is_lightning():
|
|
assert isinstance(invoice, LNInvoice)
|
|
key = invoice.rhash
|
|
else:
|
|
assert isinstance(invoice, OnchainInvoice)
|
|
key = invoice.id
|
|
return key
|
|
|
|
def get_key_for_receive_request(self, req: Invoice, *, sanity_checks: bool = False) -> str:
|
|
"""Return the key to use for this invoice in self.receive_requests."""
|
|
if not req.is_lightning():
|
|
assert isinstance(req, OnchainInvoice)
|
|
addr = req.get_address()
|
|
if sanity_checks:
|
|
if not bitcoin.is_address(addr):
|
|
raise Exception(_('Invalid Bitcoin address.'))
|
|
if not self.is_mine(addr):
|
|
raise Exception(_('Address not in wallet.'))
|
|
key = addr
|
|
else:
|
|
assert isinstance(req, LNInvoice)
|
|
key = req.rhash
|
|
return key
|
|
|
|
def add_payment_request(self, req: Invoice):
|
|
key = self.get_key_for_receive_request(req, sanity_checks=True)
|
|
message = req.message
|
|
self.receive_requests[key] = req
|
|
self.set_label(key, message) # should be a default label
|
|
return req
|
|
|
|
def delete_request(self, key):
|
|
""" lightning or on-chain """
|
|
if key in self.receive_requests:
|
|
self.remove_payment_request(key)
|
|
elif self.lnworker:
|
|
self.lnworker.delete_payment(key)
|
|
|
|
def delete_invoice(self, key):
|
|
""" lightning or on-chain """
|
|
if key in self.invoices:
|
|
self.invoices.pop(key)
|
|
elif self.lnworker:
|
|
self.lnworker.delete_payment(key)
|
|
|
|
def remove_payment_request(self, addr):
|
|
if addr not in self.receive_requests:
|
|
return False
|
|
self.receive_requests.pop(addr)
|
|
return True
|
|
|
|
def get_sorted_requests(self) -> List[Invoice]:
|
|
""" sorted by timestamp """
|
|
out = [self.get_request(x) for x in self.receive_requests.keys()]
|
|
out = [x for x in out if x is not None]
|
|
out.sort(key=lambda x: x.time)
|
|
return out
|
|
|
|
def get_unpaid_requests(self):
|
|
out = [self.get_request(x) for x in self.receive_requests.keys() if self.get_request_status(x) != PR_PAID]
|
|
out = [x for x in out if x is not None]
|
|
out.sort(key=lambda x: x.time)
|
|
return out
|
|
|
|
@abstractmethod
|
|
def get_fingerprint(self) -> str:
|
|
"""Returns a string that can be used to identify this wallet.
|
|
Used e.g. by Labels plugin, and LN channel backups.
|
|
Returns empty string "" for wallets that don't have an ID.
|
|
"""
|
|
pass
|
|
|
|
def can_import_privkey(self):
|
|
return False
|
|
|
|
def can_import_address(self):
|
|
return False
|
|
|
|
def can_delete_address(self):
|
|
return False
|
|
|
|
def has_password(self):
|
|
return self.has_keystore_encryption() or self.has_storage_encryption()
|
|
|
|
def can_have_keystore_encryption(self):
|
|
return self.keystore and self.keystore.may_have_password()
|
|
|
|
def get_available_storage_encryption_version(self) -> StorageEncryptionVersion:
|
|
"""Returns the type of storage encryption offered to the user.
|
|
|
|
A wallet file (storage) is either encrypted with this version
|
|
or is stored in plaintext.
|
|
"""
|
|
if isinstance(self.keystore, Hardware_KeyStore):
|
|
return StorageEncryptionVersion.XPUB_PASSWORD
|
|
else:
|
|
return StorageEncryptionVersion.USER_PASSWORD
|
|
|
|
def has_keystore_encryption(self):
|
|
"""Returns whether encryption is enabled for the keystore.
|
|
|
|
If True, e.g. signing a transaction will require a password.
|
|
"""
|
|
if self.can_have_keystore_encryption():
|
|
return self.db.get('use_encryption', False)
|
|
return False
|
|
|
|
def has_storage_encryption(self):
|
|
"""Returns whether encryption is enabled for the wallet file on disk."""
|
|
return self.storage and self.storage.is_encrypted()
|
|
|
|
@classmethod
|
|
def may_have_password(cls):
|
|
return True
|
|
|
|
def check_password(self, password):
|
|
if self.has_keystore_encryption():
|
|
self.keystore.check_password(password)
|
|
if self.has_storage_encryption():
|
|
self.storage.check_password(password)
|
|
|
|
def update_password(self, old_pw, new_pw, *, encrypt_storage: bool = True):
|
|
if old_pw is None and self.has_password():
|
|
raise InvalidPassword()
|
|
self.check_password(old_pw)
|
|
if self.storage:
|
|
if encrypt_storage:
|
|
enc_version = self.get_available_storage_encryption_version()
|
|
else:
|
|
enc_version = StorageEncryptionVersion.PLAINTEXT
|
|
self.storage.set_password(new_pw, enc_version)
|
|
# make sure next storage.write() saves changes
|
|
self.db.set_modified(True)
|
|
|
|
# note: Encrypting storage with a hw device is currently only
|
|
# allowed for non-multisig wallets. Further,
|
|
# Hardware_KeyStore.may_have_password() == False.
|
|
# If these were not the case,
|
|
# extra care would need to be taken when encrypting keystores.
|
|
self._update_password_for_keystore(old_pw, new_pw)
|
|
encrypt_keystore = self.can_have_keystore_encryption()
|
|
self.db.set_keystore_encryption(bool(new_pw) and encrypt_keystore)
|
|
self.save_db()
|
|
|
|
@abstractmethod
|
|
def _update_password_for_keystore(self, old_pw: Optional[str], new_pw: Optional[str]) -> None:
|
|
pass
|
|
|
|
def sign_message(self, address, message, password):
|
|
index = self.get_address_index(address)
|
|
return self.keystore.sign_message(index, message, password)
|
|
|
|
def decrypt_message(self, pubkey: str, message, password) -> bytes:
|
|
addr = self.pubkeys_to_address([pubkey])
|
|
index = self.get_address_index(addr)
|
|
return self.keystore.decrypt_message(index, message, password)
|
|
|
|
@abstractmethod
|
|
def pubkeys_to_address(self, pubkeys: Sequence[str]) -> Optional[str]:
|
|
pass
|
|
|
|
def price_at_timestamp(self, txid, price_func):
|
|
"""Returns fiat price of bitcoin at the time tx got confirmed."""
|
|
timestamp = self.get_tx_height(txid).timestamp
|
|
return price_func(timestamp if timestamp else time.time())
|
|
|
|
def average_price(self, txid, price_func, ccy) -> Decimal:
|
|
""" Average acquisition price of the inputs of a transaction """
|
|
input_value = 0
|
|
total_price = 0
|
|
txi_addresses = self.db.get_txi_addresses(txid)
|
|
if not txi_addresses:
|
|
return Decimal('NaN')
|
|
for addr in txi_addresses:
|
|
d = self.db.get_txi_addr(txid, addr)
|
|
for ser, v in d:
|
|
input_value += v
|
|
total_price += self.coin_price(ser.split(':')[0], price_func, ccy, v)
|
|
return total_price / (input_value/Decimal(COIN))
|
|
|
|
def clear_coin_price_cache(self):
|
|
self._coin_price_cache = {}
|
|
|
|
def coin_price(self, txid, price_func, ccy, txin_value) -> Decimal:
|
|
"""
|
|
Acquisition price of a coin.
|
|
This assumes that either all inputs are mine, or no input is mine.
|
|
"""
|
|
if txin_value is None:
|
|
return Decimal('NaN')
|
|
cache_key = "{}:{}:{}".format(str(txid), str(ccy), str(txin_value))
|
|
result = self._coin_price_cache.get(cache_key, None)
|
|
if result is not None:
|
|
return result
|
|
if self.db.get_txi_addresses(txid):
|
|
result = self.average_price(txid, price_func, ccy) * txin_value/Decimal(COIN)
|
|
self._coin_price_cache[cache_key] = result
|
|
return result
|
|
else:
|
|
fiat_value = self.get_fiat_value(txid, ccy)
|
|
if fiat_value is not None:
|
|
return fiat_value
|
|
else:
|
|
p = self.price_at_timestamp(txid, price_func)
|
|
return p * txin_value/Decimal(COIN)
|
|
|
|
def is_billing_address(self, addr):
|
|
# overridden for TrustedCoin wallets
|
|
return False
|
|
|
|
@abstractmethod
|
|
def is_watching_only(self) -> bool:
|
|
pass
|
|
|
|
def get_keystore(self) -> Optional[KeyStore]:
|
|
return self.keystore
|
|
|
|
def get_keystores(self) -> Sequence[KeyStore]:
|
|
return [self.keystore] if self.keystore else []
|
|
|
|
@abstractmethod
|
|
def save_keystore(self):
|
|
pass
|
|
|
|
@abstractmethod
|
|
def has_seed(self) -> bool:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_all_known_addresses_beyond_gap_limit(self) -> Set[str]:
|
|
pass
|
|
|
|
def create_transaction(self, outputs, *, fee=None, feerate=None, change_addr=None, domain_addr=None, domain_coins=None,
|
|
unsigned=False, rbf=None, password=None, locktime=None):
|
|
if fee is not None and feerate is not None:
|
|
raise Exception("Cannot specify both 'fee' and 'feerate' at the same time!")
|
|
coins = self.get_spendable_coins(domain_addr)
|
|
if domain_coins is not None:
|
|
coins = [coin for coin in coins if (coin.prevout.to_str() in domain_coins)]
|
|
if feerate is not None:
|
|
fee_per_kb = 1000 * Decimal(feerate)
|
|
fee_estimator = partial(SimpleConfig.estimate_fee_for_feerate, fee_per_kb)
|
|
else:
|
|
fee_estimator = fee
|
|
tx = self.make_unsigned_transaction(
|
|
coins=coins,
|
|
outputs=outputs,
|
|
fee=fee_estimator,
|
|
change_addr=change_addr)
|
|
if locktime is not None:
|
|
tx.locktime = locktime
|
|
if rbf is None:
|
|
rbf = bool(self.config.get('use_rbf', True))
|
|
tx.set_rbf(rbf)
|
|
if not unsigned:
|
|
self.sign_transaction(tx, password)
|
|
return tx
|
|
|
|
def get_warning_for_risk_of_burning_coins_as_fees(self, tx: 'PartialTransaction') -> Optional[str]:
|
|
"""Returns a warning message if there is risk of burning coins as fees if we sign.
|
|
Note that if not all inputs are ismine, e.g. coinjoin, the risk is not just about fees.
|
|
|
|
Note:
|
|
- legacy sighash does not commit to any input amounts
|
|
- BIP-0143 sighash only commits to the *corresponding* input amount
|
|
- BIP-taproot sighash commits to *all* input amounts
|
|
"""
|
|
assert isinstance(tx, PartialTransaction)
|
|
# if we have all full previous txs, we *know* all the input amounts -> fine
|
|
if all([txin.utxo for txin in tx.inputs()]):
|
|
return None
|
|
# a single segwit input -> fine
|
|
if len(tx.inputs()) == 1 and tx.inputs()[0].is_segwit() and tx.inputs()[0].witness_utxo:
|
|
return None
|
|
# coinjoin or similar
|
|
if any([not self.is_mine(txin.address) for txin in tx.inputs()]):
|
|
return (_("Warning") + ": "
|
|
+ _("The input amounts could not be verified as the previous transactions are missing.\n"
|
|
"The amount of money being spent CANNOT be verified."))
|
|
# some inputs are legacy
|
|
if any([not txin.is_segwit() for txin in tx.inputs()]):
|
|
return (_("Warning") + ": "
|
|
+ _("The fee could not be verified. Signing non-segwit inputs is risky:\n"
|
|
"if this transaction was maliciously modified before you sign,\n"
|
|
"you might end up paying a higher mining fee than displayed."))
|
|
# all inputs are segwit
|
|
# https://lists.linuxfoundation.org/pipermail/bitcoin-dev/2017-August/014843.html
|
|
return (_("Warning") + ": "
|
|
+ _("If you received this transaction from an untrusted device, "
|
|
"do not accept to sign it more than once,\n"
|
|
"otherwise you could end up paying a different fee."))
|
|
|
|
def get_tx_fee_warning(
|
|
self, *,
|
|
invoice_amt: int,
|
|
tx_size: int,
|
|
fee: int) -> Optional[Tuple[bool, str, str]]:
|
|
|
|
feerate = Decimal(fee) / tx_size # sat/byte
|
|
fee_ratio = Decimal(fee) / invoice_amt if invoice_amt else 1
|
|
long_warning = None
|
|
short_warning = None
|
|
allow_send = True
|
|
if feerate < self.relayfee() / 1000:
|
|
long_warning = (
|
|
_("This transaction requires a higher fee, or it will not be propagated by your current server.") + " "
|
|
+ _("Try to raise your transaction fee, or use a server with a lower relay fee."))
|
|
short_warning = _("below relay fee") + "!"
|
|
allow_send = False
|
|
elif fee_ratio >= FEE_RATIO_HIGH_WARNING:
|
|
long_warning = (
|
|
_('Warning') + ': ' + _("The fee for this transaction seems unusually high.")
|
|
+ f' ({fee_ratio*100:.2f}% of amount)')
|
|
short_warning = _("high fee ratio") + "!"
|
|
elif feerate > FEERATE_WARNING_HIGH_FEE / 1000:
|
|
long_warning = (
|
|
_('Warning') + ': ' + _("The fee for this transaction seems unusually high.")
|
|
+ f' (feerate: {feerate:.2f} sat/byte)')
|
|
short_warning = _("high fee rate") + "!"
|
|
if long_warning is None:
|
|
return None
|
|
else:
|
|
return allow_send, long_warning, short_warning
|
|
|
|
|
|
class Simple_Wallet(Abstract_Wallet):
|
|
# wallet with a single keystore
|
|
|
|
def is_watching_only(self):
|
|
return self.keystore.is_watching_only()
|
|
|
|
def _update_password_for_keystore(self, old_pw, new_pw):
|
|
if self.keystore and self.keystore.may_have_password():
|
|
self.keystore.update_password(old_pw, new_pw)
|
|
self.save_keystore()
|
|
|
|
def save_keystore(self):
|
|
self.db.put('keystore', self.keystore.dump())
|
|
|
|
@abstractmethod
|
|
def get_public_key(self, address: str) -> Optional[str]:
|
|
pass
|
|
|
|
def get_public_keys(self, address: str) -> Sequence[str]:
|
|
return [self.get_public_key(address)]
|
|
|
|
def get_redeem_script(self, address: str) -> Optional[str]:
|
|
txin_type = self.get_txin_type(address)
|
|
if txin_type in ('p2pkh', 'p2wpkh', 'p2pk'):
|
|
return None
|
|
if txin_type == 'p2wpkh-p2sh':
|
|
pubkey = self.get_public_key(address)
|
|
return bitcoin.p2wpkh_nested_script(pubkey)
|
|
if txin_type == 'address':
|
|
return None
|
|
raise UnknownTxinType(f'unexpected txin_type {txin_type}')
|
|
|
|
def get_witness_script(self, address: str) -> Optional[str]:
|
|
return None
|
|
|
|
|
|
class Imported_Wallet(Simple_Wallet):
|
|
# wallet made of imported addresses
|
|
|
|
wallet_type = 'imported'
|
|
txin_type = 'address'
|
|
|
|
def __init__(self, db, storage, *, config):
|
|
Abstract_Wallet.__init__(self, db, storage, config=config)
|
|
|
|
def is_watching_only(self):
|
|
return self.keystore is None
|
|
|
|
def can_import_privkey(self):
|
|
return bool(self.keystore)
|
|
|
|
def load_keystore(self):
|
|
self.keystore = load_keystore(self.db, 'keystore') if self.db.get('keystore') else None
|
|
|
|
def save_keystore(self):
|
|
self.db.put('keystore', self.keystore.dump())
|
|
|
|
def can_import_address(self):
|
|
return self.is_watching_only()
|
|
|
|
def can_delete_address(self):
|
|
return True
|
|
|
|
def has_seed(self):
|
|
return False
|
|
|
|
def is_deterministic(self):
|
|
return False
|
|
|
|
def is_change(self, address):
|
|
return False
|
|
|
|
def get_all_known_addresses_beyond_gap_limit(self) -> Set[str]:
|
|
return set()
|
|
|
|
def get_fingerprint(self):
|
|
return ''
|
|
|
|
def get_addresses(self):
|
|
# note: overridden so that the history can be cleared
|
|
return self.db.get_imported_addresses()
|
|
|
|
def get_receiving_addresses(self, **kwargs):
|
|
return self.get_addresses()
|
|
|
|
def get_change_addresses(self, **kwargs):
|
|
return []
|
|
|
|
def import_addresses(self, addresses: List[str], *,
|
|
write_to_disk=True) -> Tuple[List[str], List[Tuple[str, str]]]:
|
|
good_addr = [] # type: List[str]
|
|
bad_addr = [] # type: List[Tuple[str, str]]
|
|
for address in addresses:
|
|
if not bitcoin.is_address(address):
|
|
bad_addr.append((address, _('invalid address')))
|
|
continue
|
|
if self.db.has_imported_address(address):
|
|
bad_addr.append((address, _('address already in wallet')))
|
|
continue
|
|
good_addr.append(address)
|
|
self.db.add_imported_address(address, {})
|
|
self.add_address(address)
|
|
if write_to_disk:
|
|
self.save_db()
|
|
return good_addr, bad_addr
|
|
|
|
def import_address(self, address: str) -> str:
|
|
good_addr, bad_addr = self.import_addresses([address])
|
|
if good_addr and good_addr[0] == address:
|
|
return address
|
|
else:
|
|
raise BitcoinException(str(bad_addr[0][1]))
|
|
|
|
def delete_address(self, address: str) -> None:
|
|
if not self.db.has_imported_address(address):
|
|
return
|
|
if len(self.get_addresses()) <= 1:
|
|
raise UserFacingException("cannot delete last remaining address from wallet")
|
|
transactions_to_remove = set() # only referred to by this address
|
|
transactions_new = set() # txs that are not only referred to by address
|
|
with self.lock:
|
|
for addr in self.db.get_history():
|
|
details = self.get_address_history(addr)
|
|
if addr == address:
|
|
for tx_hash, height in details:
|
|
transactions_to_remove.add(tx_hash)
|
|
else:
|
|
for tx_hash, height in details:
|
|
transactions_new.add(tx_hash)
|
|
transactions_to_remove -= transactions_new
|
|
self.db.remove_addr_history(address)
|
|
for tx_hash in transactions_to_remove:
|
|
self.remove_transaction(tx_hash)
|
|
self.set_label(address, None)
|
|
self.remove_payment_request(address)
|
|
self.set_frozen_state_of_addresses([address], False)
|
|
pubkey = self.get_public_key(address)
|
|
self.db.remove_imported_address(address)
|
|
if pubkey:
|
|
# delete key iff no other address uses it (e.g. p2pkh and p2wpkh for same key)
|
|
for txin_type in bitcoin.WIF_SCRIPT_TYPES.keys():
|
|
try:
|
|
addr2 = bitcoin.pubkey_to_address(txin_type, pubkey)
|
|
except NotImplementedError:
|
|
pass
|
|
else:
|
|
if self.db.has_imported_address(addr2):
|
|
break
|
|
else:
|
|
self.keystore.delete_imported_key(pubkey)
|
|
self.save_keystore()
|
|
self.save_db()
|
|
|
|
def is_mine(self, address) -> bool:
|
|
if not address: return False
|
|
return self.db.has_imported_address(address)
|
|
|
|
def get_address_index(self, address) -> Optional[str]:
|
|
# returns None if address is not mine
|
|
return self.get_public_key(address)
|
|
|
|
def get_address_path_str(self, address):
|
|
return None
|
|
|
|
def get_public_key(self, address) -> Optional[str]:
|
|
x = self.db.get_imported_address(address)
|
|
return x.get('pubkey') if x else None
|
|
|
|
def import_private_keys(self, keys: List[str], password: Optional[str], *,
|
|
write_to_disk=True) -> Tuple[List[str], List[Tuple[str, str]]]:
|
|
good_addr = [] # type: List[str]
|
|
bad_keys = [] # type: List[Tuple[str, str]]
|
|
for key in keys:
|
|
try:
|
|
txin_type, pubkey = self.keystore.import_privkey(key, password)
|
|
except Exception as e:
|
|
bad_keys.append((key, _('invalid private key') + f': {e}'))
|
|
continue
|
|
if txin_type not in ('p2pkh', 'p2wpkh', 'p2wpkh-p2sh'):
|
|
bad_keys.append((key, _('not implemented type') + f': {txin_type}'))
|
|
continue
|
|
addr = bitcoin.pubkey_to_address(txin_type, pubkey)
|
|
good_addr.append(addr)
|
|
self.db.add_imported_address(addr, {'type':txin_type, 'pubkey':pubkey})
|
|
self.add_address(addr)
|
|
self.save_keystore()
|
|
if write_to_disk:
|
|
self.save_db()
|
|
return good_addr, bad_keys
|
|
|
|
def import_private_key(self, key: str, password: Optional[str]) -> str:
|
|
good_addr, bad_keys = self.import_private_keys([key], password=password)
|
|
if good_addr:
|
|
return good_addr[0]
|
|
else:
|
|
raise BitcoinException(str(bad_keys[0][1]))
|
|
|
|
def get_txin_type(self, address):
|
|
return self.db.get_imported_address(address).get('type', 'address')
|
|
|
|
def _add_input_sig_info(self, txin, address, *, only_der_suffix):
|
|
if not self.is_mine(address):
|
|
return
|
|
if txin.script_type in ('unknown', 'address'):
|
|
return
|
|
elif txin.script_type in ('p2pkh', 'p2wpkh', 'p2wpkh-p2sh'):
|
|
pubkey = self.get_public_key(address)
|
|
if not pubkey:
|
|
return
|
|
txin.pubkeys = [bfh(pubkey)]
|
|
else:
|
|
raise Exception(f'Unexpected script type: {txin.script_type}. '
|
|
f'Imported wallets are not implemented to handle this.')
|
|
|
|
def pubkeys_to_address(self, pubkeys):
|
|
pubkey = pubkeys[0]
|
|
for addr in self.db.get_imported_addresses(): # FIXME slow...
|
|
if self.db.get_imported_address(addr)['pubkey'] == pubkey:
|
|
return addr
|
|
return None
|
|
|
|
def decrypt_message(self, pubkey: str, message, password) -> bytes:
|
|
# this is significantly faster than the implementation in the superclass
|
|
return self.keystore.decrypt_message(pubkey, message, password)
|
|
|
|
|
|
class Deterministic_Wallet(Abstract_Wallet):
|
|
|
|
def __init__(self, db, storage, *, config):
|
|
self._ephemeral_addr_to_addr_index = {} # type: Dict[str, Sequence[int]]
|
|
Abstract_Wallet.__init__(self, db, storage, config=config)
|
|
self.gap_limit = db.get('gap_limit', 20)
|
|
# generate addresses now. note that without libsecp this might block
|
|
# for a few seconds!
|
|
self.synchronize()
|
|
# lightning_privkey2 is not deterministic (legacy wallets, bip39)
|
|
ln_xprv = self.db.get('lightning_xprv') or self.db.get('lightning_privkey2')
|
|
# lnworker can only be initialized once receiving addresses are available
|
|
# therefore we instantiate lnworker in DeterministicWallet
|
|
self.lnworker = LNWallet(self, ln_xprv) if ln_xprv else None
|
|
|
|
def has_seed(self):
|
|
return self.keystore.has_seed()
|
|
|
|
def get_addresses(self):
|
|
# note: overridden so that the history can be cleared.
|
|
# addresses are ordered based on derivation
|
|
out = self.get_receiving_addresses()
|
|
out += self.get_change_addresses()
|
|
return out
|
|
|
|
def get_receiving_addresses(self, *, slice_start=None, slice_stop=None):
|
|
return self.db.get_receiving_addresses(slice_start=slice_start, slice_stop=slice_stop)
|
|
|
|
def get_change_addresses(self, *, slice_start=None, slice_stop=None):
|
|
return self.db.get_change_addresses(slice_start=slice_start, slice_stop=slice_stop)
|
|
|
|
@profiler
|
|
def try_detecting_internal_addresses_corruption(self):
|
|
addresses_all = self.get_addresses()
|
|
# sample 1: first few
|
|
addresses_sample1 = addresses_all[:10]
|
|
# sample2: a few more randomly selected
|
|
addresses_rand = addresses_all[10:]
|
|
addresses_sample2 = random.sample(addresses_rand, min(len(addresses_rand), 10))
|
|
for addr_found in itertools.chain(addresses_sample1, addresses_sample2):
|
|
self.check_address_for_corruption(addr_found)
|
|
|
|
def check_address_for_corruption(self, addr):
|
|
if addr and self.is_mine(addr):
|
|
if addr != self.derive_address(*self.get_address_index(addr)):
|
|
raise InternalAddressCorruption()
|
|
|
|
def get_seed(self, password):
|
|
return self.keystore.get_seed(password)
|
|
|
|
def change_gap_limit(self, value):
|
|
'''This method is not called in the code, it is kept for console use'''
|
|
value = int(value)
|
|
if value >= self.min_acceptable_gap():
|
|
self.gap_limit = value
|
|
self.db.put('gap_limit', self.gap_limit)
|
|
self.save_db()
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def num_unused_trailing_addresses(self, addresses):
|
|
k = 0
|
|
for addr in addresses[::-1]:
|
|
if self.db.get_addr_history(addr):
|
|
break
|
|
k += 1
|
|
return k
|
|
|
|
def min_acceptable_gap(self) -> int:
|
|
# fixme: this assumes wallet is synchronized
|
|
n = 0
|
|
nmax = 0
|
|
addresses = self.get_receiving_addresses()
|
|
k = self.num_unused_trailing_addresses(addresses)
|
|
for addr in addresses[0:-k]:
|
|
if self.address_is_old(addr):
|
|
n = 0
|
|
else:
|
|
n += 1
|
|
nmax = max(nmax, n)
|
|
return nmax + 1
|
|
|
|
@abstractmethod
|
|
def derive_pubkeys(self, c: int, i: int) -> Sequence[str]:
|
|
pass
|
|
|
|
def derive_address(self, for_change: int, n: int) -> str:
|
|
for_change = int(for_change)
|
|
pubkeys = self.derive_pubkeys(for_change, n)
|
|
return self.pubkeys_to_address(pubkeys)
|
|
|
|
def export_private_key_for_path(self, path: Union[Sequence[int], str], password: Optional[str]) -> str:
|
|
if isinstance(path, str):
|
|
path = convert_bip32_path_to_list_of_uint32(path)
|
|
pk, compressed = self.keystore.get_private_key(path, password)
|
|
txin_type = self.get_txin_type() # assumes no mixed-scripts in wallet
|
|
return bitcoin.serialize_privkey(pk, compressed, txin_type)
|
|
|
|
def get_public_keys_with_deriv_info(self, address: str):
|
|
der_suffix = self.get_address_index(address)
|
|
der_suffix = [int(x) for x in der_suffix]
|
|
return {k.derive_pubkey(*der_suffix): (k, der_suffix)
|
|
for k in self.get_keystores()}
|
|
|
|
def _add_input_sig_info(self, txin, address, *, only_der_suffix):
|
|
self._add_txinout_derivation_info(txin, address, only_der_suffix=only_der_suffix)
|
|
|
|
def _add_txinout_derivation_info(self, txinout, address, *, only_der_suffix):
|
|
if not self.is_mine(address):
|
|
return
|
|
pubkey_deriv_info = self.get_public_keys_with_deriv_info(address)
|
|
txinout.pubkeys = sorted([pk for pk in list(pubkey_deriv_info)])
|
|
for pubkey in pubkey_deriv_info:
|
|
ks, der_suffix = pubkey_deriv_info[pubkey]
|
|
fp_bytes, der_full = ks.get_fp_and_derivation_to_be_used_in_partial_tx(der_suffix,
|
|
only_der_suffix=only_der_suffix)
|
|
txinout.bip32_paths[pubkey] = (fp_bytes, der_full)
|
|
|
|
def create_new_address(self, for_change: bool = False):
|
|
assert type(for_change) is bool
|
|
with self.lock:
|
|
n = self.db.num_change_addresses() if for_change else self.db.num_receiving_addresses()
|
|
address = self.derive_address(int(for_change), n)
|
|
self.db.add_change_address(address) if for_change else self.db.add_receiving_address(address)
|
|
self.add_address(address)
|
|
if for_change:
|
|
# note: if it's actually "old", it will get filtered later
|
|
self._not_old_change_addresses.append(address)
|
|
return address
|
|
|
|
def synchronize_sequence(self, for_change):
|
|
limit = self.gap_limit_for_change if for_change else self.gap_limit
|
|
while True:
|
|
num_addr = self.db.num_change_addresses() if for_change else self.db.num_receiving_addresses()
|
|
if num_addr < limit:
|
|
self.create_new_address(for_change)
|
|
continue
|
|
if for_change:
|
|
last_few_addresses = self.get_change_addresses(slice_start=-limit)
|
|
else:
|
|
last_few_addresses = self.get_receiving_addresses(slice_start=-limit)
|
|
if any(map(self.address_is_old, last_few_addresses)):
|
|
self.create_new_address(for_change)
|
|
else:
|
|
break
|
|
|
|
@AddressSynchronizer.with_local_height_cached
|
|
def synchronize(self):
|
|
with self.lock:
|
|
self.synchronize_sequence(False)
|
|
self.synchronize_sequence(True)
|
|
|
|
def get_all_known_addresses_beyond_gap_limit(self):
|
|
# note that we don't stop at first large gap
|
|
found = set()
|
|
|
|
def process_addresses(addrs, gap_limit):
|
|
rolling_num_unused = 0
|
|
for addr in addrs:
|
|
if self.db.get_addr_history(addr):
|
|
rolling_num_unused = 0
|
|
else:
|
|
if rolling_num_unused >= gap_limit:
|
|
found.add(addr)
|
|
rolling_num_unused += 1
|
|
|
|
process_addresses(self.get_receiving_addresses(), self.gap_limit)
|
|
process_addresses(self.get_change_addresses(), self.gap_limit_for_change)
|
|
return found
|
|
|
|
def get_address_index(self, address) -> Optional[Sequence[int]]:
|
|
return self.db.get_address_index(address) or self._ephemeral_addr_to_addr_index.get(address)
|
|
|
|
def get_address_path_str(self, address):
|
|
intpath = self.get_address_index(address)
|
|
if intpath is None:
|
|
return None
|
|
return convert_bip32_intpath_to_strpath(intpath)
|
|
|
|
def _learn_derivation_path_for_address_from_txinout(self, txinout, address):
|
|
for ks in self.get_keystores():
|
|
pubkey, der_suffix = ks.find_my_pubkey_in_txinout(txinout, only_der_suffix=True)
|
|
if der_suffix is not None:
|
|
# note: we already know the pubkey belongs to the keystore,
|
|
# but the script template might be different
|
|
if len(der_suffix) != 2: continue
|
|
try:
|
|
my_address = self.derive_address(*der_suffix)
|
|
except CannotDerivePubkey:
|
|
my_address = None
|
|
if my_address == address:
|
|
self._ephemeral_addr_to_addr_index[address] = list(der_suffix)
|
|
return True
|
|
return False
|
|
|
|
def get_master_public_keys(self):
|
|
return [self.get_master_public_key()]
|
|
|
|
def get_fingerprint(self):
|
|
return self.get_master_public_key()
|
|
|
|
def get_txin_type(self, address=None):
|
|
return self.txin_type
|
|
|
|
|
|
class Simple_Deterministic_Wallet(Simple_Wallet, Deterministic_Wallet):
|
|
|
|
""" Deterministic Wallet with a single pubkey per address """
|
|
|
|
def __init__(self, db, storage, *, config):
|
|
Deterministic_Wallet.__init__(self, db, storage, config=config)
|
|
|
|
def get_public_key(self, address):
|
|
sequence = self.get_address_index(address)
|
|
pubkeys = self.derive_pubkeys(*sequence)
|
|
return pubkeys[0]
|
|
|
|
def load_keystore(self):
|
|
self.keystore = load_keystore(self.db, 'keystore')
|
|
try:
|
|
xtype = bip32.xpub_type(self.keystore.xpub)
|
|
except:
|
|
xtype = 'standard'
|
|
self.txin_type = 'p2pkh' if xtype == 'standard' else xtype
|
|
|
|
def get_master_public_key(self):
|
|
return self.keystore.get_master_public_key()
|
|
|
|
def derive_pubkeys(self, c, i):
|
|
return [self.keystore.derive_pubkey(c, i).hex()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Standard_Wallet(Simple_Deterministic_Wallet):
|
|
wallet_type = 'standard'
|
|
|
|
def pubkeys_to_address(self, pubkeys):
|
|
pubkey = pubkeys[0]
|
|
return bitcoin.pubkey_to_address(self.txin_type, pubkey)
|
|
|
|
|
|
class Multisig_Wallet(Deterministic_Wallet):
|
|
# generic m of n
|
|
|
|
def __init__(self, db, storage, *, config):
|
|
self.wallet_type = db.get('wallet_type')
|
|
self.m, self.n = multisig_type(self.wallet_type)
|
|
Deterministic_Wallet.__init__(self, db, storage, config=config)
|
|
|
|
def get_public_keys(self, address):
|
|
return [pk.hex() for pk in self.get_public_keys_with_deriv_info(address)]
|
|
|
|
def pubkeys_to_address(self, pubkeys):
|
|
redeem_script = self.pubkeys_to_scriptcode(pubkeys)
|
|
return bitcoin.redeem_script_to_address(self.txin_type, redeem_script)
|
|
|
|
def pubkeys_to_scriptcode(self, pubkeys: Sequence[str]) -> str:
|
|
return transaction.multisig_script(sorted(pubkeys), self.m)
|
|
|
|
def get_redeem_script(self, address):
|
|
txin_type = self.get_txin_type(address)
|
|
pubkeys = self.get_public_keys(address)
|
|
scriptcode = self.pubkeys_to_scriptcode(pubkeys)
|
|
if txin_type == 'p2sh':
|
|
return scriptcode
|
|
elif txin_type == 'p2wsh-p2sh':
|
|
return bitcoin.p2wsh_nested_script(scriptcode)
|
|
elif txin_type == 'p2wsh':
|
|
return None
|
|
raise UnknownTxinType(f'unexpected txin_type {txin_type}')
|
|
|
|
def get_witness_script(self, address):
|
|
txin_type = self.get_txin_type(address)
|
|
pubkeys = self.get_public_keys(address)
|
|
scriptcode = self.pubkeys_to_scriptcode(pubkeys)
|
|
if txin_type == 'p2sh':
|
|
return None
|
|
elif txin_type in ('p2wsh-p2sh', 'p2wsh'):
|
|
return scriptcode
|
|
raise UnknownTxinType(f'unexpected txin_type {txin_type}')
|
|
|
|
def derive_pubkeys(self, c, i):
|
|
return [k.derive_pubkey(c, i).hex() for k in self.get_keystores()]
|
|
|
|
def load_keystore(self):
|
|
self.keystores = {}
|
|
for i in range(self.n):
|
|
name = 'x%d/'%(i+1)
|
|
self.keystores[name] = load_keystore(self.db, name)
|
|
self.keystore = self.keystores['x1/']
|
|
xtype = bip32.xpub_type(self.keystore.xpub)
|
|
self.txin_type = 'p2sh' if xtype == 'standard' else xtype
|
|
|
|
def save_keystore(self):
|
|
for name, k in self.keystores.items():
|
|
self.db.put(name, k.dump())
|
|
|
|
def get_keystore(self):
|
|
return self.keystores.get('x1/')
|
|
|
|
def get_keystores(self):
|
|
return [self.keystores[i] for i in sorted(self.keystores.keys())]
|
|
|
|
def can_have_keystore_encryption(self):
|
|
return any([k.may_have_password() for k in self.get_keystores()])
|
|
|
|
def _update_password_for_keystore(self, old_pw, new_pw):
|
|
for name, keystore in self.keystores.items():
|
|
if keystore.may_have_password():
|
|
keystore.update_password(old_pw, new_pw)
|
|
self.db.put(name, keystore.dump())
|
|
|
|
def check_password(self, password):
|
|
for name, keystore in self.keystores.items():
|
|
if keystore.may_have_password():
|
|
keystore.check_password(password)
|
|
if self.has_storage_encryption():
|
|
self.storage.check_password(password)
|
|
|
|
def get_available_storage_encryption_version(self):
|
|
# multisig wallets are not offered hw device encryption
|
|
return StorageEncryptionVersion.USER_PASSWORD
|
|
|
|
def has_seed(self):
|
|
return self.keystore.has_seed()
|
|
|
|
def is_watching_only(self):
|
|
return all([k.is_watching_only() for k in self.get_keystores()])
|
|
|
|
def get_master_public_key(self):
|
|
return self.keystore.get_master_public_key()
|
|
|
|
def get_master_public_keys(self):
|
|
return [k.get_master_public_key() for k in self.get_keystores()]
|
|
|
|
def get_fingerprint(self):
|
|
return ''.join(sorted(self.get_master_public_keys()))
|
|
|
|
|
|
wallet_types = ['standard', 'multisig', 'imported']
|
|
|
|
def register_wallet_type(category):
|
|
wallet_types.append(category)
|
|
|
|
wallet_constructors = {
|
|
'standard': Standard_Wallet,
|
|
'old': Standard_Wallet,
|
|
'xpub': Standard_Wallet,
|
|
'imported': Imported_Wallet
|
|
}
|
|
|
|
def register_constructor(wallet_type, constructor):
|
|
wallet_constructors[wallet_type] = constructor
|
|
|
|
# former WalletFactory
|
|
class Wallet(object):
|
|
"""The main wallet "entry point".
|
|
This class is actually a factory that will return a wallet of the correct
|
|
type when passed a WalletStorage instance."""
|
|
|
|
def __new__(self, db: 'WalletDB', storage: Optional[WalletStorage], *, config: SimpleConfig):
|
|
wallet_type = db.get('wallet_type')
|
|
WalletClass = Wallet.wallet_class(wallet_type)
|
|
wallet = WalletClass(db, storage, config=config)
|
|
return wallet
|
|
|
|
@staticmethod
|
|
def wallet_class(wallet_type):
|
|
if multisig_type(wallet_type):
|
|
return Multisig_Wallet
|
|
if wallet_type in wallet_constructors:
|
|
return wallet_constructors[wallet_type]
|
|
raise WalletFileException("Unknown wallet type: " + str(wallet_type))
|
|
|
|
|
|
def create_new_wallet(*, path, config: SimpleConfig, passphrase=None, password=None,
|
|
encrypt_file=True, seed_type=None, gap_limit=None) -> dict:
|
|
"""Create a new wallet"""
|
|
storage = WalletStorage(path)
|
|
if storage.file_exists():
|
|
raise Exception("Remove the existing wallet first!")
|
|
db = WalletDB('', manual_upgrades=False)
|
|
|
|
seed = Mnemonic('en').make_seed(seed_type=seed_type)
|
|
k = keystore.from_seed(seed, passphrase)
|
|
db.put('keystore', k.dump())
|
|
db.put('wallet_type', 'standard')
|
|
if k.can_have_deterministic_lightning_xprv():
|
|
db.put('lightning_xprv', k.get_lightning_xprv(None))
|
|
if gap_limit is not None:
|
|
db.put('gap_limit', gap_limit)
|
|
wallet = Wallet(db, storage, config=config)
|
|
wallet.update_password(old_pw=None, new_pw=password, encrypt_storage=encrypt_file)
|
|
wallet.synchronize()
|
|
msg = "Please keep your seed in a safe place; if you lose it, you will not be able to restore your wallet."
|
|
wallet.save_db()
|
|
return {'seed': seed, 'wallet': wallet, 'msg': msg}
|
|
|
|
|
|
def restore_wallet_from_text(text, *, path, config: SimpleConfig,
|
|
passphrase=None, password=None, encrypt_file=True,
|
|
gap_limit=None) -> dict:
|
|
"""Restore a wallet from text. Text can be a seed phrase, a master
|
|
public key, a master private key, a list of bitcoin addresses
|
|
or bitcoin private keys."""
|
|
storage = WalletStorage(path)
|
|
if storage.file_exists():
|
|
raise Exception("Remove the existing wallet first!")
|
|
db = WalletDB('', manual_upgrades=False)
|
|
text = text.strip()
|
|
if keystore.is_address_list(text):
|
|
wallet = Imported_Wallet(db, storage, config=config)
|
|
addresses = text.split()
|
|
good_inputs, bad_inputs = wallet.import_addresses(addresses, write_to_disk=False)
|
|
# FIXME tell user about bad_inputs
|
|
if not good_inputs:
|
|
raise Exception("None of the given addresses can be imported")
|
|
elif keystore.is_private_key_list(text, allow_spaces_inside_key=False):
|
|
k = keystore.Imported_KeyStore({})
|
|
db.put('keystore', k.dump())
|
|
wallet = Imported_Wallet(db, storage, config=config)
|
|
keys = keystore.get_private_keys(text, allow_spaces_inside_key=False)
|
|
good_inputs, bad_inputs = wallet.import_private_keys(keys, None, write_to_disk=False)
|
|
# FIXME tell user about bad_inputs
|
|
if not good_inputs:
|
|
raise Exception("None of the given privkeys can be imported")
|
|
else:
|
|
if keystore.is_master_key(text):
|
|
k = keystore.from_master_key(text)
|
|
elif keystore.is_seed(text):
|
|
k = keystore.from_seed(text, passphrase)
|
|
if k.can_have_deterministic_lightning_xprv():
|
|
db.put('lightning_xprv', k.get_lightning_xprv(None))
|
|
else:
|
|
raise Exception("Seed or key not recognized")
|
|
db.put('keystore', k.dump())
|
|
db.put('wallet_type', 'standard')
|
|
if gap_limit is not None:
|
|
db.put('gap_limit', gap_limit)
|
|
wallet = Wallet(db, storage, config=config)
|
|
assert not storage.file_exists(), "file was created too soon! plaintext keys might have been written to disk"
|
|
wallet.update_password(old_pw=None, new_pw=password, encrypt_storage=encrypt_file)
|
|
wallet.synchronize()
|
|
msg = ("This wallet was restored offline. It may contain more addresses than displayed. "
|
|
"Start a daemon and use load_wallet to sync its history.")
|
|
wallet.save_db()
|
|
return {'wallet': wallet, 'msg': msg}
|
|
|
|
|
|
def check_password_for_directory(config: SimpleConfig, old_password, new_password=None) -> Tuple[bool, bool]:
|
|
"""Checks password against all wallets, returns whether they can be unified and whether they are already.
|
|
If new_password is not None, update all wallet passwords to new_password.
|
|
"""
|
|
dirname = os.path.dirname(config.get_wallet_path())
|
|
failed = []
|
|
is_unified = True
|
|
for filename in os.listdir(dirname):
|
|
path = os.path.join(dirname, filename)
|
|
if not os.path.isfile(path):
|
|
continue
|
|
basename = os.path.basename(path)
|
|
storage = WalletStorage(path)
|
|
if not storage.is_encrypted():
|
|
is_unified = False
|
|
# it is a bit wasteful load the wallet here, but that is fine
|
|
# because we are progressively enforcing storage encryption.
|
|
try:
|
|
db = WalletDB(storage.read(), manual_upgrades=False)
|
|
wallet = Wallet(db, storage, config=config)
|
|
except:
|
|
_logger.exception(f'failed to load {basename}:')
|
|
failed.append(basename)
|
|
continue
|
|
if wallet.has_keystore_encryption():
|
|
try:
|
|
wallet.check_password(old_password)
|
|
except:
|
|
failed.append(basename)
|
|
continue
|
|
if new_password:
|
|
wallet.update_password(old_password, new_password)
|
|
else:
|
|
if new_password:
|
|
wallet.update_password(None, new_password)
|
|
continue
|
|
if not storage.is_encrypted_with_user_pw():
|
|
failed.append(basename)
|
|
continue
|
|
try:
|
|
storage.check_password(old_password)
|
|
except:
|
|
failed.append(basename)
|
|
continue
|
|
try:
|
|
db = WalletDB(storage.read(), manual_upgrades=False)
|
|
wallet = Wallet(db, storage, config=config)
|
|
except:
|
|
_logger.exception(f'failed to load {basename}:')
|
|
failed.append(basename)
|
|
continue
|
|
try:
|
|
wallet.check_password(old_password)
|
|
except:
|
|
failed.append(basename)
|
|
continue
|
|
if new_password:
|
|
wallet.update_password(old_password, new_password)
|
|
can_be_unified = failed == []
|
|
is_unified = can_be_unified and is_unified
|
|
return can_be_unified, is_unified
|
|
|
|
|
|
def update_password_for_directory(config: SimpleConfig, old_password, new_password) -> bool:
|
|
" returns whether password is unified "
|
|
if new_password is None:
|
|
# we opened a non-encrypted wallet
|
|
return False
|
|
can_be_unified, is_unified = check_password_for_directory(config, old_password, None)
|
|
if not can_be_unified:
|
|
return False
|
|
if is_unified and old_password == new_password:
|
|
return True
|
|
check_password_for_directory(config, old_password, new_password)
|
|
return True
|
|
|