diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 4a8393bc..5107df56 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -120,6 +120,9 @@ public class AppController implements Initializable { @FXML private CheckMenuItem useHdCameraResolution; + @FXML + private CheckMenuItem showLoadingLog; + @FXML private CheckMenuItem showTxHex; @@ -259,6 +262,7 @@ public class AppController implements Initializable { hideEmptyUsedAddresses.setSelected(Config.get().isHideEmptyUsedAddresses()); useHdCameraResolution.setSelected(Config.get().isHdCapture()); showTxHex.setSelected(Config.get().isShowTransactionHex()); + showLoadingLog.setSelected(Config.get().isShowLoadingLog()); savePSBT.visibleProperty().bind(saveTransaction.visibleProperty().not()); exportWallet.setDisable(true); refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not()))); @@ -664,6 +668,12 @@ public class AppController implements Initializable { Config.get().setHdCapture(item.isSelected()); } + public void showLoadingLog(ActionEvent event) { + CheckMenuItem item = (CheckMenuItem)event.getSource(); + Config.get().setShowLoadingLog(item.isSelected()); + EventManager.get().post(new LoadingLogChangedEvent(item.isSelected())); + } + public void showTxHex(ActionEvent event) { CheckMenuItem item = (CheckMenuItem)event.getSource(); Config.get().setShowTransactionHex(item.isSelected()); @@ -1364,6 +1374,7 @@ public class AppController implements Initializable { saveTransaction.setVisible(false); } exportWallet.setDisable(true); + showLoadingLog.setDisable(true); showTxHex.setDisable(false); } else if(event instanceof WalletTabSelectedEvent) { WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event; @@ -1371,6 +1382,7 @@ public class AppController implements Initializable { saveTransaction.setVisible(true); saveTransaction.setDisable(true); exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid()); + showLoadingLog.setDisable(false); showTxHex.setDisable(true); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index a1a030f7..bbe56d67 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -219,7 +219,7 @@ public class AppServices { connectionService.setRestartOnFailure(false); if(tlsServerException.getCause().getMessage().contains("PKIX path building failed")) { File crtFile = Config.get().getElectrumServerCert(); - if(crtFile != null) { + if(crtFile != null && Config.get().getServerType() == ServerType.ELECTRUM_SERVER) { AppServices.showErrorDialog("SSL Handshake Failed", "The configured server certificate at " + crtFile.getAbsolutePath() + " did not match the certificate provided by the server at " + tlsServerException.getServer().getHost() + "." + "\n\nThis may indicate a man-in-the-middle attack!" + "\n\nChange the configured server certificate if you would like to proceed."); diff --git a/src/main/java/com/sparrowwallet/sparrow/event/LoadingLogChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/LoadingLogChangedEvent.java new file mode 100644 index 00000000..0f65b06e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/LoadingLogChangedEvent.java @@ -0,0 +1,13 @@ +package com.sparrowwallet.sparrow.event; + +public class LoadingLogChangedEvent { + private final boolean visible; + + public LoadingLogChangedEvent(boolean visible) { + this.visible = visible; + } + + public boolean isVisible() { + return visible; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryFailedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryFailedEvent.java index a14c9470..c07d7e30 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryFailedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryFailedEvent.java @@ -6,7 +6,7 @@ public class WalletHistoryFailedEvent extends WalletHistoryStatusEvent { private final Throwable exception; public WalletHistoryFailedEvent(Wallet wallet, Throwable exception) { - super(wallet, exception.getCause() == null ? exception.getMessage() : exception.getCause().getMessage()); + super(wallet, exception.getMessage()); this.exception = exception; } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Config.java b/src/main/java/com/sparrowwallet/sparrow/io/Config.java index 625ea1ee..93bfce03 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Config.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Config.java @@ -38,6 +38,7 @@ public class Config { private boolean openWalletsInNewWindows = false; private boolean hideEmptyUsedAddresses = false; private boolean showTransactionHex = true; + private boolean showLoadingLog = false; private List recentWalletFiles; private Integer keyDerivationPeriod; private File hwi; @@ -249,6 +250,15 @@ public class Config { flush(); } + public boolean isShowLoadingLog() { + return showLoadingLog; + } + + public void setShowLoadingLog(boolean showLoadingLog) { + this.showLoadingLog = showLoadingLog; + flush(); + } + public List getRecentWalletFiles() { return recentWalletFiles; } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java index ee36dadf..388444df 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java @@ -5,6 +5,8 @@ import com.github.arteam.simplejsonrpc.client.Transport; import com.github.arteam.simplejsonrpc.client.builder.BatchRequestBuilder; import com.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException; import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException; +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.crypto.ChildNumber; import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.EventManager; @@ -12,11 +14,9 @@ import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; +import java.util.*; import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; public class BatchedElectrumServerRpc implements ElectrumServerRpc { private static final Logger log = LoggerFactory.getLogger(BatchedElectrumServerRpc.class); @@ -74,7 +74,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { public Map getScriptHashHistory(Transport transport, Wallet wallet, Map pathScriptHashes, boolean failOnError) { JsonRpcClient client = new JsonRpcClient(transport); BatchRequestBuilder batchRequest = client.createBatchRequest().keysType(String.class).returnType(ScriptHashTx[].class); - EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Loading transactions")); + EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Loading transactions for " + getScriptHashesAbbreviation(pathScriptHashes.keySet()))); for(String path : pathScriptHashes.keySet()) { batchRequest.add(path, "blockchain.scripthash.get_history", pathScriptHashes.get(path)); @@ -84,7 +84,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { return new RetryLogic>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute); } catch (JsonRpcBatchException e) { if(failOnError) { - throw new ElectrumServerRpcException("Failed to retrieve references for paths: " + e.getErrors().keySet(), e); + throw new ElectrumServerRpcException("Failed to retrieve transaction history for paths: " + getScriptHashesAbbreviation((Collection)e.getErrors().keySet()), e); } Map result = (Map)e.getSuccesses(); @@ -94,7 +94,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { return result; } catch(Exception e) { - throw new ElectrumServerRpcException("Failed to retrieve references for paths: " + pathScriptHashes.keySet(), e); + throw new ElectrumServerRpcException("Failed to retrieve transaction history for paths: " + getScriptHashesAbbreviation(pathScriptHashes.keySet()), e); } } @@ -112,7 +112,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { return new RetryLogic>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute); } catch(JsonRpcBatchException e) { if(failOnError) { - throw new ElectrumServerRpcException("Failed to retrieve references for paths: " + e.getErrors().keySet(), e); + throw new ElectrumServerRpcException("Failed to retrieve mempool transactions for paths: " + getScriptHashesAbbreviation((Collection)e.getErrors().keySet()), e); } Map result = (Map)e.getSuccesses(); @@ -122,15 +122,16 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { return result; } catch(Exception e) { - throw new ElectrumServerRpcException("Failed to retrieve references for paths: " + pathScriptHashes.keySet(), e); + throw new ElectrumServerRpcException("Failed to retrieve mempool transactions for paths: " + getScriptHashesAbbreviation(pathScriptHashes.keySet()), e); } } @Override + @SuppressWarnings("unchecked") public Map subscribeScriptHashes(Transport transport, Wallet wallet, Map pathScriptHashes) { JsonRpcClient client = new JsonRpcClient(transport); BatchRequestBuilder batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class); - EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions")); + EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions for " + getScriptHashesAbbreviation(pathScriptHashes.keySet()))); for(String path : pathScriptHashes.keySet()) { batchRequest.add(path, "blockchain.scripthash.subscribe", pathScriptHashes.get(path)); @@ -140,9 +141,9 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { return new RetryLogic>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute); } catch(JsonRpcBatchException e) { //Even if we have some successes, failure to subscribe for all script hashes will result in outdated wallet view. Don't proceed. - throw new ElectrumServerRpcException("Failed to subscribe for updates for paths: " + e.getErrors().keySet(), e); + throw new ElectrumServerRpcException("Failed to subscribe to paths: " + getScriptHashesAbbreviation((Collection)e.getErrors().keySet()), e); } catch(Exception e) { - throw new ElectrumServerRpcException("Failed to subscribe for updates for paths: " + pathScriptHashes.keySet(), e); + throw new ElectrumServerRpcException("Failed to subscribe to paths: " + getScriptHashesAbbreviation(pathScriptHashes.keySet()), e); } } @@ -151,7 +152,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { public Map getBlockHeaders(Transport transport, Wallet wallet, Set blockHeights) { JsonRpcClient client = new JsonRpcClient(transport); BatchRequestBuilder batchRequest = client.createBatchRequest().keysType(Integer.class).returnType(String.class); - EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving blocks")); + EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving " + blockHeights.size() + " block headers")); for(Integer height : blockHeights) { batchRequest.add(height, "blockchain.block.header", height); @@ -162,7 +163,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { } catch(JsonRpcBatchException e) { return (Map)e.getSuccesses(); } catch(Exception e) { - throw new ElectrumServerRpcException("Failed to block headers for block heights: " + blockHeights, e); + throw new ElectrumServerRpcException("Failed to retrieve block headers for block heights: " + blockHeights, e); } } @@ -171,7 +172,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { public Map getTransactions(Transport transport, Wallet wallet, Set txids) { JsonRpcClient client = new JsonRpcClient(transport); BatchRequestBuilder batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class); - EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving transactions")); + EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving " + txids.size() + " transactions")); for(String txid : txids) { batchRequest.add(txid, "blockchain.transaction.get", txid); @@ -190,7 +191,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { return result; } catch(Exception e) { - throw new ElectrumServerRpcException("Failed to retrieve transactions for txids: " + txids, e); + throw new ElectrumServerRpcException("Failed to retrieve transactions for txids: " + txids.stream().map(txid -> "[" + txid.substring(0, 6) + "]").collect(Collectors.toList()), e); } } @@ -272,4 +273,65 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { throw new ElectrumServerRpcException("Error broadcasting transaction", e); } } + + private static String getScriptHashesAbbreviation(Collection scriptHashes) { + List sortedHashes = new ArrayList<>(scriptHashes); + + if(scriptHashes.isEmpty()) { + return "[]"; + } + + List> contiguous = splitToContiguous(sortedHashes); + + String abbrev = "["; + for(Iterator> iter = contiguous.iterator(); iter.hasNext(); ) { + List range = iter.next(); + abbrev += range.get(0); + if(range.size() > 1) { + abbrev += "-" + range.get(range.size() - 1); + } + if(iter.hasNext()) { + abbrev += ", "; + } + } + abbrev += "]"; + + return abbrev; + } + + static List> splitToContiguous(List input) { + List> result = new ArrayList<>(); + int prev = 0; + + int keyPurpose = getKeyPurpose(input.get(0)); + int index = getIndex(input.get(0)); + + for (int cur = 0; cur < input.size(); cur++) { + if(getKeyPurpose(input.get(cur)) != keyPurpose || getIndex(input.get(cur)) != index) { + result.add(input.subList(prev, cur)); + prev = cur; + } + index = getIndex(input.get(cur)) + 1; + keyPurpose = getKeyPurpose(input.get(cur)); + } + result.add(input.subList(prev, input.size())); + + return result; + } + + private static int getKeyPurpose(String path) { + List childNumbers = KeyDerivation.parsePath(path); + if(childNumbers.isEmpty()) { + return -1; + } + return childNumbers.get(0).num(); + } + + private static int getIndex(String path) { + List childNumbers = KeyDerivation.parsePath(path); + if(childNumbers.isEmpty()) { + return -1; + } + return childNumbers.get(childNumbers.size() - 1).num(); + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index e3bc5e25..75be18c2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -433,6 +433,8 @@ public class ElectrumServer { return blockTransactionHashes; } catch (IllegalStateException e) { throw new ServerException(e.getCause()); + } catch (ElectrumServerRpcException e) { + throw new ServerException(e.getMessage(), e.getCause()); } catch (Exception e) { throw new ServerException(e); } @@ -493,6 +495,8 @@ public class ElectrumServer { return blockHeaderMap; } catch (IllegalStateException e) { throw new ServerException(e.getCause()); + } catch (ElectrumServerRpcException e) { + throw new ServerException(e.getMessage(), e.getCause()); } catch (Exception e) { throw new ServerException(e); } @@ -561,6 +565,8 @@ public class ElectrumServer { return transactionMap; } catch (IllegalStateException e) { throw new ServerException(e.getCause()); + } catch (ElectrumServerRpcException e) { + throw new ServerException(e.getMessage(), e.getCause()); } catch (Exception e) { throw new ServerException(e); } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java index 94104d8b..643fcaf0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java @@ -82,7 +82,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc { result.put(path, scriptHashTxes); } catch(Exception e) { if(failOnError) { - throw new ElectrumServerRpcException("Failed to retrieve reference for path: " + path, e); + throw new ElectrumServerRpcException("Failed to retrieve transaction history for path: " + path, e); } result.put(path, new ScriptHashTx[] {ScriptHashTx.ERROR_TX}); @@ -104,7 +104,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc { result.put(path, scriptHashTxes); } catch(Exception e) { if(failOnError) { - throw new ElectrumServerRpcException("Failed to retrieve reference for path: " + path, e); + throw new ElectrumServerRpcException("Failed to retrieve mempool transactions for path: " + path, e); } result.put(path, new ScriptHashTx[] {ScriptHashTx.ERROR_TX}); @@ -127,7 +127,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc { result.put(path, scriptHash); } catch(Exception e) { //Even if we have some successes, failure to subscribe for all script hashes will result in outdated wallet view. Don't proceed. - throw new ElectrumServerRpcException("Failed to retrieve reference for path: " + path, e); + throw new ElectrumServerRpcException("Failed to subscribe to path: " + path, e); } } @@ -169,6 +169,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc { String rawTxHex = new RetryLogic(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() -> client.createRequest().returnAs(String.class).method("blockchain.transaction.get").id(idCounter.incrementAndGet()).params(txid).execute()); result.put(txid, rawTxHex); + } catch(ServerException e) { + //If there is an error with the server connection, don't keep trying - this may take too long given many txids + throw new ElectrumServerRpcException("Failed to retrieve transaction for txid [" + txid.substring(0, 6) + "]", e); } catch(Exception e) { result.put(txid, Sha256Hash.ZERO_HASH.toString()); } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java index 35234d68..f80ac439 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java @@ -9,15 +9,19 @@ import com.sparrowwallet.sparrow.CurrencyRate; 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.net.ExchangeSource; +import javafx.application.Platform; import javafx.collections.ListChangeListener; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.fxml.Initializable; import javafx.scene.control.Button; +import javafx.scene.control.TextArea; import javafx.scene.control.TreeItem; import javafx.stage.FileChooser; import javafx.stage.Stage; +import org.controlsfx.control.MasterDetailPane; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,13 +30,18 @@ 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 { private static final Logger log = LoggerFactory.getLogger(TransactionsController.class); + private static final DateFormat LOG_DATE_FORMAT = new SimpleDateFormat("[MMM dd HH:mm:ss]"); + @FXML private CoinLabel balance; @@ -48,9 +57,15 @@ public class TransactionsController extends WalletFormController implements Init @FXML private CopyableLabel transactionCount; + @FXML + private MasterDetailPane transactionsMasterDetail; + @FXML private TransactionsTreeTable transactionsTable; + @FXML + private TextArea loadingLog; + @FXML private BalanceChart balanceChart; @@ -85,6 +100,10 @@ public class TransactionsController extends WalletFormController implements Init balanceChart.select((TransactionEntry)selectedItem.getValue()); } }); + + transactionsMasterDetail.setShowDetailNode(Config.get().isShowLoadingLog()); + loadingLog.appendText("Wallet loading history for " + getWalletForm().getWallet().getName()); + loadingLog.setEditable(false); } private void setFiatBalance(FiatLabel fiatLabel, CurrencyRate currencyRate, long balance) { @@ -136,6 +155,15 @@ public class TransactionsController extends WalletFormController implements Init String.format(Locale.ENGLISH, "%d", value); } + private void logMessage(String logMessage) { + if(logMessage != null) { + logMessage = logMessage.replace("m/", "/"); + String date = LOG_DATE_FORMAT.format(new Date()); + String logLine = "\n" + date + " " + logMessage; + Platform.runLater(() -> loadingLog.appendText(logLine)); + } + } + @Subscribe public void walletNodesChanged(WalletNodesChangedEvent event) { if(event.getWallet().equals(walletForm.getWallet())) { @@ -200,6 +228,18 @@ public class TransactionsController extends WalletFormController implements Init @Subscribe public void walletHistoryStatus(WalletHistoryStatusEvent event) { transactionsTable.updateHistoryStatus(event); + + if(event.getWallet() != null && getWalletForm().getWallet() == event.getWallet()) { + String logMessage = event.getStatusMessage(); + if(logMessage == null) { + if(event instanceof WalletHistoryFinishedEvent) { + logMessage = "Finished loading."; + } else if(event instanceof WalletHistoryFailedEvent) { + logMessage = event.getErrorMessage(); + } + } + logMessage(logMessage); + } } @Subscribe @@ -233,4 +273,9 @@ public class TransactionsController extends WalletFormController implements Init public void includeMempoolOutputsChangedEvent(IncludeMempoolOutputsChangedEvent event) { walletHistoryChanged(new WalletHistoryChangedEvent(getWalletForm().getWallet(), getWalletForm().getStorage(), Collections.emptyList())); } + + @Subscribe + public void loadingLogChanged(LoadingLogChangedEvent event) { + transactionsMasterDetail.setShowDetailNode(event.isVisible()); + } } diff --git a/src/main/resources/com/sparrowwallet/sparrow/app.fxml b/src/main/resources/com/sparrowwallet/sparrow/app.fxml index 52078e30..90f0196a 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/app.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/app.fxml @@ -88,6 +88,7 @@ + diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml index 15ae32f8..ee80cfce 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml @@ -16,6 +16,7 @@ +
@@ -61,7 +62,14 @@ - + + + + + +