Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduced support for backward-compatible BIP-329 import and BIP-329 export. Bounty by @nvk #8614

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
128 changes: 128 additions & 0 deletions electrum/bip329.py
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 2 additions & 3 deletions electrum/gui/qt/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2856,4 +2856,3 @@ def on_swap_result(self, txid):
else:
msg += _("Lightning funds were not received.")
self.show_error_signal.emit(msg)

13 changes: 8 additions & 5 deletions electrum/gui/qt/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
)
Expand Down
22 changes: 18 additions & 4 deletions electrum/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

import os
import sys
import io
import random
import time
import json
Expand All @@ -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,
Expand Down Expand Up @@ -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):
Expand Down