Enable to remix mixed outputs
Resolves
MODIFY
WalletWasabi.Tests/RegTests.cs
WalletWasabi.Tests/RegTests.cs
@@ -1697,19 +1697,19 @@ namespace WalletWasabi.Tests
httpRequestException = await Assert.ThrowsAsync<HttpRequestException>(async () => await AliceClient.CreateNewAsync(network, inputsRequest, baseUri));
Assert.StartsWith($"{HttpStatusCode.BadRequest.ToReasonString()}\nNot enough inputs are provided. Fee to pay:", httpRequestException.Message);
- roundConfig.Denomination = Money.Coins(0.01m); // exactly the same as our output
+ roundConfig.SetDenomination(Money.Coins(0.01m)); // exactly the same as our output
coordinator.UpdateRoundConfig(roundConfig);
coordinator.AbortAllRoundsInInputRegistration(nameof(RegTests), "");
httpRequestException = await Assert.ThrowsAsync<HttpRequestException>(async () => await AliceClient.CreateNewAsync(network, inputsRequest, baseUri));
Assert.StartsWith($"{HttpStatusCode.BadRequest.ToReasonString()}\nNot enough inputs are provided. Fee to pay:", httpRequestException.Message);
- roundConfig.Denomination = Money.Coins(0.00999999m); // one satoshi less than our output
+ roundConfig.SetDenomination(Money.Coins(0.00999999m)); // one satoshi less than our output
coordinator.UpdateRoundConfig(roundConfig);
coordinator.AbortAllRoundsInInputRegistration(nameof(RegTests), "");
httpRequestException = await Assert.ThrowsAsync<HttpRequestException>(async () => await AliceClient.CreateNewAsync(network, inputsRequest, baseUri));
Assert.StartsWith($"{HttpStatusCode.BadRequest.ToReasonString()}\nNot enough inputs are provided. Fee to pay:", httpRequestException.Message);
- roundConfig.Denomination = Money.Coins(0.008m); // one satoshi less than our output
+ roundConfig.SetDenomination(Money.Coins(0.008m)); // one satoshi less than our output
roundConfig.ConnectionConfirmationTimeout = 2;
coordinator.UpdateRoundConfig(roundConfig);
coordinator.AbortAllRoundsInInputRegistration(nameof(RegTests), "");
@@ -2545,7 +2545,7 @@ namespace WalletWasabi.Tests
Assert.True(2 * feeRateReal.FeePerK > feeRateTx.FeePerK); // Max 200% mistake.
var activeOutput = finalCoinjoin.GetIndistinguishableOutputs().OrderByDescending(x => x.count).First();
- Assert.True(activeOutput.value >= roundConfig.Denomination);
+ Assert.True(activeOutput.value >= roundConfig.CurrentDenomination);
Assert.True(activeOutput.value >= roundConfig.AnonymitySet);
foreach (var aliceClient in aliceClients)

