implement bip329 for importing and exporting wallet labels

This commit is contained in:
Craig Raw 2023-02-08 08:03:06 +02:00
parent 8d584d1c48
commit 555260e954
12 changed files with 359 additions and 13 deletions

2
drongo

@ -1 +1 @@
Subproject commit b487396417fbdf3c73c24399a778855c97a26584 Subproject commit d48054ac6ba071b49180017fe12e3acb41635b9c

View file

@ -1068,13 +1068,16 @@ public class AppController implements Initializable {
} }
public void importWallet(ActionEvent event) { public void importWallet(ActionEvent event) {
WalletImportDialog dlg = new WalletImportDialog(); List<WalletForm> selectedWalletForms = getSelectedWalletForms();
WalletImportDialog dlg = new WalletImportDialog(selectedWalletForms);
Optional<Wallet> optionalWallet = dlg.showAndWait(); Optional<Wallet> optionalWallet = dlg.showAndWait();
if(optionalWallet.isPresent()) { if(optionalWallet.isPresent()) {
Wallet wallet = optionalWallet.get(); Wallet wallet = optionalWallet.get();
if(selectedWalletForms.isEmpty() || wallet != selectedWalletForms.get(0).getWallet()) {
addImportedWallet(wallet); addImportedWallet(wallet);
} }
} }
}
private boolean attemptImportWallet(File file, SecureString password) { private boolean attemptImportWallet(File file, SecureString password) {
List<WalletImport> walletImporters = List.of(new ColdcardSinglesig(), new ColdcardMultisig(), List<WalletImport> walletImporters = List.of(new ColdcardSinglesig(), new ColdcardMultisig(),
@ -1653,6 +1656,19 @@ public class AppController implements Initializable {
return null; return null;
} }
public List<WalletForm> getSelectedWalletForms() {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
if(selectedTab != null) {
TabData tabData = (TabData) selectedTab.getUserData();
if(tabData instanceof WalletTabData) {
TabPane subTabs = (TabPane) selectedTab.getContent();
return subTabs.getTabs().stream().map(subTab -> ((WalletTabData) subTab.getUserData()).getWalletForm()).collect(Collectors.toList());
}
}
return Collections.emptyList();
}
private void addTransactionTab(String name, File file, String string) throws ParseException, PSBTParseException, TransactionParseException { private void addTransactionTab(String name, File file, String string) throws ParseException, PSBTParseException, TransactionParseException {
if(Utils.isBase64(string) && !Utils.isHex(string)) { if(Utils.isBase64(string) && !Utils.isHex(string)) {
addTransactionTab(name, file, Base64.getDecoder().decode(string)); addTransactionTab(name, file, Base64.getDecoder().decode(string));

View file

@ -975,7 +975,7 @@ public class DevicePane extends TitledDescriptionPane {
} }
importButton.setVisible(true); importButton.setVisible(true);
showHideLink.setText("Show derivation..."); showHideLink.setText("Show derivation...");
showHideLink.setVisible(true); showHideLink.setVisible(!device.isCard());
List<ChildNumber> defaultDerivation = wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation(); List<ChildNumber> defaultDerivation = wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
setContent(getDerivationEntry(keyDerivation == null ? defaultDerivation : keyDerivation.getDerivation())); setContent(getDerivationEntry(keyDerivation == null ? defaultDerivation : keyDerivation.getDerivation()));
} else if(deviceOperation.equals(DeviceOperation.SIGN)) { } else if(deviceOperation.equals(DeviceOperation.SIGN)) {

View file

@ -42,9 +42,9 @@ public class WalletExportDialog extends Dialog<Wallet> {
List<WalletExport> exporters; List<WalletExport> exporters;
if(wallet.getPolicyType() == PolicyType.SINGLE) { if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow()); exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels());
} else if(wallet.getPolicyType() == PolicyType.MULTI) { } else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(), new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow()); exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(), new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels());
} else { } else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType()); throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
} }

View file

