diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransaction.java b/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransaction.java index c474e78..bf866a9 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransaction.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransaction.java @@ -32,20 +32,20 @@ public class BlockTransaction extends BlockTransactionHash implements Comparable } @Override - public int compareTo(BlockTransaction blockchainTransaction) { - if(getHeight() != blockchainTransaction.getHeight()) { - return getHeight() - blockchainTransaction.getHeight(); + public int compareTo(BlockTransaction blkTx) { + if(getHeight() != blkTx.getHeight()) { + return (getHeight() > 0 ? getHeight() : Integer.MAX_VALUE) - (blkTx.getHeight() > 0 ? blkTx.getHeight() : Integer.MAX_VALUE); } - if(getReferencedOutpoints(this).removeAll(getOutputs(blockchainTransaction))) { + if(getReferencedOutpoints(this).removeAll(getOutputs(blkTx))) { return 1; } - if(getReferencedOutpoints(blockchainTransaction).removeAll(getOutputs(this))) { + if(getReferencedOutpoints(blkTx).removeAll(getOutputs(this))) { return -1; } - return super.compareTo(blockchainTransaction); + return super.compareTo(blkTx); } private static List getReferencedOutpoints(BlockTransaction blockchainTransaction) { diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHash.java b/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHash.java index db6566f..a8e75dc 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHash.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHash.java @@ -67,9 +67,8 @@ public abstract class BlockTransactionHash { } public int compareTo(BlockTransactionHash reference) { - int heightDiff = height - reference.height; - if(heightDiff != 0) { - return heightDiff; + if(height != reference.height) { + return (height > 0 ? height : Integer.MAX_VALUE) - (reference.height > 0 ? reference.height : Integer.MAX_VALUE); } return hash.compareTo(reference.hash); diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/OutputGroup.java b/src/main/java/com/sparrowwallet/drongo/wallet/OutputGroup.java index a350d04..42f74d9 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/OutputGroup.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/OutputGroup.java @@ -7,6 +7,7 @@ import static com.sparrowwallet.drongo.protocol.Transaction.WITNESS_SCALE_FACTOR public class OutputGroup { private final List utxos = new ArrayList<>(); + private final int walletBlockHeight; private final long inputWeightUnits; private final double feeRate; private final double longTermFeeRate; @@ -14,26 +15,24 @@ public class OutputGroup { private long effectiveValue = 0; private long fee = 0; private long longTermFee = 0; + private int depth = Integer.MAX_VALUE; + private boolean allInputsFromWallet = true; - public OutputGroup(long inputWeightUnits, double feeRate, double longTermFeeRate) { + public OutputGroup(int walletBlockHeight, long inputWeightUnits, double feeRate, double longTermFeeRate) { + this.walletBlockHeight = walletBlockHeight; this.inputWeightUnits = inputWeightUnits; this.feeRate = feeRate; this.longTermFeeRate = longTermFeeRate; } - public OutputGroup(long inputWeightUnits, double feeRate, double longTermFeeRate, BlockTransactionHashIndex utxo) { - this.inputWeightUnits = inputWeightUnits; - this.feeRate = feeRate; - this.longTermFeeRate = longTermFeeRate; - add(utxo); - } - - public void add(BlockTransactionHashIndex utxo) { + public void add(BlockTransactionHashIndex utxo, boolean allInputsFromWallet) { utxos.add(utxo); value += utxo.getValue(); effectiveValue += utxo.getValue() - (long)(inputWeightUnits * feeRate / WITNESS_SCALE_FACTOR); fee += (long)(inputWeightUnits * feeRate / WITNESS_SCALE_FACTOR); longTermFee += (long)(inputWeightUnits * longTermFeeRate / WITNESS_SCALE_FACTOR); + depth = utxo.getHeight() <= 0 ? 0 : Math.min(depth, walletBlockHeight - utxo.getHeight() + 1); + this.allInputsFromWallet &= allInputsFromWallet; } public void remove(BlockTransactionHashIndex utxo) { @@ -64,4 +63,30 @@ public class OutputGroup { public long getLongTermFee() { return longTermFee; } + + public int getDepth() { + return depth; + } + + public boolean isAllInputsFromWallet() { + return allInputsFromWallet; + } + + public static class Filter { + private final int minWalletConfirmations; + private final int minExternalConfirmations; + + public Filter(int minWalletConfirmations, int minExternalConfirmations) { + this.minWalletConfirmations = minWalletConfirmations; + this.minExternalConfirmations = minExternalConfirmations; + } + + public boolean isEligible(OutputGroup outputGroup) { + if(outputGroup.isAllInputsFromWallet()) { + return outputGroup.getDepth() >= minWalletConfirmations; + } + + return outputGroup.getDepth() >= minExternalConfirmations; + } + } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/PriorityUtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/PriorityUtxoSelector.java index 482ddd6..3ca1125 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/PriorityUtxoSelector.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/PriorityUtxoSelector.java @@ -15,7 +15,7 @@ public class PriorityUtxoSelector implements UtxoSelector { public Collection select(long targetValue, Collection candidates) { List selected = new ArrayList<>(); - List sorted = candidates.stream().flatMap(outputGroup -> outputGroup.getUtxos().stream()).filter(ref -> ref.getHeight() != 0).collect(Collectors.toList()); + List sorted = candidates.stream().flatMap(outputGroup -> outputGroup.getUtxos().stream()).filter(ref -> ref.getHeight() > 0).collect(Collectors.toList()); sort(sorted); //Testing only: remove diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java index 5d158d5..562e607 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java @@ -233,12 +233,29 @@ public class Wallet { } } + public boolean isWalletTxo(BlockTransactionHashIndex txo) { + return getWalletTxos().containsKey(txo); + } + + public Map getWalletTxos() { + Map walletTxos = new TreeMap<>(); + getWalletTxos(walletTxos, getNode(KeyPurpose.RECEIVE)); + getWalletTxos(walletTxos, getNode(KeyPurpose.CHANGE)); + return walletTxos; + } + + private void getWalletTxos(Map walletTxos, WalletNode purposeNode) { + for(WalletNode addressNode : purposeNode.getChildren()) { + for(BlockTransactionHashIndex txo : addressNode.getTransactionOutputs()) { + walletTxos.put(txo, addressNode); + } + } + } + public Map getWalletUtxos() { Map walletUtxos = new TreeMap<>(); - getWalletUtxos(walletUtxos, getNode(KeyPurpose.RECEIVE)); getWalletUtxos(walletUtxos, getNode(KeyPurpose.CHANGE)); - return walletUtxos; } @@ -357,11 +374,11 @@ public class Wallet { return getFee(changeOutput, feeRate, longTermFeeRate); } - public WalletTransaction createWalletTransaction(List utxoSelectors, Address recipientAddress, long recipientAmount, double feeRate, double longTermFeeRate, Long fee, boolean sendAll) throws InsufficientFundsException { + public WalletTransaction createWalletTransaction(List utxoSelectors, Address recipientAddress, long recipientAmount, double feeRate, double longTermFeeRate, Long fee, boolean sendAll, boolean groupByAddress, boolean includeMempoolChange) throws InsufficientFundsException { long valueRequiredAmt = recipientAmount; while(true) { - Map selectedUtxos = selectInputs(utxoSelectors, valueRequiredAmt, feeRate, longTermFeeRate); + Map selectedUtxos = selectInputs(utxoSelectors, valueRequiredAmt, feeRate, longTermFeeRate, groupByAddress, includeMempoolChange); long totalSelectedAmt = selectedUtxos.keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); //Add inputs @@ -434,22 +451,85 @@ public class Wallet { } } - private Map selectInputs(List utxoSelectors, Long targetValue, double feeRate, double longTermFeeRate) throws InsufficientFundsException { - Map utxos = getWalletUtxos(); + private Map selectInputs(List utxoSelectors, Long targetValue, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeMempoolChange) throws InsufficientFundsException { + List utxoPool = getGroupedUtxos(feeRate, longTermFeeRate, groupByAddress); - for(UtxoSelector utxoSelector : utxoSelectors) { - List utxoPool = utxos.keySet().stream().map(utxo -> new OutputGroup(getInputWeightUnits(), feeRate, longTermFeeRate, utxo)).collect(Collectors.toList()); - Collection selectedInputs = utxoSelector.select(targetValue, utxoPool); - long total = selectedInputs.stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); - if(total > targetValue) { - utxos.keySet().retainAll(selectedInputs); - return utxos; + List filters = new ArrayList<>(); + filters.add(new OutputGroup.Filter(1, 6)); + filters.add(new OutputGroup.Filter(1, 1)); + if(includeMempoolChange) { + filters.add(new OutputGroup.Filter(0, 1)); + } + + for(OutputGroup.Filter filter : filters) { + List filteredPool = utxoPool.stream().filter(filter::isEligible).collect(Collectors.toList()); + + for(UtxoSelector utxoSelector : utxoSelectors) { + Collection selectedInputs = utxoSelector.select(targetValue, filteredPool); + long total = selectedInputs.stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); + if(total > targetValue) { + Map utxos = getWalletUtxos(); + utxos.keySet().retainAll(selectedInputs); + return utxos; + } } } throw new InsufficientFundsException("Not enough combined value in UTXOs for output value " + targetValue); } + private List getGroupedUtxos(double feeRate, double longTermFeeRate, boolean groupByAddress) { + List outputGroups = new ArrayList<>(); + getGroupedUtxos(outputGroups, getNode(KeyPurpose.RECEIVE), feeRate, longTermFeeRate, groupByAddress); + getGroupedUtxos(outputGroups, getNode(KeyPurpose.CHANGE), feeRate, longTermFeeRate, groupByAddress); + return outputGroups; + } + + private void getGroupedUtxos(List outputGroups, WalletNode purposeNode, double feeRate, double longTermFeeRate, boolean groupByAddress) { + for(WalletNode addressNode : purposeNode.getChildren()) { + OutputGroup outputGroup = null; + for(BlockTransactionHashIndex utxo : addressNode.getUnspentTransactionOutputs()) { + if(outputGroup == null || !groupByAddress) { + outputGroup = new OutputGroup(getStoredBlockHeight(), getInputWeightUnits(), feeRate, longTermFeeRate); + outputGroups.add(outputGroup); + } + + outputGroup.add(utxo, allInputsFromWallet(utxo.getHash())); + } + } + } + + /** + * Determines if the provided wallet transaction was created from a purely internal transaction + * + * @param txId The txid + * @return Whether the transaction was created entirely from inputs that reference outputs that belong to this wallet + */ + private boolean allInputsFromWallet(Sha256Hash txId) { + BlockTransaction utxoBlkTx = getTransactions().get(txId); + if(utxoBlkTx == null) { + throw new IllegalArgumentException("Provided txId was not a wallet transaction"); + } + + for(int i = 0; i < utxoBlkTx.getTransaction().getInputs().size(); i++) { + TransactionInput utxoTxInput = utxoBlkTx.getTransaction().getInputs().get(i); + BlockTransaction prevBlkTx = getTransactions().get(utxoTxInput.getOutpoint().getHash()); + if(prevBlkTx == null) { + return false; + } + + int index = (int)utxoTxInput.getOutpoint().getIndex(); + TransactionOutput prevTxOut = prevBlkTx.getTransaction().getOutputs().get(index); + BlockTransactionHashIndex spendingTXI = new BlockTransactionHashIndex(utxoBlkTx.getHash(), utxoBlkTx.getHeight(), utxoBlkTx.getDate(), utxoBlkTx.getFee(), i, prevTxOut.getValue()); + BlockTransactionHashIndex spentTXO = new BlockTransactionHashIndex(prevBlkTx.getHash(), prevBlkTx.getHeight(), prevBlkTx.getDate(), prevBlkTx.getFee(), index, prevTxOut.getValue(), spendingTXI); + if(!isWalletTxo(spentTXO)) { + return false; + } + } + + return true; + } + public BitcoinUnit getAutoUnit() { for(KeyPurpose keyPurpose : KeyPurpose.values()) { for(WalletNode addressNode : getNode(keyPurpose).getChildren()) {