MODIFY
WalletWasabi/Models/ChaumianCoinJoin/CcjRound.cs
WalletWasabi/Models/ChaumianCoinJoin/CcjRound.cs
@@ -131,7 +131,7 @@ namespace WalletWasabi.Models.ChaumianCoinJoin
UtxoReferee = Guard.NotNull(nameof(utxoReferee), utxoReferee);
Guard.NotNull(nameof(config), config);
- Denomination = config.Denomination;
+ Denomination = config.CurrentDenomination;
ConfirmationTarget = (int)config.ConfirmationTarget;
CoordinatorFeePercent = (decimal)config.CoordinatorFeePercent;
AnonymitySet = (int)config.AnonymitySet;
@@ -168,7 +168,7 @@ namespace WalletWasabi.Models.ChaumianCoinJoin
}
}
- public async Task ExecuteNextPhaseAsync(CcjRoundPhase expectedPhase)
+ public async Task ExecuteNextPhaseAsync(CcjRoundPhase expectedPhase, Money feePerInputs = null, Money feePerOutputs = null)
{
using (await RoundSynchronizerLock.LockAsync())
{
@@ -183,33 +183,17 @@ namespace WalletWasabi.Models.ChaumianCoinJoin
return;
}
- // Calculate fees
- var inputSizeInBytes = (int)Math.Ceiling(((3 * Constants.P2wpkhInputSizeInBytes) + Constants.P2pkhInputSizeInBytes) / 4m);
- var outputSizeInBytes = Constants.OutputSizeInBytes;
- try
+ // Calculate fees.
+ if (feePerInputs is null || feePerOutputs is null)
{
- var estimateSmartFeeResponse = await RpcClient.EstimateSmartFeeAsync(ConfirmationTarget, EstimateSmartFeeMode.Conservative, simulateIfRegTest: true, tryOtherFeeRates: true);
- if (estimateSmartFeeResponse is null) throw new InvalidOperationException("FeeRate is not yet initialized");
- var feeRate = estimateSmartFeeResponse.FeeRate;
- Money feePerBytes = (feeRate.FeePerK / 1000);
-
- // Make sure min relay fee (1000 sat) is hit.
- FeePerInputs = Math.Max(feePerBytes * inputSizeInBytes, new Money(500));
- FeePerOutputs = Math.Max(feePerBytes * outputSizeInBytes, new Money(250));
+ (Money feePerInputs, Money feePerOutputs) fees = await CalculateFeesAsync(RpcClient, ConfirmationTarget);
+ FeePerInputs = feePerInputs ?? fees.feePerInputs;
+ FeePerOutputs = feePerOutputs ?? fees.feePerOutputs;
}
- catch (Exception ex)
- {
- // If fee hasn't been initialized once, fall back.
- if (FeePerInputs is null || FeePerOutputs is null)
+ else
{
- var feePerBytes = new Money(100); // 100 satoshi per byte
-
- // Make sure min relay fee (1000 sat) is hit.
- FeePerInputs = Math.Max(feePerBytes * inputSizeInBytes, new Money(500));
- FeePerOutputs = Math.Max(feePerBytes * outputSizeInBytes, new Money(250));
- }
-
- Logger.LogError<CcjRound>(ex);
+ FeePerInputs = feePerInputs;
+ FeePerOutputs = feePerOutputs;
}
Status = CcjRoundStatus.Running;
@@ -525,6 +509,44 @@ namespace WalletWasabi.Models.ChaumianCoinJoin
#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
}
+ public static async Task<(Money feePerInputs, Money feePerOutputs)> CalculateFeesAsync(RPCClient rpc, int confirmationTarget)
+ {
+ Guard.NotNull(nameof(rpc), rpc);
+ Guard.NotNull(nameof(confirmationTarget), confirmationTarget);
+
+ Money feePerInputs = null;
+ Money feePerOutputs = null;
+ var inputSizeInBytes = (int)Math.Ceiling(((3 * Constants.P2wpkhInputSizeInBytes) + Constants.P2pkhInputSizeInBytes) / 4m);
+ var outputSizeInBytes = Constants.OutputSizeInBytes;
+ try
+ {
+ var estimateSmartFeeResponse = await rpc.EstimateSmartFeeAsync(confirmationTarget, EstimateSmartFeeMode.Conservative, simulateIfRegTest: true, tryOtherFeeRates: true);
+ if (estimateSmartFeeResponse is null) throw new InvalidOperationException("FeeRate is not yet initialized");
+ var feeRate = estimateSmartFeeResponse.FeeRate;
+ Money feePerBytes = (feeRate.FeePerK / 1000);
+
+ // Make sure min relay fee (1000 sat) is hit.
+ feePerInputs = Math.Max(feePerBytes * inputSizeInBytes, new Money(500));
+ feePerOutputs = Math.Max(feePerBytes * outputSizeInBytes, new Money(250));
+ }
+ catch (Exception ex)
+ {
+ // If fee hasn't been initialized once, fall back.
+ if (feePerInputs is null || feePerOutputs is null)
+ {
+ var feePerBytes = new Money(100); // 100 satoshi per byte
+
+ // Make sure min relay fee (1000 sat) is hit.
+ feePerInputs = Math.Max(feePerBytes * inputSizeInBytes, new Money(500));
+ feePerOutputs = Math.Max(feePerBytes * outputSizeInBytes, new Money(250));
+ }
+
+ Logger.LogError<CcjRound>(ex);
+ }
+
+ return (feePerInputs, feePerOutputs);
+ }
+
public void Succeed(bool syncLock = true)
{
if (syncLock)

MODIFY
WalletWasabi/Models/ChaumianCoinJoin/CcjRoundConfig.cs
WalletWasabi/Models/ChaumianCoinJoin/CcjRoundConfig.cs
@@ -14,12 +14,32 @@ namespace WalletWasabi.Models.ChaumianCoinJoin
[JsonObject(MemberSerialization.OptIn)]
public class CcjRoundConfig : IConfig
{
+ private Money _denomination;
+
/// <inheritdoc />
public string FilePath { get; internal set; }
+ public Money CurrentDenomination { get; internal set; }
+
[JsonProperty(PropertyName = "Denomination")]
[JsonConverter(typeof(MoneyBtcJsonConverter))]
- public Money Denomination { get; internal set; }
+ private Money Denomination
+ {
+ get => _denomination;
+ set
+ {
+ if (value != _denomination)
+ {
+ _denomination = value;
+ CurrentDenomination = value;
+ }
+ }
+ }
+
+ internal void SetDenomination(Money denomination)
+ {
+ Denomination = denomination;
+ }
[JsonProperty(PropertyName = "ConfirmationTarget")]
public int? ConfirmationTarget { get; internal set; }

MODIFY
WalletWasabi/Services/CcjCoordinator.cs
WalletWasabi/Services/CcjCoordinator.cs
@@ -177,7 +177,7 @@ namespace WalletWasabi.Services
RoundConfig.UpdateOrDefault(roundConfig);
}
- public async Task MakeSureTwoRunningRoundsAsync()
+ public async Task MakeSureTwoRunningRoundsAsync(Money feePerInputs = null, Money feePerOutputs = null)
{
using (await RoundsListLock.LockAsync())
{
@@ -187,13 +187,13 @@ namespace WalletWasabi.Services
var round = new CcjRound(RpcClient, UtxoReferee, RoundConfig);
round.CoinJoinBroadcasted += Round_CoinJoinBroadcasted;
round.StatusChanged += Round_StatusChangedAsync;
- await round.ExecuteNextPhaseAsync(CcjRoundPhase.InputRegistration);
+ await round.ExecuteNextPhaseAsync(CcjRoundPhase.InputRegistration, feePerInputs, feePerOutputs);
Rounds.Add(round);
var round2 = new CcjRound(RpcClient, UtxoReferee, RoundConfig);
round2.StatusChanged += Round_StatusChangedAsync;
round2.CoinJoinBroadcasted += Round_CoinJoinBroadcasted;
- await round2.ExecuteNextPhaseAsync(CcjRoundPhase.InputRegistration);
+ await round2.ExecuteNextPhaseAsync(CcjRoundPhase.InputRegistration, feePerInputs, feePerOutputs);
Rounds.Add(round2);
}
else if (runningRoundCount == 1)
@@ -201,21 +201,24 @@ namespace WalletWasabi.Services
var round = new CcjRound(RpcClient, UtxoReferee, RoundConfig);
round.StatusChanged += Round_StatusChangedAsync;
round.CoinJoinBroadcasted += Round_CoinJoinBroadcasted;
- await round.ExecuteNextPhaseAsync(CcjRoundPhase.InputRegistration);
+ await round.ExecuteNextPhaseAsync(CcjRoundPhase.InputRegistration, feePerInputs, feePerOutputs);
Rounds.Add(round);
}
}
}
- private void Round_CoinJoinBroadcasted(object sender, Transaction e)
+ private void Round_CoinJoinBroadcasted(object sender, Transaction transaction)
{
- CoinJoinBroadcasted?.Invoke(sender, e);
+ CoinJoinBroadcasted?.Invoke(sender, transaction);
}
private async void Round_StatusChangedAsync(object sender, CcjRoundStatus status)
{
var round = sender as CcjRound;
+ Money feePerInputs = null;
+ Money feePerOutputs = null;
+
// If success save the coinjoin.
if (status == CcjRoundStatus.Succeded)
{
@@ -224,6 +227,27 @@ namespace WalletWasabi.Services
uint256 coinJoinHash = round.SignedCoinJoin.GetHash();
CoinJoins.Add(coinJoinHash);
await File.AppendAllLinesAsync(CoinJoinsFilePath, new[] { coinJoinHash.ToString() });
+
+ // When a round succeeded, adjust the denomination as to users still be able to register with the latest round's active output amount.
+ IEnumerable<(Money value, int count)> outputs = round.SignedCoinJoin.GetIndistinguishableOutputs();
+ var bestOutput = outputs.OrderByDescending(x => x.count).FirstOrDefault();
+ if (bestOutput != default)
+ {
+ Money activeOutputAmount = bestOutput.value;
+
+ var fees = await CcjRound.CalculateFeesAsync(RpcClient, RoundConfig.ConfirmationTarget.Value);
+ feePerInputs = fees.feePerInputs;
+ feePerOutputs = fees.feePerOutputs;
+
+ Money newDenominationToGetInWithactiveOutputs = activeOutputAmount - (feePerInputs + 2 * feePerOutputs);
+ if (newDenominationToGetInWithactiveOutputs < RoundConfig.CurrentDenomination)
+ {
+ if (newDenominationToGetInWithactiveOutputs > Money.Coins(0.01m))
+ {
+ RoundConfig.CurrentDenomination = newDenominationToGetInWithactiveOutputs;
+ }
+ }
+ }
}
}
@@ -243,7 +267,7 @@ namespace WalletWasabi.Services
{
round.StatusChanged -= Round_StatusChangedAsync;
round.CoinJoinBroadcasted -= Round_CoinJoinBroadcasted;
- await MakeSureTwoRunningRoundsAsync();
+ await MakeSureTwoRunningRoundsAsync(feePerInputs, feePerOutputs);
}
}

Enable to remix mixed outputs
Resolves https://github.com/zkSNACKs/WalletWasabi/issues/833