use txo filters for all wallet transaction output filtering

This commit is contained in:
Craig Raw 2023-07-11 09:07:22 +02:00
parent 6a7d2aac28
commit 8484dd397b
9 changed files with 106 additions and 75 deletions

View file

@ -2,10 +2,10 @@ package com.sparrowwallet.drongo.wallet;
import com.sparrowwallet.drongo.protocol.Transaction;
public class CoinbaseUtxoFilter implements UtxoFilter {
public class CoinbaseTxoFilter implements TxoFilter {
private final Wallet wallet;
public CoinbaseUtxoFilter(Wallet wallet) {
public CoinbaseTxoFilter(Wallet wallet) {
this.wallet = wallet;
}

View file

@ -0,0 +1,31 @@
package com.sparrowwallet.drongo.wallet;
import java.util.ArrayList;
import java.util.Collection;
public class ExcludeTxoFilter implements TxoFilter {
private final Collection<BlockTransactionHashIndex> excludedTxos;
public ExcludeTxoFilter() {
this.excludedTxos = new ArrayList<>();
}
public ExcludeTxoFilter(Collection<BlockTransactionHashIndex> excludedTxos) {
this.excludedTxos = new ArrayList<>(excludedTxos);
}
@Override
public boolean isEligible(BlockTransactionHashIndex candidate) {
for(BlockTransactionHashIndex excludedTxo : excludedTxos) {
if(candidate.getHash().equals(excludedTxo.getHash()) && candidate.getIndex() == excludedTxo.getIndex()) {
return false;
}
}
return true;
}
public Collection<BlockTransactionHashIndex> getExcludedTxos() {
return excludedTxos;
}
}

View file

@ -1,31 +0,0 @@
package com.sparrowwallet.drongo.wallet;
import java.util.ArrayList;
import java.util.Collection;
public class ExcludeUtxoFilter implements UtxoFilter {
private final Collection<BlockTransactionHashIndex> excludedUtxos;
public ExcludeUtxoFilter() {
this.excludedUtxos = new ArrayList<>();
}
public ExcludeUtxoFilter(Collection<BlockTransactionHashIndex> excludedUtxos) {
this.excludedUtxos = new ArrayList<>(excludedUtxos);
}
@Override
public boolean isEligible(BlockTransactionHashIndex candidate) {
for(BlockTransactionHashIndex excludedUtxo : excludedUtxos) {
if(candidate.getHash().equals(excludedUtxo.getHash()) && candidate.getIndex() == excludedUtxo.getIndex()) {
return false;
}
}
return true;
}
public Collection<BlockTransactionHashIndex> getExcludedUtxos() {
return excludedUtxos;
}
}

View file

@ -1,6 +1,6 @@
package com.sparrowwallet.drongo.wallet;
public class FrozenUtxoFilter implements UtxoFilter {
public class FrozenTxoFilter implements TxoFilter {
@Override
public boolean isEligible(BlockTransactionHashIndex candidate) {
return candidate.getStatus() == null || candidate.getStatus() != Status.FROZEN;

View file

@ -1,14 +1,13 @@
package com.sparrowwallet.drongo.wallet;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.*;
import java.util.stream.Collectors;
public class PresetUtxoSelector extends SingleSetUtxoSelector {
private final Collection<BlockTransactionHashIndex> presetUtxos;
private final Collection<BlockTransactionHashIndex> excludedUtxos;
private final boolean maintainOrder;
private final boolean requireAll;
public PresetUtxoSelector(Collection<BlockTransactionHashIndex> presetUtxos) {
this(presetUtxos, new ArrayList<>());
@ -18,12 +17,14 @@ public class PresetUtxoSelector extends SingleSetUtxoSelector {
this.presetUtxos = presetUtxos;
this.excludedUtxos = excludedUtxos;
this.maintainOrder = false;
this.requireAll = false;
}
public PresetUtxoSelector(Collection<BlockTransactionHashIndex> presetUtxos, boolean maintainOrder) {
public PresetUtxoSelector(Collection<BlockTransactionHashIndex> presetUtxos, boolean maintainOrder, boolean requireAll) {
this.presetUtxos = presetUtxos;
this.excludedUtxos = new ArrayList<>();
this.maintainOrder = maintainOrder;
this.requireAll = requireAll;
}
@Override
@ -40,8 +41,11 @@ public class PresetUtxoSelector extends SingleSetUtxoSelector {
}
}
if(maintainOrder && utxos.containsAll(presetUtxos)) {
Set<BlockTransactionHashIndex> utxosSet = new HashSet<>(utxos);
if(maintainOrder && utxosSet.containsAll(presetUtxos)) {
return presetUtxos;
} else if(requireAll && !utxosSet.containsAll(presetUtxos)) {
return Collections.emptyList();
}
return utxos;

View file

@ -0,0 +1,24 @@
package com.sparrowwallet.drongo.wallet;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
public class SpentTxoFilter implements TxoFilter {
private final Sha256Hash replacedTxid;
public SpentTxoFilter() {
replacedTxid = null;
}
public SpentTxoFilter(Sha256Hash replacedTxid) {
this.replacedTxid = replacedTxid;
}
@Override
public boolean isEligible(BlockTransactionHashIndex candidate) {
return !isSpentOrReplaced(candidate);
}
private boolean isSpentOrReplaced(BlockTransactionHashIndex candidate) {
return candidate.getHash().equals(replacedTxid) || (candidate.isSpent() && !candidate.getSpentBy().getHash().equals(replacedTxid));
}
}

View file

@ -1,5 +1,5 @@
package com.sparrowwallet.drongo.wallet;
public interface UtxoFilter {
public interface TxoFilter {
boolean isEligible(BlockTransactionHashIndex candidate);
}

View file

@ -799,30 +799,38 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
}
public Map<BlockTransactionHashIndex, WalletNode> getWalletUtxos() {
return getWalletUtxos(false);
return getWalletTxos(List.of(new SpentTxoFilter()));
}
public Map<BlockTransactionHashIndex, WalletNode> getWalletUtxos(boolean includeSpentMempoolOutputs) {
Map<BlockTransactionHashIndex, WalletNode> walletUtxos = new TreeMap<>();
public Map<BlockTransactionHashIndex, WalletNode> getSpendableUtxos() {
return getWalletTxos(List.of(new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(this)));
}
public Map<BlockTransactionHashIndex, WalletNode> getSpendableUtxos(BlockTransaction replacedTransaction) {
return getWalletTxos(List.of(new SpentTxoFilter(replacedTransaction == null ? null : replacedTransaction.getHash()), new FrozenTxoFilter(), new CoinbaseTxoFilter(this)));
}
public Map<BlockTransactionHashIndex, WalletNode> getWalletTxos(Collection<TxoFilter> txoFilters) {
Map<BlockTransactionHashIndex, WalletNode> walletTxos = new TreeMap<>();
for(KeyPurpose keyPurpose : getWalletKeyPurposes()) {
getWalletUtxos(walletUtxos, getNode(keyPurpose), includeSpentMempoolOutputs);
getWalletTxos(walletTxos, getNode(keyPurpose), txoFilters);
}
for(Wallet childWallet : getChildWallets()) {
if(childWallet.isNested()) {
for(KeyPurpose keyPurpose : childWallet.getWalletKeyPurposes()) {
getWalletUtxos(walletUtxos, childWallet.getNode(keyPurpose), includeSpentMempoolOutputs);
getWalletTxos(walletTxos, childWallet.getNode(keyPurpose), txoFilters);
}
}
}
return walletUtxos;
return walletTxos;
}
private void getWalletUtxos(Map<BlockTransactionHashIndex, WalletNode> walletUtxos, WalletNode purposeNode, boolean includeSpentMempoolOutputs) {
private void getWalletTxos(Map<BlockTransactionHashIndex, WalletNode> walletTxos, WalletNode purposeNode, Collection<TxoFilter> txoFilters) {
for(WalletNode addressNode : purposeNode.getChildren()) {
for(BlockTransactionHashIndex utxo : addressNode.getUnspentTransactionOutputs(includeSpentMempoolOutputs)) {
walletUtxos.put(utxo, addressNode);
for(BlockTransactionHashIndex utxo : addressNode.getTransactionOutputs(txoFilters)) {
walletTxos.put(utxo, addressNode);
}
}
}
@ -981,29 +989,30 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
return getFee(changeOutput, feeRate, longTermFeeRate);
}
public WalletTransaction createWalletTransaction(List<UtxoSelector> utxoSelectors, List<UtxoFilter> utxoFilters, List<Payment> payments, List<byte[]> opReturns, Set<WalletNode> excludedChangeNodes, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs) throws InsufficientFundsException {
public WalletTransaction createWalletTransaction(List<UtxoSelector> utxoSelectors, List<TxoFilter> txoFilters, List<Payment> payments, List<byte[]> opReturns, Set<WalletNode> excludedChangeNodes, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs) throws InsufficientFundsException {
boolean sendMax = payments.stream().anyMatch(Payment::isSendMax);
long totalPaymentAmount = payments.stream().map(Payment::getAmount).mapToLong(v -> v).sum();
long totalUtxoValue = getWalletUtxos().keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum();
Map<BlockTransactionHashIndex, WalletNode> availableTxos = getWalletTxos(txoFilters);
long totalAvailableValue = availableTxos.keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum();
if(fee != null && feeRate != Transaction.DEFAULT_MIN_RELAY_FEE) {
throw new IllegalArgumentException("Use an input fee rate of 1 sat/vB when using a defined fee amount so UTXO selectors overestimate effective value");
}
long maxSpendableAmt = getMaxSpendable(payments.stream().map(Payment::getAddress).collect(Collectors.toList()), feeRate, includeSpentMempoolOutputs);
long maxSpendableAmt = getMaxSpendable(payments.stream().map(Payment::getAddress).collect(Collectors.toList()), feeRate, availableTxos);
if(maxSpendableAmt < 0) {
throw new InsufficientFundsException("Not enough combined value in all available UTXOs to send a transaction to the provided addresses at this fee rate");
}
//When a user fee is set, we can calculate the fees to spend all UTXOs because we assume all UTXOs are spendable at a fee rate of 1 sat/vB
//We can then add the user set fee less this amount as a "phantom payment amount" to the value required to find (which cannot include transaction fees)
long valueRequiredAmt = totalPaymentAmount + (fee != null ? fee - (totalUtxoValue - maxSpendableAmt) : 0);
long valueRequiredAmt = totalPaymentAmount + (fee != null ? fee - (totalAvailableValue - maxSpendableAmt) : 0);
if(maxSpendableAmt < valueRequiredAmt) {
throw new InsufficientFundsException("Not enough combined value in all available UTXOs to send a transaction to send the provided payments at the user set fee" + (fee == null ? " rate" : ""));
}
while(true) {
List<Map<BlockTransactionHashIndex, WalletNode>> selectedUtxoSets = selectInputSets(utxoSelectors, utxoFilters, valueRequiredAmt, feeRate, longTermFeeRate, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs, sendMax);
List<Map<BlockTransactionHashIndex, WalletNode>> selectedUtxoSets = selectInputSets(availableTxos, utxoSelectors, txoFilters, valueRequiredAmt, feeRate, longTermFeeRate, groupByAddress, includeMempoolOutputs, sendMax);
Map<BlockTransactionHashIndex, WalletNode> selectedUtxos = new LinkedHashMap<>();
selectedUtxoSets.forEach(selectedUtxos::putAll);
long totalSelectedAmt = selectedUtxos.keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum();
@ -1076,7 +1085,7 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
if(differenceAmt < noChangeFeeRequiredAmt) {
valueRequiredAmt = totalSelectedAmt + 1;
//If we haven't selected all UTXOs yet, don't require more than the max spendable amount
if(valueRequiredAmt > maxSpendableAmt && transaction.getInputs().size() < getWalletUtxos().size()) {
if(valueRequiredAmt > maxSpendableAmt && transaction.getInputs().size() < availableTxos.size()) {
valueRequiredAmt = maxSpendableAmt;
}
@ -1180,8 +1189,8 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
}
}
private List<Map<BlockTransactionHashIndex, WalletNode>> selectInputSets(List<UtxoSelector> utxoSelectors, List<UtxoFilter> utxoFilters, Long targetValue, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs, boolean sendMax) throws InsufficientFundsException {
List<OutputGroup> utxoPool = getGroupedUtxos(utxoFilters, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs);
private List<Map<BlockTransactionHashIndex, WalletNode>> selectInputSets(Map<BlockTransactionHashIndex, WalletNode> availableTxos, List<UtxoSelector> utxoSelectors, List<TxoFilter> txoFilters, Long targetValue, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeMempoolOutputs, boolean sendMax) throws InsufficientFundsException {
List<OutputGroup> utxoPool = getGroupedUtxos(txoFilters, feeRate, longTermFeeRate, groupByAddress);
List<OutputGroup.Filter> filters = new ArrayList<>();
filters.add(new OutputGroup.Filter(1, 6, false));
@ -1204,7 +1213,6 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
List<Collection<BlockTransactionHashIndex>> selectedInputSets = utxoSelector.selectSets(targetValue, filteredPool);
List<Map<BlockTransactionHashIndex, WalletNode>> selectedInputSetsList = new ArrayList<>();
long total = 0;
Map<BlockTransactionHashIndex, WalletNode> utxos = getWalletUtxos(includeSpentMempoolOutputs);
for(Collection<BlockTransactionHashIndex> selectedInputs : selectedInputSets) {
total += selectedInputs.stream().mapToLong(BlockTransactionHashIndex::getValue).sum();
Map<BlockTransactionHashIndex, WalletNode> selectedInputsMap = new LinkedHashMap<>();
@ -1213,7 +1221,7 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
Collections.shuffle(shuffledInputs);
}
for(BlockTransactionHashIndex shuffledInput : shuffledInputs) {
selectedInputsMap.put(shuffledInput, utxos.get(shuffledInput));
selectedInputsMap.put(shuffledInput, availableTxos.get(shuffledInput));
}
selectedInputSetsList.add(selectedInputsMap);
}
@ -1227,18 +1235,18 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
throw new InsufficientFundsException("Not enough combined value in UTXOs for output value " + targetValue, targetValue);
}
private List<OutputGroup> getGroupedUtxos(List<UtxoFilter> utxoFilters, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeSpentMempoolOutputs) {
public List<OutputGroup> getGroupedUtxos(List<TxoFilter> txoFilters, double feeRate, double longTermFeeRate, boolean groupByAddress) {
List<OutputGroup> outputGroups = new ArrayList<>();
Map<Sha256Hash, BlockTransaction> walletTransactions = getWalletTransactions();
Map<BlockTransactionHashIndex, WalletNode> walletTxos = getWalletTxos();
for(KeyPurpose keyPurpose : getWalletKeyPurposes()) {
getGroupedUtxos(outputGroups, getNode(keyPurpose), utxoFilters, walletTransactions, walletTxos, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs);
getGroupedUtxos(outputGroups, getNode(keyPurpose), txoFilters, walletTransactions, walletTxos, feeRate, longTermFeeRate, groupByAddress);
}
for(Wallet childWallet : getChildWallets()) {
if(childWallet.isNested()) {
for(KeyPurpose keyPurpose : childWallet.getWalletKeyPurposes()) {
childWallet.getGroupedUtxos(outputGroups, childWallet.getNode(keyPurpose), utxoFilters, walletTransactions, walletTxos, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs);
childWallet.getGroupedUtxos(outputGroups, childWallet.getNode(keyPurpose), txoFilters, walletTransactions, walletTxos, feeRate, longTermFeeRate, groupByAddress);
}
}
}
@ -1246,16 +1254,11 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
return outputGroups;
}
private void getGroupedUtxos(List<OutputGroup> outputGroups, WalletNode purposeNode, List<UtxoFilter> utxoFilters, Map<Sha256Hash, BlockTransaction> walletTransactions, Map<BlockTransactionHashIndex, WalletNode> walletTxos, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeSpentMempoolOutputs) {
private void getGroupedUtxos(List<OutputGroup> outputGroups, WalletNode purposeNode, List<TxoFilter> txoFilters, Map<Sha256Hash, BlockTransaction> walletTransactions, Map<BlockTransactionHashIndex, WalletNode> walletTxos, double feeRate, double longTermFeeRate, boolean groupByAddress) {
int inputWeightUnits = getInputWeightUnits();
for(WalletNode addressNode : purposeNode.getChildren()) {
OutputGroup outputGroup = null;
for(BlockTransactionHashIndex utxo : addressNode.getUnspentTransactionOutputs(includeSpentMempoolOutputs)) {
Optional<UtxoFilter> matchedFilter = utxoFilters.stream().filter(utxoFilter -> !utxoFilter.isEligible(utxo)).findAny();
if(matchedFilter.isPresent()) {
continue;
}
for(BlockTransactionHashIndex utxo : addressNode.getTransactionOutputs(txoFilters)) {
if(outputGroup == null || !groupByAddress) {
outputGroup = new OutputGroup(addressNode.getWallet().getScriptType(), getStoredBlockHeight(), inputWeightUnits, feeRate, longTermFeeRate);
outputGroups.add(outputGroup);
@ -1323,12 +1326,12 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
* @param feeRate the fee rate in sats/vB
* @return the maximum spendable amount (can be negative if the fee is higher than the combined UTXO value)
*/
public long getMaxSpendable(List<Address> paymentAddresses, double feeRate, boolean includeSpentMempoolOutputs) {
public long getMaxSpendable(List<Address> paymentAddresses, double feeRate, Map<BlockTransactionHashIndex, WalletNode> availableTxos) {
long maxInputValue = 0;
Map<Wallet, Integer> cachedInputWeightUnits = new HashMap<>();
Transaction transaction = new Transaction();
for(Map.Entry<BlockTransactionHashIndex, WalletNode> utxo : getWalletUtxos(includeSpentMempoolOutputs).entrySet()) {
for(Map.Entry<BlockTransactionHashIndex, WalletNode> utxo : availableTxos.entrySet()) {
int inputWeightUnits = cachedInputWeightUnits.computeIfAbsent(utxo.getValue().getWallet(), Wallet::getInputWeightUnits);
long minInputValue = (long)Math.ceil(feeRate * inputWeightUnits / WITNESS_SCALE_FACTOR);
if(utxo.getKey().getValue() > minInputValue) {

View file

@ -168,16 +168,16 @@ public class WalletNode extends Persistable implements Comparable<WalletNode> {
}
public Set<BlockTransactionHashIndex> getUnspentTransactionOutputs() {
return getUnspentTransactionOutputs(false);
return getTransactionOutputs(List.of(new SpentTxoFilter()));
}
public Set<BlockTransactionHashIndex> getUnspentTransactionOutputs(boolean includeSpentMempoolOutputs) {
public Set<BlockTransactionHashIndex> getTransactionOutputs(Collection<TxoFilter> txoFilters) {
if(transactionOutputs.isEmpty()) {
return Collections.emptySet();
}
Set<BlockTransactionHashIndex> unspentTXOs = new TreeSet<>(transactionOutputs);
unspentTXOs.removeIf(txo -> txo.isSpent() && (!includeSpentMempoolOutputs || txo.getSpentBy().getHeight() > 0));
unspentTXOs.removeIf(txo -> !txoFilters.stream().allMatch(txoFilter -> txoFilter.isEligible(txo)));
return unspentTXOs;
}