forked from Qortal/qortal
Browse Source
Also removes transaction building code, since this is handled by the light wallet library.pirate-chain
CalDescent
2 years ago
1 changed files with 318 additions and 0 deletions
@ -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