add cormorant server to support bitcoin core descriptor wallets

This commit is contained in:
Craig Raw 2022-12-08 08:42:40 +02:00
parent df7f40dbc9
commit 08cf01a5c6
54 changed files with 2100 additions and 47 deletions

2
drongo

@ -1 +1 @@
Subproject commit fa18ec9d458bb17221fb01b6be9f4eceb354a156 Subproject commit 692f23e02656b43b58c33b44467f920ddc3a3f65

View file

@ -2589,6 +2589,50 @@ public class AppController implements Initializable {
} }
} }
@Subscribe
public void cormorantSyncStatus(CormorantSyncStatusEvent event) {
serverToggle.setDisable(false);
if((AppServices.isConnecting() || AppServices.isConnected()) && !event.isCompleted()) {
statusUpdated(new StatusEvent("Syncing... (" + event.getProgress() + "% complete, synced to " + event.getTipAsString() + ")"));
if(event.getProgress() > 0 && (statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING)) {
statusBar.setProgress((double)event.getProgress() / 100);
}
}
}
@Subscribe
public void cormorantScanStatus(CormorantScanStatusEvent event) {
serverToggle.setDisable(true);
if((AppServices.isConnecting() || AppServices.isConnected()) && !event.isCompleted()) {
statusUpdated(new StatusEvent("Scanning... (" + event.getProgress() + "% complete, " + event.getRemainingAsString() + " remaining)"));
if(event.getProgress() > 0 && (statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING)) {
statusBar.setProgress((double)event.getProgress() / 100);
}
} else if(event.isCompleted()) {
serverToggle.setDisable(false);
if(statusBar.getText().startsWith("Scanning...")) {
statusBar.setText("");
}
}
}
@Subscribe
public void cormorantPruneStatus(CormorantPruneStatusEvent event) {
if(event.legacyWalletExists()) {
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("Error importing Bitcoin Core descriptor wallet",
"The connected node is pruned at " + event.getPruneDateAsString() + ", but the wallet birthday for " + event.getWallet().getFullDisplayName() + " is set to " + event.getScanDateAsString() + ".\n\n" +
"Do you want to try using the existing legacy Bitcoin Core wallet?", ButtonType.YES, ButtonType.NO);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
Config.get().setUseLegacyCoreWallet(true);
onlineProperty().set(false);
Platform.runLater(() -> onlineProperty().set(true));
}
} else {
AppServices.showErrorDialog("Error importing Bitcoin Core descriptor wallet",
"The connected node is pruned at " + event.getPruneDateAsString() + ", but the wallet birthday for " + event.getWallet().getFullDisplayName() + " is set to " + event.getScanDateAsString() + ".");
}
}
@Subscribe @Subscribe
public void bwtBootStatus(BwtBootStatusEvent event) { public void bwtBootStatus(BwtBootStatusEvent event) {
serverToggle.setDisable(true); serverToggle.setDisable(true);

View file

@ -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;
}
}

View file

@ -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 "";
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -64,6 +64,7 @@ public class Config {
private CoreAuthType coreAuthType; private CoreAuthType coreAuthType;
private File coreDataDir; private File coreDataDir;
private String coreAuth; private String coreAuth;
private boolean useLegacyCoreWallet;
private Server electrumServer; private Server electrumServer;
private List<Server> recentElectrumServers; private List<Server> recentElectrumServers;
private File electrumServerCert; private File electrumServerCert;
@ -512,6 +513,15 @@ public class Config {
flush(); flush();
} }
public boolean isUseLegacyCoreWallet() {
return useLegacyCoreWallet;
}
public void setUseLegacyCoreWallet(boolean useLegacyCoreWallet) {
this.useLegacyCoreWallet = useLegacyCoreWallet;
flush();
}
public Server getElectrumServer() { public Server getElectrumServer() {
return electrumServer; return electrumServer;
} }

View file

@ -24,7 +24,6 @@ import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.IOException;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.Duration; import java.time.Duration;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -33,7 +32,6 @@ public class Bwt {
private static final Logger log = LoggerFactory.getLogger(Bwt.class); private static final Logger log = LoggerFactory.getLogger(Bwt.class);
public static final String DEFAULT_CORE_WALLET = "sparrow"; 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"; public static final String ELECTRUM_PORT = "0";
private static final int IMPORT_BATCH_SIZE = 350; private static final int IMPORT_BATCH_SIZE = 350;
private static boolean initialized; private static boolean initialized;
@ -142,7 +140,7 @@ public class Bwt {
bwtConfig.setupLogger = false; bwtConfig.setupLogger = false;
} }
bwtConfig.electrumAddr = ELECTRUM_HOST + ":" + ELECTRUM_PORT; bwtConfig.electrumAddr = ElectrumServer.CORE_ELECTRUM_HOST + ":" + ELECTRUM_PORT;
bwtConfig.electrumSkipMerkle = true; bwtConfig.electrumSkipMerkle = true;
Config config = Config.get(); Config config = Config.get();

View file

