tx creation algorithm

This commit is contained in:
Craig Raw 2020-07-04 12:51:31 +02:00
parent 3ee7cd11eb
commit ccf7de9f62
6 changed files with 261 additions and 19 deletions

View file

@ -13,6 +13,7 @@ import java.util.stream.Collectors;
import static com.sparrowwallet.drongo.policy.PolicyType.*; import static com.sparrowwallet.drongo.policy.PolicyType.*;
import static com.sparrowwallet.drongo.protocol.Script.decodeFromOpN; import static com.sparrowwallet.drongo.protocol.Script.decodeFromOpN;
import static com.sparrowwallet.drongo.protocol.ScriptOpCodes.*; import static com.sparrowwallet.drongo.protocol.ScriptOpCodes.*;
import static com.sparrowwallet.drongo.protocol.Transaction.WITNESS_SCALE_FACTOR;
public enum ScriptType { public enum ScriptType {
P2PK("P2PK", "m/44'/0'/0'") { 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[] 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<ScriptType> getScriptTypesForPolicyType(PolicyType policyType) { public static List<ScriptType> getScriptTypesForPolicyType(PolicyType policyType) {
return Arrays.stream(values()).filter(scriptType -> scriptType.isAllowed(policyType)).collect(Collectors.toList()); return Arrays.stream(values()).filter(scriptType -> scriptType.isAllowed(policyType)).collect(Collectors.toList());
} }
@ -1044,6 +1049,30 @@ public enum ScriptType {
return null; 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 @Override
public String toString() { public String toString() {
return name; return name;

View file

@ -21,6 +21,8 @@ public class Transaction extends ChildMessage {
public static final long MAX_BITCOIN = 21 * 1000 * 1000L; public static final long MAX_BITCOIN = 21 * 1000 * 1000L;
public static final long SATOSHIS_PER_BITCOIN = 100 * 1000 * 1000L; public static final long SATOSHIS_PER_BITCOIN = 100 * 1000 * 1000L;
public static final long MAX_BLOCK_LOCKTIME = 500000000L; 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 version;
private long locktime; private long locktime;
@ -147,6 +149,18 @@ public class Transaction extends ChildMessage {
return false; 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 { public void bitcoinSerializeToStream(OutputStream stream) throws IOException {
boolean useSegwit = isSegwit(); boolean useSegwit = isSegwit();
bitcoinSerializeToStream(stream, useSegwit); bitcoinSerializeToStream(stream, useSegwit);
@ -253,19 +267,19 @@ public class Transaction extends ChildMessage {
int wu = 0; int wu = 0;
// version // version
wu += 4*4; wu += 4 * WITNESS_SCALE_FACTOR;
// marker, flag // marker, flag
if(isSegwit()) { if(isSegwit()) {
wu += 2; wu += 2;
} }
// txin_count, txins // txin_count, txins
wu += new VarInt(inputs.size()).getSizeInBytes() * 4; wu += new VarInt(inputs.size()).getSizeInBytes() * WITNESS_SCALE_FACTOR;
for (TransactionInput in : inputs) for (TransactionInput in : inputs)
wu += in.length * 4; wu += in.length * WITNESS_SCALE_FACTOR;
// txout_count, txouts // txout_count, txouts
wu += new VarInt(outputs.size()).getSizeInBytes() * 4; wu += new VarInt(outputs.size()).getSizeInBytes() * WITNESS_SCALE_FACTOR;
for (TransactionOutput out : outputs) for (TransactionOutput out : outputs)
wu += out.length * 4; wu += out.length * WITNESS_SCALE_FACTOR;
// script_witnesses // script_witnesses
if(isSegwit()) { if(isSegwit()) {
for (TransactionInput in : inputs) { for (TransactionInput in : inputs) {
@ -275,9 +289,9 @@ public class Transaction extends ChildMessage {
} }
} }
// lock_time // 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<TransactionInput> getInputs() { public List<TransactionInput> getInputs() {

View file

@ -0,0 +1,11 @@
package com.sparrowwallet.drongo.wallet;
public class InsufficientFundsException extends Exception {
public InsufficientFundsException() {
super();
}
public InsufficientFundsException(String msg) {
super(msg);
}
}

View file

@ -18,6 +18,9 @@ public class PriorityUtxoSelector implements UtxoSelector {
List<BlockTransactionHashIndex> sorted = candidates.stream().filter(ref -> ref.getHeight() != 0).collect(Collectors.toList()); List<BlockTransactionHashIndex> sorted = candidates.stream().filter(ref -> ref.getHeight() != 0).collect(Collectors.toList());
sort(sorted); sort(sorted);
//Testing only: remove
Collections.reverse(sorted);
long total = 0; long total = 0;
for(BlockTransactionHashIndex reference : sorted) { for(BlockTransactionHashIndex reference : sorted) {
if(total > targetValue) { if(total > targetValue) {

View file

@ -151,17 +151,45 @@ public class Wallet {
throw new IllegalStateException("Could not fill nodes to index " + index); 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<ECKey> getPubKeys(WalletNode node) {
return getPubKeys(node.getKeyPurpose(), node.getIndex());
}
public List<ECKey> 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) { public Address getAddress(WalletNode node) {
return getAddress(node.getKeyPurpose(), node.getIndex()); return getAddress(node.getKeyPurpose(), node.getIndex());
} }
public Address getAddress(KeyPurpose keyPurpose, int index) { public Address getAddress(KeyPurpose keyPurpose, int index) {
if(policyType == PolicyType.SINGLE) { if(policyType == PolicyType.SINGLE) {
Keystore keystore = getKeystores().get(0); ECKey pubKey = getPubKey(keyPurpose, index);
DeterministicKey key = keystore.getKey(keyPurpose, index); return scriptType.getAddress(pubKey);
return scriptType.getAddress(key);
} else if(policyType == PolicyType.MULTI) { } else if(policyType == PolicyType.MULTI) {
List<ECKey> pubKeys = getKeystores().stream().map(keystore -> keystore.getKey(keyPurpose, index)).collect(Collectors.toList()); List<ECKey> pubKeys = getPubKeys(keyPurpose, index);
Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys);
return scriptType.getAddress(script); return scriptType.getAddress(script);
} else { } else {
@ -175,11 +203,10 @@ public class Wallet {
public Script getOutputScript(KeyPurpose keyPurpose, int index) { public Script getOutputScript(KeyPurpose keyPurpose, int index) {
if(policyType == PolicyType.SINGLE) { if(policyType == PolicyType.SINGLE) {
Keystore keystore = getKeystores().get(0); ECKey pubKey = getPubKey(keyPurpose, index);
DeterministicKey key = keystore.getKey(keyPurpose, index); return scriptType.getOutputScript(pubKey);
return scriptType.getOutputScript(key);
} else if(policyType == PolicyType.MULTI) { } else if(policyType == PolicyType.MULTI) {
List<ECKey> pubKeys = getKeystores().stream().map(keystore -> keystore.getKey(keyPurpose, index)).collect(Collectors.toList()); List<ECKey> pubKeys = getPubKeys(keyPurpose, index);
Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys);
return scriptType.getOutputScript(script); return scriptType.getOutputScript(script);
} else { } else {
@ -193,11 +220,10 @@ public class Wallet {
public String getOutputDescriptor(KeyPurpose keyPurpose, int index) { public String getOutputDescriptor(KeyPurpose keyPurpose, int index) {
if(policyType == PolicyType.SINGLE) { if(policyType == PolicyType.SINGLE) {
Keystore keystore = getKeystores().get(0); ECKey pubKey = getPubKey(keyPurpose, index);
DeterministicKey key = keystore.getKey(keyPurpose, index); return scriptType.getOutputDescriptor(pubKey);
return scriptType.getOutputDescriptor(key);
} else if(policyType == PolicyType.MULTI) { } else if(policyType == PolicyType.MULTI) {
List<ECKey> pubKeys = getKeystores().stream().map(keystore -> keystore.getKey(keyPurpose, index)).collect(Collectors.toList()); List<ECKey> pubKeys = getPubKeys(keyPurpose, index);
Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys);
return scriptType.getOutputDescriptor(script); return scriptType.getOutputDescriptor(script);
} else { } else {
@ -222,6 +248,91 @@ public class Wallet {
} }
} }
public WalletTransaction createWalletTransaction(List<UtxoSelector> utxoSelectors, Address recipientAddress, long recipientAmount, double feeRate) throws InsufficientFundsException {
long valueRequiredAmt = recipientAmount;
while(true) {
Map<BlockTransactionHashIndex, WalletNode> selectedUtxos = selectInputs(utxoSelectors, valueRequiredAmt);
long totalSelectedAmt = selectedUtxos.keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum();
//Add inputs
Transaction transaction = new Transaction();
for(Map.Entry<BlockTransactionHashIndex, WalletNode> 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<ECKey> pubKeys = getPubKeys(selectedUtxo.getValue());
int threshold = getDefaultPolicy().getNumSignaturesRequired();
List<TransactionSignature> 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<BlockTransactionHashIndex, WalletNode> selectInputs(List<UtxoSelector> utxoSelectors, Long targetValue) throws InsufficientFundsException {
Map<BlockTransactionHashIndex, WalletNode> utxos = getWalletUtxos();
for(UtxoSelector utxoSelector : utxoSelectors) {
Collection<BlockTransactionHashIndex> 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() { public void clearNodes() {
purposeNodes.clear(); purposeNodes.clear();
transactions.clear(); transactions.clear();

View file

@ -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<BlockTransactionHashIndex, WalletNode> 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<BlockTransactionHashIndex, WalletNode> selectedUtxos, Address recipientAddress, long recipientAmount, long fee) {
this(wallet, transaction, selectedUtxos, recipientAddress, recipientAmount, null, 0L, fee);
}
public WalletTransaction(Wallet wallet, Transaction transaction, Map<BlockTransactionHashIndex, WalletNode> 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<BlockTransactionHashIndex, WalletNode> 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;
}
}