diff --git a/.gitignore b/.gitignore index ffd2e7fcf..0f5791160 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ obj/ libs/ *.chain *.spvchain -*.wallet +/*.wallet *.tmp checkpoints checkpoints-testnet @@ -21,7 +21,8 @@ checkpoints-testnet.txt checkpoints.txt checkpoints-krupnik checkpoints-krupnik.txt -*.mnlist -*.chainlocks +/*.mnlist +/*.chainlocks *.gobjects -*.log \ No newline at end of file +/*.log +.DS_Store \ No newline at end of file diff --git a/core/build.gradle b/core/build.gradle index d6764bec8..eb792c18c 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -66,6 +66,7 @@ test { exclude 'org/bitcoinj/protocols/channels/ChannelConnectionTest*' exclude 'org/bitcoinj/core/LevelDBFullPrunedBlockChainTest*' exclude 'org/bitcoinj/store/LevelDBBlockStoreTest*' + maxHeapSize = "2g" // Increase heap for large wallet tests testLogging { events "failed" exceptionFormat "full" diff --git a/core/src/main/java/org/bitcoinj/coinjoin/CoinJoinCoinSelector.java b/core/src/main/java/org/bitcoinj/coinjoin/CoinJoinCoinSelector.java index a732c6b6b..90fdf495b 100644 --- a/core/src/main/java/org/bitcoinj/coinjoin/CoinJoinCoinSelector.java +++ b/core/src/main/java/org/bitcoinj/coinjoin/CoinJoinCoinSelector.java @@ -14,6 +14,8 @@ package org.bitcoinj.coinjoin; +import com.google.common.base.Stopwatch; +import com.google.common.collect.Maps; import org.bitcoinj.core.Coin; import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.Transaction; @@ -22,50 +24,388 @@ import org.bitcoinj.core.TransactionOutput; import org.bitcoinj.wallet.CoinSelection; import org.bitcoinj.wallet.ZeroConfCoinSelector; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; + import static com.google.common.base.Preconditions.checkArgument; public class CoinJoinCoinSelector extends ZeroConfCoinSelector { + private final Logger log = LoggerFactory.getLogger(CoinJoinCoinSelector.class); private final TransactionBag transactionBag; - private boolean onlyConfirmed; + private final boolean onlyConfirmed; + private final boolean useGreedyAlgorithm; public CoinJoinCoinSelector(TransactionBag transactionBag) { - this(transactionBag, false); + this(transactionBag, false, false); } - public CoinJoinCoinSelector(TransactionBag transactionBag, boolean onlyConfirmed) { + public CoinJoinCoinSelector(TransactionBag transactionBag, boolean onlyConfirmed, boolean useGreedyAlgorithm) { checkArgument(transactionBag != null, "transactionBag cannot be null"); this.transactionBag = transactionBag; this.onlyConfirmed = onlyConfirmed; + this.useGreedyAlgorithm = useGreedyAlgorithm; } @Override public CoinSelection select(Coin target, List candidates) { + Stopwatch watch = Stopwatch.createStarted(); ArrayList selected = new ArrayList<>(); // Sort the inputs by age*value so we get the highest "coindays" spent. // TODO: Consider changing the wallets internal format to track just outputs and keep them ordered. ArrayList sortedOutputs = new ArrayList<>(candidates); // When calculating the wallet balance, we may be asked to select all possible coins, if so, avoid sorting // them in order to improve performance. - // TODO: Take in network parameters when instanatiated, and then test against the current network. Or just have a boolean parameter for "give me everything" + // TODO: Take in network parameters when instantiated, and then test against the current network. Or just have a boolean parameter for "give me everything" if (!target.equals(NetworkParameters.MAX_MONEY)) { sortOutputs(sortedOutputs); } // Now iterate over the sorted outputs until we have got as close to the target as possible or a little // bit over (excessive value will be change). long total = 0; - for (TransactionOutput output : sortedOutputs) { - if (total >= target.value) - break; - // don't bother with shouldSelect, since we have to check all the outputs - // with isCoinJoin anyways. - if (output.isCoinJoin(transactionBag) && !transactionBag.isLockedOutput(output.getOutPointFor())) { - selected.add(output); - total += output.getValue().value; + if (useGreedyAlgorithm) { + for (TransactionOutput output : sortedOutputs) { + // don't bother with shouldSelect, since we have to check all the outputs + // with isCoinJoin anyways. + if (output.isCoinJoin(transactionBag) && !transactionBag.isLockedOutput(output.getOutPointFor())) { + selected.add(output); + total += output.getValue().value; + } + } + HashMap> denomMap = Maps.newHashMap(); + selected.forEach(output -> + denomMap.computeIfAbsent(output.getValue(), k -> new ArrayList<>()).add(output) + ); + // randomly order each list + denomMap.values().forEach(Collections::shuffle); + + // Fee calculation constant: 0.00001000 DASH per kB + Coin feePerKb = Transaction.DEFAULT_TX_FEE; + + // Create a working copy of denomMap to avoid modifying the original + HashMap> workingDenomMap = new HashMap<>(); + denomMap.forEach((denom, outputs) -> workingDenomMap.put(denom, new ArrayList<>(outputs))); + + // Sort denominations from largest to smallest + List denoms = new ArrayList<>(workingDenomMap.keySet()); + denoms.sort(Comparator.reverseOrder()); + + ArrayList bestSelection = findBestCombination(workingDenomMap, denoms, target.value, feePerKb); + + if (bestSelection != null) { + long bestTotal = bestSelection.stream().mapToLong(o -> o.getValue().value).sum(); + log.info("found candidates({}): {}, {}", useGreedyAlgorithm, watch, bestSelection.size()); + return new CoinSelection(Coin.valueOf(bestTotal), bestSelection); + } else { + // Fallback to all available outputs (use original denomMap, not depleted workingDenomMap) + ArrayList allOutputs = new ArrayList<>(); + denomMap.values().forEach(allOutputs::addAll); + long bestTotal = allOutputs.stream().mapToLong(o -> o.getValue().value).sum(); + log.info("found candidates({}): {}, {}", useGreedyAlgorithm, watch, allOutputs.size()); + return new CoinSelection(Coin.valueOf(bestTotal), allOutputs); + } + } else { + for (TransactionOutput output : sortedOutputs) { + if (total >= target.value) + break; + // don't bother with shouldSelect, since we have to check all the outputs + // with isCoinJoin anyways. + if (output.isCoinJoin(transactionBag) && !transactionBag.isLockedOutput(output.getOutPointFor())) { + selected.add(output); + total += output.getValue().value; + } + } + log.info("found candidates({}): {}, {}", useGreedyAlgorithm, watch, selected.size()); + return new CoinSelection(Coin.valueOf(total), selected); + } + } + + private ArrayList findBestCombination(HashMap> workingDenomMap, + List denomsDescending, long target, Coin feePerKb) { + + log.info("Target: " + Coin.valueOf(target).toFriendlyString()); + + // Create ascending sorted list (reuse descending, just iterate backwards when needed) + List denomsAscending = new ArrayList<>(denomsDescending); + Collections.reverse(denomsAscending); + + ArrayList selection = new ArrayList<>(); + long selectionTotal = 0; // Track running total instead of repeated stream operations + long remaining = target; // Start with just the target amount + + log.info("Starting greedy selection for target: " + Coin.valueOf(remaining).toFriendlyString()); + + // Phase 1: Use largest denominations that are <= remaining amount + for (Coin denom : denomsDescending) { + if (remaining <= 0) break; + + ArrayList availableOutputs = workingDenomMap.get(denom); + if (availableOutputs == null || availableOutputs.isEmpty()) continue; + + // Only use this denomination if it's <= remaining amount + if (denom.value <= remaining) { + int neededCount = (int) (remaining / denom.value); + int availableCount = availableOutputs.size(); + int useCount = Math.min(neededCount, availableCount); + + log.info("Phase 1 - Using {} of {} (available: {}, needed: {})", useCount, denom.toFriendlyString(), availableCount, neededCount); + + for (int i = 0; i < useCount; i++) { + selection.add(availableOutputs.remove(availableOutputs.size() - 1)); + remaining -= denom.value; + selectionTotal += denom.value; + } + } + } + + // Calculate fee based on actual transaction structure: + // Transaction header: version(4) + input_count(1) + output_count(1) + locktime(4) = 10 bytes + // Input: outpoint(36) + script_len(1) + script_sig(~107) + sequence(4) = ~148 bytes + // Output: value(8) + script_len(1) + script_pubkey(25) = 34 bytes + // Based on your measurement: 1 input + 1 output = 193 bytes + + int numInputs = selection.size(); + + // Transaction structure breakdown: + // Header: 10 bytes (version + varint counts + locktime) + // Each input: ~148 bytes (36 + 1 + ~107 + 4) for P2PKH signed input + // Each output: 34 bytes (8 + 1 + 25) for P2PKH output + + int txSize; + if (numInputs == 0) { + // Minimum case: need at least 1 input + txSize = 10 + 148 + 34; // header + 1_input + 1_output = 192 bytes (close to your 193) + } else { + // Formula based on actual structure + txSize = 10 + (numInputs * 148) + 34; // header + inputs + 1_output + } + + long calculatedFee = (feePerKb.value * txSize) / 1000; + remaining += calculatedFee; // Add fee to remaining needed + + log.info("After Phase 1: {} inputs selected, fee needed: {}, still need: {}", + selection.size(), Coin.valueOf(calculatedFee).toFriendlyString(), Coin.valueOf(remaining).toFriendlyString()); + + // Phase 2: If we still need more (for fee), use smallest denominations first + if (remaining > 0) { + log.info("Phase 2 - Using smallest denominations first for fee coverage"); + + for (Coin denom : denomsAscending) { + if (remaining <= 0) break; + + ArrayList availableOutputs = workingDenomMap.get(denom); + if (availableOutputs == null || availableOutputs.isEmpty()) continue; + + // Use available denominations to cover remaining amount (fee) + while (remaining > 0 && !availableOutputs.isEmpty()) { + log.info("Phase 2 - Using 1 of {} for remaining {}", denom.toFriendlyString(), Coin.valueOf(remaining).toFriendlyString()); + selection.add(availableOutputs.remove(availableOutputs.size() - 1)); + remaining -= denom.value; + selectionTotal += denom.value; + + // Recalculate fee as we add more inputs using the formula + numInputs = selection.size(); + txSize = 10 + (numInputs * 148) + 34; // header + inputs + 1_output + long newFee = (feePerKb.value * txSize) / 1000; + long additionalFee = newFee - calculatedFee; + if (additionalFee > 0) { + remaining += additionalFee; + calculatedFee = newFee; + log.info("Fee increased to: {}", Coin.valueOf(calculatedFee).toFriendlyString()); + } + } + } + + // Phase 3: If still need more, use exactly 1 of the next larger denomination + if (remaining > 0) { + log.info("Phase 3 - Small denoms insufficient, using 1 larger denomination..."); + + for (Coin denom : denomsDescending) { + ArrayList availableOutputs = workingDenomMap.get(denom); + if (availableOutputs == null || availableOutputs.isEmpty()) continue; + + if (denom.value >= remaining) { + log.info("Using 1 of {} to cover remaining {}", denom.toFriendlyString(), Coin.valueOf(remaining).toFriendlyString()); + selection.add(availableOutputs.remove(availableOutputs.size() - 1)); + remaining -= denom.value; + + // Recalculate fee as we add more inputs + numInputs = selection.size(); + txSize = 10 + (numInputs * 148) + 34; + long newFee = (feePerKb.value * txSize) / 1000; + long additionalFee = newFee - calculatedFee; + if (additionalFee > 0) { + remaining += additionalFee; + calculatedFee = newFee; + log.info("Fee increased to: {}", Coin.valueOf(calculatedFee).toFriendlyString()); + } + + if (remaining <= 0) { + break; + } + // Continue loop if still need more after fee adjustment + } + } + } + } + + // Phase 4: Optimize by consolidating 10x smaller denominations with 1x larger denomination + log.info("Phase 4 - Optimizing denomination consolidation"); + + // Create a copy of selection to test optimizations + ArrayList optimizedSelection = new ArrayList<>(selection); + HashMap> optimizedWorkingMap = new HashMap<>(); + workingDenomMap.forEach((denom, outputs) -> optimizedWorkingMap.put(denom, new ArrayList<>(outputs))); + + // Count outputs by denomination and calculate total in one pass + HashMap selectionCounts = new HashMap<>(); + long optimizedTotal = 0; + for (TransactionOutput output : optimizedSelection) { + selectionCounts.merge(output.getValue(), 1, Integer::sum); + optimizedTotal += output.getValue().value; + } + + // Use ascending order for consolidation (smallest to largest) + for (int i = 0; i < denomsAscending.size() - 1; i++) { + Coin smallDenom = denomsAscending.get(i); + Coin largeDenom = denomsAscending.get(i + 1); + + // Check if we have 10+ of the small denomination and the large denom is exactly 10x + if (largeDenom.value == smallDenom.value * 10) { + int smallCount = selectionCounts.getOrDefault(smallDenom, 0); + ArrayList availableLarge = optimizedWorkingMap.get(largeDenom); + + if (smallCount >= 10 && availableLarge != null && !availableLarge.isEmpty()) { + log.info("Consolidating 10x{} with 1x{}", smallDenom.toFriendlyString(), largeDenom.toFriendlyString()); + + // Remove 10 small denominations from optimized selection + int removed = 0; + for (int j = optimizedSelection.size() - 1; j >= 0 && removed < 10; j--) { + if (optimizedSelection.get(j).getValue().equals(smallDenom)) { + optimizedSelection.remove(j); + removed++; + } + } + + // Add 1 large denomination (total value unchanged: 10 * small = 1 * large) + optimizedSelection.add(availableLarge.remove(availableLarge.size() - 1)); + + // Update counts + selectionCounts.put(smallDenom, selectionCounts.get(smallDenom) - 10); + selectionCounts.put(largeDenom, selectionCounts.getOrDefault(largeDenom, 0) + 1); + + // Recalculate fee with new input count (fewer inputs = lower fee) + int newNumInputs = optimizedSelection.size(); + int newTxSize = 10 + (newNumInputs * 148) + 34; + long newCalculatedFee = (feePerKb.value * newTxSize) / 1000; + + // Total value is unchanged, but fee is reduced + long optimizedRemaining = target + newCalculatedFee - optimizedTotal; + + log.info("After consolidation: {} inputs, fee: {}, remaining: {}", newNumInputs, + Coin.valueOf(newCalculatedFee).toFriendlyString(), Coin.valueOf(optimizedRemaining).toFriendlyString()); + + // If optimization is valid (we still have enough value), use it + if (optimizedRemaining <= 0) { + selection = optimizedSelection; + workingDenomMap.clear(); + workingDenomMap.putAll(optimizedWorkingMap); + calculatedFee = newCalculatedFee; + log.info("Optimization successful - using consolidated selection"); + } else { + log.info("Optimization failed - insufficient value, reverting"); + // Revert the changes + selectionCounts.put(smallDenom, selectionCounts.get(smallDenom) + 10); + selectionCounts.put(largeDenom, selectionCounts.get(largeDenom) - 1); + break; // Stop trying further consolidations for this iteration + } + } + } + } + + // Phase 5: Remove unnecessary smallest denomination inputs + log.info("Phase 5 - Removing unnecessary smallest denomination inputs"); + + // Build count map and calculate total in one pass + HashMap removalCounts = new HashMap<>(); + long currentTotal = 0; + for (TransactionOutput output : selection) { + removalCounts.merge(output.getValue(), 1, Integer::sum); + currentTotal += output.getValue().value; + } + + long totalNeeded = target + calculatedFee; + long excess = currentTotal - totalNeeded; + + log.info("Current total: {}, needed: {}, excess: {}", Coin.valueOf(currentTotal).toFriendlyString(), + Coin.valueOf(totalNeeded).toFriendlyString(), Coin.valueOf(excess).toFriendlyString()); + + if (excess > 0) { + // Try to remove smallest denominations that we don't need + for (Coin denom : denomsAscending) { + if (excess <= 0) break; + + int denomCount = removalCounts.getOrDefault(denom, 0); + long canRemove = Math.min(excess / denom.value, denomCount); + + if (canRemove > 0) { + log.info("Removing {} unnecessary {} inputs", canRemove, denom.toFriendlyString()); + + // Remove the specified number of this denomination + long removed = 0; + for (int i = selection.size() - 1; i >= 0 && removed < canRemove; i--) { + if (selection.get(i).getValue().equals(denom)) { + selection.remove(i); + currentTotal -= denom.value; + excess -= denom.value; + removed++; + } + } + removalCounts.put(denom, (int) (denomCount - removed)); + + // Recalculate fee with new input count + int newNumInputs = selection.size(); + int newTxSize = 10 + (newNumInputs * 148) + 34; + long newCalculatedFee = (feePerKb.value * newTxSize) / 1000; + + long newTotalNeeded = target + newCalculatedFee; + + log.info("After removing {} inputs: {} total inputs, fee: {}, total: {}, needed: {}", removed, + newNumInputs, Coin.valueOf(newCalculatedFee).toFriendlyString(), + Coin.valueOf(currentTotal).toFriendlyString(), Coin.valueOf(newTotalNeeded).toFriendlyString()); + + // Verify we still have enough (should always be true, but safety check) + if (currentTotal >= newTotalNeeded) { + calculatedFee = newCalculatedFee; + excess = currentTotal - newTotalNeeded; + log.info("Successfully removed unnecessary inputs"); + } else { + log.info("ERROR: Removal created insufficient funds - this shouldn't happen"); + break; + } + } } + + long finalNeeded = target + calculatedFee; + log.info("Phase 5 complete - Final: {} inputs, total: {}, needed: {}, excess: {}", selection.size(), + Coin.valueOf(currentTotal).toFriendlyString(), Coin.valueOf(finalNeeded).toFriendlyString(), + Coin.valueOf(currentTotal - finalNeeded).toFriendlyString()); + } else { + log.info("No excess to remove - selection is optimal"); + } + + if (remaining > 0) { + log.info("WARNING: Could not satisfy target + fee, remaining: {}", Coin.valueOf(remaining).toFriendlyString()); + return null; } - return new CoinSelection(Coin.valueOf(total), selected); + + log.info("Final selection: {} inputs, total fee: {}", selection.size(), Coin.valueOf(calculatedFee).toFriendlyString()); + + return selection; } @Override diff --git a/core/src/main/java/org/bitcoinj/wallet/CoinJoinExtension.java b/core/src/main/java/org/bitcoinj/wallet/CoinJoinExtension.java index 8e263ed44..42aee8f25 100644 --- a/core/src/main/java/org/bitcoinj/wallet/CoinJoinExtension.java +++ b/core/src/main/java/org/bitcoinj/wallet/CoinJoinExtension.java @@ -56,6 +56,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Random; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.atomic.AtomicInteger; @@ -173,11 +174,11 @@ public byte[] serializeWalletExtension() { // Time protobuf building and optimized I/O long ioStartTime = System.nanoTime(); Protos.CoinJoin coinJoinProto = builder.build(); - + // Use optimized buffer size based on actual serialized size int serializedSize = coinJoinProto.getSerializedSize(); ByteArrayOutputStream output = new ByteArrayOutputStream(serializedSize); - + // Use optimized CodedOutputStream buffer size - larger than default for better performance int bufferSize = Math.max(64 * 1024, serializedSize / 4); // At least 64KB or 25% of data size try { @@ -221,7 +222,7 @@ public void deserializeWalletExtension(Wallet containingWallet, byte[] data) thr } rounds = coinJoinProto.getRounds(); CoinJoinClientOptions.setRounds(rounds); - + // Deserialize outpoint rounds cache for WalletEx if (containingWallet instanceof WalletEx && coinJoinProto.hasOutpointRoundsCache()) { WalletEx walletEx = (WalletEx) containingWallet; @@ -234,7 +235,7 @@ public void deserializeWalletExtension(Wallet containingWallet, byte[] data) thr walletEx.mapOutpointRoundsCache.put(outPoint, rounds); } } - + // Deserialize coinJoinSalt if (coinJoinProto.hasCoinjoinSalt()) { coinJoinSalt = Sha256Hash.wrap(coinJoinProto.getCoinjoinSalt().toByteArray()); @@ -242,7 +243,7 @@ public void deserializeWalletExtension(Wallet containingWallet, byte[] data) thr // if there is no coinJoinSalt, then add it. calculateCoinJoinSalt(); } - + loadedKeys = true; } diff --git a/core/src/main/java/org/bitcoinj/wallet/Wallet.java b/core/src/main/java/org/bitcoinj/wallet/Wallet.java index 143dd28e7..be17cca03 100644 --- a/core/src/main/java/org/bitcoinj/wallet/Wallet.java +++ b/core/src/main/java/org/bitcoinj/wallet/Wallet.java @@ -5770,6 +5770,12 @@ private FeeCalculation calculateFee(SendRequest req, Coin value, List candidates = realWallet.calculateAllSpendCandidates(false, false); + System.out.println("Available candidates: " + candidates.size()); + + try { + CoinSelection greedySelection = greedySelector.select(sendAmount, candidates); + CoinSelection normalSelection = normalSelector.select(sendAmount, candidates); + assertNotNull( "greedy selections failed", greedySelection); + assertNotNull( "normal selections failed", normalSelection); + Coin greedyChange = greedySelection.valueGathered.subtract(sendAmount); + Coin normalChange = normalSelection.valueGathered.subtract(sendAmount); + + System.out.println("Greedy Selection:"); + System.out.println(" Inputs: " + greedySelection.gathered.size()); + System.out.println(" Total: " + greedySelection.valueGathered.toFriendlyString()); + System.out.println(" Change: " + greedyChange.toFriendlyString()); + + System.out.println("Normal Selection:"); + System.out.println(" Inputs: " + normalSelection.gathered.size()); + System.out.println(" Total: " + normalSelection.valueGathered.toFriendlyString()); + System.out.println(" Change: " + normalChange.toFriendlyString()); + + System.out.println("Comparison:"); + System.out.println(" Greedy change: " + greedyChange.toFriendlyString()); + System.out.println(" Normal change: " + normalChange.toFriendlyString()); + System.out.println(" Greedy is better: " + greedyChange.isLessThanOrEqualTo(normalChange)); + + // Verify greedy minimizes change + assertTrue("Greedy algorithm should minimize change", + greedyChange.isLessThanOrEqualTo(normalChange)); + + } catch (Exception e) { + System.out.println("Exception during coin selection: " + e.getMessage()); + e.printStackTrace(); + fail(); + } + } + + @Test + public void testRealWalletGreedySelection2() throws IOException, UnreadableWalletException, InsufficientMoneyException { + WalletEx realWallet; + Context.propagate(new Context(TestNet3Params.get(), 100, Coin.ZERO, false)); + + try (InputStream is = getClass().getResourceAsStream("/org/bitcoinj/wallet/coinjoin-large.wallet")) { + if (is == null) { + System.out.println("Wallet file not found, skipping test"); + return; + } + realWallet = (WalletEx) new WalletProtobufSerializer().readWallet(is); + } + + // Test multiple amounts: 0.5, 0.05, 5 DASH + Coin[] testAmounts = { + Coin.valueOf(50000L), // 0.0005 DASH + Coin.valueOf(5000000L), // 0.05 DASH + Coin.valueOf(50000000L), // 0.5 DASH + Coin.valueOf(5000000L), // 0.05 DASH + Coin.COIN, + Coin.COIN.plus(Coin.CENT.multiply(27)), + Coin.valueOf(500000000L), // 5 DASH + Coin.FIFTY_COINS // 50 DASH + }; + + for (int i = 0; i < testAmounts.length; i++) { + Coin sendAmount = testAmounts[i]; + System.out.println("\n=== Testing Real Wallet Transaction " + (i+1) + " ==="); + System.out.println("Send amount: " + sendAmount.toFriendlyString()); + System.out.println("Wallet balance: " + realWallet.getBalance().toFriendlyString()); + + try { + // Create transaction with greedy algorithm + Address toAddress = realWallet.freshReceiveAddress(); + SendRequest greedyReq = SendRequest.to(toAddress, sendAmount); + greedyReq.coinSelector = new CoinJoinCoinSelector(realWallet, false, true); + greedyReq.feePerKb = Coin.valueOf(1000L); // Set fee + greedyReq.returnChange = false; + + // Clone wallet state for parallel testing + WalletEx greedyWallet = realWallet; // Use same wallet for now + greedyWallet.completeTx(greedyReq); + Transaction greedyTx = greedyReq.tx; + + // Create transaction with normal algorithm + SendRequest normalReq = SendRequest.to(toAddress, sendAmount); + normalReq.coinSelector = new CoinJoinCoinSelector(realWallet, false, false); + normalReq.feePerKb = Coin.valueOf(1000L); // Set same fee + + realWallet.completeTx(normalReq); + Transaction normalTx = normalReq.tx; + + // Calculate input totals and change + Coin greedyInputTotal = greedyTx.getInputs().stream() + .map(input -> input.getConnectedOutput().getValue()) + .reduce(Coin.ZERO, Coin::add); + Coin normalInputTotal = normalTx.getInputs().stream() + .map(input -> input.getConnectedOutput().getValue()) + .reduce(Coin.ZERO, Coin::add); + + Coin greedyChange = greedyInputTotal.subtract(sendAmount).subtract(greedyTx.getFee()); + Coin normalChange = normalInputTotal.subtract(sendAmount).subtract(normalTx.getFee()); + + System.out.println("Greedy Transaction:"); + System.out.println(" Inputs: " + greedyTx.getInputs().size()); + System.out.println(" Total input: " + greedyInputTotal.toFriendlyString()); + System.out.println(" Fee: " + greedyTx.getFee().toFriendlyString()); + System.out.println(" Change: " + greedyChange.toFriendlyString()); + greedyTx.getInputs().forEach(input -> + System.out.println(" Input: " + input.getConnectedOutput().getValue().toFriendlyString())); + + System.out.println("Normal Transaction:"); + System.out.println(" Inputs: " + normalTx.getInputs().size()); + System.out.println(" Total input: " + normalInputTotal.toFriendlyString()); + System.out.println(" Fee: " + normalTx.getFee().toFriendlyString()); + System.out.println(" Change: " + normalChange.toFriendlyString()); + normalTx.getInputs().forEach(input -> + System.out.println(" Input: " + input.getConnectedOutput().getValue().toFriendlyString())); + + System.out.println("Comparison:"); + System.out.println(" Greedy change: " + greedyChange.toFriendlyString()); + System.out.println(" Normal change: " + normalChange.toFriendlyString()); + System.out.println(" Greedy fee: " + greedyTx.getFee().toFriendlyString()); + System.out.println(" Normal fee: " + normalTx.getFee().toFriendlyString()); + System.out.println(" Greedy uses more inputs: " + (greedyTx.getInputs().size() > normalTx.getInputs().size())); + System.out.println(" Greedy has higher fee: " + greedyTx.getFee().isGreaterThan(normalTx.getFee())); + + // Verify that greedy algorithm produces efficient results +// assertTrue("Greedy algorithm should minimize change for " + sendAmount.toFriendlyString(), +// greedyChange.isLessThanOrEqualTo(normalChange)); + + } catch (InsufficientMoneyException e) { + System.out.println("Insufficient funds for " + sendAmount.toFriendlyString()); + System.out.println("Available: " + realWallet.getBalance().toFriendlyString()); + } catch (Exception e) { + System.out.println("Exception creating transaction for " + sendAmount.toFriendlyString() + ": " + e.getMessage()); + e.printStackTrace(); + // Don't fail the test, just log and continue + fail(); + } + } + } } diff --git a/core/src/test/java/org/bitcoinj/coinjoin/CoinJoinGreedyCustomTest.java b/core/src/test/java/org/bitcoinj/coinjoin/CoinJoinGreedyCustomTest.java new file mode 100644 index 000000000..66731d7d1 --- /dev/null +++ b/core/src/test/java/org/bitcoinj/coinjoin/CoinJoinGreedyCustomTest.java @@ -0,0 +1,331 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.bitcoinj.coinjoin; + +import org.bitcoinj.core.*; +import org.bitcoinj.crypto.TransactionSignature; +import org.bitcoinj.params.TestNet3Params; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; +import org.bitcoinj.wallet.KeyChain; +import org.bitcoinj.wallet.SendRequest; +import org.bitcoinj.wallet.WalletEx; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +public class CoinJoinGreedyCustomTest { + + private static final TestNet3Params PARAMS = TestNet3Params.get(); + private static final Context CONTEXT = new Context(PARAMS); + + private WalletEx wallet; + + @Before + public void setUp() { + Context.propagate(CONTEXT); + wallet = WalletEx.createDeterministic(PARAMS, Script.ScriptType.P2PKH); + wallet.initializeCoinJoin(null, 0); + } + + /** + * Creates a CoinJoin transaction with specified denomination outputs and adds it to the wallet. + * Marks all outputs as fully mixed by manipulating mapOutpointRoundsCache. + */ + private void addCoinJoinTransaction(Coin... denominations) { + Transaction tx = new Transaction(PARAMS); + + // Create a dummy coinbase input + ECKey key = wallet.currentKey(KeyChain.KeyPurpose.RECEIVE_FUNDS); + + // Create outputs with specified denominations using CoinJoin addresses + List outputs = new ArrayList<>(); + for (Coin denomination : denominations) { + Address address = wallet.getCoinJoin().getKeyChainGroup().freshAddress(KeyChain.KeyPurpose.RECEIVE_FUNDS); + TransactionOutput output = new TransactionOutput(PARAMS, tx, denomination, address); + tx.addOutput(output); + outputs.add(output); + } + + Script inputScript = ScriptBuilder.createOutputScript(Address.fromKey(PARAMS, key)); + int index = 0; + for (Coin denomination : denominations) { + tx.addSignedInput(new TransactionOutPoint(PARAMS, 0, Sha256Hash.of(new byte[index++])), inputScript, key, Transaction.SigHash.ALL, true); + } + + // Add transaction to wallet + wallet.receivePending(tx, null); + + // Set confidence to building (confirmed) + tx.getConfidence().setConfidenceType(TransactionConfidence.ConfidenceType.BUILDING); + tx.getConfidence().setAppearedAtChainHeight(100); + tx.getConfidence().setDepthInBlocks(10); + + // Mark all outputs as fully mixed by setting high round count + for (int i = 0; i < outputs.size(); i++) { + TransactionOutPoint outPoint = new TransactionOutPoint(PARAMS, i, tx.getTxId()); + setOutputRounds(outPoint, 10); // 10 rounds = fully mixed + } + + System.out.println("Added CoinJoin transaction with denominations:"); + for (Coin denom : denominations) { + System.out.println(" " + denom.toFriendlyString()); + } + } + + /** + * Helper method to set the round count for an output using reflection to access mapOutpointRoundsCache + */ + private void setOutputRounds(TransactionOutPoint outPoint, int rounds) { + try { + // Access the private mapOutpointRoundsCache field + java.lang.reflect.Field field = WalletEx.class.getDeclaredField("mapOutpointRoundsCache"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map cache = + (Map) field.get(wallet); + cache.put(outPoint, rounds); + System.out.println("Set rounds for " + outPoint + " to " + rounds); + } catch (Exception e) { + throw new RuntimeException("Failed to set output rounds", e); + } + } + + /** + * Creates a transaction using the greedy algorithm with returnChange = false + */ + private Transaction createGreedyTransaction(Coin sendAmount) throws InsufficientMoneyException { + Address toAddress = wallet.freshReceiveAddress(); + SendRequest req = SendRequest.to(toAddress, sendAmount); + req.coinSelector = new CoinJoinCoinSelector(wallet, false, true); // Use greedy algorithm + req.returnChange = false; // No change output + req.feePerKb = Coin.valueOf(1000L); // Set fee rate + + System.out.println("\n=== Creating Greedy Transaction ==="); + System.out.println("Send amount: " + sendAmount.toFriendlyString()); + System.out.println("Return change: " + req.returnChange); + + wallet.completeTx(req); + return req.tx; + } + + @Test + public void testGreedyWithSpecificDenominations() throws InsufficientMoneyException { + System.out.println("=== Test: Greedy with Specific Denominations ==="); + + // Add CoinJoin transaction with various denominations + addCoinJoinTransaction( + Denomination.THOUSANDTH.value, // 0.001 DASH + Denomination.THOUSANDTH.value, // 0.001 DASH + Denomination.THOUSANDTH.value, // 0.001 DASH + Denomination.THOUSANDTH.value, // 0.001 DASH + Denomination.THOUSANDTH.value, // 0.001 DASH + Denomination.HUNDREDTH.value, // 0.01 DASH + Denomination.HUNDREDTH.value, // 0.01 DASH + Denomination.TENTH.value, // 0.1 DASH + Denomination.TENTH.value, // 0.1 DASH + Denomination.ONE.value // 1.0 DASH + ); + + // Test sending 0.05 DASH (should use optimal combination) + Coin sendAmount = Coin.valueOf(5000000L); // 0.05 DASH + Transaction tx = createGreedyTransaction(sendAmount); + + // Print transaction details + System.out.println("\n=== Transaction Results ==="); + System.out.println("Transaction ID: " + tx.getTxId()); + System.out.println("Number of inputs: " + tx.getInputs().size()); + System.out.println("Number of outputs: " + tx.getOutputs().size()); + + System.out.println("\nInputs used:"); + Coin totalInput = Coin.ZERO; + for (int i = 0; i < tx.getInputs().size(); i++) { + TransactionInput input = tx.getInputs().get(i); + Coin inputValue = input.getConnectedOutput().getValue(); + totalInput = totalInput.add(inputValue); + System.out.println(" Input " + (i+1) + ": " + inputValue.toFriendlyString()); + } + + System.out.println("\nOutputs created:"); + for (int i = 0; i < tx.getOutputs().size(); i++) { + TransactionOutput output = tx.getOutputs().get(i); + System.out.println(" Output " + (i+1) + ": " + output.getValue().toFriendlyString()); + } + + System.out.println("\nTransaction summary:"); + System.out.println("Total input: " + totalInput.toFriendlyString()); + System.out.println("Send amount: " + sendAmount.toFriendlyString()); + System.out.println("Transaction fee: " + tx.getFee().toFriendlyString()); + + // Verify transaction properties + assertFalse("Should have at least one input", tx.getInputs().isEmpty()); + assertEquals("Should have exactly one output (no change)", 1, tx.getOutputs().size()); + assertEquals("Output should equal send amount", sendAmount, tx.getOutput(0).getValue()); + assertTrue("Total input should be >= send amount", totalInput.isGreaterThanOrEqualTo(sendAmount)); + } + + @Test + public void testGreedyWithOneDenominations() throws InsufficientMoneyException { + System.out.println("=== Test: Greedy with Specific Denominations ==="); + + // Add CoinJoin transaction with various denominations + addCoinJoinTransaction( + Denomination.ONE.value // 1.0 DASH + ); + + // Test sending 1 DASH (should use optimal combination) + Coin sendAmount = Coin.COIN; // 1.0 DASH + Transaction tx = createGreedyTransaction(sendAmount); + + // Print transaction details + System.out.println("\n=== Transaction Results ==="); + System.out.println("Transaction ID: " + tx.getTxId()); + System.out.println("Number of inputs: " + tx.getInputs().size()); + System.out.println("Number of outputs: " + tx.getOutputs().size()); + + System.out.println("\nInputs used:"); + Coin totalInput = Coin.ZERO; + for (int i = 0; i < tx.getInputs().size(); i++) { + TransactionInput input = tx.getInputs().get(i); + Coin inputValue = input.getConnectedOutput().getValue(); + totalInput = totalInput.add(inputValue); + System.out.println(" Input " + (i+1) + ": " + inputValue.toFriendlyString()); + } + + System.out.println("\nOutputs created:"); + for (int i = 0; i < tx.getOutputs().size(); i++) { + TransactionOutput output = tx.getOutputs().get(i); + System.out.println(" Output " + (i+1) + ": " + output.getValue().toFriendlyString()); + } + + System.out.println("\nTransaction summary:"); + System.out.println("Total input: " + totalInput.toFriendlyString()); + System.out.println("Send amount: " + sendAmount.toFriendlyString()); + System.out.println("Transaction fee: " + tx.getFee().toFriendlyString()); + + // Verify transaction properties + assertTrue("Should have at least one input", tx.getInputs().size() > 0); + assertEquals("Should have exactly one output (no change)", 1, tx.getOutputs().size()); + assertEquals("Output should equal send amount", sendAmount, tx.getOutput(0).getValue()); + assertTrue("Total input should be >= send amount", totalInput.isGreaterThanOrEqualTo(sendAmount)); + } + + @Test + public void testGreedyWithManySmallDenominations() throws InsufficientMoneyException { + System.out.println("\n=== Test: Greedy with Many Small Denominations ==="); + + // Add transaction with many small denominations to test consolidation + Coin[] denominations = new Coin[15]; + for (int i = 0; i < 15; i++) { + denominations[i] = Denomination.THOUSANDTH.value; // 15 × 0.001 DASH + } + // Add some larger denominations + addCoinJoinTransaction(denominations); + addCoinJoinTransaction( + Denomination.HUNDREDTH.value, // 0.01 DASH + Denomination.HUNDREDTH.value, // 0.01 DASH + Denomination.TENTH.value // 0.1 DASH + ); + + // Test sending amount that should trigger consolidation + Coin sendAmount = Coin.valueOf(12000000L); // 0.12 DASH + Transaction tx = createGreedyTransaction(sendAmount); + + // Print results + System.out.println("\n=== Transaction Results ==="); + System.out.println("Number of inputs: " + tx.getInputs().size()); + System.out.println("Number of outputs: " + tx.getOutputs().size()); + + System.out.println("\nInputs used:"); + for (int i = 0; i < tx.getInputs().size(); i++) { + TransactionInput input = tx.getInputs().get(i); + System.out.println(" Input " + (i+1) + ": " + input.getConnectedOutput().getValue().toFriendlyString()); + } + + // Verify the greedy algorithm optimized the selection + assertTrue("Should use fewer than all available small denominations", tx.getInputs().size() < 15); + assertEquals("Should have exactly one output (no change)", 1, tx.getOutputs().size()); + } + + @Test + public void testGreedyWithLargeDenominations() throws InsufficientMoneyException { + System.out.println("\n=== Test: Greedy with Large Denominations ==="); + + // Add transaction with larger denominations + addCoinJoinTransaction( + Denomination.TEN.value, // 10.0 DASH + Denomination.ONE.value, // 1.0 DASH + Denomination.ONE.value, // 1.0 DASH + Denomination.TENTH.value, // 0.1 DASH + Denomination.TENTH.value, // 0.1 DASH + Denomination.HUNDREDTH.value, // 0.01 DASH + Denomination.THOUSANDTH.value // 0.001 DASH + ); + + // Test sending amount requiring exact denomination selection + Coin sendAmount = Coin.valueOf(211000000L); // 2.11 DASH + Transaction tx = createGreedyTransaction(sendAmount); + + // Print results + System.out.println("\n=== Transaction Results ==="); + System.out.println("Number of inputs: " + tx.getInputs().size()); + + System.out.println("\nInputs used:"); + Coin totalInput = Coin.ZERO; + for (int i = 0; i < tx.getInputs().size(); i++) { + TransactionInput input = tx.getInputs().get(i); + Coin inputValue = input.getConnectedOutput().getValue(); + totalInput = totalInput.add(inputValue); + System.out.println(" Input " + (i+1) + ": " + inputValue.toFriendlyString()); + } + + System.out.println("\nTotal input: " + totalInput.toFriendlyString()); + System.out.println("Send amount: " + sendAmount.toFriendlyString()); + System.out.println("Fee: " + tx.getFee().toFriendlyString()); + + // Verify transaction + assertEquals("Should have exactly one output (no change)", 1, tx.getOutputs().size()); + assertEquals("Output should equal send amount", sendAmount, tx.getOutput(0).getValue()); + } + + @Test(timeout = 3000) // guards against the pre-fix infinite loop + public void recipientsPayFees_noChange_exactMatch_standardDenom_doesNotLoop() throws Exception { + // 1) Fund wallet with exactly one standard denom UTXO (1 DASH + 1000 satoshis) + addCoinJoinTransaction(Denomination.ONE.value); // 1.00001000 DASH + + // 2) Build a send that exactly matches inputs, with no change and recipients paying fees + Address dest = Address.fromKey(PARAMS, new ECKey()); + SendRequest req = SendRequest.to(dest, Denomination.ONE.value); // 1.00001 DASH + req.returnChange = false; + req.recipientsPayFees = true; + req.feePerKb = Transaction.REFERENCE_DEFAULT_MIN_TX_FEE; // any non-zero fee works + req.shuffleOutputs = false; + req.sortByBIP69 = false; + + wallet.completeTx(req); // before fix: loops; after fix: completes + + // 3) Assertions: single recipient output, no change, fee > 0 + assertEquals(1, req.tx.getOutputs().size()); + Coin inputSum = req.tx.getInputSum(); + Coin outputSum = req.tx.getOutputSum(); + assertEquals(Denomination.ONE.value, inputSum); // spent exactly the ONE denom + assertTrue(inputSum.isGreaterThan(outputSum)); // recipient paid the fee + } +} \ No newline at end of file diff --git a/core/src/test/java/org/bitcoinj/coinjoin/CoinJoinSelectorGreedyTest.java b/core/src/test/java/org/bitcoinj/coinjoin/CoinJoinSelectorGreedyTest.java new file mode 100644 index 000000000..b3de58764 --- /dev/null +++ b/core/src/test/java/org/bitcoinj/coinjoin/CoinJoinSelectorGreedyTest.java @@ -0,0 +1,293 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.bitcoinj.coinjoin; + +import org.bitcoinj.core.*; +import org.bitcoinj.crypto.TransactionSignature; +import org.bitcoinj.params.TestNet3Params; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; +import org.bitcoinj.wallet.CoinSelection; +import org.bitcoinj.wallet.WalletEx; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import java.math.BigInteger; +import java.util.*; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +@RunWith(Parameterized.class) +public class CoinJoinSelectorGreedyTest { + + private static final TestNet3Params PARAMS = TestNet3Params.get(); + private static final Context CONTEXT = new Context(PARAMS); + + private WalletEx wallet; + private List availableOutputs; + private Coin sendAmount; + private List expectedGreedyOutputs; + private String testName; + public CoinJoinSelectorGreedyTest(String testName, List availableOutputs, + Coin sendAmount, List expectedGreedyOutputs) { + this.testName = testName; + this.availableOutputs = availableOutputs; + this.sendAmount = sendAmount; + this.expectedGreedyOutputs = expectedGreedyOutputs; + } + + @Parameterized.Parameters(name = "{0}") + public static Collection data() { + + return Arrays.asList(new Object[][]{ + { + "Nearly perfect match with 0.1 denominations and 0.001 for fee", + Arrays.asList( + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.THOUSANDTH, + Denomination.THOUSANDTH, + Denomination.THOUSANDTH, + Denomination.ONE // 1.0 DASH + ), + Coin.valueOf(50000000L), // Send 0.5 DASH + Arrays.asList( + Denomination.TENTH, // Should use 5 × 0.1 DASH + Denomination.TENTH, + Denomination.TENTH, + Denomination.TENTH, + Denomination.TENTH, + Denomination.THOUSANDTH + ) + }, + { + "Use larger denomination when small insufficient", + Arrays.asList( + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.ONE, // 1.0 DASH + Denomination.TEN // 10.0 DASH + ), + Coin.valueOf(50000000L), // Send 0.5 DASH + Collections.singletonList( + Denomination.ONE // Then 1 × 1.0 DASH to cover remaining + ) + }, + { + "Small amount with mixed denominations", + Arrays.asList( + Denomination.HUNDREDTH, // 0.01 DASH + Denomination.HUNDREDTH, // 0.01 DASH + Denomination.HUNDREDTH, // 0.01 DASH + Denomination.HUNDREDTH, // 0.01 DASH + Denomination.HUNDREDTH, // 0.01 DASH + Denomination.HUNDREDTH, // 0.01 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.ONE // 1.0 DASH + ), + Coin.valueOf(5000000L), // Send 0.05 DASH + Arrays.asList( + Denomination.HUNDREDTH, // Should use 5 × 0.01 DASH + Denomination.HUNDREDTH, + Denomination.HUNDREDTH, + Denomination.HUNDREDTH, + Denomination.HUNDREDTH, + Denomination.HUNDREDTH + ) + }, + { + "Large amount requiring multiple denominations", + Arrays.asList( + Denomination.TEN, // 10.0 DASH + Denomination.ONE, // 1.0 DASH + Denomination.ONE, // 1.0 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH, // 0.1 DASH + Denomination.TENTH // 0.1 DASH + ), + Coin.valueOf(1050000000L), // Send 10.5 DASH + Arrays.asList( + Denomination.TEN, // Use 1 × 10.0 DASH + Denomination.TENTH, // Then 5 × 0.1 DASH + Denomination.TENTH, + Denomination.TENTH, + Denomination.TENTH, + Denomination.TENTH + ) + }, + { + "Only large denomination available", + Arrays.asList( + Denomination.ONE, // 1.0 DASH + Denomination.ONE, // 1.0 DASH + Denomination.ONE // 1.0 DASH + ), + Coin.valueOf(50000000L), // Send 0.5 DASH + Arrays.asList( + Denomination.ONE // Must use 1 × 1.0 DASH (no smaller available) + ) + }, + { + "Send amount less than 0.001 denomination", + Arrays.asList( + Denomination.ONE, // 1.0 DASH + Denomination.THOUSANDTH, // 1.0 DASH + Denomination.THOUSANDTH // 1.0 DASH + ), + Coin.valueOf(50000L), // Send 0.5 DASH + Arrays.asList( + Denomination.THOUSANDTH // Must use 1 × 1.0 DASH (no smaller available) + ) + } + }); + } + + @Before + public void setUp() { + Context.propagate(CONTEXT); + wallet = WalletEx.createDeterministic(PARAMS, Script.ScriptType.P2PKH); + wallet.initializeCoinJoin(0); + + // Create transactions with the specified outputs and add them to wallet + for (Denomination outputValue : availableOutputs) { + Transaction tx = createCoinJoinTransaction(outputValue.value); + wallet.receivePending(tx, null); + // Mark output as fully mixed by setting rounds to a high value + TransactionOutput output = tx.getOutputs().get(0); + } + } + + @Test + public void testGreedyVsNonGreedySelection() throws InsufficientMoneyException { + System.out.println("\n=== Testing: " + testName + " ==="); + System.out.println("Available outputs: " + + availableOutputs.stream() + .map(a -> a.value.toFriendlyString()) + .collect(Collectors.joining(", "))); + System.out.println("Send amount: " + sendAmount.toFriendlyString()); + System.out.println("Expected greedy outputs: " + + expectedGreedyOutputs.stream() + .map(a -> a.value.toFriendlyString()) + .collect(Collectors.joining(", "))); + + // Test with greedy algorithm enabled + CoinJoinCoinSelector greedySelector = new CoinJoinCoinSelector(wallet, false, true); + wallet.getTransactionList(false).forEach(a -> a.getConfidence().setConfidenceType(TransactionConfidence.ConfidenceType.BUILDING)); + List candidates = wallet.calculateAllSpendCandidates(false, false); + + System.out.println("Candidates found: " + candidates.size()); + candidates.forEach(c -> System.out.println(" " + c.getValue().toFriendlyString())); + + CoinSelection greedySelection = greedySelector.select(sendAmount, candidates); + + // Test with greedy algorithm disabled + CoinJoinCoinSelector normalSelector = new CoinJoinCoinSelector(wallet, false, false); + CoinSelection normalSelection = normalSelector.select(sendAmount, candidates); + + // Check if we have any candidates at all + if (candidates.isEmpty()) { + System.out.println("No candidates available - skipping test"); + return; + } + + // Verify greedy selection + assertNotNull("Greedy selection should not be null", greedySelection); + if (greedySelection != null) { + assertTrue("Greedy selection should have enough value", + greedySelection.valueGathered.isGreaterThanOrEqualTo(sendAmount)); + } + + // Verify normal selection + assertNotNull("Normal selection should not be null", normalSelection); + assertTrue("Normal selection should have enough value", + normalSelection.valueGathered.isGreaterThanOrEqualTo(sendAmount)); + + // Print results + System.out.println("\nGreedy Selection:"); + System.out.println(" Inputs: " + greedySelection.gathered.size()); + System.out.println(" Total: " + greedySelection.valueGathered.toFriendlyString()); + System.out.println(" Change: " + greedySelection.valueGathered.subtract(sendAmount).toFriendlyString()); + greedySelection.gathered.forEach(output -> + System.out.println(" " + output.getValue().toFriendlyString())); + + System.out.println("\nNormal Selection:"); + System.out.println(" Inputs: " + normalSelection.gathered.size()); + System.out.println(" Total: " + normalSelection.valueGathered.toFriendlyString()); + System.out.println(" Change: " + normalSelection.valueGathered.subtract(sendAmount).toFriendlyString()); + normalSelection.gathered.forEach(output -> + System.out.println(" " + output.getValue().toFriendlyString())); + + // Verify that greedy algorithm produces expected results + List actualGreedyValues = greedySelection.gathered.stream() + .map(TransactionOutput::getValue) + .sorted() + .collect(Collectors.toList()); + List expectedSorted = expectedGreedyOutputs.stream() + .map(a -> a.value) + .sorted() + .collect(Collectors.toList()); + + // Check if greedy selection minimizes change better than normal selection + Coin greedyChange = greedySelection.valueGathered.subtract(sendAmount); + Coin normalChange = normalSelection.valueGathered.subtract(sendAmount); + + System.out.println("\nComparison:"); + System.out.println(" Greedy change: " + greedyChange.toFriendlyString()); + System.out.println(" Normal change: " + normalChange.toFriendlyString()); + System.out.println(" Greedy is better: " + greedyChange.isLessThanOrEqualTo(normalChange)); + + // Greedy should generally produce less or equal change + assertTrue("Greedy algorithm should minimize change", + greedyChange.isLessThanOrEqualTo(normalChange)); + assertEquals(expectedSorted, actualGreedyValues); + } + + private Transaction createCoinJoinTransaction(Coin outputValue) { + Transaction tx = new Transaction(PARAMS); + + // Create a dummy input (from coinbase for simplicity) + TransactionInput input = new TransactionInput(PARAMS, tx, ScriptBuilder.createInputScript(new TransactionSignature(BigInteger.ONE, BigInteger.TEN), new ECKey()).getProgram()); + tx.addInput(input); + + // Create the output with specified value + Address address = wallet.getCoinJoin().freshReceiveAddress(); + TransactionOutput output = new TransactionOutput(PARAMS, tx, outputValue, address); + + // Mark as CoinJoin output (this is what the selector looks for) + //output.markAsCoinJoin(); + tx.addOutput(output); + wallet.markAsFullyMixed(new TransactionOutPoint(PARAMS, output)); + + // Set confidence to building (confirmed) + tx.getConfidence().setConfidenceType(TransactionConfidence.ConfidenceType.BUILDING); + tx.getConfidence().setAppearedAtChainHeight(100); + tx.getConfidence().setDepthInBlocks(10); + + return tx; + } + + +} \ No newline at end of file diff --git a/core/src/test/java/org/bitcoinj/wallet/LargeCoinJoinWalletTest.java b/core/src/test/java/org/bitcoinj/wallet/LargeCoinJoinWalletTest.java index c78e9f5a0..f24e141d6 100644 --- a/core/src/test/java/org/bitcoinj/wallet/LargeCoinJoinWalletTest.java +++ b/core/src/test/java/org/bitcoinj/wallet/LargeCoinJoinWalletTest.java @@ -17,9 +17,16 @@ package org.bitcoinj.wallet; import com.google.common.base.Stopwatch; +import com.google.common.collect.Lists; +import org.bitcoinj.coinjoin.CoinJoin; +import org.bitcoinj.coinjoin.CoinJoinCoinSelector; +import org.bitcoinj.core.Address; import org.bitcoinj.core.Coin; import org.bitcoinj.core.Context; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.InsufficientMoneyException; import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionInput; import org.bitcoinj.params.TestNet3Params; import org.bitcoinj.utils.BriefLogFormatter; import org.junit.Before; @@ -34,9 +41,12 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; public class LargeCoinJoinWalletTest { private WalletEx wallet; @@ -47,7 +57,7 @@ public class LargeCoinJoinWalletTest { @Before public void setup() { BriefLogFormatter.initVerbose(); - try (InputStream is = getClass().getResourceAsStream("coinjoin-cache.wallet")) { + try (InputStream is = getClass().getResourceAsStream("coinjoin-decrypted.wallet")) { Stopwatch watch = Stopwatch.createStarted(); wallet = (WalletEx) new WalletProtobufSerializer().readWallet(is); info("loading wallet: {}; {} transactions", watch, wallet.getTransactionCount(true)); @@ -72,7 +82,7 @@ public void coinJoinInfoTest() { @Test public void balanceAndMixingProgressTest() { Stopwatch watch0 = Stopwatch.createStarted(); - assertEquals(Coin.valueOf(16724708510L), wallet.getBalance(Wallet.BalanceType.ESTIMATED)); + assertEquals(Coin.valueOf(16932557094L), wallet.getBalance(Wallet.BalanceType.ESTIMATED)); info("getBalance(ESTIMATED): {}", watch0); Stopwatch watch1 = Stopwatch.createStarted(); @@ -138,19 +148,19 @@ public void getTransactionsTest() { info("wallet.getWalletTransactions(): {}", watch3); } - @Test + @Test @Ignore public void walletSavePerformanceTest() throws IOException { // Show wallet statistics int transactionCount = wallet.getTransactionCount(true); int watchedScriptCount = wallet.getWatchedScripts().size(); int keyCount = wallet.getKeyChainGroupSize(); - + info("=== Wallet Statistics ==="); info("Transactions: {}", transactionCount); info("Watched Scripts: {}", watchedScriptCount); info("Keys: {}", keyCount); info("Complexity Score: {}", transactionCount + (watchedScriptCount * 2) + keyCount); - + // Test different buffer sizes int[] bufferSizes = { 1024, // 1KB - Very small @@ -162,27 +172,27 @@ public void walletSavePerformanceTest() throws IOException { 131072, // 128KB - Extra large 262144 // 256KB - Maximum reasonable }; - + String[] sizeNames = {"1KB", "4KB", "8KB", "16KB", "32KB", "64KB", "128KB", "256KB"}; - + info("\n=== Fixed Buffer Size Performance Comparison ==="); - + long[] averageTimes = new long[bufferSizes.length]; - + for (int bufferIndex = 0; bufferIndex < bufferSizes.length; bufferIndex++) { int bufferSize = bufferSizes[bufferIndex]; String sizeName = sizeNames[bufferIndex]; - + WalletProtobufSerializer serializer = new WalletProtobufSerializer(); serializer.setUseAdaptiveBufferSizing(false); // Disable adaptive sizing serializer.setWalletWriteBufferSize(bufferSize); - + // Warm up JVM for this buffer size for (int i = 0; i < 2; i++) { ByteArrayOutputStream warmupStream = new ByteArrayOutputStream(); serializer.writeWallet(wallet, warmupStream); } - + // Measure performance for this buffer size long[] times = new long[10]; for (int i = 0; i < 10; i++) { @@ -192,27 +202,27 @@ public void walletSavePerformanceTest() throws IOException { watch.stop(); times[i] = watch.elapsed().toMillis(); } - + long avgTime = java.util.Arrays.stream(times).sum() / times.length; averageTimes[bufferIndex] = avgTime; - - info("{} buffer: {} ms avg (runs: {} {} {} {} {} {} {} {} {} {})", - sizeName, avgTime, times[0], times[1], times[2], times[3], times[4], + + info("{} buffer: {} ms avg (runs: {} {} {} {} {} {} {} {} {} {})", + sizeName, avgTime, times[0], times[1], times[2], times[3], times[4], times[5], times[6], times[7], times[8], times[9]); } - + // Test with adaptive buffer sizing WalletProtobufSerializer adaptiveSerializer = new WalletProtobufSerializer(); adaptiveSerializer.setUseAdaptiveBufferSizing(true); // Enable adaptive sizing (default) - + info("\n=== Adaptive Buffer Sizing ==="); - + // Warm up JVM for (int i = 0; i < 3; i++) { ByteArrayOutputStream warmupStream = new ByteArrayOutputStream(); adaptiveSerializer.writeWallet(wallet, warmupStream); } - + // Measure adaptive performance long[] adaptiveTimes = new long[10]; for (int i = 0; i < 10; i++) { @@ -222,12 +232,12 @@ public void walletSavePerformanceTest() throws IOException { adaptiveWatch.stop(); adaptiveTimes[i] = adaptiveWatch.elapsed().toMillis(); } - + long adaptiveAvg = java.util.Arrays.stream(adaptiveTimes).sum() / adaptiveTimes.length; info("Adaptive sizing: {} ms avg (runs: {} {} {} {} {} {} {} {} {} {})", adaptiveAvg, adaptiveTimes[0], adaptiveTimes[1], adaptiveTimes[2], adaptiveTimes[3], adaptiveTimes[4], adaptiveTimes[5], adaptiveTimes[6], adaptiveTimes[7], adaptiveTimes[8], adaptiveTimes[9]); - + // Find the best fixed buffer size int bestFixedIndex = 0; long bestFixedTime = averageTimes[0]; @@ -237,21 +247,21 @@ public void walletSavePerformanceTest() throws IOException { bestFixedIndex = i; } } - + // Compare with original 4KB (index 1) long originalTime = averageTimes[1]; // 4KB is at index 1 - + info("\n=== Performance Analysis ==="); info("Original 4KB buffer: {} ms", originalTime); info("Best fixed buffer ({}): {} ms", sizeNames[bestFixedIndex], bestFixedTime); info("Adaptive buffer: {} ms", adaptiveAvg); - + double improvementOverOriginal = ((double)(originalTime - adaptiveAvg) / originalTime) * 100; double improvementOverBest = ((double)(bestFixedTime - adaptiveAvg) / bestFixedTime) * 100; - + info("Adaptive vs Original 4KB: {:.1f}% improvement", improvementOverOriginal); info("Adaptive vs Best Fixed: {:.1f}% improvement", improvementOverBest); - + // Show the actual buffer size chosen by adaptive algorithm info("\n=== Adaptive Algorithm Details ==="); // Calculate what the adaptive algorithm chose @@ -266,10 +276,10 @@ public void walletSavePerformanceTest() throws IOException { } else { chosenBufferSize = 64 * 1024; } - + info("Complexity score: {}", complexityScore); info("Adaptive algorithm chose: {} KB buffer", chosenBufferSize / 1024); - + // Create performance comparison chart info("\n=== Performance Chart (relative to 4KB baseline) ==="); for (int i = 0; i < bufferSizes.length; i++) { @@ -277,20 +287,20 @@ public void walletSavePerformanceTest() throws IOException { String bar = createPerformanceBar(relativePerformance); info("{}: {:.2f}x {} ({} ms)", sizeNames[i], relativePerformance, bar, averageTimes[i]); } - + double adaptiveRelative = (double)originalTime / adaptiveAvg; String adaptiveBar = createPerformanceBar(adaptiveRelative); info("Adaptive: {:.2f}x {} ({} ms)", adaptiveRelative, adaptiveBar, adaptiveAvg); - + // Save files with different buffer sizes for actual file system testing File tempDir = new File(System.getProperty("java.io.tmpdir")); info("\n=== File System Performance Test ==="); - + // Test original vs adaptive with actual file I/O WalletProtobufSerializer originalSerializer = new WalletProtobufSerializer(); originalSerializer.setUseAdaptiveBufferSizing(false); originalSerializer.setWalletWriteBufferSize(4096); - + File originalFile = new File(tempDir, "wallet-original-4kb.dat"); File adaptiveFile = new File(tempDir, "wallet-adaptive.dat"); try { @@ -324,14 +334,14 @@ public void walletSavePerformanceTest() throws IOException { } else { info("⚠ Adaptive sizing is close to optimal but {} ms slower than best fixed size", adaptiveAvg - bestFixedTime); } - + if (improvementOverOriginal > 0) { info("✓ Adaptive sizing improves performance by {:.1f}% over original implementation", improvementOverOriginal); } else { info("⚠ No significant improvement over original implementation"); } } - + private String createPerformanceBar(double relativePerformance) { int barLength = Math.min(20, (int)(relativePerformance * 10)); StringBuilder bar = new StringBuilder(); @@ -364,7 +374,7 @@ private static void log(String level, String format, Object... args) { private static String formatMessage(String format, Object... args) { String result = format; int argIndex = 0; - + // Handle formatted placeholders like {:.2f} while (result.contains("{:.") && argIndex < args.length) { int start = result.indexOf("{:."); @@ -374,7 +384,7 @@ private static String formatMessage(String format, Object... args) { String formatSpec = result.substring(start + 2, end); Object arg = args[argIndex++]; String replacement; - + if (arg instanceof Number && formatSpec.endsWith("f")) { // Handle decimal formatting like .2f, .1f try { @@ -393,7 +403,7 @@ private static String formatMessage(String format, Object... args) { } else { replacement = arg == null ? "null" : arg.toString(); } - + result = result.substring(0, start) + replacement + result.substring(end + 1); } else { break; @@ -402,7 +412,7 @@ private static String formatMessage(String format, Object... args) { break; } } - + // Handle simple {} placeholders for (int i = argIndex; i < args.length; i++) { int pos = result.indexOf("{}"); @@ -411,93 +421,93 @@ private static String formatMessage(String format, Object... args) { result = result.substring(0, pos) + replacement + result.substring(pos + 2); } } - + return result; } @Test @Ignore public void walletConsistencyAndCachingPerformanceTest() throws IOException, UnreadableWalletException { info("=== Wallet Consistency and Caching Performance Test ==="); - + // Save the original wallet to get baseline data ByteArrayOutputStream originalStream = new ByteArrayOutputStream(); WalletProtobufSerializer serializer = new WalletProtobufSerializer(); - + Stopwatch originalSaveWatch = Stopwatch.createStarted(); serializer.writeWallet(wallet, originalStream); originalSaveWatch.stop(); byte[] originalBytes = originalStream.toByteArray(); - + info("Original wallet save: {} ms, size: {} bytes", originalSaveWatch.elapsed().toMillis(), originalBytes.length); - + // Load the wallet from the saved bytes WalletProtobufSerializer loader = new WalletProtobufSerializer(); Stopwatch loadWatch = Stopwatch.createStarted(); WalletEx loadedWallet = (WalletEx) loader.readWallet(new ByteArrayInputStream(originalBytes)); loadWatch.stop(); - + info("Wallet load: {} ms", loadWatch.elapsed().toMillis()); - + // Verify basic properties match assertEquals("Transaction count should match", wallet.getTransactionCount(true), loadedWallet.getTransactionCount(true)); assertEquals("Balance should match", wallet.getBalance(Wallet.BalanceType.ESTIMATED), loadedWallet.getBalance(Wallet.BalanceType.ESTIMATED)); - + // Now save the loaded wallet 3 times and track performance and consistency long[] saveTimes = new long[3]; WalletEx[] reloadedWallets = new WalletEx[3]; - + for (int i = 0; i < 3; i++) { ByteArrayOutputStream saveStream = new ByteArrayOutputStream(); - + Stopwatch saveWatch = Stopwatch.createStarted(); serializer.writeWallet(loadedWallet, saveStream); saveWatch.stop(); - + saveTimes[i] = saveWatch.elapsed().toMillis(); byte[] savedBytes = saveStream.toByteArray(); - + // Immediately reload the wallet to verify consistency reloadedWallets[i] = (WalletEx) loader.readWallet(new ByteArrayInputStream(savedBytes)); - + info("Save #{}: {} ms, size: {} bytes", i + 1, saveTimes[i], savedBytes.length); } - + // Verify all reloaded wallets have identical transaction content for (int i = 1; i < 3; i++) { compareWallets(reloadedWallets[0], reloadedWallets[i], i + 1); } - + // Analyze performance improvements from caching long firstSaveTime = saveTimes[0]; long bestSubsequentTime = Math.min(saveTimes[1], saveTimes[2]); long avgSubsequentTime = (saveTimes[1] + saveTimes[2]) / 2; - + info("\n=== Performance Analysis ==="); info("First save (cache misses): {} ms", firstSaveTime); info("Second save: {} ms", saveTimes[1]); info("Third save: {} ms", saveTimes[2]); info("Best subsequent save: {} ms", bestSubsequentTime); info("Average subsequent saves: {} ms", avgSubsequentTime); - + if (firstSaveTime > 0) { double improvementPercent = ((double)(firstSaveTime - bestSubsequentTime) / firstSaveTime) * 100; info("Best improvement from caching: {:.1f}%", improvementPercent); - + double avgImprovementPercent = ((double)(firstSaveTime - avgSubsequentTime) / firstSaveTime) * 100; info("Average improvement from caching: {:.1f}%", avgImprovementPercent); } - + // Verify the final saved wallet (reloadedWallets[2]) matches original - assertEquals("Final wallet transaction count should match original", + assertEquals("Final wallet transaction count should match original", wallet.getTransactionCount(true), reloadedWallets[2].getTransactionCount(true)); - assertEquals("Final wallet balance should match original", + assertEquals("Final wallet balance should match original", wallet.getBalance(Wallet.BalanceType.ESTIMATED), reloadedWallets[2].getBalance(Wallet.BalanceType.ESTIMATED)); - + info("\n=== Test Results ==="); info("✓ All saves produced identical results"); info("✓ Loaded wallet matches original wallet properties"); info("✓ Transaction protobuf caching is working correctly"); - + // Performance expectations (these are guidelines, not strict requirements) if (saveTimes[1] < firstSaveTime && saveTimes[2] < firstSaveTime) { info("✓ Subsequent saves are faster than first save (caching working)"); @@ -505,7 +515,7 @@ public void walletConsistencyAndCachingPerformanceTest() throws IOException, Unr info("⚠ Expected subsequent saves to be faster due to caching"); } } - + /** * Compare two wallets for transaction consistency */ @@ -515,35 +525,35 @@ private void compareWallets(WalletEx wallet1, WalletEx wallet2, int wallet2Numbe wallet1.getTransactionCount(true), wallet2.getTransactionCount(true)); assertEquals("Wallet #" + wallet2Number + " balance should match wallet #1", wallet1.getBalance(Wallet.BalanceType.ESTIMATED), wallet2.getBalance(Wallet.BalanceType.ESTIMATED)); - + // Compare transactions in detail List txs1 = new java.util.ArrayList<>(wallet1.getTransactions(true)); List txs2 = new java.util.ArrayList<>(wallet2.getTransactions(true)); - + assertEquals("Wallet #" + wallet2Number + " should have same number of transactions as wallet #1", txs1.size(), txs2.size()); - + info("Comparing {} transactions between wallet #1 and wallet #{}", txs1.size(), wallet2Number); - + // Create a map of transactions by ID for wallet2 for easier lookup java.util.Map txMap2 = new java.util.HashMap<>(); for (Transaction tx : txs2) { txMap2.put(tx.getTxId(), tx); } - + // Compare each transaction from wallet1 with its corresponding transaction in wallet2 for (int i = 0; i < txs1.size(); i++) { Transaction tx1 = txs1.get(i); Transaction tx2 = txMap2.get(tx1.getTxId()); - + // Verify the transaction exists in wallet2 assertEquals("Transaction " + tx1.getTxId() + " should exist in wallet #" + wallet2Number, true, tx2 != null); - + // Compare memos assertEquals("Transaction " + tx1.getTxId() + " memo should match between wallets", tx1.getMemo(), tx2.getMemo()); - + // Compare exchange rates if (tx1.getExchangeRate() == null) { assertEquals("Transaction " + tx1.getTxId() + " exchange rate should be null in both wallets", @@ -557,16 +567,221 @@ private void compareWallets(WalletEx wallet1, WalletEx wallet2, int wallet2Numbe assertEquals("Transaction " + tx1.getTxId() + " exchange rate should not be null in wallet #" + wallet2Number, tx1.getExchangeRate(), tx2.getExchangeRate()); } - + // Compare cached values assertEquals("Transaction " + tx1.getTxId() + " cached value should match between wallets", tx1.getCachedValue(), tx2.getCachedValue()); - + // Compare coinjoin transaction types assertEquals("Transaction " + tx1.getTxId() + " coinjoin type should match between wallets", tx1.getCoinJoinTransactionType(), tx2.getCoinJoinTransactionType()); } - + info("✓ Wallet #{} transactions match wallet #1 perfectly", wallet2Number); } + + @Test + public void greedyAlgorithmTest() throws InsufficientMoneyException { + Stopwatch watch = Stopwatch.createStarted(); + + //Coin sendAmount = Coin.valueOf(1050000000L); + Coin sendAmount = Coin.COIN.div(2); + Address toAddress = wallet.freshReceiveAddress(); + + System.out.println("=== GREEDY ALGORITHM TEST ==="); + System.out.println("Testing greedy algorithm with send amount: " + sendAmount.toFriendlyString()); + System.out.println("Available balance: " + wallet.getBalance().toFriendlyString()); + + SendRequest req = SendRequest.to(toAddress, sendAmount); + req.coinSelector = new CoinJoinCoinSelector(wallet, true, true); + req.returnChange = false; + //req.greedyAlgorithm = true; + + wallet.completeTx(req); + Transaction tx = req.tx; + + System.out.println("Transaction has " + tx.getInputs().size() + " inputs and " + tx.getOutputs().size() + " outputs"); + assertEquals(1, req.tx.getOutputs().size()); + + boolean hasLargerInput = false; + Coin totalInputValue = Coin.ZERO; + + for (int i = 0; i < tx.getInputs().size(); i++) { + TransactionInput input = tx.getInputs().get(i); + Coin inputValue = input.getConnectedOutput().getValue(); + totalInputValue = totalInputValue.add(inputValue); + System.out.println("Input " + i + ": " + inputValue.toFriendlyString()); + + if (inputValue.isGreaterThan(sendAmount)) { + hasLargerInput = true; + System.out.println(" -> Input " + i + " (" + inputValue.toFriendlyString() + + ") is larger than send amount (" + sendAmount.toFriendlyString() + ")"); + } + } + + System.out.println("Total input value: " + totalInputValue.toFriendlyString()); + System.out.println("Send amount: " + sendAmount.toFriendlyString()); + System.out.println("Change: " + totalInputValue.subtract(sendAmount).toFriendlyString()); + + if (sendAmount.isGreaterThanOrEqualTo(Coin.valueOf(100000L))) { + if (hasLargerInput) { + System.out.println("CONSTRAINT VIOLATION: Found inputs larger than send amount for amount >= 0.001 DASH"); + } else { + System.out.println("SUCCESS: No inputs larger than send amount for amount >= 0.001 DASH"); + } + } + + System.out.println("=== TEST COMPLETED IN " + watch + " ==="); + + info("Testing greedy algorithm with send amount: {}", sendAmount.toFriendlyString()); + info("Available balance: {}", wallet.getBalance().toFriendlyString()); + info("Created greedy transaction: {}", tx); + info("Transaction has {} inputs and {} outputs", tx.getInputs().size(), tx.getOutputs().size()); + + for (int i = 0; i < tx.getInputs().size(); i++) { + TransactionInput input = tx.getInputs().get(i); + Coin inputValue = input.getConnectedOutput().getValue(); + info("Input {}: {}", i, inputValue.toFriendlyString()); + } + + info("Total input value: {}", totalInputValue.toFriendlyString()); + info("Send amount: {}", sendAmount.toFriendlyString()); + info("Fee amount: {}", tx.getFee().toFriendlyString()); + info("Greedy algorithm test completed in: {}", watch); + } + + @Test + public void greedyAlgorithmRecipientPaysFeeTest() throws InsufficientMoneyException { + Stopwatch watch = Stopwatch.createStarted(); + Coin sendAmount = CoinJoin.getStandardDenominations().get(1); + Address toAddress = wallet.freshReceiveAddress(); + + System.out.println("=== GREEDY ALGORITHM TEST ==="); + System.out.println("Testing greedy algorithm with send amount: " + sendAmount.toFriendlyString()); + System.out.println("Available balance: " + wallet.getBalance().toFriendlyString()); + + SendRequest req = SendRequest.to(toAddress, sendAmount); + req.coinSelector = new CoinJoinCoinSelector(wallet, true, true); + req.returnChange = false; + req.recipientsPayFees = true; + + wallet.completeTx(req); + Transaction tx = req.tx; + + System.out.println("Transaction has " + tx.getInputs().size() + " inputs and " + tx.getOutputs().size() + " outputs"); + assertEquals(1, req.tx.getOutputs().size()); + + boolean hasLargerInput = false; + Coin totalInputValue = Coin.ZERO; + + for (int i = 0; i < tx.getInputs().size(); i++) { + TransactionInput input = tx.getInputs().get(i); + Coin inputValue = input.getConnectedOutput().getValue(); + totalInputValue = totalInputValue.add(inputValue); + System.out.println("Input " + i + ": " + inputValue.toFriendlyString()); + + if (inputValue.isGreaterThan(sendAmount)) { + hasLargerInput = true; + System.out.println(" -> Input " + i + " (" + inputValue.toFriendlyString() + + ") is larger than send amount (" + sendAmount.toFriendlyString() + ")"); + } + } + + System.out.println("Total input value: " + totalInputValue.toFriendlyString()); + System.out.println("Send amount: " + sendAmount.toFriendlyString()); + System.out.println("Change: " + totalInputValue.subtract(sendAmount).toFriendlyString()); + + if (sendAmount.isGreaterThanOrEqualTo(Coin.valueOf(100000L))) { + if (hasLargerInput) { + System.out.println("CONSTRAINT VIOLATION: Found inputs larger than send amount for amount >= 0.001 DASH"); + } else { + System.out.println("SUCCESS: No inputs larger than send amount for amount >= 0.001 DASH"); + } + } + + System.out.println("=== TEST COMPLETED IN " + watch + " ==="); + + info("Testing greedy algorithm with send amount: {}", sendAmount.toFriendlyString()); + info("Available balance: {}", wallet.getBalance().toFriendlyString()); + info("Created greedy transaction: {}", tx); + info("Transaction has {} inputs and {} outputs", tx.getInputs().size(), tx.getOutputs().size()); + + for (int i = 0; i < tx.getInputs().size(); i++) { + TransactionInput input = tx.getInputs().get(i); + Coin inputValue = input.getConnectedOutput().getValue(); + info("Input {}: {}", i, inputValue.toFriendlyString()); + } + + info("Total input value: {}", totalInputValue.toFriendlyString()); + info("Send amount: {}", sendAmount.toFriendlyString()); + info("Fee amount: {}", tx.getFee().toFriendlyString()); + info("Greedy algorithm test completed in: {}", watch); + } + + @Test + public void greedyAlgorithmSmallAmountTest() throws InsufficientMoneyException { + Stopwatch watch = Stopwatch.createStarted(); + + Coin sendAmount = Coin.valueOf(50000L); + Address toAddress = wallet.freshReceiveAddress(); + + SendRequest req = SendRequest.to(toAddress, sendAmount); + + info("Testing greedy algorithm with small send amount: {}", sendAmount.toFriendlyString()); + + wallet.completeTx(req); + Transaction tx = req.tx; + + info("Created transaction: {}", tx); + info("Transaction has {} inputs and {} outputs", tx.getInputs().size(), tx.getOutputs().size()); + + for (int i = 0; i < tx.getInputs().size(); i++) { + TransactionInput input = tx.getInputs().get(i); + Coin inputValue = input.getConnectedOutput().getValue(); + info("Input {}: {}", i, inputValue.toFriendlyString()); + } + + info("For amounts < 0.001 DASH, inputs can be larger than send amount"); + assertTrue("Should have at least one input", tx.getInputs().size() > 0); + info("Small amount greedy algorithm test completed in: {}", watch); + } + + /** + * Creates a transaction using the greedy algorithm with returnChange = false + */ + private Transaction createTestTransaction(Coin sendAmount, boolean useGreedyAlgorithm) throws InsufficientMoneyException { + Address toAddress = Address.fromKey(wallet.getParams(), new ECKey()); + SendRequest req = SendRequest.to(toAddress, sendAmount); + req.coinSelector = new CoinJoinCoinSelector(wallet, false, useGreedyAlgorithm); // Use greedy algorithm + req.returnChange = !useGreedyAlgorithm; // No change output + req.feePerKb = Coin.valueOf(1000L); // Set fee rate + + System.out.println("\n=== Creating Transaction ==="); + System.out.println("Send amount: " + sendAmount.toFriendlyString()); + System.out.println("Return change: " + req.returnChange); + + wallet.completeTx(req); + return req.tx; + } + + @Test + public void selectionTest() throws InsufficientMoneyException { + createTestTransaction(Coin.valueOf(5,50), true); + createTestTransaction(Coin.valueOf(5,50), false); + } + + @Test + public void selectionOverRangeTest() throws InsufficientMoneyException { + ArrayList list = Lists.newArrayList(); + for (int i = 0; i < 100; i++) { + for (int j = 0; j < 10; ++j) { + if (i == 0 && j == 0) j = 1; + Transaction tx = createTestTransaction(Coin.valueOf(i, j * 10), true); + list.add(tx); + } + } + list.forEach(tx -> { + System.out.println("" + tx.getValue(wallet).toFriendlyString() + " fee: " + tx.getFee().toFriendlyString()); + }); + } } diff --git a/core/src/test/resources/org/bitcoinj/wallet/coinjoin-decrypted.wallet b/core/src/test/resources/org/bitcoinj/wallet/coinjoin-decrypted.wallet new file mode 100644 index 000000000..47810db7f Binary files /dev/null and b/core/src/test/resources/org/bitcoinj/wallet/coinjoin-decrypted.wallet differ