diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletBlockHeightChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletBlockHeightChangedEvent.java index bf1c3a63..8146f56e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletBlockHeightChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletBlockHeightChangedEvent.java @@ -3,8 +3,8 @@ package com.sparrowwallet.sparrow.event; import com.sparrowwallet.drongo.wallet.Wallet; /** - * This event is posted if the wallet block height has changed. - * Note that it is not posted if the wallet history has also changed - this event is used mainly to ensure the new block height is saved + * This event is posted if the wallet's stored block height has changed. + * */ public class WalletBlockHeightChangedEvent extends WalletChangedEvent { private final Integer blockHeight; diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletChangedEvent.java index df10bc3b..e60a3878 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletChangedEvent.java @@ -3,7 +3,7 @@ package com.sparrowwallet.sparrow.event; import com.sparrowwallet.drongo.wallet.Wallet; /** - * The base class for all wallet events that should also trigger saving of the wallet + * The base class for all wallet events */ public class WalletChangedEvent { private final Wallet wallet; diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletDataChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletDataChangedEvent.java new file mode 100644 index 00000000..a7d35dd8 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletDataChangedEvent.java @@ -0,0 +1,13 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.wallet.Wallet; + +/** + * Indicates that the internal data (non-settings) of the wallet has changed, either from a blockchain update or entry label change etc. + * Used to trigger a background save of the wallet + */ +public class WalletDataChangedEvent extends WalletChangedEvent { + public WalletDataChangedEvent(Wallet wallet) { + super(wallet); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletEntryLabelChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletEntryLabelChangedEvent.java index 462a51bf..8a0f0e55 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletEntryLabelChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletEntryLabelChangedEvent.java @@ -3,7 +3,11 @@ package com.sparrowwallet.sparrow.event; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.wallet.Entry; -public class WalletEntryLabelChangedEvent extends WalletChangedEvent { +/** + * This event is fired when a wallet entry (transaction, txi or txo) label is changed. + * Extends WalletDataChangedEvent so triggers a background save. + */ +public class WalletEntryLabelChangedEvent extends WalletDataChangedEvent { private final Entry entry; public WalletEntryLabelChangedEvent(Wallet wallet, Entry entry) { diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryChangedEvent.java index 1fdef2e4..49ef4af9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryChangedEvent.java @@ -9,13 +9,13 @@ import java.util.stream.Collectors; /** * This is posted by WalletForm once the history of the wallet has been refreshed, and new transactions detected - * Extends WalletChangedEvent so also saves the wallet. + * */ -public class WalletHistoryChangedEvent extends WalletBlockHeightChangedEvent { +public class WalletHistoryChangedEvent extends WalletChangedEvent { private final List historyChangedNodes; - public WalletHistoryChangedEvent(Wallet wallet, Integer blockHeight, List historyChangedNodes) { - super(wallet, blockHeight); + public WalletHistoryChangedEvent(Wallet wallet, List historyChangedNodes) { + super(wallet); this.historyChangedNodes = historyChangedNodes; } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java new file mode 100644 index 00000000..bbd757c4 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java @@ -0,0 +1,43 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletNode; +import com.sparrowwallet.sparrow.io.ElectrumServer; + +import java.util.List; + +/** + * Used to notify that a wallet node (identified by it's script hash) has been updated on the blockchain. + * Does not extend WalletChangedEvent as the wallet is not known when this is fired. + */ +public class WalletNodeHistoryChangedEvent { + private final String scriptHash; + + public WalletNodeHistoryChangedEvent(String scriptHash) { + this.scriptHash = scriptHash; + } + + public WalletNode getWalletNode(Wallet wallet) { + List keyPurposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE); + for(KeyPurpose keyPurpose : keyPurposes) { + WalletNode changedNode = getWalletNode(wallet, keyPurpose); + if(changedNode != null) { + return changedNode; + } + } + + return null; + } + + private WalletNode getWalletNode(Wallet wallet, KeyPurpose keyPurpose) { + WalletNode purposeNode = wallet.getNode(keyPurpose); + for(WalletNode addressNode : purposeNode.getChildren()) { + if(ElectrumServer.getScriptHash(wallet, addressNode).equals(scriptHash)) { + return addressNode; + } + } + + return null; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java index a2bdb612..86804ae7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java @@ -18,6 +18,7 @@ import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.ConnectionEvent; import com.sparrowwallet.sparrow.event.FeeRatesUpdatedEvent; import com.sparrowwallet.sparrow.event.NewBlockEvent; +import com.sparrowwallet.sparrow.event.WalletNodeHistoryChangedEvent; import com.sparrowwallet.sparrow.wallet.SendController; import javafx.application.Platform; import javafx.concurrent.ScheduledService; @@ -37,6 +38,7 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; @@ -47,6 +49,8 @@ public class ElectrumServer { private static Transport transport; + private static final Map subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>()); + private static synchronized Transport getTransport() throws ServerException { if(transport == null) { try { @@ -171,6 +175,7 @@ public class ElectrumServer { public void getHistory(Wallet wallet, Collection nodes, Map> nodeTransactionMap, int startIndex) throws ServerException { getReferences(wallet, "blockchain.scripthash.get_history", nodes, nodeTransactionMap, startIndex); + subscribeWalletNodes(wallet, nodes, startIndex); } public void getMempool(Wallet wallet, Collection nodes, Map> nodeTransactionMap, int startIndex) throws ServerException { @@ -231,6 +236,50 @@ public class ElectrumServer { } } + public void subscribeWalletNodes(Wallet wallet, Collection nodes, int startIndex) throws ServerException { + try { + JsonRpcClient client = new JsonRpcClient(getTransport()); + BatchRequestBuilder batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class); + + Set scriptHashes = new HashSet<>(); + for(WalletNode node : nodes) { + if(node.getIndex() >= startIndex) { + String scriptHash = getScriptHash(wallet, node); + if(!subscribedScriptHashes.containsKey(scriptHash)) { + scriptHashes.add(scriptHash); + batchRequest.add(node.getDerivationPath(), "blockchain.scripthash.subscribe", scriptHash); + } + } + } + + if(scriptHashes.isEmpty()) { + return; + } + + Map result; + try { + result = 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 IllegalStateException("Failed to subscribe for updates for paths: " + e.getErrors().keySet()); + } + + for(String path : result.keySet()) { + String status = result.get(path); + + Optional optionalNode = nodes.stream().filter(n -> n.getDerivationPath().equals(path)).findFirst(); + if(optionalNode.isPresent()) { + WalletNode node = optionalNode.get(); + subscribedScriptHashes.put(getScriptHash(wallet, node), status); + } + } + } catch (IllegalStateException e) { + throw new ServerException(e.getCause()); + } catch (Exception e) { + throw new ServerException(e); + } + } + @SuppressWarnings("unchecked") public List> getOutputTransactionReferences(Transaction transaction, int indexStart, int indexEnd) throws ServerException { try { @@ -545,7 +594,7 @@ public class ElectrumServer { return targetBlocksFeeRatesSats; } - private String getScriptHash(Wallet wallet, WalletNode node) { + public static String getScriptHash(Wallet wallet, WalletNode node) { byte[] hash = Sha256Hash.hash(wallet.getOutputScript(node).getProgram()); byte[] reversed = Utils.reverseBytes(hash); return Utils.bytesToHex(reversed); @@ -630,6 +679,16 @@ public class ElectrumServer { public void newBlockHeaderTip(@JsonRpcParam("header") final BlockHeaderTip header) { Platform.runLater(() -> EventManager.get().post(new NewBlockEvent(header.height, header.getBlockHeader()))); } + + @JsonRpcMethod("blockchain.scripthash.subscribe") + public void scriptHashStatusUpdated(@JsonRpcParam("scripthash") final String scriptHash, @JsonRpcParam("status") final String status) { + String oldStatus = subscribedScriptHashes.put(scriptHash, status); + if(Objects.equals(oldStatus, status)) { + System.out.println("Received script hash status update, but status has not changed"); + } + + Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHash))); + } } public static class TcpTransport implements Transport, Closeable { @@ -886,6 +945,7 @@ public class ElectrumServer { BlockHeaderTip tip; if(subscribe) { tip = electrumServer.subscribeBlockHeaders(); + subscribedScriptHashes.clear(); } else { tip = new BlockHeaderTip(); } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java index 34747da6..9cbbfc62 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java @@ -60,8 +60,7 @@ public class WalletForm { if(wallet.isValid() && AppController.isOnline()) { ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet); historyService.setOnSucceeded(workerStateEvent -> { - wallet.setStoredBlockHeight(blockHeight); - notifyIfChanged(previousWallet, blockHeight); + updateWallet(previousWallet, blockHeight); }); historyService.setOnFailed(workerStateEvent -> { workerStateEvent.getSource().getException().printStackTrace(); @@ -70,15 +69,32 @@ public class WalletForm { } } + private void updateWallet(Wallet previousWallet, Integer blockHeight) { + if(blockHeight != null) { + wallet.setStoredBlockHeight(blockHeight); + } + + notifyIfChanged(previousWallet, blockHeight); + } + private void notifyIfChanged(Wallet previousWallet, Integer blockHeight) { List historyChangedNodes = new ArrayList<>(); historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.RECEIVE).getChildren(), wallet.getNode(KeyPurpose.RECEIVE).getChildren())); historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.CHANGE).getChildren(), wallet.getNode(KeyPurpose.CHANGE).getChildren())); + boolean changed = false; if(!historyChangedNodes.isEmpty()) { - Platform.runLater(() -> EventManager.get().post(new WalletHistoryChangedEvent(wallet, blockHeight, historyChangedNodes))); - } else if(blockHeight != null && !blockHeight.equals(previousWallet.getStoredBlockHeight())) { + Platform.runLater(() -> EventManager.get().post(new WalletHistoryChangedEvent(wallet, historyChangedNodes))); + changed = true; + } + + if(blockHeight != null && !blockHeight.equals(previousWallet.getStoredBlockHeight())) { Platform.runLater(() -> EventManager.get().post(new WalletBlockHeightChangedEvent(wallet, blockHeight))); + changed = true; + } + + if(changed) { + Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet))); } } @@ -146,20 +162,13 @@ public class WalletForm { } @Subscribe - public void walletLabelChanged(WalletEntryLabelChangedEvent event) { + public void walletDataChanged(WalletDataChangedEvent event) { if(event.getWallet().equals(wallet)) { - backgroundSaveWallet(event); + backgroundSaveWallet(); } } - @Subscribe - public void walletBlockHeightChanged(WalletBlockHeightChangedEvent event) { - if(event.getWallet().equals(wallet)) { - backgroundSaveWallet(event); - } - } - - private void backgroundSaveWallet(WalletChangedEvent event) { + private void backgroundSaveWallet() { try { save(); } catch (IOException e) { @@ -182,11 +191,18 @@ public class WalletForm { @Subscribe public void newBlock(NewBlockEvent event) { - refreshHistory(event.getHeight()); + updateWallet(wallet, event.getHeight()); } @Subscribe public void connected(ConnectionEvent event) { refreshHistory(event.getBlockHeight()); } + + @Subscribe + public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) { + if(event.getWalletNode(wallet) != null) { + refreshHistory(AppController.getCurrentBlockHeight()); + } + } }