Use MultipartyTransaction model in the backend
Updates the `Arena` & `Round` classes to use the immutable state model,
which now takes care of transaction level validations (e.g. amount
constraints).
MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/PhaseStepping/StepOutputRegistrationTests.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/PhaseStepping/StepOutputRegistrationTests.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+using WalletWasabi.Helpers;
using WalletWasabi.Tests.Helpers;
using WalletWasabi.WabiSabi.Backend;
using WalletWasabi.WabiSabi.Backend.Banning;
@@ -12,6 +13,7 @@ using WalletWasabi.WabiSabi.Backend.PostRequests;
using WalletWasabi.WabiSabi.Backend.Rounds;
using WalletWasabi.WabiSabi.Crypto;
using WalletWasabi.WabiSabi.Models;
+using WalletWasabi.WabiSabi.Models.MultipartyTransaction;
using Xunit;
namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PhaseStepping
@@ -75,8 +77,9 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PhaseStepping
}
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromSeconds(21));
Assert.Equal(Phase.TransactionSigning, round.Phase);
- Assert.Equal(2, round.Coinjoin.Inputs.Count);
- Assert.Equal(2, round.Coinjoin.Outputs.Count);
+ var tx = round.CoinjoinState.AssertSigning().CreateTransaction();
+ Assert.Equal(2, tx.Inputs.Count);
+ Assert.Equal(2, tx.Outputs.Count);
await arena.StopAsync(CancellationToken.None);
}
@@ -136,9 +139,10 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PhaseStepping
var orresp = await arena.RegisterOutputAsync(WabiSabiFactory.CreateOutputRegistrationRequests(round, ccresps).First());
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromSeconds(21));
Assert.Equal(Phase.TransactionSigning, round.Phase);
- Assert.Equal(2, round.Coinjoin.Inputs.Count);
- Assert.Equal(2, round.Coinjoin.Outputs.Count);
- Assert.Contains(cfg.BlameScript, round.Coinjoin.Outputs.Select(x => x.ScriptPubKey));
+ var tx = round.CoinjoinState.AssertSigning().CreateTransaction();
+ Assert.Equal(2, tx.Inputs.Count);
+ Assert.Equal(2, tx.Outputs.Count);
+ Assert.Contains(cfg.BlameScript, tx.Outputs.Select(x => x.ScriptPubKey));
await arena.StopAsync(CancellationToken.None);
}
@@ -199,12 +203,21 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PhaseStepping
{
var orresp = await arena.RegisterOutputAsync(orreq);
}
- round.Alices.Add(WabiSabiFactory.CreateAlice(value: round.MinRegistrableAmount - Money.Satoshis(1)));
+
+ // Add another input. The input must be able to pay for itself, but
+ // the remaining amount after deducting the fees needs to be less
+ // than the minimum.
+ var txParams = round.CoinjoinState.AssertConstruction().Parameters;
+ var extraAlice = WabiSabiFactory.CreateAlice(value: txParams.FeeRate.GetFee(Constants.P2wpkhInputVirtualSize) + txParams.AllowedOutputAmounts.Min - new Money(1));
+ round.Alices.Add(extraAlice);
+ round.CoinjoinState = round.CoinjoinState.AssertConstruction().AddInput(extraAlice.Coins.First());
+
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromSeconds(21));
Assert.Equal(Phase.TransactionSigning, round.Phase);
- Assert.Equal(3, round.Coinjoin.Inputs.Count);
- Assert.Equal(2, round.Coinjoin.Outputs.Count);
- Assert.DoesNotContain(cfg.BlameScript, round.Coinjoin.Outputs.Select(x => x.ScriptPubKey));
+ var tx = round.CoinjoinState.AssertSigning().CreateTransaction();
+ Assert.Equal(3, tx.Inputs.Count);
+ Assert.Equal(2, tx.Outputs.Count);
+ Assert.DoesNotContain(cfg.BlameScript, tx.Outputs.Select(x => x.ScriptPubKey));
await arena.StopAsync(CancellationToken.None);
}

MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/PhaseStepping/StepTransactionSigningTests.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/PhaseStepping/StepTransactionSigningTests.cs
@@ -13,6 +13,7 @@ using WalletWasabi.WabiSabi.Backend.PostRequests;
using WalletWasabi.WabiSabi.Backend.Rounds;
using WalletWasabi.WabiSabi.Crypto;
using WalletWasabi.WabiSabi.Models;
+using WalletWasabi.WabiSabi.Models.MultipartyTransaction;
using Xunit;
namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PhaseStepping
@@ -77,7 +78,7 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PhaseStepping
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromSeconds(21));
Assert.Equal(Phase.TransactionSigning, round.Phase);
- var signedCoinJoin = round.Coinjoin.Clone();
+ var signedCoinJoin = round.CoinjoinState.AssertSigning().CreateTransaction();
var coin1 = alice1.Coins.First();
var coin2 = alice2.Coins.First();
var idx1 = signedCoinJoin.Inputs.IndexOf(signedCoinJoin.Inputs.Single(x => x.PrevOut == coin1.Outpoint));
@@ -157,7 +158,7 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PhaseStepping
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromSeconds(21));
Assert.Equal(Phase.TransactionSigning, round.Phase);
- var signedCoinJoin = round.Coinjoin.Clone();
+ var signedCoinJoin = round.CoinjoinState.AssertSigning().CreateTransaction();
var coin1 = alice1.Coins.First();
var coin2 = alice2.Coins.First();
var idx1 = signedCoinJoin.Inputs.IndexOf(signedCoinJoin.Inputs.Single(x => x.PrevOut == coin1.Outpoint));
@@ -239,7 +240,7 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PhaseStepping
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromSeconds(21));
Assert.Equal(Phase.TransactionSigning, round.Phase);
- var signedCoinJoin = round.Coinjoin.Clone();
+ var signedCoinJoin = round.CoinjoinState.AssertSigning().CreateTransaction();
var coin1 = alice1.Coins.First();
var coin2 = alice2.Coins.First();
var idx1 = signedCoinJoin.Inputs.IndexOf(signedCoinJoin.Inputs.Single(x => x.PrevOut == coin1.Outpoint));
@@ -325,7 +326,7 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PhaseStepping
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromSeconds(21));
Assert.Equal(Phase.TransactionSigning, round.Phase);
- var signedCoinJoin = round.Coinjoin.Clone();
+ var signedCoinJoin = round.CoinjoinState.AssertSigning().CreateTransaction();
var coin1 = alice1.Coins.First();
var idx1 = signedCoinJoin.Inputs.IndexOf(signedCoinJoin.Inputs.Single(x => x.PrevOut == coin1.Outpoint));
signedCoinJoin.Sign(key1.GetBitcoinSecret(Network.Main), coin1);
@@ -405,10 +406,11 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PhaseStepping
var alice3 = WabiSabiFactory.CreateAlice();
alice3.ConfirmedConnection = true;
round.Alices.Add(alice3);
+ round.CoinjoinState = round.CoinjoinState.AssertConstruction().AddInput(alice3.Coins.First());
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromSeconds(21));
Assert.Equal(Phase.TransactionSigning, round.Phase);
- var signedCoinJoin = round.Coinjoin.Clone();
+ var signedCoinJoin = round.CoinjoinState.AssertSigning().CreateTransaction();
var coin1 = alice1.Coins.First();
var coin2 = alice2.Coins.First();
var idx1 = signedCoinJoin.Inputs.IndexOf(signedCoinJoin.Inputs.Single(x => x.PrevOut == coin1.Outpoint));

MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/PostRequests/RegisterOutputTests.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/PostRequests/RegisterOutputTests.cs
@@ -55,10 +55,12 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PostRequests
{
WabiSabiConfig cfg = new();
var round = WabiSabiFactory.CreateRound(cfg);
- round.SetPhase(Phase.OutputRegistration);
using Arena arena = await WabiSabiFactory.CreateAndStartArenaAsync(cfg, round);
using Key key = new();
+ round.SetPhase(Phase.OutputRegistration);
+ round.Alices.Add(WabiSabiFactory.CreateAlice(value: Money.Coins(1)));
+
var req = WabiSabiFactory.CreateOutputRegistrationRequest(round, key.PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main).ScriptPubKey);
await using ArenaRequestHandler handler = new(cfg, new Prison(), arena, new MockRpcClient());
var ex = await Assert.ThrowsAsync<WabiSabiProtocolException>(async () => await handler.RegisterOutputAsync(req));
@@ -72,9 +74,10 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PostRequests
{
WabiSabiConfig cfg = new();
var round = WabiSabiFactory.CreateRound(cfg);
- round.SetPhase(Phase.OutputRegistration);
using Arena arena = await WabiSabiFactory.CreateAndStartArenaAsync(cfg, round);
- using Key key = new();
+
+ round.SetPhase(Phase.OutputRegistration);
+ round.Alices.Add(WabiSabiFactory.CreateAlice(value: Money.Coins(1)));
var sha256Bounty = Script.FromHex("aa20000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f87");
var req = WabiSabiFactory.CreateOutputRegistrationRequest(round, sha256Bounty);

MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/PostRequests/SignTransactionTests.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/PostRequests/SignTransactionTests.cs
@@ -12,6 +12,7 @@ using WalletWasabi.WabiSabi.Backend.Models;
using WalletWasabi.WabiSabi.Backend.PostRequests;
using WalletWasabi.WabiSabi.Backend.Rounds;
using WalletWasabi.WabiSabi.Models;
+using WalletWasabi.WabiSabi.Models.MultipartyTransaction;
using Xunit;
namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PostRequests
@@ -26,18 +27,17 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PostRequests
using Key key = new();
Alice alice = WabiSabiFactory.CreateAlice(key: key);
round.Alices.Add(alice);
- var coinjoin = round.Coinjoin;
- coinjoin.Inputs.Add(alice.Coins.First().Outpoint);
+ round.CoinjoinState = round.AddInput(alice.Coins.First()).Finalize();
round.SetPhase(Phase.TransactionSigning);
using Arena arena = await WabiSabiFactory.CreateAndStartArenaAsync(cfg, round);
- var signedCoinJoin = coinjoin.Clone();
- signedCoinJoin.Sign(key.GetBitcoinSecret(Network.Main), alice.Coins.First());
+ var aliceSignedCoinJoin = round.CoinjoinState.AssertSigning().CreateUnsignedTransaction();
+ aliceSignedCoinJoin.Sign(key.GetBitcoinSecret(Network.Main), alice.Coins.First());
- var req = new TransactionSignaturesRequest(round.Id, new[] { new InputWitnessPair(0, signedCoinJoin.Inputs[0].WitScript) });
+ var req = new TransactionSignaturesRequest(round.Id, new[] { new InputWitnessPair(0, aliceSignedCoinJoin.Inputs[0].WitScript) });
await using ArenaRequestHandler handler = new(cfg, new Prison(), arena, new MockRpcClient());
await handler.SignTransactionAsync(req);
- Assert.True(round.Coinjoin.Inputs.First().HasWitScript());
+ Assert.True(round.CoinjoinState.AssertSigning().IsFullySigned);
await arena.StopAsync(CancellationToken.None);
}
@@ -85,17 +85,15 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.PostRequests
Alice alice2 = WabiSabiFactory.CreateAlice(key: key2);
round.Alices.Add(alice1);
round.Alices.Add(alice2);
- var coinjoin = round.Coinjoin;
- coinjoin.Inputs.Add(alice1.Coins.First().Outpoint);
- coinjoin.Inputs.Add(alice2.Coins.First().Outpoint);
+ round.CoinjoinState = round.CoinjoinState.AssertConstruction().AddInput(alice1.Coins.First()).AddInput(alice2.Coins.First()).Finalize();
round.SetPhase(Phase.TransactionSigning);
using Arena arena = await WabiSabiFactory.CreateAndStartArenaAsync(cfg, round);
// Submit the signature for the second alice to the first alice's input.
- var signedCoinJoin = coinjoin.Clone();
- signedCoinJoin.Sign(key2.GetBitcoinSecret(Network.Main), alice2.Coins.First());
+ var alice2signedCoinJoin = round.CoinjoinState.AssertSigning().CreateUnsignedTransaction();
+ alice2signedCoinJoin.Sign(key2.GetBitcoinSecret(Network.Main), alice2.Coins.First());
- var req = new TransactionSignaturesRequest(round.Id, new[] { new InputWitnessPair(0, signedCoinJoin.Inputs[0].WitScript) });
+ var req = new TransactionSignaturesRequest(round.Id, new[] { new InputWitnessPair(0, alice2signedCoinJoin.Inputs[0].WitScript) });
await using ArenaRequestHandler handler = new(cfg, new Prison(), arena, new MockRpcClient());
var ex = await Assert.ThrowsAsync<WabiSabiProtocolException>(async () => await handler.SignTransactionAsync(req));
Assert.Equal(WabiSabiProtocolErrorCode.WrongCoinjoinSignature, ex.ErrorCode);

MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Client/ArenaClientTests.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Client/ArenaClientTests.cs
@@ -2,6 +2,7 @@ using Moq;
using NBitcoin;
using System;
using System.Collections.Generic;
+using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using WalletWasabi.BitcoinCore.Rpc;
@@ -17,6 +18,7 @@ using WalletWasabi.WabiSabi.Backend.PostRequests;
using WalletWasabi.WabiSabi.Backend.Rounds;
using WalletWasabi.WabiSabi.Client;
using WalletWasabi.WabiSabi.Crypto;
+using WalletWasabi.WabiSabi.Models.MultipartyTransaction;
using Xunit;
namespace WalletWasabi.Tests.UnitTests.WabiSabi.Client
@@ -134,8 +136,9 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Client
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromMinutes(1));
Assert.Equal(Phase.TransactionSigning, round.Phase);
- Assert.Equal(1, round.Coinjoin.Inputs.Count);
- Assert.Equal(2, round.Coinjoin.Outputs.Count);
+ var tx = round.CoinjoinState.AssertSigning().CreateTransaction();
+ Assert.Equal(1, tx.Inputs.Count);
+ Assert.Equal(2, tx.Outputs.Count);
}
[Fact]
@@ -173,7 +176,6 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Client
Alice alice2 = WabiSabiFactory.CreateAlice(key: key2);
round.Alices.Add(alice2);
- var coinjoin = round.Coinjoin;
using Arena arena = await WabiSabiFactory.CreateAndStartArenaAsync(config, round);
var mockRpc = new Mock<IRPCClient>();
@@ -186,26 +188,40 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Client
round.SetPhase(Phase.TransactionSigning);
+ var emptyState = round.CoinjoinState.AssertConstruction();
+
+ // We can't use ``emptyState.Finalize()` because this is not a valid transaction so we fake it
+ var finalizedEmptyState = new SigningState(emptyState.Parameters, emptyState.Inputs.ToImmutableArray(), emptyState.Outputs.ToImmutableArray());
+
// No inputs in the CoinJoin.
- await Assert.ThrowsAsync<ArgumentException>(async () => await apiClient.SignTransactionAsync(round.Id, alice1.Coins.ToArray(), new BitcoinSecret(key1, Network.Main), coinjoin));
+ await Assert.ThrowsAsync<ArgumentException>(async () => await apiClient.SignTransactionAsync(round.Id, alice1.Coins.ToArray(), new BitcoinSecret(key1, Network.Main), finalizedEmptyState.CreateUnsignedTransaction()));
- coinjoin.Inputs.Add(alice1.Coins.First().Outpoint);
+ var oneInput = emptyState.AddInput(alice1.Coins.First()).Finalize();
+ round.CoinjoinState = oneInput;
// Trying to sign coins those are not in the CoinJoin.
- await Assert.ThrowsAsync<InvalidOperationException>(async () => await apiClient.SignTransactionAsync(round.Id, alice2.Coins.ToArray(), new BitcoinSecret(key2, Network.Main), coinjoin));
+ await Assert.ThrowsAsync<InvalidOperationException>(async () => await apiClient.SignTransactionAsync(round.Id, alice2.Coins.ToArray(), new BitcoinSecret(key2, Network.Main), oneInput.CreateUnsignedTransaction()));
- coinjoin.Inputs.Add(alice2.Coins.First().Outpoint);
+ var twoInputs = emptyState.AddInput(alice1.Coins.First()).AddInput(alice2.Coins.First()).Finalize();
+ round.CoinjoinState = twoInputs;
// Trying to sign coins with the wrong secret.
- await Assert.ThrowsAsync<InvalidOperationException>(async () => await apiClient.SignTransactionAsync(round.Id, alice1.Coins.ToArray(), new BitcoinSecret(key2, Network.Main), coinjoin));
+ await Assert.ThrowsAsync<InvalidOperationException>(async () => await apiClient.SignTransactionAsync(round.Id, alice1.Coins.ToArray(), new BitcoinSecret(key2, Network.Main), twoInputs.CreateUnsignedTransaction()));
+
+ Assert.False(round.CoinjoinState.AssertSigning().IsFullySigned);
+
+ var unsigned = round.CoinjoinState.AssertSigning().CreateUnsignedTransaction();
+
+ await apiClient.SignTransactionAsync(round.Id, alice1.Coins.ToArray(), new BitcoinSecret(key1, Network.Main), unsigned);
+ Assert.True(alice1.Coins.All(c => round.CoinjoinState.AssertSigning().IsInputSigned(c.Outpoint)));
+ Assert.False(alice2.Coins.Any(c => round.CoinjoinState.AssertSigning().IsInputSigned(c.Outpoint)));
- Assert.False(round.Coinjoin.HasWitness);
+ Assert.False(round.CoinjoinState.AssertSigning().IsFullySigned);
- await apiClient.SignTransactionAsync(round.Id, alice1.Coins.ToArray(), new BitcoinSecret(key1, Network.Main), coinjoin);
- Assert.True(round.Coinjoin.Inputs.Where(i => alice1.Coins.Select(c => c.Outpoint).Contains(i.PrevOut)).All(i => i.HasWitScript()));
+ await apiClient.SignTransactionAsync(round.Id, alice2.Coins.ToArray(), new BitcoinSecret(key2, Network.Main), unsigned);
+ Assert.True(alice2.Coins.All(c => round.CoinjoinState.AssertSigning().IsInputSigned(c.Outpoint)));
- await apiClient.SignTransactionAsync(round.Id, alice2.Coins.ToArray(), new BitcoinSecret(key2, Network.Main), coinjoin);
- Assert.True(round.Coinjoin.Inputs.Where(i => alice2.Coins.Select(c => c.Outpoint).Contains(i.PrevOut)).All(i => i.HasWitScript()));
+ Assert.True(round.CoinjoinState.AssertSigning().IsFullySigned);
}
}
}

