From 331b83f7a0625336c12209f72bf28abd66551d54 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Sat, 30 May 2020 11:22:51 +0200 Subject: [PATCH] fetch transaction history --- build.gradle | 1 + drongo | 2 +- .../sparrowwallet/sparrow/AppController.java | 11 + .../com/sparrowwallet/sparrow/io/Config.java | 21 +- .../sparrow/io/ElectrumServer.java | 379 ++++++++++++++++++ .../sparrow/io/ServerException.java | 19 + .../com/sparrowwallet/sparrow/io/Storage.java | 31 ++ src/main/java/module-info.java | 4 + 8 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/ServerException.java diff --git a/build.gradle b/build.gradle index 212d8e11..a5cdf285 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation('org.fxmisc.richtext:richtextfx:0.10.4') implementation('no.tornado:tornadofx-controls:1.0.4') implementation('com.google.zxing:javase:3.4.0') + implementation('com.github.arteam:simple-json-rpc-client:1.0') implementation('org.controlsfx:controlsfx:11.0.1' ) { exclude group: 'org.openjfx', module: 'javafx-base' exclude group: 'org.openjfx', module: 'javafx-graphics' diff --git a/drongo b/drongo index 11978e1f..a32410b5 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 11978e1f48851cd964f1c5f52a29a8e2ea432432 +Subproject commit a32410b53831b3e5b111dd8fc82291c7ab30c732 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index f89753ab..a49e3e9a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -382,6 +382,17 @@ public class AppController implements Initializable { WalletForm walletForm = new WalletForm(storage, wallet); controller.setWalletForm(walletForm); + if(wallet.isValid()) { + ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet); + historyService.setOnSucceeded(workerStateEvent -> { + //TODO: Show connected + }); + historyService.setOnFailed(workerStateEvent -> { + //TODO: Show not connected, log exception + }); + historyService.start(); + } + if(!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB)) { Hwi.ScheduledEnumerateService enumerateService = new Hwi.ScheduledEnumerateService(null); enumerateService.setPeriod(new Duration(30 * 1000)); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Config.java b/src/main/java/com/sparrowwallet/sparrow/io/Config.java index d1193dd7..6c7bb2d4 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Config.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Config.java @@ -1,7 +1,6 @@ package com.sparrowwallet.sparrow.io; import com.google.gson.*; -import com.sparrowwallet.drongo.Utils; import java.io.*; import java.lang.reflect.Type; @@ -11,6 +10,8 @@ public class Config { private Integer keyDerivationPeriod; private File hwi; + private String electrumServer; + private File electrumServerCert; private static Config INSTANCE; @@ -71,6 +72,24 @@ public class Config { flush(); } + public String getElectrumServer() { + return electrumServer; + } + + public void setElectrumServer(String electrumServer) { + this.electrumServer = electrumServer; + flush(); + } + + public File getElectrumServerCert() { + return electrumServerCert; + } + + public void setElectrumServerCert(File electrumServerCert) { + this.electrumServerCert = electrumServerCert; + flush(); + } + private void flush() { Gson gson = getGson(); try { diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java new file mode 100644 index 00000000..5d4d49ed --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java @@ -0,0 +1,379 @@ +package com.sparrowwallet.sparrow.io; + +import com.github.arteam.simplejsonrpc.client.*; +import com.github.arteam.simplejsonrpc.client.builder.BatchRequestBuilder; +import com.github.arteam.simplejsonrpc.client.generator.CurrentTimeIdGenerator; +import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod; +import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam; +import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService; +import com.google.common.net.HostAndPort; +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.wallet.TransactionReference; +import com.sparrowwallet.drongo.wallet.Wallet; +import javafx.concurrent.Service; +import javafx.concurrent.Task; +import org.jetbrains.annotations.NotNull; + +import javax.net.SocketFactory; +import javax.net.ssl.*; +import java.io.*; +import java.net.Socket; +import java.security.*; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.*; +import java.util.stream.Collectors; + +public class ElectrumServer { + private static Transport transport; + + private synchronized Transport getTransport() throws ServerException { + if(transport == null) { + try { + String electrumServer = Config.get().getElectrumServer(); + File electrumServerCert = Config.get().getElectrumServerCert(); + + if(electrumServer == null) { + throw new ServerException("Electrum server URL not specified"); + } + + if(electrumServerCert != null && !electrumServerCert.exists()) { + throw new ServerException("Electrum server certificate file not found"); + } + + Protocol protocol = Protocol.getProtocol(electrumServer); + if(protocol == null) { + throw new ServerException("Electrum server URL must start with " + Protocol.TCP.toUrlString() + " or " + Protocol.SSL.toUrlString()); + } + + HostAndPort server = protocol.getServerHostAndPort(electrumServer); + transport = protocol.getTransport(server, electrumServerCert); + } catch (Exception e) { + throw new ServerException(e); + } + } + + return transport; + } + + public String getServerVersion() throws ServerException { + JsonRpcClient client = new JsonRpcClient(getTransport()); + List serverVersion = client.createRequest().returnAsList(String.class).method("server.version").id(1).param("client_name", "Sparrow").param("protocol_version", "1.4").execute(); + return serverVersion.get(1); + } + + public void getHistory(Wallet wallet) throws ServerException { + getHistory(wallet, KeyPurpose.RECEIVE); + getHistory(wallet, KeyPurpose.CHANGE); + } + + public void getHistory(Wallet wallet, KeyPurpose keyPurpose) throws ServerException { + getHistory(wallet.getNode(keyPurpose).getChildren()); + getMempool(wallet.getNode(keyPurpose).getChildren()); + } + + public void getHistory(Collection nodes) throws ServerException { + getReferences("blockchain.scripthash.get_history", nodes); + } + + public void getMempool(Collection nodes) throws ServerException { + getReferences("blockchain.scripthash.get_mempool", nodes); + } + + public void getReferences(String method, Collection nodes) throws ServerException { + try { + JsonRpcClient client = new JsonRpcClient(getTransport()); + BatchRequestBuilder batchRequest = client.createBatchRequest().keysType(String.class).returnType(ScriptHashTx[].class); + for(Wallet.Node node : nodes) { + batchRequest.add(node.getDerivationPath(), method, getScriptHash(node)); + } + Map result = batchRequest.execute(); + + for(String path : result.keySet()) { + ScriptHashTx[] txes = result.get(path); + + Optional optionalNode = nodes.stream().filter(n -> n.getDerivationPath().equals(path)).findFirst(); + if(optionalNode.isPresent()) { + Wallet.Node node = optionalNode.get(); + Set references = Arrays.stream(txes).map(ScriptHashTx::getTransactionReference).collect(Collectors.toSet()); + + for(TransactionReference reference : references) { + if(!node.getHistory().add(reference)) { + Optional optionalReference = node.getHistory().stream().filter(tr -> tr.getTransactionId().equals(reference.getTransactionId())).findFirst(); + if(optionalReference.isPresent()) { + TransactionReference existingReference = optionalReference.get(); + if(existingReference.getHeight() < reference.getHeight()) { + node.getHistory().remove(existingReference); + node.getHistory().add(reference); + } + } + } + } + } + } + } catch (IllegalStateException e) { + throw new ServerException(e.getCause()); + } catch (Exception e) { + throw new ServerException(e); + } + } + + public void getReferencedTransactions(Wallet wallet) throws ServerException { + getReferencedTransactions(wallet, KeyPurpose.RECEIVE); + getReferencedTransactions(wallet, KeyPurpose.CHANGE); + } + + public void getReferencedTransactions(Wallet wallet, KeyPurpose keyPurpose) throws ServerException { + Wallet.Node purposeNode = wallet.getNode(keyPurpose); + Set references = new HashSet<>(); + for(Wallet.Node addressNode : purposeNode.getChildren()) { + references.addAll(addressNode.getHistory()); + } + + Map transactionMap = getTransactions(references); + wallet.getTransactions().putAll(transactionMap); + } + + public Map getTransactions(Set references) throws ServerException { + try { + JsonRpcClient client = new JsonRpcClient(getTransport()); + BatchRequestBuilder batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class); + for(TransactionReference reference : references) { + batchRequest.add(reference.getTransactionId(), "blockchain.transaction.get", reference.getTransactionId()); + } + Map result = batchRequest.execute(); + + Map transactionMap = new HashMap<>(); + for(String txid : result.keySet()) { + byte[] rawtx = Utils.hexToBytes(result.get(txid)); + Transaction transaction = new Transaction(rawtx); + transactionMap.put(txid, transaction); + } + + return transactionMap; + } catch (IllegalStateException e) { + throw new ServerException(e.getCause()); + } catch (Exception e) { + throw new ServerException(e); + } + } + + public void getHistory(Wallet.Node node) throws ServerException { + getHistory(getScriptHash(node)); + } + + public void getHistory(String scriptHash) throws ServerException { + try { + JsonRpcClient client = new JsonRpcClient(getTransport()); + List txList = client.onDemand(ELectrumXService.class).getHistory(scriptHash); + for(ScriptHashTx tx : txList) { + System.out.println(tx); + } + } catch (IllegalStateException e) { + throw new ServerException(e.getCause()); + } catch (Exception e) { + throw new ServerException(e); + } + } + + private String getScriptHash(Wallet.Node node) { + byte[] hash = Sha256Hash.hash(node.getOutputScript().getProgram()); + byte[] reversed = Utils.reverseBytes(hash); + return Utils.bytesToHex(reversed); + } + + private static class ScriptHashTx { + public int height; + public String tx_hash; + public long fee; + + public TransactionReference getTransactionReference() { + return new TransactionReference(tx_hash, height, fee); + } + + @Override + public String toString() { + return "ScriptHashTx{" + + "height=" + height + + ", tx_hash='" + tx_hash + '\'' + + ", fee=" + fee + + '}'; + } + } + + @JsonRpcService + @JsonRpcId(CurrentTimeIdGenerator.class) + @JsonRpcParams(ParamsType.MAP) + private interface ELectrumXService { + + @JsonRpcMethod("blockchain.scripthash.get_history") + List getHistory(@JsonRpcParam("scripthash") String scriptHash); + + } + + private static class TcpTransport implements Transport { + private static final int DEFAULT_PORT = 50001; + + protected final HostAndPort server; + private final SocketFactory socketFactory; + + private Socket socket; + + public TcpTransport(HostAndPort server) { + this.server = server; + this.socketFactory = SocketFactory.getDefault(); + } + + @Override + public @NotNull String pass(@NotNull String request) throws IOException { + if(socket == null) { + socket = createSocket(); + } + + try { + writeRequest(socket, request); + } catch (IOException e) { + socket = createSocket(); + writeRequest(socket, request); + } + + return readResponse(socket); + } + + private void writeRequest(Socket socket, String request) throws IOException { + PrintWriter out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))); + out.println(request); + out.flush(); + } + + private String readResponse(Socket socket) throws IOException { + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); + String response = in.readLine(); + + if(response == null) { + throw new IOException("Could not connect to server at " + Config.get().getElectrumServer()); + } + + return response; + } + + protected Socket createSocket() throws IOException { + return socketFactory.createSocket(server.getHost(), server.getPortOrDefault(DEFAULT_PORT)); + } + } + + private static class TcpOverTlsTransport extends TcpTransport { + private static final int DEFAULT_PORT = 50002; + + private final SSLSocketFactory sslSocketFactory; + + public TcpOverTlsTransport(HostAndPort server) throws NoSuchAlgorithmException, KeyManagementException { + super(server); + + TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + public void checkClientTrusted(X509Certificate[] certs, String authType) {} + public void checkServerTrusted(X509Certificate[] certs, String authType) {} + } + }; + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustAllCerts, new SecureRandom()); + + this.sslSocketFactory = sslContext.getSocketFactory(); + } + + public TcpOverTlsTransport(HostAndPort server, File crtFile) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + super(server); + + Certificate certificate = CertificateFactory.getInstance("X.509").generateCertificate(new FileInputStream(crtFile)); + + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + keyStore.setCertificateEntry("electrumx", certificate); + + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keyStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagerFactory.getTrustManagers(), null); + + sslSocketFactory = sslContext.getSocketFactory(); + } + + protected Socket createSocket() throws IOException { + SSLSocket sslSocket = (SSLSocket)sslSocketFactory.createSocket(server.getHost(), server.getPortOrDefault(DEFAULT_PORT)); + sslSocket.startHandshake(); + + return sslSocket; + } + } + + public static class TransactionHistoryService extends Service { + private final Wallet wallet; + + public TransactionHistoryService(Wallet wallet) { + this.wallet = wallet; + } + + @Override + protected Task createTask() { + return new Task<>() { + protected Boolean call() throws ServerException { + ElectrumServer electrumServer = new ElectrumServer(); + electrumServer.getHistory(wallet); + electrumServer.getReferencedTransactions(wallet); + return true; + } + }; + } + } + + public enum Protocol { + TCP { + @Override + public Transport getTransport(HostAndPort server, File serverCert) throws IOException { + return new TcpTransport(server); + } + }, + SSL{ + @Override + public Transport getTransport(HostAndPort server, File serverCert) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + if(serverCert != null && serverCert.exists()) { + return new TcpOverTlsTransport(server, serverCert); + } else { + return new TcpOverTlsTransport(server); + } + } + }; + + public abstract Transport getTransport(HostAndPort server, File serverCert) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException; + + public HostAndPort getServerHostAndPort(String url) { + return HostAndPort.fromString(url.substring(this.toUrlString().length())); + } + + public String toUrlString() { + return toString().toLowerCase() + "://"; + } + + public static Protocol getProtocol(String url) { + if(url.startsWith("tcp://")) { + return TCP; + } + if(url.startsWith("ssl://")) { + return SSL; + } + + return null; + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ServerException.java b/src/main/java/com/sparrowwallet/sparrow/io/ServerException.java new file mode 100644 index 00000000..5cf459e3 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ServerException.java @@ -0,0 +1,19 @@ +package com.sparrowwallet.sparrow.io; + +public class ServerException extends Exception { + public ServerException() { + super(); + } + + public ServerException(String message) { + super(message); + } + + public ServerException(Throwable cause) { + super(cause); + } + + public ServerException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java index b3bde7d8..b3f19e06 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java @@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.ExtendedKey; import com.sparrowwallet.drongo.SecureString; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.crypto.*; +import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.MnemonicException; import com.sparrowwallet.drongo.wallet.Wallet; @@ -57,6 +58,8 @@ public class Storage { gsonBuilder.registerTypeAdapter(ExtendedKey.class, new ExtendedPublicKeyDeserializer()); gsonBuilder.registerTypeAdapter(byte[].class, new ByteArraySerializer()); gsonBuilder.registerTypeAdapter(byte[].class, new ByteArrayDeserializer()); + gsonBuilder.registerTypeAdapter(Transaction.class, new TransactionSerializer()); + gsonBuilder.registerTypeAdapter(Transaction.class, new TransactionDeserializer()); if(includeWalletSerializers) { gsonBuilder.registerTypeAdapter(Keystore.class, new KeystoreSerializer()); gsonBuilder.registerTypeAdapter(Wallet.Node.class, new NodeSerializer()); @@ -267,6 +270,27 @@ public class Storage { } } + private static class TransactionSerializer implements JsonSerializer { + @Override + public JsonElement serialize(Transaction src, Type typeOfSrc, JsonSerializationContext context) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + src.bitcoinSerializeToStream(baos); + return new JsonPrimitive(Utils.bytesToHex(baos.toByteArray())); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + } + + private static class TransactionDeserializer implements JsonDeserializer { + @Override + public Transaction deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + byte[] rawTx = Utils.hexToBytes(json.getAsJsonPrimitive().getAsString()); + return new Transaction(rawTx); + } + } + private static class KeystoreSerializer implements JsonSerializer { @Override public JsonElement serialize(Keystore keystore, Type typeOfSrc, JsonSerializationContext context) { @@ -296,6 +320,10 @@ public class Storage { if(childObject.get("children") != null && childObject.getAsJsonArray("children").size() == 0) { childObject.remove("children"); } + + if(childObject.get("history") != null && childObject.getAsJsonArray("history").size() == 0) { + childObject.remove("history"); + } } return jsonObject; @@ -310,6 +338,9 @@ public class Storage { if(childNode.getChildren() == null) { childNode.setChildren(new TreeSet<>()); } + if(childNode.getHistory() == null) { + childNode.setHistory(new TreeSet<>()); + } } return node; diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 6ab058bd..1e53b10d 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -12,5 +12,9 @@ open module com.sparrowwallet.sparrow { requires com.google.gson; requires com.google.zxing; requires com.google.zxing.javase; + requires simple.json.rpc.client; + requires simple.json.rpc.core; + requires org.jetbrains.annotations; + requires com.fasterxml.jackson.databind; requires javafx.swing; } \ No newline at end of file