diff --git a/src/main/java/org/qortal/crosschain/Bitcoin.java b/src/main/java/org/qortal/crosschain/Bitcoin.java index 3276a24b..02eb03d9 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoin.java +++ b/src/main/java/org/qortal/crosschain/Bitcoin.java @@ -171,6 +171,8 @@ public class Bitcoin extends Bitcoiny { Context bitcoinjContext = new Context(bitcoinNet.getParams()); instance = new Bitcoin(bitcoinNet, electrumX, bitcoinjContext, CURRENCY_CODE); + + electrumX.setBlockchain(instance); } return instance; diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index f66ea939..202ebb35 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -42,7 +42,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { public static final int HASH160_LENGTH = 20; - protected final BitcoinyBlockchainProvider blockchain; + protected final BitcoinyBlockchainProvider blockchainProvider; protected final Context bitcoinjContext; protected final String currencyCode; @@ -71,8 +71,8 @@ public abstract class Bitcoiny implements ForeignBlockchain { // Constructors and instance - protected Bitcoiny(BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) { - this.blockchain = blockchain; + protected Bitcoiny(BitcoinyBlockchainProvider blockchainProvider, Context bitcoinjContext, String currencyCode) { + this.blockchainProvider = blockchainProvider; this.bitcoinjContext = bitcoinjContext; this.currencyCode = currencyCode; @@ -82,7 +82,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { // Getters & setters public BitcoinyBlockchainProvider getBlockchainProvider() { - return this.blockchain; + return this.blockchainProvider; } public Context getBitcoinjContext() { @@ -155,10 +155,10 @@ public abstract class Bitcoiny implements ForeignBlockchain { * @throws ForeignBlockchainException if error occurs */ public int getMedianBlockTime() throws ForeignBlockchainException { - int height = this.blockchain.getCurrentHeight(); + int height = this.blockchainProvider.getCurrentHeight(); // Grab latest 11 blocks - List blockHeaders = this.blockchain.getRawBlockHeaders(height - 11, 11); + List blockHeaders = this.blockchainProvider.getRawBlockHeaders(height - 11, 11); if (blockHeaders.size() < 11) throw new ForeignBlockchainException("Not enough blocks to determine median block time"); @@ -197,7 +197,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { * @throws ForeignBlockchainException if there was an error */ public long getConfirmedBalance(String base58Address) throws ForeignBlockchainException { - return this.blockchain.getConfirmedBalance(addressToScriptPubKey(base58Address)); + return this.blockchainProvider.getConfirmedBalance(addressToScriptPubKey(base58Address)); } /** @@ -208,7 +208,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { */ // TODO: don't return bitcoinj-based objects like TransactionOutput, use BitcoinyTransaction.Output instead public List getUnspentOutputs(String base58Address) throws ForeignBlockchainException { - List unspentOutputs = this.blockchain.getUnspentOutputs(addressToScriptPubKey(base58Address), false); + List unspentOutputs = this.blockchainProvider.getUnspentOutputs(addressToScriptPubKey(base58Address), false); List unspentTransactionOutputs = new ArrayList<>(); for (UnspentOutput unspentOutput : unspentOutputs) { @@ -228,7 +228,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { */ // TODO: don't return bitcoinj-based objects like TransactionOutput, use BitcoinyTransaction.Output instead public List getOutputs(byte[] txHash) throws ForeignBlockchainException { - byte[] rawTransactionBytes = this.blockchain.getRawTransaction(txHash); + byte[] rawTransactionBytes = this.blockchainProvider.getRawTransaction(txHash); Context.propagate(bitcoinjContext); Transaction transaction = new Transaction(this.params, rawTransactionBytes); @@ -245,7 +245,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { ForeignBlockchainException e2 = null; while (retries <= 3) { try { - return this.blockchain.getAddressTransactions(scriptPubKey, includeUnconfirmed); + return this.blockchainProvider.getAddressTransactions(scriptPubKey, includeUnconfirmed); } catch (ForeignBlockchainException e) { e2 = e; retries++; @@ -261,7 +261,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { * @throws ForeignBlockchainException if there was an error. */ public List getAddressTransactions(String base58Address, boolean includeUnconfirmed) throws ForeignBlockchainException { - return this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), includeUnconfirmed); + return this.blockchainProvider.getAddressTransactions(addressToScriptPubKey(base58Address), includeUnconfirmed); } /** @@ -270,11 +270,11 @@ public abstract class Bitcoiny implements ForeignBlockchain { * @throws ForeignBlockchainException if there was an error */ public List getAddressTransactions(String base58Address) throws ForeignBlockchainException { - List transactionHashes = this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), false); + List transactionHashes = this.blockchainProvider.getAddressTransactions(addressToScriptPubKey(base58Address), false); List rawTransactions = new ArrayList<>(); for (TransactionHash transactionInfo : transactionHashes) { - byte[] rawTransaction = this.blockchain.getRawTransaction(HashCode.fromString(transactionInfo.txHash).asBytes()); + byte[] rawTransaction = this.blockchainProvider.getRawTransaction(HashCode.fromString(transactionInfo.txHash).asBytes()); rawTransactions.add(rawTransaction); } @@ -292,7 +292,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { ForeignBlockchainException e2 = null; while (retries <= 3) { try { - return this.blockchain.getTransaction(txHash); + return this.blockchainProvider.getTransaction(txHash); } catch (ForeignBlockchainException e) { e2 = e; retries++; @@ -307,7 +307,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { * @throws ForeignBlockchainException if error occurs */ public void broadcastTransaction(Transaction transaction) throws ForeignBlockchainException { - this.blockchain.broadcastTransaction(transaction.bitcoinSerialize()); + this.blockchainProvider.broadcastTransaction(transaction.bitcoinSerialize()); } /** @@ -360,17 +360,20 @@ public abstract class Bitcoiny implements ForeignBlockchain { * @param key58 BIP32/HD extended Bitcoin private/public key * @return unspent BTC balance, or null if unable to determine balance */ - public Long getWalletBalance(String key58) { - Context.propagate(bitcoinjContext); - - Wallet wallet = walletFromDeterministicKey58(key58); - wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); - - Coin balance = wallet.getBalance(); - if (balance == null) - return null; - - return balance.value; + public Long getWalletBalance(String key58) throws ForeignBlockchainException { + // It's more accurate to calculate the balance from the transactions, rather than asking Bitcoinj + return this.getWalletBalanceFromTransactions(key58); + +// Context.propagate(bitcoinjContext); +// +// Wallet wallet = walletFromDeterministicKey58(key58); +// wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); +// +// Coin balance = wallet.getBalance(); +// if (balance == null) +// return null; +// +// return balance.value; } public Long getWalletBalanceFromTransactions(String key58) throws ForeignBlockchainException { @@ -573,7 +576,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); - List unspentOutputs = this.blockchain.getUnspentOutputs(script, false); + List unspentOutputs = this.blockchainProvider.getUnspentOutputs(script, false); /* * If there are no unspent outputs then either: @@ -591,7 +594,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { } // Ask for transaction history - if it's empty then key has never been used - List historicTransactionHashes = this.blockchain.getAddressTransactions(script, false); + List historicTransactionHashes = this.blockchainProvider.getAddressTransactions(script, false); if (!historicTransactionHashes.isEmpty()) { // Fully spent key - case (a) @@ -650,7 +653,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { List unspentOutputs; try { - unspentOutputs = this.bitcoiny.blockchain.getUnspentOutputs(script, false); + unspentOutputs = this.bitcoiny.blockchainProvider.getUnspentOutputs(script, false); } catch (ForeignBlockchainException e) { throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address)); } @@ -674,7 +677,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { // Ask for transaction history - if it's empty then key has never been used List historicTransactionHashes; try { - historicTransactionHashes = this.bitcoiny.blockchain.getAddressTransactions(script, false); + historicTransactionHashes = this.bitcoiny.blockchainProvider.getAddressTransactions(script, false); } catch (ForeignBlockchainException e) { throw new UTXOProviderException(String.format("Unable to fetch transaction history for %s", address)); } @@ -727,7 +730,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { @Override public int getChainHeadHeight() throws UTXOProviderException { try { - return this.bitcoiny.blockchain.getCurrentHeight(); + return this.bitcoiny.blockchainProvider.getCurrentHeight(); } catch (ForeignBlockchainException e) { throw new UTXOProviderException("Unable to determine Bitcoiny chain height"); } diff --git a/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java b/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java index 7691efb1..1449db3e 100644 --- a/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java +++ b/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java @@ -1,5 +1,7 @@ package org.qortal.crosschain; +import cash.z.wallet.sdk.rpc.CompactFormats.*; + import java.util.List; public abstract class BitcoinyBlockchainProvider { @@ -7,18 +9,32 @@ public abstract class BitcoinyBlockchainProvider { public static final boolean INCLUDE_UNCONFIRMED = true; public static final boolean EXCLUDE_UNCONFIRMED = false; + /** Sets the blockchain using this provider instance */ + public abstract void setBlockchain(Bitcoiny blockchain); + /** Returns ID unique to bitcoiny network (e.g. "Litecoin-TEST3") */ public abstract String getNetId(); /** Returns current blockchain height. */ public abstract int getCurrentHeight() throws ForeignBlockchainException; + /** Returns a list of compact blocks, starting at startHeight (inclusive), up to count max. + * Used for Pirate/Zcash only. If ever needed for other blockchains, the response format will need to be + * made generic. */ + public abstract List getCompactBlocks(int startHeight, int count) throws ForeignBlockchainException; + /** Returns a list of raw block headers, starting at startHeight (inclusive), up to count max. */ public abstract List getRawBlockHeaders(int startHeight, int count) throws ForeignBlockchainException; + /** Returns a list of block timestamps, starting at startHeight (inclusive), up to count max. */ + public abstract List getBlockTimestamps(int startHeight, int count) throws ForeignBlockchainException; + /** Returns balance of address represented by scriptPubKey. */ public abstract long getConfirmedBalance(byte[] scriptPubKey) throws ForeignBlockchainException; + /** Returns balance of base58 encoded address. */ + public abstract long getConfirmedAddressBalance(String base58Address) throws ForeignBlockchainException; + /** Returns raw, serialized, transaction bytes given txHash. */ public abstract byte[] getRawTransaction(String txHash) throws ForeignBlockchainException; @@ -31,6 +47,9 @@ public abstract class BitcoinyBlockchainProvider { /** Returns list of transaction hashes (and heights) for address represented by scriptPubKey, optionally including unconfirmed transactions. */ public abstract List getAddressTransactions(byte[] scriptPubKey, boolean includeUnconfirmed) throws ForeignBlockchainException; + /** Returns list of unspent transaction outputs for address, optionally including unconfirmed transactions. */ + public abstract List getUnspentOutputs(String address, boolean includeUnconfirmed) throws ForeignBlockchainException; + /** Returns list of unspent transaction outputs for address represented by scriptPubKey, optionally including unconfirmed transactions. */ public abstract List getUnspentOutputs(byte[] scriptPubKey, boolean includeUnconfirmed) throws ForeignBlockchainException; diff --git a/src/main/java/org/qortal/crosschain/Dogecoin.java b/src/main/java/org/qortal/crosschain/Dogecoin.java index 219c762c..d6955e18 100644 --- a/src/main/java/org/qortal/crosschain/Dogecoin.java +++ b/src/main/java/org/qortal/crosschain/Dogecoin.java @@ -135,6 +135,8 @@ public class Dogecoin extends Bitcoiny { Context bitcoinjContext = new Context(dogecoinNet.getParams()); instance = new Dogecoin(dogecoinNet, electrumX, bitcoinjContext, CURRENCY_CODE); + + electrumX.setBlockchain(instance); } return instance; diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index 6d6cfb15..3c867ae8 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -11,6 +11,7 @@ import java.util.regex.Pattern; import javax.net.ssl.SSLSocketFactory; +import cash.z.wallet.sdk.rpc.CompactFormats.*; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.json.simple.JSONArray; @@ -107,6 +108,7 @@ public class ElectrumX extends BitcoinyBlockchainProvider { private final String netId; private final String expectedGenesisHash; private final Map defaultPorts = new EnumMap<>(Server.ConnectionType.class); + private Bitcoiny blockchain; private final Object serverLock = new Object(); private Server currentServer; @@ -135,6 +137,11 @@ public class ElectrumX extends BitcoinyBlockchainProvider { // Methods for use by other classes + @Override + public void setBlockchain(Bitcoiny blockchain) { + this.blockchain = blockchain; + } + @Override public String getNetId() { return this.netId; @@ -161,6 +168,16 @@ public class ElectrumX extends BitcoinyBlockchainProvider { return ((Long) heightObj).intValue(); } + /** + * Returns list of raw blocks, starting from startHeight inclusive. + *

+ * @throws ForeignBlockchainException if error occurs + */ + @Override + public List getCompactBlocks(int startHeight, int count) throws ForeignBlockchainException { + throw new ForeignBlockchainException("getCompactBlocks not implemented for ElectrumX due to being specific to zcash"); + } + /** * Returns list of raw block headers, starting from startHeight inclusive. *

@@ -222,6 +239,17 @@ public class ElectrumX extends BitcoinyBlockchainProvider { return rawBlockHeaders; } + /** + * Returns list of raw block timestamps, starting from startHeight inclusive. + *

+ * @throws ForeignBlockchainException if error occurs + */ + @Override + public List getBlockTimestamps(int startHeight, int count) throws ForeignBlockchainException { + // FUTURE: implement this if needed. For now we use getRawBlockHeaders directly + throw new ForeignBlockchainException("getBlockTimestamps not yet implemented for ElectrumX"); + } + /** * Returns confirmed balance, based on passed payment script. *

@@ -247,6 +275,29 @@ public class ElectrumX extends BitcoinyBlockchainProvider { return (Long) balanceJson.get("confirmed"); } + /** + * Returns confirmed balance, based on passed base58 encoded address. + *

+ * @return confirmed balance, or zero if address unknown + * @throws ForeignBlockchainException if there was an error + */ + @Override + public long getConfirmedAddressBalance(String base58Address) throws ForeignBlockchainException { + throw new ForeignBlockchainException("getConfirmedAddressBalance not yet implemented for ElectrumX"); + } + + /** + * Returns list of unspent outputs pertaining to passed address. + *

+ * @return list of unspent outputs, or empty list if address unknown + * @throws ForeignBlockchainException if there was an error. + */ + @Override + public List getUnspentOutputs(String address, boolean includeUnconfirmed) throws ForeignBlockchainException { + byte[] script = this.blockchain.addressToScriptPubKey(address); + return this.getUnspentOutputs(script, includeUnconfirmed); + } + /** * Returns list of unspent outputs pertaining to passed payment script. *

diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java index 02dd466f..3ab30b2b 100644 --- a/src/main/java/org/qortal/crosschain/Litecoin.java +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -145,6 +145,8 @@ public class Litecoin extends Bitcoiny { Context bitcoinjContext = new Context(litecoinNet.getParams()); instance = new Litecoin(litecoinNet, electrumX, bitcoinjContext, CURRENCY_CODE); + + electrumX.setBlockchain(instance); } return instance; diff --git a/src/main/java/org/qortal/crosschain/PirateChain.java b/src/main/java/org/qortal/crosschain/PirateChain.java new file mode 100644 index 00000000..19617a8f --- /dev/null +++ b/src/main/java/org/qortal/crosschain/PirateChain.java @@ -0,0 +1,209 @@ +package org.qortal.crosschain; + +import cash.z.wallet.sdk.rpc.CompactFormats; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.Context; +import org.bitcoinj.core.NetworkParameters; +import org.libdohj.params.LitecoinMainNetParams; +import org.libdohj.params.LitecoinRegTestParams; +import org.libdohj.params.LitecoinTestNet3Params; +import org.qortal.crosschain.PirateLightClient.Server; +import org.qortal.crosschain.PirateLightClient.Server.ConnectionType; +import org.qortal.settings.Settings; + +import java.util.*; + +public class PirateChain extends Bitcoiny { + + public static final String CURRENCY_CODE = "ARRR"; + + private static final Coin DEFAULT_FEE_PER_KB = Coin.valueOf(10000); // 0.0001 ARRR per 1000 bytes + + private static final long MINIMUM_ORDER_AMOUNT = 50000000; // 0.5 ARRR minimum order, to avoid dust errors // TODO: may need calibration + + // Temporary values until a dynamic fee system is written. + private static final long MAINNET_FEE = 10000L; // 0.0001 ARRR + private static final long NON_MAINNET_FEE = 10000L; // 0.0001 ARRR + + private static final Map DEFAULT_LITEWALLET_PORTS = new EnumMap<>(ConnectionType.class); + static { + DEFAULT_LITEWALLET_PORTS.put(ConnectionType.TCP, 9067); + DEFAULT_LITEWALLET_PORTS.put(ConnectionType.SSL, 443); + } + + public enum PirateChainNet { + MAIN { + @Override + public NetworkParameters getParams() { + return LitecoinMainNetParams.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + // Servers chosen on NO BASIS WHATSOEVER from various sources! + new Server("lightd.pirate.black", ConnectionType.SSL, 443)); + } + + @Override + public String getGenesisHash() { + return "027e3758c3a65b12aa1046462b486d0a63bfa1beae327897f56c5cfb7daaae71"; + } + + @Override + public long getP2shFee(Long timestamp) { + // TODO: This will need to be replaced with something better in the near future! + return MAINNET_FEE; + } + }, + TEST3 { + @Override + public NetworkParameters getParams() { + return LitecoinTestNet3Params.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList(); + } + + @Override + public String getGenesisHash() { + return "4966625a4b2851d9fdee139e56211a0d88575f59ed816ff5e6a63deb4e3e29a0"; + } + + @Override + public long getP2shFee(Long timestamp) { + return NON_MAINNET_FEE; + } + }, + REGTEST { + @Override + public NetworkParameters getParams() { + return LitecoinRegTestParams.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + new Server("localhost", ConnectionType.TCP, 9067), + new Server("localhost", ConnectionType.SSL, 443)); + } + + @Override + public String getGenesisHash() { + // This is unique to each regtest instance + return null; + } + + @Override + public long getP2shFee(Long timestamp) { + return NON_MAINNET_FEE; + } + }; + + public abstract NetworkParameters getParams(); + public abstract Collection getServers(); + public abstract String getGenesisHash(); + public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException; + } + + private static PirateChain instance; + + private final PirateChainNet pirateChainNet; + + // Constructors and instance + + private PirateChain(PirateChainNet pirateChainNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) { + super(blockchain, bitcoinjContext, currencyCode); + this.pirateChainNet = pirateChainNet; + + LOGGER.info(() -> String.format("Starting Pirate Chain support using %s", this.pirateChainNet.name())); + } + + public static synchronized PirateChain getInstance() { + if (instance == null) { + PirateChainNet pirateChainNet = Settings.getInstance().getPirateChainNet(); + + BitcoinyBlockchainProvider pirateLightClient = new PirateLightClient("PirateChain-" + pirateChainNet.name(), pirateChainNet.getGenesisHash(), pirateChainNet.getServers(), DEFAULT_LITEWALLET_PORTS); + Context bitcoinjContext = new Context(pirateChainNet.getParams()); + + instance = new PirateChain(pirateChainNet, pirateLightClient, bitcoinjContext, CURRENCY_CODE); + + pirateLightClient.setBlockchain(instance); + } + + return instance; + } + + // Getters & setters + + public static synchronized void resetForTesting() { + instance = null; + } + + // Actual useful methods for use by other classes + + /** Default Litecoin fee is lower than Bitcoin: only 10sats/byte. */ + @Override + public Coin getFeePerKb() { + return DEFAULT_FEE_PER_KB; + } + + @Override + public long getMinimumOrderAmount() { + return MINIMUM_ORDER_AMOUNT; + } + + /** + * Returns estimated LTC fee, in sats per 1000bytes, optionally for historic timestamp. + * + * @param timestamp optional milliseconds since epoch, or null for 'now' + * @return sats per 1000bytes, or throws ForeignBlockchainException if something went wrong + */ + @Override + public long getP2shFee(Long timestamp) throws ForeignBlockchainException { + return this.pirateChainNet.getP2shFee(timestamp); + } + + /** + * Returns confirmed balance, based on passed payment script. + *

+ * @return confirmed balance, or zero if balance unknown + * @throws ForeignBlockchainException if there was an error + */ + public long getConfirmedBalance(String base58Address) throws ForeignBlockchainException { + return this.blockchainProvider.getConfirmedAddressBalance(base58Address); + } + + /** + * Returns median timestamp from latest 11 blocks, in seconds. + *

+ * @throws ForeignBlockchainException if error occurs + */ + @Override + public int getMedianBlockTime() throws ForeignBlockchainException { + int height = this.blockchainProvider.getCurrentHeight(); + + // Grab latest 11 blocks + List blockTimestamps = this.blockchainProvider.getBlockTimestamps(height - 11, 11); + if (blockTimestamps.size() < 11) + throw new ForeignBlockchainException("Not enough blocks to determine median block time"); + + // Descending order + blockTimestamps.sort((a, b) -> Long.compare(b, a)); + + // Pick median + return Math.toIntExact(blockTimestamps.get(5)); + } + + /** + * Returns list of compact blocks + *

+ * @throws ForeignBlockchainException if error occurs + */ + public List getCompactBlocks(int startHeight, int count) throws ForeignBlockchainException { + return this.blockchainProvider.getCompactBlocks(startHeight, count); + } + +} diff --git a/src/main/java/org/qortal/crosschain/PirateLightClient.java b/src/main/java/org/qortal/crosschain/PirateLightClient.java new file mode 100644 index 00000000..7306d19e --- /dev/null +++ b/src/main/java/org/qortal/crosschain/PirateLightClient.java @@ -0,0 +1,589 @@ +package org.qortal.crosschain; + +import cash.z.wallet.sdk.rpc.CompactFormats.*; +import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc; +import cash.z.wallet.sdk.rpc.Service.*; +import com.google.common.hash.HashCode; +import com.google.protobuf.ByteString; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; + +import java.math.BigDecimal; +import java.util.*; +import java.util.regex.Pattern; + +/** Pirate Chain network support for querying Bitcoiny-related info like block headers, transaction outputs, etc. */ +public class PirateLightClient extends BitcoinyBlockchainProvider { + + private static final Logger LOGGER = LogManager.getLogger(PirateLightClient.class); + private static final Random RANDOM = new Random(); + + private static final double MIN_PROTOCOL_VERSION = 1.2; + private static final int BLOCK_HEADER_LENGTH = 80; + + // "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})" + private static final Pattern DAEMON_ERROR_REGEX = Pattern.compile("DaemonError\\(\\{.*'code': ?(-?[0-9]+).*\\}\\)\\z"); // Capture 'code' inside curly-brace content + + /** Error message sent by some ElectrumX servers when they don't support returning verbose transactions. */ + private static final String VERBOSE_TRANSACTIONS_UNSUPPORTED_MESSAGE = "verbose transactions are currently unsupported"; + + private static final int RESPONSE_TIME_READINGS = 5; + private static final long MAX_AVG_RESPONSE_TIME = 500L; // ms + + public static class Server { + String hostname; + + public enum ConnectionType { TCP, SSL } + ConnectionType connectionType; + + int port; + private List responseTimes = new ArrayList<>(); + + public Server(String hostname, ConnectionType connectionType, int port) { + this.hostname = hostname; + this.connectionType = connectionType; + this.port = port; + } + + public void addResponseTime(long responseTime) { + while (this.responseTimes.size() > RESPONSE_TIME_READINGS) { + this.responseTimes.remove(0); + } + this.responseTimes.add(responseTime); + } + + public long averageResponseTime() { + if (this.responseTimes.size() < RESPONSE_TIME_READINGS) { + // Not enough readings yet + return 0L; + } + OptionalDouble average = this.responseTimes.stream().mapToDouble(a -> a).average(); + if (average.isPresent()) { + return Double.valueOf(average.getAsDouble()).longValue(); + } + return 0L; + } + + @Override + public boolean equals(Object other) { + if (other == this) + return true; + + if (!(other instanceof Server)) + return false; + + Server otherServer = (Server) other; + + return this.connectionType == otherServer.connectionType + && this.port == otherServer.port + && this.hostname.equals(otherServer.hostname); + } + + @Override + public int hashCode() { + return this.hostname.hashCode() ^ this.port; + } + + @Override + public String toString() { + return String.format("%s:%s:%d", this.connectionType.name(), this.hostname, this.port); + } + } + private Set servers = new HashSet<>(); + private List remainingServers = new ArrayList<>(); + private Set uselessServers = Collections.synchronizedSet(new HashSet<>()); + + private final String netId; + private final String expectedGenesisHash; + private final Map defaultPorts = new EnumMap<>(Server.ConnectionType.class); + private Bitcoiny blockchain; + + private final Object serverLock = new Object(); + private Server currentServer; + private ManagedChannel channel; + private int nextId = 1; + + private static final int TX_CACHE_SIZE = 1000; + @SuppressWarnings("serial") + private final Map transactionCache = Collections.synchronizedMap(new LinkedHashMap<>(TX_CACHE_SIZE + 1, 0.75F, true) { + // This method is called just after a new entry has been added + @Override + public boolean removeEldestEntry(Map.Entry eldest) { + return size() > TX_CACHE_SIZE; + } + }); + + // Constructors + + public PirateLightClient(String netId, String genesisHash, Collection initialServerList, Map defaultPorts) { + this.netId = netId; + this.expectedGenesisHash = genesisHash; + this.servers.addAll(initialServerList); + this.defaultPorts.putAll(defaultPorts); + } + + // Methods for use by other classes + + @Override + public void setBlockchain(Bitcoiny blockchain) { + this.blockchain = blockchain; + } + + @Override + public String getNetId() { + return this.netId; + } + + /** + * Returns current blockchain height. + *

+ * @throws ForeignBlockchainException if error occurs + */ + @Override + public int getCurrentHeight() throws ForeignBlockchainException { + BlockID latestBlock = this.getCompactTxStreamerStub().getLatestBlock(null); + + if (!(latestBlock instanceof BlockID)) + throw new ForeignBlockchainException.NetworkException("Unexpected output from Pirate Chain getLatestBlock gRPC"); + + return (int)latestBlock.getHeight(); + } + + /** + * Returns list of compact blocks, starting from startHeight inclusive. + *

+ * @throws ForeignBlockchainException if error occurs + * @return + */ + @Override + public List getCompactBlocks(int startHeight, int count) throws ForeignBlockchainException { + BlockID startBlock = BlockID.newBuilder().setHeight(startHeight).build(); + BlockID endBlock = BlockID.newBuilder().setHeight(startHeight + count - 1).build(); + BlockRange range = BlockRange.newBuilder().setStart(startBlock).setEnd(endBlock).build(); + + Iterator blocksIterator = this.getCompactTxStreamerStub().getBlockRange(range); + + // Map from Iterator to List + List blocks = new ArrayList<>(); + blocksIterator.forEachRemaining(blocks::add); + + return blocks; + } + + /** + * Returns list of raw block headers, starting from startHeight inclusive. + *

+ * @throws ForeignBlockchainException if error occurs + */ + @Override + public List getRawBlockHeaders(int startHeight, int count) throws ForeignBlockchainException { + BlockID startBlock = BlockID.newBuilder().setHeight(startHeight).build(); + BlockID endBlock = BlockID.newBuilder().setHeight(startHeight + count - 1).build(); + BlockRange range = BlockRange.newBuilder().setStart(startBlock).setEnd(endBlock).build(); + + Iterator blocks = this.getCompactTxStreamerStub().getBlockRange(range); + + List rawBlockHeaders = new ArrayList<>(); + + while (blocks.hasNext()) { + CompactBlock block = blocks.next(); + + if (block.getHeader() == null) { + throw new ForeignBlockchainException.NetworkException("Unexpected output from Pirate Chain getBlockRange gRPC"); + } + + rawBlockHeaders.add(block.getHeader().toByteArray()); + } + + return rawBlockHeaders; + } + + /** + * Returns list of raw block timestamps, starting from startHeight inclusive. + *

+ * @throws ForeignBlockchainException if error occurs + */ + @Override + public List getBlockTimestamps(int startHeight, int count) throws ForeignBlockchainException { + BlockID startBlock = BlockID.newBuilder().setHeight(startHeight).build(); + BlockID endBlock = BlockID.newBuilder().setHeight(startHeight + count - 1).build(); + BlockRange range = BlockRange.newBuilder().setStart(startBlock).setEnd(endBlock).build(); + + Iterator blocks = this.getCompactTxStreamerStub().getBlockRange(range); + + List rawBlockTimestamps = new ArrayList<>(); + + while (blocks.hasNext()) { + CompactBlock block = blocks.next(); + + if (block.getTime() <= 0) { + throw new ForeignBlockchainException.NetworkException("Unexpected output from Pirate Chain getBlockRange gRPC"); + } + + rawBlockTimestamps.add(Long.valueOf(block.getTime())); + } + + return rawBlockTimestamps; + } + + /** + * Returns confirmed balance, based on passed payment script. + *

+ * @return confirmed balance, or zero if script unknown + * @throws ForeignBlockchainException if there was an error + */ + @Override + public long getConfirmedBalance(byte[] script) throws ForeignBlockchainException { + throw new ForeignBlockchainException("getConfirmedBalance not yet implemented for Pirate Chain"); + } + + /** + * Returns confirmed balance, based on passed base58 encoded address. + *

+ * @return confirmed balance, or zero if address unknown + * @throws ForeignBlockchainException if there was an error + */ + @Override + public long getConfirmedAddressBalance(String base58Address) throws ForeignBlockchainException { + AddressList addressList = AddressList.newBuilder().addAddresses(base58Address).build(); + Balance balance = this.getCompactTxStreamerStub().getTaddressBalance(addressList); + + if (!(balance instanceof Balance)) + throw new ForeignBlockchainException.NetworkException("Unexpected output from Pirate Chain getConfirmedAddressBalance gRPC"); + + return balance.getValueZat(); + } + + /** + * Returns list of unspent outputs pertaining to passed address. + *

+ * @return list of unspent outputs, or empty list if address unknown + * @throws ForeignBlockchainException if there was an error. + */ + @Override + public List getUnspentOutputs(String address, boolean includeUnconfirmed) throws ForeignBlockchainException { + GetAddressUtxosArg getAddressUtxosArg = GetAddressUtxosArg.newBuilder().addAddresses(address).build(); + GetAddressUtxosReplyList replyList = this.getCompactTxStreamerStub().getAddressUtxos(getAddressUtxosArg); + + if (!(replyList instanceof GetAddressUtxosReplyList)) + throw new ForeignBlockchainException.NetworkException("Unexpected output from Pirate Chain getUnspentOutputs gRPC"); + + List unspentList = replyList.getAddressUtxosList(); + if (unspentList == null) + throw new ForeignBlockchainException.NetworkException("Unexpected output from Pirate Chain getUnspentOutputs gRPC"); + + List unspentOutputs = new ArrayList<>(); + for (GetAddressUtxosReply unspent : unspentList) { + + int height = (int)unspent.getHeight(); + // We only want unspent outputs from confirmed transactions (and definitely not mempool duplicates with height 0) + if (!includeUnconfirmed && height <= 0) + continue; + + byte[] txHash = unspent.getTxid().toByteArray(); + int outputIndex = unspent.getIndex(); + long value = unspent.getValueZat(); + + unspentOutputs.add(new UnspentOutput(txHash, outputIndex, height, value)); + } + + return unspentOutputs; + } + + /** + * Returns list of unspent outputs pertaining to passed payment script. + *

+ * @return list of unspent outputs, or empty list if script unknown + * @throws ForeignBlockchainException if there was an error. + */ + @Override + public List getUnspentOutputs(byte[] script, boolean includeUnconfirmed) throws ForeignBlockchainException { + String address = this.blockchain.deriveP2shAddress(script); + return this.getUnspentOutputs(address, includeUnconfirmed); + } + + /** + * Returns raw transaction for passed transaction hash. + *

+ * NOTE: Do not mutate returned byte[]! + * + * @throws ForeignBlockchainException.NotFoundException if transaction not found + * @throws ForeignBlockchainException if error occurs + */ + @Override + public byte[] getRawTransaction(String txHash) throws ForeignBlockchainException { + return getRawTransaction(HashCode.fromString(txHash).asBytes()); + } + + /** + * Returns raw transaction for passed transaction hash. + *

+ * NOTE: Do not mutate returned byte[]! + * + * @throws ForeignBlockchainException.NotFoundException if transaction not found + * @throws ForeignBlockchainException if error occurs + */ + @Override + public byte[] getRawTransaction(byte[] txHash) throws ForeignBlockchainException { + ByteString byteString = ByteString.copyFrom(txHash); + TxFilter txFilter = TxFilter.newBuilder().setHash(byteString).build(); + RawTransaction rawTransaction = this.getCompactTxStreamerStub().getTransaction(txFilter); + + if (!(rawTransaction instanceof RawTransaction)) + throw new ForeignBlockchainException.NetworkException("Unexpected output from Pirate Chain getTransaction gRPC"); + + return rawTransaction.getData().toByteArray(); + } + + /** + * Returns transaction info for passed transaction hash. + *

+ * @throws ForeignBlockchainException.NotFoundException if transaction not found + * @throws ForeignBlockchainException if error occurs + */ + @Override + public BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException { + // Check cache first + BitcoinyTransaction transaction = transactionCache.get(txHash); + if (transaction != null) + return transaction; + + ByteString byteString = ByteString.copyFrom(HashCode.fromString(txHash).asBytes()); + TxFilter txFilter = TxFilter.newBuilder().setHash(byteString).build(); + RawTransaction rawTransaction = this.getCompactTxStreamerStub().getTransaction(txFilter); + + if (!(rawTransaction instanceof RawTransaction)) + throw new ForeignBlockchainException.NetworkException("Unexpected output from Pirate Chain getTransaction gRPC"); + + byte[] transactionData = rawTransaction.getData().toByteArray(); + String transactionDataString = HashCode.fromBytes(transactionData).toString(); + + JSONParser parser = new JSONParser(); + JSONObject transactionJson; + try { + transactionJson = (JSONObject) parser.parse(transactionDataString); + } catch (ParseException e) { + throw new ForeignBlockchainException.NetworkException("Expected JSON string from Pirate Chain getTransaction gRPC"); + } + + Object inputsObj = transactionJson.get("vin"); + if (!(inputsObj instanceof JSONArray)) + throw new ForeignBlockchainException.NetworkException("Expected JSONArray for 'vin' from Pirate Chain getTransaction gRPC"); + + Object outputsObj = transactionJson.get("vout"); + if (!(outputsObj instanceof JSONArray)) + throw new ForeignBlockchainException.NetworkException("Expected JSONArray for 'vout' from Pirate Chain getTransaction gRPC"); + + try { + int size = ((Long) transactionJson.get("size")).intValue(); + int locktime = ((Long) transactionJson.get("locktime")).intValue(); + + // Timestamp might not be present, e.g. for unconfirmed transaction + Object timeObj = transactionJson.get("time"); + Integer timestamp = timeObj != null + ? ((Long) timeObj).intValue() + : null; + + List inputs = new ArrayList<>(); + for (Object inputObj : (JSONArray) inputsObj) { + JSONObject inputJson = (JSONObject) inputObj; + + String scriptSig = (String) ((JSONObject) inputJson.get("scriptSig")).get("hex"); + int sequence = ((Long) inputJson.get("sequence")).intValue(); + String outputTxHash = (String) inputJson.get("txid"); + int outputVout = ((Long) inputJson.get("vout")).intValue(); + + inputs.add(new BitcoinyTransaction.Input(scriptSig, sequence, outputTxHash, outputVout)); + } + + List outputs = new ArrayList<>(); + for (Object outputObj : (JSONArray) outputsObj) { + JSONObject outputJson = (JSONObject) outputObj; + + String scriptPubKey = (String) ((JSONObject) outputJson.get("scriptPubKey")).get("hex"); + long value = BigDecimal.valueOf((Double) outputJson.get("value")).setScale(8).unscaledValue().longValue(); + + // address too, if present in the "addresses" array + List addresses = null; + Object addressesObj = ((JSONObject) outputJson.get("scriptPubKey")).get("addresses"); + if (addressesObj instanceof JSONArray) { + addresses = new ArrayList<>(); + for (Object addressObj : (JSONArray) addressesObj) { + addresses.add((String) addressObj); + } + } + + // some peers return a single "address" string + Object addressObj = ((JSONObject) outputJson.get("scriptPubKey")).get("address"); + if (addressObj instanceof String) { + if (addresses == null) { + addresses = new ArrayList<>(); + } + addresses.add((String) addressObj); + } + + // For the purposes of Qortal we require all outputs to contain addresses + // Some servers omit this info, causing problems down the line with balance calculations + // Update: it turns out that they were just using a different key - "address" instead of "addresses" + // The code below can remain in place, just in case a peer returns a missing address in the future + if (addresses == null || addresses.isEmpty()) { + if (this.currentServer != null) { + this.uselessServers.add(this.currentServer); + this.closeServer(this.currentServer); + } + LOGGER.info("No output addresses returned for transaction {}", txHash); + throw new ForeignBlockchainException(String.format("No output addresses returned for transaction %s", txHash)); + } + + outputs.add(new BitcoinyTransaction.Output(scriptPubKey, value, addresses)); + } + + transaction = new BitcoinyTransaction(txHash, size, locktime, timestamp, inputs, outputs); + + // Save into cache + transactionCache.put(txHash, transaction); + + return transaction; + } catch (NullPointerException | ClassCastException e) { + // Unexpected / invalid response from ElectrumX server + } + + throw new ForeignBlockchainException.NetworkException("Unexpected JSON format from Pirate Chain getTransaction gRPC"); + } + + /** + * Returns list of transactions, relating to passed payment script. + *

+ * @return list of related transactions, or empty list if script unknown + * @throws ForeignBlockchainException if error occurs + */ + @Override + public List getAddressTransactions(byte[] script, boolean includeUnconfirmed) throws ForeignBlockchainException { + // FUTURE: implement this if needed. Probably not very useful for private blockchains. + throw new ForeignBlockchainException("getAddressTransactions not yet implemented for Pirate Chain"); + } + + /** + * Broadcasts raw transaction to network. + *

+ * @throws ForeignBlockchainException if error occurs + */ + @Override + public void broadcastTransaction(byte[] transactionBytes) throws ForeignBlockchainException { + ByteString byteString = ByteString.copyFrom(transactionBytes); + RawTransaction rawTransaction = RawTransaction.newBuilder().setData(byteString).build(); + SendResponse sendResponse = this.getCompactTxStreamerStub().sendTransaction(rawTransaction); + + if (!(sendResponse instanceof SendResponse)) + throw new ForeignBlockchainException.NetworkException("Unexpected output from Pirate Chain broadcastTransaction gRPC"); + + if (sendResponse.getErrorCode() != 0) + throw new ForeignBlockchainException.NetworkException(String.format("Unexpected error code from Pirate Chain broadcastTransaction gRPC: %d", sendResponse.getErrorCode())); + } + + // Class-private utility methods + + + /** + * Performs RPC call, with automatic reconnection to different server if needed. + *

+ * @return "result" object from within JSON output + * @throws ForeignBlockchainException if server returns error or something goes wrong + */ + private CompactTxStreamerGrpc.CompactTxStreamerBlockingStub getCompactTxStreamerStub() throws ForeignBlockchainException { + synchronized (this.serverLock) { + if (this.remainingServers.isEmpty()) + this.remainingServers.addAll(this.servers); + + while (haveConnection()) { + // If we have more servers and the last one replied slowly, try another + if (!this.remainingServers.isEmpty()) { + long averageResponseTime = this.currentServer.averageResponseTime(); + if (averageResponseTime > MAX_AVG_RESPONSE_TIME) { + LOGGER.info("Slow average response time {}ms from {} - trying another server...", averageResponseTime, this.currentServer.hostname); + this.closeServer(); + continue; + } + } + + return CompactTxStreamerGrpc.newBlockingStub(this.channel); + +// // Didn't work, try another server... +// this.closeServer(); + } + + // Failed to perform RPC - maybe lack of servers? + LOGGER.info("Error: No connected Pirate Light servers when trying to make RPC call"); + throw new ForeignBlockchainException.NetworkException("No connected Pirate Light servers when trying to make RPC call"); + } + } + + /** Returns true if we have, or create, a connection to an ElectrumX server. */ + private boolean haveConnection() throws ForeignBlockchainException { + if (this.currentServer != null) + return true; + + while (!this.remainingServers.isEmpty()) { + Server server = this.remainingServers.remove(RANDOM.nextInt(this.remainingServers.size())); + LOGGER.trace(() -> String.format("Connecting to %s", server)); + + try { + this.channel = ManagedChannelBuilder.forAddress(server.hostname, server.port).build(); + + CompactTxStreamerGrpc.CompactTxStreamerBlockingStub stub = CompactTxStreamerGrpc.newBlockingStub(this.channel); + LightdInfo lightdInfo = stub.getLightdInfo(Empty.newBuilder().build()); + + if (lightdInfo == null || lightdInfo.getBlockHeight() <= 0) + continue; + + // TODO: find a way to verify that the server is using the expected chain + +// if (featuresJson == null || Double.valueOf((String) featuresJson.get("protocol_min")) < MIN_PROTOCOL_VERSION) +// continue; + +// if (this.expectedGenesisHash != null && !((String) featuresJson.get("genesis_hash")).equals(this.expectedGenesisHash)) +// continue; + + LOGGER.debug(() -> String.format("Connected to %s", server)); + this.currentServer = server; + return true; + } catch (ClassCastException | NullPointerException e) { + // Didn't work, try another server... + closeServer(); + } + } + + return false; + } + + /** + * Closes connection to server if it is currently connected server. + * @param server + */ + private void closeServer(Server server) { + synchronized (this.serverLock) { + if (this.currentServer == null || !this.currentServer.equals(server)) + return; + + if (this.channel != null && !this.channel.isShutdown()) + this.channel.shutdown(); + + this.channel = null; + this.currentServer = null; + } + } + + /** Closes connection to currently connected server (if any). */ + private void closeServer() { + synchronized (this.serverLock) { + this.closeServer(this.currentServer); + } + } + +} diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 24fbfff6..6f9e10d8 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -26,6 +26,7 @@ import org.qortal.controller.arbitrary.ArbitraryDataStorageManager.*; import org.qortal.crosschain.Bitcoin.BitcoinNet; import org.qortal.crosschain.Litecoin.LitecoinNet; import org.qortal.crosschain.Dogecoin.DogecoinNet; +import org.qortal.crosschain.PirateChain.PirateChainNet; import org.qortal.utils.EnumUtils; // All properties to be converted to JSON via JAXB @@ -222,6 +223,7 @@ public class Settings { private BitcoinNet bitcoinNet = BitcoinNet.MAIN; private LitecoinNet litecoinNet = LitecoinNet.MAIN; private DogecoinNet dogecoinNet = DogecoinNet.MAIN; + private PirateChainNet pirateChainNet = PirateChainNet.MAIN; // Also crosschain-related: /** Whether to show SysTray pop-up notifications when trade-bot entries change state */ private boolean tradebotSystrayEnabled = false; @@ -680,6 +682,10 @@ public class Settings { return this.dogecoinNet; } + public PirateChainNet getPirateChainNet() { + return this.pirateChainNet; + } + public boolean isTradebotSystrayEnabled() { return this.tradebotSystrayEnabled; } diff --git a/src/test/java/org/qortal/test/crosschain/BitcoinTests.java b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java index af879e08..07a01ce2 100644 --- a/src/test/java/org/qortal/test/crosschain/BitcoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java @@ -81,7 +81,7 @@ public class BitcoinTests extends Common { } @Test - public void testGetWalletBalance() { + public void testGetWalletBalance() throws ForeignBlockchainException { String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; Long balance = bitcoin.getWalletBalance(xprv58); diff --git a/src/test/java/org/qortal/test/crosschain/DogecoinTests.java b/src/test/java/org/qortal/test/crosschain/DogecoinTests.java index 2b0410c3..6c070d09 100644 --- a/src/test/java/org/qortal/test/crosschain/DogecoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/DogecoinTests.java @@ -81,7 +81,7 @@ public class DogecoinTests extends Common { } @Test - public void testGetWalletBalance() { + public void testGetWalletBalance() throws ForeignBlockchainException { String xprv58 = "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; Long balance = dogecoin.getWalletBalance(xprv58); diff --git a/src/test/java/org/qortal/test/crosschain/LitecoinTests.java b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java index 64837347..6236483a 100644 --- a/src/test/java/org/qortal/test/crosschain/LitecoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java @@ -80,7 +80,7 @@ public class LitecoinTests extends Common { } @Test - public void testGetWalletBalance() { + public void testGetWalletBalance() throws ForeignBlockchainException { String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; Long balance = litecoin.getWalletBalance(xprv58); diff --git a/src/test/java/org/qortal/test/crosschain/PirateChainTests.java b/src/test/java/org/qortal/test/crosschain/PirateChainTests.java new file mode 100644 index 00000000..13896809 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/PirateChainTests.java @@ -0,0 +1,138 @@ +package org.qortal.test.crosschain; + +import cash.z.wallet.sdk.rpc.CompactFormats.*; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.store.BlockStoreException; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Litecoin; +import org.qortal.crosschain.PirateChain; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.*; + +public class PirateChainTests extends Common { + + private PirateChain pirateChain; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // TestNet3 + pirateChain = PirateChain.getInstance(); + } + + @After + public void afterTest() { + Litecoin.resetForTesting(); + pirateChain = null; + } + + @Test + public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { + long before = System.currentTimeMillis(); + System.out.println(String.format("Pirate Chain median blocktime: %d", pirateChain.getMedianBlockTime())); + long afterFirst = System.currentTimeMillis(); + + System.out.println(String.format("Pirate Chain median blocktime: %d", pirateChain.getMedianBlockTime())); + long afterSecond = System.currentTimeMillis(); + + long firstPeriod = afterFirst - before; + long secondPeriod = afterSecond - afterFirst; + + System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); + + assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); + assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + } + + @Test + public void testGetCompactBlocks() throws ForeignBlockchainException { + int startHeight = 1000000; + int count = 20; + + long before = System.currentTimeMillis(); + List compactBlocks = pirateChain.getCompactBlocks(startHeight, count); + long after = System.currentTimeMillis(); + + System.out.println(String.format("Retrieval took: %d ms", after-before)); + + for (CompactBlock block : compactBlocks) { + System.out.println(String.format("Block height: %d, transaction count: %d", block.getHeight(), block.getVtxCount())); + } + + assertEquals(count, compactBlocks.size()); + } + + @Test + @Ignore(value = "Doesn't work, to be fixed later") + public void testFindHtlcSecret() throws ForeignBlockchainException { + // This actually exists on TEST3 but can take a while to fetch + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); + byte[] secret = BitcoinyHTLC.findHtlcSecret(pirateChain, p2shAddress); + + assertNotNull("secret not found", secret); + assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); + } + + @Test + @Ignore(value = "Needs adapting for Pirate Chain") + public void testBuildSpend() { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + long amount = 1000L; + + Transaction transaction = pirateChain.buildSpend(xprv58, recipient, amount); + assertNotNull("insufficient funds", transaction); + + // Check spent key caching doesn't affect outcome + + transaction = pirateChain.buildSpend(xprv58, recipient, amount); + assertNotNull("insufficient funds", transaction); + } + + @Test + @Ignore(value = "Needs adapting for Pirate Chain") + public void testGetWalletBalance() throws ForeignBlockchainException { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + Long balance = pirateChain.getWalletBalance(xprv58); + + assertNotNull(balance); + + System.out.println(pirateChain.format(balance)); + + // Check spent key caching doesn't affect outcome + + Long repeatBalance = pirateChain.getWalletBalance(xprv58); + + assertNotNull(repeatBalance); + + System.out.println(pirateChain.format(repeatBalance)); + + assertEquals(balance, repeatBalance); + } + + @Test + @Ignore(value = "Needs adapting for Pirate Chain") + public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String address = pirateChain.getUnusedReceiveAddress(xprv58); + + assertNotNull(address); + + System.out.println(address); + } + +}