implement sp wallet loading

This commit is contained in:
Craig Raw
2026-05-04 14:02:52 +02:00
parent e96a7113b0
commit e64069f02f
22 changed files with 823 additions and 141 deletions
@@ -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();
}
}
}
@@ -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<Entry> {
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<Entry> {
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
@@ -336,7 +336,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> 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<Entry, Entry> 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);
@@ -218,7 +218,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
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<Transaction> {
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());
}
@@ -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".
* <p>
* 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;
}
}
@@ -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.
* <p>
* 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<SilentPaymentsTx> history;
public SilentPaymentsNotificationEvent(SilentPaymentsSubscription subscription, double progress, List<SilentPaymentsTx> history) {
this.subscription = subscription;
this.progress = progress;
this.history = history;
}
public SilentPaymentsSubscription getSubscription() {
return subscription;
}
public double getProgress() {
return progress;
}
public List<SilentPaymentsTx> getHistory() {
return history;
}
}
@@ -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.01.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;
}
}
@@ -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;
}
}
@@ -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<String>(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<SilentPaymentsSubscription>(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);
}
@@ -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<Integer, WalletSyncLock> walletSyncLocks = Collections.synchronizedMap(new HashMap<>());
private static final Map<String, SilentPaymentsScanCache> spScanCaches = new ConcurrentHashMap<>();
private final static Map<String, Integer> subscribedRecent = new ConcurrentHashMap<>();
private final static Map<String, String> 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<SilentPaymentsTx> 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<WalletNode> processSilentPaymentBatch(Wallet wallet, List<SilentPaymentsTx> entries) throws ServerException {
if(entries.isEmpty()) {
return Collections.emptySet();
}
Map<BlockTransactionHash, Transaction> references = new TreeMap<>();
Map<Sha256Hash, byte[]> 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<Integer, BlockHeader> blockHeaderMap = getBlockHeaders(wallet, references.keySet());
Map<Sha256Hash, BlockTransaction> transactionMap = getTransactions(wallet, references, blockHeaderMap);
ECKey scanPriv = wallet.getSilentPaymentScanAddress().getScanKey();
ECKey spendPub = wallet.getSilentPaymentScanAddress().getSpendKey();
Map<Address, WalletNode> walletAddresses = wallet.getWalletAddresses();
Set<WalletNode> newNodes = new LinkedHashSet<>();
int receiveNextIndex = nextIndex(wallet.getNode(KeyPurpose.RECEIVE));
int changeNextIndex = nextIndex(wallet.getNode(KeyPurpose.CHANGE));
for(Map.Entry<Sha256Hash, BlockTransaction> entry : transactionMap.entrySet()) {
Sha256Hash txid = entry.getKey();
Transaction tx = entry.getValue().getTransaction();
byte[] tweakKey = tweakMap.get(txid);
if(tweakKey == null) {
continue;
}
try {
List<SilentPaymentScanMatch> 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<Address, WalletNode> walletAddresses, KeyPurpose purpose, int newIndex) {
WalletNode purposeNode = wallet.getNode(purpose);
Set<WalletNode> 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<String> existingStatuses = subscribedScriptHashes.get(scriptHash);
if(existingStatuses != null && !existingStatuses.isEmpty()) {
@@ -1798,6 +2051,80 @@ public class ElectrumServer {
}
}
public static class SilentPaymentScanService extends Service<Boolean> {
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<Boolean> 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<SilentPaymentsTx> entries = electrumServer.getSilentPaymentHistory(scanAddress);
Set<WalletNode> 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<WalletNode> 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<Boolean> {
private final SilentPaymentScanAddress scanAddress;
public SilentPaymentsUnsubscribeService(SilentPaymentScanAddress scanAddress) {
this.scanAddress = scanAddress;
}
@Override
protected Task<Boolean> 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<Set<String>> {
private final Wallet wallet;
private final Sha256Hash txId;
@@ -26,7 +26,7 @@ public interface ElectrumServerRpc {
Map<String, Boolean> unsubscribeScriptHashes(Transport transport, Set<String> 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);
@@ -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<SilentPaymentsTx> 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.
* <p>
* Deliberately does <b>not</b> signal either condition. Reasoning:
* <ul>
* <li><b>subscriptionComplete</b> 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.</li>
* <li><b>scanComplete</b> 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}.</li>
* </ul>
* <b>Maintenance note:</b> 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<SilentPaymentsTx> newEntries) {
assert lock.isHeldByCurrentThread();
entries.addAll(newEntries);
}
List<SilentPaymentsTx> 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<SilentPaymentsTx> entries;
private Snapshot(State state, Integer serverStart, List<SilentPaymentsTx> entries) {
this.state = state;
this.serverStart = serverStart;
this.entries = entries;
}
}
}
@@ -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<String>(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<SilentPaymentsSubscription>(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);
}
@@ -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<SilentPaymentsTx> 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)));
}
}
}
@@ -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
@@ -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;
}
@@ -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));
}
});
@@ -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<Address> existingAddresses = getOtherAddresses();
WalletNode freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE);
@@ -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) {
@@ -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
@@ -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<WalletNode> 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<ButtonType> 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<Wallet> 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<Wallet> filterToWallets, Set<WalletNode> nodes) {
Set<WalletNode> 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<Wallet> 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<ButtonType> 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<WalletNode> nestedHistoryChangedNodes = new ArrayList<>();
for(Wallet childWallet : new ArrayList<>(wallet.getChildWallets())) {
@@ -246,6 +325,11 @@ public class WalletForm {
}
private List<WalletNode> updateWallet(Integer blockHeight, Wallet currentWallet, Wallet previousWallet, List<WalletNode> 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();
}
}
}
}