mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-08-10 08:37:05 +00:00
implement sp wallet loading
This commit is contained in:
@@ -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.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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user