From 3fe43372a759dd4e27fc0ca712f6e0e405a4b3e5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 20 May 2022 21:21:51 +0100 Subject: [PATCH] Added PirateChainHTLC - uses a different HTLC format suitable for Pirate Chain. Also removes transaction building code, since this is handled by the light wallet library. --- .../qortal/crosschain/PirateChainHTLC.java | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 src/main/java/org/qortal/crosschain/PirateChainHTLC.java diff --git a/src/main/java/org/qortal/crosschain/PirateChainHTLC.java b/src/main/java/org/qortal/crosschain/PirateChainHTLC.java new file mode 100644 index 00000000..5ad1126d --- /dev/null +++ b/src/main/java/org/qortal/crosschain/PirateChainHTLC.java @@ -0,0 +1,318 @@ +package org.qortal.crosschain; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; +import org.bitcoinj.core.*; +import org.bitcoinj.core.Transaction.SigHash; +import org.bitcoinj.crypto.TransactionSignature; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; +import org.bitcoinj.script.ScriptChunk; +import org.bitcoinj.script.ScriptOpCodes; +import org.qortal.crypto.Crypto; +import org.qortal.utils.Base58; +import org.qortal.utils.BitTwiddling; + +import java.util.*; +import java.util.function.Function; + +public class PirateChainHTLC { + + public enum Status { + UNFUNDED, FUNDING_IN_PROGRESS, FUNDED, REDEEM_IN_PROGRESS, REDEEMED, REFUND_IN_PROGRESS, REFUNDED + } + + public static final int SECRET_LENGTH = 32; + public static final int MIN_LOCKTIME = 1500000000; + + public static final long NO_LOCKTIME_NO_RBF_SEQUENCE = 0xFFFFFFFFL; + public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1; + + // Assuming node's trade-bot has no more than 100 entries? + private static final int MAX_CACHE_ENTRIES = 100; + + // Max time-to-live for cache entries (milliseconds) + private static final long CACHE_TIMEOUT = 30_000L; + + @SuppressWarnings("serial") + private static final Map SECRET_CACHE = new LinkedHashMap<>(MAX_CACHE_ENTRIES + 1, 0.75F, true) { + // This method is called just after a new entry has been added + @Override + public boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_ENTRIES; + } + }; + private static final byte[] NO_SECRET_CACHE_ENTRY = new byte[0]; + + @SuppressWarnings("serial") + private static final Map STATUS_CACHE = new LinkedHashMap<>(MAX_CACHE_ENTRIES + 1, 0.75F, true) { + // This method is called just after a new entry has been added + @Override + public boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_ENTRIES; + } + }; + + /* + * OP_RETURN + OP_PUSHDATA1 + bytes (not part of actual redeem script - used for "push only" secondary output when funding P2SH) + * + * OP_IF (if top stack value isn't false) (true=refund; false=redeem) (boolean is then removed from stack) + * + * OP_CHECKLOCKTIMEVERIFY (if stack locktime greater than transaction's lock time - i.e. refunding but too soon - then fail validation) + * OP_DROP (remove locktime from top of stack) + * + * OP_CHECKSIG (check signature and public key are correct; returns 1 or 0) + * OP_ELSE (if top stack value was false, i.e. attempting to redeem) + * OP_SIZE (push length of top item - the secret - to the top of the stack) + * 32 + * OP_EQUALVERIFY (unhashed secret must be 32 bytes in length) + * OP_HASH160 (hash the secret) + * + * OP_EQUALVERIFY (ensure hash of supplied secret matches intended secret hash; transaction invalid if no match) + * + * OP_CHECKSIG (check signature and public key are correct; returns 1 or 0) + * OP_ENDIF + */ + + private static final byte[] pushOnlyPrefix = HashCode.fromString("6a4c").asBytes(); // OP_RETURN + push(redeem script) + private static final byte[] redeemScript1 = HashCode.fromString("6304").asBytes(); // OP_IF push(4 bytes locktime) + private static final byte[] redeemScript2 = HashCode.fromString("b17521").asBytes(); // OP_CHECKLOCKTIMEVERIFY OP_DROP push(33 bytes refund pubkey) + private static final byte[] redeemScript3 = HashCode.fromString("ac6782012088a914").asBytes(); // OP_CHECKSIG OP_ELSE OP_SIZE push(0x20) OP_EQUALVERIFY OP_HASH160 push(20 bytes hash of secret) + private static final byte[] redeemScript4 = HashCode.fromString("8821").asBytes(); // OP_EQUALVERIFY push(33 bytes redeem pubkey) + private static final byte[] redeemScript5 = HashCode.fromString("ac68").asBytes(); // OP_CHECKSIG OP_ENDIF + + /** + * Returns redeemScript used for cross-chain trading. + *

+ * See comments in {@link PirateChainHTLC} for more details. + * + * @param refunderPubKey 33-byte P2SH funder's public key, for refunding purposes + * @param lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund + * @param redeemerPubKey 33-byte P2SH redeemer's public key + * @param hashOfSecret 20-byte HASH160 of secret, used by P2SH redeemer to claim funds + */ + public static byte[] buildScript(byte[] refunderPubKey, int lockTime, byte[] redeemerPubKey, byte[] hashOfSecret) { + return Bytes.concat(redeemScript1, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)), redeemScript2, + refunderPubKey, redeemScript3, hashOfSecret, redeemScript4, redeemerPubKey, redeemScript5); + } + + /** + * Alternative to buildScript() above, this time with a prefix suitable for adding the redeem script + * to a "push only" output (via OP_RETURN followed by OP_PUSHDATA1) + * + * @param refunderPubKey 33-byte P2SH funder's public key, for refunding purposes + * @param lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund + * @param redeemerPubKey 33-byte P2SH redeemer's public key + * @param hashOfSecret 20-byte HASH160 of secret, used by P2SH redeemer to claim funds + * @return + */ + public static byte[] buildScriptWithPrefix(byte[] refunderPubKey, int lockTime, byte[] redeemerPubKey, byte[] hashOfSecret) { + byte[] redeemScript = buildScript(refunderPubKey, lockTime, redeemerPubKey, hashOfSecret); + int size = redeemScript.length; + String sizeHex = Integer.toHexString(size & 0xFF); + return Bytes.concat(pushOnlyPrefix, HashCode.fromString(sizeHex).asBytes(), redeemScript); + } + + /** + * Returns 'secret', if any, given HTLC's P2SH address. + *

+ * @throws ForeignBlockchainException + */ + public static byte[] findHtlcSecret(Bitcoiny bitcoiny, String p2shAddress) throws ForeignBlockchainException { + NetworkParameters params = bitcoiny.getNetworkParameters(); + String compoundKey = String.format("%s-%s-%d", params.getId(), p2shAddress, System.currentTimeMillis() / CACHE_TIMEOUT); + + byte[] secret = SECRET_CACHE.getOrDefault(compoundKey, NO_SECRET_CACHE_ENTRY); + if (secret != NO_SECRET_CACHE_ENTRY) + return secret; + + List rawTransactions = bitcoiny.getAddressTransactions(p2shAddress); + + for (byte[] rawTransaction : rawTransactions) { + Transaction transaction = new Transaction(params, rawTransaction); + + // Cycle through inputs, looking for one that spends our HTLC + for (TransactionInput input : transaction.getInputs()) { + Script scriptSig = input.getScriptSig(); + List scriptChunks = scriptSig.getChunks(); + + // Expected number of script chunks for redeem. Refund might not have the same number. + int expectedChunkCount = 1 /*secret*/ + 1 /*sig*/ + 1 /*pubkey*/ + 1 /*redeemScript*/; + if (scriptChunks.size() != expectedChunkCount) + continue; + + // We're expecting last chunk to contain the actual redeemScript + ScriptChunk lastChunk = scriptChunks.get(scriptChunks.size() - 1); + byte[] redeemScriptBytes = lastChunk.data; + + // If non-push scripts, redeemScript will be null + if (redeemScriptBytes == null) + continue; + + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); + Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + + if (!inputAddress.toString().equals(p2shAddress)) + // Input isn't spending our HTLC + continue; + + secret = scriptChunks.get(0).data; + if (secret.length != PirateChainHTLC.SECRET_LENGTH) + continue; + + // Cache secret for a while + SECRET_CACHE.put(compoundKey, secret); + + return secret; + } + } + + // Cache negative result + SECRET_CACHE.put(compoundKey, null); + + return null; + } + + /** + * Returns HTLC status, given P2SH address and expected redeem/refund amount + *

+ * @throws ForeignBlockchainException if error occurs + */ + public static Status determineHtlcStatus(BitcoinyBlockchainProvider blockchain, String p2shAddress, long minimumAmount) throws ForeignBlockchainException { + String compoundKey = String.format("%s-%s-%d", blockchain.getNetId(), p2shAddress, System.currentTimeMillis() / CACHE_TIMEOUT); + + Status cachedStatus = STATUS_CACHE.getOrDefault(compoundKey, null); + if (cachedStatus != null) + return cachedStatus; + + byte[] ourScriptPubKey = addressToScriptPubKey(p2shAddress); + + // Note: we can't include unconfirmed transactions here because the Pirate light wallet server requires a block range + List transactions = blockchain.getAddressBitcoinyTransactions(p2shAddress, BitcoinyBlockchainProvider.EXCLUDE_UNCONFIRMED); + + // Sort by confirmed first, followed by ascending height + transactions.sort(BitcoinyTransaction.CONFIRMED_FIRST.thenComparing(BitcoinyTransaction::getHeight)); + + // Transaction cache + //Map transactionsByHash = new HashMap<>(); + // HASH160(redeem script) for this p2shAddress + byte[] ourRedeemScriptHash = addressToRedeemScriptHash(p2shAddress); + + // Check for spends first, caching full transaction info as we progress just in case we don't return in this loop + for (BitcoinyTransaction bitcoinyTransaction : transactions) { + + // Cache for possible later reuse + // transactionsByHash.put(transactionInfo.txHash, bitcoinyTransaction); + + // Acceptable funding is one transaction output, so we're expecting only one input + if (bitcoinyTransaction.inputs.size() != 1) + // Wrong number of inputs + continue; + + String scriptSig = bitcoinyTransaction.inputs.get(0).scriptSig; + + List scriptSigChunks = extractScriptSigChunks(HashCode.fromString(scriptSig).asBytes()); + if (scriptSigChunks.size() < 3 || scriptSigChunks.size() > 4) + // Not valid chunks for our form of HTLC + continue; + + // Last chunk is redeem script + byte[] redeemScriptBytes = scriptSigChunks.get(scriptSigChunks.size() - 1); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); + if (!Arrays.equals(redeemScriptHash, ourRedeemScriptHash)) + // Not spending our specific HTLC redeem script + continue; + + if (scriptSigChunks.size() == 4) + // If we have 4 chunks, then secret is present, hence redeem + cachedStatus = bitcoinyTransaction.height == 0 ? Status.REDEEM_IN_PROGRESS : Status.REDEEMED; + else + cachedStatus = bitcoinyTransaction.height == 0 ? Status.REFUND_IN_PROGRESS : Status.REFUNDED; + + STATUS_CACHE.put(compoundKey, cachedStatus); + return cachedStatus; + } + + String ourScriptPubKeyHex = HashCode.fromBytes(ourScriptPubKey).toString(); + + // Check for funding + for (BitcoinyTransaction bitcoinyTransaction : transactions) { + if (bitcoinyTransaction == null) + // Should be present in map! + throw new ForeignBlockchainException("Cached Bitcoin transaction now missing?"); + + // Check outputs for our specific P2SH + for (BitcoinyTransaction.Output output : bitcoinyTransaction.outputs) { + // Check amount + if (output.value < minimumAmount) + // Output amount too small (not taking fees into account) + continue; + + String scriptPubKeyHex = output.scriptPubKey; + if (!scriptPubKeyHex.equals(ourScriptPubKeyHex)) + // Not funding our specific P2SH + continue; + + cachedStatus = bitcoinyTransaction.height == 0 ? Status.FUNDING_IN_PROGRESS : Status.FUNDED; + STATUS_CACHE.put(compoundKey, cachedStatus); + return cachedStatus; + } + } + + cachedStatus = Status.UNFUNDED; + STATUS_CACHE.put(compoundKey, cachedStatus); + return cachedStatus; + } + + private static List extractScriptSigChunks(byte[] scriptSigBytes) { + List chunks = new ArrayList<>(); + + int offset = 0; + int previousOffset = 0; + while (offset < scriptSigBytes.length) { + byte pushOp = scriptSigBytes[offset++]; + + if (pushOp < 0 || pushOp > 0x4c) + // Unacceptable OP + return Collections.emptyList(); + + // Special treatment for OP_PUSHDATA1 + if (pushOp == 0x4c) { + if (offset >= scriptSigBytes.length) + // Run out of scriptSig bytes? + return Collections.emptyList(); + + pushOp = scriptSigBytes[offset++]; + } + + previousOffset = offset; + offset += Byte.toUnsignedInt(pushOp); + + byte[] chunk = Arrays.copyOfRange(scriptSigBytes, previousOffset, offset); + chunks.add(chunk); + } + + return chunks; + } + + private static byte[] addressToScriptPubKey(String p2shAddress) { + // We want the HASH160 part of the P2SH address + byte[] p2shAddressBytes = Base58.decode(p2shAddress); + + byte[] scriptPubKey = new byte[1 + 1 + 20 + 1]; + scriptPubKey[0x00] = (byte) 0xa9; /* OP_HASH160 */ + scriptPubKey[0x01] = (byte) 0x14; /* PUSH 0x14 bytes */ + System.arraycopy(p2shAddressBytes, 1, scriptPubKey, 2, 0x14); + scriptPubKey[0x16] = (byte) 0x87; /* OP_EQUAL */ + + return scriptPubKey; + } + + private static byte[] addressToRedeemScriptHash(String p2shAddress) { + // We want the HASH160 part of the P2SH address + byte[] p2shAddressBytes = Base58.decode(p2shAddress); + + return Arrays.copyOfRange(p2shAddressBytes, 1, 1 + 20); + } + +}