forked from Qortal/qortal
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.
This commit is contained in:
parent
c7bc1d7dcd
commit
3fe43372a7
318
src/main/java/org/qortal/crosschain/PirateChainHTLC.java
Normal file
318
src/main/java/org/qortal/crosschain/PirateChainHTLC.java
Normal file
@ -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…
Reference in New Issue
Block a user