@ -9,6 +9,7 @@ import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.*; import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
import javafx.scene.control.*; import javafx.scene.control.*;
@ -16,6 +17,7 @@ import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane; import javafx.scene.layout.StackPane;
import org.controlsfx.glyphfont.Glyph; import org.controlsfx.glyphfont.Glyph;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
@ -24,7 +26,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
private final Accordion importAccordion; private final Accordion importAccordion;
private final Button scanButton; private final Button scanButton;
public WalletImportDialog() { public WalletImportDialog(List<WalletForm> selectedWalletForms) {
EventManager.get().register(this); EventManager.get().register(this);
setOnCloseRequest(event -> { setOnCloseRequest(event -> {
EventManager.get().unregister(this); EventManager.get().unregister(this);
@ -57,7 +59,10 @@ public class WalletImportDialog extends Dialog<Wallet> {
} }
} }
List<WalletImport> walletImporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow()); List<WalletImport> walletImporters = new ArrayList<>(List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow()));
if(!selectedWalletForms.isEmpty()) {
walletImporters.add(new WalletLabels(selectedWalletForms));
}
for(WalletImport importer : walletImporters) { for(WalletImport importer : walletImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) { if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletImportPane importPane = new FileWalletImportPane(importer); FileWalletImportPane importPane = new FileWalletImportPane(importer);

View file

@ -11,22 +11,29 @@ import java.util.*;
public class WalletEntryLabelsChangedEvent extends WalletChangedEvent { public class WalletEntryLabelsChangedEvent extends WalletChangedEvent {
//Contains the changed entry mapped to the entry that changed it, if changed recursively (otherwise null) //Contains the changed entry mapped to the entry that changed it, if changed recursively (otherwise null)
private final Map<Entry, Entry> entrySourceMap; private final Map<Entry, Entry> entrySourceMap;
private final boolean propagate;
public WalletEntryLabelsChangedEvent(Wallet wallet, Entry entry) { public WalletEntryLabelsChangedEvent(Wallet wallet, Entry entry) {
this(wallet, List.of(entry)); this(wallet, List.of(entry));
} }
public WalletEntryLabelsChangedEvent(Wallet wallet, List<Entry> entries) { public WalletEntryLabelsChangedEvent(Wallet wallet, List<Entry> entries) {
this(wallet, entries, true);
}
public WalletEntryLabelsChangedEvent(Wallet wallet, List<Entry> entries, boolean propagate) {
super(wallet); super(wallet);
this.entrySourceMap = new LinkedHashMap<>(); this.entrySourceMap = new LinkedHashMap<>();
for(Entry entry : entries) { for(Entry entry : entries) {
entrySourceMap.put(entry, null); entrySourceMap.put(entry, null);
} }
this.propagate = propagate;
} }
public WalletEntryLabelsChangedEvent(Wallet wallet, Map<Entry, Entry> entrySourceMap) { public WalletEntryLabelsChangedEvent(Wallet wallet, Map<Entry, Entry> entrySourceMap) {
super(wallet); super(wallet);
this.entrySourceMap = entrySourceMap; this.entrySourceMap = entrySourceMap;
this.propagate = true;
} }
public Collection<Entry> getEntries() { public Collection<Entry> getEntries() {
@ -36,4 +43,8 @@ public class WalletEntryLabelsChangedEvent extends WalletChangedEvent {
public Entry getSource(Entry entry) { public Entry getSource(Entry entry) {
return entrySourceMap.get(entry); return entrySourceMap.get(entry);
} }
public boolean propagate() {
return propagate;
}
} }

View file

@ -0,0 +1,296 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class WalletLabels implements WalletImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(WalletLabels.class);
private final List<WalletForm> walletForms;
public WalletLabels() {
this.walletForms = Collections.emptyList();
}
public WalletLabels(List<WalletForm> walletForms) {
this.walletForms = walletForms;
}
@Override
public boolean isEncrypted(File file) {
return false;
}
@Override
public String getName() {
return "Wallet Labels";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.LABELS;
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
List<Label> labels = new ArrayList<>();
List<Wallet> allWallets = wallet.isMasterWallet() ? wallet.getAllWallets() : wallet.getMasterWallet().getAllWallets();
for(Wallet exportWallet : allWallets) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet);
String origin = outputDescriptor.toString(true, false, false);
for(Keystore keystore : exportWallet.getKeystores()) {
if(keystore.getLabel() != null && !keystore.getLabel().isEmpty()) {
labels.add(new Label(Type.xpub, keystore.getExtendedPublicKey().toString(), keystore.getLabel(), null));
}
}
for(BlockTransaction blkTx : exportWallet.getWalletTransactions().values()) {
if(blkTx.getLabel() != null && !blkTx.getLabel().isEmpty()) {
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin));
}
}
for(WalletNode addressNode : exportWallet.getWalletAddresses().values()) {
if(addressNode.getLabel() != null && !addressNode.getLabel().isEmpty()) {
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null));
}
}
for(BlockTransactionHashIndex txo : exportWallet.getWalletTxos().keySet()) {
if(txo.getLabel() != null && !txo.getLabel().isEmpty()) {
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null));
}
if(txo.isSpent() && txo.getSpentBy().getLabel() != null && !txo.getSpentBy().getLabel().isEmpty()) {
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null));
}
}
}
try {
Gson gson = new Gson();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
for(Label label : labels) {
writer.write(gson.toJson(label) + "\n");
}
writer.flush();
} catch(Exception e) {
log.error("Error exporting labels", e);
throw new ExportException("Error exporting labels", e);
}
}
@Override
public String getWalletExportDescription() {
return "Exports a file containing labels from this wallet in the BIP329 standard format.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
return "jsonl";
}
@Override
public boolean isWalletExportScannable() {
return false;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
@Override
public String getWalletImportDescription() {
return "Imports a file containing labels in the BIP329 standard format to the currently selected wallet.";
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
if(walletForms.isEmpty()) {
throw new IllegalStateException("No wallets to import labels for");
}
Gson gson = new Gson();
List<Label> labels = new ArrayList<>();
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
String line;
while((line = reader.readLine()) != null) {
Label label;
try {
label = gson.fromJson(line, Label.class);
} catch(Exception e) {
continue;
}
if(label == null || label.type == null || label.ref == null || label.label == null || label.label.isEmpty()) {
continue;
}
labels.add(label);
}
} catch(Exception e) {
throw new ImportException("Error importing labels file", e);
}
Map<Wallet, List<Keystore>> changedWalletKeystores = new LinkedHashMap<>();
Map<Wallet, List<Entry>> changedWalletEntries = new LinkedHashMap<>();
for(WalletForm walletForm : walletForms) {
Wallet wallet = walletForm.getWallet();
if(!wallet.isValid()) {
continue;
}
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(wallet);
String origin = outputDescriptor.toString(true, false, false);
List<Entry> transactionEntries = walletForm.getWalletTransactionsEntry().getChildren();
List<Entry> addressEntries = new ArrayList<>();
addressEntries.addAll(walletForm.getNodeEntry(KeyPurpose.RECEIVE).getChildren());
addressEntries.addAll(walletForm.getNodeEntry(KeyPurpose.CHANGE).getChildren());
List<Entry> utxoEntries = walletForm.getWalletUtxosEntry().getChildren();
for(Label label : labels) {
if(label.origin != null && !label.origin.equals(origin)) {
continue;
}
if(label.type == Type.xpub) {
for(Keystore keystore : wallet.getKeystores()) {
if(keystore.getExtendedPublicKey().toString().equals(label.ref)) {
keystore.setLabel(label.label);
List<Keystore> changedKeystores = changedWalletKeystores.computeIfAbsent(wallet, w -> new ArrayList<>());
changedKeystores.add(keystore);
}
}
}
if(label.type == Type.tx) {
for(Entry entry : transactionEntries) {
if(entry instanceof TransactionEntry transactionEntry) {
BlockTransaction blkTx = transactionEntry.getBlockTransaction();
if(blkTx.getHashAsString().equals(label.ref)) {
transactionEntry.getBlockTransaction().setLabel(label.label);
transactionEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, entry);
}
}
}
}
if(label.type == Type.addr) {
for(Entry addressEntry : addressEntries) {
if(addressEntry instanceof NodeEntry nodeEntry) {
WalletNode addressNode = nodeEntry.getNode();
if(addressNode.getAddress().toString().equals(label.ref)) {
nodeEntry.getNode().setLabel(label.label);
nodeEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, addressEntry);
}
}
}
}
if(label.type == Type.output || label.type == Type.input) {
for(Entry entry : transactionEntries) {
for(Entry hashIndexEntry : entry.getChildren()) {
if(hashIndexEntry instanceof TransactionHashIndexEntry txioEntry) {
BlockTransactionHashIndex reference = txioEntry.getHashIndex();
if((label.type == Type.output && txioEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|| (label.type == Type.input && txioEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
txioEntry.getHashIndex().setLabel(label.label);
txioEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, txioEntry);
}
}
}
}
for(Entry addressEntry : addressEntries) {
for(Entry entry : addressEntry.getChildren()) {
updateHashIndexEntryLabel(label, entry);
for(Entry spentEntry : entry.getChildren()) {
updateHashIndexEntryLabel(label, spentEntry);
}
}
}
for(Entry entry : utxoEntries) {
updateHashIndexEntryLabel(label, entry);
}
}
}
}
for(Map.Entry<Wallet, List<Keystore>> walletKeystores : changedWalletKeystores.entrySet()) {
Wallet wallet = walletKeystores.getKey();
Storage storage = AppServices.get().getOpenWallets().get(wallet);
EventManager.get().post(new KeystoreLabelsChangedEvent(wallet, wallet, storage.getWalletId(wallet), walletKeystores.getValue()));
}
for(Map.Entry<Wallet, List<Entry>> walletEntries : changedWalletEntries.entrySet()) {
EventManager.get().post(new WalletEntryLabelsChangedEvent(walletEntries.getKey(), walletEntries.getValue(), false));
}
return walletForms.get(0).getWallet();
}
private static void updateHashIndexEntryLabel(Label label, Entry entry) {
if(entry instanceof HashIndexEntry hashIndexEntry) {
BlockTransactionHashIndex reference = hashIndexEntry.getHashIndex();
if((label.type == Type.output && hashIndexEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|| (label.type == Type.input && hashIndexEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
hashIndexEntry.labelProperty().set(label.label);
}
}
}
private static void addChangedEntry(Map<Wallet, List<Entry>> changedEntries, Entry entry) {
List<Entry> entries = changedEntries.computeIfAbsent(entry.getWallet(), wallet -> new ArrayList<>());
entries.add(entry);
}
@Override
public boolean isWalletImportScannable() {
return false;
}
@Override
public boolean exportsAllWallets() {
return true;
}
private enum Type {
tx, addr, pubkey, input, output, xpub
}
private static class Label {
public Label(Type type, String ref, String label, String origin) {
this.type = type;
this.ref = ref;
this.label = label;
this.origin = origin;
}
Type type;
String ref;
String label;
String origin;
}
}

View file

@ -13,6 +13,7 @@ import com.sparrowwallet.sparrow.io.CardApi;
import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.keystoreimport.KeystoreImportDialog; import com.sparrowwallet.sparrow.keystoreimport.KeystoreImportDialog;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.concurrent.Service; import javafx.concurrent.Service;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
import javafx.fxml.FXML; import javafx.fxml.FXML;
@ -96,6 +97,11 @@ public class KeystoreController extends WalletFormController implements Initiali
private final ValidationSupport validationSupport = new ValidationSupport(); private final ValidationSupport validationSupport = new ValidationSupport();
private final ChangeListener<String> labelChangeListener = (observable, oldValue, newValue) -> {
keystore.setLabel(newValue);
EventManager.get().post(new SettingsChangedEvent(walletForm.getWallet(), SettingsChangedEvent.Type.KEYSTORE_LABEL));
};
@Override @Override
public void initialize(URL location, ResourceBundle resources) { public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this); EventManager.get().register(this);
@ -152,10 +158,7 @@ public class KeystoreController extends WalletFormController implements Initiali
keystore.setKeyDerivation(new KeyDerivation("","")); keystore.setKeyDerivation(new KeyDerivation("",""));
} }
label.textProperty().addListener((observable, oldValue, newValue) -> { label.textProperty().addListener(labelChangeListener);
keystore.setLabel(newValue);
EventManager.get().post(new SettingsChangedEvent(walletForm.getWallet(), SettingsChangedEvent.Type.KEYSTORE_LABEL));
});
fingerprint.textProperty().addListener((observable, oldValue, newValue) -> { fingerprint.textProperty().addListener((observable, oldValue, newValue) -> {
keystore.setKeyDerivation(new KeyDerivation(newValue, keystore.getKeyDerivation().getDerivationPath())); keystore.setKeyDerivation(new KeyDerivation(newValue, keystore.getKeyDerivation().getDerivationPath()));
fingerprintIcon.setHex(newValue.length() == 8 ? newValue : null); fingerprintIcon.setHex(newValue.length() == 8 ? newValue : null);
@ -597,4 +600,18 @@ public class KeystoreController extends WalletFormController implements Initiali
} }
} }
} }
@Subscribe
public void keystoreLabelsChanged(KeystoreLabelsChangedEvent event) {
if(event.getWalletId().equals(walletForm.getWalletId())) {
for(Keystore changedKeystore : event.getChangedKeystores()) {
if(xpub.getText().trim().equals(changedKeystore.getExtendedPublicKey().toString()) && !label.getText().equals(changedKeystore.getLabel())) {
label.textProperty().removeListener(labelChangeListener);
label.setText(changedKeystore.getLabel());
keystore.setLabel(changedKeystore.getLabel());
label.textProperty().addListener(labelChangeListener);
}
}
}
}
} }

View file

@ -488,7 +488,8 @@ public class WalletForm {
public void walletLabelsChanged(WalletEntryLabelsChangedEvent event) { public void walletLabelsChanged(WalletEntryLabelsChangedEvent event) {
if(event.toThisOrNested(wallet)) { if(event.toThisOrNested(wallet)) {
Map<Entry, Entry> labelChangedEntries = new LinkedHashMap<>(); Map<Entry, Entry> labelChangedEntries = new LinkedHashMap<>();
for(Entry entry : event.getEntries()) { Collection<Entry> entries = event.propagate() ? event.getEntries() : Collections.emptyList();
for(Entry entry : entries) {
if(entry.getLabel() != null && !entry.getLabel().isEmpty()) { if(entry.getLabel() != null && !entry.getLabel().isEmpty()) {
if(entry instanceof TransactionEntry transactionEntry) { if(entry instanceof TransactionEntry transactionEntry) {
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) { for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB