From 8844cc0076dcbba7f6d17c6352f3e185807fee4b Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 27 Mar 2020 18:24:41 +0000 Subject: [PATCH] GetTransaction test app to demo fetching any bitcoin transaction using bitcoinj. Plus some AT-API work --- src/main/java/org/qora/at/BlockchainAPI.java | 153 ++++++++++++++++++ .../org/qortal/at/QortalFunctionCode.java | 84 +++++++++- src/main/java/org/qortal/crosschain/BTC.java | 98 ++++++++++- .../org/qora/test/btcacct/GetTransaction.java | 64 ++++++++ .../java/org/qora/test/btcacct/Redeem.java | 2 +- .../java/org/qora/test/btcacct/Refund.java | 2 +- 6 files changed, 390 insertions(+), 13 deletions(-) create mode 100644 src/main/java/org/qora/at/BlockchainAPI.java create mode 100644 src/test/java/org/qora/test/btcacct/GetTransaction.java diff --git a/src/main/java/org/qora/at/BlockchainAPI.java b/src/main/java/org/qora/at/BlockchainAPI.java new file mode 100644 index 00000000..b8cd8c90 --- /dev/null +++ b/src/main/java/org/qora/at/BlockchainAPI.java @@ -0,0 +1,153 @@ +package org.qora.at; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import org.ciyam.at.MachineState; +import org.ciyam.at.Timestamp; +import org.qora.account.Account; +import org.qora.block.Block; +import org.qora.data.block.BlockData; +import org.qora.data.transaction.ATTransactionData; +import org.qora.data.transaction.PaymentTransactionData; +import org.qora.data.transaction.TransactionData; +import org.qora.repository.BlockRepository; +import org.qora.repository.DataException; +import org.qora.transaction.Transaction; + +public enum BlockchainAPI { + + QORA(0) { + @Override + public void putTransactionFromRecipientAfterTimestampInA(String recipient, Timestamp timestamp, MachineState state) { + int height = timestamp.blockHeight; + int sequence = timestamp.transactionSequence + 1; + + QoraATAPI api = (QoraATAPI) state.getAPI(); + BlockRepository blockRepository = api.getRepository().getBlockRepository(); + + try { + Account recipientAccount = new Account(api.getRepository(), recipient); + + while (height <= blockRepository.getBlockchainHeight()) { + BlockData blockData = blockRepository.fromHeight(height); + + if (blockData == null) + throw new DataException("Unable to fetch block " + height + " from repository?"); + + Block block = new Block(api.getRepository(), blockData); + + List transactions = block.getTransactions(); + + // No more transactions in this block? Try next block + if (sequence >= transactions.size()) { + ++height; + sequence = 0; + continue; + } + + Transaction transaction = transactions.get(sequence); + + // Transaction needs to be sent to specified recipient + if (transaction.getRecipientAccounts().contains(recipientAccount)) { + // Found a transaction + + api.setA1(state, new Timestamp(height, timestamp.blockchainId, sequence).longValue()); + + // Hash transaction's signature into other three A fields for future verification that it's the same transaction + byte[] hash = QoraATAPI.sha192(transaction.getTransactionData().getSignature()); + + api.setA2(state, QoraATAPI.fromBytes(hash, 0)); + api.setA3(state, QoraATAPI.fromBytes(hash, 8)); + api.setA4(state, QoraATAPI.fromBytes(hash, 16)); + return; + } + + // Transaction wasn't for us - keep going + ++sequence; + } + + // No more transactions - zero A and exit + api.zeroA(state); + } catch (DataException e) { + throw new RuntimeException("AT API unable to fetch next transaction?", e); + } + } + + @Override + public long getAmountFromTransactionInA(Timestamp timestamp, MachineState state) { + QoraATAPI api = (QoraATAPI) state.getAPI(); + TransactionData transactionData = api.fetchTransaction(state); + + switch (transactionData.getType()) { + case PAYMENT: + return ((PaymentTransactionData) transactionData).getAmount().unscaledValue().longValue(); + + case AT: + BigDecimal amount = ((ATTransactionData) transactionData).getAmount(); + + if (amount != null) + return amount.unscaledValue().longValue(); + else + return 0xffffffffffffffffL; + + default: + return 0xffffffffffffffffL; + } + } + + @Override + public TransactionOutput getIndexedOutputFromTransactionInA(MachineState state, int outputIndex) { + // TODO + return null; + } + }, + BTC(1) { + @Override + public void putTransactionFromRecipientAfterTimestampInA(String recipient, Timestamp timestamp, MachineState state) { + // TODO BTC transaction support for ATv2 + } + + @Override + public long getAmountFromTransactionInA(Timestamp timestamp, MachineState state) { + // TODO BTC transaction support for ATv2 + return 0; + } + + @Override + public TransactionOutput getIndexedOutputFromTransactionInA(MachineState state, int outputIndex) { + // TODO + return null; + } + }; + + public static class TransactionOutput { + byte[] recipient; + long amount; + } + + public final int value; + + private static final Map map = stream(BlockchainAPI.values()).collect(toMap(type -> type.value, type -> type)); + + BlockchainAPI(int value) { + this.value = value; + } + + public static BlockchainAPI valueOf(int value) { + return map.get(value); + } + + // Blockchain-specific API methods + + public abstract void putTransactionFromRecipientAfterTimestampInA(String recipient, Timestamp timestamp, MachineState state); + + public abstract long getAmountFromTransactionInA(Timestamp timestamp, MachineState state); + + public abstract TransactionOutput getIndexedOutputFromTransactionInA(MachineState state, int outputIndex); + +} diff --git a/src/main/java/org/qortal/at/QortalFunctionCode.java b/src/main/java/org/qortal/at/QortalFunctionCode.java index f7d089cf..1c68b244 100644 --- a/src/main/java/org/qortal/at/QortalFunctionCode.java +++ b/src/main/java/org/qortal/at/QortalFunctionCode.java @@ -10,6 +10,8 @@ import org.ciyam.at.FunctionData; import org.ciyam.at.IllegalFunctionCodeException; import org.ciyam.at.MachineState; import org.ciyam.at.Timestamp; +import org.qortal.crypto.Crypto; +import org.qortal.settings.Settings; /** * Qortal-specific CIYAM-AT Functions. @@ -20,7 +22,7 @@ import org.ciyam.at.Timestamp; public enum QortalFunctionCode { /** * 0x0500
- * Returns current BTC block's "timestamp" + * Returns current BTC block's "timestamp". */ GET_BTC_BLOCK_TIMESTAMP(0x0500, 0, true) { @Override @@ -30,7 +32,7 @@ public enum QortalFunctionCode { }, /** * 0x0501
- * Put transaction from specific recipient after timestamp in A, or zero if none
+ * Put transaction from specific recipient after timestamp in A, or zero if none. */ PUT_TX_FROM_B_RECIPIENT_AFTER_TIMESTAMP_IN_A(0x0501, 1, false) { @Override @@ -42,13 +44,74 @@ public enum QortalFunctionCode { BlockchainAPI blockchainAPI = BlockchainAPI.valueOf(timestamp.blockchainId); blockchainAPI.putTransactionFromRecipientAfterTimestampInA(recipient, timestamp, state); } + }, + /** + * 0x0502
+ * Get output, using transaction in A and passed index, putting address in B and returning amount.
+ * Return -1 if no such output; + */ + GET_INDEXED_OUTPUT(0x0502, 1, true) { + @Override + protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { + int outputIndex = (int) (functionData.value1 & 0xffffffffL); + + BlockchainAPI.TransactionOutput output = BlockchainAPI.BTC.getIndexedOutputFromTransactionInA(state, outputIndex); + + if (output == null) { + functionData.returnValue = -1L; + return; + } + + state.getAPI().setB(state, output.recipient); + functionData.returnValue = output.amount; + } + }, + /** + * 0x0510
+ * Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3. + */ + CONVERT_B_TO_PKH(0x0510, 0, false) { + @Override + protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { + // Needs to be 'B' sized + byte[] pkh = new byte[32]; + + // Copy PKH part of B to last 20 bytes + System.arraycopy(state.getB(), 32 - 20 - 4, pkh, 32 - 20, 20); + + state.getAPI().setB(state, pkh); + } + }, + /** + * 0x0511
+ * Convert 20-byte value in LSB of B1, and all of B2 & B3 to P2SH.
+ * P2SH stored in lower 25 bytes of B. + */ + CONVERT_B_TO_P2SH(0x0511, 0, false) { + @Override + protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { + byte addressPrefix = Settings.getInstance().useBitcoinTestNet() ? (byte) 0xc4 : 0x05; + + convertAddressInB(addressPrefix, state); + } + }, + /** + * 0x0512
+ * Convert 20-byte value in LSB of B1, and all of B2 & B3 to Qortal address.
+ * Qortal address stored in lower 25 bytes of B. + */ + CONVERT_B_TO_QORTAL(0x0512, 0, false) { + @Override + protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { + convertAddressInB(Crypto.ADDRESS_VERSION, state); + } }; public final short value; public final int paramCount; public final boolean returnsValue; - private final static Map map = Arrays.stream(QortalFunctionCode.values()) + private static final Map map = Arrays.stream(QortalFunctionCode.values()) .collect(Collectors.toMap(functionCode -> functionCode.value, functionCode -> functionCode)); private QortalFunctionCode(int value, int paramCount, boolean returnsValue) { @@ -100,4 +163,19 @@ public enum QortalFunctionCode { /** Actually execute function */ protected abstract void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException; + private static void convertAddressInB(byte addressPrefix, MachineState state) { + byte[] addressNoChecksum = new byte[1 + 20]; + addressNoChecksum[0] = addressPrefix; + System.arraycopy(state.getB(), 0, addressNoChecksum, 1, 20); + + byte[] checksum = Crypto.doubleDigest(addressNoChecksum); + + // Needs to be 'B' sized + byte[] address = new byte[32]; + System.arraycopy(addressNoChecksum, 0, address, 32 - 1 - 20 - 4, addressNoChecksum.length); + System.arraycopy(checksum, 0, address, 32 - 4, 4); + + state.getAPI().setB(state, address); + } + } diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 7eaee53b..f46e0536 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -10,7 +10,6 @@ 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.security.DigestOutputStream; @@ -21,6 +20,7 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; +import java.util.function.Function; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -28,22 +28,28 @@ 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.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.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.utils.Threading; import org.bitcoinj.wallet.Wallet; +import org.bitcoinj.wallet.WalletTransaction; +import org.bitcoinj.wallet.WalletTransaction.Pool; import org.bitcoinj.wallet.listeners.WalletCoinsReceivedEventListener; import org.bitcoinj.wallet.listeners.WalletCoinsSentEventListener; import org.qortal.settings.Settings; @@ -168,10 +174,12 @@ public class BTC { private BTC() { if (Settings.getInstance().useBitcoinTestNet()) { + /* this.params = RegTestParams.get(); this.checkpointsFileName = "checkpoints-regtest.txt"; - // TestNet3Params.get(); - // this.checkpointsFileName = "checkpoints-testnet.txt"; + */ + this.params = TestNet3Params.get(); + this.checkpointsFileName = "checkpoints-testnet.txt"; } else { this.params = MainNetParams.get(); this.checkpointsFileName = "checkpoints.txt"; @@ -256,7 +264,25 @@ public class BTC { return Wallet.createBasic(this.params); } - private void replayChain(long startTime, Wallet wallet) throws BlockStoreException { + 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(long startTime, Wallet wallet, ReplayHooks replayHooks) throws BlockStoreException { this.start(startTime); final WalletCoinsReceivedEventListener coinsReceivedListener = (someWallet, tx, prevBalance, newBalance) -> { @@ -277,12 +303,18 @@ public class BTC { } 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); @@ -296,7 +328,7 @@ public class BTC { } private void replayChain(long startTime) throws BlockStoreException { - this.replayChain(startTime, null); + this.replayChain(startTime, null, null); } // Actual useful methods for use by other classes @@ -334,7 +366,7 @@ public class BTC { wallet.addWatchedAddress(address, startTime); try { - replayChain(startTime, wallet); + replayChain(startTime, wallet, null); // Now that blockchain is up-to-date, return current balance return wallet.getBalance(); @@ -344,13 +376,13 @@ public class BTC { } } - public List getUnspentOutputs(String base58Address, long startTime) { + public List getOutputs(String base58Address, long startTime) { Wallet wallet = createEmptyWallet(); Address address = Address.fromString(this.params, base58Address); wallet.addWatchedAddress(address, startTime); try { - replayChain(startTime, wallet); + replayChain(startTime, wallet, null); // Now that blockchain is up-to-date, return outputs return wallet.getWatchedOutputs(true); @@ -360,4 +392,54 @@ public class BTC { } } + private static class TransactionStorage { + private Transaction transaction; + + public void store(Transaction transaction) { + this.transaction = transaction; + } + + public Transaction getTransaction() { + return this.transaction; + } + } + + public List getOutputs(byte[] txId, long startTime) { + 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 TransactionStorage transactionStorage = new TransactionStorage(); + + final BlocksDownloadedEventListener listener = (peer, block, filteredBlock, blocksLeft) -> { + List transactions = block.getTransactions(); + + if (transactions == null) + return; + + for (Transaction transaction : transactions) + if (transaction.getTxId().equals(txHash)) { + System.out.println(String.format("We downloaded block containing tx!")); + transactionStorage.store(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 = transactionStorage.getTransaction(); + return realTx.getOutputs(); + } catch (BlockStoreException e) { + LOGGER.error(String.format("BTC blockstore issue: %s", e.getMessage())); + return null; + } + } + } diff --git a/src/test/java/org/qora/test/btcacct/GetTransaction.java b/src/test/java/org/qora/test/btcacct/GetTransaction.java new file mode 100644 index 00000000..9686a3f2 --- /dev/null +++ b/src/test/java/org/qora/test/btcacct/GetTransaction.java @@ -0,0 +1,64 @@ +package org.qora.test.btcacct; + +import java.security.Security; +import java.util.List; + +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.TransactionOutput; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.qora.crosschain.BTC; +import org.qora.settings.Settings; + +import com.google.common.hash.HashCode; + +public class GetTransaction { + + static { + // This must go before any calls to LogManager/Logger + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + } + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: GetTransaction ")); + System.err.println(String.format("example: GetTransaction 816407e79568f165f13e09e9912c5f2243e0a23a007cec425acedc2e89284660 (mainnet)")); + System.err.println(String.format("example: GetTransaction 3bfd17a492a4e3d6cb7204e17e20aca6c1ab82e1828bd1106eefbaf086fb8a4e (testnet)")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length < 1 || args.length > 1) + usage(null); + + Security.insertProviderAt(new BouncyCastleProvider(), 0); + Settings.fileInstance("settings-test.json"); + + byte[] transactionId = null; + + try { + int argIndex = 0; + + transactionId = HashCode.fromString(args[argIndex++]).asBytes(); + } catch (NumberFormatException | AddressFormatException e) { + usage(String.format("Argument format exception: %s", e.getMessage())); + } + + // Chain replay start time + long startTime = (System.currentTimeMillis() / 1000L) - 14 * 24 * 60 * 60; // 14 days before now, in seconds + + // Grab all outputs from transaction + List fundingOutputs = BTC.getInstance().getOutputs(transactionId, startTime); + if (fundingOutputs == null) { + System.out.println(String.format("Transaction not found")); + return; + } + + System.out.println(String.format("Found %d output%s", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : ""))); + + for (TransactionOutput fundingOutput : fundingOutputs) + System.out.println(String.format("Output %d: %s", fundingOutput.getIndex(), fundingOutput.getValue().toPlainString())); + } + +} diff --git a/src/test/java/org/qora/test/btcacct/Redeem.java b/src/test/java/org/qora/test/btcacct/Redeem.java index 1ba65bce..503fc710 100644 --- a/src/test/java/org/qora/test/btcacct/Redeem.java +++ b/src/test/java/org/qora/test/btcacct/Redeem.java @@ -167,7 +167,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().getUnspentOutputs(p2shAddress.toString(), lockTime - REFUND_TIMEOUT); + List fundingOutputs = BTC.getInstance().getOutputs(p2shAddress.toString(), lockTime - REFUND_TIMEOUT); System.out.println(String.format("Found %d unspent output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : ""))); if (fundingOutputs.isEmpty()) { diff --git a/src/test/java/org/qora/test/btcacct/Refund.java b/src/test/java/org/qora/test/btcacct/Refund.java index 69cddde7..662bf63e 100644 --- a/src/test/java/org/qora/test/btcacct/Refund.java +++ b/src/test/java/org/qora/test/btcacct/Refund.java @@ -171,7 +171,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().getUnspentOutputs(p2shAddress.toString(), lockTime - REFUND_TIMEOUT); + List fundingOutputs = BTC.getInstance().getOutputs(p2shAddress.toString(), lockTime - REFUND_TIMEOUT); System.out.println(String.format("Found %d unspent output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : ""))); if (fundingOutputs.isEmpty()) {