mirror of
https://github.com/sparrowwallet/drongo.git
synced 2024-12-26 01:56:44 +00:00
tx creation algorithm
This commit is contained in:
parent
3ee7cd11eb
commit
ccf7de9f62
6 changed files with 261 additions and 19 deletions
|
@ -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<ScriptType> 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;
|
||||
|
|
|
@ -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<TransactionInput> getInputs() {
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
package com.sparrowwallet.drongo.wallet;
|
||||
|
||||
public class InsufficientFundsException extends Exception {
|
||||
public InsufficientFundsException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public InsufficientFundsException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
|
@ -18,6 +18,9 @@ public class PriorityUtxoSelector implements UtxoSelector {
|
|||
List<BlockTransactionHashIndex> 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) {
|
||||
|
|
|
@ -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<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) {
|
||||
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<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);
|
||||
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<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);
|
||||
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<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);
|
||||
return scriptType.getOutputDescriptor(script);
|
||||
} 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() {
|
||||
purposeNodes.clear();
|
||||
transactions.clear();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue