Client side blame round support
MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Integration/WabiSabiHttpApiIntegrationTests.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Integration/WabiSabiHttpApiIntegrationTests.cs
@@ -10,7 +10,10 @@ using WalletWasabi.Blockchain.Keys;
using WalletWasabi.Tests.Helpers;
using WalletWasabi.WabiSabi.Backend;
using WalletWasabi.WabiSabi.Backend.Models;
+using WalletWasabi.WabiSabi.Backend.PostRequests;
+using WalletWasabi.WabiSabi.Backend.Rounds;
using WalletWasabi.WabiSabi.Client;
+using WalletWasabi.WabiSabi.Models;
using WalletWasabi.WabiSabi.Models.MultipartyTransaction;
using WalletWasabi.Wallets;
using Xunit;
@@ -53,7 +56,7 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Integration
[InlineData(new long[] { 10_000_000, 20_000_000, 30_000_000, 40_000_000, 100_000_000 }, new long[] { 100_000_000, 100_000_000 })]
[InlineData(new long[] { 120_000_000 }, new long[] { 20_000_000, 40_000_000, 60_000_000 })]
[InlineData(new long[] { 100_000_000, 10_000_000, 10_000 })]
- public async Task SoloCoinJoinTestAsync(long[] amounts, long[]? outputs = null)
+ public async Task SoloCoinJoinTestAsync(long[] amounts, long[]? outputDenominations = null)
{
int inputCount = amounts.Length;
@@ -64,7 +67,7 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Integration
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(180));
cts.Token.Register(() => transactionCompleted.TrySetCanceled(), useSynchronizationContext: false);
- // Create a key manager and use it to create two fake coins.
+ // Create a key manager and use it to create fake coins.
var keyManager = KeyManager.CreateNew(out var _, password: "");
keyManager.AssertCleanKeysIndexed();
var coins = keyManager.GetKeys()
@@ -80,7 +83,7 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Integration
{
var rpc = BitcoinFactory.GetMockMinimalRpc();
- // Make the coordinator to believe that those two coins are real and
+ // Make the coordinator to believe that the coins are real and
// that they exist in the blockchain with many confirmations.
rpc.OnGetTxOutAsync = (txId, idx, _) => new()
{
@@ -118,10 +121,112 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Integration
var coinJoinClient = new CoinJoinClient(apiClient, coins, kitchen, keyManager, roundStateUpdater);
// Run the coinjoin client task.
- Assert.True(await coinJoinClient.StartCoinJoinAsync(cts.Token, outputs?.Select(s => Money.Satoshis(s))));
+ Assert.True(await coinJoinClient.StartCoinJoinAsync(cts.Token, outputDenominations?.Select(s => Money.Satoshis(s))));
- var boadcastedTx = await transactionCompleted.Task.ConfigureAwait(false); // wait for the transaction to be broadcasted.
- Assert.NotNull(boadcastedTx);
+ var broadcastedTx = await transactionCompleted.Task.ConfigureAwait(false); // wait for the transaction to be broadcasted.
+ Assert.NotNull(broadcastedTx);
+
+ await roundStateUpdater.StopAsync(CancellationToken.None);
+ }
+
+ [Theory]
+ [InlineData(new long[] { 20_000_000, 40_000_000, 60_000_000, 80_000_000 })]
+ [InlineData(new long[] { 10_000_000, 20_000_000, 30_000_000, 40_000_000, 100_000_000 })]
+ [InlineData(new long[] { 100_000_000, 10_000_000, 10_000 })]
+ public async Task SoloCoinJoinWithBlameRoundTestAsync(long[] amounts, long[]? outputDenominations = null)
+ {
+ int inputCount = amounts.Length;
+
+ // At the end of the test a coinjoin transaction has to be created and broadcasted.
+ var transactionCompleted = new TaskCompletionSource<Transaction>();
+
+ // Total test timeout.
+ using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
+ cts.Token.Register(() => transactionCompleted.TrySetCanceled(), useSynchronizationContext: false);
+
+ var keyManager1 = KeyManager.CreateNew(out var _, password: "");
+ keyManager1.AssertCleanKeysIndexed();
+
+ var keyManager2 = KeyManager.CreateNew(out var _, password: "");
+ keyManager2.AssertCleanKeysIndexed();
+
+ var coins = keyManager1.GetKeys()
+ .Take(inputCount)
+ .Select((x, i) => new Coin(
+ BitcoinFactory.CreateOutPoint(),
+ new TxOut(Money.Satoshis(amounts[i]), x.P2wpkhScript)))
+ .ToArray();
+
+ var badCoins = keyManager2.GetKeys()
+ .Take(inputCount)
+ .Select((x, i) => new Coin(
+ BitcoinFactory.CreateOutPoint(),
+ new TxOut(Money.Satoshis(amounts[i]), x.P2wpkhScript)))
+ .ToArray();
+
+ var httpClient = _apiApplicationFactory.WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureServices(services =>
+ {
+ var rpc = BitcoinFactory.GetMockMinimalRpc();
+
+ // Make the coordinator to believe that the coins are real and
+ // that they exist in the blockchain with many confirmations.
+ rpc.OnGetTxOutAsync = (txId, idx, _) => new()
+ {
+ Confirmations = 101,
+ IsCoinBase = false,
+ ScriptPubKeyType = "witness_v0_keyhash",
+ TxOut = Enumerable.Concat(coins, badCoins).Single(x => x.Outpoint.Hash == txId && x.Outpoint.N == idx).TxOut
+ };
+
+ // Make the coordinator believe that the transaction is being
+ // broadcasted using the RPC interface. Once we receive this tx
+ // (the `SendRawTransationAsync` was invoked) we stop waiting
+ // and finish the waiting tasks to finish the test successfully.
+ rpc.OnSendRawTransactionAsync = (tx) =>
+ {
+ transactionCompleted.SetResult(tx);
+ return tx.GetHash();
+ };
+
+ // Instruct the coodinator DI container to use these two scoped
+ // services to build everything (wabisabi controller, arena, etc)
+ services.AddScoped<IRPCClient>(s => rpc);
+ services.AddScoped<WabiSabiConfig>(s => new WabiSabiConfig { MaxInputCountByRound = 2 * inputCount });
+ });
+ }).CreateClient();
+
+ // Create the coinjoin client
+ var apiClient = _apiApplicationFactory.CreateWabiSabiHttpApiClient(httpClient);
+ using var roundStateUpdater = new RoundStateUpdater(TimeSpan.FromSeconds(1), apiClient);
+ await roundStateUpdater.StartAsync(CancellationToken.None);
+
+ var roundState = await roundStateUpdater.CreateRoundAwaiter(roundState => roundState.Phase == Phase.InputRegistration, cts.Token);
+
+ var kitchen = new Kitchen();
+ kitchen.Cook("");
+
+ var coinJoinClient = new CoinJoinClient(apiClient, coins, kitchen, keyManager1, roundStateUpdater);
+
+ // Run the coinjoin client task.
+ var coinJoinTask = Task.Run(async () => await coinJoinClient.StartCoinJoinAsync(cts.Token));
+
+ var noSignatureApiClient = new SignatureDroppingClient(apiClient);
+ var badCoinJoinClient = new CoinJoinClient(noSignatureApiClient, badCoins, kitchen, keyManager2, roundStateUpdater);
+ var badCoinsTask = Task.Run(async () => await badCoinJoinClient.StartRoundAsync(roundState, cts.Token) );
+
+ await Task.WhenAll(new Task[] { badCoinsTask, coinJoinTask });
+
+ Assert.False(badCoinsTask.Result);
+ Assert.True(coinJoinTask.Result);
+
+ var broadcastedTx = await transactionCompleted.Task.ConfigureAwait(false); // wait for the transaction to be broadcasted.
+ Assert.NotNull(broadcastedTx);
+
+ Assert.Equal(
+ coins.Select(x => x.Outpoint.ToString()).OrderBy(x => x),
+ broadcastedTx.Inputs.Select(x => x.PrevOut.ToString()).OrderBy(x => x));
await roundStateUpdater.StopAsync(CancellationToken.None);
}
@@ -208,5 +313,35 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Integration
return result2;
}
}
+
+ private class SignatureDroppingClient : IWabiSabiApiRequestHandler
+ {
+ private IWabiSabiApiRequestHandler ApiClient;
+
+ public SignatureDroppingClient(IWabiSabiApiRequestHandler apiClient)
+ {
+ ApiClient = apiClient;
+ }
+
+ public async Task SignTransactionAsync(TransactionSignaturesRequest request, CancellationToken cancellationToken)
+ {
+ return;
+ }
+
+ public Task<InputRegistrationResponse> RegisterInputAsync(InputRegistrationRequest request, CancellationToken cancellationToken)
+ => ApiClient.RegisterInputAsync(request, cancellationToken);
+ public Task<ConnectionConfirmationResponse> ConfirmConnectionAsync(ConnectionConfirmationRequest request, CancellationToken cancellationToken)
+ => ApiClient.ConfirmConnectionAsync(request, cancellationToken);
+ public Task<OutputRegistrationResponse> RegisterOutputAsync(OutputRegistrationRequest request, CancellationToken cancellationToken)
+ => ApiClient.RegisterOutputAsync(request, cancellationToken);
+ public Task<ReissueCredentialResponse> ReissueCredentialAsync(ReissueCredentialRequest request, CancellationToken cancellationToken)
+ => ApiClient.ReissueCredentialAsync(request, cancellationToken);
+ public Task ReadyToSign(ReadyToSignRequestRequest request, CancellationToken cancellationToken) // FIXME naming
+ => ApiClient.ReadyToSign(request, cancellationToken);
+ public Task RemoveInputAsync(InputsRemovalRequest request, CancellationToken cancellationToken)
+ => ApiClient.RemoveInputAsync(request, cancellationToken);
+ public Task<RoundState[]> GetStatusAsync(CancellationToken cancellationToken)
+ => ApiClient.GetStatusAsync(cancellationToken);
+ }
}
}

