Check that all the Txo are unspent
In case at least one Alice spent the coin those alices are noticed and the
round is ended.
MODIFY
WalletWasabi.Tests/Helpers/WabiSabiFactory.cs
WalletWasabi.Tests/Helpers/WabiSabiFactory.cs
@@ -78,6 +78,8 @@ namespace WalletWasabi.Tests.Helpers
{
MinRelayTxFee = 1
});
+ mockRpc.Setup(rpc => rpc.PrepareBatch()).Returns(mockRpc.Object);
+ mockRpc.Setup(rpc => rpc.SendBatchAsync()).Returns(Task.CompletedTask);
return mockRpc;
}

MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/PhaseStepping/StepInputRegistrationTests.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/PhaseStepping/StepInputRegistrationTests.cs
using System;
using System.Threading;
using System.Threading.Tasks;
+using Moq;
+using NBitcoin;
+using NBitcoin.RPC;
using WalletWasabi.Tests.Helpers;
using WalletWasabi.WabiSabi.Backend;
+using WalletWasabi.WabiSabi.Backend.Banning;
using WalletWasabi.WabiSabi.Backend.Rounds;
using Xunit;
@@ -32,6 +36,32 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PhaseStepping
await arena.StopAsync(CancellationToken.None);
}
+ [Fact]
+ public async Task DetectSpentTxoBeforeSteppingIntoConnectionConfirmationAsync()
+ {
+ WabiSabiConfig cfg = new() { MaxInputCountByRound = 3 };
+ var round = WabiSabiFactory.CreateRound(cfg);
+ var offendingAlice = WabiSabiFactory.CreateAlice(round); // this Alice spent the coin after registration
+
+ var mockRpc = WabiSabiFactory.CreatePreconfiguredRpcClient();
+ mockRpc.Setup(rpc => rpc.GetTxOutAsync(offendingAlice.Coin.Outpoint.Hash, (int)offendingAlice.Coin.Outpoint.N, true))
+ .ReturnsAsync((GetTxOutResponse)null!);
+
+ using Arena arena = await WabiSabiFactory.CreateAndStartArenaAsync(cfg, mockRpc, round);
+
+ round.Alices.Add(WabiSabiFactory.CreateAlice(round));
+ round.Alices.Add(offendingAlice);
+ round.Alices.Add(WabiSabiFactory.CreateAlice(round));
+ await arena.TriggerAndWaitRoundAsync(TimeSpan.FromSeconds(21));
+ Assert.Equal(Phase.Ended, round.Phase);
+
+ var punishedAlice = Assert.Single(arena.Prison.GetInmates());
+ Assert.Equal(offendingAlice.Coin.Outpoint, punishedAlice.Utxo);
+ Assert.Equal(round.Id, punishedAlice.LastDisruptedRoundId);
+ Assert.Equal(Punishment.Banned, punishedAlice.Punishment);
+ await arena.StopAsync(CancellationToken.None);
+ }
+
[Fact]
public async Task BlameRoundFullAsync()
{

MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Client/ArenaClientTests.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Client/ArenaClientTests.cs
@@ -54,6 +54,8 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Client
{
MinRelayTxFee = 1
});
+ mockRpc.Setup(rpc => rpc.PrepareBatch()).Returns(mockRpc.Object);
+ mockRpc.Setup(rpc => rpc.SendBatchAsync()).Returns(Task.CompletedTask);
using Arena arena = await WabiSabiFactory.CreateAndStartArenaAsync(config, mockRpc, round);
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromMinutes(1));

MODIFY
WalletWasabi/WabiSabi/Backend/Banning/Prison.cs
WalletWasabi/WabiSabi/Backend/Banning/Prison.cs
@@ -42,6 +42,11 @@ namespace WalletWasabi.WabiSabi.Backend.Banning
Punish(alice.Coin.Outpoint, Punishment.Noted, lastDisruptedRoundId);
}
+ public void Ban(Alice alice, uint256 lastDisruptedRoundId)
+ {
+ Punish(alice.Coin.Outpoint, Punishment.Banned, lastDisruptedRoundId);
+ }
+
public void Punish(OutPoint utxo, Punishment punishment, uint256 lastDisruptedRoundId)
=> Punish(new Inmate(utxo, punishment, DateTimeOffset.UtcNow, lastDisruptedRoundId));

MODIFY
WalletWasabi/WabiSabi/Backend/Rounds/Arena.cs
WalletWasabi/WabiSabi/Backend/Rounds/Arena.cs
@@ -53,7 +53,7 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
StepConnectionConfirmationPhase();
- StepInputRegistrationPhase();
+ await StepInputRegistrationPhaseAsync(cancel).ConfigureAwait(false);
cancel.ThrowIfCancellationRequested();
@@ -62,7 +62,7 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
}
}
- private void StepInputRegistrationPhase()
+ private async Task StepInputRegistrationPhaseAsync(CancellationToken cancel)
{
foreach (var round in Rounds.Where(x =>
x.Phase == Phase.InputRegistration
@@ -75,10 +75,50 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
round.LogInfo($"Not enough inputs ({round.InputCount}) in {nameof(Phase.InputRegistration)} phase.");
}
else
+ {
+ var areAllTxoUnspent = await CheckTxoSpendStatusAsync(round, cancel).ConfigureAwait(false);
+ if (areAllTxoUnspent)
{
round.SetPhase(Phase.ConnectionConfirmation);
}
+ else
+ {
+ round.SetPhase(Phase.Ended);
+ round.LogInfo($"The round was disrupted by users who spent their coins.");
+ }
+ }
+ }
+ }
+
+ private async Task<bool> CheckTxoSpendStatusAsync(Round round, CancellationToken cancellationToken)
+ {
+ IEnumerable<(Alice Alice, Task<GetTxOutResponse?> Status)> GetTxoSpendingStatus()
+ {
+ foreach (var alice in round.Alices)
+ {
+ var input = alice.Coin.Outpoint;
+ var getTxOutTask = Rpc.GetTxOutAsync(input.Hash, (int)input.N, includeMempool: true);
+ yield return (alice, getTxOutTask);
+ }
+ }
+
+ var allTxoAreUnspent = true;
+ var batchedRpc = Rpc.PrepareBatch();
+ var spendStatusCheckingTasks = GetTxoSpendingStatus().ToArray();
+
+ cancellationToken.ThrowIfCancellationRequested();
+ await batchedRpc.SendBatchAsync().ConfigureAwait(false);
+
+ foreach (var (alice, task) in spendStatusCheckingTasks)
+ {
+ var status = await task.ConfigureAwait(false);
+ if (status is null)
+ {
+ allTxoAreUnspent = false;
+ Prison.Ban(alice, round.Id);
+ }
}
+ return allTxoAreUnspent;
}
private void StepConnectionConfirmationPhase()

Check that all the Txo are unspent

In case at least one Alice spent the coin those alices are noticed and the
round is ended.