diff --git a/electrum/commands.py b/electrum/commands.py index 2d6442e32..436c1e99d 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -816,26 +816,26 @@ class Commands: await self.addtransaction(result, wallet=wallet) return result - @command('w') - async def onchain_history(self, year=None, show_addresses=False, show_fiat=False, wallet: Abstract_Wallet = None, - from_height=None, to_height=None): - """Wallet onchain history. Returns the transaction history of your wallet.""" - kwargs = { - 'show_addresses': show_addresses, - 'from_height': from_height, - 'to_height': to_height, - } + def get_year_timestamps(self, year:int): + kwargs = {} if year: import time start_date = datetime.datetime(year, 1, 1) end_date = datetime.datetime(year+1, 1, 1) kwargs['from_timestamp'] = time.mktime(start_date.timetuple()) kwargs['to_timestamp'] = time.mktime(end_date.timetuple()) - if show_fiat: - from .exchange_rate import FxThread - kwargs['fx'] = self.daemon.fx if self.daemon else FxThread(config=self.config) + return kwargs - return json_normalize(wallet.get_detailed_history(**kwargs)) + @command('w') + async def onchain_capital_gains(self, year=None, wallet: Abstract_Wallet = None): + """ + Capital gains, using utxo pricing. + This cannot be used with lightning. + """ + kwargs = self.get_year_timestamps(year) + from .exchange_rate import FxThread + fx = self.daemon.fx if self.daemon else FxThread(config=self.config) + return json_normalize(wallet.get_onchain_capital_gains(fx, **kwargs)) @command('wp') async def bumpfee(self, tx, new_fee_rate, from_coins=None, decrease_payment=False, password=None, unsigned=False, wallet: Abstract_Wallet = None): @@ -867,11 +867,34 @@ class Commands: wallet.sign_transaction(new_tx, password) return new_tx.serialize() + @command('w') + async def onchain_history(self, show_fiat=False, year=None, show_addresses=False, wallet: Abstract_Wallet = None): + """Wallet onchain history. Returns the transaction history of your wallet.""" + kwargs = self.get_year_timestamps(year) + onchain_history = wallet.get_onchain_history(**kwargs) + out = [x.to_dict() for x in onchain_history.values()] + if show_fiat: + from .exchange_rate import FxThread + fx = self.daemon.fx if self.daemon else FxThread(config=self.config) + else: + fx = None + for item in out: + if show_addresses: + tx = wallet.db.get_transaction(item['txid']) + item['inputs'] = list(map(lambda x: x.to_json(), tx.inputs())) + item['outputs'] = list(map(lambda x: {'address': x.get_ui_address_str(), 'value_sat': x.value}, + tx.outputs())) + if fx: + fiat_fields = wallet.get_tx_item_fiat(tx_hash=item['txid'], amount_sat=item['amount_sat'], fx=fx, tx_fee=item['fee_sat']) + item.update(fiat_fields) + return json_normalize(out) + @command('wl') - async def lightning_history(self, show_fiat=False, wallet: Abstract_Wallet = None): - """ lightning history """ - lightning_history = wallet.lnworker.get_history() if wallet.lnworker else [] - return json_normalize(lightning_history) + async def lightning_history(self, wallet: Abstract_Wallet = None): + """ lightning history. """ + lightning_history = wallet.lnworker.get_lightning_history() if wallet.lnworker else {} + sorted_hist= sorted(lightning_history.values(), key=lambda x: x.timestamp) + return json_normalize([x.to_dict() for x in sorted_hist]) @command('w') async def setlabel(self, key, label, wallet: Abstract_Wallet = None): diff --git a/electrum/gui/qml/components/LightningPaymentDetails.qml b/electrum/gui/qml/components/LightningPaymentDetails.qml index beef2a5af..31ea3bfda 100644 --- a/electrum/gui/qml/components/LightningPaymentDetails.qml +++ b/electrum/gui/qml/components/LightningPaymentDetails.qml @@ -144,11 +144,13 @@ Pane { Layout.topMargin: constants.paddingSmall text: qsTr('Payment hash') color: Material.accentColor + visible: lnpaymentdetails.paymentHash } TextHighlightPane { Layout.columnSpan: 2 Layout.fillWidth: true + visible: lnpaymentdetails.paymentHash RowLayout { width: parent.width @@ -177,11 +179,13 @@ Pane { Layout.topMargin: constants.paddingSmall text: qsTr('Preimage') color: Material.accentColor + visible: lnpaymentdetails.preimage } TextHighlightPane { Layout.columnSpan: 2 Layout.fillWidth: true + visible: lnpaymentdetails.preimage RowLayout { width: parent.width diff --git a/electrum/gui/qml/qelnpaymentdetails.py b/electrum/gui/qml/qelnpaymentdetails.py index 190a0ef06..574fb1b01 100644 --- a/electrum/gui/qml/qelnpaymentdetails.py +++ b/electrum/gui/qml/qelnpaymentdetails.py @@ -96,16 +96,16 @@ class QELnPaymentDetails(QObject): return # TODO this is horribly inefficient. need a payment getter/query method - tx = self._wallet.wallet.lnworker.get_lightning_history()[bfh(self._key)] + tx = self._wallet.wallet.lnworker.get_lightning_history()[self._key] self._logger.debug(str(tx)) - self._fee.msatsInt = 0 if not tx['fee_msat'] else int(tx['fee_msat']) - self._amount.msatsInt = int(tx['amount_msat']) - self._label = tx['label'] - self._date = format_time(tx['timestamp']) - self._timestamp = tx['timestamp'] + self._fee.msatsInt = 0 if not tx.fee_msat else int(tx.fee_msat) + self._amount.msatsInt = int(tx.amount_msat) + self._label = tx.label + self._date = format_time(tx.timestamp) + self._timestamp = tx.timestamp self._status = 'settled' # TODO: other states? get_lightning_history is deciding the filter for us :( - self._phash = tx['payment_hash'] - self._preimage = tx['preimage'] + self._phash = tx.payment_hash + self._preimage = tx.preimage self.detailsChanged.emit() diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 96b8275e7..b93ff505c 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -127,15 +127,14 @@ class QETransactionListModel(QAbstractListModel, QtEventListener): #self._logger.debug(str(tx_item)) item = tx_item - item['key'] = item['txid'] if 'txid' in item else item['payment_hash'] + item['key'] = item.get('txid') or item.get('group_id') or item['payment_hash'] if 'lightning' not in item: item['lightning'] = False if item['lightning']: item['value'] = QEAmount(amount_sat=item['value'].value, amount_msat=item['amount_msat']) - if item['type'] == 'payment': - item['incoming'] = True if item['direction'] == 'received' else False + item['incoming'] = True if item['amount_msat'] > 0 else False item['confirmations'] = 0 else: item['value'] = QEAmount(amount_sat=item['value'].value) diff --git a/electrum/gui/qml/qetxdetails.py b/electrum/gui/qml/qetxdetails.py index d6a894063..f8c3275e5 100644 --- a/electrum/gui/qml/qetxdetails.py +++ b/electrum/gui/qml/qetxdetails.py @@ -330,20 +330,13 @@ class QETxDetails(QObject, QtEventListener): self._sighash_danger = self._wallet.wallet.check_sighash(self._tx) if self._wallet.wallet.lnworker: - # Calling lnworker.get_onchain_history and wallet.get_full_history here - # is inefficient. We should probably pass the tx_item to the constructor. - lnworker_history = self._wallet.wallet.lnworker.get_onchain_history() - if self._txid in lnworker_history: - item = lnworker_history[self._txid] - group_id = item.get('group_id') - if group_id: - full_history = self._wallet.wallet.get_full_history() - group_item = full_history['group:' + group_id] - self._lnamount.satsInt = int(group_item['ln_value'].value) - else: - self._lnamount.satsInt = int(item['amount_msat'] / 1000) - else: - self._lnamount.satsInt = 0 + # Calling wallet.get_full_history here is inefficient. + # We should probably pass the tx_item to the constructor. + full_history = self._wallet.wallet.get_full_history() + item = full_history.get('group:' + self._txid) + self._lnamount.satsInt = int(item['ln_value'].value) if item else 0 + else: + self._lnamount.satsInt = 0 self._is_complete = self._tx.is_complete() self._is_rbf_enabled = self._tx.is_rbf_enabled() diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py index e582c86ed..2e307e69e 100644 --- a/electrum/gui/qt/history_list.py +++ b/electrum/gui/qt/history_list.py @@ -601,11 +601,10 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): self.main_window.show_message(_("Enable fiat exchange rate with history.")) return fx = self.main_window.fx - h = self.wallet.get_detailed_history( + summary = self.wallet.get_onchain_capital_gains( from_timestamp=time.mktime(self.start_date.timetuple()) if self.start_date else None, to_timestamp=time.mktime(self.end_date.timetuple()) if self.end_date else None, fx=fx) - summary = h['summary'] if not summary: self.main_window.show_message(_("Nothing to summarize.")) return @@ -738,7 +737,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): # can happen e.g. before list is populated for the first time return tx_item = idx.internalPointer().get_data() - if tx_item.get('lightning') and tx_item['type'] == 'payment': + if tx_item.get('lightning'): menu = QMenu() menu.addAction(_("Details"), lambda: self.main_window.show_lightning_transaction(tx_item)) cc = self.add_copy_menu(menu, idx) diff --git a/electrum/gui/qt/lightning_tx_dialog.py b/electrum/gui/qt/lightning_tx_dialog.py index 8c61a69e6..2ea94fd4d 100644 --- a/electrum/gui/qt/lightning_tx_dialog.py +++ b/electrum/gui/qt/lightning_tx_dialog.py @@ -45,7 +45,6 @@ class LightningTxDialog(WindowModalDialog): WindowModalDialog.__init__(self, parent, _("Lightning Payment")) self.main_window = parent self.config = parent.config - self.is_sent = tx_item['direction'] == PaymentDirection.SENT self.label = tx_item['label'] self.timestamp = tx_item['timestamp'] self.amount = Decimal(tx_item['amount_msat']) / 1000 @@ -61,8 +60,8 @@ class LightningTxDialog(WindowModalDialog): self.setLayout(vbox) amount_str = self.main_window.format_amount_and_units(self.amount, timestamp=self.timestamp) vbox.addWidget(QLabel(_("Amount") + f": {amount_str}")) - if self.is_sent: - fee_msat = tx_item['fee_msat'] + fee_msat = tx_item.get('fee_msat') + if fee_msat is not None: fee_sat = Decimal(fee_msat) / 1000 if fee_msat is not None else None fee_str = self.main_window.format_amount_and_units(fee_sat, timestamp=self.timestamp) vbox.addWidget(QLabel(_("Fee: {}").format(fee_str))) diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index 8830779ce..152afa2c8 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -808,14 +808,15 @@ class TxDialog(QDialog, MessageBoxMixin): if txid is not None and fx.is_enabled() and amount is not None: tx_item_fiat = self.wallet.get_tx_item_fiat( tx_hash=txid, amount_sat=abs(amount), fx=fx, tx_fee=fee) - lnworker_history = self.wallet.lnworker.get_onchain_history() if self.wallet.lnworker else {} - if txid in lnworker_history: - item = lnworker_history[txid] - ln_amount = item['amount_msat'] / 1000 - if amount is None: - tx_mined_status = self.wallet.adb.get_tx_height(txid) + + if self.wallet.lnworker: + # if it is a group, collect ln amount + full_history = self.wallet.get_full_history() + item = full_history.get('group:' + txid) + ln_amount = item['ln_value'].value if item else None else: ln_amount = None + self.broadcast_button.setEnabled(tx_details.can_broadcast) can_sign = not self.tx.is_complete() and \ (self.wallet.can_sign(self.tx) or bool(self.external_keypairs)) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index ccbb67500..693d07734 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -36,7 +36,7 @@ from .util import ( profiler, OldTaskGroup, ESocksProxy, NetworkRetryManager, JsonRPCClient, NotEnoughFunds, EventListener, event_listener, bfh, InvoiceError, resolve_dns_srv, is_ip_address, log_exceptions, ignore_exceptions, make_aiohttp_session, timestamp_to_datetime, random_shuffled_copy, is_private_netaddress, - UnrelatedTransactionException + UnrelatedTransactionException, LightningHistoryItem ) from .invoices import Invoice, PR_UNPAID, PR_PAID, PR_INFLIGHT, PR_FAILED, LN_EXPIRY_NEVER, BaseInvoice from .bitcoin import COIN, opcodes, make_op_return, address_to_scripthash, DummyAddress @@ -48,7 +48,6 @@ from .transaction import ( from .crypto import ( sha256, chacha20_encrypt, chacha20_decrypt, pw_encode_with_version_and_mac, pw_decode_with_version_and_mac ) - from .lntransport import LNTransport, LNResponderTransport, LNTransportBase, LNPeerAddr, split_host_port, extract_nodeid, ConnStringFormatError from .lnpeer import Peer, LN_P2P_NETWORK_TIMEOUT from .lnaddr import lnencode, LnAddr, lndecode @@ -983,7 +982,11 @@ class LNWallet(LNWorker): timestamp = min([htlc_with_status.htlc.timestamp for htlc_with_status in plist]) return direction, amount_msat, fee_msat, timestamp - def get_lightning_history(self): + def get_lightning_history(self) -> Dict[str, LightningHistoryItem]: + """ + side effect: sets defaults labels + note that the result is not ordered + """ out = {} for payment_hash, plist in self.get_payments(status='settled').items(): if len(plist) == 0: @@ -995,94 +998,94 @@ class LNWallet(LNWorker): if not label and direction == PaymentDirection.FORWARDING: label = _('Forwarding') preimage = self.get_preimage(payment_hash).hex() - item = { - 'type': 'payment', - 'label': label, - 'timestamp': timestamp or 0, - 'date': timestamp_to_datetime(timestamp), - 'direction': direction, - 'amount_msat': amount_msat, - 'fee_msat': fee_msat, - 'payment_hash': key, - 'preimage': preimage, - } - item['group_id'] = self.swap_manager.get_group_id_for_payment_hash(payment_hash) + group_id = self.swap_manager.get_group_id_for_payment_hash(payment_hash) + item = LightningHistoryItem( + type = 'payment', + payment_hash = payment_hash.hex(), + preimage = preimage, + amount_msat = amount_msat, + fee_msat = fee_msat, + group_id = group_id, + timestamp = timestamp or 0, + label=label, + ) out[payment_hash] = item + for chan in itertools.chain(self.channels.values(), self.channel_backups.values()): # type: AbstractChannel + item = chan.get_funding_height() + if item is None: + continue + funding_txid, funding_height, funding_timestamp = item + label = _('Open channel') + ' ' + chan.get_id_for_log() + self.wallet.set_default_label(funding_txid, label) + self.wallet.set_group_label(funding_txid, label) + item = LightningHistoryItem( + type = 'channel_opening', + label = label, + group_id = funding_txid, + timestamp = funding_timestamp, + amount_msat = chan.balance(LOCAL, ctn=0), + fee_msat = None, + payment_hash = None, + preimage = None, + ) + out[funding_txid] = item + item = chan.get_closing_height() + if item is None: + continue + closing_txid, closing_height, closing_timestamp = item + label = _('Close channel') + ' ' + chan.get_id_for_log() + self.wallet.set_default_label(closing_txid, label) + self.wallet.set_group_label(closing_txid, label) + item = LightningHistoryItem( + type = 'channel_closing', + label = label, + group_id = closing_txid, + timestamp = closing_timestamp, + amount_msat = -chan.balance(LOCAL), + fee_msat = None, + payment_hash = None, + preimage = None, + ) + out[closing_txid] = item + + # sanity check + balance_msat = sum([x.amount_msat for x in out.values()]) + lb = sum(chan.balance(LOCAL) if not chan.is_closed() else 0 + for chan in self.channels.values()) + assert balance_msat == lb return out - def get_onchain_history(self): - out = {} + def get_groups_for_onchain_history(self) -> Dict[str, str]: + """ + returns dict: txid -> group_id + side effect: sets default labels + """ + groups = {} # add funding events for chan in itertools.chain(self.channels.values(), self.channel_backups.values()): # type: AbstractChannel item = chan.get_funding_height() if item is None: continue funding_txid, funding_height, funding_timestamp = item - tx_height = self.wallet.adb.get_tx_height(funding_txid) - self.wallet.set_default_label(chan.funding_outpoint.to_str(), _('Open channel') + ' ' + chan.get_id_for_log()) - item = { - 'channel_id': chan.channel_id.hex(), - 'type': 'channel_opening', - 'label': self.wallet.get_label_for_txid(funding_txid), - 'txid': funding_txid, - 'amount_msat': chan.balance(LOCAL, ctn=0), - 'direction': PaymentDirection.RECEIVED, - 'timestamp': tx_height.timestamp, - 'monotonic_timestamp': tx_height.timestamp or TX_TIMESTAMP_INF, - 'date': timestamp_to_datetime(tx_height.timestamp), - 'fee_sat': None, - 'fee_msat': None, - 'height': tx_height.height, - 'confirmations': tx_height.conf, - 'txpos_in_block': tx_height.txpos, - } # FIXME this data structure needs to be kept in ~sync with wallet.get_onchain_history - out[funding_txid] = item + groups[funding_txid] = funding_txid item = chan.get_closing_height() if item is None: continue closing_txid, closing_height, closing_timestamp = item - tx_height = self.wallet.adb.get_tx_height(closing_txid) - self.wallet.set_default_label(closing_txid, _('Close channel') + ' ' + chan.get_id_for_log()) - item = { - 'channel_id': chan.channel_id.hex(), - 'txid': closing_txid, - 'label': self.wallet.get_label_for_txid(closing_txid), - 'type': 'channel_closure', - 'amount_msat': -chan.balance(LOCAL), - 'direction': PaymentDirection.SENT, - 'timestamp': tx_height.timestamp, - 'monotonic_timestamp': tx_height.timestamp or TX_TIMESTAMP_INF, - 'date': timestamp_to_datetime(tx_height.timestamp), - 'fee_sat': None, - 'fee_msat': None, - 'height': tx_height.height, - 'confirmations': tx_height.conf, - 'txpos_in_block': tx_height.txpos, - } # FIXME this data structure needs to be kept in ~sync with wallet.get_onchain_history - out[closing_txid] = item + groups[closing_txid] = closing_txid d = self.swap_manager.get_groups_for_onchain_history() - for k,v in d.items(): - group_id = v.get('group_id') - group_label = v.get('group_label') - if group_id and group_label: - self.wallet.set_default_label(group_id, group_label) - out.update(d) - return out + for txid, v in d.items(): + group_id = v['group_id'] + label = v.get('label') + group_label = v.get('group_label') or label + groups[txid] = group_id + if label: + self.wallet.set_default_label(txid, label) + if group_label: + self.wallet.set_group_label(group_id, group_label) - def get_history(self): - out = list(self.get_lightning_history().values()) + list(self.get_onchain_history().values()) - # sort by timestamp - out.sort(key=lambda x: (x.get('timestamp') or float("inf"))) - balance_msat = 0 - for item in out: - balance_msat += item['amount_msat'] - item['balance_msat'] = balance_msat - - lb = sum(chan.balance(LOCAL) if not chan.is_closed() else 0 - for chan in self.channels.values()) - assert balance_msat == lb - return out + return groups def channel_peers(self) -> List[bytes]: node_ids = [chan.node_id for chan in self.channels.values() if not chan.is_closed()] diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index cc81c0e4a..b07d51c42 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -1234,8 +1234,6 @@ class SwapManager(Logger): label += f' (refundable in {-delta} blocks)' # fixme: only if unspent d[txid] = { 'group_id': txid, - 'amount_msat': 0, # must be zero for onchain tx - 'type': 'swap', 'label': label, 'group_label': group_label, } @@ -1244,8 +1242,7 @@ class SwapManager(Logger): # to the group (see wallet.get_full_history) d[swap.spending_txid] = { 'group_id': txid, - 'amount_msat': 0, # must be zero for onchain tx - 'type': 'swap', + 'group_label': group_label, 'label': _('Refund transaction'), } return d @@ -1254,10 +1251,7 @@ class SwapManager(Logger): # add group_id to swap transactions swap = self.get_swap(payment_hash) if swap: - if swap.is_reverse: - return swap.spending_txid - else: - return swap.funding_txid + return swap.spending_txid if swap.is_reverse else swap.funding_txid class SwapServerTransport(Logger): diff --git a/electrum/util.py b/electrum/util.py index 0aca54f49..a5237ee68 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -2184,3 +2184,56 @@ def get_nostr_ann_pow_amount(nostr_pubk: bytes, nonce: Optional[int]) -> int: digest = hash_function(hash_preimage + nonce.to_bytes(32, 'big')).digest() digest = int.from_bytes(digest, 'big') return hash_len_bits - digest.bit_length() + + +class OnchainHistoryItem(NamedTuple): + txid: str + amount_sat: int + fee_sat: int + balance_sat: int + tx_mined_status: TxMinedInfo + group_id: Optional[str] + label: str + monotonic_timestamp: int + group_id: Optional[str] + def to_dict(self): + return { + 'txid': self.txid, + 'amount_sat': self.amount_sat, + 'fee_sat': self.fee_sat, + 'height': self.tx_mined_status.height, + 'confirmations': self.tx_mined_status.conf, + 'timestamp': self.tx_mined_status.timestamp, + 'monotonic_timestamp': self.monotonic_timestamp, + 'incoming': True if self.amount_sat>0 else False, + 'bc_value': Satoshis(self.amount_sat), + 'bc_balance': Satoshis(self.balance_sat), + 'date': timestamp_to_datetime(self.tx_mined_status.timestamp), + 'txpos_in_block': self.tx_mined_status.txpos, + 'wanted_height': self.tx_mined_status.wanted_height, + 'label': self.label, + 'group_id': self.group_id, + } + +class LightningHistoryItem(NamedTuple): + payment_hash: str + preimage: str + amount_msat: int + fee_msat: Optional[int] + type: str + group_id: Optional[str] + timestamp: int + label: str + def to_dict(self): + return { + 'type': self.type, + 'label': self.label, + 'timestamp': self.timestamp or 0, + 'date': timestamp_to_datetime(self.timestamp), + 'amount_msat': self.amount_msat, + 'fee_msat': self.fee_msat, + 'payment_hash': self.payment_hash, + 'preimage': self.preimage, + 'group_id': self.group_id, + 'ln_value': Satoshis(Decimal(self.amount_msat) / 1000), + } diff --git a/electrum/wallet.py b/electrum/wallet.py index f733eb301..14eb41267 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -88,6 +88,7 @@ from .util import read_json_file, write_json_file, UserFacingException, FileImpo from .util import EventListener, event_listener from . import descriptor from .descriptor import Descriptor +from .util import OnchainHistoryItem, LightningHistoryItem if TYPE_CHECKING: from .network import Network @@ -1012,11 +1013,11 @@ class Abstract_Wallet(ABC, Logger, EventListener): """ with self.lock, self.transaction_lock: if self._last_full_history is None: - self._last_full_history = self.get_full_history(None, include_lightning=False) + self._last_full_history = self.get_onchain_history() # populate cache in chronological order (confirmed tx only) # todo: get_full_history should return unconfirmed tx topologically sorted for _txid, tx_item in self._last_full_history.items(): - if tx_item['height'] > 0: + if tx_item.tx_mined_status.height > 0: self.get_tx_parents(_txid) result = self._tx_parents_cache.get(txid, None) @@ -1145,28 +1146,53 @@ class Abstract_Wallet(ABC, Logger, EventListener): # return last balance return balance - def get_onchain_history(self, *, domain=None): + def get_onchain_history( + self, *, + domain=None, + from_timestamp=None, + to_timestamp=None, + from_height=None, + to_height=None) -> Dict[str, OnchainHistoryItem]: + # sanity check + 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 UserFacingException('timestamp and block height based filtering cannot be used together') + # call lnworker first, because it adds accounting addresses + groups = self.lnworker.get_groups_for_onchain_history() if self.lnworker else {} if domain is None: domain = self.get_addresses() + + now = time.time() + transactions = OrderedDictWithIndex() monotonic_timestamp = 0 for hist_item in self.adb.get_history(domain=domain): - monotonic_timestamp = max(monotonic_timestamp, (hist_item.tx_mined_status.timestamp or TX_TIMESTAMP_INF)) - d = { - '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, - 'wanted_height': hist_item.tx_mined_status.wanted_height, - } - yield d + timestamp = (hist_item.tx_mined_status.timestamp or TX_TIMESTAMP_INF) + height = hist_item.tx_mined_status + if from_timestamp and (timestamp or now) < from_timestamp: + continue + if to_timestamp and (timestamp or now) >= to_timestamp: + continue + 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 + monotonic_timestamp = max(monotonic_timestamp, timestamp) + txid = hist_item.txid + group_id = groups.get(txid) + label = self.get_label_for_txid(txid) + tx_item = OnchainHistoryItem( + txid=hist_item.txid, + amount_sat=hist_item.delta, + fee_sat=hist_item.fee, + balance_sat=hist_item.balance, + tx_mined_status=hist_item.tx_mined_status, + label=label, + monotonic_timestamp=monotonic_timestamp, + group_id=group_id, + ) + transactions[hist_item.txid] = tx_item + + return transactions def create_invoice(self, *, outputs: List[PartialTxOutput], message, pr, URI) -> Invoice: height = self.adb.get_local_height() @@ -1342,44 +1368,26 @@ class Abstract_Wallet(ABC, Logger, EventListener): return is_paid, conf_needed @profiler - def get_full_history(self, fx=None, *, onchain_domain=None, include_lightning=True, include_fiat=False): + def get_full_history(self, fx=None, *, onchain_domain=None, include_lightning=True, include_fiat=False) -> dict: + """ + includes both onchain and lightning + includes grouping information + """ 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 to transactions_tmp - # add group_id to tx that are in a group - 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) - if channel_id := item.get('channel_id'): - tx_item['channel_id'] = channel_id - 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) + for tx_item in onchain_history.values(): + txid = tx_item.txid + transactions_tmp[txid] = tx_item.to_dict() + transactions_tmp[txid]['lightning'] = False + # 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 + key = tx_item.payment_hash or 'ln:' + tx_item.group_id + transactions_tmp[key] = tx_item.to_dict() + transactions_tmp[key]['lightning'] = True + # sort on-chain and LN stuff into new dict, by timestamp # (we rely on this being a *stable* sort) def sort_key(x): @@ -1396,10 +1404,10 @@ class Abstract_Wallet(ABC, Logger, EventListener): else: key = 'group:' + group_id parent = transactions.get(key) - label = self.get_label_for_txid(group_id) + group_label = self.get_label_for_group(group_id) if parent is None: parent = { - 'label': label, + 'label': group_label, 'fiat_value': Fiat(Decimal(0), fx.ccy) if fx else None, 'bc_value': Satoshis(0), 'ln_value': Satoshis(0), @@ -1455,20 +1463,12 @@ class Abstract_Wallet(ABC, Logger, EventListener): 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): + def get_onchain_capital_gains(self, fx, **kwargs): # 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 UserFacingException('timestamp and block height based filtering cannot be used together') - + from_timestamp = kwargs.get('from_timestamp') + to_timestamp = kwargs.get('to_timestamp') + history = self.get_onchain_history(**kwargs) show_fiat = fx and fx.is_enabled() and fx.has_history() out = [] income = 0 @@ -1476,26 +1476,13 @@ class Abstract_Wallet(ABC, Logger, EventListener): capital_gains = Decimal(0) fiat_income = Decimal(0) fiat_expenditures = Decimal(0) - now = time.time() - for item in self.get_onchain_history(): + for txid, hitem in history.items(): + item = hitem.to_dict() + if item['bc_value'].value == 0: + continue 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: @@ -1506,7 +1493,6 @@ class Abstract_Wallet(ABC, Logger, EventListener): 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 @@ -1517,12 +1503,8 @@ class Abstract_Wallet(ABC, Logger, EventListener): 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'] + start_height = first_item['height'] - 1 + end_height = last_item['height'] b = first_item['bc_balance'].value v = first_item['bc_value'].value @@ -1583,10 +1565,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): else: summary = {} - return { - 'transactions': out, - 'summary': summary - } + return summary def acquisition_price(self, coins, price_func, ccy): return Decimal(sum(self.coin_price(coin.prevout.txid.hex(), price_func, ccy, self.adb.get_txin_value(coin)) for coin in coins)) @@ -1644,6 +1623,12 @@ class Abstract_Wallet(ABC, Logger, EventListener): def _get_default_label_for_outpoint(self, outpoint: str) -> str: return self._default_labels.get(outpoint) + def get_label_for_group(self, group_id: str) -> str: + return self._default_labels.get('group:' + group_id) + + def set_group_label(self, group_id: str, label: str): + self._default_labels['group:' + group_id] = label + 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)