diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index bb60c372..d5a886c2 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -30,7 +30,6 @@ import org.bitcoinj.core.LegacyAddress; import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.TransactionOutput; import org.bitcoinj.script.Script.ScriptType; -import org.bitcoinj.wallet.WalletTransaction; import org.qortal.account.PublicKeyAccount; import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; @@ -452,7 +451,7 @@ public class CrossChainResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), crossChainTradeData.lockTime, redeemBitcoinAddress.getHash(), crossChainTradeData.secretHash); - byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); return p2shAddress.toString(); @@ -522,22 +521,19 @@ public class CrossChainResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), crossChainTradeData.lockTime, redeemBitcoinAddress.getHash(), crossChainTradeData.secretHash); - byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); - Long medianBlockTime = BTC.getInstance().getMedianBlockTime(); + Integer medianBlockTime = BTC.getInstance().getMedianBlockTime(); if (medianBlockTime == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); long now = NTP.getTime(); // Check P2SH is funded - final int startTime = (int) (crossChainTradeData.tradeModeTimestamp / 1000L); - List fundingOutputs = new ArrayList<>(); - List walletTransactions = new ArrayList<>(); - Coin p2shBalance = BTC.getInstance().getBalanceAndOtherInfo(p2shAddress.toString(), startTime, fundingOutputs, walletTransactions); + Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); @@ -545,6 +541,8 @@ public class CrossChainResource { p2shStatus.bitcoinP2shAddress = p2shAddress.toString(); p2shStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance.value, 8); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); + if (p2shBalance.value >= crossChainTradeData.expectedBitcoin && fundingOutputs.size() == 1) { p2shStatus.canRedeem = now >= medianBlockTime * 1000L; p2shStatus.canRefund = now >= crossChainTradeData.lockTime * 1000L; @@ -552,7 +550,8 @@ public class CrossChainResource { if (now >= medianBlockTime * 1000L) { // See if we can extract secret - p2shStatus.secret = BTCACCT.findP2shSecret(p2shStatus.bitcoinP2shAddress, walletTransactions); + List rawTransactions = BTC.getInstance().getAddressTransactions(p2shStatus.bitcoinP2shAddress); + p2shStatus.secret = BTCACCT.findP2shSecret(p2shStatus.bitcoinP2shAddress, rawTransactions); } return p2shStatus; @@ -630,20 +629,19 @@ public class CrossChainResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); byte[] redeemScriptBytes = BTCACCT.buildScript(refundAddress.getHash(), crossChainTradeData.lockTime, redeemBitcoinAddress.getHash(), crossChainTradeData.secretHash); - byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); long now = NTP.getTime(); // Check P2SH is funded - final int startTime = (int) (crossChainTradeData.tradeModeTimestamp / 1000L); - List fundingOutputs = new ArrayList<>(); - Coin p2shBalance = BTC.getInstance().getBalanceAndOtherInfo(p2shAddress.toString(), startTime, fundingOutputs, null); + Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); if (fundingOutputs.size() != 1) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); @@ -741,24 +739,22 @@ public class CrossChainResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), crossChainTradeData.lockTime, redeemAddress.getHash(), crossChainTradeData.secretHash); - byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); - Long medianBlockTime = BTC.getInstance().getMedianBlockTime(); + Integer medianBlockTime = BTC.getInstance().getMedianBlockTime(); if (medianBlockTime == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); long now = NTP.getTime(); // Check P2SH is funded - final int startTime = (int) (crossChainTradeData.tradeModeTimestamp / 1000L); - List fundingOutputs = new ArrayList<>(); - - Coin p2shBalance = BTC.getInstance().getBalanceAndOtherInfo(p2shAddress.toString(), startTime, fundingOutputs, null); + Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); if (fundingOutputs.size() != 1) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 957b1d04..6e69631d 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -35,7 +35,6 @@ import org.qortal.block.BlockChain; import org.qortal.block.BlockMinter; import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.controller.Synchronizer.SynchronizationResult; -import org.qortal.crosschain.BTC; import org.qortal.crypto.Crypto; import org.qortal.data.account.MintingAccountData; import org.qortal.data.account.RewardShareData; @@ -676,9 +675,6 @@ public class Controller extends Thread { if (!isStopping) { isStopping = true; - LOGGER.info("Shutting down Bitcoin support"); - BTC.shutdown(); - LOGGER.info("Shutting down API"); ApiService.getInstance().stop(); diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index bd413f13..77d24cad 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -1,68 +1,24 @@ package org.qortal.crosschain; -import java.io.ByteArrayInputStream; -import java.io.DataOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; -import java.net.InetAddress; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.security.DigestOutputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; import java.util.ArrayList; -import java.util.Collection; import java.util.List; -import java.util.TreeMap; -import java.util.concurrent.CancellationException; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; -import java.util.concurrent.FutureTask; -import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bitcoinj.core.Address; -import org.bitcoinj.core.BlockChain; -import org.bitcoinj.core.CheckpointManager; import org.bitcoinj.core.Coin; -import org.bitcoinj.core.ECKey; import org.bitcoinj.core.NetworkParameters; -import org.bitcoinj.core.Peer; -import org.bitcoinj.core.PeerAddress; -import org.bitcoinj.core.PeerGroup; -import org.bitcoinj.core.Sha256Hash; -import org.bitcoinj.core.StoredBlock; import org.bitcoinj.core.Transaction; -import org.bitcoinj.core.TransactionBroadcast; import org.bitcoinj.core.TransactionOutput; -import org.bitcoinj.core.listeners.BlocksDownloadedEventListener; -import org.bitcoinj.core.listeners.NewBestBlockListener; -import org.bitcoinj.net.discovery.DnsDiscovery; import org.bitcoinj.params.MainNetParams; import org.bitcoinj.params.RegTestParams; import org.bitcoinj.params.TestNet3Params; -import org.bitcoinj.script.Script.ScriptType; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.store.MemoryBlockStore; +import org.bitcoinj.script.ScriptBuilder; import org.bitcoinj.utils.MonetaryFormat; -import org.bitcoinj.utils.Threading; -import org.bitcoinj.wallet.Wallet; -import org.bitcoinj.wallet.WalletTransaction; -import org.bitcoinj.wallet.listeners.WalletCoinsReceivedEventListener; -import org.bitcoinj.wallet.listeners.WalletCoinsSentEventListener; import org.qortal.settings.Settings; +import org.qortal.utils.BitTwiddling; +import org.qortal.utils.Pair; public class BTC { @@ -71,19 +27,10 @@ public class BTC { public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1; public static final int HASH160_LENGTH = 20; - private static final MessageDigest RIPE_MD160_DIGESTER; - private static final MessageDigest SHA256_DIGESTER; - static { - try { - RIPE_MD160_DIGESTER = MessageDigest.getInstance("RIPEMD160"); - SHA256_DIGESTER = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - } - protected static final Logger LOGGER = LogManager.getLogger(BTC.class); + private static final int TIMESTAMP_OFFSET = 4 + 32 + 32; + public enum BitcoinNet { MAIN { @Override @@ -107,156 +54,9 @@ public class BTC { public abstract NetworkParameters getParams(); } - private static class UpdateableCheckpointManager extends CheckpointManager implements NewBestBlockListener { - private static final long CHECKPOINT_THRESHOLD = 7 * 24 * 60 * 60; // seconds - - private static final String MINIMAL_TESTNET3_TEXTFILE = "TXT CHECKPOINTS 1\n0\n1\nAAAAAAAAB+EH4QfhAAAH4AEAAAApmwX6UCEnJcYIKTa7HO3pFkqqNhAzJVBMdEuGAAAAAPSAvVCBUypCbBW/OqU0oIF7ISF84h2spOqHrFCWN9Zw6r6/T///AB0E5oOO\n"; - private static final String MINIMAL_MAINNET_TEXTFILE = "TXT CHECKPOINTS 1\n0\n1\nAAAAAAAAB+EH4QfhAAAH4AEAAABjl7tqvU/FIcDT9gcbVlA4nwtFUbxAtOawZzBpAAAAAKzkcK7NqciBjI/ldojNKncrWleVSgDfBCCn3VRrbSxXaw5/Sf//AB0z8Bkv\n"; - - public UpdateableCheckpointManager(NetworkParameters params, File checkpointsFile) throws IOException, InterruptedException { - super(params, getMinimalTextFileStream(params, checkpointsFile)); - } - - public UpdateableCheckpointManager(NetworkParameters params, InputStream inputStream) throws IOException { - super(params, inputStream); - } - - private static ByteArrayInputStream getMinimalTextFileStream(NetworkParameters params, File checkpointsFile) throws IOException, InterruptedException { - if (params == MainNetParams.get()) - return new ByteArrayInputStream(MINIMAL_MAINNET_TEXTFILE.getBytes()); - - if (params == TestNet3Params.get()) - return new ByteArrayInputStream(MINIMAL_TESTNET3_TEXTFILE.getBytes()); - - if (params == RegTestParams.get()) - return newRegTestCheckpointsStream(checkpointsFile); // We have to build this - - throw new FileNotFoundException("Failed to construct empty UpdateableCheckpointManageer"); - } - - private static ByteArrayInputStream newRegTestCheckpointsStream(File checkpointsFile) throws IOException, InterruptedException { - try { - final NetworkParameters params = RegTestParams.get(); - - final BlockStore store = new MemoryBlockStore(params); - final BlockChain chain = new BlockChain(params, store); - final PeerGroup peerGroup = new PeerGroup(params, chain); - - final InetAddress ipAddress = InetAddress.getLoopbackAddress(); - final PeerAddress peerAddress = new PeerAddress(params, ipAddress); - peerGroup.addAddress(peerAddress); - // startAsync().get() to allow interruption - peerGroup.startAsync().get(); - - final TreeMap checkpoints = new TreeMap<>(); - chain.addNewBestBlockListener((block) -> checkpoints.put(block.getHeight(), block)); - - peerGroup.downloadBlockChain(); - peerGroup.stop(); - - saveAsText(checkpointsFile, checkpoints.values()); - - return new ByteArrayInputStream(Files.readAllBytes(checkpointsFile.toPath())); - } catch (BlockStoreException e) { - throw new IOException(e); - } catch (ExecutionException e) { - // Couldn't start peerGroup - throw new IOException(e); - } - } - - @Override - public void notifyNewBestBlock(StoredBlock block) { - final int height = block.getHeight(); - - if (height % this.params.getInterval() != 0) - return; - - final long blockTimestamp = block.getHeader().getTimeSeconds(); - final long now = System.currentTimeMillis() / 1000L; - if (blockTimestamp > now - CHECKPOINT_THRESHOLD) - return; // Too recent - - LOGGER.trace(() -> String.format("Checkpointing at block %d dated %s", height, LocalDateTime.ofInstant(Instant.ofEpochSecond(blockTimestamp), ZoneOffset.UTC))); - this.checkpoints.put(blockTimestamp, block); - - try { - saveAsText(new File(BTC.getInstance().getDirectory(), BTC.getInstance().getCheckpointsFileName()), this.checkpoints.values()); - } catch (FileNotFoundException e) { - // Save failed - log it but it's not critical - LOGGER.warn(() -> String.format("Failed to save updated BTC checkpoints: %s", e.getMessage())); - } - } - - private static void saveAsText(File textFile, Collection checkpointBlocks) throws FileNotFoundException { - try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(new FileOutputStream(textFile), StandardCharsets.US_ASCII))) { - writer.println("TXT CHECKPOINTS 1"); - writer.println("0"); // Number of signatures to read. Do this later. - writer.println(checkpointBlocks.size()); - - ByteBuffer buffer = ByteBuffer.allocate(StoredBlock.COMPACT_SERIALIZED_SIZE); - - for (StoredBlock block : checkpointBlocks) { - block.serializeCompact(buffer); - writer.println(CheckpointManager.BASE64.encode(buffer.array())); - buffer.position(0); - } - } - } - - @SuppressWarnings("unused") - public void saveAsBinary(File file) throws IOException { - try (final FileOutputStream fileOutputStream = new FileOutputStream(file, false)) { - MessageDigest digest = Sha256Hash.newDigest(); - - try (final DigestOutputStream digestOutputStream = new DigestOutputStream(fileOutputStream, digest)) { - digestOutputStream.on(false); - - try (final DataOutputStream dataOutputStream = new DataOutputStream(digestOutputStream)) { - dataOutputStream.writeBytes("CHECKPOINTS 1"); - dataOutputStream.writeInt(0); // Number of signatures to read. Do this later. - digestOutputStream.on(true); - dataOutputStream.writeInt(this.checkpoints.size()); - - ByteBuffer buffer = ByteBuffer.allocate(StoredBlock.COMPACT_SERIALIZED_SIZE); - - for (StoredBlock block : this.checkpoints.values()) { - block.serializeCompact(buffer); - dataOutputStream.write(buffer.array()); - buffer.position(0); - } - } - } - } - } - } - - private static class ResettableBlockChain extends BlockChain { - public ResettableBlockChain(NetworkParameters params, BlockStore blockStore) throws BlockStoreException { - super(params, blockStore); - } - - // Overridden to increase visibility to public - @Override - public void setChainHead(StoredBlock chainHead) throws BlockStoreException { - super.setChainHead(chainHead); - } - } - - private static final Object instanceLock = new Object(); private static BTC instance; - private enum RunningState { RUNNING, STOPPED }; - FutureTask startupFuture; - private final NetworkParameters params; - private final String checkpointsFileName; - private final File directory; - - private PeerGroup peerGroup; - private BlockStore blockStore; - private ResettableBlockChain chain; - - private UpdateableCheckpointManager manager; + private final ElectrumX electrumX; // Constructors and instance @@ -264,399 +64,96 @@ public class BTC { BitcoinNet bitcoinNet = Settings.getInstance().getBitcoinNet(); this.params = bitcoinNet.getParams(); - switch (bitcoinNet) { - case MAIN: - this.checkpointsFileName = "checkpoints.txt"; - break; + LOGGER.info(() -> String.format("Starting Bitcoin support using %s", bitcoinNet.name())); - case TEST3: - this.checkpointsFileName = "checkpoints-testnet.txt"; - break; - - case REGTEST: - this.checkpointsFileName = "checkpoints-regtest.txt"; - break; - - default: - throw new IllegalStateException("Unsupported Bitcoin network: " + bitcoinNet.name()); - } - - this.directory = new File("Qortal-BTC"); - - startupFuture = new FutureTask<>(BTC::startUp); + this.electrumX = ElectrumX.getInstance(bitcoinNet.name()); } - public static BTC getInstance() { - synchronized (instanceLock) { - if (instance == null) { - instance = new BTC(); - Executors.newSingleThreadExecutor().execute(instance.startupFuture); - } + public static synchronized BTC getInstance() { + if (instance == null) + instance = new BTC(); - return instance; - } + return instance; } // Getters & setters - /* package */ File getDirectory() { - return this.directory; - } - - /* package */ String getCheckpointsFileName() { - return this.checkpointsFileName; - } - public NetworkParameters getNetworkParameters() { return this.params; } - // Static utility methods - - public static byte[] hash160(byte[] message) { - return RIPE_MD160_DIGESTER.digest(SHA256_DIGESTER.digest(message)); - } - - // Start-up & shutdown - - private static RunningState startUp() { - Thread.currentThread().setName("Bitcoin support"); - - LOGGER.info(() -> String.format("Starting Bitcoin support using %s", Settings.getInstance().getBitcoinNet().name())); - - final long startTime = System.currentTimeMillis() / 1000L; - - if (!instance.directory.exists()) - if (!instance.directory.mkdirs()) { - LOGGER.error(() -> String.format("Stopping Bitcoin support: couldn't create directory '%s'", instance.directory.getName())); - return RunningState.STOPPED; - } - - File checkpointsFile = new File(instance.directory, instance.checkpointsFileName); - try (InputStream checkpointsStream = new FileInputStream(checkpointsFile)) { - instance.manager = new UpdateableCheckpointManager(instance.params, checkpointsStream); - } catch (FileNotFoundException e) { - // Construct with no checkpoints then - try { - instance.manager = new UpdateableCheckpointManager(instance.params, checkpointsFile); - } catch (IOException e2) { - LOGGER.error(() -> String.format("Stopping Bitcoin support: couldn't create checkpoints file: %s", e.getMessage())); - return RunningState.STOPPED; - } catch (InterruptedException e2) { - // Probably normal shutdown so quietly return - LOGGER.debug("Stopping Bitcoin support due to interrupt"); - return RunningState.STOPPED; - } - } catch (IOException e) { - LOGGER.error(() -> String.format("Stopping Bitcoin support: couldn't load checkpoints file: %s", e.getMessage())); - return RunningState.STOPPED; - } - - try { - StoredBlock checkpoint = instance.manager.getCheckpointBefore(startTime - 1); - - instance.blockStore = new MemoryBlockStore(instance.params); - instance.blockStore.put(checkpoint); - instance.blockStore.setChainHead(checkpoint); - - instance.chain = new ResettableBlockChain(instance.params, instance.blockStore); - } catch (BlockStoreException e) { - LOGGER.error(() -> String.format("Stopping Bitcoin support: couldn't initialize blockstore: %s", e.getMessage())); - return RunningState.STOPPED; - } - - instance.peerGroup = new PeerGroup(instance.params, instance.chain); - instance.peerGroup.setUserAgent("qortal", "1.0"); - // instance.peerGroup.setPingIntervalMsec(1000L); - // instance.peerGroup.setMaxConnections(20); - - if (instance.params != RegTestParams.get()) { - instance.peerGroup.addPeerDiscovery(new DnsDiscovery(instance.params)); - } else { - instance.peerGroup.addAddress(PeerAddress.localhost(instance.params)); - } - - // final check that we haven't been interrupted - if (Thread.currentThread().isInterrupted()) { - LOGGER.debug("Stopping Bitcoin support due to interrupt"); - return RunningState.STOPPED; - } - - // startAsync() so we can return - instance.peerGroup.startAsync(); - - return RunningState.RUNNING; - } - - public static void shutdown() { - // This is make sure we don't check instance - // while some other thread is in the middle of BTC.getInstance() - synchronized (instanceLock) { - if (instance == null) - return; - } - - // If we can't cancel because we've finished start-up with RUNNING state then stop peerGroup. - // Has side-effect of cancelling in-progress start-up, which is what we want too. - try { - if (!instance.startupFuture.cancel(true) - && instance.startupFuture.isDone() - && instance.startupFuture.get() == RunningState.RUNNING) - instance.peerGroup.stop(); - } catch (InterruptedException | ExecutionException | CancellationException e) { - // Start-up was in-progress when cancel() called, so this is ok - } - } - - public static void resetForTesting() { - synchronized (instanceLock) { - instance = null; - } - } - - // Utility methods - - /** Returns whether Bitcoin support is running, blocks until RUNNING if STARTING. */ - private boolean isRunning() { - try { - return this.startupFuture.get() == RunningState.RUNNING; - } catch (InterruptedException | ExecutionException | CancellationException e) { - return false; - } - } - - protected Wallet createEmptyWallet() { - return Wallet.createBasic(this.params); - } - - private class ReplayHooks { - private Runnable preReplay; - private Runnable postReplay; - - public ReplayHooks(Runnable preReplay, Runnable postReplay) { - this.preReplay = preReplay; - this.postReplay = postReplay; - } - - public void preReplay() { - this.preReplay.run(); - } - - public void postReplay() { - this.postReplay.run(); - } - } - - private void replayChain(int startTime, Wallet wallet, ReplayHooks replayHooks) throws BlockStoreException { - StoredBlock checkpoint = this.manager.getCheckpointBefore(startTime - 1); - this.blockStore.put(checkpoint); - this.blockStore.setChainHead(checkpoint); - this.chain.setChainHead(checkpoint); - - final WalletCoinsReceivedEventListener coinsReceivedListener = (someWallet, tx, prevBalance, newBalance) -> { - LOGGER.trace(() -> String.format("Wallet-related transaction %s", tx.getTxId())); - }; - - final WalletCoinsSentEventListener coinsSentListener = (someWallet, tx, prevBalance, newBalance) -> { - LOGGER.trace(() -> String.format("Wallet-related transaction %s", tx.getTxId())); - }; - - if (wallet != null) { - wallet.addCoinsReceivedEventListener(coinsReceivedListener); - wallet.addCoinsSentEventListener(coinsSentListener); - - // Link wallet to chain and peerGroup - this.chain.addWallet(wallet); - this.peerGroup.addWallet(wallet); - } - - try { - if (replayHooks != null) - replayHooks.preReplay(); - - // Sync blockchain using peerGroup, skipping as much as we can before startTime - this.peerGroup.setFastCatchupTimeSecs(startTime); - this.chain.addNewBestBlockListener(Threading.SAME_THREAD, this.manager); - this.peerGroup.downloadBlockChain(); - } finally { - // Clean up - if (replayHooks != null) - replayHooks.postReplay(); - - if (wallet != null) { - wallet.removeCoinsReceivedEventListener(coinsReceivedListener); - wallet.removeCoinsSentEventListener(coinsSentListener); - - this.peerGroup.removeWallet(wallet); - this.chain.removeWallet(wallet); - } - - // For safety, disconnect download peer just in case - Peer downloadPeer = this.peerGroup.getDownloadPeer(); - if (downloadPeer != null) - downloadPeer.close(); - } + public static synchronized void resetForTesting() { + instance = null; } // Actual useful methods for use by other classes /** Returns median timestamp from latest 11 blocks, in seconds. */ - public Long getMedianBlockTime() { - if (!this.isRunning()) - // Failed to start up, or we're shutting down + public Integer getMedianBlockTime() { + Integer height = this.electrumX.getCurrentHeight(); + if (height == null) return null; - // 11 blocks, at roughly 10 minutes per block, means we should go back at least 110 minutes - // but some blocks have been way longer than 10 minutes, so be massively pessimistic - int startTime = (int) (System.currentTimeMillis() / 1000L) - 11 * 60 * 60; // 11 hours before now, in seconds - - try { - this.replayChain(startTime, null, null); - - List latestBlocks = new ArrayList<>(11); - StoredBlock block = this.blockStore.getChainHead(); - for (int i = 0; i < 11; ++i) { - latestBlocks.add(block); - block = block.getPrev(this.blockStore); - - // If previous block is null then chain replay didn't fetch any recent blocks (REGTEST) - if (block == null) - return null; - } - - // Descending, but order shouldn't matter as we're picking median... - latestBlocks.sort((a, b) -> Long.compare(b.getHeader().getTimeSeconds(), a.getHeader().getTimeSeconds())); - - return latestBlocks.get(5).getHeader().getTimeSeconds(); - } catch (BlockStoreException e) { - LOGGER.error(String.format("Can't get Bitcoin median block time due to blockstore issue: %s", e.getMessage())); + // Grab latest 11 blocks + List blockHeaders = this.electrumX.getBlockHeaders(height, 11); + if (blockHeaders == null || blockHeaders.size() < 11) return null; - } + + List blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.fromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList()); + + // Descending, but order shouldn't matter as we're picking median... + blockTimestamps.sort((a, b) -> Integer.compare(b, a)); + + return blockTimestamps.get(5); } - public Coin getBalance(String base58Address, int startTime) { - if (!this.isRunning()) - // Failed to start up, or we're shutting down + public Coin getBalance(String base58Address) { + Long balance = this.electrumX.getBalance(addressToScript(base58Address)); + if (balance == null) return null; - // Create new wallet containing only the address we're interested in, ignoring anything prior to startTime - Wallet wallet = createEmptyWallet(); - Address address = Address.fromString(this.params, base58Address); - wallet.addWatchedAddress(address, startTime); - - try { - replayChain(startTime, wallet, null); - - // Now that blockchain is up-to-date, return current balance - return wallet.getBalance(); - } catch (BlockStoreException e) { - LOGGER.error(String.format("Can't get Bitcoin balance for %s due to blockstore issue: %s", base58Address, e.getMessage())); - return null; - } + return Coin.valueOf(balance); } - public List getOutputs(String base58Address, int startTime) { - if (!this.isRunning()) - // Failed to start up, or we're shutting down + public List getUnspentOutputs(String base58Address) { + List> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address)); + if (unspentOutputs == null) return null; - Wallet wallet = createEmptyWallet(); - Address address = Address.fromString(this.params, base58Address); - wallet.addWatchedAddress(address, startTime); + List unspentTransactionOutputs = new ArrayList<>(); + for (Pair unspentOutput : unspentOutputs) { + List transactionOutputs = getOutputs(unspentOutput.getA()); + if (transactionOutputs == null) + return null; - try { - replayChain(startTime, wallet, null); - - // Now that blockchain is up-to-date, return outputs - return wallet.getWatchedOutputs(true); - } catch (BlockStoreException e) { - LOGGER.error(String.format("Can't get Bitcoin outputs for %s due to blockstore issue: %s", base58Address, e.getMessage())); - return null; + unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.getB())); } + + return unspentTransactionOutputs; } - public Coin getBalanceAndOtherInfo(String base58Address, int startTime, List unspentOutputs, List walletTransactions) { - if (!this.isRunning()) - // Failed to start up, or we're shutting down + public List getOutputs(byte[] txHash) { + byte[] rawTransactionBytes = this.electrumX.getRawTransaction(txHash); + if (rawTransactionBytes == null) return null; - // Create new wallet containing only the address we're interested in, ignoring anything prior to startTime - Wallet wallet = createEmptyWallet(); - Address address = Address.fromString(this.params, base58Address); - wallet.addWatchedAddress(address, startTime); - - try { - replayChain(startTime, wallet, null); - - if (unspentOutputs != null) - unspentOutputs.addAll(wallet.getWatchedOutputs(true)); - - if (walletTransactions != null) - for (WalletTransaction walletTransaction : wallet.getWalletTransactions()) - walletTransactions.add(walletTransaction); - - return wallet.getBalance(); - } catch (BlockStoreException e) { - LOGGER.error(String.format("Can't get Bitcoin info for %s due to blockstore issue: %s", base58Address, e.getMessage())); - return null; - } + Transaction transaction = new Transaction(this.params, rawTransactionBytes); + return transaction.getOutputs(); } - public List getOutputs(byte[] txId, int startTime) { - if (!this.isRunning()) - // Failed to start up, or we're shutting down - return null; - - Wallet wallet = createEmptyWallet(); - - // Add random address to wallet - ECKey fakeKey = new ECKey(); - wallet.addWatchedAddress(Address.fromKey(this.params, fakeKey, ScriptType.P2PKH), startTime); - - final Sha256Hash txHash = Sha256Hash.wrap(txId); - - final AtomicReference foundTransaction = new AtomicReference<>(); - - final BlocksDownloadedEventListener listener = (peer, block, filteredBlock, blocksLeft) -> { - List transactions = block.getTransactions(); - - if (transactions == null) - return; - - for (Transaction transaction : transactions) - if (transaction.getTxId().equals(txHash)) { - LOGGER.trace(() -> String.format("We downloaded block containing tx %s", txHash)); - foundTransaction.set(transaction); - } - }; - - ReplayHooks replayHooks = new ReplayHooks(() -> this.peerGroup.addBlocksDownloadedEventListener(listener), () -> this.peerGroup.removeBlocksDownloadedEventListener(listener)); - - // Replay chain in the hope it will download transactionId as a dependency - try { - replayChain(startTime, wallet, replayHooks); - - Transaction realTx = foundTransaction.get(); - return realTx.getOutputs(); - } catch (BlockStoreException e) { - LOGGER.error(String.format("BTC blockstore issue: %s", e.getMessage())); - return null; - } + public List getAddressTransactions(String base58Address) { + return this.electrumX.getAddressTransactions(addressToScript(base58Address)); } public boolean broadcastTransaction(Transaction transaction) { - if (!this.isRunning()) - // Failed to start up, or we're shutting down - return false; + return this.electrumX.broadcastTransaction(transaction.bitcoinSerialize()); + } - TransactionBroadcast transactionBroadcast = this.peerGroup.broadcastTransaction(transaction); + // Utility methods for us - try { - transactionBroadcast.future().get(); - return true; - } catch (InterruptedException | ExecutionException e) { - return false; - } + private byte[] addressToScript(String base58Address) { + Address address = Address.fromString(this.params, base58Address); + return ScriptBuilder.createOutputScript(address).getProgram(); } } diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 28f26ce9..30e3b8be 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -21,7 +21,6 @@ import org.bitcoinj.script.ScriptBuilder; import org.bitcoinj.script.ScriptChunk; import org.bitcoinj.script.ScriptOpCodes; import org.bitcoinj.script.Script.ScriptType; -import org.bitcoinj.wallet.WalletTransaction; import org.ciyam.at.API; import org.ciyam.at.CompilationException; import org.ciyam.at.FunctionCode; @@ -619,11 +618,11 @@ public class BTCACCT { return tradeData; } - public static byte[] findP2shSecret(String p2shAddress, List walletTransactions) { + public static byte[] findP2shSecret(String p2shAddress, List rawTransactions) { NetworkParameters params = BTC.getInstance().getNetworkParameters(); - for (WalletTransaction walletTransaction : walletTransactions) { - Transaction transaction = walletTransaction.getTransaction(); + for (byte[] rawTransaction : rawTransactions) { + Transaction transaction = new Transaction(params, rawTransaction); // Cycle through inputs, looking for one that spends our P2SH for (TransactionInput input : transaction.getInputs()) { diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java new file mode 100644 index 00000000..95b3ecf4 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -0,0 +1,387 @@ +package org.qortal.crosschain; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Random; +import java.util.Scanner; +import java.util.Set; + +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +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.JSONValue; +import org.qortal.crypto.Crypto; +import org.qortal.crypto.TrustlessSSLSocketFactory; +import org.qortal.utils.Pair; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; + +public class ElectrumX { + + private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class); + private static final Random RANDOM = new Random(); + + private static final int DEFAULT_TCP_PORT = 50001; + private static final int DEFAULT_SSL_PORT = 50002; + + private static final int BLOCK_HEADER_LENGTH = 80; + + private static final Map instances = new HashMap<>(); + + static class Server { + String hostname; + + enum ConnectionType { TCP, SSL }; + ConnectionType connectionType; + + int port; + + public Server(String hostname, ConnectionType connectionType, int port) { + this.hostname = hostname; + this.connectionType = connectionType; + this.port = port; + } + + @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 Server currentServer; + private Socket socket; + private Scanner scanner; + private int nextId = 1; + + // Constructors + + private ElectrumX(String bitcoinNetwork) { + switch (bitcoinNetwork) { + case "MAIN": + servers.addAll(Arrays.asList()); + break; + + case "TEST3": + servers.addAll(Arrays.asList( + new Server("tn.not.fyi", Server.ConnectionType.TCP, 55001), + new Server("tn.not.fyi", Server.ConnectionType.SSL, 55002), + new Server("testnet.aranguren.org", Server.ConnectionType.TCP, 51001), + new Server("testnet.aranguren.org", Server.ConnectionType.SSL, 51002), + new Server("testnet.hsmiths.com", Server.ConnectionType.SSL, 53012))); + break; + + case "REGTEST": + servers.addAll(Arrays.asList( + new Server("localhost", Server.ConnectionType.TCP, DEFAULT_TCP_PORT), + new Server("localhost", Server.ConnectionType.SSL, DEFAULT_SSL_PORT))); + break; + + default: + throw new IllegalArgumentException(String.format("Bitcoin network '%s' unknown", bitcoinNetwork)); + } + + LOGGER.debug(() -> String.format("Starting ElectrumX support for %s Bitcoin network", bitcoinNetwork)); + rpc("server.banner"); + } + + public static synchronized ElectrumX getInstance(String bitcoinNetwork) { + if (!instances.containsKey(bitcoinNetwork)) + instances.put(bitcoinNetwork, new ElectrumX(bitcoinNetwork)); + + return instances.get(bitcoinNetwork); + } + + // Methods for use by other classes + + public Integer getCurrentHeight() { + JSONObject blockJson = (JSONObject) this.rpc("blockchain.headers.subscribe"); + if (blockJson == null || !blockJson.containsKey("height")) + return null; + + return ((Long) blockJson.get("height")).intValue(); + } + + public List getBlockHeaders(int startHeight, long count) { + JSONObject blockJson = (JSONObject) this.rpc("blockchain.block.headers", startHeight, count); + if (blockJson == null || !blockJson.containsKey("count") || !blockJson.containsKey("hex")) + return null; + + Long returnedCount = (Long) blockJson.get("count"); + String hex = (String) blockJson.get("hex"); + + byte[] raw = HashCode.fromString(hex).asBytes(); + if (raw.length != returnedCount * BLOCK_HEADER_LENGTH) + return null; + + List rawBlockHeaders = new ArrayList<>(returnedCount.intValue()); + for (int i = 0; i < returnedCount; ++i) + rawBlockHeaders.add(Arrays.copyOfRange(raw, i * BLOCK_HEADER_LENGTH, (i + 1) * BLOCK_HEADER_LENGTH)); + + return rawBlockHeaders; + } + + public Long getBalance(byte[] script) { + byte[] scriptHash = Crypto.digest(script); + Bytes.reverse(scriptHash); + + JSONObject balanceJson = (JSONObject) this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString()); + if (balanceJson == null || !balanceJson.containsKey("confirmed")) + return null; + + return (Long) balanceJson.get("confirmed"); + } + + public List> getUnspentOutputs(byte[] script) { + byte[] scriptHash = Crypto.digest(script); + Bytes.reverse(scriptHash); + + JSONArray unspentJson = (JSONArray) this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString()); + if (unspentJson == null) + return null; + + List> unspentOutputs = new ArrayList<>(); + for (Object rawUnspent : unspentJson) { + JSONObject unspent = (JSONObject) rawUnspent; + + byte[] txHash = HashCode.fromString((String) unspent.get("tx_hash")).asBytes(); + int outputIndex = ((Long) unspent.get("tx_pos")).intValue(); + + unspentOutputs.add(new Pair<>(txHash, outputIndex)); + } + + return unspentOutputs; + } + + public byte[] getRawTransaction(byte[] txHash) { + String rawTransactionHex = (String) this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString()); + if (rawTransactionHex == null) + return null; + + return HashCode.fromString(rawTransactionHex).asBytes(); + } + + public List getAddressTransactions(byte[] script) { + byte[] scriptHash = Crypto.digest(script); + Bytes.reverse(scriptHash); + + JSONArray transactionsJson = (JSONArray) this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString()); + if (transactionsJson == null) + return null; + + List rawTransactions = new ArrayList<>(); + + for (Object rawTransactionInfo : transactionsJson) { + JSONObject transactionInfo = (JSONObject) rawTransactionInfo; + + // We only want confirmed transactions + if (!transactionInfo.containsKey("height")) + continue; + + String txHash = (String) transactionInfo.get("tx_hash"); + String rawTransactionHex = (String) this.rpc("blockchain.transaction.get", txHash); + if (rawTransactionHex == null) + return null; + + rawTransactions.add(HashCode.fromString(rawTransactionHex).asBytes()); + } + + return rawTransactions; + } + + public boolean broadcastTransaction(byte[] transactionBytes) { + JSONObject broadcastJson = (JSONObject) this.rpc("blockchain.transaction.broadcast", HashCode.fromBytes(transactionBytes).toString()); + if (broadcastJson == null) + return false; + + // If JSON contains "result", then it went through ok. + // Otherwise JSON would contain "error" instead. + return broadcastJson.containsKey("result"); + } + + // Class-private utility methods + + private Set serverPeersSubscribe() { + Set newServers = new HashSet<>(); + + JSONArray peers = (JSONArray) this.connectedRpc("server.peers.subscribe"); + if (peers == null) + return newServers; + + for (Object rawPeer : peers) { + JSONArray peer = (JSONArray) rawPeer; + if (peer.size() < 3) + continue; + + String hostname = (String) peer.get(1); + JSONArray features = (JSONArray) peer.get(2); + + for (Object rawFeature : features) { + String feature = (String) rawFeature; + Server.ConnectionType connectionType = null; + int port = -1; + + switch (feature.charAt(0)) { + case 's': + connectionType = Server.ConnectionType.SSL; + port = DEFAULT_SSL_PORT; + break; + + case 't': + connectionType = Server.ConnectionType.TCP; + port = DEFAULT_TCP_PORT; + break; + } + + if (connectionType == null) + continue; + + // Possible non-default port? + if (feature.length() > 1) + try { + port = Integer.parseInt(feature.substring(1)); + } catch (NumberFormatException e) { + // no good + continue; // for-loop above + } + + Server newServer = new Server(hostname, connectionType, port); + newServers.add(newServer); + } + } + + return newServers; + } + + private synchronized Object rpc(String method, Object...params) { + while (haveConnection()) { + Object response = connectedRpc(method, params); + if (response != null) + return response; + + this.currentServer = null; + try { + this.socket.close(); + } catch (IOException e) { + /* ignore */ + } + this.scanner = null; + } + + return null; + } + + private boolean haveConnection() { + if (this.currentServer != null) + return true; + + List remainingServers = new ArrayList<>(this.servers); + + while (!remainingServers.isEmpty()) { + Server server = remainingServers.remove(RANDOM.nextInt(remainingServers.size())); + LOGGER.trace(() -> String.format("Connecting to %s", server)); + + try { + SocketAddress endpoint = new InetSocketAddress(server.hostname, server.port); + int timeout = 5000; // ms + + this.socket = new Socket(); + this.socket.connect(endpoint, timeout); + this.socket.setTcpNoDelay(true); + + if (server.connectionType == Server.ConnectionType.SSL) { + SSLSocketFactory factory = TrustlessSSLSocketFactory.getSocketFactory(); + this.socket = (SSLSocket) factory.createSocket(this.socket, server.hostname, server.port, true); + } + + this.scanner = new Scanner(this.socket.getInputStream()); + this.scanner.useDelimiter("\n"); + + // Check connection works by asking for more servers + Set moreServers = serverPeersSubscribe(); + moreServers.removeAll(this.servers); + remainingServers.addAll(moreServers); + this.servers.addAll(moreServers); + + LOGGER.debug(() -> String.format("Connected to %s", server)); + this.currentServer = server; + return true; + } catch (IOException e) { + // Try another server... + this.socket = null; + this.scanner = null; + } + } + + return false; + } + + @SuppressWarnings("unchecked") + private Object connectedRpc(String method, Object...params) { + JSONObject requestJson = new JSONObject(); + requestJson.put("id", this.nextId++); + requestJson.put("method", method); + + JSONArray requestParams = new JSONArray(); + requestParams.addAll(Arrays.asList(params)); + requestJson.put("params", requestParams); + + String request = requestJson.toJSONString() + "\n"; + LOGGER.trace(() -> String.format("Request: %s", request)); + + final String response; + + try { + this.socket.getOutputStream().write(request.getBytes()); + response = scanner.next(); + } catch (IOException | NoSuchElementException e) { + return null; + } + + LOGGER.trace(() -> String.format("Response: %s", response)); + + if (response.isEmpty()) + return null; + + JSONObject responseJson = (JSONObject) JSONValue.parse(response); + if (responseJson == null) + return null; + + return responseJson.get("result"); + } + +} diff --git a/src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java b/src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java new file mode 100644 index 00000000..aba1955e --- /dev/null +++ b/src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java @@ -0,0 +1,42 @@ +package org.qortal.crypto; + +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +public abstract class TrustlessSSLSocketFactory { + + // Create a trust manager that does not validate certificate chains + private static final TrustManager[] TRUSTLESS_MANAGER = new TrustManager[] { + new X509TrustManager() { + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) { + } + + public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) { + } + } + }; + + // Install the all-trusting trust manager + private static final SSLContext sc; + static { + try { + sc = SSLContext.getInstance("SSL"); + sc.init(null, TRUSTLESS_MANAGER, new java.security.SecureRandom()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static SSLSocketFactory getSocketFactory() { + return sc.getSocketFactory(); + } + +} diff --git a/src/main/java/org/qortal/utils/BitTwiddling.java b/src/main/java/org/qortal/utils/BitTwiddling.java index ada2c2f5..f13300c5 100644 --- a/src/main/java/org/qortal/utils/BitTwiddling.java +++ b/src/main/java/org/qortal/utils/BitTwiddling.java @@ -26,4 +26,9 @@ public class BitTwiddling { return new byte[] { (byte) (value), (byte) (value >> 8), (byte) (value >> 16), (byte) (value >> 24) }; } + /** Convert little-endian bytes to int */ + public static int fromLEBytes(byte[] bytes, int offset) { + return (bytes[offset] & 0xff) | (bytes[offset + 1] & 0xff) << 8 | (bytes[offset + 2] & 0xff) << 16 | (bytes[offset + 3] & 0xff) << 24; + } + } diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index 7be73c89..4f3f5bd1 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -2,7 +2,6 @@ package org.qortal.test.btcacct; import static org.junit.Assert.*; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.ExecutionException; @@ -11,7 +10,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.wallet.WalletTransaction; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -37,12 +35,10 @@ public class BtcTests extends Common { BTC btc = BTC.getInstance(); ExecutorService executor = Executors.newSingleThreadExecutor(); - Future future = executor.submit(() -> btc.getMedianBlockTime()); - - BTC.shutdown(); + Future future = executor.submit(() -> btc.getMedianBlockTime()); try { - Long medianBlockTime = future.get(); + Integer medianBlockTime = future.get(); assertNull("Shutdown should occur before we get a result", medianBlockTime); } catch (InterruptedException | ExecutionException e) { } @@ -55,12 +51,10 @@ public class BtcTests extends Common { BTC btc = BTC.getInstance(); ExecutorService executor = Executors.newSingleThreadExecutor(); - Future future = executor.submit(() -> btc.getMedianBlockTime()); - - BTC.shutdown(); + Future future = executor.submit(() -> btc.getMedianBlockTime()); try { - Long medianBlockTime = future.get(); + Integer medianBlockTime = future.get(); assertNull("Shutdown should occur before we get a result", medianBlockTime); } catch (InterruptedException | ExecutionException e) { } @@ -92,14 +86,11 @@ public class BtcTests extends Common { public void testFindP2shSecret() { // This actually exists on TEST3 but can take a while to fetch String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - int startTime = 1587510000; // Tue 21 Apr 2020 23:00:00 UTC - List walletTransactions = new ArrayList<>(); - - BTC.getInstance().getBalanceAndOtherInfo(p2shAddress, startTime, null, walletTransactions); + List rawTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); byte[] expectedSecret = AtTests.secret; - byte[] secret = BTCACCT.findP2shSecret(p2shAddress, walletTransactions); + byte[] secret = BTCACCT.findP2shSecret(p2shAddress, rawTransactions); assertNotNull(secret); assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); diff --git a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java b/src/test/java/org/qortal/test/btcacct/BuildP2SH.java index 33f86526..25a95d8b 100644 --- a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/BuildP2SH.java @@ -14,6 +14,7 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; +import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryFactory; @@ -105,7 +106,7 @@ public class BuildP2SH { byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); - byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); System.out.println(String.format("P2SH address: %s", p2shAddress)); diff --git a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java index ec2ccb86..8313d573 100644 --- a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java @@ -16,6 +16,7 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; +import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryFactory; @@ -115,7 +116,7 @@ public class CheckP2SH { byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); - byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); if (!derivedP2shAddress.equals(p2shAddress)) { @@ -134,9 +135,7 @@ public class CheckP2SH { System.out.println(String.format("Too soon (%s) to redeem based on median block time %s", LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC), LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC))); // Check P2SH is funded - final int startTime = lockTime - 86400; - - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString(), startTime); + Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) { System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); System.exit(2); @@ -144,7 +143,7 @@ public class CheckP2SH { System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.FORMAT.format(p2shBalance))); // Grab all P2SH funding transactions (just in case there are more than one) - List fundingOutputs = BTC.getInstance().getOutputs(p2shAddress.toString(), startTime); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); if (fundingOutputs == null) { System.err.println(String.format("Can't find outputs for P2SH")); System.exit(2); diff --git a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java new file mode 100644 index 00000000..a8c3cb12 --- /dev/null +++ b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java @@ -0,0 +1,136 @@ +package org.qortal.test.btcacct; + +import static org.junit.Assert.*; + +import java.security.Security; +import java.util.List; + +import org.bitcoinj.core.Address; +import org.bitcoinj.params.TestNet3Params; +import org.bitcoinj.script.ScriptBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.junit.Test; +import org.qortal.crosschain.ElectrumX; +import org.qortal.utils.BitTwiddling; +import org.qortal.utils.Pair; + +import com.google.common.hash.HashCode; + +public class ElectrumXTests { + + static { + // This must go before any calls to LogManager/Logger + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + + Security.insertProviderAt(new BouncyCastleProvider(), 0); + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + } + + @Test + public void testInstance() { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + assertNotNull(electrumX); + } + + @Test + public void testGetCurrentHeight() { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + + Integer height = electrumX.getCurrentHeight(); + + assertNotNull(height); + assertTrue(height > 10000); + System.out.println("Current TEST3 height: " + height); + } + + @Test + public void testGetRecentBlocks() { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + + Integer height = electrumX.getCurrentHeight(); + assertNotNull(height); + assertTrue(height > 10000); + + List recentBlockHeaders = electrumX.getBlockHeaders(height - 11, 11); + assertNotNull(recentBlockHeaders); + + System.out.println(String.format("Returned %d recent blocks", recentBlockHeaders.size())); + for (int i = 0; i < recentBlockHeaders.size(); ++i) { + byte[] blockHeader = recentBlockHeaders.get(i); + + // Timestamp(int) is at 4 + 32 + 32 = 68 bytes offset + int offset = 4 + 32 + 32; + int timestamp = BitTwiddling.fromLEBytes(blockHeader, offset); + System.out.println(String.format("Block %d timestamp: %d", height + i, timestamp)); + } + } + + @Test + public void testGetP2PKHBalance() { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + + Address address = Address.fromString(TestNet3Params.get(), "n3GNqMveyvaPvUbH469vDRadqpJMPc84JA"); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + Long balance = electrumX.getBalance(script); + + assertNotNull(balance); + assertTrue(balance > 0L); + + System.out.println(String.format("TestNet address %s has balance: %d sats / %d.%08d BTC", address, balance, (balance / 100000000L), (balance % 100000000L))); + } + + @Test + public void testGetP2SHBalance() { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + + Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF"); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + Long balance = electrumX.getBalance(script); + + assertNotNull(balance); + assertTrue(balance > 0L); + + System.out.println(String.format("TestNet address %s has balance: %d sats / %d.%08d BTC", address, balance, (balance / 100000000L), (balance % 100000000L))); + } + + @Test + public void testGetUnspentOutputs() { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + + Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF"); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + List> unspentOutputs = electrumX.getUnspentOutputs(script); + + assertNotNull(unspentOutputs); + assertFalse(unspentOutputs.isEmpty()); + + for (Pair unspentOutput : unspentOutputs) + System.out.println(String.format("TestNet address %s has unspent output at tx %s, output index %d", address, HashCode.fromBytes(unspentOutput.getA()).toString(), unspentOutput.getB())); + } + + @Test + public void testGetRawTransaction() { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + + byte[] txHash = HashCode.fromString("7653fea9ffcd829d45ed2672938419a94951b08175982021e77d619b553f29af").asBytes(); + + byte[] rawTransactionBytes = electrumX.getRawTransaction(txHash); + + assertNotNull(rawTransactionBytes); + } + + @Test + public void testGetAddressTransactions() { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + + Address address = Address.fromString(TestNet3Params.get(), "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + List rawTransactions = electrumX.getAddressTransactions(script); + + assertNotNull(rawTransactions); + assertFalse(rawTransactions.isEmpty()); + } + +} diff --git a/src/test/java/org/qortal/test/btcacct/GetTransaction.java b/src/test/java/org/qortal/test/btcacct/GetTransaction.java index 48af4ebf..7f42b10b 100644 --- a/src/test/java/org/qortal/test/btcacct/GetTransaction.java +++ b/src/test/java/org/qortal/test/btcacct/GetTransaction.java @@ -22,34 +22,31 @@ public class GetTransaction { if (error != null) System.err.println(error); - System.err.println(String.format("usage: GetTransaction ")); - System.err.println(String.format("example (mainnet): GetTransaction 816407e79568f165f13e09e9912c5f2243e0a23a007cec425acedc2e89284660 1585317000")); - System.err.println(String.format("example (testnet): GetTransaction 3bfd17a492a4e3d6cb7204e17e20aca6c1ab82e1828bd1106eefbaf086fb8a4e 1584376000")); + System.err.println(String.format("usage: GetTransaction ")); + System.err.println(String.format("example (mainnet): GetTransaction 816407e79568f165f13e09e9912c5f2243e0a23a007cec425acedc2e89284660")); + System.err.println(String.format("example (testnet): GetTransaction 3bfd17a492a4e3d6cb7204e17e20aca6c1ab82e1828bd1106eefbaf086fb8a4e")); System.exit(1); } public static void main(String[] args) { - if (args.length < 2 || args.length > 2) + if (args.length != 1) usage(null); Security.insertProviderAt(new BouncyCastleProvider(), 0); Settings.fileInstance("settings-test.json"); byte[] transactionId = null; - int startTime = 0; try { int argIndex = 0; transactionId = HashCode.fromString(args[argIndex++]).asBytes(); - - startTime = Integer.parseInt(args[argIndex++]); } catch (NumberFormatException | AddressFormatException e) { usage(String.format("Argument format exception: %s", e.getMessage())); } // Grab all outputs from transaction - List fundingOutputs = BTC.getInstance().getOutputs(transactionId, startTime); + List fundingOutputs = BTC.getInstance().getOutputs(transactionId); if (fundingOutputs == null) { System.out.println(String.format("Transaction not found")); return; diff --git a/src/test/java/org/qortal/test/btcacct/Redeem.java b/src/test/java/org/qortal/test/btcacct/Redeem.java index 4c5b9fb7..85670d68 100644 --- a/src/test/java/org/qortal/test/btcacct/Redeem.java +++ b/src/test/java/org/qortal/test/btcacct/Redeem.java @@ -19,6 +19,7 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; +import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryFactory; @@ -111,7 +112,7 @@ public class Redeem { // New/derived info - byte[] secretHash = BTC.hash160(secret); + byte[] secretHash = Crypto.hash160(secret); System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash))); ECKey redeemKey = ECKey.fromPrivate(redeemPrivateKey); @@ -123,7 +124,7 @@ public class Redeem { byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); - byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); if (!derivedP2shAddress.equals(p2shAddress)) { @@ -146,9 +147,7 @@ public class Redeem { } // Check P2SH is funded - final int startTime = ((int) (System.currentTimeMillis() / 1000L)) - 86400; - - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString(), startTime); + Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) { System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); System.exit(2); @@ -156,7 +155,7 @@ public class Redeem { System.out.println(String.format("P2SH address %s balance: %s BTC", p2shAddress, p2shBalance.toPlainString())); // Grab all P2SH funding transactions (just in case there are more than one) - List fundingOutputs = BTC.getInstance().getOutputs(p2shAddress.toString(), startTime); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); if (fundingOutputs == null) { System.err.println(String.format("Can't find outputs for P2SH")); System.exit(2); diff --git a/src/test/java/org/qortal/test/btcacct/Refund.java b/src/test/java/org/qortal/test/btcacct/Refund.java index 3393f8bb..57835041 100644 --- a/src/test/java/org/qortal/test/btcacct/Refund.java +++ b/src/test/java/org/qortal/test/btcacct/Refund.java @@ -19,6 +19,7 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; +import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryFactory; @@ -122,7 +123,7 @@ public class Refund { byte[] redeemScriptBytes = BTCACCT.buildScript(refundAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); - byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); if (!derivedP2shAddress.equals(p2shAddress)) { @@ -150,9 +151,7 @@ public class Refund { } // Check P2SH is funded - final int startTime = ((int) (System.currentTimeMillis() / 1000L)) - 86400; - - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString(), startTime); + Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) { System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); System.exit(2); @@ -160,7 +159,7 @@ public class Refund { System.out.println(String.format("P2SH address %s balance: %s BTC", p2shAddress, p2shBalance.toPlainString())); // Grab all P2SH funding transactions (just in case there are more than one) - List fundingOutputs = BTC.getInstance().getOutputs(p2shAddress.toString(), startTime); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); if (fundingOutputs == null) { System.err.println(String.format("Can't find outputs for P2SH")); System.exit(2);