Browse Source

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.
pirate-chain
CalDescent 2 years ago
parent
commit
3fe43372a7
  1. 318
      src/main/java/org/qortal/crosschain/PirateChainHTLC.java

318
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<String, byte[]> 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<String, byte[]> eldest) {
return size() > MAX_CACHE_ENTRIES;
}
};
private static final byte[] NO_SECRET_CACHE_ENTRY = new byte[0];
@SuppressWarnings("serial")
private static final Map<String, Status> 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<String, Status> 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)
* <push 4 bytes> <intended locktime>
* 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)
* <push 33 bytes> <intended refunder public key>
* 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)
* <push 1 byte> 32
* OP_EQUALVERIFY (unhashed secret must be 32 bytes in length)
* OP_HASH160 (hash the secret)
* <push 20 bytes> <intended secret hash>
* OP_EQUALVERIFY (ensure hash of supplied secret matches intended secret hash; transaction invalid if no match)
* <push 33 bytes> <intended redeemer public key>
* 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.
* <p>
* 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.
* <p>
* @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<byte[]> 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<ScriptChunk> 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
* <p>
* @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<BitcoinyTransaction> 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<String, BitcoinyTransaction> 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<byte[]> 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<byte[]> extractScriptSigChunks(byte[] scriptSigBytes) {
List<byte[]> 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);
}
}
Loading…
Cancel
Save