@ -15,6 +15,8 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Server; 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 com.sparrowwallet.sparrow.paynym.PayNym;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.IntegerProperty; 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"); 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; private static final int MINIMUM_BROADCASTS = 2;
public static final BlockTransaction UNFETCHABLE_BLOCK_TRANSACTION = new BlockTransaction(Sha256Hash.ZERO_HASH, 0, null, null, null); 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 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:[^\"]*)\".*"); private static final Pattern RPC_WALLET_LOADING_PATTERN = Pattern.compile(".*\"(Wallet loading failed:[^\"]*)\".*");
@ -74,12 +80,12 @@ public class ElectrumServer {
electrumServer = Config.get().getPublicElectrumServer(); electrumServer = Config.get().getPublicElectrumServer();
proxyServer = Config.get().getProxyServer(); proxyServer = Config.get().getProxyServer();
} else if(Config.get().getServerType() == ServerType.BITCOIN_CORE) { } else if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
if(bwtElectrumServer == null) { if(coreElectrumServer == null) {
throw new ServerConfigException("Could not connect to Bitcoin Core RPC"); throw new ServerConfigException("Could not connect to Bitcoin Core RPC");
} }
electrumServer = bwtElectrumServer; electrumServer = coreElectrumServer;
if(previousServer != null && previousServer.getUrl().contains(Bwt.ELECTRUM_HOST)) { if(previousServer != null && previousServer.getUrl().contains(CORE_ELECTRUM_HOST)) {
previousServer = bwtElectrumServer; previousServer = coreElectrumServer;
} }
} else if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER) { } else if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER) {
electrumServer = Config.get().getElectrumServer(); electrumServer = Config.get().getElectrumServer();
@ -1080,46 +1086,59 @@ public class ElectrumServer {
ElectrumServer electrumServer = new ElectrumServer(); ElectrumServer electrumServer = new ElectrumServer();
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) { 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 { try {
bwtStartLock.lock(); 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 { } finally {
bwtStartLock.unlock(); 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() { 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() { public boolean isConnectionRunning() {
@ -1204,7 +1223,7 @@ public class ElectrumServer {
} }
public boolean isConnected() { 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() { public boolean isShutdown() {
@ -1229,10 +1248,16 @@ public class ElectrumServer {
reader.interrupt(); reader.interrupt();
} }
if(ElectrumServer.cormorant != null) {
ElectrumServer.cormorant.stop();
ElectrumServer.cormorant = null;
ElectrumServer.coreElectrumServer = null;
}
if(Config.get().getServerType() == ServerType.BITCOIN_CORE && bwt.isRunning()) { if(Config.get().getServerType() == ServerType.BITCOIN_CORE && bwt.isRunning()) {
Bwt.DisconnectionService disconnectionService = bwt.getDisconnectionService(); Bwt.DisconnectionService disconnectionService = bwt.getDisconnectionService();
disconnectionService.setOnSucceeded(workerStateEvent -> { disconnectionService.setOnSucceeded(workerStateEvent -> {
ElectrumServer.bwtElectrumServer = null; ElectrumServer.coreElectrumServer = null;
if(subscribe) { if(subscribe) {
EventManager.get().post(new BwtShutdownEvent()); EventManager.get().post(new BwtShutdownEvent());
} }
@ -1261,7 +1286,7 @@ public class ElectrumServer {
@Subscribe @Subscribe
public void bwtElectrumReadyStatus(BwtElectrumReadyStatusEvent event) { public void bwtElectrumReadyStatus(BwtElectrumReadyStatusEvent event) {
if(this.isRunning()) { if(this.isRunning()) {
ElectrumServer.bwtElectrumServer = new Server(Protocol.TCP.toUrlString(HostAndPort.fromString(event.getElectrumAddr()))); ElectrumServer.coreElectrumServer = new Server(Protocol.TCP.toUrlString(HostAndPort.fromString(event.getElectrumAddr())));
} }
} }
@ -1323,6 +1348,12 @@ public class ElectrumServer {
protected Task<Boolean> createTask() { protected Task<Boolean> createTask() {
return new Task<>() { return new Task<>() {
protected Boolean call() throws ServerException { protected Boolean call() throws ServerException {
if(ElectrumServer.cormorant != null) {
if(!ElectrumServer.cormorant.checkWalletImport(mainWallet)) {
return true;
}
}
boolean historyFetched = getTransactionHistory(mainWallet); boolean historyFetched = getTransactionHistory(mainWallet);
for(Wallet childWallet : new ArrayList<>(mainWallet.getChildWallets())) { for(Wallet childWallet : new ArrayList<>(mainWallet.getChildWallets())) {
if(childWallet.isNested()) { if(childWallet.isNested()) {
@ -1423,6 +1454,12 @@ public class ElectrumServer {
protected Task<Set<String>> createTask() { protected Task<Set<String>> createTask() {
return new Task<>() { return new Task<>() {
protected Set<String> call() throws ServerException { protected Set<String> call() throws ServerException {
if(ElectrumServer.cormorant != null) {
if(!ElectrumServer.cormorant.checkWalletImport(wallet)) {
return Collections.emptySet();
}
}
iterationCount.set(iterationCount.get() + 1); iterationCount.set(iterationCount.get() + 1);
ElectrumServer electrumServer = new ElectrumServer(); ElectrumServer electrumServer = new ElectrumServer();
return electrumServer.getMempoolScriptHashes(wallet, txId, nodes); return electrumServer.getMempoolScriptHashes(wallet, txId, nodes);
@ -1737,6 +1774,12 @@ public class ElectrumServer {
protected Task<List<Wallet>> createTask() { protected Task<List<Wallet>> createTask() {
return new Task<>() { return new Task<>() {
protected List<Wallet> call() throws ServerException { protected List<Wallet> call() throws ServerException {
if(ElectrumServer.cormorant != null) {
if(!ElectrumServer.cormorant.checkWalletImport(wallet)) {
return Collections.emptyList();
}
}
Wallet notificationWallet = wallet.getNotificationWallet(); Wallet notificationWallet = wallet.getNotificationWallet();
WalletNode notificationNode = notificationWallet.getNode(KeyPurpose.NOTIFICATION); WalletNode notificationNode = notificationWallet.getNode(KeyPurpose.NOTIFICATION);

View file

@ -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;
}
}

View file

@ -0,0 +1,493 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.CormorantPruneStatusEvent;
import com.sparrowwallet.sparrow.event.CormorantScanStatusEvent;
import com.sparrowwallet.sparrow.event.CormorantSyncStatusEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.Bwt;
import com.sparrowwallet.sparrow.net.CoreAuthType;
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.sparrow.net.cormorant.electrum.ElectrumBlockHeader;
import com.sparrowwallet.sparrow.net.cormorant.electrum.ScriptHashStatus;
import com.sparrowwallet.sparrow.net.cormorant.index.Store;
import com.sparrowwallet.drongo.protocol.*;
import javafx.application.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
public class BitcoindClient {
private static final Logger log = LoggerFactory.getLogger(BitcoindClient.class);
public static final String CORE_WALLET_NAME = "cormorant";
private static final int GAP_LIMIT = 1000;
private final JsonRpcClient jsonRpcClient;
private final Timer timer = new Timer(true);
private final Store store = new Store();
private NetworkInfo networkInfo;
private String lastBlock;
private ElectrumBlockHeader tip;
private final Map<String, Lock> descriptorLocks = Collections.synchronizedMap(new HashMap<>());
private final Map<String, ScanDate> importedDescriptors = Collections.synchronizedMap(new HashMap<>());
private boolean initialized;
private boolean stopped;
private boolean pruned;
private boolean prunedWarningShown = false;
private boolean legacyWalletExists;
private final Lock syncingLock = new ReentrantLock();
private final Condition syncingCondition = syncingLock.newCondition();
private boolean syncing;
private final Lock scanningLock = new ReentrantLock();
public BitcoindClient() {
BitcoindTransport bitcoindTransport;
Config config = Config.get();
if((config.getCoreAuthType() == CoreAuthType.COOKIE || config.getCoreAuth() == null || config.getCoreAuth().length() < 2) && config.getCoreDataDir() != null) {
bitcoindTransport = new BitcoindTransport(config.getCoreServer(), CORE_WALLET_NAME, config.getCoreDataDir());
} else {
bitcoindTransport = new BitcoindTransport(config.getCoreServer(), CORE_WALLET_NAME, config.getCoreAuth());
}
this.jsonRpcClient = new JsonRpcClient(bitcoindTransport);
}
public void initialize() throws CormorantBitcoindException {
networkInfo = getBitcoindService().getNetworkInfo();
if(networkInfo.version() < 240000) {
throw new CormorantBitcoindException("Bitcoin Core versions older than v24 are not supported");
}
BlockchainInfo blockchainInfo = getBitcoindService().getBlockchainInfo();
pruned = blockchainInfo.pruned();
VerboseBlockHeader blockHeader = getBitcoindService().getBlockHeader(blockchainInfo.bestblockhash());
tip = blockHeader.getBlockHeader();
timer.schedule(new PollTask(), 5000, 5000);
if(blockchainInfo.initialblockdownload()) {
syncingLock.lock();
try {
syncing = true;
syncingCondition.await();
} catch(InterruptedException e) {
throw new CormorantBitcoindException("Interrupted while waiting for sync to complete");
} finally {
syncingLock.unlock();
}
blockchainInfo = getBitcoindService().getBlockchainInfo();
blockHeader = getBitcoindService().getBlockHeader(blockchainInfo.bestblockhash());
tip = blockHeader.getBlockHeader();
}
ListWalletDirResult listWalletDirResult = getBitcoindService().listWalletDir();
boolean exists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(CORE_WALLET_NAME));
legacyWalletExists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(Bwt.DEFAULT_CORE_WALLET));
if(!exists) {
getBitcoindService().createWallet(CORE_WALLET_NAME, true, true, "", true, true, false, false);
} else {
try {
getBitcoindService().loadWallet(CORE_WALLET_NAME, false);
} catch(JsonRpcException e) {
getBitcoindService().unloadWallet(CORE_WALLET_NAME, false);
getBitcoindService().loadWallet(CORE_WALLET_NAME, false);
}
}
ListSinceBlock listSinceBlock = getListSinceBlock(null);
updateStore(listSinceBlock);
}
private ListSinceBlock getListSinceBlock(String blockHash) {
return getBitcoindService().listSinceBlock(blockHash, 1, true, true, true);
}
public void importWallets(Set<Wallet> wallets) throws ImportFailedException {
importDescriptors(getWalletDescriptors(wallets));
}
public void importWallet(Wallet wallet) throws ImportFailedException {
importDescriptors(getWalletDescriptors(Set.of(wallet)));
}
private Map<String, ScanDate> getWalletDescriptors(Set<Wallet> wallets) throws ImportFailedException {
List<Wallet> validWallets = wallets.stream().filter(Wallet::isValid).collect(Collectors.toList());
Map<String, ScanDate> outputDescriptors = new LinkedHashMap<>();
for(Wallet wallet : validWallets) {
if(pruned) {
Optional<Date> optPrunedDate = getPrunedDate();
if(optPrunedDate.isPresent() && wallet.getBirthDate() != null) {
Date prunedDate = optPrunedDate.get();
Date earliestScanDate = wallet.getBirthDate();
if(earliestScanDate.before(prunedDate)) {
if(!prunedWarningShown) {
prunedWarningShown = true;
Platform.runLater(() -> EventManager.get().post(new CormorantPruneStatusEvent("Error: Wallet birthday earlier than Bitcoin Core prune date", wallet, earliestScanDate, prunedDate, legacyWalletExists)));
}
throw new ImportFailedException("Wallet birthday earlier than prune date");
}
}
}
OutputDescriptor receiveOutputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.RECEIVE);
outputDescriptors.put(OutputDescriptor.normalize(receiveOutputDescriptor.toString(false, false)), getScanDate(wallet, KeyPurpose.RECEIVE));
OutputDescriptor changeOutputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.CHANGE);
outputDescriptors.put(OutputDescriptor.normalize(changeOutputDescriptor.toString(false, false)), getScanDate(wallet, KeyPurpose.CHANGE));
if(wallet.isMasterWallet() && wallet.hasPaymentCode()) {
Wallet notificationWallet = wallet.getNotificationWallet();
WalletNode notificationNode = notificationWallet.getNode(KeyPurpose.NOTIFICATION);
outputDescriptors.put(OutputDescriptor.normalize(OutputDescriptor.toDescriptorString(notificationNode.getAddress())), getScanDate(wallet, null));
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isNested()) {
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
for(WalletNode addressNode : childWallet.getNode(keyPurpose).getChildren()) {
outputDescriptors.put(OutputDescriptor.normalize(OutputDescriptor.toDescriptorString(addressNode.getAddress())), getScanDate(wallet, null));
}
}
}
}
}
}
return outputDescriptors;
}
private Optional<Date> getPrunedDate() {
BlockchainInfo blockchainInfo = getBitcoindService().getBlockchainInfo();
if(blockchainInfo.pruned()) {
String pruneBlockHash = getBitcoindService().getBlockHash(blockchainInfo.pruneheight());
VerboseBlockHeader pruneBlockHeader = getBitcoindService().getBlockHeader(pruneBlockHash);
return Optional.of(new Date(pruneBlockHeader.time() * 1000));
}
return Optional.empty();
}
private ScanDate getScanDate(Wallet wallet, KeyPurpose keyPurpose) {
Integer range = (keyPurpose == null ? null : wallet.getFreshNode(keyPurpose).getIndex() + GAP_LIMIT);
boolean forceRescan = false;
Date txBirthDate = wallet.getTransactions().values().stream().map(BlockTransactionHash::getDate).filter(Objects::nonNull).min(Date::compareTo).orElse(null);
if((wallet.getBirthDate() != null && txBirthDate != null && wallet.getBirthDate().before(txBirthDate)) || (txBirthDate == null && wallet.getStoredBlockHeight() != null && wallet.getStoredBlockHeight() == 0)) {
forceRescan = true;
}
return new ScanDate(wallet.getBirthDate(), range, forceRescan);
}
private void importDescriptors(Map<String, ScanDate> descriptors) {
for(String descriptor : descriptors.keySet()) {
Lock lock = descriptorLocks.computeIfAbsent(descriptor, desc -> new ReentrantLock());
lock.lock();
}
try {
Set<String> addedDescriptors = addDescriptors(descriptors);
if(!addedDescriptors.isEmpty()) {
ListSinceBlock listSinceBlock = getListSinceBlock(null);
updateStore(listSinceBlock, addedDescriptors);
}
} finally {
for(String descriptor : descriptors.keySet()) {
Lock lock = descriptorLocks.get(descriptor);
lock.unlock();
}
}
}
private Set<String> addDescriptors(Map<String, ScanDate> descriptors) {
boolean forceRescan = descriptors.values().stream().anyMatch(scanDate -> scanDate.forceRescan);
if(!initialized || forceRescan) {
ListDescriptorsResult listDescriptorsResult = getBitcoindService().listDescriptors(false);
for(ListDescriptorResult result : listDescriptorsResult.descriptors()) {
String descriptor = OutputDescriptor.normalize(result.desc());
ScanDate previousScanDate = importedDescriptors.get(descriptor);
Integer range = result.range() == null ? null : result.range().get(result.range().size() - 1);
importedDescriptors.put(descriptor, new ScanDate(previousScanDate == null ? null : previousScanDate.rescanSince, range, false));
}
}
Map<String, ScanDate> importingDescriptors = new LinkedHashMap<>(descriptors);
importingDescriptors.keySet().removeAll(importedDescriptors.keySet());
for(Map.Entry<String, ScanDate> entry : descriptors.entrySet()) {
if(importingDescriptors.containsKey(entry.getKey())) {
continue;
}
ScanDate scanDate = entry.getValue();
if(scanDate.forceRescan) {
ScanDate importedScanDate = importedDescriptors.get(entry.getKey());
if(scanDate.rescanSince != null && (importedScanDate == null || importedScanDate.rescanSince == null || scanDate.rescanSince.before(importedScanDate.rescanSince))) {
importingDescriptors.put(entry.getKey(), new ScanDate(scanDate.rescanSince, importedScanDate != null ? importedScanDate.range : scanDate.range, false));
}
}
}
if(!importingDescriptors.isEmpty()) {
log.warn("Importing descriptors " + importingDescriptors);
List<ImportDescriptor> importDescriptors = importingDescriptors.entrySet().stream()
.map(entry -> {
ScanDate scanDate = entry.getValue();
if(entry.getKey().contains("/0/*")) {
return new ImportRangedDescriptor(entry.getKey(), true, scanDate.range(), scanDate.getTimestamp(), false);
} else if(entry.getKey().contains("/1/*")) {
return new ImportRangedDescriptor(entry.getKey(), false, scanDate.range(), scanDate.getTimestamp(), true);
}
return new ImportDescriptor(entry.getKey(), false, entry.getValue().getTimestamp(), true);
}).toList();
List<ImportDescriptorResult> results;
scanningLock.lock();
try {
results = getBitcoindService().importDescriptors(importDescriptors);
} finally {
scanningLock.unlock();
Platform.runLater(() -> EventManager.get().post(new CormorantScanStatusEvent("Scanning completed", 100, Duration.ZERO)));
}
for(int i = 0; i < importDescriptors.size(); i++) {
ImportDescriptor importDescriptor = importDescriptors.get(i);
ImportDescriptorResult importDescriptorResult = results.get(i);
if(importDescriptorResult.success()) {
importedDescriptors.put(importDescriptor.getDesc(), importingDescriptors.get(importDescriptor.getDesc()));
} else {
log.error("Error importing descriptor " + importDescriptor.getDesc() + ": " + importDescriptorResult);
}
}
}
initialized = true;
return importingDescriptors.keySet();
}
public void stop() {
if(initialized) {
try {
getBitcoindService().unloadWallet(CORE_WALLET_NAME, false);
} catch(Exception e) {
log.info("Error unloading Core wallet " + CORE_WALLET_NAME, e);
}
}
timer.cancel();
stopped = true;
}
private void updateStore(ListSinceBlock listSinceBlock, Set<String> descriptors) {
listSinceBlock.removed().removeIf(lt -> lt.parent_descs() != null && lt.parent_descs().stream().map(OutputDescriptor::normalize).noneMatch(descriptors::contains));
listSinceBlock.transactions().removeIf(lt -> lt.parent_descs() != null && lt.parent_descs().stream().map(OutputDescriptor::normalize).noneMatch(descriptors::contains));
updateStore(listSinceBlock);
}
private synchronized void updateStore(ListSinceBlock listSinceBlock) {
Set<String> updatedScriptHashes = new HashSet<>();
for(ListTransaction removedTransaction : listSinceBlock.removed()) {
if(removedTransaction.confirmations() < 0) {
updatedScriptHashes.addAll(store.purgeTransaction(removedTransaction.txid()));
}
}
List<ListTransaction> sentTransactions = new ArrayList<>();
Map<String, Boolean> conflictCache = new HashMap<>();
for(ListTransaction listTransaction : listSinceBlock.transactions()) {
if(isConflicted(listTransaction, conflictCache)) {
updatedScriptHashes.addAll(store.purgeTransaction(listTransaction.txid()));
continue;
}
try {
if(listTransaction.category() == Category.receive) {
//Transactions received to an address can be added directly
Address address = Address.fromString(listTransaction.address());
String updatedScriptHash = store.addAddressTransaction(address, listTransaction);
if(updatedScriptHash != null) {
updatedScriptHashes.add(updatedScriptHash);
}
} else if(listTransaction.category() == Category.send) {
//Need to determine the address the transaction was sent from
//Cache until all receive txes are processed
sentTransactions.add(listTransaction);
}
} catch(InvalidAddressException e) {
//ignore
}
}
for(ListTransaction sentTransaction : sentTransactions) {
Set<HashIndex> spentOutputs = store.getSpentOutputs().computeIfAbsent(sentTransaction.txid(), txid -> {
String txhex = getBitcoindService().getRawTransaction(txid, false).toString();
Transaction tx = new Transaction(Utils.hexToBytes(txhex));
return tx.getInputs().stream().map(txInput -> new HashIndex(txInput.getOutpoint().getHash(), txInput.getOutpoint().getIndex())).collect(Collectors.toSet());
});
boolean foundFundingAddress = false;
for(HashIndex spentOutput : spentOutputs) {
Address fundingAddress = store.getFundingAddress(spentOutput);
if(fundingAddress != null) {
String updatedScriptHash = store.addAddressTransaction(fundingAddress, sentTransaction);
if(updatedScriptHash != null) {
updatedScriptHashes.add(updatedScriptHash);
}
foundFundingAddress = true;
}
}
if(!foundFundingAddress) {
log.error("Could not find a funding address for wallet spend tx " + sentTransaction.txid());
}
}
syncMempool(!listSinceBlock.lastblock().equals(lastBlock));
updatedScriptHashes.addAll(store.updateMempoolTransactions());
lastBlock = listSinceBlock.lastblock();
for(String updatedScriptHash : updatedScriptHashes) {
Cormorant.getEventBus().post(new ScriptHashStatus(updatedScriptHash, store.getStatus(updatedScriptHash)));
}
}
private void syncMempool(boolean forceRefresh) {
Map<String, MempoolEntry> mempoolEntries = store.getMempoolEntries();
for(String txid : new HashSet<>(mempoolEntries.keySet())) {
if(forceRefresh || mempoolEntries.get(txid) == null) {
MempoolEntry mempoolEntry = getBitcoindService().getMempoolEntry(txid);
if(mempoolEntry != null) {
mempoolEntries.put(txid, mempoolEntry);
} else {
mempoolEntries.remove(txid);
}
}
}
}
private boolean isConflicted(ListTransaction listTransaction, Map<String, Boolean> conflictCache) {
if(listTransaction.confirmations() == 0 && !listTransaction.walletconflicts().isEmpty()) {
Boolean active = conflictCache.computeIfAbsent(listTransaction.txid(), txid -> getBitcoindService().getMempoolEntry(txid) != null);
if(active) {
for(String conflictedTxid : listTransaction.walletconflicts()) {
conflictCache.put(conflictedTxid, false);
}
}
return !active;
} else {
return listTransaction.confirmations() < 0;
}
}
public Store getStore() {
return store;
}
public BitcoindClientService getBitcoindService() {
return jsonRpcClient.onDemand(BitcoindClientService.class);
}
public NetworkInfo getNetworkInfo() {
return networkInfo;
}
public ElectrumBlockHeader getTip() {
return tip;
}
private class PollTask extends TimerTask {
@Override
public void run() {
if(stopped) {
timer.cancel();
}
try {
if(syncing) {
BlockchainInfo blockchainInfo = getBitcoindService().getBlockchainInfo();
if(blockchainInfo.initialblockdownload()) {
int percent = blockchainInfo.getProgressPercent();
Date tipDate = blockchainInfo.getTip();
Platform.runLater(() -> EventManager.get().post(new CormorantSyncStatusEvent("Syncing" + (percent < 100 ? " (" + percent + "%)" : ""), percent, tipDate)));
return;
} else {
syncing = false;
syncingLock.lock();
try {
syncingCondition.signal();
} finally {
syncingLock.unlock();
}
}
}
if(lastBlock != null && tip != null) {
String blockhash = getBitcoindService().getBlockHash(tip.height());
if(!lastBlock.equals(blockhash)) {
log.warn("Reorg detected, block height " + tip.height() + " was " + lastBlock + " and now is " + blockhash);
lastBlock = null;
}
}
ListSinceBlock listSinceBlock = getListSinceBlock(lastBlock);
String currentBlock = lastBlock;
updateStore(listSinceBlock);
if(currentBlock == null || !currentBlock.equals(listSinceBlock.lastblock())) {
VerboseBlockHeader blockHeader = getBitcoindService().getBlockHeader(listSinceBlock.lastblock());
tip = blockHeader.getBlockHeader();
Cormorant.getEventBus().post(tip);
}
if(scanningLock.tryLock()) {
scanningLock.unlock();
} else {
WalletInfo walletInfo = getBitcoindService().getWalletInfo();
if(walletInfo.scanning().isScanning()) {
int percent = walletInfo.scanning().getPercent();
Duration remainingDuration = walletInfo.scanning().getRemaining();
Platform.runLater(() -> EventManager.get().post(new CormorantScanStatusEvent("Scanning" + (percent < 100 ? " (" + percent + "%)" : ""), percent, remainingDuration)));
}
}
} catch(Exception e) {
log.warn("Error polling Bitcoin Core: " + e.getMessage());
}
}
}
private record ScanDate(Date rescanSince, Integer range, boolean forceRescan) {
public Object getTimestamp() {
return rescanSince == null ? "now" : rescanSince.getTime() / 1000;
}
}
}

View file

@ -0,0 +1,76 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
import java.util.List;
import java.util.Map;
@JsonRpcService
public interface BitcoindClientService {
@JsonRpcMethod("uptime")
long uptime();
@JsonRpcMethod("getnetworkinfo")
NetworkInfo getNetworkInfo();
@JsonRpcMethod("estimatesmartfee")
FeeInfo estimateSmartFee(@JsonRpcParam("conf_target") int blocks);
@JsonRpcMethod("getrawmempool")
Map<String, MempoolEntry> getRawMempool(@JsonRpcParam("verbose") boolean verbose);
@JsonRpcMethod("getmempoolinfo")
MempoolInfo getMempoolInfo();
@JsonRpcMethod("getblockchaininfo")
BlockchainInfo getBlockchainInfo();
@JsonRpcMethod("getwalletinfo")
WalletInfo getWalletInfo();
@JsonRpcMethod("getblockhash")
String getBlockHash(@JsonRpcParam("height") int height);
@JsonRpcMethod("getblockheader")
String getBlockHeader(@JsonRpcParam("blockhash") String blockhash, @JsonRpcParam("verbose") boolean verbose);
@JsonRpcMethod("getblockheader")
VerboseBlockHeader getBlockHeader(@JsonRpcParam("blockhash") String blockhash);
@JsonRpcMethod("getrawtransaction")
Object getRawTransaction(@JsonRpcParam("txid") String txid, @JsonRpcParam("verbose") boolean verbose);
@JsonRpcMethod("getmempoolentry")
MempoolEntry getMempoolEntry(@JsonRpcParam("txid") String txid);
@JsonRpcMethod("listsinceblock")
ListSinceBlock listSinceBlock(@JsonRpcParam("blockhash") @JsonRpcOptional String blockhash, @JsonRpcParam("target_confirmations") int targetConfirmations,
@JsonRpcParam("include_watchonly") boolean includeWatchOnly, @JsonRpcParam("include_removed") boolean includeRemoved,
@JsonRpcParam("include_change") boolean includeChange);
@JsonRpcMethod("listwalletdir")
ListWalletDirResult listWalletDir();
@JsonRpcMethod("createwallet")
CreateLoadWalletResult createWallet(@JsonRpcParam("wallet_name") String name, @JsonRpcParam("disable_private_keys") boolean disablePrivateKeys, @JsonRpcParam("blank") boolean blank,
@JsonRpcParam("passphrase") String passphrase, @JsonRpcParam("avoid_reuse") boolean avoidReuse, @JsonRpcParam("descriptors") boolean descriptors,
@JsonRpcParam("load_on_startup") boolean loadOnStartup, @JsonRpcParam("external_signer") boolean externalSigner);
@JsonRpcMethod("loadwallet")
CreateLoadWalletResult loadWallet(@JsonRpcParam("filename") String name, @JsonRpcParam("load_on_startup") boolean loadOnStartup);
@JsonRpcMethod("unloadwallet")
CreateLoadWalletResult unloadWallet(@JsonRpcParam("wallet_name") String name, @JsonRpcParam("load_on_startup") boolean loadOnStartup);
@JsonRpcMethod("listdescriptors")
ListDescriptorsResult listDescriptors(@JsonRpcParam("private") boolean listPrivate);
@JsonRpcMethod("importdescriptors")
List<ImportDescriptorResult> importDescriptors(@JsonRpcParam("requests") List<ImportDescriptor> importDescriptors);
@JsonRpcMethod("sendrawtransaction")
String sendRawTransaction(@JsonRpcParam("hexstring") String rawTx, @JsonRpcParam("maxfeerate") Double maxFeeRate);
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
public class CormorantBitcoindException extends Exception {
public CormorantBitcoindException(String message) {
super(message);
}
}

View file

@ -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) {
}

View file

@ -0,0 +1,10 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record FeeInfo(Double feerate, List<String> errors, int blocks) {
}

View file

@ -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) {
}

View file

@ -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;
}
}

View file

@ -0,0 +1,10 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ImportDescriptorResult(boolean success, List<String> warnings, ErrorMessage error) {
}

View file

@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
public class ImportFailedException extends Exception {
public ImportFailedException(String message) {
super(message);
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ListDescriptorResult(String desc, long timestamp, boolean active, boolean internal, List<Integer> range) {
}

View file

@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ListDescriptorsResult(String wallet_name, List<ListDescriptorResult> descriptors) {
}

View file

@ -0,0 +1,10 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ListSinceBlock(List<ListTransaction> transactions, List<ListTransaction> removed, String lastblock) {
}

View file

@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ListTransaction(String address, List<String> parent_descs, Category category, double amount, int vout, double fee, int confirmations, String blockhash, int blockindex, long blocktime, int blockheight, String txid, long time, long timereceived, List<String> walletconflicts) {
}

View file

@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ListWalletDirResult(List<WalletDirResult> wallets) {
}

View file

@ -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);
}
}

View file

@ -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) {
}

View file

@ -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) {
}

View file

@ -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()));
}
}

View file

@ -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) {
}

View file

@ -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) {
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.net.cormorant.electrum;
public record ElectrumBlockHeader(int height, String hex) {
}

View file

@ -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);
}

View file

@ -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}";
}
}

View file

@ -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);
}
}
}

View file

@ -0,0 +1,184 @@
package com.sparrowwallet.sparrow.net.cormorant.electrum;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.net.Version;
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.*;
import com.sparrowwallet.sparrow.net.cormorant.index.TxEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@JsonRpcService
public class ElectrumServerService {
private static final Logger log = LoggerFactory.getLogger(ElectrumServerService.class);
private static final Version VERSION = new Version("1.4");
private static final long VSIZE_BIN_WIDTH = 50000;
private static final double DEFAULT_FEE_RATE = 0.00001d;
private final BitcoindClient bitcoindClient;
private final RequestHandler requestHandler;
public ElectrumServerService(BitcoindClient bitcoindClient, RequestHandler requestHandler) {
this.bitcoindClient = bitcoindClient;
this.requestHandler = requestHandler;
}
@JsonRpcMethod("server.version")
public List<String> getServerVersion(@JsonRpcParam("client_name") String clientName, @JsonRpcParam("protocol_version") String protocolVersion) throws UnsupportedVersionException {
Version clientVersion = new Version(protocolVersion);
if(clientVersion.compareTo(VERSION) < 0) {
throw new UnsupportedVersionException(protocolVersion);
}
return List.of(Cormorant.SERVER_NAME + " " + SparrowWallet.APP_VERSION, VERSION.get());
}
@JsonRpcMethod("server.banner")
public String getServerBanner() {
return SparrowWallet.APP_NAME + " " + SparrowWallet.APP_VERSION + "\n" + bitcoindClient.getNetworkInfo().subversion();
}
@JsonRpcMethod("blockchain.estimatefee")
public Double estimateFee(@JsonRpcParam("number") int blocks) throws BitcoindIOException {
try {
FeeInfo feeInfo = bitcoindClient.getBitcoindService().estimateSmartFee(blocks);
if(feeInfo == null || feeInfo.feerate() == null) {
return DEFAULT_FEE_RATE;
}
return feeInfo.feerate();
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("mempool.get_fee_histogram")
public List<List<Number>> getFeeHistogram() throws BitcoindIOException {
try {
Map<String, MempoolEntry> mempoolEntries = bitcoindClient.getBitcoindService().getRawMempool(true);
List<VsizeFeerate> vsizeFeerates = mempoolEntries.values().stream().map(entry -> new VsizeFeerate(entry.vsize(), entry.fees().base())).sorted().toList();
List<List<Number>> histogram = new ArrayList<>();
long binSize = 0;
double lastFeerate = 0.0;
for(VsizeFeerate vsizeFeerate : vsizeFeerates) {
if(binSize > VSIZE_BIN_WIDTH && Math.abs(lastFeerate - vsizeFeerate.feerate) > 0.0d) {
// vsize of transactions paying >= last_feerate
histogram.add(List.of(lastFeerate, binSize));
binSize = 0;
}
binSize += vsizeFeerate.vsize;
lastFeerate = vsizeFeerate.feerate;
}
if(binSize > 0) {
histogram.add(List.of(lastFeerate, binSize));
}
return histogram;
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("blockchain.relayfee")
public Double getRelayFee() throws BitcoindIOException {
try {
MempoolInfo mempoolInfo = bitcoindClient.getBitcoindService().getMempoolInfo();
return mempoolInfo.minrelaytxfee();
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("blockchain.headers.subscribe")
public ElectrumBlockHeader subscribeHeaders() {
requestHandler.setHeadersSubscribed(true);
return bitcoindClient.getTip();
}
@JsonRpcMethod("server.ping")
public void ping() throws BitcoindIOException {
try {
bitcoindClient.getBitcoindService().uptime();
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("blockchain.scripthash.subscribe")
public String subscribeScriptHash(@JsonRpcParam("scripthash") String scriptHash) {
requestHandler.subscribeScriptHash(scriptHash);
return bitcoindClient.getStore().getStatus(scriptHash);
}
@JsonRpcMethod("blockchain.scripthash.get_history")
public Collection<TxEntry> getHistory(@JsonRpcParam("scripthash") String scriptHash) {
return bitcoindClient.getStore().getHistory(scriptHash);
}
@JsonRpcMethod("blockchain.block.header")
public String getBlockHeader(@JsonRpcParam("height") int height) throws BitcoindIOException, BlockNotFoundException {
try {
String blockHash = bitcoindClient.getStore().getBlockHash(height);
if(blockHash == null) {
blockHash = bitcoindClient.getBitcoindService().getBlockHash(height);
}
return bitcoindClient.getBitcoindService().getBlockHeader(blockHash, false);
} catch(JsonRpcException e) {
throw new BlockNotFoundException(e.getErrorMessage());
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("blockchain.transaction.get")
public String getTransaction(@JsonRpcParam("tx_hash") String tx_hash, @JsonRpcParam("verbose") @JsonRpcOptional boolean verbose) throws BitcoindIOException, TransactionNotFoundException {
try {
return bitcoindClient.getBitcoindService().getRawTransaction(tx_hash, verbose).toString();
} catch(JsonRpcException e) {
throw new TransactionNotFoundException(e.getErrorMessage());
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("blockchain.transaction.broadcast")
public String broadcastTransaction(@JsonRpcParam("raw_tx") String rawTx) throws BitcoindIOException, BroadcastFailedException {
try {
return bitcoindClient.getBitcoindService().sendRawTransaction(rawTx, 0d);
} catch(JsonRpcException e) {
throw new BroadcastFailedException(e.getErrorMessage());
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
private static class VsizeFeerate implements Comparable<VsizeFeerate> {
private final int vsize;
private final double feerate;
public VsizeFeerate(int vsize, double fee) {
this.vsize = vsize;
this.feerate = fee / vsize * 100000000;
}
@Override
public int compareTo(VsizeFeerate o) {
return Double.compare(o.feerate, feerate);
}
}
}

View file

@ -0,0 +1,87 @@
package com.sparrowwallet.sparrow.net.cormorant.electrum;
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.server.JsonRpcServer;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.BitcoindClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Set;
public class RequestHandler implements Runnable {
private static final Logger log = LoggerFactory.getLogger(RequestHandler.class);
private final Socket clientSocket;
private final ElectrumServerService electrumServerService;
private final JsonRpcServer rpcServer = new JsonRpcServer();
private boolean headersSubscribed;
private final Set<String> scriptHashesSubscribed = new HashSet<>();
public RequestHandler(Socket clientSocket, BitcoindClient bitcoindClient) {
this.clientSocket = clientSocket;
this.electrumServerService = new ElectrumServerService(bitcoindClient, this);
}
public void run() {
Cormorant.getEventBus().register(this);
try {
InputStream input = clientSocket.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
OutputStream output = clientSocket.getOutputStream();
PrintWriter out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)));
while(true) {
String request = reader.readLine();
if(request == null) {
break;
}
String response = rpcServer.handle(request, electrumServerService);
out.println(response);
out.flush();
}
} catch(IOException e) {
log.error("Could not communicate with client socket", e);
}
Cormorant.getEventBus().unregister(this);
}
public void setHeadersSubscribed(boolean headersSubscribed) {
this.headersSubscribed = headersSubscribed;
}
public void subscribeScriptHash(String scriptHash) {
scriptHashesSubscribed.add(scriptHash);
}
public boolean isScriptHashSubscribed(String scriptHash) {
return scriptHashesSubscribed.contains(scriptHash);
}
@Subscribe
public void newBlock(ElectrumBlockHeader electrumBlockHeader) {
if(headersSubscribed) {
ElectrumNotificationTransport electrumNotificationTransport = new ElectrumNotificationTransport(clientSocket);
JsonRpcClient jsonRpcClient = new JsonRpcClient(electrumNotificationTransport);
jsonRpcClient.onDemand(ElectrumNotificationService.class).notifyHeaders(electrumBlockHeader);
}
}
@Subscribe
public void scriptHashStatus(ScriptHashStatus scriptHashStatus) {
if(isScriptHashSubscribed(scriptHashStatus.scriptHash())) {
ElectrumNotificationTransport electrumNotificationTransport = new ElectrumNotificationTransport(clientSocket);
JsonRpcClient jsonRpcClient = new JsonRpcClient(electrumNotificationTransport);
jsonRpcClient.onDemand(ElectrumNotificationService.class).notifyScriptHash(scriptHashStatus.scriptHash(), scriptHashStatus.status());
}
}
}

View file

@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.net.cormorant.electrum;
public record ScriptHashStatus(String scriptHash, String status) {
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,145 @@
package com.sparrowwallet.sparrow.net.cormorant.index;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.Category;
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.ListTransaction;
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.MempoolEntry;
import com.sparrowwallet.drongo.protocol.HashIndex;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.Utils;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class Store {
private final Map<String, Set<TxEntry>> scriptHashEntries = new HashMap<>();
private final Map<HashIndex, Address> fundingAddresses = new HashMap<>();
private final Map<String, Set<HashIndex>> spentOutputs = new HashMap<>();
private final Map<Integer, String> blockHeightHashes = new HashMap<>();
private final Map<String, MempoolEntry> mempoolEntries = new HashMap<>();
public String addAddressTransaction(Address address, ListTransaction listTransaction) {
if(listTransaction.category() == Category.receive) {
fundingAddresses.put(new HashIndex(Sha256Hash.wrap(listTransaction.txid()), listTransaction.vout()), address);
}
blockHeightHashes.put(listTransaction.blockheight(), listTransaction.blockhash());
String scriptHash = getScriptHash(address);
Set<TxEntry> entries = scriptHashEntries.computeIfAbsent(scriptHash, k -> new TreeSet<>());
TxEntry txEntry;
String txid = listTransaction.txid();
if(listTransaction.confirmations() == 0) {
if(!mempoolEntries.containsKey(txid)) {
mempoolEntries.put(txid, null);
}
entries.removeIf(txe -> txe.height > 0 && txe.tx_hash.equals(listTransaction.txid()));
txEntry = new TxEntry(0, 0, listTransaction.txid());
} else {
mempoolEntries.remove(txid);
entries.removeIf(txe -> txe.height != listTransaction.blockheight() && txe.tx_hash.equals(listTransaction.txid()));
txEntry = new TxEntry(listTransaction.blockheight(), listTransaction.blockindex(), listTransaction.txid());
}
if(entries.add(txEntry)) {
return scriptHash;
}
return null;
}
public Set<String> updateMempoolTransactions() {
Set<String> updatedScriptHashes = new HashSet<>();
for(Map.Entry<String, Set<TxEntry>> scriptHashEntry : scriptHashEntries.entrySet()) {
Set<TxEntry> txEntries = scriptHashEntry.getValue();
Set<TxEntry> oldEntries = new HashSet<>();
Set<TxEntry> newEntries = new HashSet<>();
for(TxEntry txEntry : txEntries) {
if(txEntry.height <= 0) {
MempoolEntry mempoolEntry = mempoolEntries.get(txEntry.tx_hash);
TxEntry newEntry = (mempoolEntry == null ? null : mempoolEntry.getTxEntry(txEntry.tx_hash));
if(!txEntry.equals(newEntry)) {
oldEntries.add(txEntry);
if(newEntry != null) {
newEntries.add(newEntry);
}
}
}
}
boolean removed = txEntries.removeAll(oldEntries);
boolean added = txEntries.addAll(newEntries);
if(added || removed) {
updatedScriptHashes.add(scriptHashEntry.getKey());
}
}
return updatedScriptHashes;
}
public Set<String> purgeTransaction(String txid) {
Set<String> updatedScriptHashes = new HashSet<>();
for(Map.Entry<String, Set<TxEntry>> scriptHashEntry : scriptHashEntries.entrySet()) {
Set<TxEntry> txEntries = scriptHashEntry.getValue();
if(txEntries.removeIf(txEntry -> txEntry.tx_hash.equals(txid))) {
updatedScriptHashes.add(scriptHashEntry.getKey());
}
}
Sha256Hash txHash = Sha256Hash.wrap(txid);
fundingAddresses.keySet().removeIf(hashIndex -> hashIndex.getHash().equals(txHash));
spentOutputs.remove(txid);
mempoolEntries.remove(txid);
return updatedScriptHashes;
}
public String getStatus(String scriptHash) {
Set<TxEntry> entries = scriptHashEntries.get(scriptHash);
if(entries == null || entries.isEmpty()) {
return null;
}
StringBuilder scriptHashStatus = new StringBuilder();
for(TxEntry entry : entries) {
scriptHashStatus.append(entry.tx_hash).append(":").append(entry.height).append(":");
}
return Utils.bytesToHex(Sha256Hash.hash(scriptHashStatus.toString().getBytes(StandardCharsets.UTF_8)));
}
public Address getFundingAddress(HashIndex spentOutput) {
return fundingAddresses.get(spentOutput);
}
public Map<String, Set<HashIndex>> getSpentOutputs() {
return spentOutputs;
}
public Map<String, MempoolEntry> getMempoolEntries() {
return mempoolEntries;
}
public Set<TxEntry> getHistory(String scriptHash) {
Set<TxEntry> entries = scriptHashEntries.get(scriptHash);
if(entries == null) {
return Collections.emptySet();
}
return entries;
}
public String getBlockHash(int height) {
return blockHeightHashes.get(height);
}
public static String getScriptHash(Address address) {
byte[] hash = Sha256Hash.hash(address.getOutputScript().getProgram());
byte[] reversed = Utils.reverseBytes(hash);
return Utils.bytesToHex(reversed);
}
}

View file

@ -0,0 +1,67 @@
package com.sparrowwallet.sparrow.net.cormorant.index;
public class TxEntry implements Comparable<TxEntry> {
public final int height;
private final transient int index;
public final String tx_hash;
public TxEntry(int height, int index, String tx_hash) {
this.height = height;
this.index = index;
this.tx_hash = tx_hash;
}
@Override
public boolean equals(Object o) {
if(this == o) {
return true;
}
if(o == null || getClass() != o.getClass()) {
return false;
}
TxEntry txEntry = (TxEntry) o;
if(height != txEntry.height) {
return false;
}
return tx_hash.equals(txEntry.tx_hash);
}
@Override
public int hashCode() {
int result = height;
result = 31 * result + tx_hash.hashCode();
return result;
}
@Override
public int compareTo(TxEntry o) {
if(height <= 0 && o.height > 0) {
return 1;
}
if(height > 0 && o.height <= 0) {
return -1;
}
if(height != o.height) {
return height - o.height;
}
if(height == 0) {
return tx_hash.compareTo(o.tx_hash);
}
return index - o.index;
}
@Override
public String toString() {
return "TxEntry{" +
"height=" + height +
", index=" + index +
", tx_hash='" + tx_hash + '\'' +
'}';
}
}

View file

@ -891,6 +891,18 @@ public class ServerPreferencesController extends PreferencesDetailController {
return serverObservableList; 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 @Subscribe
public void bwtStatus(BwtStatusEvent event) { public void bwtStatus(BwtStatusEvent event) {
if(!(event instanceof BwtSyncStatusEvent)) { if(!(event instanceof BwtSyncStatusEvent)) {

View file

@ -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"));
}
} }

View file

@ -213,6 +213,16 @@ public class ServerTestDialog extends DialogWindow {
testResults.setText("Could not connect:\n\n" + reason); 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 @Subscribe
public void bwtStatus(BwtStatusEvent event) { public void bwtStatus(BwtStatusEvent event) {
if(!(event instanceof BwtSyncStatusEvent)) { if(!(event instanceof BwtSyncStatusEvent)) {

View file

@ -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 @Subscribe
public void bwtSyncStatus(BwtSyncStatusEvent event) { public void bwtSyncStatus(BwtSyncStatusEvent event) {
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus())); walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus()));

View file

@ -569,6 +569,11 @@ public class UtxosController extends WalletFormController implements Initializab
getWalletForm().getWalletUtxosEntry().updateMixProgress(); getWalletForm().getWalletUtxosEntry().updateMixProgress();
} }
@Subscribe
public void cormorantStatus(CormorantStatusEvent event) {
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus()));
}
@Subscribe @Subscribe
public void bwtSyncStatus(BwtSyncStatusEvent event) { public void bwtSyncStatus(BwtSyncStatusEvent event) {
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus())); walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus()));