From 1e3ce7eb886b270d23ea79ec25f7122d34ec45fa Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Tue, 21 Nov 2023 09:33:47 +0200 Subject: [PATCH] add historical fiat values to transactions csv export --- drongo | 2 +- .../sparrowwallet/sparrow/AppController.java | 2 +- .../com/sparrowwallet/sparrow/UnitFormat.java | 16 ++ .../sparrow/control/FileWalletExportPane.java | 43 ++++- .../sparrow/control/WalletExportDialog.java | 10 +- .../sparrow/io/WalletLabels.java | 2 +- .../sparrow/io/WalletTransactions.java | 173 ++++++++++++++++++ .../sparrow/net/ExchangeSource.java | 94 ++++++++++ .../sparrow/terminal/wallet/WalletData.java | 2 +- .../sparrow/wallet/SettingsController.java | 7 +- .../sparrow/wallet/SettingsWalletForm.java | 8 +- .../wallet/TransactionsController.java | 66 +------ .../sparrow/wallet/WalletController.java | 2 +- src/main/resources/image/transactions.png | Bin 0 -> 5890 bytes src/main/resources/image/transactions@2x.png | Bin 0 -> 7111 bytes src/main/resources/image/transactions@3x.png | Bin 0 -> 10396 bytes 16 files changed, 353 insertions(+), 74 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/WalletTransactions.java create mode 100644 src/main/resources/image/transactions.png create mode 100644 src/main/resources/image/transactions@2x.png create mode 100644 src/main/resources/image/transactions@3x.png diff --git a/drongo b/drongo index 94aafbc1..0815484c 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 94aafbc11e974e44ba53fe505940767ff77ef940 +Subproject commit 0815484c4cb384522cf215ef18fc69a666b43c37 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 7664a8ea..d1f1e207 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -1299,7 +1299,7 @@ public class AppController implements Initializable { public void exportWallet(ActionEvent event) { WalletForm selectedWalletForm = getSelectedWalletForm(); if(selectedWalletForm != null) { - WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm.getWallet()); + WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm); dlg.initOwner(rootStack.getScene().getWindow()); Optional wallet = dlg.showAndWait(); if(wallet.isPresent()) { diff --git a/src/main/java/com/sparrowwallet/sparrow/UnitFormat.java b/src/main/java/com/sparrowwallet/sparrow/UnitFormat.java index 85ec8331..9f367c24 100644 --- a/src/main/java/com/sparrowwallet/sparrow/UnitFormat.java +++ b/src/main/java/com/sparrowwallet/sparrow/UnitFormat.java @@ -12,6 +12,7 @@ public enum UnitFormat { private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols()); private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols()); private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols()); + private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols()); public DecimalFormat getBtcFormat() { btcFormat.setMaximumFractionDigits(8); @@ -30,6 +31,10 @@ public enum UnitFormat { return currencyFormat; } + public DecimalFormat getTableCurrencyFormat() { + return tableCurrencyFormat; + } + public DecimalFormatSymbols getDecimalFormatSymbols() { DecimalFormatSymbols symbols = new DecimalFormatSymbols(); symbols.setDecimalSeparator('.'); @@ -42,6 +47,7 @@ public enum UnitFormat { private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols()); private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols()); private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols()); + private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols()); public DecimalFormat getBtcFormat() { btcFormat.setMaximumFractionDigits(8); @@ -60,6 +66,10 @@ public enum UnitFormat { return currencyFormat; } + public DecimalFormat getTableCurrencyFormat() { + return tableCurrencyFormat; + } + public DecimalFormatSymbols getDecimalFormatSymbols() { DecimalFormatSymbols symbols = new DecimalFormatSymbols(); symbols.setDecimalSeparator(','); @@ -78,6 +88,8 @@ public enum UnitFormat { public abstract DecimalFormat getCurrencyFormat(); + public abstract DecimalFormat getTableCurrencyFormat(); + public String formatBtcValue(Long amount) { return getBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN); } @@ -94,6 +106,10 @@ public enum UnitFormat { return getCurrencyFormat().format(amount); } + public String tableFormatCurrencyValue(double amount) { + return getTableCurrencyFormat().format(amount); + } + public String getGroupingSeparator() { return Character.toString(getDecimalFormatSymbols().getGroupingSeparator()); } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java index d2704daf..7085f5f6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java @@ -10,6 +10,8 @@ import com.sparrowwallet.sparrow.event.TimedEvent; import com.sparrowwallet.sparrow.event.WalletExportEvent; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.*; +import javafx.concurrent.Service; +import javafx.concurrent.Task; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.Control; @@ -141,10 +143,19 @@ public class FileWalletExportPane extends TitledDescriptionPane { private void exportWallet(File file, Wallet exportWallet) { try { if(file != null) { - try(OutputStream outputStream = new FileOutputStream(file)) { - exporter.exportWallet(exportWallet, outputStream); + FileWalletExportService fileWalletExportService = new FileWalletExportService(exporter, file, exportWallet); + fileWalletExportService.setOnSucceeded(event -> { EventManager.get().post(new WalletExportEvent(exportWallet)); - } + }); + fileWalletExportService.setOnFailed(event -> { + Throwable e = event.getSource().getException(); + String errorMessage = e.getMessage(); + if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) { + errorMessage = e.getCause().getMessage(); + } + setError("Export Error", errorMessage); + }); + fileWalletExportService.start(); } else { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); exporter.exportWallet(exportWallet, outputStream); @@ -167,4 +178,30 @@ public class FileWalletExportPane extends TitledDescriptionPane { setError("Export Error", errorMessage); } } + + public static class FileWalletExportService extends Service { + private final WalletExport exporter; + private final File file; + private final Wallet wallet; + + public FileWalletExportService(WalletExport exporter, File file, Wallet wallet) { + this.exporter = exporter; + this.file = file; + this.wallet = wallet; + } + + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected Void call() throws Exception { + try(OutputStream outputStream = new FileOutputStream(file)) { + exporter.exportWallet(wallet, outputStream); + } + + return null; + } + }; + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java index 343cbb53..bbabd108 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java @@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.WalletExportEvent; +import com.sparrowwallet.sparrow.wallet.WalletForm; import com.sparrowwallet.sparrow.io.*; import javafx.scene.control.*; import javafx.scene.layout.AnchorPane; @@ -17,7 +18,9 @@ import java.util.List; public class WalletExportDialog extends Dialog { private Wallet wallet; - public WalletExportDialog(Wallet wallet) { + public WalletExportDialog(WalletForm walletForm) { + this.wallet = walletForm.getWallet(); + EventManager.get().register(this); setOnCloseRequest(event -> { EventManager.get().unregister(this); @@ -42,9 +45,10 @@ public class WalletExportDialog extends Dialog { List exporters; if(wallet.getPolicyType() == PolicyType.SINGLE) { - exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels()); + exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels(), new WalletTransactions(walletForm)); } else if(wallet.getPolicyType() == PolicyType.MULTI) { - 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()); + 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)); } else { throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType()); } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java b/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java index 8dac89c3..e771b5ae 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java @@ -37,7 +37,7 @@ public class WalletLabels implements WalletImport, WalletExport { @Override public String getName() { - return "Wallet Labels"; + return "Labels"; } @Override diff --git a/src/main/java/com/sparrowwallet/sparrow/io/WalletTransactions.java b/src/main/java/com/sparrowwallet/sparrow/io/WalletTransactions.java new file mode 100644 index 00000000..4ab1fb3a --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/WalletTransactions.java @@ -0,0 +1,173 @@ +package com.sparrowwallet.sparrow.io; + +import com.csvreader.CsvWriter; +import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.protocol.TransactionInput; +import com.sparrowwallet.drongo.protocol.TransactionOutput; +import com.sparrowwallet.drongo.wallet.BlockTransaction; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletModel; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.UnitFormat; +import com.sparrowwallet.sparrow.control.EntryCell; +import com.sparrowwallet.sparrow.net.ExchangeSource; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.TransactionEntry; +import com.sparrowwallet.sparrow.wallet.WalletForm; +import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry; +import org.apache.commons.lang3.time.DateUtils; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +public class WalletTransactions implements WalletExport { + private static final long ONE_DAY = 24*60*60*1000L; + + private final WalletForm walletForm; + + public WalletTransactions(WalletForm walletForm) { + this.walletForm = walletForm; + } + + @Override + public String getName() { + return "Transactions"; + } + + @Override + public WalletModel getWalletModel() { + return WalletModel.TRANSACTIONS; + } + + @Override + public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException { + WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry(); + + ExchangeSource exchangeSource = Config.get().getExchangeSource(); + if(Config.get().getExchangeSource() == null) { + exchangeSource = ExchangeSource.COINGECKO; + } + + Currency fiatCurrency = (exchangeSource == ExchangeSource.NONE || !AppServices.onlineProperty().get() ? null : Config.get().getFiatCurrency()); + Map fiatRates = new HashMap<>(); + if(fiatCurrency != null && !walletTransactionsEntry.getChildren().isEmpty()) { + LongSummaryStatistics stats = walletTransactionsEntry.getChildren().stream() + .map(entry -> ((TransactionEntry)entry).getBlockTransaction().getDate()) + .filter(Objects::nonNull) + .collect(Collectors.summarizingLong(Date::getTime)); + fiatRates = exchangeSource.getHistoricalExchangeRates(fiatCurrency, new Date(stats.getMin() - ONE_DAY), new Date(stats.getMax())); + } + + BitcoinUnit bitcoinUnit = Config.get().getBitcoinUnit(); + if(bitcoinUnit == null || bitcoinUnit.equals(BitcoinUnit.AUTO)) { + bitcoinUnit = walletForm.getWallet().getAutoUnit(); + } + + try { + CsvWriter writer = new CsvWriter(outputStream, ',', StandardCharsets.UTF_8); + + writer.write("Date"); + writer.write("Label"); + writer.write("Value"); + writer.write("Balance"); + writer.write("Fee"); + if(fiatCurrency != null) { + writer.write("Value (" + fiatCurrency.getCurrencyCode() + ")"); + } + writer.write("Txid"); + writer.endRecord(); + + for(Entry entry : walletTransactionsEntry.getChildren()) { + TransactionEntry txEntry = (TransactionEntry)entry; + writer.write(txEntry.getBlockTransaction().getDate() == null ? "Unconfirmed" : EntryCell.DATE_FORMAT.format(txEntry.getBlockTransaction().getDate())); + writer.write(txEntry.getLabel()); + writer.write(getCoinValue(bitcoinUnit, txEntry.getValue())); + writer.write(getCoinValue(bitcoinUnit, txEntry.getBalance())); + Long fee = txEntry.getValue() < 0 ? getFee(wallet, txEntry.getBlockTransaction()) : null; + writer.write(fee == null ? "" : getCoinValue(bitcoinUnit, fee)); + if(fiatCurrency != null) { + Double fiatValue = getFiatValue(txEntry, fiatRates); + writer.write(fiatValue == null ? "" : getFiatValue(fiatValue)); + } + writer.write(txEntry.getBlockTransaction().getHash().toString()); + writer.endRecord(); + } + writer.close(); + } catch(IOException e) { + throw new ExportException("Error writing transactions CSV", e); + } + } + + private Long getFee(Wallet wallet, BlockTransaction blockTransaction) { + long fee = 0L; + for(TransactionInput txInput : blockTransaction.getTransaction().getInputs()) { + if(txInput.isCoinBase()) { + return 0L; + } + + BlockTransaction inputTx = wallet.getWalletTransaction(txInput.getOutpoint().getHash()); + if(inputTx == null || inputTx.getTransaction().getOutputs().size() <= txInput.getOutpoint().getIndex()) { + return null; + } + TransactionOutput spentOutput = inputTx.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex()); + fee += spentOutput.getValue(); + } + + for(TransactionOutput txOutput : blockTransaction.getTransaction().getOutputs()) { + fee -= txOutput.getValue(); + } + + return fee; + } + + private String getCoinValue(BitcoinUnit bitcoinUnit, Long value) { + UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat(); + return BitcoinUnit.BTC.equals(bitcoinUnit) ? format.tableFormatBtcValue(value) : String.format(Locale.ENGLISH, "%d", value); + } + + private String getFiatValue(Double value) { + UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat(); + return format.tableFormatCurrencyValue(value); + } + + private Double getFiatValue(TransactionEntry txEntry, Map fiatRates) { + Double dayRate = null; + if(txEntry.getBlockTransaction().getDate() == null) { + if(AppServices.getFiatCurrencyExchangeRate() != null) { + dayRate = AppServices.getFiatCurrencyExchangeRate().getBtcRate(); + } + } else { + dayRate = fiatRates.get(DateUtils.truncate(txEntry.getBlockTransaction().getDate(), Calendar.DAY_OF_MONTH)); + } + + if(dayRate != null) { + return dayRate * txEntry.getValue() / Transaction.SATOSHIS_PER_BITCOIN; + } + + return null; + } + + @Override + public String getWalletExportDescription() { + return "Exports a CSV file containing dates and values of the transactions in this wallet. Additionally, if a fiat currency has been configured, the fiat value on the day they were first included in the blockchain."; + } + + @Override + public String getExportFileExtension(Wallet wallet) { + return "csv"; + } + + @Override + public boolean isWalletExportScannable() { + return false; + } + + @Override + public boolean walletExportRequiresDecryption() { + return false; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java b/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java index 7684308e..7745c8f4 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java @@ -1,13 +1,20 @@ package com.sparrowwallet.sparrow.net; +import com.sparrowwallet.nightjar.http.JavaHttpException; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent; import javafx.concurrent.ScheduledService; import javafx.concurrent.Service; import javafx.concurrent.Task; +import org.apache.commons.lang3.time.DateUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; @@ -22,6 +29,11 @@ public enum ExchangeSource { public Double getExchangeRate(Currency currency) { return null; } + + @Override + public Map getHistoricalExchangeRates(Currency currency, Date start, Date end) { + return Collections.emptyMap(); + } }, COINBASE("Coinbase") { @Override @@ -60,6 +72,52 @@ public enum ExchangeSource { return new CoinbaseRates(); } } + + @Override + public Map getHistoricalExchangeRates(Currency currency, Date start, Date end) { + Map historicalRates = new TreeMap<>(); + DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); + + Instant currentInstant = start.toInstant(); + Instant endInstant = end.toInstant(); + + while(currentInstant.isBefore(endInstant) || currentInstant.equals(endInstant)) { + Date fromDate = Date.from(currentInstant.atZone(ZoneId.systemDefault()).toInstant()); + currentInstant = currentInstant.plus(300, ChronoUnit.DAYS); + Date toDate = Date.from(currentInstant.atZone(ZoneId.systemDefault()).toInstant()); + toDate = toDate.after(end) ? end : toDate; + + String startTime = dateFormat.format(fromDate); + String endTime = dateFormat.format(toDate); + + String url = "https://api.pro.coinbase.com/products/BTC-" + currency.getCurrencyCode() + "/candles?start=" + startTime + "T12:00:00&end=" + endTime + "T12:00:00&granularity=86400"; + + if(log.isInfoEnabled()) { + log.info("Requesting historical exchange rates from " + url); + } + + HttpClientService httpClientService = AppServices.getHttpClientService(); + try { + Number[][] coinbaseData = httpClientService.requestJson(url, Number[][].class, Map.of("User-Agent", "Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)", "Accept", "*/*")); + for(Number[] price : coinbaseData) { + Date date = new Date(price[0].longValue() * 1000); + historicalRates.put(DateUtils.truncate(date, Calendar.DAY_OF_MONTH), price[3].doubleValue()); + } + } catch(Exception e) { + if(log.isDebugEnabled()) { + log.warn("Error retrieving historical currency rates", e); + } else { + if(e instanceof JavaHttpException javaHttpException && javaHttpException.getStatusCode() == 404) { + log.warn("Error retrieving historical currency rates (" + e.getMessage() + "). BTC-" + currency.getCurrencyCode() + " may not be supported by " + this); + } else { + log.warn("Error retrieving historical currency rates (" + e.getMessage() + ")"); + } + } + } + } + + return historicalRates; + } }, COINGECKO("Coingecko") { @Override @@ -98,6 +156,36 @@ public enum ExchangeSource { return new CoinGeckoRates(); } } + + @Override + public Map getHistoricalExchangeRates(Currency currency, Date start, Date end) { + long startDate = start.getTime() / 1000; + long endDate = end.getTime() / 1000; + + String url = "https://api.coingecko.com/api/v3/coins/bitcoin/market_chart/range?vs_currency=" + currency.getCurrencyCode() + "&from=" + startDate + "&to=" + endDate; + + if(log.isInfoEnabled()) { + log.info("Requesting historical exchange rates from " + url); + } + + Map historicalRates = new TreeMap<>(); + HttpClientService httpClientService = AppServices.getHttpClientService(); + try { + CoinGeckoHistoricalRates coinGeckoHistoricalRates = httpClientService.requestJson(url, CoinGeckoHistoricalRates.class, null); + for(List historicalRate : coinGeckoHistoricalRates.prices) { + Date date = new Date(historicalRate.get(0).longValue()); + historicalRates.put(DateUtils.truncate(date, Calendar.DAY_OF_MONTH), historicalRate.get(1).doubleValue()); + } + } catch(Exception e) { + if(log.isDebugEnabled()) { + log.warn("Error retrieving historical currency rates", e); + } else { + log.warn("Error retrieving historical currency rates (" + e.getMessage() + ")"); + } + } + + return historicalRates; + } }; private static final Logger log = LoggerFactory.getLogger(ExchangeSource.class); @@ -112,6 +200,8 @@ public enum ExchangeSource { public abstract Double getExchangeRate(Currency currency); + public abstract Map getHistoricalExchangeRates(Currency currency, Date start, Date end); + private static boolean isValidISO4217Code(String code) { try { Currency currency = Currency.getInstance(code); @@ -185,4 +275,8 @@ public enum ExchangeSource { public Double value; public String type; } + + private static class CoinGeckoHistoricalRates { + public List> prices = new ArrayList<>(); + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletData.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletData.java index 3309fc56..fc5b8e10 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletData.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletData.java @@ -58,7 +58,7 @@ public class WalletData { public SettingsDialog getSettingsDialog() { if(settingsDialog == null) { - SettingsWalletForm settingsWalletForm = new SettingsWalletForm(walletForm.getStorage(), walletForm.getWallet()); + SettingsWalletForm settingsWalletForm = new SettingsWalletForm(walletForm.getStorage(), walletForm.getWallet(), walletForm); settingsDialog = new SettingsDialog(settingsWalletForm); EventManager.get().register(settingsDialog); } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java index 716caf7e..9bcef974 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java @@ -508,11 +508,8 @@ public class SettingsController extends WalletFormController implements Initiali throw new IllegalStateException("Cannot export unsaved wallet"); } - Optional optWallet = AppServices.get().getOpenWallets().entrySet().stream() - .filter(entry -> walletForm.getWalletFile().equals(entry.getValue().getWalletFile()) - && entry.getKey().getName().equals(walletForm.getWallet().getName())).map(Map.Entry::getKey).findFirst(); - if(optWallet.isPresent()) { - WalletExportDialog dlg = new WalletExportDialog(optWallet.get()); + if(walletForm instanceof SettingsWalletForm settingsWalletForm) { + WalletExportDialog dlg = new WalletExportDialog(settingsWalletForm.getAppWalletForm()); dlg.initOwner(export.getScene().getWindow()); dlg.showAndWait(); } else { diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsWalletForm.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsWalletForm.java index fc5dbbb1..7ad6fdd5 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsWalletForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsWalletForm.java @@ -22,11 +22,13 @@ import java.util.Objects; */ public class SettingsWalletForm extends WalletForm { private Wallet walletCopy; + private final WalletForm appWalletForm; - public SettingsWalletForm(Storage storage, Wallet currentWallet) { + public SettingsWalletForm(Storage storage, Wallet currentWallet, WalletForm appWalletForm) { super(storage, currentWallet); this.walletCopy = currentWallet.copy(); this.walletCopy.setMasterWallet(walletCopy.isMasterWallet() ? null : walletCopy.getMasterWallet().copy()); + this.appWalletForm = appWalletForm; } @Override @@ -39,6 +41,10 @@ public class SettingsWalletForm extends WalletForm { this.walletCopy = wallet; } + public WalletForm getAppWalletForm() { + return appWalletForm; + } + @Override public void revert() { this.walletCopy = super.getWallet().copy(); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java index 43507afa..1053b357 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java @@ -1,18 +1,14 @@ package com.sparrowwallet.sparrow.wallet; -import com.csvreader.CsvWriter; import com.google.common.eventbus.Subscribe; -import com.sparrowwallet.drongo.BitcoinUnit; -import com.sparrowwallet.drongo.protocol.TransactionInput; -import com.sparrowwallet.drongo.protocol.TransactionOutput; -import com.sparrowwallet.drongo.wallet.BlockTransaction; -import com.sparrowwallet.sparrow.UnitFormat; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.control.*; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.io.WalletTransactions; import com.sparrowwallet.sparrow.net.ExchangeSource; +import com.sparrowwallet.drongo.wallet.Wallet; import javafx.application.Platform; import javafx.collections.ListChangeListener; import javafx.event.ActionEvent; @@ -28,15 +24,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; import java.net.URL; -import java.nio.charset.StandardCharsets; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Collections; import java.util.Date; -import java.util.Locale; import java.util.ResourceBundle; public class TransactionsController extends WalletFormController implements Initializable { @@ -113,65 +105,25 @@ public class TransactionsController extends WalletFormController implements Init } public void exportCSV(ActionEvent event) { - WalletTransactionsEntry walletTransactionsEntry = getWalletForm().getWalletTransactionsEntry(); + Wallet wallet = getWalletForm().getWallet(); Stage window = new Stage(); FileChooser fileChooser = new FileChooser(); fileChooser.setTitle("Export Transactions as CSV"); - fileChooser.setInitialFileName(getWalletForm().getWallet().getFullName() + ".csv"); - + fileChooser.setInitialFileName(wallet.getFullName() + "-transactions.csv"); AppServices.moveToActiveWindowScreen(window, 800, 450); File file = fileChooser.showSaveDialog(window); if(file != null) { - try(FileOutputStream outputStream = new FileOutputStream(file)) { - CsvWriter writer = new CsvWriter(outputStream, ',', StandardCharsets.UTF_8); - writer.writeRecord(new String[] {"Date", "Label", "Value", "Balance", "Fee", "Txid"}); - for(Entry entry : walletTransactionsEntry.getChildren()) { - TransactionEntry txEntry = (TransactionEntry)entry; - writer.write(txEntry.getBlockTransaction().getDate() == null ? "Unconfirmed" : EntryCell.DATE_FORMAT.format(txEntry.getBlockTransaction().getDate())); - writer.write(txEntry.getLabel()); - writer.write(getCoinValue(txEntry.getValue())); - writer.write(getCoinValue(txEntry.getBalance())); - Long fee = txEntry.getValue() < 0 ? getFee(txEntry.getBlockTransaction()) : null; - writer.write(fee == null ? "" : getCoinValue(fee)); - writer.write(txEntry.getBlockTransaction().getHash().toString()); - writer.endRecord(); - } - writer.close(); - } catch(IOException e) { + FileWalletExportPane.FileWalletExportService exportService = new FileWalletExportPane.FileWalletExportService(new WalletTransactions(getWalletForm()), file, wallet); + exportService.setOnFailed(failedEvent -> { + Throwable e = failedEvent.getSource().getException(); log.error("Error exporting transactions as CSV", e); AppServices.showErrorDialog("Error exporting transactions as CSV", e.getMessage()); - } + }); + exportService.start(); } } - private Long getFee(BlockTransaction blockTransaction) { - long fee = 0L; - for(TransactionInput txInput : blockTransaction.getTransaction().getInputs()) { - if(txInput.isCoinBase()) { - return 0L; - } - - BlockTransaction inputTx = getWalletForm().getWallet().getWalletTransaction(txInput.getOutpoint().getHash()); - if(inputTx == null || inputTx.getTransaction().getOutputs().size() <= txInput.getOutpoint().getIndex()) { - return null; - } - TransactionOutput spentOutput = inputTx.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex()); - fee += spentOutput.getValue(); - } - - for(TransactionOutput txOutput : blockTransaction.getTransaction().getOutputs()) { - fee -= txOutput.getValue(); - } - - return fee; - } - - private String getCoinValue(Long value) { - UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat(); - return BitcoinUnit.BTC.equals(transactionsTable.getBitcoinUnit()) ? format.tableFormatBtcValue(value) : String.format(Locale.ENGLISH, "%d", value); - } - private void logMessage(String logMessage) { if(logMessage != null) { logMessage = logMessage.replace("m/", "../"); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java index a5a891ad..b8cc2149 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java @@ -102,7 +102,7 @@ public class WalletController extends WalletFormController implements Initializa WalletForm walletForm = getWalletForm(); if(function.equals(Function.SETTINGS)) { - walletForm = new SettingsWalletForm(getWalletForm().getStorage(), getWalletForm().getWallet()); + walletForm = new SettingsWalletForm(getWalletForm().getStorage(), getWalletForm().getWallet(), getWalletForm()); getWalletForm().setSettingsWalletForm(walletForm); } diff --git a/src/main/resources/image/transactions.png b/src/main/resources/image/transactions.png new file mode 100644 index 0000000000000000000000000000000000000000..1bfcd48762f6884fcea731d5ca81fdb25f07b7ca GIT binary patch literal 5890 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/image/transactions@2x.png b/src/main/resources/image/transactions@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a8a23ec0805b6bdbf49c3f314b0d0fc7f4b75117 GIT binary patch literal 7111 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/image/transactions@3x.png b/src/main/resources/image/transactions@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9bf73e6d15dcdc385b0e3a0db4a5cf67693c12b2 GIT binary patch literal 10396 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 Q`yVqI2}SW5QKO*$0ibe;$^ZZW literal 0 HcmV?d00001