MODIFY
WalletWasabi/WabiSabi/Backend/PostRequests/InputRegistrationHandler.cs
WalletWasabi/WabiSabi/Backend/PostRequests/InputRegistrationHandler.cs
@@ -13,6 +13,7 @@ using WalletWasabi.WabiSabi.Backend.Models;
using WalletWasabi.WabiSabi.Backend.Rounds;
using WalletWasabi.WabiSabi.Crypto.CredentialRequesting;
using WalletWasabi.WabiSabi.Models;
+using WalletWasabi.WabiSabi.Models.MultipartyTransaction;
namespace WalletWasabi.WabiSabi.Backend.PostRequests
{
@@ -81,6 +82,11 @@ namespace WalletWasabi.WabiSabi.Backend.PostRequests
var coins = coinRoundSignaturePairs.Select(x => x.Key);
+ if (round.IsInputRegistrationEnded(config.MaxInputCountByRound, config.GetInputRegistrationTimeout(round)))
+ {
+ throw new WabiSabiProtocolException(WabiSabiProtocolErrorCode.WrongPhase);
+ }
+
if (round.MaxInputCountByAlice < coins.Count())
{
throw new WabiSabiProtocolException(WabiSabiProtocolErrorCode.TooManyInputs);
@@ -118,9 +124,13 @@ namespace WalletWasabi.WabiSabi.Backend.PostRequests
throw new WabiSabiProtocolException(WabiSabiProtocolErrorCode.TooMuchVsize);
}
- if (round.IsInputRegistrationEnded(config.MaxInputCountByRound, config.GetInputRegistrationTimeout(round)))
+ // Compute but don't commit updated CoinJoin to round state, it will
+ // be re-calculated on input confirmation. This is computed it here
+ // for validation purposes.
+ var state = round.CoinjoinState.AssertConstruction();
+ foreach (var coin in alice.Coins)
{
- throw new WabiSabiProtocolException(WabiSabiProtocolErrorCode.WrongPhase);
+ state = state.AddInput(coin);
}
if (round.RemainingInputVsizeAllocation < round.PerAliceVsizeAllocation)

MODIFY
WalletWasabi/WabiSabi/Backend/Rounds/Arena.cs
WalletWasabi/WabiSabi/Backend/Rounds/Arena.cs
@@ -18,6 +18,7 @@ using WalletWasabi.WabiSabi.Backend.Models;
using WalletWasabi.WabiSabi.Backend.PostRequests;
using WalletWasabi.WabiSabi.Crypto.CredentialRequesting;
using WalletWasabi.WabiSabi.Models;
+using WalletWasabi.WabiSabi.Models.MultipartyTransaction;
namespace WalletWasabi.WabiSabi.Backend.Rounds
{
@@ -121,33 +122,22 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
var diff = aliceSum - bobSum;
if (diff == 0 || round.OutputRegistrationStart + round.OutputRegistrationTimeout < DateTimeOffset.UtcNow)
{
- // Build a coinjoin:
- var coinjoin = round.Coinjoin;
+ var coinjoin = round.CoinjoinState.AssertConstruction();
- // Add inputs:
- var spentCoins = round.Alices.SelectMany(x => x.Coins).ToArray();
- foreach (var input in spentCoins.Select(x => x.Outpoint))
- {
- coinjoin.Inputs.Add(input);
- }
- round.LogInfo($"{coinjoin.Inputs.Count} inputs were added.");
-
- // Add outputs:
- foreach (var bob in round.Bobs)
- {
- coinjoin.Outputs.AddWithOptimize(bob.CalculateOutputAmount(round.FeeRate), bob.Script);
- }
- round.LogInfo($"{round.Bobs.Count} outputs were added.");
+ round.LogInfo($"{coinjoin.Inputs.Count()} inputs were added.");
+ round.LogInfo($"{coinjoin.Outputs.Count()} outputs were added.");
// If timeout we must fill up the outputs to build a reasonable transaction.
// This won't be signed by the alice who failed to provide output, so we know who to ban.
- if (diff > round.MinRegistrableAmount)
+ if (diff > coinjoin.Parameters.AllowedOutputAmounts.Min)
{
- var diffMoney = Money.Satoshis(diff);
- coinjoin.Outputs.AddWithOptimize(diffMoney, Config.BlameScript);
+ var diffMoney = Money.Satoshis(diff) - coinjoin.Parameters.FeeRate.GetFee(Config.BlameScript.EstimateOutputVsize());
+ coinjoin = coinjoin.AddOutput(new TxOut(diffMoney, Config.BlameScript));
round.LogInfo("Filled up the outputs to build a reasonable transaction because some alice failed to provide its output.");
}
+ round.CoinjoinState = coinjoin.Finalize();
+
round.SetPhase(Phase.TransactionSigning);
}
}
@@ -157,13 +147,14 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
{
foreach (var round in Rounds.Values.Where(x => x.Phase == Phase.TransactionSigning).ToArray())
{
- var coinjoin = round.Coinjoin;
- var isFullySigned = coinjoin.Inputs.All(x => x.HasWitScript());
+ var state = round.CoinjoinState.AssertSigning();
try
{
- if (isFullySigned)
+ if (state.IsFullySigned)
{
+ var coinjoin = state.CreateTransaction();
+
// Logging.
round.LogInfo("Trying to broadcast coinjoin.");
Coin[]? spentCoins = round.Alices.SelectMany(x => x.Coins).ToArray();
@@ -202,10 +193,13 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
private async Task FailTransactionSigningPhaseAsync(Round round)
{
- var unsignedCoins = round.Coinjoin.Inputs.Where(x => !x.HasWitScript()).Select(x => x.PrevOut);
+ var state = round.CoinjoinState.AssertSigning();
+
+ var unsignedPrevouts = state.UnsignedInputs.ToHashSet();
+
var alicesWhoDidntSign = round.Alices
- .SelectMany(alice => alice.Coins, (alice, coin) => (Alice: alice, coin.Outpoint))
- .Where(x => unsignedCoins.Contains(x.Outpoint))
+ .SelectMany(alice => alice.Coins, (alice, coin) => (Alice: alice, Coin: coin))
+ .Where(x => unsignedPrevouts.Contains(x.Coin))
.Select(x => x.Alice)
.ToHashSet();
@@ -329,9 +323,20 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
}
else if (round.Phase == Phase.ConnectionConfirmation)
{
+ var state = round.CoinjoinState.AssertConstruction();
+
+ foreach (var coin in alice.Coins)
+ {
+ // Ensure the input can be added to the CoinJoin
+ state = state.AddInput(coin);
+ }
+
var commitAmountRealCredentialResponse = round.AmountCredentialIssuer.PrepareResponse(realAmountCredentialRequests);
var commitVsizeRealCredentialResponse = round.VsizeCredentialIssuer.PrepareResponse(realVsizeCredentialRequests);
+
+ // update state
alice.ConfirmedConnection = true;
+ round.CoinjoinState = state;
return new(
commitAmountZeroCredentialResponse.Commit(),
@@ -357,27 +362,9 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
var credentialAmount = -request.AmountCredentialRequests.Delta;
- if (!StandardScripts.IsStandardScriptPubKey(request.Script))
- {
- throw new WabiSabiProtocolException(WabiSabiProtocolErrorCode.NonStandardOutput, $"Round ({request.RoundId}): Non standard output.");
- }
-
- if (!request.Script.IsScriptType(ScriptType.P2WPKH))
- {
- throw new WabiSabiProtocolException(WabiSabiProtocolErrorCode.ScriptNotAllowed, $"Round ({request.RoundId}): Script not allowed.");
- }
-
Bob bob = new(request.Script, credentialAmount);
var outputValue = bob.CalculateOutputAmount(round.FeeRate);
- if (outputValue < round.MinRegistrableAmount)
- {
- throw new WabiSabiProtocolException(WabiSabiProtocolErrorCode.NotEnoughFunds, $"Round ({request.RoundId}): Not enough funds.");
- }
- if (outputValue > round.MaxRegistrableAmount)
- {
- throw new WabiSabiProtocolException(WabiSabiProtocolErrorCode.TooMuchFunds, $"Round ({request.RoundId}): Too much funds.");
- }
var vsizeCredentialRequests = request.VsizeCredentialRequests;
if (-vsizeCredentialRequests.Delta != bob.OutputVsize)
@@ -390,11 +377,18 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
throw new WabiSabiProtocolException(WabiSabiProtocolErrorCode.WrongPhase, $"Round ({request.RoundId}): Wrong phase ({round.Phase}).");
}
+ // Update the current round state with the additional output to ensure it's valid.
+ var newState = round.AddOutput(new TxOut(outputValue, bob.Script));
+
+ // Verify the credential requests and prepare their responses.
var commitAmountCredentialResponse = round.AmountCredentialIssuer.PrepareResponse(request.AmountCredentialRequests);
var commitVsizeCredentialResponse = round.VsizeCredentialIssuer.PrepareResponse(vsizeCredentialRequests);
+ // Update round state.
round.Bobs.Add(bob);
+ round.CoinjoinState = newState;
+ // Issue credentials and mark presented credentials as used.
return new(
commitAmountCredentialResponse.Commit(),
commitVsizeCredentialResponse.Commit());
@@ -414,39 +408,15 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
{
throw new WabiSabiProtocolException(WabiSabiProtocolErrorCode.WrongPhase, $"Round ({request.RoundId}): Wrong phase ({round.Phase}).");
}
- foreach (var inputWitnessPair in request.InputWitnessPairs)
- {
- var index = (int)inputWitnessPair.InputIndex;
- var witness = inputWitnessPair.Witness;
-
- // If input is already signed, don't bother.
- if (round.Coinjoin.Inputs[index].HasWitScript())
- {
- continue;
- }
-
- // Verify witness.
- // 1. Copy UnsignedCoinJoin.
- Transaction cjCopy = Transaction.Parse(round.Coinjoin.ToHex(), Network);
-
- // 2. Sign the copy.
- cjCopy.Inputs[index].WitScript = witness;
- // 3. Convert the current input to IndexedTxIn.
- IndexedTxIn currentIndexedInput = cjCopy.Inputs.AsIndexedInputs().Skip(index).First();
-
- // 4. Find the corresponding registered input.
- Coin registeredCoin = round.Alices.SelectMany(x => x.Coins).Single(x => x.Outpoint == cjCopy.Inputs[index].PrevOut);
-
- // 5. Verify if currentIndexedInput is correctly signed, if not, return the specific error.
- if (!currentIndexedInput.VerifyScript(registeredCoin, out ScriptError error))
+ var state = round.CoinjoinState.AssertSigning();
+ foreach (var inputWitnessPair in request.InputWitnessPairs)
{
- throw new WabiSabiProtocolException(WabiSabiProtocolErrorCode.WrongCoinjoinSignature, $"Round ({request.RoundId}): Wrong CoinJoin signature.");
+ state = state.AddWitness((int)inputWitnessPair.InputIndex, inputWitnessPair.Witness);
}
- // Finally add it to our CJ.
- round.Coinjoin.Inputs[index].WitScript = witness;
- }
+ // at this point all of the witnesses have been verified and the state can be updated
+ round.CoinjoinState = state;
}
}

MODIFY
WalletWasabi/WabiSabi/Backend/Rounds/Round.cs
WalletWasabi/WabiSabi/Backend/Rounds/Round.cs
@@ -13,6 +13,7 @@ using WalletWasabi.WabiSabi.Backend.Models;
using WalletWasabi.WabiSabi.Crypto;
using WalletWasabi.WabiSabi.Crypto.CredentialRequesting;
using WalletWasabi.WabiSabi.Models;
+using WalletWasabi.WabiSabi.Models.MultipartyTransaction;
namespace WalletWasabi.WabiSabi.Backend.Rounds
{
@@ -27,11 +28,15 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
AmountCredentialIssuerParameters = AmountCredentialIssuer.CredentialIssuerSecretKey.ComputeCredentialIssuerParameters();
VsizeCredentialIssuerParameters = VsizeCredentialIssuer.CredentialIssuerSecretKey.ComputeCredentialIssuerParameters();
- Coinjoin = Transaction.Create(Network);
+ var allowedAmounts = new MoneyRange(roundParameters.MinRegistrableAmount, RoundParameters.MaxRegistrableAmount);
+ var txParams = new MultipartyTransactionParameters(roundParameters.FeeRate, allowedAmounts, allowedAmounts, roundParameters.Network);
+ CoinjoinState = new ConstructionState(txParams);
Hash = new(HashHelpers.GenerateSha256Hash($"{Id}{MaxInputCountByAlice}{MinRegistrableAmount}{MaxRegistrableAmount}{PerAliceVsizeAllocation}{AmountCredentialIssuerParameters}{VsizeCredentialIssuerParameters}{FeeRate.SatoshiPerByte}"));
}
+ public IState CoinjoinState { get; set; }
+
public uint256 Hash { get; }
public Network Network => RoundParameters.Network;
public uint MaxInputCountByAlice => RoundParameters.MaxInputCountByAlice;
@@ -57,7 +62,6 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
public TimeSpan OutputRegistrationTimeout => RoundParameters.OutputRegistrationTimeout;
public TimeSpan TransactionSigningTimeout => RoundParameters.TransactionSigningTimeout;
- public Transaction Coinjoin { get; }
private RoundParameters RoundParameters { get; }
public Phase Phase { get; private set; } = Phase.InputRegistration;
public DateTimeOffset InputRegistrationStart { get; } = DateTimeOffset.UtcNow;
@@ -122,5 +126,14 @@ namespace WalletWasabi.WabiSabi.Backend.Rounds
return false;
}
+
+ public ConstructionState AddInput(Coin coin)
+ => CoinjoinState.AssertConstruction().AddInput(coin);
+
+ public ConstructionState AddOutput(TxOut output)
+ => CoinjoinState.AssertConstruction().AddOutput(output);
+
+ public SigningState AddWitness(int index, WitScript witness)
+ => CoinjoinState.AssertSigning().AddWitness(index, witness);
}
}

MODIFY
WalletWasabi/WabiSabi/Models/MultipartyTransaction/ConstructionState.cs
WalletWasabi/WabiSabi/Models/MultipartyTransaction/ConstructionState.cs
@@ -17,7 +17,11 @@ namespace WalletWasabi.WabiSabi.Models.MultipartyTransaction
public int OutputsVsize => Outputs.Sum(x => x.ScriptPubKey.EstimateOutputVsize());
public int EstimatedVsize => MultipartyTransactionParameters.SharedOverhead + EstimatedInputsVsize + OutputsVsize;
- public FeeRate EffectiveFeeRate => new FeeRate(Balance, EstimatedVsize);
+ // With no coordinator fees we can't ensure that the shared overhead
+ // of the transaction also pays at the nominal feerate so this will have
+ // to do for now, but in the future EstimatedVsize should be used
+ // including the shared overhead
+ public FeeRate EffectiveFeeRate => new FeeRate(Balance, EstimatedInputsVsize + OutputsVsize);
// TODO ownership proofs and spend status also in scope
public ConstructionState AddInput(Coin coin)

MODIFY
WalletWasabi/WabiSabi/Models/MultipartyTransaction/SigningState.cs
WalletWasabi/WabiSabi/Models/MultipartyTransaction/SigningState.cs
@@ -23,6 +23,10 @@ namespace WalletWasabi.WabiSabi.Models.MultipartyTransaction
public bool IsInputSigned(int index) => Witnesses.ContainsKey(index);
+ public bool IsInputSigned(OutPoint prevout) => IsInputSigned(GetInputIndex(prevout));
+
+ public int GetInputIndex(OutPoint prevout) => Inputs.ToList().FindIndex(coin => coin.Outpoint == prevout); // this is inefficient but is only used in tests, see also dotnet/runtime#45366
+
public SigningState AddWitness(int index, WitScript witness)
{
if (IsInputSigned(index))

Use MultipartyTransaction model in the backend

Updates the `Arena` & `Round` classes to use the immutable state model,
which now takes care of transaction level validations (e.g. amount
constraints).