MODIFY
WalletWasabi/WabiSabi/Backend/Rounds/Arena.cs
WalletWasabi/WabiSabi/Backend/Rounds/Arena.cs
@@ -221,6 +221,7 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
RoundParameters parameters = new(Config, Network, Random, feeRate, blameOf: round);
Round blameRound = new(parameters);
Rounds.Add(blameRound);
+ Rounds.Remove(round);
}
private async Task CreateRoundsAsync()

MODIFY
WalletWasabi/WabiSabi/Client/CoinJoinClient.cs
WalletWasabi/WabiSabi/Client/CoinJoinClient.cs
@@ -45,7 +45,33 @@ namespace WalletWasabi.WabiSabi.Client
public async Task<bool> StartCoinJoinAsync(CancellationToken cancellationToken, IEnumerable<Money>? forcedOutputDenominations = null)
{
- var roundState = await RoundStatusUpdater.CreateRoundAwaiter(roundState => roundState.Phase == Phase.InputRegistration, cancellationToken).ConfigureAwait(false);
+ var currentRoundState = await RoundStatusUpdater.CreateRoundAwaiter(roundState => roundState.Phase == Phase.InputRegistration, cancellationToken).ConfigureAwait(false);
+
+ // This should be roughly log(#inputs), it could be set slightly
+ // higher if more inputs are observed but that involves trusting the
+ // coordinator with those values. Therefore, conservatively set this
+ // so that a maximum of 5 blame rounds are executed.
+ // FIXME should smaller rounds abort earlier?
+ var tryLimit = 6;
+
+ for (var tries = 0; tries < tryLimit; tries++)
+ {
+ if (await StartRoundAsync(currentRoundState, cancellationToken, forcedOutputDenominations))
+ {
+ return true;
+ }
+ else
+ {
+ var blameRoundState = await RoundStatusUpdater.CreateRoundAwaiter(roundState => roundState.BlameOf == currentRoundState.Id, cancellationToken).ConfigureAwait(false);
+ currentRoundState = blameRoundState;
+ }
+ }
+
+ return false;
+ }
+
+ public async Task<bool> StartRoundAsync(RoundState roundState, CancellationToken cancellationToken, IEnumerable<Money>? forcedOutputDenominations = null)
+ {
var constructionState = roundState.Assert<ConstructionState>();
// Calculate outputs values
@@ -91,13 +117,9 @@ namespace WalletWasabi.WabiSabi.Client
// Send signature.
await SignTransactionAsync(aliceClients, unsignedCoinJoin, cancellationToken).ConfigureAwait(false);
- // TODO wait for round to end when it is pruned from the Arena:
- // 1. success: round disappears without a trace (wait for it to appear in mempool?)
- // 2. failed: a new round with isBlameOf appears
-
- var finalRoundState = await RoundStatusUpdater.CreateRoundAwaiter(s => s.Id == roundState.Id && ( s.Phase == Phase.Failed || s.Phase == Phase.TransactionBroadcasting ), cancellationToken).ConfigureAwait(false);
+ var finalRoundState = await RoundStatusUpdater.CreateRoundAwaiter(s => s.Id == roundState.Id && ( s.Phase == Phase.Failed || s.Phase == Phase.TransactionBroadcasting ) || s.BlameOf == roundState.Id, cancellationToken).ConfigureAwait(false);
- return finalRoundState.Phase == Phase.TransactionBroadcasting;
+ return finalRoundState.Id == roundState.Id && finalRoundState.Phase == Phase.TransactionBroadcasting;
}
private List<AliceClient> CreateAliceClients(RoundState roundState)

Client side blame round support