From ccf7de9f625c4cc73efc6948b3e699a7786da276 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Sat, 4 Jul 2020 12:51:31 +0200 Subject: [PATCH] tx creation algorithm --- .../drongo/protocol/ScriptType.java | 29 ++++ .../drongo/protocol/Transaction.java | 28 +++- .../wallet/InsufficientFundsException.java | 11 ++ .../drongo/wallet/PriorityUtxoSelector.java | 3 + .../sparrowwallet/drongo/wallet/Wallet.java | 135 ++++++++++++++++-- .../drongo/wallet/WalletTransaction.java | 74 ++++++++++ 6 files changed, 261 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/drongo/wallet/InsufficientFundsException.java create mode 100644 src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java index 96cd790..ed5b87a 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java @@ -13,6 +13,7 @@ import java.util.stream.Collectors; import static com.sparrowwallet.drongo.policy.PolicyType.*; import static com.sparrowwallet.drongo.protocol.Script.decodeFromOpN; import static com.sparrowwallet.drongo.protocol.ScriptOpCodes.*; +import static com.sparrowwallet.drongo.protocol.Transaction.WITNESS_SCALE_FACTOR; public enum ScriptType { P2PK("P2PK", "m/44'/0'/0'") { @@ -1030,6 +1031,10 @@ public enum ScriptType { public static final ScriptType[] SINGLE_HASH_TYPES = {P2PKH, P2SH, P2SH_P2WPKH, P2SH_P2WSH, P2WPKH, P2WSH}; + public static final ScriptType[] NON_WITNESS_TYPES = {P2PK, P2PKH, P2SH}; + + public static final ScriptType[] WITNESS_TYPES = {P2SH_P2WPKH, P2SH_P2WSH, P2WPKH, P2WSH}; + public static List getScriptTypesForPolicyType(PolicyType policyType) { return Arrays.stream(values()).filter(scriptType -> scriptType.isAllowed(policyType)).collect(Collectors.toList()); } @@ -1044,6 +1049,30 @@ public enum ScriptType { return null; } + /** + * Determines the dust threshold for this script type. + * + * @param output The output under consideration + * @param feeRate The fee rate at which the fee required will be calculated + * @return the minimum viable value than the provided output must have in order to not be dust + */ + public long getDustThreshold(TransactionOutput output, Double feeRate) { + //Start with length of output + int totalLength = output.getLength(); + if(Arrays.asList(WITNESS_TYPES).contains(this)) { + //Add length of spending input with 75% discount to script size + totalLength += (32 + 4 + 1 + (107 / WITNESS_SCALE_FACTOR) + 4); + } else if(Arrays.asList(NON_WITNESS_TYPES).contains(this)) { + //Add length of spending input with no discount + totalLength += (32 + 4 + 1 + 107 + 4); + } else { + throw new UnsupportedOperationException("Cannot determine dust threshold for script type " + this.getName()); + } + + //Return fee rate in sats/vbyte multiplied by the calculated total byte length + return (long)(feeRate * totalLength); + } + @Override public String toString() { return name; diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java b/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java index b152f8d..65bd140 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java @@ -21,6 +21,8 @@ public class Transaction extends ChildMessage { public static final long MAX_BITCOIN = 21 * 1000 * 1000L; public static final long SATOSHIS_PER_BITCOIN = 100 * 1000 * 1000L; public static final long MAX_BLOCK_LOCKTIME = 500000000L; + public static final int WITNESS_SCALE_FACTOR = 4; + public static final double DEFAULT_DISCARD_FEE_RATE = 10000d / 1000; private long version; private long locktime; @@ -147,6 +149,18 @@ public class Transaction extends ChildMessage { return false; } + public byte[] bitcoinSerialize() { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + bitcoinSerializeToStream(outputStream); + return outputStream.toByteArray(); + } catch (IOException e) { + //can't happen + } + + return null; + } + public void bitcoinSerializeToStream(OutputStream stream) throws IOException { boolean useSegwit = isSegwit(); bitcoinSerializeToStream(stream, useSegwit); @@ -253,19 +267,19 @@ public class Transaction extends ChildMessage { int wu = 0; // version - wu += 4*4; + wu += 4 * WITNESS_SCALE_FACTOR; // marker, flag if(isSegwit()) { wu += 2; } // txin_count, txins - wu += new VarInt(inputs.size()).getSizeInBytes() * 4; + wu += new VarInt(inputs.size()).getSizeInBytes() * WITNESS_SCALE_FACTOR; for (TransactionInput in : inputs) - wu += in.length * 4; + wu += in.length * WITNESS_SCALE_FACTOR; // txout_count, txouts - wu += new VarInt(outputs.size()).getSizeInBytes() * 4; + wu += new VarInt(outputs.size()).getSizeInBytes() * WITNESS_SCALE_FACTOR; for (TransactionOutput out : outputs) - wu += out.length * 4; + wu += out.length * WITNESS_SCALE_FACTOR; // script_witnesses if(isSegwit()) { for (TransactionInput in : inputs) { @@ -275,9 +289,9 @@ public class Transaction extends ChildMessage { } } // lock_time - wu += 4*4; + wu += 4 * WITNESS_SCALE_FACTOR; - return (int)Math.ceil((double)wu / 4.0); + return (int)Math.ceil((double)wu / (double)WITNESS_SCALE_FACTOR); } public List getInputs() { diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/InsufficientFundsException.java b/src/main/java/com/sparrowwallet/drongo/wallet/InsufficientFundsException.java new file mode 100644 index 0000000..dca047e --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/InsufficientFundsException.java @@ -0,0 +1,11 @@ +package com.sparrowwallet.drongo.wallet; + +public class InsufficientFundsException extends Exception { + public InsufficientFundsException() { + super(); + } + + public InsufficientFundsException(String msg) { + super(msg); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/PriorityUtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/PriorityUtxoSelector.java index 8aed731..4ac2d67 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/PriorityUtxoSelector.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/PriorityUtxoSelector.java @@ -18,6 +18,9 @@ public class PriorityUtxoSelector implements UtxoSelector { List sorted = candidates.stream().filter(ref -> ref.getHeight() != 0).collect(Collectors.toList()); sort(sorted); + //Testing only: remove + Collections.reverse(sorted); + long total = 0; for(BlockTransactionHashIndex reference : sorted) { if(total > targetValue) { diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java index 0270ddf..f41d8ec 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java @@ -151,17 +151,45 @@ public class Wallet { throw new IllegalStateException("Could not fill nodes to index " + index); } + public ECKey getPubKey(WalletNode node) { + return getPubKey(node.getKeyPurpose(), node.getIndex()); + } + + public ECKey getPubKey(KeyPurpose keyPurpose, int index) { + if(policyType == PolicyType.MULTI) { + throw new IllegalStateException("Attempting to retrieve a single key for a multisig policy wallet"); + } else if(policyType == PolicyType.CUSTOM) { + throw new UnsupportedOperationException("Cannot determine a public key for a custom policy"); + } + + Keystore keystore = getKeystores().get(0); + return keystore.getKey(keyPurpose, index); + } + + public List getPubKeys(WalletNode node) { + return getPubKeys(node.getKeyPurpose(), node.getIndex()); + } + + public List getPubKeys(KeyPurpose keyPurpose, int index) { + if(policyType == PolicyType.SINGLE) { + throw new IllegalStateException("Attempting to retrieve multiple keys for a singlesig policy wallet"); + } else if(policyType == PolicyType.CUSTOM) { + throw new UnsupportedOperationException("Cannot determine public keys for a custom policy"); + } + + return getKeystores().stream().map(keystore -> keystore.getKey(keyPurpose, index)).collect(Collectors.toList()); + } + public Address getAddress(WalletNode node) { return getAddress(node.getKeyPurpose(), node.getIndex()); } public Address getAddress(KeyPurpose keyPurpose, int index) { if(policyType == PolicyType.SINGLE) { - Keystore keystore = getKeystores().get(0); - DeterministicKey key = keystore.getKey(keyPurpose, index); - return scriptType.getAddress(key); + ECKey pubKey = getPubKey(keyPurpose, index); + return scriptType.getAddress(pubKey); } else if(policyType == PolicyType.MULTI) { - List pubKeys = getKeystores().stream().map(keystore -> keystore.getKey(keyPurpose, index)).collect(Collectors.toList()); + List pubKeys = getPubKeys(keyPurpose, index); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); return scriptType.getAddress(script); } else { @@ -175,11 +203,10 @@ public class Wallet { public Script getOutputScript(KeyPurpose keyPurpose, int index) { if(policyType == PolicyType.SINGLE) { - Keystore keystore = getKeystores().get(0); - DeterministicKey key = keystore.getKey(keyPurpose, index); - return scriptType.getOutputScript(key); + ECKey pubKey = getPubKey(keyPurpose, index); + return scriptType.getOutputScript(pubKey); } else if(policyType == PolicyType.MULTI) { - List pubKeys = getKeystores().stream().map(keystore -> keystore.getKey(keyPurpose, index)).collect(Collectors.toList()); + List pubKeys = getPubKeys(keyPurpose, index); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); return scriptType.getOutputScript(script); } else { @@ -193,11 +220,10 @@ public class Wallet { public String getOutputDescriptor(KeyPurpose keyPurpose, int index) { if(policyType == PolicyType.SINGLE) { - Keystore keystore = getKeystores().get(0); - DeterministicKey key = keystore.getKey(keyPurpose, index); - return scriptType.getOutputDescriptor(key); + ECKey pubKey = getPubKey(keyPurpose, index); + return scriptType.getOutputDescriptor(pubKey); } else if(policyType == PolicyType.MULTI) { - List pubKeys = getKeystores().stream().map(keystore -> keystore.getKey(keyPurpose, index)).collect(Collectors.toList()); + List pubKeys = getPubKeys(keyPurpose, index); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); return scriptType.getOutputDescriptor(script); } else { @@ -222,6 +248,91 @@ public class Wallet { } } + public WalletTransaction createWalletTransaction(List utxoSelectors, Address recipientAddress, long recipientAmount, double feeRate) throws InsufficientFundsException { + long valueRequiredAmt = recipientAmount; + + while(true) { + Map selectedUtxos = selectInputs(utxoSelectors, valueRequiredAmt); + long totalSelectedAmt = selectedUtxos.keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); + + //Add inputs + Transaction transaction = new Transaction(); + for(Map.Entry selectedUtxo : selectedUtxos.entrySet()) { + Transaction prevTx = getTransactions().get(selectedUtxo.getKey().getHash()).getTransaction(); + TransactionOutput prevTxOut = prevTx.getOutputs().get((int)selectedUtxo.getKey().getIndex()); + + if(getPolicyType().equals(PolicyType.SINGLE)) { + ECKey pubKey = getPubKey(selectedUtxo.getValue()); + TransactionSignature signature = TransactionSignature.dummy(); + getScriptType().addSpendingInput(transaction, prevTxOut, pubKey, signature); + } else if(getPolicyType().equals(PolicyType.MULTI)) { + List pubKeys = getPubKeys(selectedUtxo.getValue()); + int threshold = getDefaultPolicy().getNumSignaturesRequired(); + List signatures = new ArrayList<>(threshold); + for(int i = 0; i < threshold; i++) { + signatures.add(TransactionSignature.dummy()); + } + getScriptType().addMultisigSpendingInput(transaction, prevTxOut, threshold, pubKeys, signatures); + } + } + + //Add recipient output + transaction.addOutput(recipientAmount, recipientAddress); + int noChangeVSize = transaction.getVirtualSize(); + long noChangeFeeRequiredAmt = (long)(feeRate * noChangeVSize); + + //Calculate what is left over from selected utxos after paying recipient + long differenceAmt = totalSelectedAmt - recipientAmount; + + //If insufficient fee, increase value required from inputs to include the fee and try again + if(differenceAmt < noChangeFeeRequiredAmt) { + valueRequiredAmt = totalSelectedAmt + 1; + continue; + } + + //Determine if a change output is required by checking if its value is greater than its dust threshold + long changeAmt = differenceAmt - noChangeFeeRequiredAmt; + WalletNode changeNode = getFreshNode(KeyPurpose.CHANGE); + TransactionOutput changeOutput = new TransactionOutput(transaction, changeAmt, getOutputScript(changeNode)); + long dustThreshold = getScriptType().getDustThreshold(changeOutput, Transaction.DEFAULT_DISCARD_FEE_RATE); + if(changeAmt > dustThreshold) { + //Change output is required, determine new fee once change output has been added + int changeVSize = noChangeVSize + changeOutput.getLength(); + long changeFeeRequiredAmt = (long)(feeRate * changeVSize); + + //Recalculate the change amount with the new fee + changeAmt = differenceAmt - changeFeeRequiredAmt; + if(changeAmt < dustThreshold) { + //The new fee has meant that the change output is now dust. We pay too high a fee without change, but change is dust when added. Increase value required from inputs and try again + valueRequiredAmt = totalSelectedAmt + 1; + continue; + } + + //Add change output + transaction.addOutput(changeAmt, getOutputScript(changeNode)); + + return new WalletTransaction(this, transaction, selectedUtxos, recipientAddress, recipientAmount, changeNode, changeAmt, changeFeeRequiredAmt); + } + + return new WalletTransaction(this, transaction, selectedUtxos, recipientAddress, recipientAmount, differenceAmt); + } + } + + private Map selectInputs(List utxoSelectors, Long targetValue) throws InsufficientFundsException { + Map utxos = getWalletUtxos(); + + for(UtxoSelector utxoSelector : utxoSelectors) { + Collection selectedInputs = utxoSelector.select(targetValue, utxos.keySet()); + long total = selectedInputs.stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); + if(total > targetValue) { + utxos.keySet().retainAll(selectedInputs); + return utxos; + } + } + + throw new InsufficientFundsException("Not enough combined value in UTXOs for output value " + targetValue); + } + public void clearNodes() { purposeNodes.clear(); transactions.clear(); diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java b/src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java new file mode 100644 index 0000000..12356e1 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java @@ -0,0 +1,74 @@ +package com.sparrowwallet.drongo.wallet; + +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.psbt.PSBT; + +import java.util.Map; + +/** + * WalletTransaction contains a draft transaction along with associated metadata. The draft transaction has empty signatures but is otherwise complete. + * This object represents an intermediate step before the transaction is signed or a PSBT is created from it. + */ +public class WalletTransaction { + private final Wallet wallet; + private final Transaction transaction; + private final Map selectedUtxos; + private final Address recipientAddress; + private final long recipientAmount; + private final WalletNode changeNode; + private final long changeAmount; + private final long fee; + + public WalletTransaction(Wallet wallet, Transaction transaction, Map selectedUtxos, Address recipientAddress, long recipientAmount, long fee) { + this(wallet, transaction, selectedUtxos, recipientAddress, recipientAmount, null, 0L, fee); + } + + public WalletTransaction(Wallet wallet, Transaction transaction, Map selectedUtxos, Address recipientAddress, long recipientAmount, WalletNode changeNode, long changeAmount, long fee) { + this.wallet = wallet; + this.transaction = transaction; + this.selectedUtxos = selectedUtxos; + this.recipientAddress = recipientAddress; + this.recipientAmount = recipientAmount; + this.changeNode = changeNode; + this.changeAmount = changeAmount; + this.fee = fee; + } + + public PSBT createPSBT() { + //TODO: Create PSBT + return null; + } + + public Wallet getWallet() { + return wallet; + } + + public Transaction getTransaction() { + return transaction; + } + + public Map getSelectedUtxos() { + return selectedUtxos; + } + + public Address getRecipientAddress() { + return recipientAddress; + } + + public long getRecipientAmount() { + return recipientAmount; + } + + public WalletNode getChangeNode() { + return changeNode; + } + + public long getChangeAmount() { + return changeAmount; + } + + public long getFee() { + return fee; + } +}