draft implementation of optional bip329 fields

This commit is contained in:
Craig Raw 2025-02-08 11:43:59 +02:00
parent 1140a678ad
commit 20d3f07059
4 changed files with 180 additions and 35 deletions

View file

@ -1369,7 +1369,7 @@ public class AppController implements Initializable {
public void exportWallet(ActionEvent event) { public void exportWallet(ActionEvent event) {
WalletForm selectedWalletForm = getSelectedWalletForm(); WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) { if(selectedWalletForm != null) {
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm); WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm, getSelectedWalletForms());
dlg.initOwner(rootStack.getScene().getWindow()); dlg.initOwner(rootStack.getScene().getWindow());
Optional<Wallet> wallet = dlg.showAndWait(); Optional<Wallet> wallet = dlg.showAndWait();
if(wallet.isPresent()) { if(wallet.isPresent()) {

View file

@ -18,8 +18,8 @@ import java.util.List;
public class WalletExportDialog extends Dialog<Wallet> { public class WalletExportDialog extends Dialog<Wallet> {
private Wallet wallet; private Wallet wallet;
public WalletExportDialog(WalletForm walletForm) { public WalletExportDialog(WalletForm selectedWalletForm, List<WalletForm> allWalletForms) {
this.wallet = walletForm.getWallet(); this.wallet = selectedWalletForm.getWallet();
EventManager.get().register(this); EventManager.get().register(this);
setOnCloseRequest(event -> { setOnCloseRequest(event -> {
@ -45,10 +45,10 @@ 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(), new WalletLabels(), new WalletTransactions(walletForm)); exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm));
} else if(wallet.getPolicyType() == PolicyType.MULTI) { } else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(), exporters = List.of(new Bip129(), 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(), new WalletTransactions(walletForm)); new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm));
} 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

@ -1,33 +1,40 @@
package com.sparrowwallet.sparrow.io; package com.sparrowwallet.sparrow.io;
import com.csvreader.CsvReader; import com.csvreader.CsvReader;
import com.google.gson.Gson; import com.google.gson.*;
import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor; import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent; import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent; import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent; import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.wallet.*; import com.sparrowwallet.sparrow.wallet.*;
import org.apache.commons.lang3.time.DateUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.*; import java.io.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class WalletLabels implements WalletImport, WalletExport { public class WalletLabels implements WalletImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(WalletLabels.class); private static final Logger log = LoggerFactory.getLogger(WalletLabels.class);
private static final long ONE_DAY = 24*60*60*1000L;
private final List<WalletForm> walletForms; private final List<WalletForm> walletForms;
public WalletLabels() {
this.walletForms = Collections.emptyList();
}
public WalletLabels(List<WalletForm> walletForms) { public WalletLabels(List<WalletForm> walletForms) {
this.walletForms = walletForms; this.walletForms = walletForms;
} }
@ -50,8 +57,9 @@ public class WalletLabels implements WalletImport, WalletExport {
@Override @Override
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException { public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
List<Label> labels = new ArrayList<>(); List<Label> labels = new ArrayList<>();
List<Wallet> allWallets = wallet.isMasterWallet() ? wallet.getAllWallets() : wallet.getMasterWallet().getAllWallets(); Map<Date, Double> fiatRates = getFiatRates(walletForms);
for(Wallet exportWallet : allWallets) { for(WalletForm exportWalletForm : walletForms) {
Wallet exportWallet = exportWalletForm.getWallet();
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet); OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet);
String origin = outputDescriptor.toString(true, false, false); String origin = outputDescriptor.toString(true, false, false);
@ -61,34 +69,38 @@ public class WalletLabels implements WalletImport, WalletExport {
} }
} }
for(BlockTransaction blkTx : exportWallet.getWalletTransactions().values()) { WalletTransactionsEntry walletTransactionsEntry = exportWalletForm.getWalletTransactionsEntry();
if(blkTx.getLabel() != null && !blkTx.getLabel().isEmpty()) { for(Entry entry : walletTransactionsEntry.getChildren()) {
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin, null)); TransactionEntry txEntry = (TransactionEntry)entry;
} BlockTransaction blkTx = txEntry.getBlockTransaction();
labels.add(new TransactionLabel(blkTx.getHashAsString(), blkTx.getLabel(), origin,
txEntry.isConfirming() ? null : blkTx.getHeight(), blkTx.getDate(),
blkTx.getFee() == null || blkTx.getFee() == 0 ? null : blkTx.getFee(), txEntry.getValue(),
getFiatValue(blkTx.getDate(), Transaction.SATOSHIS_PER_BITCOIN, fiatRates)));
} }
for(WalletNode addressNode : exportWallet.getWalletAddresses().values()) { for(WalletNode addressNode : exportWallet.getWalletAddresses().values()) {
if(addressNode.getLabel() != null && !addressNode.getLabel().isEmpty()) { labels.add(new AddressLabel(addressNode.getAddress().toString(), addressNode.getLabel(), origin, addressNode.getDerivationPath().substring(1),
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null, null)); addressNode.getTransactionOutputs().stream().flatMap(txo -> txo.isSpent() ? Stream.of(txo, txo.getSpentBy()) : Stream.of(txo)).map(BlockTransactionHash::getHeight).toList()));
}
} }
for(BlockTransactionHashIndex txo : exportWallet.getWalletTxos().keySet()) { for(Map.Entry<BlockTransactionHashIndex, WalletNode> txoEntry : exportWallet.getWalletTxos().entrySet()) {
String spendable = (txo.isSpent() ? null : txo.getStatus() == Status.FROZEN ? "false" : "true"); BlockTransactionHashIndex txo = txoEntry.getKey();
if(txo.getLabel() != null && !txo.getLabel().isEmpty()) { WalletNode addressNode = txoEntry.getValue();
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null, spendable)); Boolean spendable = (txo.isSpent() ? null : txo.getStatus() != Status.FROZEN);
} else if(!txo.isSpent()) { labels.add(new InputOutputLabel(Type.output, txo.toString(), txo.getLabel(), origin, spendable, addressNode.getDerivationPath().substring(1),
labels.add(new Label(Type.output, txo.toString(), null, null, spendable)); txo.getValue(), txo.getHeight(), txo.getDate(), getFiatValue(txo, fiatRates)));
}
if(txo.isSpent() && txo.getSpentBy().getLabel() != null && !txo.getSpentBy().getLabel().isEmpty()) { if(txo.isSpent()) {
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null, null)); BlockTransactionHashIndex txi = txo.getSpentBy();
labels.add(new InputOutputLabel(Type.input, txi.toString(), txi.getLabel(), origin, null, addressNode.getDerivationPath().substring(1),
txi.getValue(), null, null, getFiatValue(txi, fiatRates)));
} }
} }
} }
try { try {
Gson gson = new Gson(); Gson gson = new GsonBuilder().registerTypeAdapter(Date.class, new GsonUTCDateAdapter()).create();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
for(Label label : labels) { for(Label label : labels) {
@ -247,11 +259,11 @@ public class WalletLabels implements WalletImport, WalletExport {
addChangedEntry(changedWalletEntries, txioEntry); addChangedEntry(changedWalletEntries, txioEntry);
} }
if(label.type == Type.output && !reference.isSpent()) { if(label.type == Type.output && !reference.isSpent() && label.spendable != null) {
if("false".equalsIgnoreCase(label.spendable) && reference.getStatus() != Status.FROZEN) { if(!label.spendable && reference.getStatus() != Status.FROZEN) {
reference.setStatus(Status.FROZEN); reference.setStatus(Status.FROZEN);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry); addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
} else if("true".equalsIgnoreCase(label.spendable) && reference.getStatus() == Status.FROZEN) { } else if(label.spendable && reference.getStatus() == Status.FROZEN) {
reference.setStatus(null); reference.setStatus(null);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry); addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
} }
@ -324,12 +336,77 @@ public class WalletLabels implements WalletImport, WalletExport {
return true; return true;
} }
private Map<Date, Double> getFiatRates(List<WalletForm> walletForms) {
ExchangeSource exchangeSource = getExchangeSource();
Currency fiatCurrency = getFiatCurrency();
Map<Date, Double> fiatRates = new HashMap<>();
if(fiatCurrency != null) {
long min = Long.MAX_VALUE;
long max = Long.MIN_VALUE;
for(WalletForm walletForm : walletForms) {
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
if(!walletTransactionsEntry.getChildren().isEmpty()) {
LongSummaryStatistics stats = walletTransactionsEntry.getChildren().stream()
.map(entry -> ((TransactionEntry)entry).getBlockTransaction().getDate())
.filter(Objects::nonNull)
.collect(Collectors.summarizingLong(Date::getTime));
min = Math.min(min, stats.getMin());
max = Math.max(max, stats.getMax());
}
}
if(max > min) {
fiatRates = exchangeSource.getHistoricalExchangeRates(fiatCurrency, new Date(min - ONE_DAY), new Date(max));
}
}
return fiatRates;
}
private static ExchangeSource getExchangeSource() {
return Config.get().getExchangeSource() == null ? ExchangeSource.COINGECKO : Config.get().getExchangeSource();
}
private static Currency getFiatCurrency() {
return getExchangeSource() == ExchangeSource.NONE || !AppServices.onlineProperty().get() ? null : Config.get().getFiatCurrency();
}
private Map<Currency, BigDecimal> getFiatValue(TransactionEntry txEntry, Map<Date, Double> fiatRates) {
return getFiatValue(txEntry.getBlockTransaction().getDate(), txEntry.getValue(), fiatRates);
}
private Map<Currency, BigDecimal> getFiatValue(BlockTransactionHashIndex ref, Map<Date, Double> fiatRates) {
return getFiatValue(ref.getDate(), ref.getValue(), fiatRates);
}
private Map<Currency, BigDecimal> getFiatValue(Date date, long value, Map<Date, Double> fiatRates) {
Currency fiatCurrency = getFiatCurrency();
if(fiatCurrency != null) {
Double dayRate = null;
if(date == null) {
if(AppServices.getFiatCurrencyExchangeRate() != null) {
dayRate = AppServices.getFiatCurrencyExchangeRate().getBtcRate();
}
} else {
dayRate = fiatRates.get(DateUtils.truncate(date, Calendar.DAY_OF_MONTH));
}
if(dayRate != null) {
BigDecimal fiatValue = BigDecimal.valueOf(dayRate * value / Transaction.SATOSHIS_PER_BITCOIN);
return Map.of(fiatCurrency, fiatValue.setScale(fiatCurrency.getDefaultFractionDigits(), RoundingMode.HALF_UP));
}
}
return null;
}
private enum Type { private enum Type {
tx, addr, pubkey, input, output, xpub tx, addr, pubkey, input, output, xpub
} }
private static class Label { private static class Label {
public Label(Type type, String ref, String label, String origin, String spendable) { public Label(Type type, String ref, String label, String origin, Boolean spendable) {
this.type = type; this.type = type;
this.ref = ref; this.ref = ref;
this.label = label; this.label = label;
@ -341,6 +418,74 @@ public class WalletLabels implements WalletImport, WalletExport {
String ref; String ref;
String label; String label;
String origin; String origin;
String spendable; Boolean spendable;
}
private static class TransactionLabel extends Label {
public TransactionLabel(String ref, String label, String origin, Integer height, Date time, Long fee, Long value, Map<Currency, BigDecimal> rate) {
super(Type.tx, ref, label, origin, null);
this.height = height;
this.time = time;
this.fee = fee;
this.value = value;
this.rate = rate;
}
Integer height;
Date time;
Long fee;
Long value;
Map<Currency, BigDecimal> rate;
}
private static class AddressLabel extends Label {
public AddressLabel(String ref, String label, String origin, String keypath, List<Integer> heights) {
super(Type.addr, ref, label, origin, null);
this.keypath = keypath;
this.heights = heights;
}
String keypath;
List<Integer> heights;
}
private static class InputOutputLabel extends Label {
public InputOutputLabel(Type type, String ref, String label, String origin, Boolean spendable, String keypath, Long value, Integer height, Date time, Map<Currency, BigDecimal> fmv) {
super(type, ref, label, origin, spendable);
this.keypath = keypath;
this.value = value;
this.height = height;
this.time = time;
this.fmv = fmv;
}
String keypath;
Long value;
Integer height;
Date time;
Map<Currency, BigDecimal> fmv;
}
public static class GsonUTCDateAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {
private final DateFormat dateFormat;
public GsonUTCDateAdapter() {
dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
}
@Override
public JsonElement serialize(Date src, java.lang.reflect.Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(dateFormat.format(src));
}
@Override
public Date deserialize(JsonElement json, java.lang.reflect.Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
try {
return dateFormat.parse(json.getAsString());
} catch (ParseException e) {
throw new JsonParseException(e);
}
}
} }
} }

View file

@ -591,7 +591,7 @@ public class SettingsController extends WalletFormController implements Initiali
} }
if(walletForm instanceof SettingsWalletForm settingsWalletForm) { if(walletForm instanceof SettingsWalletForm settingsWalletForm) {
WalletExportDialog dlg = new WalletExportDialog(settingsWalletForm.getAppWalletForm()); WalletExportDialog dlg = new WalletExportDialog(settingsWalletForm.getAppWalletForm(), List.of(settingsWalletForm.getAppWalletForm()));
dlg.initOwner(export.getScene().getWindow()); dlg.initOwner(export.getScene().getWindow());
dlg.showAndWait(); dlg.showAndWait();
} else { } else {