mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2024-12-24 12:46:45 +00:00
add cormorant server to support bitcoin core descriptor wallets
This commit is contained in:
parent
df7f40dbc9
commit
08cf01a5c6
54 changed files with 2100 additions and 47 deletions
2
drongo
2
drongo
|
@ -1 +1 @@
|
|||
Subproject commit fa18ec9d458bb17221fb01b6be9f4eceb354a156
|
||||
Subproject commit 692f23e02656b43b58c33b44467f920ddc3a3f65
|
|
@ -2589,6 +2589,50 @@ public class AppController implements Initializable {
|
|||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void cormorantSyncStatus(CormorantSyncStatusEvent event) {
|
||||
serverToggle.setDisable(false);
|
||||
if((AppServices.isConnecting() || AppServices.isConnected()) && !event.isCompleted()) {
|
||||
statusUpdated(new StatusEvent("Syncing... (" + event.getProgress() + "% complete, synced to " + event.getTipAsString() + ")"));
|
||||
if(event.getProgress() > 0 && (statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING)) {
|
||||
statusBar.setProgress((double)event.getProgress() / 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void cormorantScanStatus(CormorantScanStatusEvent event) {
|
||||
serverToggle.setDisable(true);
|
||||
if((AppServices.isConnecting() || AppServices.isConnected()) && !event.isCompleted()) {
|
||||
statusUpdated(new StatusEvent("Scanning... (" + event.getProgress() + "% complete, " + event.getRemainingAsString() + " remaining)"));
|
||||
if(event.getProgress() > 0 && (statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING)) {
|
||||
statusBar.setProgress((double)event.getProgress() / 100);
|
||||
}
|
||||
} else if(event.isCompleted()) {
|
||||
serverToggle.setDisable(false);
|
||||
if(statusBar.getText().startsWith("Scanning...")) {
|
||||
statusBar.setText("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void cormorantPruneStatus(CormorantPruneStatusEvent event) {
|
||||
if(event.legacyWalletExists()) {
|
||||
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("Error importing Bitcoin Core descriptor wallet",
|
||||
"The connected node is pruned at " + event.getPruneDateAsString() + ", but the wallet birthday for " + event.getWallet().getFullDisplayName() + " is set to " + event.getScanDateAsString() + ".\n\n" +
|
||||
"Do you want to try using the existing legacy Bitcoin Core wallet?", ButtonType.YES, ButtonType.NO);
|
||||
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
|
||||
Config.get().setUseLegacyCoreWallet(true);
|
||||
onlineProperty().set(false);
|
||||
Platform.runLater(() -> onlineProperty().set(true));
|
||||
}
|
||||
} else {
|
||||
AppServices.showErrorDialog("Error importing Bitcoin Core descriptor wallet",
|
||||
"The connected node is pruned at " + event.getPruneDateAsString() + ", but the wallet birthday for " + event.getWallet().getFullDisplayName() + " is set to " + event.getScanDateAsString() + ".");
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void bwtBootStatus(BwtBootStatusEvent event) {
|
||||
serverToggle.setDisable(true);
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
public class CormorantPruneStatusEvent extends CormorantStatusEvent {
|
||||
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy/MM/dd");
|
||||
|
||||
private final Wallet wallet;
|
||||
private final Date scanDate;
|
||||
private final Date pruneDate;
|
||||
private final boolean legacyWalletExists;
|
||||
|
||||
public CormorantPruneStatusEvent(String status, Wallet wallet, Date scanDate, Date pruneDate, boolean legacyWalletExists) {
|
||||
super(status);
|
||||
this.wallet = wallet;
|
||||
this.scanDate = scanDate;
|
||||
this.pruneDate = pruneDate;
|
||||
this.legacyWalletExists = legacyWalletExists;
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public Date getScanDate() {
|
||||
return scanDate;
|
||||
}
|
||||
|
||||
public String getScanDateAsString() {
|
||||
return DATE_FORMAT.format(scanDate);
|
||||
}
|
||||
|
||||
public Date getPruneDate() {
|
||||
return pruneDate;
|
||||
}
|
||||
|
||||
public String getPruneDateAsString() {
|
||||
return DATE_FORMAT.format(pruneDate);
|
||||
}
|
||||
|
||||
public boolean legacyWalletExists() {
|
||||
return legacyWalletExists;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
public class CormorantScanStatusEvent extends CormorantStatusEvent {
|
||||
private final int progress;
|
||||
private final Duration remainingDuration;
|
||||
|
||||
public CormorantScanStatusEvent(String status, int progress, Duration remainingDuration) {
|
||||
super(status);
|
||||
this.progress = progress;
|
||||
this.remainingDuration = remainingDuration;
|
||||
}
|
||||
|
||||
public int getProgress() {
|
||||
return progress;
|
||||
}
|
||||
|
||||
public boolean isCompleted() {
|
||||
return progress == 100;
|
||||
}
|
||||
|
||||
public Duration getRemaining() {
|
||||
return remainingDuration;
|
||||
}
|
||||
|
||||
public String getRemainingAsString() {
|
||||
if(remainingDuration != null) {
|
||||
if(progress < 30) {
|
||||
return Math.round((double)remainingDuration.toSeconds() / 60) + "m";
|
||||
} else {
|
||||
return remainingDuration.toMinutesPart() + "m " + remainingDuration.toSecondsPart() + "s";
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
public class CormorantStatusEvent {
|
||||
private final String status;
|
||||
|
||||
public CormorantStatusEvent(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
public class CormorantSyncStatusEvent extends CormorantStatusEvent {
|
||||
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy/MM/dd HH:mm");
|
||||
|
||||
private final int progress;
|
||||
private final Date tip;
|
||||
|
||||
public CormorantSyncStatusEvent(String status, int progress, Date tip) {
|
||||
super(status);
|
||||
this.progress = progress;
|
||||
this.tip = tip;
|
||||
}
|
||||
|
||||
public int getProgress() {
|
||||
return progress;
|
||||
}
|
||||
|
||||
public boolean isCompleted() {
|
||||
return progress == 100;
|
||||
}
|
||||
|
||||
public Date getTip() {
|
||||
return tip;
|
||||
}
|
||||
|
||||
public String getTipAsString() {
|
||||
return tip == null ? "" : DATE_FORMAT.format(tip);
|
||||
}
|
||||
}
|
|
@ -64,6 +64,7 @@ public class Config {
|
|||
private CoreAuthType coreAuthType;
|
||||
private File coreDataDir;
|
||||
private String coreAuth;
|
||||
private boolean useLegacyCoreWallet;
|
||||
private Server electrumServer;
|
||||
private List<Server> recentElectrumServers;
|
||||
private File electrumServerCert;
|
||||
|
@ -512,6 +513,15 @@ public class Config {
|
|||
flush();
|
||||
}
|
||||
|
||||
public boolean isUseLegacyCoreWallet() {
|
||||
return useLegacyCoreWallet;
|
||||
}
|
||||
|
||||
public void setUseLegacyCoreWallet(boolean useLegacyCoreWallet) {
|
||||
this.useLegacyCoreWallet = useLegacyCoreWallet;
|
||||
flush();
|
||||
}
|
||||
|
||||
public Server getElectrumServer() {
|
||||
return electrumServer;
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ import org.slf4j.LoggerFactory;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
@ -33,7 +32,6 @@ public class Bwt {
|
|||
private static final Logger log = LoggerFactory.getLogger(Bwt.class);
|
||||
|
||||
public static final String DEFAULT_CORE_WALLET = "sparrow";
|
||||
public static final String ELECTRUM_HOST = "127.0.0.1";
|
||||
public static final String ELECTRUM_PORT = "0";
|
||||
private static final int IMPORT_BATCH_SIZE = 350;
|
||||
private static boolean initialized;
|
||||
|
@ -142,7 +140,7 @@ public class Bwt {
|
|||
bwtConfig.setupLogger = false;
|
||||
}
|
||||
|
||||
bwtConfig.electrumAddr = ELECTRUM_HOST + ":" + ELECTRUM_PORT;
|
||||
bwtConfig.electrumAddr = ElectrumServer.CORE_ELECTRUM_HOST + ":" + ELECTRUM_PORT;
|
||||
bwtConfig.electrumSkipMerkle = true;
|
||||
|
||||
Config config = Config.get();
|
||||
|
|
|
@ -15,6 +15,8 @@ import com.sparrowwallet.sparrow.EventManager;
|
|||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.Server;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.CormorantBitcoindException;
|
||||
import com.sparrowwallet.sparrow.paynym.PayNym;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
|
@ -43,6 +45,8 @@ public class ElectrumServer {
|
|||
|
||||
private static final Version FULCRUM_MIN_BATCHING_VERSION = new Version("1.6.0");
|
||||
|
||||
public static final String CORE_ELECTRUM_HOST = "127.0.0.1";
|
||||
|
||||
private static final int MINIMUM_BROADCASTS = 2;
|
||||
|
||||
public static final BlockTransaction UNFETCHABLE_BLOCK_TRANSACTION = new BlockTransaction(Sha256Hash.ZERO_HASH, 0, null, null, null);
|
||||
|
@ -59,7 +63,9 @@ public class ElectrumServer {
|
|||
|
||||
private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc();
|
||||
|
||||
private static Server bwtElectrumServer;
|
||||
private static Cormorant cormorant;
|
||||
|
||||
private static Server coreElectrumServer;
|
||||
|
||||
private static final Pattern RPC_WALLET_LOADING_PATTERN = Pattern.compile(".*\"(Wallet loading failed:[^\"]*)\".*");
|
||||
|
||||
|
@ -74,12 +80,12 @@ public class ElectrumServer {
|
|||
electrumServer = Config.get().getPublicElectrumServer();
|
||||
proxyServer = Config.get().getProxyServer();
|
||||
} else if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
|
||||
if(bwtElectrumServer == null) {
|
||||
if(coreElectrumServer == null) {
|
||||
throw new ServerConfigException("Could not connect to Bitcoin Core RPC");
|
||||
}
|
||||
electrumServer = bwtElectrumServer;
|
||||
if(previousServer != null && previousServer.getUrl().contains(Bwt.ELECTRUM_HOST)) {
|
||||
previousServer = bwtElectrumServer;
|
||||
electrumServer = coreElectrumServer;
|
||||
if(previousServer != null && previousServer.getUrl().contains(CORE_ELECTRUM_HOST)) {
|
||||
previousServer = coreElectrumServer;
|
||||
}
|
||||
} else if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER) {
|
||||
electrumServer = Config.get().getElectrumServer();
|
||||
|
@ -1080,46 +1086,59 @@ public class ElectrumServer {
|
|||
ElectrumServer electrumServer = new ElectrumServer();
|
||||
|
||||
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
|
||||
Bwt.initialize();
|
||||
try {
|
||||
if(Config.get().isUseLegacyCoreWallet()) {
|
||||
throw new CormorantBitcoindException("Legacy wallet configured");
|
||||
}
|
||||
if(ElectrumServer.cormorant == null) {
|
||||
ElectrumServer.cormorant = new Cormorant();
|
||||
ElectrumServer.coreElectrumServer = cormorant.start();
|
||||
}
|
||||
} catch(CormorantBitcoindException e) {
|
||||
ElectrumServer.cormorant = null;
|
||||
log.debug("Cannot start cormorant: " + e.getMessage() + ". Starting BWT...");
|
||||
|
||||
Bwt.initialize();
|
||||
|
||||
if(!bwt.isRunning()) {
|
||||
Bwt.ConnectionService bwtConnectionService = bwt.getConnectionService(subscribe);
|
||||
bwtStartException = null;
|
||||
bwtConnectionService.setOnFailed(workerStateEvent -> {
|
||||
log.error("Failed to start BWT", workerStateEvent.getSource().getException());
|
||||
bwtStartException = workerStateEvent.getSource().getException();
|
||||
try {
|
||||
bwtStartLock.lock();
|
||||
bwtStartCondition.signal();
|
||||
} finally {
|
||||
bwtStartLock.unlock();
|
||||
}
|
||||
});
|
||||
Platform.runLater(bwtConnectionService::start);
|
||||
|
||||
if(!bwt.isRunning()) {
|
||||
Bwt.ConnectionService bwtConnectionService = bwt.getConnectionService(subscribe);
|
||||
bwtStartException = null;
|
||||
bwtConnectionService.setOnFailed(workerStateEvent -> {
|
||||
log.error("Failed to start BWT", workerStateEvent.getSource().getException());
|
||||
bwtStartException = workerStateEvent.getSource().getException();
|
||||
try {
|
||||
bwtStartLock.lock();
|
||||
bwtStartCondition.signal();
|
||||
bwtStartCondition.await();
|
||||
|
||||
if(!bwt.isReady()) {
|
||||
if(bwtStartException != null) {
|
||||
Matcher walletLoadingMatcher = RPC_WALLET_LOADING_PATTERN.matcher(bwtStartException.getMessage());
|
||||
if(bwtStartException.getMessage().contains("Wallet file not specified")) {
|
||||
throw new ServerException("Bitcoin Core requires Multi-Wallet to be enabled in the Server Preferences");
|
||||
} else if(bwtStartException.getMessage().contains("Taproot wallets are not supported")) {
|
||||
throw new ServerException(bwtStartException.getMessage());
|
||||
} else if(walletLoadingMatcher.matches() && walletLoadingMatcher.group(1) != null) {
|
||||
throw new ServerException(walletLoadingMatcher.group(1));
|
||||
}
|
||||
}
|
||||
|
||||
throw new ServerException("Check if Bitcoin Core is running, and the authentication details are correct.");
|
||||
}
|
||||
} catch(InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} finally {
|
||||
bwtStartLock.unlock();
|
||||
}
|
||||
});
|
||||
Platform.runLater(bwtConnectionService::start);
|
||||
|
||||
try {
|
||||
bwtStartLock.lock();
|
||||
bwtStartCondition.await();
|
||||
|
||||
if(!bwt.isReady()) {
|
||||
if(bwtStartException != null) {
|
||||
Matcher walletLoadingMatcher = RPC_WALLET_LOADING_PATTERN.matcher(bwtStartException.getMessage());
|
||||
if(bwtStartException.getMessage().contains("Wallet file not specified")) {
|
||||
throw new ServerException("Bitcoin Core requires Multi-Wallet to be enabled in the Server Preferences");
|
||||
} else if(bwtStartException.getMessage().contains("Taproot wallets are not supported")) {
|
||||
throw new ServerException(bwtStartException.getMessage());
|
||||
} else if(walletLoadingMatcher.matches() && walletLoadingMatcher.group(1) != null) {
|
||||
throw new ServerException(walletLoadingMatcher.group(1));
|
||||
}
|
||||
}
|
||||
|
||||
throw new ServerException("Check if Bitcoin Core is running, and the authentication details are correct.");
|
||||
}
|
||||
} catch(InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} finally {
|
||||
bwtStartLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1196,7 +1215,7 @@ public class ElectrumServer {
|
|||
}
|
||||
|
||||
public boolean isConnecting() {
|
||||
return isRunning() && firstCall && !shutdown && (Config.get().getServerType() != ServerType.BITCOIN_CORE || (bwt.isRunning() && !bwt.isReady()));
|
||||
return isRunning() && firstCall && !shutdown && (Config.get().getServerType() != ServerType.BITCOIN_CORE || (cormorant != null && !cormorant.isRunning()) || (bwt.isRunning() && !bwt.isReady()));
|
||||
}
|
||||
|
||||
public boolean isConnectionRunning() {
|
||||
|
@ -1204,7 +1223,7 @@ public class ElectrumServer {
|
|||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return isRunning() && !firstCall && (Config.get().getServerType() != ServerType.BITCOIN_CORE || (bwt.isRunning() && bwt.isReady()));
|
||||
return isRunning() && !firstCall && (Config.get().getServerType() != ServerType.BITCOIN_CORE || (cormorant != null && cormorant.isRunning()) || (bwt.isRunning() && bwt.isReady()));
|
||||
}
|
||||
|
||||
public boolean isShutdown() {
|
||||
|
@ -1229,10 +1248,16 @@ public class ElectrumServer {
|
|||
reader.interrupt();
|
||||
}
|
||||
|
||||
if(ElectrumServer.cormorant != null) {
|
||||
ElectrumServer.cormorant.stop();
|
||||
ElectrumServer.cormorant = null;
|
||||
ElectrumServer.coreElectrumServer = null;
|
||||
}
|
||||
|
||||
if(Config.get().getServerType() == ServerType.BITCOIN_CORE && bwt.isRunning()) {
|
||||
Bwt.DisconnectionService disconnectionService = bwt.getDisconnectionService();
|
||||
disconnectionService.setOnSucceeded(workerStateEvent -> {
|
||||
ElectrumServer.bwtElectrumServer = null;
|
||||
ElectrumServer.coreElectrumServer = null;
|
||||
if(subscribe) {
|
||||
EventManager.get().post(new BwtShutdownEvent());
|
||||
}
|
||||
|
@ -1261,7 +1286,7 @@ public class ElectrumServer {
|
|||
@Subscribe
|
||||
public void bwtElectrumReadyStatus(BwtElectrumReadyStatusEvent event) {
|
||||
if(this.isRunning()) {
|
||||
ElectrumServer.bwtElectrumServer = new Server(Protocol.TCP.toUrlString(HostAndPort.fromString(event.getElectrumAddr())));
|
||||
ElectrumServer.coreElectrumServer = new Server(Protocol.TCP.toUrlString(HostAndPort.fromString(event.getElectrumAddr())));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1323,6 +1348,12 @@ public class ElectrumServer {
|
|||
protected Task<Boolean> createTask() {
|
||||
return new Task<>() {
|
||||
protected Boolean call() throws ServerException {
|
||||
if(ElectrumServer.cormorant != null) {
|
||||
if(!ElectrumServer.cormorant.checkWalletImport(mainWallet)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
boolean historyFetched = getTransactionHistory(mainWallet);
|
||||
for(Wallet childWallet : new ArrayList<>(mainWallet.getChildWallets())) {
|
||||
if(childWallet.isNested()) {
|
||||
|
@ -1423,6 +1454,12 @@ public class ElectrumServer {
|
|||
protected Task<Set<String>> createTask() {
|
||||
return new Task<>() {
|
||||
protected Set<String> call() throws ServerException {
|
||||
if(ElectrumServer.cormorant != null) {
|
||||
if(!ElectrumServer.cormorant.checkWalletImport(wallet)) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
iterationCount.set(iterationCount.get() + 1);
|
||||
ElectrumServer electrumServer = new ElectrumServer();
|
||||
return electrumServer.getMempoolScriptHashes(wallet, txId, nodes);
|
||||
|
@ -1737,6 +1774,12 @@ public class ElectrumServer {
|
|||
protected Task<List<Wallet>> createTask() {
|
||||
return new Task<>() {
|
||||
protected List<Wallet> call() throws ServerException {
|
||||
if(ElectrumServer.cormorant != null) {
|
||||
if(!ElectrumServer.cormorant.checkWalletImport(wallet)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
Wallet notificationWallet = wallet.getNotificationWallet();
|
||||
WalletNode notificationNode = notificationWallet.getNode(KeyPurpose.NOTIFICATION);
|
||||
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant;
|
||||
|
||||
import com.google.common.eventbus.EventBus;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.io.Server;
|
||||
import com.sparrowwallet.sparrow.net.Protocol;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.BitcoindClient;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.CormorantBitcoindException;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.ImportFailedException;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.electrum.ElectrumServerRunnable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class Cormorant {
|
||||
private static final Logger log = LoggerFactory.getLogger(Cormorant.class);
|
||||
|
||||
private static final EventBus EVENT_BUS = new EventBus();
|
||||
|
||||
public static final String SERVER_NAME = "Cormorant";
|
||||
|
||||
private BitcoindClient bitcoindClient;
|
||||
private ElectrumServerRunnable electrumServer;
|
||||
|
||||
private boolean running;
|
||||
|
||||
public Server start() throws CormorantBitcoindException {
|
||||
bitcoindClient = new BitcoindClient();
|
||||
bitcoindClient.initialize();
|
||||
|
||||
Thread importThread = new Thread(() -> {
|
||||
try {
|
||||
bitcoindClient.importWallets(AppServices.get().getOpenWallets().keySet());
|
||||
} catch(ImportFailedException e) {
|
||||
log.debug("Failed to import wallets", e);
|
||||
}
|
||||
}, "Cormorant Initial Wallet Importer");
|
||||
importThread.setDaemon(true);
|
||||
importThread.start();
|
||||
|
||||
electrumServer = new ElectrumServerRunnable(bitcoindClient);
|
||||
Thread electrumServerThread = new Thread(electrumServer, "Cormorant Electrum Server");
|
||||
electrumServerThread.setDaemon(true);
|
||||
electrumServerThread.start();
|
||||
|
||||
running = true;
|
||||
return new Server(Protocol.TCP.toUrlString(com.sparrowwallet.sparrow.net.ElectrumServer.CORE_ELECTRUM_HOST, electrumServer.getPort()));
|
||||
}
|
||||
|
||||
public boolean checkWalletImport(Wallet wallet) {
|
||||
//Will block until all wallet descriptors have been added
|
||||
try {
|
||||
bitcoindClient.importWallet(wallet);
|
||||
return true;
|
||||
} catch(ImportFailedException e) {
|
||||
log.debug("Failed to import wallets", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
bitcoindClient.stop();
|
||||
if(electrumServer != null) {
|
||||
electrumServer.stop();
|
||||
}
|
||||
|
||||
running = false;
|
||||
}
|
||||
|
||||
public static EventBus getEventBus() {
|
||||
return EVENT_BUS;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,493 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
|
||||
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.CormorantPruneStatusEvent;
|
||||
import com.sparrowwallet.sparrow.event.CormorantScanStatusEvent;
|
||||
import com.sparrowwallet.sparrow.event.CormorantSyncStatusEvent;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.net.Bwt;
|
||||
import com.sparrowwallet.sparrow.net.CoreAuthType;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.electrum.ElectrumBlockHeader;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.electrum.ScriptHashStatus;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.index.Store;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import javafx.application.Platform;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.locks.Condition;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class BitcoindClient {
|
||||
private static final Logger log = LoggerFactory.getLogger(BitcoindClient.class);
|
||||
|
||||
public static final String CORE_WALLET_NAME = "cormorant";
|
||||
private static final int GAP_LIMIT = 1000;
|
||||
|
||||
private final JsonRpcClient jsonRpcClient;
|
||||
private final Timer timer = new Timer(true);
|
||||
private final Store store = new Store();
|
||||
|
||||
private NetworkInfo networkInfo;
|
||||
private String lastBlock;
|
||||
private ElectrumBlockHeader tip;
|
||||
|
||||
private final Map<String, Lock> descriptorLocks = Collections.synchronizedMap(new HashMap<>());
|
||||
private final Map<String, ScanDate> importedDescriptors = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
private boolean initialized;
|
||||
private boolean stopped;
|
||||
|
||||
private boolean pruned;
|
||||
private boolean prunedWarningShown = false;
|
||||
private boolean legacyWalletExists;
|
||||
|
||||
private final Lock syncingLock = new ReentrantLock();
|
||||
private final Condition syncingCondition = syncingLock.newCondition();
|
||||
private boolean syncing;
|
||||
|
||||
private final Lock scanningLock = new ReentrantLock();
|
||||
|
||||
public BitcoindClient() {
|
||||
BitcoindTransport bitcoindTransport;
|
||||
|
||||
Config config = Config.get();
|
||||
if((config.getCoreAuthType() == CoreAuthType.COOKIE || config.getCoreAuth() == null || config.getCoreAuth().length() < 2) && config.getCoreDataDir() != null) {
|
||||
bitcoindTransport = new BitcoindTransport(config.getCoreServer(), CORE_WALLET_NAME, config.getCoreDataDir());
|
||||
} else {
|
||||
bitcoindTransport = new BitcoindTransport(config.getCoreServer(), CORE_WALLET_NAME, config.getCoreAuth());
|
||||
}
|
||||
|
||||
this.jsonRpcClient = new JsonRpcClient(bitcoindTransport);
|
||||
}
|
||||
|
||||
public void initialize() throws CormorantBitcoindException {
|
||||
networkInfo = getBitcoindService().getNetworkInfo();
|
||||
if(networkInfo.version() < 240000) {
|
||||
throw new CormorantBitcoindException("Bitcoin Core versions older than v24 are not supported");
|
||||
}
|
||||
|
||||
BlockchainInfo blockchainInfo = getBitcoindService().getBlockchainInfo();
|
||||
pruned = blockchainInfo.pruned();
|
||||
VerboseBlockHeader blockHeader = getBitcoindService().getBlockHeader(blockchainInfo.bestblockhash());
|
||||
tip = blockHeader.getBlockHeader();
|
||||
timer.schedule(new PollTask(), 5000, 5000);
|
||||
|
||||
if(blockchainInfo.initialblockdownload()) {
|
||||
syncingLock.lock();
|
||||
try {
|
||||
syncing = true;
|
||||
syncingCondition.await();
|
||||
} catch(InterruptedException e) {
|
||||
throw new CormorantBitcoindException("Interrupted while waiting for sync to complete");
|
||||
} finally {
|
||||
syncingLock.unlock();
|
||||
}
|
||||
|
||||
blockchainInfo = getBitcoindService().getBlockchainInfo();
|
||||
blockHeader = getBitcoindService().getBlockHeader(blockchainInfo.bestblockhash());
|
||||
tip = blockHeader.getBlockHeader();
|
||||
}
|
||||
|
||||
ListWalletDirResult listWalletDirResult = getBitcoindService().listWalletDir();
|
||||
boolean exists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(CORE_WALLET_NAME));
|
||||
legacyWalletExists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(Bwt.DEFAULT_CORE_WALLET));
|
||||
|
||||
if(!exists) {
|
||||
getBitcoindService().createWallet(CORE_WALLET_NAME, true, true, "", true, true, false, false);
|
||||
} else {
|
||||
try {
|
||||
getBitcoindService().loadWallet(CORE_WALLET_NAME, false);
|
||||
} catch(JsonRpcException e) {
|
||||
getBitcoindService().unloadWallet(CORE_WALLET_NAME, false);
|
||||
getBitcoindService().loadWallet(CORE_WALLET_NAME, false);
|
||||
}
|
||||
}
|
||||
|
||||
ListSinceBlock listSinceBlock = getListSinceBlock(null);
|
||||
updateStore(listSinceBlock);
|
||||
}
|
||||
|
||||
private ListSinceBlock getListSinceBlock(String blockHash) {
|
||||
return getBitcoindService().listSinceBlock(blockHash, 1, true, true, true);
|
||||
}
|
||||
|
||||
public void importWallets(Set<Wallet> wallets) throws ImportFailedException {
|
||||
importDescriptors(getWalletDescriptors(wallets));
|
||||
}
|
||||
|
||||
public void importWallet(Wallet wallet) throws ImportFailedException {
|
||||
importDescriptors(getWalletDescriptors(Set.of(wallet)));
|
||||
}
|
||||
|
||||
private Map<String, ScanDate> getWalletDescriptors(Set<Wallet> wallets) throws ImportFailedException {
|
||||
List<Wallet> validWallets = wallets.stream().filter(Wallet::isValid).collect(Collectors.toList());
|
||||
|
||||
Map<String, ScanDate> outputDescriptors = new LinkedHashMap<>();
|
||||
for(Wallet wallet : validWallets) {
|
||||
if(pruned) {
|
||||
Optional<Date> optPrunedDate = getPrunedDate();
|
||||
if(optPrunedDate.isPresent() && wallet.getBirthDate() != null) {
|
||||
Date prunedDate = optPrunedDate.get();
|
||||
Date earliestScanDate = wallet.getBirthDate();
|
||||
if(earliestScanDate.before(prunedDate)) {
|
||||
if(!prunedWarningShown) {
|
||||
prunedWarningShown = true;
|
||||
Platform.runLater(() -> EventManager.get().post(new CormorantPruneStatusEvent("Error: Wallet birthday earlier than Bitcoin Core prune date", wallet, earliestScanDate, prunedDate, legacyWalletExists)));
|
||||
}
|
||||
throw new ImportFailedException("Wallet birthday earlier than prune date");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutputDescriptor receiveOutputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.RECEIVE);
|
||||
outputDescriptors.put(OutputDescriptor.normalize(receiveOutputDescriptor.toString(false, false)), getScanDate(wallet, KeyPurpose.RECEIVE));
|
||||
OutputDescriptor changeOutputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.CHANGE);
|
||||
outputDescriptors.put(OutputDescriptor.normalize(changeOutputDescriptor.toString(false, false)), getScanDate(wallet, KeyPurpose.CHANGE));
|
||||
|
||||
if(wallet.isMasterWallet() && wallet.hasPaymentCode()) {
|
||||
Wallet notificationWallet = wallet.getNotificationWallet();
|
||||
WalletNode notificationNode = notificationWallet.getNode(KeyPurpose.NOTIFICATION);
|
||||
outputDescriptors.put(OutputDescriptor.normalize(OutputDescriptor.toDescriptorString(notificationNode.getAddress())), getScanDate(wallet, null));
|
||||
|
||||
for(Wallet childWallet : wallet.getChildWallets()) {
|
||||
if(childWallet.isNested()) {
|
||||
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
|
||||
for(WalletNode addressNode : childWallet.getNode(keyPurpose).getChildren()) {
|
||||
outputDescriptors.put(OutputDescriptor.normalize(OutputDescriptor.toDescriptorString(addressNode.getAddress())), getScanDate(wallet, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outputDescriptors;
|
||||
}
|
||||
|
||||
private Optional<Date> getPrunedDate() {
|
||||
BlockchainInfo blockchainInfo = getBitcoindService().getBlockchainInfo();
|
||||
if(blockchainInfo.pruned()) {
|
||||
String pruneBlockHash = getBitcoindService().getBlockHash(blockchainInfo.pruneheight());
|
||||
VerboseBlockHeader pruneBlockHeader = getBitcoindService().getBlockHeader(pruneBlockHash);
|
||||
return Optional.of(new Date(pruneBlockHeader.time() * 1000));
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private ScanDate getScanDate(Wallet wallet, KeyPurpose keyPurpose) {
|
||||
Integer range = (keyPurpose == null ? null : wallet.getFreshNode(keyPurpose).getIndex() + GAP_LIMIT);
|
||||
|
||||
boolean forceRescan = false;
|
||||
Date txBirthDate = wallet.getTransactions().values().stream().map(BlockTransactionHash::getDate).filter(Objects::nonNull).min(Date::compareTo).orElse(null);
|
||||
if((wallet.getBirthDate() != null && txBirthDate != null && wallet.getBirthDate().before(txBirthDate)) || (txBirthDate == null && wallet.getStoredBlockHeight() != null && wallet.getStoredBlockHeight() == 0)) {
|
||||
forceRescan = true;
|
||||
}
|
||||
|
||||
return new ScanDate(wallet.getBirthDate(), range, forceRescan);
|
||||
}
|
||||
|
||||
private void importDescriptors(Map<String, ScanDate> descriptors) {
|
||||
for(String descriptor : descriptors.keySet()) {
|
||||
Lock lock = descriptorLocks.computeIfAbsent(descriptor, desc -> new ReentrantLock());
|
||||
lock.lock();
|
||||
}
|
||||
|
||||
try {
|
||||
Set<String> addedDescriptors = addDescriptors(descriptors);
|
||||
if(!addedDescriptors.isEmpty()) {
|
||||
ListSinceBlock listSinceBlock = getListSinceBlock(null);
|
||||
updateStore(listSinceBlock, addedDescriptors);
|
||||
}
|
||||
} finally {
|
||||
for(String descriptor : descriptors.keySet()) {
|
||||
Lock lock = descriptorLocks.get(descriptor);
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> addDescriptors(Map<String, ScanDate> descriptors) {
|
||||
boolean forceRescan = descriptors.values().stream().anyMatch(scanDate -> scanDate.forceRescan);
|
||||
if(!initialized || forceRescan) {
|
||||
ListDescriptorsResult listDescriptorsResult = getBitcoindService().listDescriptors(false);
|
||||
for(ListDescriptorResult result : listDescriptorsResult.descriptors()) {
|
||||
String descriptor = OutputDescriptor.normalize(result.desc());
|
||||
ScanDate previousScanDate = importedDescriptors.get(descriptor);
|
||||
Integer range = result.range() == null ? null : result.range().get(result.range().size() - 1);
|
||||
importedDescriptors.put(descriptor, new ScanDate(previousScanDate == null ? null : previousScanDate.rescanSince, range, false));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, ScanDate> importingDescriptors = new LinkedHashMap<>(descriptors);
|
||||
importingDescriptors.keySet().removeAll(importedDescriptors.keySet());
|
||||
for(Map.Entry<String, ScanDate> entry : descriptors.entrySet()) {
|
||||
if(importingDescriptors.containsKey(entry.getKey())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ScanDate scanDate = entry.getValue();
|
||||
if(scanDate.forceRescan) {
|
||||
ScanDate importedScanDate = importedDescriptors.get(entry.getKey());
|
||||
if(scanDate.rescanSince != null && (importedScanDate == null || importedScanDate.rescanSince == null || scanDate.rescanSince.before(importedScanDate.rescanSince))) {
|
||||
importingDescriptors.put(entry.getKey(), new ScanDate(scanDate.rescanSince, importedScanDate != null ? importedScanDate.range : scanDate.range, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!importingDescriptors.isEmpty()) {
|
||||
log.warn("Importing descriptors " + importingDescriptors);
|
||||
|
||||
List<ImportDescriptor> importDescriptors = importingDescriptors.entrySet().stream()
|
||||
.map(entry -> {
|
||||
ScanDate scanDate = entry.getValue();
|
||||
if(entry.getKey().contains("/0/*")) {
|
||||
return new ImportRangedDescriptor(entry.getKey(), true, scanDate.range(), scanDate.getTimestamp(), false);
|
||||
} else if(entry.getKey().contains("/1/*")) {
|
||||
return new ImportRangedDescriptor(entry.getKey(), false, scanDate.range(), scanDate.getTimestamp(), true);
|
||||
}
|
||||
return new ImportDescriptor(entry.getKey(), false, entry.getValue().getTimestamp(), true);
|
||||
}).toList();
|
||||
|
||||
List<ImportDescriptorResult> results;
|
||||
scanningLock.lock();
|
||||
try {
|
||||
results = getBitcoindService().importDescriptors(importDescriptors);
|
||||
} finally {
|
||||
scanningLock.unlock();
|
||||
Platform.runLater(() -> EventManager.get().post(new CormorantScanStatusEvent("Scanning completed", 100, Duration.ZERO)));
|
||||
}
|
||||
|
||||
for(int i = 0; i < importDescriptors.size(); i++) {
|
||||
ImportDescriptor importDescriptor = importDescriptors.get(i);
|
||||
ImportDescriptorResult importDescriptorResult = results.get(i);
|
||||
if(importDescriptorResult.success()) {
|
||||
importedDescriptors.put(importDescriptor.getDesc(), importingDescriptors.get(importDescriptor.getDesc()));
|
||||
} else {
|
||||
log.error("Error importing descriptor " + importDescriptor.getDesc() + ": " + importDescriptorResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
return importingDescriptors.keySet();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if(initialized) {
|
||||
try {
|
||||
getBitcoindService().unloadWallet(CORE_WALLET_NAME, false);
|
||||
} catch(Exception e) {
|
||||
log.info("Error unloading Core wallet " + CORE_WALLET_NAME, e);
|
||||
}
|
||||
}
|
||||
|
||||
timer.cancel();
|
||||
stopped = true;
|
||||
}
|
||||
|
||||
private void updateStore(ListSinceBlock listSinceBlock, Set<String> descriptors) {
|
||||
listSinceBlock.removed().removeIf(lt -> lt.parent_descs() != null && lt.parent_descs().stream().map(OutputDescriptor::normalize).noneMatch(descriptors::contains));
|
||||
listSinceBlock.transactions().removeIf(lt -> lt.parent_descs() != null && lt.parent_descs().stream().map(OutputDescriptor::normalize).noneMatch(descriptors::contains));
|
||||
updateStore(listSinceBlock);
|
||||
}
|
||||
|
||||
private synchronized void updateStore(ListSinceBlock listSinceBlock) {
|
||||
Set<String> updatedScriptHashes = new HashSet<>();
|
||||
|
||||
for(ListTransaction removedTransaction : listSinceBlock.removed()) {
|
||||
if(removedTransaction.confirmations() < 0) {
|
||||
updatedScriptHashes.addAll(store.purgeTransaction(removedTransaction.txid()));
|
||||
}
|
||||
}
|
||||
|
||||
List<ListTransaction> sentTransactions = new ArrayList<>();
|
||||
Map<String, Boolean> conflictCache = new HashMap<>();
|
||||
|
||||
for(ListTransaction listTransaction : listSinceBlock.transactions()) {
|
||||
if(isConflicted(listTransaction, conflictCache)) {
|
||||
updatedScriptHashes.addAll(store.purgeTransaction(listTransaction.txid()));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if(listTransaction.category() == Category.receive) {
|
||||
//Transactions received to an address can be added directly
|
||||
Address address = Address.fromString(listTransaction.address());
|
||||
String updatedScriptHash = store.addAddressTransaction(address, listTransaction);
|
||||
if(updatedScriptHash != null) {
|
||||
updatedScriptHashes.add(updatedScriptHash);
|
||||
}
|
||||
} else if(listTransaction.category() == Category.send) {
|
||||
//Need to determine the address the transaction was sent from
|
||||
//Cache until all receive txes are processed
|
||||
sentTransactions.add(listTransaction);
|
||||
}
|
||||
} catch(InvalidAddressException e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
for(ListTransaction sentTransaction : sentTransactions) {
|
||||
Set<HashIndex> spentOutputs = store.getSpentOutputs().computeIfAbsent(sentTransaction.txid(), txid -> {
|
||||
String txhex = getBitcoindService().getRawTransaction(txid, false).toString();
|
||||
Transaction tx = new Transaction(Utils.hexToBytes(txhex));
|
||||
return tx.getInputs().stream().map(txInput -> new HashIndex(txInput.getOutpoint().getHash(), txInput.getOutpoint().getIndex())).collect(Collectors.toSet());
|
||||
});
|
||||
|
||||
boolean foundFundingAddress = false;
|
||||
for(HashIndex spentOutput : spentOutputs) {
|
||||
Address fundingAddress = store.getFundingAddress(spentOutput);
|
||||
if(fundingAddress != null) {
|
||||
String updatedScriptHash = store.addAddressTransaction(fundingAddress, sentTransaction);
|
||||
if(updatedScriptHash != null) {
|
||||
updatedScriptHashes.add(updatedScriptHash);
|
||||
}
|
||||
foundFundingAddress = true;
|
||||
}
|
||||
}
|
||||
|
||||
if(!foundFundingAddress) {
|
||||
log.error("Could not find a funding address for wallet spend tx " + sentTransaction.txid());
|
||||
}
|
||||
}
|
||||
|
||||
syncMempool(!listSinceBlock.lastblock().equals(lastBlock));
|
||||
updatedScriptHashes.addAll(store.updateMempoolTransactions());
|
||||
|
||||
lastBlock = listSinceBlock.lastblock();
|
||||
|
||||
for(String updatedScriptHash : updatedScriptHashes) {
|
||||
Cormorant.getEventBus().post(new ScriptHashStatus(updatedScriptHash, store.getStatus(updatedScriptHash)));
|
||||
}
|
||||
}
|
||||
|
||||
private void syncMempool(boolean forceRefresh) {
|
||||
Map<String, MempoolEntry> mempoolEntries = store.getMempoolEntries();
|
||||
|
||||
for(String txid : new HashSet<>(mempoolEntries.keySet())) {
|
||||
if(forceRefresh || mempoolEntries.get(txid) == null) {
|
||||
MempoolEntry mempoolEntry = getBitcoindService().getMempoolEntry(txid);
|
||||
if(mempoolEntry != null) {
|
||||
mempoolEntries.put(txid, mempoolEntry);
|
||||
} else {
|
||||
mempoolEntries.remove(txid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isConflicted(ListTransaction listTransaction, Map<String, Boolean> conflictCache) {
|
||||
if(listTransaction.confirmations() == 0 && !listTransaction.walletconflicts().isEmpty()) {
|
||||
Boolean active = conflictCache.computeIfAbsent(listTransaction.txid(), txid -> getBitcoindService().getMempoolEntry(txid) != null);
|
||||
|
||||
if(active) {
|
||||
for(String conflictedTxid : listTransaction.walletconflicts()) {
|
||||
conflictCache.put(conflictedTxid, false);
|
||||
}
|
||||
}
|
||||
|
||||
return !active;
|
||||
} else {
|
||||
return listTransaction.confirmations() < 0;
|
||||
}
|
||||
}
|
||||
|
||||
public Store getStore() {
|
||||
return store;
|
||||
}
|
||||
|
||||
public BitcoindClientService getBitcoindService() {
|
||||
return jsonRpcClient.onDemand(BitcoindClientService.class);
|
||||
}
|
||||
|
||||
public NetworkInfo getNetworkInfo() {
|
||||
return networkInfo;
|
||||
}
|
||||
|
||||
public ElectrumBlockHeader getTip() {
|
||||
return tip;
|
||||
}
|
||||
|
||||
private class PollTask extends TimerTask {
|
||||
@Override
|
||||
public void run() {
|
||||
if(stopped) {
|
||||
timer.cancel();
|
||||
}
|
||||
|
||||
try {
|
||||
if(syncing) {
|
||||
BlockchainInfo blockchainInfo = getBitcoindService().getBlockchainInfo();
|
||||
if(blockchainInfo.initialblockdownload()) {
|
||||
int percent = blockchainInfo.getProgressPercent();
|
||||
Date tipDate = blockchainInfo.getTip();
|
||||
Platform.runLater(() -> EventManager.get().post(new CormorantSyncStatusEvent("Syncing" + (percent < 100 ? " (" + percent + "%)" : ""), percent, tipDate)));
|
||||
return;
|
||||
} else {
|
||||
syncing = false;
|
||||
syncingLock.lock();
|
||||
try {
|
||||
syncingCondition.signal();
|
||||
} finally {
|
||||
syncingLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(lastBlock != null && tip != null) {
|
||||
String blockhash = getBitcoindService().getBlockHash(tip.height());
|
||||
if(!lastBlock.equals(blockhash)) {
|
||||
log.warn("Reorg detected, block height " + tip.height() + " was " + lastBlock + " and now is " + blockhash);
|
||||
lastBlock = null;
|
||||
}
|
||||
}
|
||||
|
||||
ListSinceBlock listSinceBlock = getListSinceBlock(lastBlock);
|
||||
String currentBlock = lastBlock;
|
||||
updateStore(listSinceBlock);
|
||||
|
||||
if(currentBlock == null || !currentBlock.equals(listSinceBlock.lastblock())) {
|
||||
VerboseBlockHeader blockHeader = getBitcoindService().getBlockHeader(listSinceBlock.lastblock());
|
||||
tip = blockHeader.getBlockHeader();
|
||||
Cormorant.getEventBus().post(tip);
|
||||
}
|
||||
|
||||
if(scanningLock.tryLock()) {
|
||||
scanningLock.unlock();
|
||||
} else {
|
||||
WalletInfo walletInfo = getBitcoindService().getWalletInfo();
|
||||
if(walletInfo.scanning().isScanning()) {
|
||||
int percent = walletInfo.scanning().getPercent();
|
||||
Duration remainingDuration = walletInfo.scanning().getRemaining();
|
||||
Platform.runLater(() -> EventManager.get().post(new CormorantScanStatusEvent("Scanning" + (percent < 100 ? " (" + percent + "%)" : ""), percent, remainingDuration)));
|
||||
}
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.warn("Error polling Bitcoin Core: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record ScanDate(Date rescanSince, Integer range, boolean forceRescan) {
|
||||
public Object getTimestamp() {
|
||||
return rescanSince == null ? "now" : rescanSince.getTime() / 1000;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@JsonRpcService
|
||||
public interface BitcoindClientService {
|
||||
@JsonRpcMethod("uptime")
|
||||
long uptime();
|
||||
|
||||
@JsonRpcMethod("getnetworkinfo")
|
||||
NetworkInfo getNetworkInfo();
|
||||
|
||||
@JsonRpcMethod("estimatesmartfee")
|
||||
FeeInfo estimateSmartFee(@JsonRpcParam("conf_target") int blocks);
|
||||
|
||||
@JsonRpcMethod("getrawmempool")
|
||||
Map<String, MempoolEntry> getRawMempool(@JsonRpcParam("verbose") boolean verbose);
|
||||
|
||||
@JsonRpcMethod("getmempoolinfo")
|
||||
MempoolInfo getMempoolInfo();
|
||||
|
||||
@JsonRpcMethod("getblockchaininfo")
|
||||
BlockchainInfo getBlockchainInfo();
|
||||
|
||||
@JsonRpcMethod("getwalletinfo")
|
||||
WalletInfo getWalletInfo();
|
||||
|
||||
@JsonRpcMethod("getblockhash")
|
||||
String getBlockHash(@JsonRpcParam("height") int height);
|
||||
|
||||
@JsonRpcMethod("getblockheader")
|
||||
String getBlockHeader(@JsonRpcParam("blockhash") String blockhash, @JsonRpcParam("verbose") boolean verbose);
|
||||
|
||||
@JsonRpcMethod("getblockheader")
|
||||
VerboseBlockHeader getBlockHeader(@JsonRpcParam("blockhash") String blockhash);
|
||||
|
||||
@JsonRpcMethod("getrawtransaction")
|
||||
Object getRawTransaction(@JsonRpcParam("txid") String txid, @JsonRpcParam("verbose") boolean verbose);
|
||||
|
||||
@JsonRpcMethod("getmempoolentry")
|
||||
MempoolEntry getMempoolEntry(@JsonRpcParam("txid") String txid);
|
||||
|
||||
@JsonRpcMethod("listsinceblock")
|
||||
ListSinceBlock listSinceBlock(@JsonRpcParam("blockhash") @JsonRpcOptional String blockhash, @JsonRpcParam("target_confirmations") int targetConfirmations,
|
||||
@JsonRpcParam("include_watchonly") boolean includeWatchOnly, @JsonRpcParam("include_removed") boolean includeRemoved,
|
||||
@JsonRpcParam("include_change") boolean includeChange);
|
||||
|
||||
@JsonRpcMethod("listwalletdir")
|
||||
ListWalletDirResult listWalletDir();
|
||||
|
||||
@JsonRpcMethod("createwallet")
|
||||
CreateLoadWalletResult createWallet(@JsonRpcParam("wallet_name") String name, @JsonRpcParam("disable_private_keys") boolean disablePrivateKeys, @JsonRpcParam("blank") boolean blank,
|
||||
@JsonRpcParam("passphrase") String passphrase, @JsonRpcParam("avoid_reuse") boolean avoidReuse, @JsonRpcParam("descriptors") boolean descriptors,
|
||||
@JsonRpcParam("load_on_startup") boolean loadOnStartup, @JsonRpcParam("external_signer") boolean externalSigner);
|
||||
|
||||
@JsonRpcMethod("loadwallet")
|
||||
CreateLoadWalletResult loadWallet(@JsonRpcParam("filename") String name, @JsonRpcParam("load_on_startup") boolean loadOnStartup);
|
||||
|
||||
@JsonRpcMethod("unloadwallet")
|
||||
CreateLoadWalletResult unloadWallet(@JsonRpcParam("wallet_name") String name, @JsonRpcParam("load_on_startup") boolean loadOnStartup);
|
||||
|
||||
@JsonRpcMethod("listdescriptors")
|
||||
ListDescriptorsResult listDescriptors(@JsonRpcParam("private") boolean listPrivate);
|
||||
|
||||
@JsonRpcMethod("importdescriptors")
|
||||
List<ImportDescriptorResult> importDescriptors(@JsonRpcParam("requests") List<ImportDescriptor> importDescriptors);
|
||||
|
||||
@JsonRpcMethod("sendrawtransaction")
|
||||
String sendRawTransaction(@JsonRpcParam("hexstring") String rawTx, @JsonRpcParam("maxfeerate") Double maxFeeRate);
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.client.Transport;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.io.Server;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.net.ssl.*;
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.Proxy;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Base64;
|
||||
|
||||
public class BitcoindTransport implements Transport {
|
||||
private static final Logger log = LoggerFactory.getLogger(BitcoindTransport.class);
|
||||
public static final String COOKIE_FILENAME = ".cookie";
|
||||
|
||||
private URL bitcoindUrl;
|
||||
private File cookieFile;
|
||||
private Long cookieFileTimestamp;
|
||||
private String bitcoindAuthEncoded;
|
||||
|
||||
public BitcoindTransport(Server bitcoindServer, String bitcoindWallet, String bitcoindAuth) {
|
||||
this(bitcoindServer, bitcoindWallet);
|
||||
this.bitcoindAuthEncoded = Base64.getEncoder().encodeToString(bitcoindAuth.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public BitcoindTransport(Server bitcoindServer, String bitcoindWallet, File bitcoindDir) {
|
||||
this(bitcoindServer, bitcoindWallet);
|
||||
this.cookieFile = new File(bitcoindDir, COOKIE_FILENAME);
|
||||
}
|
||||
|
||||
private BitcoindTransport(Server bitcoindServer, String bitcoindWallet) {
|
||||
try {
|
||||
this.bitcoindUrl = new URL(bitcoindServer.getUrl() + "/wallet/" + bitcoindWallet);
|
||||
} catch(MalformedURLException e) {
|
||||
log.error("Malformed Bitcoin Core RPC URL", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String pass(String request) throws IOException {
|
||||
Proxy proxy = AppServices.getProxy();
|
||||
HttpURLConnection connection = proxy == null ? (HttpURLConnection)bitcoindUrl.openConnection() : (HttpURLConnection)bitcoindUrl.openConnection(proxy);
|
||||
|
||||
if(connection instanceof HttpsURLConnection httpsURLConnection) {
|
||||
SSLSocketFactory sslSocketFactory = getTrustAllSocketFactory();
|
||||
if(sslSocketFactory != null) {
|
||||
httpsURLConnection.setSSLSocketFactory(sslSocketFactory);
|
||||
}
|
||||
}
|
||||
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
|
||||
String auth = getBitcoindAuthEncoded();
|
||||
if(auth != null) {
|
||||
connection.setRequestProperty("Authorization", "Basic " + auth);
|
||||
}
|
||||
|
||||
connection.setDoOutput(true);
|
||||
|
||||
log.debug("> " + request);
|
||||
|
||||
try(OutputStream os = connection.getOutputStream()) {
|
||||
byte[] jsonBytes = request.getBytes(StandardCharsets.UTF_8);
|
||||
os.write(jsonBytes);
|
||||
}
|
||||
|
||||
int statusCode = connection.getResponseCode();
|
||||
if(statusCode == 401) {
|
||||
throw new IOException((cookieFile == null ? "User/pass" : "Cookie file") + " authentication failed");
|
||||
}
|
||||
InputStream inputStream = connection.getErrorStream() == null ? connection.getInputStream() : connection.getErrorStream();
|
||||
|
||||
StringBuilder res = new StringBuilder();
|
||||
try(BufferedReader br = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
||||
String responseLine;
|
||||
while((responseLine = br.readLine()) != null) {
|
||||
if(statusCode == 500) {
|
||||
responseLine = responseLine.replace("\"result\":null,", "");
|
||||
}
|
||||
|
||||
res.append(responseLine.trim());
|
||||
}
|
||||
}
|
||||
|
||||
String response = res.toString();
|
||||
log.debug("< " + response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private String getBitcoindAuthEncoded() throws IOException {
|
||||
if(cookieFile != null) {
|
||||
if(!cookieFile.exists()) {
|
||||
throw new IOException("Cannot find Bitcoin Core cookie file at " + cookieFile.getAbsolutePath());
|
||||
}
|
||||
|
||||
if(cookieFileTimestamp == null || cookieFile.lastModified() != cookieFileTimestamp) {
|
||||
try {
|
||||
String userPass = Files.readAllLines(cookieFile.toPath()).get(0);
|
||||
bitcoindAuthEncoded = Base64.getEncoder().encodeToString(userPass.getBytes(StandardCharsets.UTF_8));
|
||||
cookieFileTimestamp = cookieFile.lastModified();
|
||||
} catch(Exception e) {
|
||||
log.warn("Cannot read Bitcoin Core .cookie file", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bitcoindAuthEncoded;
|
||||
}
|
||||
|
||||
private SSLSocketFactory getTrustAllSocketFactory() {
|
||||
TrustManager[] trustAllCerts = new TrustManager[] {
|
||||
new X509TrustManager() {
|
||||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return new X509Certificate[0];
|
||||
}
|
||||
|
||||
public void checkClientTrusted(X509Certificate[] certs, String authType) throws CertificateException {
|
||||
}
|
||||
|
||||
public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAllCerts, null);
|
||||
return sslContext.getSocketFactory();
|
||||
} catch (Exception e) {
|
||||
log.error("Error creating SSL socket factory", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record BlockchainInfo(int blocks, int headers, String bestblockhash, boolean initialblockdownload, long time, Double verificationprogress, boolean pruned, Integer pruneheight) {
|
||||
public Date getTip() {
|
||||
return new Date(time * 1000);
|
||||
}
|
||||
|
||||
public int getProgressPercent() {
|
||||
return (int) Math.round(verificationprogress * 100);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public enum Category {
|
||||
send, receive, generate, immature, orphan;
|
||||
|
||||
public String toString() {
|
||||
return super.toString().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
public class CormorantBitcoindException extends Exception {
|
||||
public CormorantBitcoindException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record CreateLoadWalletResult(String name, String warning) {
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record FeeInfo(Double feerate, List<String> errors, int blocks) {
|
||||
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record FeesMempoolEntry(double base, double ancestor) {
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
public class ImportDescriptor {
|
||||
private final String desc;
|
||||
private final Boolean active;
|
||||
private final Object timestamp;
|
||||
private final Boolean internal;
|
||||
|
||||
public ImportDescriptor(String desc, Boolean active, Object timestamp, Boolean internal) {
|
||||
this.desc = desc;
|
||||
this.active = active;
|
||||
this.timestamp = timestamp;
|
||||
this.internal = internal;
|
||||
}
|
||||
|
||||
public String getDesc() {
|
||||
return desc;
|
||||
}
|
||||
|
||||
public Boolean getActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public Object getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public Boolean getInternal() {
|
||||
return internal;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ImportDescriptorResult(boolean success, List<String> warnings, ErrorMessage error) {
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
public class ImportFailedException extends Exception {
|
||||
public ImportFailedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
public class ImportRangedDescriptor extends ImportDescriptor {
|
||||
private final Integer range;
|
||||
|
||||
public ImportRangedDescriptor(String desc, Boolean active, Integer range, Object timestamp, Boolean internal) {
|
||||
super(desc, active, timestamp, internal);
|
||||
this.range = range;
|
||||
}
|
||||
|
||||
public Integer getRange() {
|
||||
return range;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ListDescriptorResult(String desc, long timestamp, boolean active, boolean internal, List<Integer> range) {
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ListDescriptorsResult(String wallet_name, List<ListDescriptorResult> descriptors) {
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ListSinceBlock(List<ListTransaction> transactions, List<ListTransaction> removed, String lastblock) {
|
||||
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ListTransaction(String address, List<String> parent_descs, Category category, double amount, int vout, double fee, int confirmations, String blockhash, int blockindex, long blocktime, int blockheight, String txid, long time, long timereceived, List<String> walletconflicts) {
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ListWalletDirResult(List<WalletDirResult> wallets) {
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.index.TxEntry;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record MempoolEntry(int vsize, int ancestorsize, boolean bip125_replaceable, FeesMempoolEntry fees) {
|
||||
public boolean hasUnconfirmedParents() {
|
||||
return vsize != ancestorsize;
|
||||
}
|
||||
|
||||
public TxEntry getTxEntry(String txid) {
|
||||
return new TxEntry(hasUnconfirmedParents() ? -1 : 0, 0, txid);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record MempoolInfo(double minrelaytxfee) {
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record NetworkInfo(int version, String subversion) {
|
||||
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.electrum.ElectrumBlockHeader;
|
||||
import com.sparrowwallet.drongo.protocol.BlockHeader;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record VerboseBlockHeader(String hash, int confirmations, int height, int version, String versionHex, String merkleroot, long time, long mediantime, long nonce,
|
||||
String bits, double difficulty, String chainwork, int nTx, String previousblockhash) {
|
||||
public ElectrumBlockHeader getBlockHeader() {
|
||||
BigInteger nBits = new BigInteger(bits, 16);
|
||||
BlockHeader blockHeader = new BlockHeader(version, Sha256Hash.wrap(previousblockhash), Sha256Hash.wrap(merkleroot), null, time, nBits.longValue(), nonce);
|
||||
return new ElectrumBlockHeader(height, Utils.bytesToHex(blockHeader.bitcoinSerialize()));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record WalletDirResult(String name) {
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record WalletInfo(String walletname, WalletScanningInfo scanning) {
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class WalletScanningInfo {
|
||||
private final boolean scanning;
|
||||
private final int duration;
|
||||
private final double progress;
|
||||
|
||||
public WalletScanningInfo(boolean scanning) {
|
||||
this.scanning = scanning;
|
||||
this.duration = 0;
|
||||
this.progress = 0;
|
||||
}
|
||||
|
||||
public WalletScanningInfo(Integer duration,Double progress) {
|
||||
this.scanning = true;
|
||||
this.duration = duration;
|
||||
this.progress = progress;
|
||||
}
|
||||
|
||||
public boolean isScanning() {
|
||||
return scanning;
|
||||
}
|
||||
|
||||
public int getDuration() {
|
||||
return duration;
|
||||
}
|
||||
|
||||
public double getProgress() {
|
||||
return progress;
|
||||
}
|
||||
|
||||
public int getPercent() {
|
||||
return (int) (progress * 100.0);
|
||||
}
|
||||
|
||||
public Duration getRemaining() {
|
||||
long total = Math.round(duration / progress);
|
||||
return Duration.ofSeconds(total - duration);
|
||||
}
|
||||
|
||||
@JsonCreator
|
||||
private static WalletScanningInfo fromJson(boolean scanning) {
|
||||
return new WalletScanningInfo(scanning);
|
||||
}
|
||||
|
||||
@JsonCreator
|
||||
public static WalletScanningInfo fromJson(@JsonProperty("duration") Integer duration, @JsonProperty("progress") Double progress) {
|
||||
return new WalletScanningInfo(duration, progress);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcError;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcErrorData;
|
||||
import com.google.common.base.Throwables;
|
||||
|
||||
@JsonRpcError(code=-32000, message="Could not connect to Bitcoin Core RPC")
|
||||
public class BitcoindIOException extends Exception {
|
||||
@JsonRpcErrorData
|
||||
private final String rootCause;
|
||||
|
||||
public BitcoindIOException(Throwable rootCause) {
|
||||
super("Could not connect to Bitcoin Core RPC");
|
||||
this.rootCause = Throwables.getRootCause(rootCause).getMessage();
|
||||
}
|
||||
|
||||
public String getRootCause() {
|
||||
return rootCause;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcError;
|
||||
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
|
||||
|
||||
@JsonRpcError(code=-32002)
|
||||
public class BlockNotFoundException extends Exception {
|
||||
private final String message;
|
||||
|
||||
public BlockNotFoundException(ErrorMessage errorMessage) {
|
||||
this.message = errorMessage == null ? "" : errorMessage.getMessage() + (errorMessage.getData() == null ? "" : " (" + errorMessage.getData() + ")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcError;
|
||||
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
|
||||
|
||||
@JsonRpcError(code=-32003)
|
||||
public class BroadcastFailedException extends Exception {
|
||||
private final String message;
|
||||
|
||||
public BroadcastFailedException(ErrorMessage errorMessage) {
|
||||
this.message = errorMessage == null ? "" : errorMessage.getMessage() + (errorMessage.getData() == null ? "" : " (" + errorMessage.getData() + ")");
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
public record ElectrumBlockHeader(int height, String hex) {
|
||||
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
|
||||
|
||||
@JsonRpcService
|
||||
public interface ElectrumNotificationService {
|
||||
@JsonRpcMethod("blockchain.headers.subscribe")
|
||||
void notifyHeaders(@JsonRpcParam("header") ElectrumBlockHeader electrumBlockHeader);
|
||||
|
||||
@JsonRpcMethod("blockchain.scripthash.subscribe")
|
||||
void notifyScriptHash(@JsonRpcParam("scripthash") String scriptHash, @JsonRpcParam("status") String status);
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.client.Transport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class ElectrumNotificationTransport implements Transport {
|
||||
private final Socket clientSocket;
|
||||
|
||||
public ElectrumNotificationTransport(Socket clientSocket) {
|
||||
this.clientSocket = clientSocket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String pass(String request) throws IOException {
|
||||
PrintWriter out = new PrintWriter(new OutputStreamWriter(clientSocket.getOutputStream(), StandardCharsets.UTF_8));
|
||||
out.println(request);
|
||||
out.flush();
|
||||
|
||||
return "{\"result\":{},\"error\":null,\"id\":1}";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.BitcoindClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class ElectrumServerRunnable implements Runnable {
|
||||
private static final Logger log = LoggerFactory.getLogger(ElectrumServerRunnable.class);
|
||||
|
||||
private final BitcoindClient bitcoindClient;
|
||||
|
||||
protected ServerSocket serverSocket = null;
|
||||
protected boolean stopped = false;
|
||||
protected Thread runningThread = null;
|
||||
protected ExecutorService threadPool = Executors.newFixedThreadPool(10, r -> {
|
||||
Thread t = Executors.defaultThreadFactory().newThread(r);
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
public ElectrumServerRunnable(BitcoindClient bitcoindClient) {
|
||||
this.bitcoindClient = bitcoindClient;
|
||||
openServerSocket();
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return serverSocket.getLocalPort();
|
||||
}
|
||||
|
||||
public void run() {
|
||||
synchronized(this) {
|
||||
this.runningThread = Thread.currentThread();
|
||||
}
|
||||
while(!isStopped()) {
|
||||
Socket clientSocket;
|
||||
try {
|
||||
clientSocket = this.serverSocket.accept();
|
||||
} catch(IOException e) {
|
||||
if(isStopped()) {
|
||||
break;
|
||||
}
|
||||
throw new RuntimeException("Error accepting client connection", e);
|
||||
}
|
||||
RequestHandler requestHandler = new RequestHandler(clientSocket, bitcoindClient);
|
||||
this.threadPool.execute(requestHandler);
|
||||
}
|
||||
|
||||
this.threadPool.shutdown();
|
||||
}
|
||||
|
||||
private synchronized boolean isStopped() {
|
||||
return stopped;
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
stopped = true;
|
||||
try {
|
||||
serverSocket.close();
|
||||
} catch(IOException e) {
|
||||
throw new RuntimeException("Error closing server", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void openServerSocket() {
|
||||
try {
|
||||
serverSocket = new ServerSocket(0);
|
||||
} catch(IOException e) {
|
||||
throw new RuntimeException("Cannot open electrum server port", e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,184 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
|
||||
import com.sparrowwallet.sparrow.SparrowWallet;
|
||||
import com.sparrowwallet.sparrow.net.Version;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.*;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.index.TxEntry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@JsonRpcService
|
||||
public class ElectrumServerService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ElectrumServerService.class);
|
||||
private static final Version VERSION = new Version("1.4");
|
||||
private static final long VSIZE_BIN_WIDTH = 50000;
|
||||
private static final double DEFAULT_FEE_RATE = 0.00001d;
|
||||
|
||||
private final BitcoindClient bitcoindClient;
|
||||
private final RequestHandler requestHandler;
|
||||
|
||||
public ElectrumServerService(BitcoindClient bitcoindClient, RequestHandler requestHandler) {
|
||||
this.bitcoindClient = bitcoindClient;
|
||||
this.requestHandler = requestHandler;
|
||||
}
|
||||
|
||||
@JsonRpcMethod("server.version")
|
||||
public List<String> getServerVersion(@JsonRpcParam("client_name") String clientName, @JsonRpcParam("protocol_version") String protocolVersion) throws UnsupportedVersionException {
|
||||
Version clientVersion = new Version(protocolVersion);
|
||||
if(clientVersion.compareTo(VERSION) < 0) {
|
||||
throw new UnsupportedVersionException(protocolVersion);
|
||||
}
|
||||
|
||||
return List.of(Cormorant.SERVER_NAME + " " + SparrowWallet.APP_VERSION, VERSION.get());
|
||||
}
|
||||
|
||||
@JsonRpcMethod("server.banner")
|
||||
public String getServerBanner() {
|
||||
return SparrowWallet.APP_NAME + " " + SparrowWallet.APP_VERSION + "\n" + bitcoindClient.getNetworkInfo().subversion();
|
||||
}
|
||||
|
||||
@JsonRpcMethod("blockchain.estimatefee")
|
||||
public Double estimateFee(@JsonRpcParam("number") int blocks) throws BitcoindIOException {
|
||||
try {
|
||||
FeeInfo feeInfo = bitcoindClient.getBitcoindService().estimateSmartFee(blocks);
|
||||
if(feeInfo == null || feeInfo.feerate() == null) {
|
||||
return DEFAULT_FEE_RATE;
|
||||
}
|
||||
|
||||
return feeInfo.feerate();
|
||||
} catch(IllegalStateException e) {
|
||||
throw new BitcoindIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonRpcMethod("mempool.get_fee_histogram")
|
||||
public List<List<Number>> getFeeHistogram() throws BitcoindIOException {
|
||||
try {
|
||||
Map<String, MempoolEntry> mempoolEntries = bitcoindClient.getBitcoindService().getRawMempool(true);
|
||||
|
||||
List<VsizeFeerate> vsizeFeerates = mempoolEntries.values().stream().map(entry -> new VsizeFeerate(entry.vsize(), entry.fees().base())).sorted().toList();
|
||||
|
||||
List<List<Number>> histogram = new ArrayList<>();
|
||||
long binSize = 0;
|
||||
double lastFeerate = 0.0;
|
||||
|
||||
for(VsizeFeerate vsizeFeerate : vsizeFeerates) {
|
||||
if(binSize > VSIZE_BIN_WIDTH && Math.abs(lastFeerate - vsizeFeerate.feerate) > 0.0d) {
|
||||
// vsize of transactions paying >= last_feerate
|
||||
histogram.add(List.of(lastFeerate, binSize));
|
||||
binSize = 0;
|
||||
}
|
||||
binSize += vsizeFeerate.vsize;
|
||||
lastFeerate = vsizeFeerate.feerate;
|
||||
}
|
||||
|
||||
if(binSize > 0) {
|
||||
histogram.add(List.of(lastFeerate, binSize));
|
||||
}
|
||||
|
||||
return histogram;
|
||||
} catch(IllegalStateException e) {
|
||||
throw new BitcoindIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonRpcMethod("blockchain.relayfee")
|
||||
public Double getRelayFee() throws BitcoindIOException {
|
||||
try {
|
||||
MempoolInfo mempoolInfo = bitcoindClient.getBitcoindService().getMempoolInfo();
|
||||
return mempoolInfo.minrelaytxfee();
|
||||
} catch(IllegalStateException e) {
|
||||
throw new BitcoindIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonRpcMethod("blockchain.headers.subscribe")
|
||||
public ElectrumBlockHeader subscribeHeaders() {
|
||||
requestHandler.setHeadersSubscribed(true);
|
||||
return bitcoindClient.getTip();
|
||||
}
|
||||
|
||||
@JsonRpcMethod("server.ping")
|
||||
public void ping() throws BitcoindIOException {
|
||||
try {
|
||||
bitcoindClient.getBitcoindService().uptime();
|
||||
} catch(IllegalStateException e) {
|
||||
throw new BitcoindIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonRpcMethod("blockchain.scripthash.subscribe")
|
||||
public String subscribeScriptHash(@JsonRpcParam("scripthash") String scriptHash) {
|
||||
requestHandler.subscribeScriptHash(scriptHash);
|
||||
return bitcoindClient.getStore().getStatus(scriptHash);
|
||||
}
|
||||
|
||||
@JsonRpcMethod("blockchain.scripthash.get_history")
|
||||
public Collection<TxEntry> getHistory(@JsonRpcParam("scripthash") String scriptHash) {
|
||||
return bitcoindClient.getStore().getHistory(scriptHash);
|
||||
}
|
||||
|
||||
@JsonRpcMethod("blockchain.block.header")
|
||||
public String getBlockHeader(@JsonRpcParam("height") int height) throws BitcoindIOException, BlockNotFoundException {
|
||||
try {
|
||||
String blockHash = bitcoindClient.getStore().getBlockHash(height);
|
||||
if(blockHash == null) {
|
||||
blockHash = bitcoindClient.getBitcoindService().getBlockHash(height);
|
||||
}
|
||||
|
||||
return bitcoindClient.getBitcoindService().getBlockHeader(blockHash, false);
|
||||
} catch(JsonRpcException e) {
|
||||
throw new BlockNotFoundException(e.getErrorMessage());
|
||||
} catch(IllegalStateException e) {
|
||||
throw new BitcoindIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonRpcMethod("blockchain.transaction.get")
|
||||
public String getTransaction(@JsonRpcParam("tx_hash") String tx_hash, @JsonRpcParam("verbose") @JsonRpcOptional boolean verbose) throws BitcoindIOException, TransactionNotFoundException {
|
||||
try {
|
||||
return bitcoindClient.getBitcoindService().getRawTransaction(tx_hash, verbose).toString();
|
||||
} catch(JsonRpcException e) {
|
||||
throw new TransactionNotFoundException(e.getErrorMessage());
|
||||
} catch(IllegalStateException e) {
|
||||
throw new BitcoindIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonRpcMethod("blockchain.transaction.broadcast")
|
||||
public String broadcastTransaction(@JsonRpcParam("raw_tx") String rawTx) throws BitcoindIOException, BroadcastFailedException {
|
||||
try {
|
||||
return bitcoindClient.getBitcoindService().sendRawTransaction(rawTx, 0d);
|
||||
} catch(JsonRpcException e) {
|
||||
throw new BroadcastFailedException(e.getErrorMessage());
|
||||
} catch(IllegalStateException e) {
|
||||
throw new BitcoindIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static class VsizeFeerate implements Comparable<VsizeFeerate> {
|
||||
private final int vsize;
|
||||
private final double feerate;
|
||||
|
||||
public VsizeFeerate(int vsize, double fee) {
|
||||
this.vsize = vsize;
|
||||
this.feerate = fee / vsize * 100000000;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(VsizeFeerate o) {
|
||||
return Double.compare(o.feerate, feerate);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
|
||||
import com.github.arteam.simplejsonrpc.server.JsonRpcServer;
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.BitcoindClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public class RequestHandler implements Runnable {
|
||||
private static final Logger log = LoggerFactory.getLogger(RequestHandler.class);
|
||||
private final Socket clientSocket;
|
||||
private final ElectrumServerService electrumServerService;
|
||||
private final JsonRpcServer rpcServer = new JsonRpcServer();
|
||||
|
||||
private boolean headersSubscribed;
|
||||
private final Set<String> scriptHashesSubscribed = new HashSet<>();
|
||||
|
||||
public RequestHandler(Socket clientSocket, BitcoindClient bitcoindClient) {
|
||||
this.clientSocket = clientSocket;
|
||||
this.electrumServerService = new ElectrumServerService(bitcoindClient, this);
|
||||
}
|
||||
|
||||
public void run() {
|
||||
Cormorant.getEventBus().register(this);
|
||||
|
||||
try {
|
||||
InputStream input = clientSocket.getInputStream();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
|
||||
|
||||
OutputStream output = clientSocket.getOutputStream();
|
||||
PrintWriter out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)));
|
||||
|
||||
while(true) {
|
||||
String request = reader.readLine();
|
||||
if(request == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
String response = rpcServer.handle(request, electrumServerService);
|
||||
out.println(response);
|
||||
out.flush();
|
||||
}
|
||||
} catch(IOException e) {
|
||||
log.error("Could not communicate with client socket", e);
|
||||
}
|
||||
|
||||
Cormorant.getEventBus().unregister(this);
|
||||
}
|
||||
|
||||
public void setHeadersSubscribed(boolean headersSubscribed) {
|
||||
this.headersSubscribed = headersSubscribed;
|
||||
}
|
||||
|
||||
public void subscribeScriptHash(String scriptHash) {
|
||||
scriptHashesSubscribed.add(scriptHash);
|
||||
}
|
||||
|
||||
public boolean isScriptHashSubscribed(String scriptHash) {
|
||||
return scriptHashesSubscribed.contains(scriptHash);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void newBlock(ElectrumBlockHeader electrumBlockHeader) {
|
||||
if(headersSubscribed) {
|
||||
ElectrumNotificationTransport electrumNotificationTransport = new ElectrumNotificationTransport(clientSocket);
|
||||
JsonRpcClient jsonRpcClient = new JsonRpcClient(electrumNotificationTransport);
|
||||
jsonRpcClient.onDemand(ElectrumNotificationService.class).notifyHeaders(electrumBlockHeader);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void scriptHashStatus(ScriptHashStatus scriptHashStatus) {
|
||||
if(isScriptHashSubscribed(scriptHashStatus.scriptHash())) {
|
||||
ElectrumNotificationTransport electrumNotificationTransport = new ElectrumNotificationTransport(clientSocket);
|
||||
JsonRpcClient jsonRpcClient = new JsonRpcClient(electrumNotificationTransport);
|
||||
jsonRpcClient.onDemand(ElectrumNotificationService.class).notifyScriptHash(scriptHashStatus.scriptHash(), scriptHashStatus.status());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
public record ScriptHashStatus(String scriptHash, String status) {
|
||||
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcError;
|
||||
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
|
||||
|
||||
@JsonRpcError(code=-32001)
|
||||
public class TransactionNotFoundException extends Exception {
|
||||
private final String message;
|
||||
|
||||
public TransactionNotFoundException(ErrorMessage errorMessage) {
|
||||
this.message = errorMessage == null ? "" : errorMessage.getMessage() + (errorMessage.getData() == null ? "" : " (" + errorMessage.getData() + ")");
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.electrum;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcError;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcErrorData;
|
||||
|
||||
@JsonRpcError(code=-32003, message="Unsupported version")
|
||||
public class UnsupportedVersionException extends Exception {
|
||||
@JsonRpcErrorData
|
||||
private final String version;
|
||||
|
||||
public UnsupportedVersionException(String version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.index;
|
||||
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.Category;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.ListTransaction;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.MempoolEntry;
|
||||
import com.sparrowwallet.drongo.protocol.HashIndex;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
|
||||
public class Store {
|
||||
private final Map<String, Set<TxEntry>> scriptHashEntries = new HashMap<>();
|
||||
private final Map<HashIndex, Address> fundingAddresses = new HashMap<>();
|
||||
private final Map<String, Set<HashIndex>> spentOutputs = new HashMap<>();
|
||||
private final Map<Integer, String> blockHeightHashes = new HashMap<>();
|
||||
private final Map<String, MempoolEntry> mempoolEntries = new HashMap<>();
|
||||
|
||||
public String addAddressTransaction(Address address, ListTransaction listTransaction) {
|
||||
if(listTransaction.category() == Category.receive) {
|
||||
fundingAddresses.put(new HashIndex(Sha256Hash.wrap(listTransaction.txid()), listTransaction.vout()), address);
|
||||
}
|
||||
|
||||
blockHeightHashes.put(listTransaction.blockheight(), listTransaction.blockhash());
|
||||
|
||||
String scriptHash = getScriptHash(address);
|
||||
Set<TxEntry> entries = scriptHashEntries.computeIfAbsent(scriptHash, k -> new TreeSet<>());
|
||||
TxEntry txEntry;
|
||||
String txid = listTransaction.txid();
|
||||
|
||||
if(listTransaction.confirmations() == 0) {
|
||||
if(!mempoolEntries.containsKey(txid)) {
|
||||
mempoolEntries.put(txid, null);
|
||||
}
|
||||
entries.removeIf(txe -> txe.height > 0 && txe.tx_hash.equals(listTransaction.txid()));
|
||||
txEntry = new TxEntry(0, 0, listTransaction.txid());
|
||||
} else {
|
||||
mempoolEntries.remove(txid);
|
||||
entries.removeIf(txe -> txe.height != listTransaction.blockheight() && txe.tx_hash.equals(listTransaction.txid()));
|
||||
txEntry = new TxEntry(listTransaction.blockheight(), listTransaction.blockindex(), listTransaction.txid());
|
||||
}
|
||||
|
||||
if(entries.add(txEntry)) {
|
||||
return scriptHash;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public Set<String> updateMempoolTransactions() {
|
||||
Set<String> updatedScriptHashes = new HashSet<>();
|
||||
|
||||
for(Map.Entry<String, Set<TxEntry>> scriptHashEntry : scriptHashEntries.entrySet()) {
|
||||
Set<TxEntry> txEntries = scriptHashEntry.getValue();
|
||||
Set<TxEntry> oldEntries = new HashSet<>();
|
||||
Set<TxEntry> newEntries = new HashSet<>();
|
||||
for(TxEntry txEntry : txEntries) {
|
||||
if(txEntry.height <= 0) {
|
||||
MempoolEntry mempoolEntry = mempoolEntries.get(txEntry.tx_hash);
|
||||
TxEntry newEntry = (mempoolEntry == null ? null : mempoolEntry.getTxEntry(txEntry.tx_hash));
|
||||
if(!txEntry.equals(newEntry)) {
|
||||
oldEntries.add(txEntry);
|
||||
if(newEntry != null) {
|
||||
newEntries.add(newEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boolean removed = txEntries.removeAll(oldEntries);
|
||||
boolean added = txEntries.addAll(newEntries);
|
||||
|
||||
if(added || removed) {
|
||||
updatedScriptHashes.add(scriptHashEntry.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
return updatedScriptHashes;
|
||||
}
|
||||
|
||||
public Set<String> purgeTransaction(String txid) {
|
||||
Set<String> updatedScriptHashes = new HashSet<>();
|
||||
|
||||
for(Map.Entry<String, Set<TxEntry>> scriptHashEntry : scriptHashEntries.entrySet()) {
|
||||
Set<TxEntry> txEntries = scriptHashEntry.getValue();
|
||||
if(txEntries.removeIf(txEntry -> txEntry.tx_hash.equals(txid))) {
|
||||
updatedScriptHashes.add(scriptHashEntry.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
Sha256Hash txHash = Sha256Hash.wrap(txid);
|
||||
fundingAddresses.keySet().removeIf(hashIndex -> hashIndex.getHash().equals(txHash));
|
||||
spentOutputs.remove(txid);
|
||||
mempoolEntries.remove(txid);
|
||||
|
||||
return updatedScriptHashes;
|
||||
}
|
||||
|
||||
public String getStatus(String scriptHash) {
|
||||
Set<TxEntry> entries = scriptHashEntries.get(scriptHash);
|
||||
if(entries == null || entries.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder scriptHashStatus = new StringBuilder();
|
||||
for(TxEntry entry : entries) {
|
||||
scriptHashStatus.append(entry.tx_hash).append(":").append(entry.height).append(":");
|
||||
}
|
||||
|
||||
return Utils.bytesToHex(Sha256Hash.hash(scriptHashStatus.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
public Address getFundingAddress(HashIndex spentOutput) {
|
||||
return fundingAddresses.get(spentOutput);
|
||||
}
|
||||
|
||||
public Map<String, Set<HashIndex>> getSpentOutputs() {
|
||||
return spentOutputs;
|
||||
}
|
||||
|
||||
public Map<String, MempoolEntry> getMempoolEntries() {
|
||||
return mempoolEntries;
|
||||
}
|
||||
|
||||
public Set<TxEntry> getHistory(String scriptHash) {
|
||||
Set<TxEntry> entries = scriptHashEntries.get(scriptHash);
|
||||
if(entries == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
public String getBlockHash(int height) {
|
||||
return blockHeightHashes.get(height);
|
||||
}
|
||||
|
||||
public static String getScriptHash(Address address) {
|
||||
byte[] hash = Sha256Hash.hash(address.getOutputScript().getProgram());
|
||||
byte[] reversed = Utils.reverseBytes(hash);
|
||||
return Utils.bytesToHex(reversed);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
package com.sparrowwallet.sparrow.net.cormorant.index;
|
||||
|
||||
public class TxEntry implements Comparable<TxEntry> {
|
||||
public final int height;
|
||||
private final transient int index;
|
||||
public final String tx_hash;
|
||||
|
||||
public TxEntry(int height, int index, String tx_hash) {
|
||||
this.height = height;
|
||||
this.index = index;
|
||||
this.tx_hash = tx_hash;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if(this == o) {
|
||||
return true;
|
||||
}
|
||||
if(o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
TxEntry txEntry = (TxEntry) o;
|
||||
|
||||
if(height != txEntry.height) {
|
||||
return false;
|
||||
}
|
||||
return tx_hash.equals(txEntry.tx_hash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = height;
|
||||
result = 31 * result + tx_hash.hashCode();
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(TxEntry o) {
|
||||
if(height <= 0 && o.height > 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if(height > 0 && o.height <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if(height != o.height) {
|
||||
return height - o.height;
|
||||
}
|
||||
|
||||
if(height == 0) {
|
||||
return tx_hash.compareTo(o.tx_hash);
|
||||
}
|
||||
|
||||
return index - o.index;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TxEntry{" +
|
||||
"height=" + height +
|
||||
", index=" + index +
|
||||
", tx_hash='" + tx_hash + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
|
@ -891,6 +891,18 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
|||
return serverObservableList;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void cormorantSyncStatus(CormorantSyncStatusEvent event) {
|
||||
editConnection.setDisable(false);
|
||||
if(connectionService != null && connectionService.isRunning() && event.getProgress() < 100) {
|
||||
DateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm");
|
||||
testResults.appendText("\nThe connection to the Bitcoin Core node was successful, but it is still syncing and cannot be used yet.");
|
||||
testResults.appendText("\nCurrently " + event.getProgress() + "% completed to date " + dateFormat.format(event.getTip()));
|
||||
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE, null));
|
||||
connectionService.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void bwtStatus(BwtStatusEvent event) {
|
||||
if(!(event instanceof BwtSyncStatusEvent)) {
|
||||
|
|
|
@ -179,4 +179,19 @@ public class SparrowTextGui extends MultiWindowTextGUI {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void cormorantSyncStatusEvent(CormorantSyncStatusEvent event) {
|
||||
statusUpdated(new StatusEvent("Syncing... (" + event.getProgress() + "% complete, synced to " + event.getTipAsString() + ")"));
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void cormorantScanStatusEvent(CormorantScanStatusEvent event) {
|
||||
statusUpdated(new StatusEvent(event.isCompleted() ? "" : "Scanning... (" + event.getProgress() + "% complete, " + event.getRemainingAsString() + " remaining)"));
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void cormorantPruneStatus(CormorantPruneStatusEvent event) {
|
||||
statusUpdated(new StatusEvent("Error importing wallet, pruned date after wallet birthday"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -213,6 +213,16 @@ public class ServerTestDialog extends DialogWindow {
|
|||
testResults.setText("Could not connect:\n\n" + reason);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void cormorantSyncStatus(CormorantSyncStatusEvent event) {
|
||||
if(connectionService != null && connectionService.isRunning() && event.getProgress() < 100) {
|
||||
DateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm");
|
||||
appendText("\nThe connection to the Bitcoin Core node was successful, but it is still syncing and cannot be used yet.");
|
||||
appendText("\nCurrently " + event.getProgress() + "% completed to date " + dateFormat.format(event.getTip()));
|
||||
connectionService.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void bwtStatus(BwtStatusEvent event) {
|
||||
if(!(event instanceof BwtSyncStatusEvent)) {
|
||||
|
|
|
@ -242,6 +242,11 @@ public class TransactionsController extends WalletFormController implements Init
|
|||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void cormorantStatus(CormorantStatusEvent event) {
|
||||
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus()));
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void bwtSyncStatus(BwtSyncStatusEvent event) {
|
||||
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus()));
|
||||
|
|
|
@ -569,6 +569,11 @@ public class UtxosController extends WalletFormController implements Initializab
|
|||
getWalletForm().getWalletUtxosEntry().updateMixProgress();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void cormorantStatus(CormorantStatusEvent event) {
|
||||
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus()));
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void bwtSyncStatus(BwtSyncStatusEvent event) {
|
||||
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus()));
|
||||
|
|
Loading…
Reference in a new issue