diff --git a/electrum/bip329.py b/electrum/bip329.py new file mode 100644 index 000000000000..0a96ff3ebe05 --- /dev/null +++ b/electrum/bip329.py @@ -0,0 +1,128 @@ +import json + +class BIP329_Parser: + """ + """ + def __init__(self, json_stream): + self.json_stream = json_stream + self.entries = [] + + def load_entries(self): + self.entries = [] + try: + entries = self.json_stream.strip().split('\n') + for entry in entries: + try: + parsed_entry = json.loads(entry.strip()) + if self.is_valid_entry(parsed_entry): + self.entries.append(parsed_entry) + except json.JSONDecodeError: + print(f"Skipping invalid JSON line: {entry.strip()}") + except Exception as e: + print(f"Error processing stream: {e}") + return self.entries + + @staticmethod + def is_valid_entry(entry): + required_keys = {'type', 'ref'} + valid_types = {'tx', 'addr', 'pubkey', 'input', 'output', 'xpub'} + + if not required_keys.issubset(entry.keys()): + return False + + if 'type' not in entry or entry['type'] not in valid_types: + return False + + if entry['type'] == 'output': + if 'spendable' in entry and entry['spendable'] not in {'true', 'false', True, False}: + return False + + if 'ref' not in entry: + return False + + if 'label' in entry and not isinstance(entry['label'], str): + return False + + if 'origin' in entry and not isinstance(entry['origin'], str): + return False + + return entry + + +def is_json_file(path): + """ """ + try: + with open(path, 'r', encoding='utf-8') as file: + data = file.read() + # Attempt to parse the content as JSON + json.loads(data) + return True + except (ValueError, FileNotFoundError): + pass + return False + +def import_bip329_labels(stream, wallet): + """ + Import transaction and address labels, and manage coin (UTXO) state according to BIP-329. + Parameters: + stream: The stream object containing the BIP-329 formatted data (JSON Lines) to be imported. + wallet: The current wallet. + Behavior: + - The function parses the BIP-329 formatted data located at the specified `path`. + - It loads the entries from the data, including transaction labels, address labels, and coin information. + - For each entry, it performs the following actions based on the entry type: + - If the entry type is "addr" or "tx," it sets labels for transactions and addresses in the wallet. + - If the entry type is "output," it sets labels for specific transactions and determines whether the associated + coins should be spendable or frozen. Coins can be frozen by setting the "spendable" attribute to "false" or + `False`. See also "Coin Management". + Coin Management: + - The function also manages coins (UTXOs) by potentially freezing them based on the provided data. + - Transactions (TXns) are labeled before coin state management. + - Note that this "output" coin management may overwrite a previous "tx" entry if applicable. + - In the context of the Electrum export, TXns are exported before coin state information. + - By default, if no specific information is provided, imported UTXOs are considered spendable (not frozen). + Note: + This function is designed to be used with BIP-329 formatted data and a wallet that supports this standard. + Importing data from other formats *may* not yield the desired results. + Disclaimer: + Ensure that you have a backup of your wallet data before using this function, as it may modify labels and coin + states within your wallet. + """ + parser = BIP329_Parser(stream) + entries = parser.load_entries() + for entry in entries: + if entry.get('type', '') in ["addr", "tx"]: + # Set txns and address labels. + wallet.set_label(entry.get('ref', ''), entry.get('label', '')) + elif entry.get('type', '') == "output": + txid, out_idx = entry.get('ref', '').split(":") + wallet.set_label(txid, entry.get('label', '')) + # Set spendable or frozen. + if entry.get("spendable", True) in ["false", False]: + wallet.set_frozen_state_of_coins(utxos=[entry.get('ref', '')], freeze=True) + else: + wallet.set_frozen_state_of_coins(utxos=[entry.get('ref', '')], freeze=False) + + +def export_bip329_labels(stream, wallet): + """ + Transactions (TXns) are exported and labeled before coin state information (spendable). + """ + for key, value in wallet.get_all_labels().items(): + data = { + "type": "tx" if len(key) == 64 else "addr", + "ref": key, + "label": value + } + json_line = json.dumps(data, ensure_ascii=False) + stream.write(f"{json_line}\n") + + for utxo in wallet.get_utxos(): + data = { + "type": "output", + "ref": "{}:{}".format(utxo.prevout.txid.hex(), utxo.prevout.out_idx), + "label": wallet.get_label_for_address(utxo.address), + "spendable": "true" if not wallet.is_frozen_coin(utxo) else "false" + } + json_line = json.dumps(data, ensure_ascii=False) + stream.write(f"{json_line}\n") diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 16e1ebcf095d..8ccade167d39 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -2430,10 +2430,10 @@ def do_export_privkeys(self, fileName, pklist, is_csv): def do_import_labels(self): def on_import(): self.need_update.set() - import_meta_gui(self, _('labels'), self.wallet.import_labels, on_import) + import_meta_gui(self, _('labels'), self.wallet.import_labels, on_import, file_type="jsonl|json") def do_export_labels(self): - export_meta_gui(self, _('labels'), self.wallet.export_labels) + export_meta_gui(self, _('labels'), self.wallet.export_labels, file_type="jsonl") def import_invoices(self): import_meta_gui(self, _('invoices'), self.wallet.import_invoices, self.send_tab.invoice_list.update) @@ -2856,4 +2856,3 @@ def on_swap_result(self, txid): else: msg += _("Lightning funds were not received.") self.show_error_signal.emit(msg) - diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 52025412c3c4..94b16dd3d6ab 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -1034,8 +1034,11 @@ def onFileAdded(self, fn): raise NotImplementedError() -def import_meta_gui(electrum_window: 'ElectrumWindow', title, importer, on_success): - filter_ = "JSON (*.json);;All files (*)" +def import_meta_gui(electrum_window: 'ElectrumWindow', title, importer, on_success, file_type="json"): + if file_type == "json": + filter_ = "JSON (*.json);;All files (*)" + elif file_type == "jsonl|json": + filter_ = "JSONL (*.jsonl);;JSON (*.json);;All files (*)" filename = getOpenFileName( parent=electrum_window, title=_("Open {} file").format(title), @@ -1053,12 +1056,12 @@ def import_meta_gui(electrum_window: 'ElectrumWindow', title, importer, on_succe on_success() -def export_meta_gui(electrum_window: 'ElectrumWindow', title, exporter): - filter_ = "JSON (*.json);;All files (*)" +def export_meta_gui(electrum_window: 'ElectrumWindow', title, exporter, file_type="json"): + filter_ = "{} (*.{});;All files (*)".format(file_type.upper(), file_type) filename = getSaveFileName( parent=electrum_window, title=_("Select file to save your {}").format(title), - filename='electrum_{}.json'.format(title), + filename='electrum_{}.{}'.format(title, file_type), filter=filter_, config=electrum_window.config, ) diff --git a/electrum/wallet.py b/electrum/wallet.py index 996e191a4ebe..12dec5faf249 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -28,6 +28,7 @@ import os import sys +import io import random import time import json @@ -51,6 +52,7 @@ from .i18n import _ from .bip32 import BIP32Node, convert_bip32_intpath_to_strpath, convert_bip32_strpath_to_intpath +from .bip329 import export_bip329_labels, import_bip329_labels, is_json_file from .crypto import sha256 from . import util from .util import (NotEnoughFunds, UserCancelled, profiler, OldTaskGroup, ignore_exceptions, @@ -636,12 +638,24 @@ def set_label(self, name: str, text: str = None) -> bool: return changed def import_labels(self, path): - data = read_json_file(path) - for key, value in data.items(): - self.set_label(key, value) + if is_json_file(path): + data = read_json_file(path) # Process as `legacy format` + for key, value in data.items(): + self.set_label(key, value) + else: + with open(path, 'r', encoding='utf-8') as file: + data = file.read() + import_bip329_labels(stream=data, wallet=self) def export_labels(self, path): - write_json_file(path, self.get_all_labels()) + if isinstance(path, str): + output_stream = io.StringIO() + else: + output_stream = path + export_bip329_labels(stream=output_stream, wallet=self) + if isinstance(path, str): + with open(path, 'w', encoding='utf-8') as file: + file.write(output_stream.getvalue()) def set_fiat_value(self, txid, ccy, text, fx, value_sat): if not self.db.get_transaction(txid):