mirror of https://github.com/qortal/qortal
Browse Source
This will most likely be used for by the trade bot, rather than for any wallet functionality.pirate-chain
CalDescent
2 years ago
13 changed files with 1055 additions and 34 deletions
@ -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<ConnectionType, Integer> 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<Server> 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<Server> 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<Server> 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<Server> 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. |
||||
* <p> |
||||
* @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. |
||||
* <p> |
||||
* @throws ForeignBlockchainException if error occurs |
||||
*/ |
||||
@Override |
||||
public int getMedianBlockTime() throws ForeignBlockchainException { |
||||
int height = this.blockchainProvider.getCurrentHeight(); |
||||
|
||||
// Grab latest 11 blocks
|
||||
List<Long> 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 |
||||
* <p> |
||||
* @throws ForeignBlockchainException if error occurs |
||||
*/ |
||||
public List<CompactFormats.CompactBlock> getCompactBlocks(int startHeight, int count) throws ForeignBlockchainException { |
||||
return this.blockchainProvider.getCompactBlocks(startHeight, count); |
||||
} |
||||
|
||||
} |
@ -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<Long> 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<Server> servers = new HashSet<>(); |
||||
private List<Server> remainingServers = new ArrayList<>(); |
||||
private Set<Server> uselessServers = Collections.synchronizedSet(new HashSet<>()); |
||||
|
||||
private final String netId; |
||||
private final String expectedGenesisHash; |
||||
private final Map<Server.ConnectionType, Integer> 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<String, BitcoinyTransaction> 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<String, BitcoinyTransaction> eldest) { |
||||
return size() > TX_CACHE_SIZE; |
||||
} |
||||
}); |
||||
|
||||
// Constructors
|
||||
|
||||
public PirateLightClient(String netId, String genesisHash, Collection<Server> initialServerList, Map<Server.ConnectionType, Integer> 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. |
||||
* <p> |
||||
* @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 <tt>startHeight</tt> inclusive. |
||||
* <p> |
||||
* @throws ForeignBlockchainException if error occurs |
||||
* @return |
||||
*/ |
||||
@Override |
||||
public List<CompactBlock> 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<CompactBlock> blocksIterator = this.getCompactTxStreamerStub().getBlockRange(range); |
||||
|
||||
// Map from Iterator to List
|
||||
List<CompactBlock> blocks = new ArrayList<>(); |
||||
blocksIterator.forEachRemaining(blocks::add); |
||||
|
||||
return blocks; |
||||
} |
||||
|
||||
/** |
||||
* Returns list of raw block headers, starting from <tt>startHeight</tt> inclusive. |
||||
* <p> |
||||
* @throws ForeignBlockchainException if error occurs |
||||
*/ |
||||
@Override |
||||
public List<byte[]> 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<CompactBlock> blocks = this.getCompactTxStreamerStub().getBlockRange(range); |
||||
|
||||
List<byte[]> 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 <tt>startHeight</tt> inclusive. |
||||
* <p> |
||||
* @throws ForeignBlockchainException if error occurs |
||||
*/ |
||||
@Override |
||||
public List<Long> 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<CompactBlock> blocks = this.getCompactTxStreamerStub().getBlockRange(range); |
||||
|
||||
List<Long> 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. |
||||
* <p> |
||||
* @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. |
||||
* <p> |
||||
* @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. |
||||
* <p> |
||||
* @return list of unspent outputs, or empty list if address unknown |
||||
* @throws ForeignBlockchainException if there was an error. |
||||
*/ |
||||
@Override |
||||
public List<UnspentOutput> 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<GetAddressUtxosReply> unspentList = replyList.getAddressUtxosList(); |
||||
if (unspentList == null) |
||||
throw new ForeignBlockchainException.NetworkException("Unexpected output from Pirate Chain getUnspentOutputs gRPC"); |
||||
|
||||
List<UnspentOutput> 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. |
||||
* <p> |
||||
* @return list of unspent outputs, or empty list if script unknown |
||||
* @throws ForeignBlockchainException if there was an error. |
||||
*/ |
||||
@Override |
||||
public List<UnspentOutput> 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. |
||||
* <p> |
||||
* 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. |
||||
* <p> |
||||
* 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. |
||||
* <p> |
||||
* @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<BitcoinyTransaction.Input> 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<BitcoinyTransaction.Output> 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<String> 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. |
||||
* <p> |
||||
* @return list of related transactions, or empty list if script unknown |
||||
* @throws ForeignBlockchainException if error occurs |
||||
*/ |
||||
@Override |
||||
public List<TransactionHash> 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. |
||||
* <p> |
||||
* @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. |
||||
* <p> |
||||
* @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 <tt>server</tt> 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); |
||||
} |
||||
} |
||||
|
||||
} |
@ -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<CompactBlock> 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); |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue