diff --git a/drongo b/drongo index 9c53c6d6..3fbad787 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 9c53c6d6d087e3bc833fbc90a718fa56bc1cf508 +Subproject commit 3fbad787a4e4e55da6743b48be64ff847611d52c diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index b5f2df97..5bd3c7ef 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -851,6 +851,10 @@ public class AppServices { public static void clearTransactionHistoryCache(Wallet wallet) { ElectrumServer.clearRetrievedScriptHashes(wallet); + if(wallet.getPolicyType() == PolicyType.SINGLE_SP && wallet.isValid()) { + ElectrumServer.releaseSilentPaymentSubscription(wallet.getSilentPaymentScanAddress()); + } + for(Wallet childWallet : wallet.getChildWallets()) { if(childWallet.isNested()) { AppServices.clearTransactionHistoryCache(childWallet); @@ -1465,4 +1469,15 @@ public class AppServices { onlineProperty.set(true); } } + + @Subscribe + public void silentPaymentsUnsubscribe(SilentPaymentsUnsubscribeEvent event) { + if(isConnected()) { + ElectrumServer.SilentPaymentsUnsubscribeService unsubscribeService = new ElectrumServer.SilentPaymentsUnsubscribeService(event.getScanAddress()); + unsubscribeService.setOnFailed(workerStateEvent -> { + log.warn("Failed to unsubscribe silent payments for " + event.getScanAddress().getAddress(), workerStateEvent.getSource().getException()); + }); + unsubscribeService.start(); + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/CoinTreeTable.java b/src/main/java/com/sparrowwallet/sparrow/control/CoinTreeTable.java index 7e727853..6688c43f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/CoinTreeTable.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/CoinTreeTable.java @@ -1,6 +1,7 @@ package com.sparrowwallet.sparrow.control; import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.wallet.SortDirection; import com.sparrowwallet.drongo.wallet.TableType; import com.sparrowwallet.drongo.wallet.Wallet; @@ -122,7 +123,7 @@ public class CoinTreeTable extends TreeTableView { StackPane stackPane = new StackPane(); stackPane.getChildren().add(AppServices.isConnecting() ? new Label("Loading transactions...") : new Label("No transactions")); - if(Config.get().getServerType() == ServerType.BITCOIN_CORE && !AppServices.isConnecting()) { + if((Config.get().getServerType() == ServerType.BITCOIN_CORE || wallet.getPolicyType() == PolicyType.SINGLE_SP) && !AppServices.isConnecting()) { Hyperlink hyperlink = new Hyperlink(); hyperlink.setTranslateY(30); hyperlink.setOnAction(event -> { @@ -133,6 +134,7 @@ public class CoinTreeTable extends TreeTableView { Storage storage = AppServices.get().getOpenWallets().get(wallet); Wallet pastWallet = wallet.copy(); wallet.setBirthDate(optDate.get()); + wallet.setBirthHeight(null); //Trigger background save of birthdate EventManager.get().post(new WalletDataChangedEvent(wallet)); //Trigger full wallet rescan diff --git a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java index 356e2638..bf43bf14 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java @@ -336,7 +336,7 @@ public class EntryCell extends TreeTableCell implements Confirmati if(cancelTransaction) { Payment existing = payments.get(0); Payment payment = transactionEntry.getWallet().getPolicyType() == PolicyType.SINGLE_SP ? - new SilentPayment(transactionEntry.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getChangeAddress().getSilentPaymentAddress(), + new SilentPayment(transactionEntry.getWallet().getSilentPaymentScanAddress().getChangeAddress().getSilentPaymentAddress(), existing.getLabel(), existing.getAmount(), true) : new Payment(transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getAddress(), existing.getLabel(), existing.getAmount(), true); payments.clear(); @@ -400,7 +400,7 @@ public class EntryCell extends TreeTableCell implements Confirmati String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel(); label += (label.isEmpty() ? "" : " ") + "(CPFP)"; Payment payment = transactionEntry.getWallet().getPolicyType() == PolicyType.SINGLE_SP ? - new SilentPayment(transactionEntry.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getChangeAddress().getSilentPaymentAddress(), + new SilentPayment(transactionEntry.getWallet().getSilentPaymentScanAddress().getChangeAddress().getSilentPaymentAddress(), label, inputTotal, true) : new Payment(transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getAddress(), label, inputTotal, true); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java index 87cbc341..33f9fd88 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java @@ -218,7 +218,7 @@ public class PrivateKeySweepDialog extends Dialog { toWallet.valueProperty().addListener((observable, oldValue, selectedWallet) -> { if(selectedWallet != null) { if(selectedWallet.getPolicyType() == PolicyType.SINGLE_SP) { - toAddress.setText(selectedWallet.getKeystores().getFirst().getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress()); + toAddress.setText(selectedWallet.getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress()); } else { toAddress.setText(selectedWallet.getFreshNode(KeyPurpose.RECEIVE).getAddress().toString()); } @@ -228,7 +228,7 @@ public class PrivateKeySweepDialog extends Dialog { keyScriptType.setValue(ScriptType.P2PKH); if(wallet != null) { if(wallet.getPolicyType() == PolicyType.SINGLE_SP) { - toAddress.setText(wallet.getKeystores().getFirst().getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress()); + toAddress.setText(wallet.getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress()); } else { toAddress.setText(wallet.getFreshNode(KeyPurpose.RECEIVE).getAddress().toString()); } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsHistoryUpdatedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsHistoryUpdatedEvent.java new file mode 100644 index 00000000..b6f35876 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsHistoryUpdatedEvent.java @@ -0,0 +1,22 @@ +package com.sparrowwallet.sparrow.event; + +/** + * Posted by SubscriptionService on a live silent-payments delta — i.e. a progress = 1.0 notification + * that arrives after the historical scan has already completed. Carries the SP address only; + * consumers re-call ElectrumServer.getSilentPaymentHistory(scanAddress, neededStart) to get the + * full cache and apply their own wallet.getWalletTransaction(txid) filter for "what's new for me". + *

+ * The first historical-scan-complete notification is consumed by the blocking getSilentPaymentHistory + * call's latch and does NOT post this event. + */ +public class SilentPaymentsHistoryUpdatedEvent { + private final String spAddress; + + public SilentPaymentsHistoryUpdatedEvent(String spAddress) { + this.spAddress = spAddress; + } + + public String getSpAddress() { + return spAddress; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsNotificationEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsNotificationEvent.java deleted file mode 100644 index 71ece42e..00000000 --- a/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsNotificationEvent.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.sparrowwallet.sparrow.event; - -import com.sparrowwallet.sparrow.net.SilentPaymentsSubscription; -import com.sparrowwallet.sparrow.net.SilentPaymentsTx; - -import java.util.List; - -/** - * Posted when a blockchain.silentpayments.subscribe notification is received from an Electrum server. - * Carries the subscription payload, the progress (with 1.0 marking the historical-scan-complete - * moment per BIP352), and the list of silent-payment transactions in this batch. - *

- * The event holds no wallet reference; consumers (typically a WalletForm) match - * {@link SilentPaymentsSubscription#address} against their wallet's silent-payment address and - * ignore the event otherwise. This mirrors how WalletNodeHistoryChangedEvent is consumed and avoids - * pinning closed wallets through static event-router state. - */ -public class SilentPaymentsNotificationEvent { - private final SilentPaymentsSubscription subscription; - private final double progress; - private final List history; - - public SilentPaymentsNotificationEvent(SilentPaymentsSubscription subscription, double progress, List history) { - this.subscription = subscription; - this.progress = progress; - this.history = history; - } - - public SilentPaymentsSubscription getSubscription() { - return subscription; - } - - public double getProgress() { - return progress; - } - - public List getHistory() { - return history; - } -} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsScanProgressEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsScanProgressEvent.java new file mode 100644 index 00000000..2195e19c --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsScanProgressEvent.java @@ -0,0 +1,24 @@ +package com.sparrowwallet.sparrow.event; + +/** + * Posted by SubscriptionService on every blockchain.silentpayments.subscribe notification. + * Carries the SP address and the scan's progress (0.0–1.0). WalletForm consumers use this for + * status UI; the gating decision (whether to display) is per-wallet runtime state, not encoded here. + */ +public class SilentPaymentsScanProgressEvent { + private final String spAddress; + private final double progress; + + public SilentPaymentsScanProgressEvent(String spAddress, double progress) { + this.spAddress = spAddress; + this.progress = progress; + } + + public String getSpAddress() { + return spAddress; + } + + public double getProgress() { + return progress; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsUnsubscribeEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsUnsubscribeEvent.java new file mode 100644 index 00000000..de79bc23 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsUnsubscribeEvent.java @@ -0,0 +1,21 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress; + +/** + * Posted by ElectrumServer.releaseSilentPaymentSubscription when the refcount on a silent-payments + * subscription reaches zero. The subscriber starts a background SilentPaymentsUnsubscribeService to + * issue the actual unsubscribe RPC, since releaseSilentPaymentSubscription is reached from JFX-thread + * paths (wallet close, refresh, history clear) where blocking on a network call is not acceptable. + */ +public class SilentPaymentsUnsubscribeEvent { + private final SilentPaymentScanAddress scanAddress; + + public SilentPaymentsUnsubscribeEvent(SilentPaymentScanAddress scanAddress) { + this.scanAddress = scanAddress; + } + + public SilentPaymentScanAddress getScanAddress() { + return scanAddress; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java index 6725fd4b..1f3b8516 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java @@ -183,11 +183,11 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { } @Override - public String subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels) { + public SilentPaymentsSubscription subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels) { JsonRpcClient client = new JsonRpcClient(transport); try { - return new RetryLogic(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() -> - client.createRequest().returnAs(String.class).method("blockchain.silentpayments.subscribe").id(idCounter.incrementAndGet()).params(scanPrivKeyHex, spendPubKeyHex, start, labels).execute()); + return new RetryLogic(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() -> + client.createRequest().returnAs(SilentPaymentsSubscription.class).method("blockchain.silentpayments.subscribe").id(idCounter.incrementAndGet()).params(scanPrivKeyHex, spendPubKeyHex, start, labels).execute()); } catch(Exception e) { throw new ElectrumServerRpcException("Failed to subscribe to silent payments for wallet " + wallet.getName(), e); } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index 1acc7fdc..f1af4efe 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -10,6 +10,11 @@ import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.bip47.InvalidPaymentCodeException; import com.sparrowwallet.drongo.bip47.PaymentCode; import com.sparrowwallet.drongo.protocol.*; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.silentpayments.InvalidSilentPaymentException; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanMatch; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentUtils; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.BlockSummary; @@ -60,6 +65,8 @@ public class ElectrumServer { private static final int MINIMUM_BROADCASTS = 2; + private static final int[] NO_LABELS = new int[0]; + public static final BlockTransaction UNFETCHABLE_BLOCK_TRANSACTION = new BlockTransaction(Sha256Hash.ZERO_HASH, 0, null, null, null); private static CloseableTransport transport; @@ -80,6 +87,8 @@ public class ElectrumServer { private static final Map walletSyncLocks = Collections.synchronizedMap(new HashMap<>()); + private static final Map spScanCaches = new ConcurrentHashMap<>(); + private final static Map subscribedRecent = new ConcurrentHashMap<>(); private final static Map broadcastRecent = new ConcurrentHashMap<>(); @@ -199,6 +208,8 @@ public class ElectrumServer { public static synchronized void closeActiveConnection() throws ServerException { if(transport != null) { + cancelSilentPaymentScans(); + spScanCaches.clear(); closeConnection(transport); transport = null; } @@ -1322,6 +1333,248 @@ public class ElectrumServer { } } + static SilentPaymentsScanCache getScanCache(String spAddress) { + return spScanCaches.get(spAddress); + } + + public static boolean hasSilentPaymentsCache(SilentPaymentScanAddress scanAddress) { + return spScanCaches.containsKey(scanAddress.getAddress()); + } + + private static void cancelSilentPaymentScans() { + for(SilentPaymentsScanCache cache : spScanCaches.values()) { + cache.lock(); + try { + cache.cancel(); + } finally { + cache.unlock(); + } + } + } + + /** + * Holds a silent-payment subscription for the given scan address. Increments the per-cache refcount, + * issuing a subscribe RPC the first time the cache is established and re-issuing if the needed start + * is lower than the current subscription's start. On RPC failure the refcount is rolled back. + * Callers must pair every successful hold with a matching call. + */ + public static void holdSilentPaymentSubscription(Wallet wallet, SilentPaymentScanAddress scanAddress, int neededStart) throws ServerException { + requireSilentPaymentsSupport(); + String spAddress = scanAddress.getAddress(); + SilentPaymentsScanCache cache = spScanCaches.computeIfAbsent(spAddress, k -> new SilentPaymentsScanCache()); + + boolean needSubscribe; + SilentPaymentsScanCache.Snapshot rollbackSnapshot = null; + cache.lock(); + try { + boolean isFirstCaller = cache.incrementRefCount() == 1; + //If a concurrent caller is currently establishing the subscription, wait for serverStart to be + //captured before making our widening decision. awaitSubscriptionComplete() releases the cache + //lock during the wait, allowing notification handlers to proceed (avoids deadlock with + //TcpTransport's read thread). + while(cache.hasMultipleHolders() && cache.getServerStart() == null && cache.isScanning()) { + try { + cache.awaitSubscriptionComplete(); + } catch(InterruptedException e) { + Thread.currentThread().interrupt(); + if(cache.decrementRefCount()) { + spScanCaches.remove(spAddress); + } + throw new ServerException("Interrupted waiting for silent payments subscription to establish", e); + } + } + + if(cache.isCancelled()) { + //First caller's subscribe failed (or scan was cancelled) — propagate. + if(cache.decrementRefCount()) { + spScanCaches.remove(spAddress); + } + throw new ServerException("Silent payments subscription failed for " + spAddress); + } + + if(isFirstCaller) { + //Cache was just created by computeIfAbsent above, with all defaults. Establish the subscription. + needSubscribe = true; + } else if(needsWiderCoverage(neededStart, cache.getServerStart())) { + //New caller wants earlier history than current coverage; widen and trigger a rescan. + rollbackSnapshot = cache.captureSnapshot(); + cache.restartScan(); + needSubscribe = true; + } else { + needSubscribe = false; + } + } finally { + cache.unlock(); + } + + if(needSubscribe) { + try { + String scanPrivHex = Utils.bytesToHex(scanAddress.getScanKey().getPrivKeyBytes()); + String spendPubHex = Utils.bytesToHex(scanAddress.getSpendKey().getPubKey(true)); + SilentPaymentsSubscription response = electrumServerRpc.subscribeSilentPayments(getTransport(), wallet, scanPrivHex, spendPubHex, neededStart, NO_LABELS); + cache.lock(); + try { + cache.setServerStart(response.start_height); + } finally { + cache.unlock(); + } + } catch(Exception e) { + cache.lock(); + try { + if(rollbackSnapshot != null && cache.hasMultipleHolders()) { + cache.restoreFromSnapshot(rollbackSnapshot); + } else { + cache.cancel(); + } + if(cache.decrementRefCount()) { + spScanCaches.remove(spAddress); + } + } finally { + cache.unlock(); + } + throw e; + } + } + } + + private static boolean needsWiderCoverage(int neededStart, int serverStart) { + boolean neededIsTimestamp = neededStart >= Transaction.MAX_BLOCK_LOCKTIME; + boolean serverIsTimestamp = serverStart >= Transaction.MAX_BLOCK_LOCKTIME; + if(neededIsTimestamp != serverIsTimestamp) { + return neededIsTimestamp; + } + return neededStart < serverStart; + } + + public List getSilentPaymentHistory(SilentPaymentScanAddress scanAddress) throws ServerException { + String spAddress = scanAddress.getAddress(); + SilentPaymentsScanCache cache = spScanCaches.get(spAddress); + if(cache == null) { + throw new IllegalStateException("No silent payments subscription is held for " + spAddress); + } + + cache.lock(); + try { + while(cache.isScanning()) { + try { + cache.awaitScanComplete(); + } catch(InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ServerException("Interrupted waiting for silent payments scan to complete", e); + } + } + if(cache.isCancelled()) { + throw new ServerException("Silent payments scan was cancelled for " + spAddress); + } + return cache.snapshotEntries(); + } finally { + cache.unlock(); + } + } + + public static void releaseSilentPaymentSubscription(SilentPaymentScanAddress scanAddress) { + String spAddress = scanAddress.getAddress(); + SilentPaymentsScanCache cache = spScanCaches.get(spAddress); + if(cache == null) { + return; + } + + boolean unsubscribe; + cache.lock(); + try { + unsubscribe = cache.decrementRefCount(); + if(unsubscribe) { + cache.cancel(); + spScanCaches.remove(spAddress); + } + } finally { + cache.unlock(); + } + + if(unsubscribe) { + Platform.runLater(() -> EventManager.get().post(new SilentPaymentsUnsubscribeEvent(scanAddress))); + } + } + + public Set processSilentPaymentBatch(Wallet wallet, List entries) throws ServerException { + if(entries.isEmpty()) { + return Collections.emptySet(); + } + + Map references = new TreeMap<>(); + Map tweakMap = new HashMap<>(); + for(SilentPaymentsTx entry : entries) { + Sha256Hash txid = Sha256Hash.wrap(entry.tx_hash); + tweakMap.putIfAbsent(txid, Utils.hexToBytes(entry.tweak_key)); + if(wallet.getWalletTransaction(txid) == null) { + references.put(new BlockTransaction(txid, entry.height, null, null, null), null); + } + } + if(references.isEmpty()) { + return Collections.emptySet(); + } + + Map blockHeaderMap = getBlockHeaders(wallet, references.keySet()); + Map transactionMap = getTransactions(wallet, references, blockHeaderMap); + + ECKey scanPriv = wallet.getSilentPaymentScanAddress().getScanKey(); + ECKey spendPub = wallet.getSilentPaymentScanAddress().getSpendKey(); + + Map walletAddresses = wallet.getWalletAddresses(); + Set newNodes = new LinkedHashSet<>(); + + int receiveNextIndex = nextIndex(wallet.getNode(KeyPurpose.RECEIVE)); + int changeNextIndex = nextIndex(wallet.getNode(KeyPurpose.CHANGE)); + + for(Map.Entry entry : transactionMap.entrySet()) { + Sha256Hash txid = entry.getKey(); + Transaction tx = entry.getValue().getTransaction(); + byte[] tweakKey = tweakMap.get(txid); + if(tweakKey == null) { + continue; + } + try { + List matches = SilentPaymentUtils.scanTransactionOutputs(scanPriv, spendPub, Collections.emptySet(), tweakKey, tx.getOutputs()); + for(SilentPaymentScanMatch match : matches) { + KeyPurpose purpose = match.labelIndex() != null && match.labelIndex() == 0 ? KeyPurpose.CHANGE : KeyPurpose.RECEIVE; + int newIndex = purpose == KeyPurpose.CHANGE ? changeNextIndex : receiveNextIndex; + WalletNode newNode = createNodeForMatch(wallet, match, walletAddresses, purpose, newIndex); + if(newNode != null) { + newNodes.add(newNode); + walletAddresses.put(wallet.getAddress(newNode), newNode); + if(purpose == KeyPurpose.CHANGE) { + changeNextIndex++; + } else { + receiveNextIndex++; + } + } + } + } catch(InvalidSilentPaymentException e) { + log.warn("Invalid silent payment tweak for tx " + txid + " — skipping", e); + } + } + + return newNodes; + } + + private static int nextIndex(WalletNode purposeNode) { + return purposeNode.getChildren().isEmpty() ? 0 : purposeNode.getChildren().stream().mapToInt(WalletNode::getIndex).max().getAsInt() + 1; + } + + private WalletNode createNodeForMatch(Wallet wallet, SilentPaymentScanMatch match, Map walletAddresses, KeyPurpose purpose, int newIndex) { + WalletNode purposeNode = wallet.getNode(purpose); + Set created = purposeNode.fillToIndex(wallet, newIndex); + WalletNode addressNode = created.stream().filter(n -> n.getIndex() == newIndex).findFirst().orElseThrow(() -> new IllegalStateException("fillToIndex did not create node at index " + newIndex)); + addressNode.setSilentPaymentTweak(match.tweak()); + + if(walletAddresses.containsKey(wallet.getAddress(addressNode))) { + purposeNode.getChildren().removeAll(created); + return null; + } + + return addressNode; + } + public static String getSubscribedScriptHashStatus(String scriptHash) { List existingStatuses = subscribedScriptHashes.get(scriptHash); if(existingStatuses != null && !existingStatuses.isEmpty()) { @@ -1798,6 +2051,80 @@ public class ElectrumServer { } } + public static class SilentPaymentScanService extends Service { + private final Wallet wallet; + private final SilentPaymentScanAddress scanAddress; + private final boolean shouldHold; + private final int neededStart; + private volatile boolean releasedHold; + + public SilentPaymentScanService(Wallet wallet, boolean shouldHold, int neededStart) { + this.wallet = wallet; + this.scanAddress = wallet.getSilentPaymentScanAddress(); + this.shouldHold = shouldHold; + this.neededStart = neededStart; + } + + public boolean isReleasedHold() { + return releasedHold; + } + + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected Boolean call() throws ServerException { + boolean acquired = shouldHold || !ElectrumServer.hasSilentPaymentsCache(scanAddress); + if(acquired) { + ElectrumServer.holdSilentPaymentSubscription(wallet, scanAddress, neededStart); + } + try { + ElectrumServer electrumServer = new ElectrumServer(); + List entries = electrumServer.getSilentPaymentHistory(scanAddress); + Set newNodes = electrumServer.processSilentPaymentBatch(wallet, entries); + + //First refresh (acquired): fetch all nodes to re-subscribe scripthashes the server forgot. Live delta: only the new ones. + Set nodesToFetch = acquired ? null : newNodes; + if(nodesToFetch != null && nodesToFetch.isEmpty()) { + return true; + } + return electrumServer.fetchAndCalculateHistory(wallet, null, nodesToFetch); + } catch(Exception e) { + if(acquired) { + ElectrumServer.releaseSilentPaymentSubscription(scanAddress); + releasedHold = true; + } + throw e; + } + } + }; + } + } + + public static class SilentPaymentsUnsubscribeService extends Service { + private final SilentPaymentScanAddress scanAddress; + + public SilentPaymentsUnsubscribeService(SilentPaymentScanAddress scanAddress) { + this.scanAddress = scanAddress; + } + + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected Boolean call() throws ServerException { + if(ElectrumServer.hasSilentPaymentsCache(scanAddress)) { + return false; + } + String scanPrivHex = Utils.bytesToHex(scanAddress.getScanKey().getPrivKeyBytes()); + String spendPubHex = Utils.bytesToHex(scanAddress.getSpendKey().getPubKey(true)); + electrumServerRpc.unsubscribeSilentPayments(getTransport(), scanPrivHex, spendPubHex); + return true; + } + }; + } + } + public static class TransactionMempoolService extends ScheduledService> { private final Wallet wallet; private final Sha256Hash txId; diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java index 5c9163ef..1082aadf 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java @@ -26,7 +26,7 @@ public interface ElectrumServerRpc { Map unsubscribeScriptHashes(Transport transport, Set scriptHashes); - String subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels); + SilentPaymentsSubscription subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels); String unsubscribeSilentPayments(Transport transport, String scanPrivKeyHex, String spendPubKeyHex); diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SilentPaymentsScanCache.java b/src/main/java/com/sparrowwallet/sparrow/net/SilentPaymentsScanCache.java new file mode 100644 index 00000000..8ecdc683 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/SilentPaymentsScanCache.java @@ -0,0 +1,166 @@ +package com.sparrowwallet.sparrow.net; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +class SilentPaymentsScanCache { + private enum State { SCANNING, COMPLETED, CANCELLED } + + private Integer serverStart; + private int refCount; + private State state = State.SCANNING; + private final List entries = new ArrayList<>(); + + private final ReentrantLock lock = new ReentrantLock(); + private final Condition subscriptionComplete = lock.newCondition(); + private final Condition scanComplete = lock.newCondition(); + + void lock() { + lock.lock(); + } + + void unlock() { + lock.unlock(); + } + + void awaitSubscriptionComplete() throws InterruptedException { + assert lock.isHeldByCurrentThread(); + subscriptionComplete.await(); + } + + void awaitScanComplete() throws InterruptedException { + assert lock.isHeldByCurrentThread(); + scanComplete.await(); + } + + boolean isScanning() { + return state == State.SCANNING; + } + + boolean isCancelled() { + return state == State.CANCELLED; + } + + void cancel() { + assert lock.isHeldByCurrentThread(); + if(state == State.SCANNING) { + state = State.CANCELLED; + subscriptionComplete.signalAll(); + scanComplete.signalAll(); + } + } + + void complete() { + assert lock.isHeldByCurrentThread(); + if(state == State.SCANNING) { + state = State.COMPLETED; + scanComplete.signalAll(); + } + } + + /** + * Reset for a widening rescan: clear serverStart, clear entries, set state back to SCANNING. + *

+ * Deliberately does not signal either condition. Reasoning: + *

    + *
  • subscriptionComplete waiters require {@code hasMultipleHolders() && getServerStart() == null && isScanning()}. + * They cannot exist at this point — restartScan is reached only when {@code getServerStart() != null} + * (the widening branch's comparison just evaluated it), so any earlier waiters had already been signalled + * by {@link #setServerStart} and exited their wait loop. After restartScan clears serverStart, new waiters + * can arrive on subsequent holdSilentPaymentSubscription calls and will block normally; the next + * {@link #setServerStart} call (after the widening RPC returns) will signal them.
  • + *
  • scanComplete waiters require {@code isScanning()}. After restartScan, isScanning is still true + * (transitioning {@code SCANNING → SCANNING} for mid-scan widening, or {@code COMPLETED → SCANNING} for + * post-scan widening — and in the latter case no scanComplete waiters can exist because they would have + * returned when the prior {@link #complete} signalled them). Existing waiters should keep waiting; they'll + * wake when the new scan reaches a terminal state via {@link #complete} or {@link #cancel}.
  • + *
+ * Maintenance note: if a future change adds a new wait condition that depends on state-not-being-SCANNING, + * serverStart-being-non-null, or entries being non-empty, this method must be updated to signal the new condition. + * The widening RPC's failure path is covered separately — {@link #cancel} fires both signals — so callers do not + * rely on restartScan having signalled. + */ + void restartScan() { + assert lock.isHeldByCurrentThread(); + serverStart = null; + entries.clear(); + state = State.SCANNING; + } + + Integer getServerStart() { + assert lock.isHeldByCurrentThread(); + return serverStart; + } + + void setServerStart(int height) { + assert lock.isHeldByCurrentThread(); + serverStart = height; + subscriptionComplete.signalAll(); + } + + int incrementRefCount() { + assert lock.isHeldByCurrentThread(); + return ++refCount; + } + + boolean decrementRefCount() { + assert lock.isHeldByCurrentThread(); + return --refCount <= 0; + } + + boolean hasMultipleHolders() { + assert lock.isHeldByCurrentThread(); + return refCount > 1; + } + + void addEntries(List newEntries) { + assert lock.isHeldByCurrentThread(); + entries.addAll(newEntries); + } + + List snapshotEntries() { + assert lock.isHeldByCurrentThread(); + return new ArrayList<>(entries); + } + + /** + * Captures the cache's pre-widening state ({@code state}, {@code serverStart}, {@code entries}) so it + * can be restored if the widening RPC fails and other holders are still relying on the cache. Caller + * must already hold the cache lock. + */ + Snapshot captureSnapshot() { + assert lock.isHeldByCurrentThread(); + return new Snapshot(state, serverStart, new ArrayList<>(entries)); + } + + /** + * Restores the cache's state from a previously captured {@link Snapshot} and signals condition + * waiters whose conditions may have become re-evaluable. Used by the widening-failure recovery path + * to restore an in-progress scan when the widening RPC fails but other holders still depend on the cache. + */ + void restoreFromSnapshot(Snapshot snapshot) { + assert lock.isHeldByCurrentThread(); + state = snapshot.state; + serverStart = snapshot.serverStart; + entries.clear(); + entries.addAll(snapshot.entries); + //Wake hold-side waiters who may have been blocked on serverStart==null during the failed widening. + //scanComplete waiters whose state-condition was unchanged during the widening don't need a signal, + //but signalling is harmless (they re-check isScanning() and re-await if still scanning). + subscriptionComplete.signalAll(); + } + + static final class Snapshot { + private final State state; + private final Integer serverStart; + private final List entries; + + private Snapshot(State state, Integer serverStart, List entries) { + this.state = state; + this.serverStart = serverStart; + this.entries = entries; + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java index 97850f98..b909e7e7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java @@ -182,11 +182,11 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc { } @Override - public String subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels) { + public SilentPaymentsSubscription subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels) { JsonRpcClient client = new JsonRpcClient(transport); try { - return new RetryLogic(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() -> - client.createRequest().returnAs(String.class).method("blockchain.silentpayments.subscribe").id(idCounter.incrementAndGet()).params(scanPrivKeyHex, spendPubKeyHex, start, labels).execute()); + return new RetryLogic(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() -> + client.createRequest().returnAs(SilentPaymentsSubscription.class).method("blockchain.silentpayments.subscribe").id(idCounter.incrementAndGet()).params(scanPrivKeyHex, spendPubKeyHex, start, labels).execute()); } catch(Exception e) { throw new ElectrumServerRpcException("Failed to subscribe to silent payments for wallet " + wallet.getName(), e); } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java b/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java index 9870ca6e..4117b5b6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java @@ -7,7 +7,8 @@ import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService; import com.google.common.collect.Iterables; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.NewBlockEvent; -import com.sparrowwallet.sparrow.event.SilentPaymentsNotificationEvent; +import com.sparrowwallet.sparrow.event.SilentPaymentsHistoryUpdatedEvent; +import com.sparrowwallet.sparrow.event.SilentPaymentsScanProgressEvent; import com.sparrowwallet.sparrow.event.WalletNodeHistoryChangedEvent; import javafx.application.Platform; import org.slf4j.Logger; @@ -44,6 +45,34 @@ public class SubscriptionService { @JsonRpcMethod("blockchain.silentpayments.subscribe") public void silentPaymentsUpdate(@JsonRpcParam("subscription") final SilentPaymentsSubscription subscription, @JsonRpcParam("progress") final double progress, @JsonRpcParam("history") final List history) { - Platform.runLater(() -> EventManager.get().post(new SilentPaymentsNotificationEvent(subscription, progress, history))); + String silentPaymentAddress = subscription.address; + SilentPaymentsScanCache cache = ElectrumServer.getScanCache(silentPaymentAddress); + if(cache == null) { + log.trace("Received silent payments notification for unknown subscription: " + silentPaymentAddress); + return; + } + + boolean justCompleted = false; + cache.lock(); + try { + //Stale-notification filter: filter out notifications from a prior subscribe + Integer canonical = cache.getServerStart(); + if(canonical == null || subscription.start_height != canonical) { + return; + } + cache.addEntries(history); + if(progress >= 1.0 && cache.isScanning()) { + cache.complete(); + justCompleted = true; + } + } finally { + cache.unlock(); + } + + Platform.runLater(() -> EventManager.get().post(new SilentPaymentsScanProgressEvent(silentPaymentAddress, progress))); + + if(progress >= 1.0 && !justCompleted && !history.isEmpty()) { + Platform.runLater(() -> EventManager.get().post(new SilentPaymentsHistoryUpdatedEvent(silentPaymentAddress))); + } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/AdvancedDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/AdvancedDialog.java index fd670a8d..d7348a8c 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/AdvancedDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/AdvancedDialog.java @@ -66,6 +66,7 @@ public class AdvancedDialog extends WalletDialog { try { Date newDate = DATE_FORMAT.parse(newText); wallet.setBirthDate(newDate); + wallet.setBirthHeight(null); apply.setEnabled(true); } catch(ParseException e) { //ignore diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java index 07c1ad12..951e34ed 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java @@ -74,7 +74,7 @@ public class ReceiveDialog extends WalletDialog { try { String qrAddress; if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP) { - qrAddress = getWalletForm().getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress(); + qrAddress = getWalletForm().getWallet().getSilentPaymentScanAddress().getAddress(); } else if(currentEntry != null) { qrAddress = currentEntry.getAddress().toString(); } else { @@ -92,7 +92,7 @@ public class ReceiveDialog extends WalletDialog { public void refreshAddress() { SparrowTerminal.get().getGuiThread().invokeLater(() -> { if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP) { - String silentPaymentAddress = getWalletForm().getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress(); + String silentPaymentAddress = getWalletForm().getWallet().getSilentPaymentScanAddress().getAddress(); address.setText(silentPaymentAddress); return; } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/AdvancedController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/AdvancedController.java index 31e115e2..a7809a39 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/AdvancedController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/AdvancedController.java @@ -57,6 +57,7 @@ public class AdvancedController implements Initializable { birthDate.valueProperty().addListener((observable, oldValue, newValue) -> { if(newValue != null) { wallet.setBirthDate(Date.from(newValue.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant())); + wallet.setBirthHeight(null); EventManager.get().post(new SettingsChangedEvent(wallet, SettingsChangedEvent.Type.BIRTH_DATE)); } }); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java index a8f98c9f..f2bd51ab 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java @@ -316,7 +316,7 @@ public class PaymentController extends WalletFormController implements Initializ } } else if(newValue != null) { if(newValue.getPolicyType() == PolicyType.SINGLE_SP) { - address.setText(newValue.getKeystores().getFirst().getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress()); + address.setText(newValue.getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress()); } else { List
existingAddresses = getOtherAddresses(); WalletNode freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java index 5f10f51f..68682ed7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java @@ -271,7 +271,7 @@ public class ReceiveController extends WalletFormController implements Initializ public void copySilentPaymentsAddress(ActionEvent actionEvent) { if(walletForm.getWallet().getPolicyType() == PolicyType.SINGLE_SP) { ClipboardContent content = new ClipboardContent(); - content.putString(walletForm.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress()); + content.putString(walletForm.getWallet().getSilentPaymentScanAddress().getAddress()); Clipboard.getSystemClipboard().setContent(content); } } @@ -302,7 +302,7 @@ public class ReceiveController extends WalletFormController implements Initializ public void refreshAddress() { if(walletForm.getWallet().getPolicyType() == PolicyType.SINGLE_SP) { - String silentPaymentAddress = walletForm.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress(); + String silentPaymentAddress = walletForm.getWallet().getSilentPaymentScanAddress().getAddress(); spAddress.setText(silentPaymentAddress); Image qrImage = getSilentPaymentsQrCode(silentPaymentAddress); if(qrImage != null) { diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java index 1bdd5bff..b5de85bc 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java @@ -594,6 +594,7 @@ public class SettingsController extends WalletFormController implements Initiali private void replaceWallet(Wallet editedWallet) { editedWallet.setName(getWalletForm().getWallet().getName()); editedWallet.setBirthDate(getWalletForm().getWallet().getBirthDate()); + editedWallet.setBirthHeight(getWalletForm().getWallet().getBirthHeight()); editedWallet.setGapLimit(getWalletForm().getWallet().getGapLimit()); editedWallet.setWatchLast(getWalletForm().getWallet().getWatchLast()); editedWallet.setMasterWallet(getWalletForm().getWallet().getMasterWallet()); @@ -878,21 +879,24 @@ public class SettingsController extends WalletFormController implements Initiali @Subscribe public void walletAddressesChanged(WalletAddressesChangedEvent event) { if(event.getWalletId().equals(walletForm.getWalletId())) { - updateBirthDate(event.getWallet()); + updateBirth(event.getWallet()); } } @Subscribe public void walletHistoryChanged(WalletHistoryChangedEvent event) { if(event.getWalletId().equals(walletForm.getWalletId())) { - updateBirthDate(event.getWallet()); + updateBirth(event.getWallet()); } } - private void updateBirthDate(Wallet wallet) { + private void updateBirth(Wallet wallet) { if(!Objects.equals(wallet.getBirthDate(), walletForm.getWallet().getBirthDate())) { walletForm.getWallet().setBirthDate(wallet.getBirthDate()); } + if(!Objects.equals(wallet.getBirthHeight(), walletForm.getWallet().getBirthHeight())) { + walletForm.getWallet().setBirthHeight(wallet.getBirthHeight()); + } } @Subscribe diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java index 2fb7ca71..f0a08cef 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java @@ -2,6 +2,8 @@ package com.sparrowwallet.sparrow.wallet; import com.google.common.eventbus.Subscribe; import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; @@ -52,6 +54,10 @@ public class WalletForm { private ElectrumServer.TransactionMempoolService transactionMempoolService; + private boolean spScanInProgress; + private boolean spSubscriptionHeld; + private boolean spPendingRefresh; + private final BooleanProperty lockedProperty = new SimpleBooleanProperty(false); public WalletForm(Storage storage, Wallet currentWallet) { @@ -155,82 +161,155 @@ public class WalletForm { log.debug(nodes == null ? wallet.getFullName() + " refreshing full wallet history" : wallet.getFullName() + " requesting node wallet history for " + nodeRangesToString(nodes)); } - Set walletTransactionNodes = getWalletTransactionNodes(nodes); - if(!wallet.isNested() && (walletTransactionNodes == null || !walletTransactionNodes.isEmpty())) { - ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, filterToWallets, walletTransactionNodes); - historyService.setOnSucceeded(workerStateEvent -> { - if(historyService.getValue()) { - EventManager.get().post(new WalletHistoryFinishedEvent(wallet)); - updateWallets(blockHeight, previousWallet); - } - }); - historyService.setOnFailed(workerStateEvent -> { - if(workerStateEvent.getSource().getException() instanceof AllHistoryChangedException) { - if(getWallet().isMasterWallet() && getWallet().getKeystores().stream().anyMatch(Keystore::needsPassphrase)) { - Optional optType = AppServices.showWarningDialog("Reopen " + getWallet().getMasterName() + "?", - "It appears that the history of this wallet has changed, which may be caused by an incorrect passphrase. " + - "Note that any typos when entering the passphrase will create an entirely different wallet, with a correspondingly different history.\n\n" + - "You can proceed with a full refresh of this wallet, or you can reopen it to enter the passphrase again.", - new ButtonType("Refresh Wallet", ButtonBar.ButtonData.CANCEL_CLOSE), - new ButtonType("Reopen Wallet", ButtonBar.ButtonData.OK_DONE)); - - if(optType.isPresent() && optType.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) { - EventManager.get().post(new RequestWalletOpenEvent(AppServices.get().getWindowForWallet(getWalletId()), getStorage().getWalletFile())); - return; - } - } - - try { - storage.backupWallet(); - } catch(IOException e) { - log.error("Error backing up wallet", e); - } - - wallet.clearHistory(); - AppServices.clearTransactionHistoryCache(wallet); - EventManager.get().post(new WalletHistoryClearedEvent(wallet, previousWallet, getWalletId())); - } else { - if(AppServices.isConnected()) { - log.error("Error retrieving wallet history", workerStateEvent.getSource().getException()); - } else { - log.debug("Disconnected while retrieving wallet history", workerStateEvent.getSource().getException()); - } - - EventManager.get().post(new WalletHistoryFailedEvent(wallet, workerStateEvent.getSource().getException())); - } - }); - - EventManager.get().post(new WalletHistoryStartedEvent(wallet, nodes)); - historyService.start(); - } - - if(wallet.isMasterWallet() && wallet.hasPaymentCode() && refreshNotificationNode(nodes)) { - ElectrumServer.PaymentCodesService paymentCodesService = new ElectrumServer.PaymentCodesService(getWalletId(), wallet); - paymentCodesService.setOnSucceeded(successEvent -> { - List addedWallets = paymentCodesService.getValue(); - for(Wallet addedWallet : addedWallets) { - if(!storage.isPersisted(addedWallet)) { - try { - storage.saveWallet(addedWallet); - EventManager.get().post(new NewChildWalletSavedEvent(storage, wallet, addedWallet)); - } catch(Exception e) { - log.error("Error saving wallet", e); - AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage()); - } - } - } - if(!addedWallets.isEmpty()) { - EventManager.get().post(new ChildWalletsAddedEvent(storage, wallet, addedWallets)); - } - }); - paymentCodesService.setOnFailed(failedEvent -> { - log.error("Could not determine payment codes for wallet " + wallet.getFullName(), failedEvent.getSource().getException()); - }); - paymentCodesService.start(); + if(wallet.getPolicyType() == PolicyType.SINGLE_SP && nodes == null) { + refreshHistorySP(previousWallet, blockHeight); + } else { + refreshHistoryHD(previousWallet, blockHeight, filterToWallets, nodes); } } } + private void refreshHistoryHD(Wallet previousWallet, Integer blockHeight, List filterToWallets, Set nodes) { + Set walletTransactionNodes = getWalletTransactionNodes(nodes); + if(!wallet.isNested() && (walletTransactionNodes == null || !walletTransactionNodes.isEmpty())) { + ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, filterToWallets, walletTransactionNodes); + historyService.setOnSucceeded(workerStateEvent -> { + if(historyService.getValue()) { + EventManager.get().post(new WalletHistoryFinishedEvent(wallet)); + updateWallets(blockHeight, previousWallet); + } + }); + historyService.setOnFailed(workerStateEvent -> { + handleHistoryFailed(previousWallet, workerStateEvent.getSource().getException()); + }); + + EventManager.get().post(new WalletHistoryStartedEvent(wallet, nodes)); + historyService.start(); + } + + if(wallet.isMasterWallet() && wallet.hasPaymentCode() && refreshNotificationNode(nodes)) { + ElectrumServer.PaymentCodesService paymentCodesService = new ElectrumServer.PaymentCodesService(getWalletId(), wallet); + paymentCodesService.setOnSucceeded(successEvent -> { + List addedWallets = paymentCodesService.getValue(); + for(Wallet addedWallet : addedWallets) { + if(!storage.isPersisted(addedWallet)) { + try { + storage.saveWallet(addedWallet); + EventManager.get().post(new NewChildWalletSavedEvent(storage, wallet, addedWallet)); + } catch(Exception e) { + log.error("Error saving wallet", e); + AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage()); + } + } + } + if(!addedWallets.isEmpty()) { + EventManager.get().post(new ChildWalletsAddedEvent(storage, wallet, addedWallets)); + } + }); + paymentCodesService.setOnFailed(failedEvent -> { + log.error("Could not determine payment codes for wallet " + wallet.getFullName(), failedEvent.getSource().getException()); + }); + paymentCodesService.start(); + } + } + + private void refreshHistorySP(Wallet previousWallet, Integer blockHeight) { + SilentPaymentScanAddress scanAddress = wallet.getSilentPaymentScanAddress(); + //Self-heal: connection-change wipes spScanCaches without resetting per-form flags; reconcile here. + if(spSubscriptionHeld && !ElectrumServer.hasSilentPaymentsCache(scanAddress)) { + spSubscriptionHeld = false; + } + + if(spScanInProgress) { + //Single-flight: defer until the in-flight scan settles; firePendingRefreshIfRequested re-triggers. + spPendingRefresh = true; + return; + } + + boolean shouldHold = !spSubscriptionHeld; + + ElectrumServer.SilentPaymentScanService scanService = new ElectrumServer.SilentPaymentScanService(wallet, shouldHold, computeNeededStart(wallet)); + scanService.setOnSucceeded(workerStateEvent -> { + spScanInProgress = false; + spSubscriptionHeld = true; + if(scanService.getValue()) { + EventManager.get().post(new WalletHistoryFinishedEvent(wallet)); + updateWallets(blockHeight, previousWallet); + } + firePendingRefreshIfRequested(); + }); + scanService.setOnFailed(workerStateEvent -> { + spScanInProgress = false; + if(scanService.isReleasedHold()) { + spSubscriptionHeld = false; + } + handleHistoryFailed(previousWallet, workerStateEvent.getSource().getException()); + firePendingRefreshIfRequested(); + }); + + EventManager.get().post(new WalletHistoryStartedEvent(wallet, null)); + spScanInProgress = true; + scanService.start(); + } + + private void firePendingRefreshIfRequested() { + if(spPendingRefresh) { + spPendingRefresh = false; + Platform.runLater(() -> refreshHistory(AppServices.getCurrentBlockHeight())); + } + } + + private void handleHistoryFailed(Wallet previousWallet, Throwable exception) { + if(exception instanceof AllHistoryChangedException) { + if(getWallet().isMasterWallet() && getWallet().getKeystores().stream().anyMatch(Keystore::needsPassphrase)) { + Optional optType = AppServices.showWarningDialog("Reopen " + getWallet().getMasterName() + "?", + "It appears that the history of this wallet has changed, which may be caused by an incorrect passphrase. " + + "Note that any typos when entering the passphrase will create an entirely different wallet, with a correspondingly different history.\n\n" + + "You can proceed with a full refresh of this wallet, or you can reopen it to enter the passphrase again.", + new ButtonType("Refresh Wallet", ButtonBar.ButtonData.CANCEL_CLOSE), + new ButtonType("Reopen Wallet", ButtonBar.ButtonData.OK_DONE)); + + if(optType.isPresent() && optType.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) { + EventManager.get().post(new RequestWalletOpenEvent(AppServices.get().getWindowForWallet(getWalletId()), getStorage().getWalletFile())); + return; + } + } + + try { + storage.backupWallet(); + } catch(IOException e) { + log.error("Error backing up wallet", e); + } + + wallet.clearHistory(); + AppServices.clearTransactionHistoryCache(wallet); + spSubscriptionHeld = false; + EventManager.get().post(new WalletHistoryClearedEvent(wallet, previousWallet, getWalletId())); + } else { + if(AppServices.isConnected()) { + log.error("Error retrieving wallet history", exception); + } else { + log.debug("Disconnected while retrieving wallet history", exception); + } + + EventManager.get().post(new WalletHistoryFailedEvent(wallet, exception)); + } + } + + private static int computeNeededStart(Wallet wallet) { + Integer stored = wallet.getStoredBlockHeight(); + if(stored != null && stored > 0) { + return Math.max(0, stored - BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM); + } + if(wallet.getBirthHeight() != null) { + return Math.max(0, wallet.getBirthHeight() - BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM); + } + if(wallet.getBirthDate() != null) { + return (int)(wallet.getBirthDate().getTime() / 1000L); + } + return 0; + } + private void updateWallets(Integer blockHeight, Wallet previousWallet) { List nestedHistoryChangedNodes = new ArrayList<>(); for(Wallet childWallet : new ArrayList<>(wallet.getChildWallets())) { @@ -246,6 +325,11 @@ public class WalletForm { } private List updateWallet(Integer blockHeight, Wallet currentWallet, Wallet previousWallet, List nestedHistoryChangedNodes) { + OptionalInt min = currentWallet.getTransactions().values().stream().filter(blockTx -> blockTx.getHeight() > 0).mapToInt(BlockTransaction::getHeight).min(); + if(min.isPresent() && (currentWallet.getBirthHeight() == null || min.getAsInt() < currentWallet.getBirthHeight())) { + currentWallet.setBirthHeight(min.getAsInt()); + } + if(blockHeight != null) { currentWallet.setStoredBlockHeight(blockHeight); } @@ -401,6 +485,30 @@ public class WalletForm { return accountEntries; } + @Subscribe + public void silentPaymentsScanProgress(SilentPaymentsScanProgressEvent event) { + if(wallet.getPolicyType() != PolicyType.SINGLE_SP || !wallet.isValid() || !event.getSpAddress().equals(wallet.getSilentPaymentScanAddress().getAddress())) { + return; + } + + if(spScanInProgress && event.getProgress() < 1.0) { + EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Scanning silent payments... (" + Math.round(event.getProgress() * 100) + "%)")); + } + } + + @Subscribe + public void silentPaymentsHistoryUpdated(SilentPaymentsHistoryUpdatedEvent event) { + if(wallet.getPolicyType() != PolicyType.SINGLE_SP || !wallet.isValid() || !event.getSpAddress().equals(wallet.getSilentPaymentScanAddress().getAddress())) { + return; + } + + if(spScanInProgress) { + spPendingRefresh = true; + } else { + refreshHistory(AppServices.getCurrentBlockHeight()); + } + } + @Subscribe public void walletDataChanged(WalletDataChangedEvent event) { if(event.getWallet().equals(wallet)) { @@ -421,6 +529,7 @@ public class WalletForm { //Clear the cache - we will need to fetch everything again AppServices.clearTransactionHistoryCache(wallet); + spSubscriptionHeld = false; refreshHistory(AppServices.getCurrentBlockHeight()); } } @@ -658,16 +767,17 @@ public class WalletForm { public void walletTabsClosed(WalletTabsClosedEvent event) { for(WalletTabData tabData : event.getClosedWalletTabData()) { if(tabData.getWalletForm() == this) { - if(wallet.isMasterWallet()) { - storage.close(); - } - if(wallet.isValid()) { - AppServices.clearTransactionHistoryCache(wallet); - } EventManager.get().unregister(this); for(WalletForm nestedWalletForm : nestedWalletForms) { EventManager.get().unregister(nestedWalletForm); } + if(wallet.isValid()) { + AppServices.clearTransactionHistoryCache(wallet); + spSubscriptionHeld = false; + } + if(wallet.isMasterWallet()) { + storage.close(); + } } } }