Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7158daa
refactor: move coinJoinSalt to CoinJoinExtension
HashEngineering Aug 16, 2025
f87e50c
tests: improve formatMessage
HashEngineering Aug 16, 2025
6f95275
feat: add greedy algorithm to CoinJoinCoinSelector
HashEngineering Aug 19, 2025
e2bec45
feat: improve greedy algorithm to CoinJoinCoinSelector
HashEngineering Aug 21, 2025
c4eed55
tests: improve CoinJoinCoinSelectorTest
HashEngineering Aug 21, 2025
80d88c4
fix: consolidate denoms
HashEngineering Aug 22, 2025
2c1b697
fix: change to logs instead of println
HashEngineering Aug 22, 2025
e2259e3
tests: add CoinJoinGreedyCustomTest
HashEngineering Jan 8, 2026
cc92f5e
Merge branch 'master' of https://github.com/dashevo/dashj into feat/c…
HashEngineering Jan 27, 2026
13d0b4c
tests: replace coinjoin wallet file with decrypted wallet
HashEngineering Feb 4, 2026
9d3fe1b
fix: optimize CoinJoinCoinSelector and add profiling
HashEngineering Feb 4, 2026
0a133c3
tests: ignore walletSavePerformanceTest
HashEngineering Feb 4, 2026
3645af2
tests: update CoinJoin greedy tests
HashEngineering Feb 4, 2026
3717cee
fix: address issues with fees and recipient pays fees
HashEngineering Feb 6, 2026
48411ba
tests: update LargeCoinJoinWalletTest balance
HashEngineering Feb 6, 2026
b5bb9ca
tests: update max heap size to 2g
HashEngineering Feb 11, 2026
ed1ea09
fix: use lock on markAsFullyMixed
HashEngineering Feb 11, 2026
906e94e
refactor: improve logging for CoinJoinCoinSelector, use constants
HashEngineering Feb 11, 2026
a40501e
tests: improve tests
HashEngineering Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ obj/
libs/
*.chain
*.spvchain
*.wallet
/*.wallet
*.tmp
checkpoints
checkpoints-testnet
checkpoints-testnet.txt
checkpoints.txt
checkpoints-krupnik
checkpoints-krupnik.txt
*.mnlist
*.chainlocks
/*.mnlist
/*.chainlocks
*.gobjects
*.log
/*.log
.DS_Store
1 change: 1 addition & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
366 changes: 353 additions & 13 deletions core/src/main/java/org/bitcoinj/coinjoin/CoinJoinCoinSelector.java

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions core/src/main/java/org/bitcoinj/wallet/CoinJoinExtension.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -234,15 +235,15 @@ public void deserializeWalletExtension(Wallet containingWallet, byte[] data) thr
walletEx.mapOutpointRoundsCache.put(outPoint, rounds);
}
}

// Deserialize coinJoinSalt
if (coinJoinProto.hasCoinjoinSalt()) {
coinJoinSalt = Sha256Hash.wrap(coinJoinProto.getCoinjoinSalt().toByteArray());
} else {
// if there is no coinJoinSalt, then add it.
calculateCoinJoinSalt();
}

loadedKeys = true;
}

Expand Down
6 changes: 6 additions & 0 deletions core/src/main/java/org/bitcoinj/wallet/Wallet.java
Original file line number Diff line number Diff line change
Expand Up @@ -5770,6 +5770,12 @@ private FeeCalculation calculateFee(SendRequest req, Coin value, List<Transactio
tx.addOutput(changeOutput);
result.bestChangeOutput = changeOutput;
}
} else if (!req.returnChange) {
Coin outputSum = Coin.ZERO;
for (TransactionOutput output : tx.getOutputs()) {
outputSum = outputSum.add(output.getValue());
}
fee = result.bestCoinSelection.valueGathered.subtract(outputSum);
}

for (TransactionOutput selectedOutput : selection.gathered) {
Expand Down
10 changes: 10 additions & 0 deletions core/src/main/java/org/bitcoinj/wallet/WalletEx.java
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,16 @@ boolean isMine(TransactionInput input) {
return false;
}

@VisibleForTesting
public void markAsFullyMixed(TransactionOutPoint outPoint) {
lock.lock();
try {
mapOutpointRoundsCache.put(outPoint, 19);
} finally {
lock.unlock();
}
}

int getRealOutpointCoinJoinRounds(TransactionOutPoint outpoint, int rounds) {
checkState(lock.isHeldByCurrentThread());

Expand Down
180 changes: 180 additions & 0 deletions core/src/test/java/org/bitcoinj/coinjoin/CoinJoinCoinSelectorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,28 @@

package org.bitcoinj.coinjoin;

import org.bitcoinj.core.Address;
import org.bitcoinj.core.Coin;
import org.bitcoinj.core.Context;
import org.bitcoinj.core.InsufficientMoneyException;
import org.bitcoinj.core.Transaction;
import org.bitcoinj.core.TransactionOutput;
import org.bitcoinj.params.TestNet3Params;
import org.bitcoinj.wallet.CoinSelection;
import org.bitcoinj.wallet.SendRequest;
import org.bitcoinj.wallet.UnreadableWalletException;
import org.bitcoinj.wallet.WalletEx;
import org.bitcoinj.wallet.WalletProtobufSerializer;
import org.junit.Test;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

public class CoinJoinCoinSelectorTest extends TestWithCoinJoinWallet {

Expand All @@ -32,4 +50,166 @@ public void selectable() {
// txDenomination is mixed zero rounds, so it should not be selected
assertFalse(coinSelector.shouldSelect(txDenomination));
}

@Test
public void testRealWalletGreedySelection() 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 just one amount to avoid transaction size issues
Coin sendAmount = Coin.valueOf(50000000L); // 0.5 DASH
System.out.println("\n=== Testing Real Wallet Transaction ===");
System.out.println("Send amount: " + sendAmount.toFriendlyString());
System.out.println("Wallet balance: " + realWallet.getBalance().toFriendlyString());

// Test with greedy algorithm - just coin selection without transaction creation
CoinJoinCoinSelector greedySelector = new CoinJoinCoinSelector(realWallet, false, true);
CoinJoinCoinSelector normalSelector = new CoinJoinCoinSelector(realWallet, false, false);

List<TransactionOutput> 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();
}
}
Comment on lines 113 to 213
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Greedy vs normal comparison mutates the same wallet and mixes settings.

completeTx spends inputs, so the “normal” transaction is built from a mutated wallet after the greedy spend. In addition, returnChange is disabled only for greedy. This makes the comparison unreliable.

Consider reloading/cloning the wallet per comparison (or per iteration) and aligning returnChange/fee settings between greedy and normal requests.

🤖 Prompt for AI Agents
In `@core/src/test/java/org/bitcoinj/coinjoin/CoinJoinCoinSelectorTest.java`
around lines 113 - 212, The test mutates the same WalletEx (realWallet) by
calling completeTx twice so the normalReq runs on a wallet already spent by
greedyReq and returnChange differs; fix by cloning or reloading the wallet
before building each transaction and ensure both SendRequest instances use
consistent settings (e.g., set returnChange the same and same feePerKb).
Specifically, for each sendAmount create two independent wallet instances (or
re-read the wallet file) and run completeTx separately for greedyReq and
normalReq, and align properties on greedyReq and normalReq (returnChange,
feePerKb, coinSelector instantiation) so the comparison uses identical starting
state and settings.

}
}
Loading
Loading