diff --git a/drongo b/drongo index fa18ec9d..692f23e0 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit fa18ec9d458bb17221fb01b6be9f4eceb354a156 +Subproject commit 692f23e02656b43b58c33b44467f920ddc3a3f65 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 5739a4e9..4155c96d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -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 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); diff --git a/src/main/java/com/sparrowwallet/sparrow/event/CormorantPruneStatusEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/CormorantPruneStatusEvent.java new file mode 100644 index 00000000..d5f16837 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/CormorantPruneStatusEvent.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/CormorantScanStatusEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/CormorantScanStatusEvent.java new file mode 100644 index 00000000..0ea0d0ea --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/CormorantScanStatusEvent.java @@ -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 ""; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/CormorantStatusEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/CormorantStatusEvent.java new file mode 100644 index 00000000..642cfda5 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/CormorantStatusEvent.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/CormorantSyncStatusEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/CormorantSyncStatusEvent.java new file mode 100644 index 00000000..721c78cf --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/CormorantSyncStatusEvent.java @@ -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); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Config.java b/src/main/java/com/sparrowwallet/sparrow/io/Config.java index d2254dbc..0a78b4d9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Config.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Config.java @@ -64,6 +64,7 @@ public class Config { private CoreAuthType coreAuthType; private File coreDataDir; private String coreAuth; + private boolean useLegacyCoreWallet; private Server electrumServer; private List 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; } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/Bwt.java b/src/main/java/com/sparrowwallet/sparrow/net/Bwt.java index 170c4323..159be72c 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/Bwt.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/Bwt.java @@ -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(); diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index c69cb195..4fcf1b70 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -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 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> createTask() { return new Task<>() { protected Set 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> createTask() { return new Task<>() { protected List 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); diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/Cormorant.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/Cormorant.java new file mode 100644 index 00000000..17456843 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/Cormorant.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindClient.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindClient.java new file mode 100644 index 00000000..27ce78bc --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindClient.java @@ -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 descriptorLocks = Collections.synchronizedMap(new HashMap<>()); + private final Map 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 wallets) throws ImportFailedException { + importDescriptors(getWalletDescriptors(wallets)); + } + + public void importWallet(Wallet wallet) throws ImportFailedException { + importDescriptors(getWalletDescriptors(Set.of(wallet))); + } + + private Map getWalletDescriptors(Set wallets) throws ImportFailedException { + List validWallets = wallets.stream().filter(Wallet::isValid).collect(Collectors.toList()); + + Map outputDescriptors = new LinkedHashMap<>(); + for(Wallet wallet : validWallets) { + if(pruned) { + Optional 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 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 descriptors) { + for(String descriptor : descriptors.keySet()) { + Lock lock = descriptorLocks.computeIfAbsent(descriptor, desc -> new ReentrantLock()); + lock.lock(); + } + + try { + Set 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 addDescriptors(Map 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 importingDescriptors = new LinkedHashMap<>(descriptors); + importingDescriptors.keySet().removeAll(importedDescriptors.keySet()); + for(Map.Entry 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 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 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 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 updatedScriptHashes = new HashSet<>(); + + for(ListTransaction removedTransaction : listSinceBlock.removed()) { + if(removedTransaction.confirmations() < 0) { + updatedScriptHashes.addAll(store.purgeTransaction(removedTransaction.txid())); + } + } + + List sentTransactions = new ArrayList<>(); + Map 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 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 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 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; + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindClientService.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindClientService.java new file mode 100644 index 00000000..eda68c4a --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindClientService.java @@ -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 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 importDescriptors(@JsonRpcParam("requests") List importDescriptors); + + @JsonRpcMethod("sendrawtransaction") + String sendRawTransaction(@JsonRpcParam("hexstring") String rawTx, @JsonRpcParam("maxfeerate") Double maxFeeRate); +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindTransport.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindTransport.java new file mode 100644 index 00000000..6c617d61 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindTransport.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BlockchainInfo.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BlockchainInfo.java new file mode 100644 index 00000000..7d19ec7c --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BlockchainInfo.java @@ -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); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/Category.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/Category.java new file mode 100644 index 00000000..4fa93fd1 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/Category.java @@ -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); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/CormorantBitcoindException.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/CormorantBitcoindException.java new file mode 100644 index 00000000..16c81db7 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/CormorantBitcoindException.java @@ -0,0 +1,7 @@ +package com.sparrowwallet.sparrow.net.cormorant.bitcoind; + +public class CormorantBitcoindException extends Exception { + public CormorantBitcoindException(String message) { + super(message); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/CreateLoadWalletResult.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/CreateLoadWalletResult.java new file mode 100644 index 00000000..3d71bf1e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/CreateLoadWalletResult.java @@ -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) { +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/FeeInfo.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/FeeInfo.java new file mode 100644 index 00000000..8d4739b4 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/FeeInfo.java @@ -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 errors, int blocks) { + +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/FeesMempoolEntry.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/FeesMempoolEntry.java new file mode 100644 index 00000000..49ed291d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/FeesMempoolEntry.java @@ -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) { +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportDescriptor.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportDescriptor.java new file mode 100644 index 00000000..5ec70bb9 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportDescriptor.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportDescriptorResult.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportDescriptorResult.java new file mode 100644 index 00000000..49c1974e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportDescriptorResult.java @@ -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 warnings, ErrorMessage error) { +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportFailedException.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportFailedException.java new file mode 100644 index 00000000..4777a10a --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportFailedException.java @@ -0,0 +1,7 @@ +package com.sparrowwallet.sparrow.net.cormorant.bitcoind; + +public class ImportFailedException extends Exception { + public ImportFailedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportRangedDescriptor.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportRangedDescriptor.java new file mode 100644 index 00000000..6355038b --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ImportRangedDescriptor.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListDescriptorResult.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListDescriptorResult.java new file mode 100644 index 00000000..1e9283a0 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListDescriptorResult.java @@ -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 range) { +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListDescriptorsResult.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListDescriptorsResult.java new file mode 100644 index 00000000..8b13bb58 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListDescriptorsResult.java @@ -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 descriptors) { +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListSinceBlock.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListSinceBlock.java new file mode 100644 index 00000000..d868c1b0 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListSinceBlock.java @@ -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 transactions, List removed, String lastblock) { + +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListTransaction.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListTransaction.java new file mode 100644 index 00000000..8d5249a3 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListTransaction.java @@ -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 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 walletconflicts) { +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListWalletDirResult.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListWalletDirResult.java new file mode 100644 index 00000000..3a8629f5 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListWalletDirResult.java @@ -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 wallets) { +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/MempoolEntry.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/MempoolEntry.java new file mode 100644 index 00000000..378fb1ae --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/MempoolEntry.java @@ -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); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/MempoolInfo.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/MempoolInfo.java new file mode 100644 index 00000000..a8345c8d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/MempoolInfo.java @@ -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) { +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/NetworkInfo.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/NetworkInfo.java new file mode 100644 index 00000000..131a28e7 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/NetworkInfo.java @@ -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) { + +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/VerboseBlockHeader.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/VerboseBlockHeader.java new file mode 100644 index 00000000..f1095348 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/VerboseBlockHeader.java @@ -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())); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/WalletDirResult.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/WalletDirResult.java new file mode 100644 index 00000000..ef2a358a --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/WalletDirResult.java @@ -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) { +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/WalletInfo.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/WalletInfo.java new file mode 100644 index 00000000..f2b955a3 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/WalletInfo.java @@ -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) { +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/WalletScanningInfo.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/WalletScanningInfo.java new file mode 100644 index 00000000..36f3b0b8 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/WalletScanningInfo.java @@ -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); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/BitcoindIOException.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/BitcoindIOException.java new file mode 100644 index 00000000..a364b5c2 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/BitcoindIOException.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/BlockNotFoundException.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/BlockNotFoundException.java new file mode 100644 index 00000000..0579b7ab --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/BlockNotFoundException.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/BroadcastFailedException.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/BroadcastFailedException.java new file mode 100644 index 00000000..22e627ba --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/BroadcastFailedException.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumBlockHeader.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumBlockHeader.java new file mode 100644 index 00000000..2ef1f9f1 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumBlockHeader.java @@ -0,0 +1,5 @@ +package com.sparrowwallet.sparrow.net.cormorant.electrum; + +public record ElectrumBlockHeader(int height, String hex) { + +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumNotificationService.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumNotificationService.java new file mode 100644 index 00000000..addc7ea2 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumNotificationService.java @@ -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); +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumNotificationTransport.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumNotificationTransport.java new file mode 100644 index 00000000..9295a930 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumNotificationTransport.java @@ -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}"; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumServerRunnable.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumServerRunnable.java new file mode 100644 index 00000000..dd041a64 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumServerRunnable.java @@ -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); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumServerService.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumServerService.java new file mode 100644 index 00000000..ca5d5572 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumServerService.java @@ -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 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> getFeeHistogram() throws BitcoindIOException { + try { + Map mempoolEntries = bitcoindClient.getBitcoindService().getRawMempool(true); + + List vsizeFeerates = mempoolEntries.values().stream().map(entry -> new VsizeFeerate(entry.vsize(), entry.fees().base())).sorted().toList(); + + List> 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 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 { + 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); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/RequestHandler.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/RequestHandler.java new file mode 100644 index 00000000..74897ea4 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/RequestHandler.java @@ -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 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()); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ScriptHashStatus.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ScriptHashStatus.java new file mode 100644 index 00000000..83b7a579 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ScriptHashStatus.java @@ -0,0 +1,5 @@ +package com.sparrowwallet.sparrow.net.cormorant.electrum; + +public record ScriptHashStatus(String scriptHash, String status) { + +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/TransactionNotFoundException.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/TransactionNotFoundException.java new file mode 100644 index 00000000..1dbabc0a --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/TransactionNotFoundException.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/UnsupportedVersionException.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/UnsupportedVersionException.java new file mode 100644 index 00000000..3ae73091 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/UnsupportedVersionException.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/index/Store.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/index/Store.java new file mode 100644 index 00000000..ffb202ad --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/index/Store.java @@ -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> scriptHashEntries = new HashMap<>(); + private final Map fundingAddresses = new HashMap<>(); + private final Map> spentOutputs = new HashMap<>(); + private final Map blockHeightHashes = new HashMap<>(); + private final Map 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 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 updateMempoolTransactions() { + Set updatedScriptHashes = new HashSet<>(); + + for(Map.Entry> scriptHashEntry : scriptHashEntries.entrySet()) { + Set txEntries = scriptHashEntry.getValue(); + Set oldEntries = new HashSet<>(); + Set 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 purgeTransaction(String txid) { + Set updatedScriptHashes = new HashSet<>(); + + for(Map.Entry> scriptHashEntry : scriptHashEntries.entrySet()) { + Set 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 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> getSpentOutputs() { + return spentOutputs; + } + + public Map getMempoolEntries() { + return mempoolEntries; + } + + public Set getHistory(String scriptHash) { + Set 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); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/index/TxEntry.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/index/TxEntry.java new file mode 100644 index 00000000..7f2a8f98 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/index/TxEntry.java @@ -0,0 +1,67 @@ +package com.sparrowwallet.sparrow.net.cormorant.index; + +public class TxEntry implements Comparable { + 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 + '\'' + + '}'; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/preferences/ServerPreferencesController.java b/src/main/java/com/sparrowwallet/sparrow/preferences/ServerPreferencesController.java index edeb5206..3fe7ea56 100644 --- a/src/main/java/com/sparrowwallet/sparrow/preferences/ServerPreferencesController.java +++ b/src/main/java/com/sparrowwallet/sparrow/preferences/ServerPreferencesController.java @@ -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)) { diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/SparrowTextGui.java b/src/main/java/com/sparrowwallet/sparrow/terminal/SparrowTextGui.java index 221c1b20..8e117f79 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/SparrowTextGui.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/SparrowTextGui.java @@ -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")); + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerTestDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerTestDialog.java index d33c28af..c7e2fabf 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerTestDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerTestDialog.java @@ -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)) { diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java index c4d59b3e..21fd2fb7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java @@ -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())); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java index 87357538..a1ab5e56 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java @@ -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()));