Make TestWallet behave like a HD wallet
MODIFY
WalletWasabi.Tests/UnitTests/TestWallet.cs
WalletWasabi.Tests/UnitTests/TestWallet.cs
using NBitcoin;
using NBitcoin.Crypto;
+using NBitcoin.DataEncoders;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -12,27 +13,24 @@ using WalletWasabi.WabiSabi.Client;
namespace WalletWasabi.Tests.UnitTests;
-public class TestWallet : IKeyChain, IDestinationProvider, IDisposable
+public class TestWallet : IKeyChain, IDestinationProvider
{
- private bool _disposedValue;
-
public TestWallet(string name, IRPCClient rpc)
{
Rpc = rpc;
- Key = new Key(Hashes.SHA256(Encoding.UTF8.GetBytes(name)));
+ ExtKey = new ExtKey(Encoders.Hex.EncodeData(Encoding.UTF8.GetBytes(name)));
}
private IRPCClient Rpc { get; }
private List<Coin> Utxos { get; } = new();
- private Key Key { get; }
- public PubKey PubKey => Key.PubKey;
- public Script ScriptPubKey => Key.GetScriptPubKey(ScriptPubKeyType.Segwit);
- public BitcoinAddress Address => Key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Rpc.Network);
+ private ExtKey ExtKey { get; }
+ private Dictionary<Script, ExtKey> ScriptPubKeys { get; } = new();
+ private uint NextKeyIndex { get; set; } = 0;
public async Task GenerateAsync(int blocks, CancellationToken cancellationToken)
{
- ThrowIfDisposed();
- var blockIds = await Rpc.GenerateToAddressAsync(blocks, Address, cancellationToken).ConfigureAwait(false);
+ var miningAddress = CreateNewAddress();
+ var blockIds = await Rpc.GenerateToAddressAsync(blocks, miningAddress, cancellationToken).ConfigureAwait(false);
foreach (var blockId in blockIds)
{
var block = await Rpc.GetBlockAsync(blockId, cancellationToken).ConfigureAwait(false);
@@ -41,9 +39,16 @@ public class TestWallet : IKeyChain, IDestinationProvider, IDisposable
}
}
+ public BitcoinAddress CreateNewAddress()
+ {
+ var key = CreateNewKey();
+ var scriptPubKey = key.PrivateKey.GetScriptPubKey(ScriptPubKeyType.Segwit);
+ ScriptPubKeys.Add(scriptPubKey, key);
+ return scriptPubKey.GetDestinationAddress(Rpc.Network);
+ }
+
public (Transaction, Coin) CreateTemplateTransaction()
{
- ThrowIfDisposed();
var biggestUtxo = Utxos.MaxBy(x => x.Amount);
if (biggestUtxo is null)
@@ -58,15 +63,13 @@ public class TestWallet : IKeyChain, IDestinationProvider, IDisposable
public Transaction CreateSelfTransfer(FeeRate feeRate)
{
- ThrowIfDisposed();
var (tx, spendingCoin) = CreateTemplateTransaction();
- tx.Outputs.Add(spendingCoin.Amount - feeRate.GetFee(Constants.P2wpkhOutputSizeInBytes), Address);
+ tx.Outputs.Add(spendingCoin.Amount - feeRate.GetFee(Constants.P2wpkhOutputSizeInBytes), CreateNewAddress());
return tx;
}
public async Task<uint256> SendToAsync(Money amount, Script scriptPubKey, FeeRate feeRate, CancellationToken cancellationToken)
{
- ThrowIfDisposed();
const int FinalSignedTxVirtualSize = 222;
var effectiveOutputCost = amount + feeRate.GetFee(FinalSignedTxVirtualSize);
var tx = CreateSelfTransfer(FeeRate.Zero);
@@ -83,7 +86,6 @@ public class TestWallet : IKeyChain, IDestinationProvider, IDisposable
public async Task<uint256> SendRawTransactionAsync(Transaction tx, CancellationToken cancellationToken)
{
- ThrowIfDisposed();
var txid = await Rpc.SendRawTransactionAsync(tx, cancellationToken).ConfigureAwait(false);
ScanTransaction(tx);
return txid;
@@ -91,73 +93,60 @@ public class TestWallet : IKeyChain, IDestinationProvider, IDisposable
public Transaction SignTransaction(Transaction tx)
{
- ThrowIfDisposed();
var signedTx = tx.Clone();
var inputTable = signedTx.Inputs.Select(x => x.PrevOut).ToHashSet();
var inputsToSign = Utxos.Where(x => inputTable.Contains(x.Outpoint));
- signedTx.Sign(Key.GetBitcoinSecret(Rpc.Network), inputsToSign);
+ var scriptsToSign = inputsToSign.Select(x => x.ScriptPubKey).ToHashSet();
+ var secrets = ScriptPubKeys
+ .Where(x => scriptsToSign.Contains(x.Key))
+ .Select(x => x.Value.PrivateKey.GetBitcoinSecret(Rpc.Network))
+ .ToList();
+ signedTx.Sign(secrets, inputsToSign);
return signedTx;
}
+ public ExtPubKey GetExtPubKey(Script scriptPubKey) =>
+ ScriptPubKeys[scriptPubKey].Neuter();
+
public OwnershipProof GetOwnershipProof(IDestination destination, CoinJoinInputCommitmentData committedData)
{
- ThrowIfDisposed();
- if (destination.ScriptPubKey != ScriptPubKey)
+ if (!ScriptPubKeys.TryGetValue(destination.ScriptPubKey, out var extKey))
{
throw new ArgumentException("Destination doesn't belong to this wallet.");
}
using var identificationKey = new Key();
return OwnershipProof.GenerateCoinJoinInputProof(
- Key,
- new OwnershipIdentifier(identificationKey, ScriptPubKey),
+ extKey.PrivateKey,
+ new OwnershipIdentifier(identificationKey, destination.ScriptPubKey),
committedData);
}
/// <remarks>Test wallet assumes that the ownership proof is always correct.</remarks>
public Transaction Sign(Transaction transaction, Coin coin, OwnershipProof ownershipProof)
{
- ThrowIfDisposed();
- transaction.Sign(Key.GetBitcoinSecret(Rpc.Network), coin);
- return transaction;
+ if (!ScriptPubKeys.TryGetValue(coin.ScriptPubKey, out var extKey))
+ {
+ throw new ArgumentException("Destination doesn't belong to this wallet.");
}
- public IEnumerable<IDestination> GetNextDestinations(int count)
- {
- ThrowIfDisposed();
- return Enumerable.Repeat(Address, count);
+ transaction.Sign(extKey.PrivateKey.GetBitcoinSecret(Rpc.Network), coin);
+ return transaction;
}
+ public IEnumerable<IDestination> GetNextDestinations(int count) =>
+ Enumerable.Repeat(CreateNewAddress(), count);
+
private void ScanTransaction(Transaction tx)
{
var receivedCoins = tx.Outputs.AsIndexedOutputs()
- .Where(x => x.TxOut.ScriptPubKey == ScriptPubKey)
+ .Where(x => ScriptPubKeys.ContainsKey(x.TxOut.ScriptPubKey))
.Select(x => x.ToCoin());
Utxos.AddRange(receivedCoins);
Utxos.RemoveAll(x => tx.Inputs.Any(y => y.PrevOut == x.Outpoint));
}
- private void ThrowIfDisposed()
- {
- if (_disposedValue)
- {
- throw new ObjectDisposedException(nameof(TestWallet));
- }
- }
-
- protected virtual void Dispose(bool disposing)
- {
- if (!_disposedValue)
- {
- Key.Dispose();
- _disposedValue = true;
- }
- }
-
- public void Dispose()
- {
- Dispose(disposing: true);
- GC.SuppressFinalize(this);
- }
+ private ExtKey CreateNewKey() =>
+ ExtKey.Derive(NextKeyIndex++);
}

MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Integration/Participant.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Integration/Participant.cs
@@ -14,10 +14,8 @@ using WalletWasabi.WebClients.Wasabi;
namespace WalletWasabi.Tests.UnitTests.WabiSabi.Integration;
-internal class Participant: IDisposable
+internal class Participant
{
- private bool _disposedValue;
-
public Participant(string name, IRPCClient rpc, IWasabiHttpClientFactory httpClientFactory)
{
HttpClientFactory = httpClientFactory;
@@ -31,13 +29,11 @@ internal class Participant: IDisposable
public async Task GenerateSourceCoinAsync(CancellationToken cancellationToken)
{
- ThrowIfDisposed();
await Wallet.GenerateAsync(1, cancellationToken).ConfigureAwait(false);
}
public async Task GenerateCoinsAsync(int numberOfCoins, int seed, CancellationToken cancellationToken)
{
- ThrowIfDisposed();
var feeRate = new FeeRate(4.0m);
var (splitTx, spendingCoin) = Wallet.CreateTemplateTransaction();
var availableAmount = spendingCoin.EffectiveValue(feeRate, CoordinationFeeRate.Zero);
@@ -57,20 +53,23 @@ internal class Participant: IDisposable
.Select(x => x * availableAmount.Satoshi)
.Select(x => Money.Satoshis((long)x));
- var scriptPubKey = Wallet.ScriptPubKey;
foreach (var amount in amounts)
{
- var effectiveOutputValue = amount - feeRate.GetFee(scriptPubKey.EstimateOutputVsize());
- splitTx.Outputs.Add(new TxOut(effectiveOutputValue, scriptPubKey));
+ var outputAddress = Wallet.CreateNewAddress();
+ var effectiveOutputValue = amount - feeRate.GetFee(outputAddress.ScriptPubKey.EstimateOutputVsize());
+ splitTx.Outputs.Add(new TxOut(effectiveOutputValue, Wallet.CreateNewAddress()));
}
-
await Wallet.SendRawTransactionAsync(Wallet.SignTransaction(splitTx), cancellationToken).ConfigureAwait(false);
SplitTransaction = new SmartTransaction(splitTx, new Height(1));
}
public async Task StartParticipatingAsync(CancellationToken cancellationToken)
{
- ThrowIfDisposed();
+ if (SplitTransaction is null)
+ {
+ throw new InvalidOperationException($"{nameof(GenerateCoinsAsync)} has to be called first.");
+ }
+
var apiClient = new WabiSabiHttpApiClient(HttpClientFactory.NewHttpClientWithDefaultCircuit());
using var roundStateUpdater = new RoundStateUpdater(TimeSpan.FromSeconds(3), apiClient);
await roundStateUpdater.StartAsync(cancellationToken).ConfigureAwait(false);
@@ -82,17 +81,16 @@ internal class Participant: IDisposable
roundStateUpdater,
consolidationMode: true);
- // Run the coinjoin client task.
- var walletHdPubKey = new HdPubKey(Wallet.PubKey, KeyPath.Parse("m/84'/0/0/0/0"), SmartLabel.Empty, KeyState.Clean);
- walletHdPubKey.SetAnonymitySet(1); // bug if not settled
-
- if (SplitTransaction is null)
+ static HdPubKey CreateHdPubKey(ExtPubKey extPubKey)
{
- throw new InvalidOperationException($"{nameof(GenerateCoinsAsync)} has to be called first.");
+ var hdPubKey = new HdPubKey(extPubKey.PubKey, KeyPath.Parse($"m/84'/0/0/0/{extPubKey.Child}"), SmartLabel.Empty, KeyState.Clean);
+ hdPubKey.SetAnonymitySet(1); // bug if not settled
+ return hdPubKey;
}
var smartCoins = SplitTransaction.Transaction.Outputs.AsIndexedOutputs()
- .Select(x => new SmartCoin(SplitTransaction, x.N, walletHdPubKey))
+ .Select(x => (IndexedTxOut: x, HdPubKey: Wallet.GetExtPubKey(x.TxOut.ScriptPubKey)))
+ .Select(x => new SmartCoin(SplitTransaction, x.IndexedTxOut.N, CreateHdPubKey(x.HdPubKey)))
.ToList();
// Run the coinjoin client task.
@@ -100,27 +98,4 @@ internal class Participant: IDisposable
await roundStateUpdater.StopAsync(cancellationToken).ConfigureAwait(false);
}
-
- private void ThrowIfDisposed()
- {
- if (_disposedValue)
- {
- throw new ObjectDisposedException(nameof(TestWallet));
- }
- }
-
- protected virtual void Dispose(bool disposing)
- {
- if (!_disposedValue)
- {
- Wallet.Dispose();
- _disposedValue = true;
- }
- }
-
- public void Dispose()
- {
- Dispose(disposing: true);
- GC.SuppressFinalize(this);
- }
}

MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Integration/WabiSabiHttpApiIntegrationTests.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Integration/WabiSabiHttpApiIntegrationTests.cs
@@ -316,100 +316,98 @@ public class WabiSabiHttpApiIntegrationTests : IClassFixture<WabiSabiApiApplicat
await roundStateUpdater.StopAsync(CancellationToken.None);
}
- // ToDo: Fix this test. It's incorrect, because the TestWallet class doesn't simulate multipe keys, only a single one.
- //[Theory]
- //[InlineData(123456)]
- //public async Task MultiClientsCoinJoinTestAsync(int seed)
- //{
- // const int NumberOfParticipants = 10;
- // const int NumberOfCoinsPerParticipant = 2;
- // const int ExpectedInputNumber = (NumberOfParticipants * NumberOfCoinsPerParticipant) / 2;
-
- // var node = await TestNodeBuilder.CreateForHeavyConcurrencyAsync();
- // try
- // {
- // var rpc = node.RpcClient;
-
- // var app = _apiApplicationFactory.WithWebHostBuilder(builder =>
- // builder.ConfigureServices(services =>
- // {
- // // Instruct the coordinator 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(Path.GetTempFileName())
- // {
- // MaxRegistrableAmount = Money.Coins(500m),
- // MaxInputCountByRound = ExpectedInputNumber,
- // StandardInputRegistrationTimeout = TimeSpan.FromSeconds(10 * ExpectedInputNumber),
- // ConnectionConfirmationTimeout = TimeSpan.FromSeconds(20 * ExpectedInputNumber),
- // OutputRegistrationTimeout = TimeSpan.FromSeconds(20 * ExpectedInputNumber),
- // });
- // }));
-
- // using PersonCircuit personCircuit = new();
- // IHttpClient httpClientWrapper = new HttpClientWrapper(app.CreateClient());
-
- // var mockHttpClientFactory = new Mock<IWasabiHttpClientFactory>(MockBehavior.Strict);
- // mockHttpClientFactory
- // .Setup(factory => factory.NewHttpClientWithPersonCircuit(out httpClientWrapper))
- // .Returns(personCircuit);
-
- // mockHttpClientFactory
- // .Setup(factory => factory.NewHttpClientWithCircuitPerRequest())
- // .Returns(httpClientWrapper);
-
- // mockHttpClientFactory
- // .Setup(factory => factory.NewHttpClientWithDefaultCircuit())
- // .Returns(httpClientWrapper);
-
- // // Total test timeout.
- // using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40 * ExpectedInputNumber));
-
- // var participants = Enumerable
- // .Range(0, NumberOfParticipants)
- // .Select(i => new Participant($"participant{i}", rpc, mockHttpClientFactory.Object))
- // .ToArray();
-
- // using var disposableParticipants = new CompositeDisposable(participants);
- // foreach (var participant in participants)
- // {
- // await participant.GenerateSourceCoinAsync(cts.Token);
- // }
- // using var dummyWallet = new TestWallet("dummy", rpc);
- // await dummyWallet.GenerateAsync(101, cts.Token);
- // foreach (var participant in participants)
- // {
- // await participant.GenerateCoinsAsync(NumberOfCoinsPerParticipant, seed, cts.Token);
- // }
- // await dummyWallet.GenerateAsync(101, cts.Token);
-
- // var tasks = participants.Select(x => x.StartParticipatingAsync(cts.Token)).ToArray();
-
- // while ((await rpc.GetRawMempoolAsync()).Length == 0)
- // {
- // if (cts.IsCancellationRequested)
- // {
- // throw new TimeoutException("Coinjoin was not propagated.");
- // }
-
- // await Task.Delay(500, cts.Token);
-
- // if (tasks.FirstOrDefault(t => t.IsFaulted)?.Exception is { } exc)
- // {
- // throw exc;
- // }
- // }
- // var mempool = await rpc.GetRawMempoolAsync();
- // var coinjoin = await rpc.GetRawTransactionAsync(mempool.Single());
-
- // Assert.True(coinjoin.Outputs.Count <= ExpectedInputNumber);
- // Assert.Equal(ExpectedInputNumber, coinjoin.Inputs.Count);
- // }
- // finally
- // {
- // await node.TryStopAsync();
- // }
- //}
+ [Theory]
+ [InlineData(123456)]
+ public async Task MultiClientsCoinJoinTestAsync(int seed)
+ {
+ const int NumberOfParticipants = 10;
+ const int NumberOfCoinsPerParticipant = 2;
+ const int ExpectedInputNumber = (NumberOfParticipants * NumberOfCoinsPerParticipant) / 2;
+
+ var node = await TestNodeBuilder.CreateForHeavyConcurrencyAsync();
+ try
+ {
+ var rpc = node.RpcClient;
+
+ var app = _apiApplicationFactory.WithWebHostBuilder(builder =>
+ builder.ConfigureServices(services =>
+ {
+ // Instruct the coordinator 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(Path.GetTempFileName())
+ {
+ MaxRegistrableAmount = Money.Coins(500m),
+ MaxInputCountByRound = ExpectedInputNumber,
+ StandardInputRegistrationTimeout = TimeSpan.FromSeconds(10 * ExpectedInputNumber),
+ ConnectionConfirmationTimeout = TimeSpan.FromSeconds(20 * ExpectedInputNumber),
+ OutputRegistrationTimeout = TimeSpan.FromSeconds(20 * ExpectedInputNumber),
+ });
+ }));
+
+ using PersonCircuit personCircuit = new();
+ IHttpClient httpClientWrapper = new HttpClientWrapper(app.CreateClient());
+
+ var mockHttpClientFactory = new Mock<IWasabiHttpClientFactory>(MockBehavior.Strict);
+ mockHttpClientFactory
+ .Setup(factory => factory.NewHttpClientWithPersonCircuit(out httpClientWrapper))
+ .Returns(personCircuit);
+
+ mockHttpClientFactory
+ .Setup(factory => factory.NewHttpClientWithCircuitPerRequest())
+ .Returns(httpClientWrapper);
+
+ mockHttpClientFactory
+ .Setup(factory => factory.NewHttpClientWithDefaultCircuit())
+ .Returns(httpClientWrapper);
+
+ // Total test timeout.
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40 * ExpectedInputNumber));
+
+ var participants = Enumerable
+ .Range(0, NumberOfParticipants)
+ .Select(i => new Participant($"participant{i}", rpc, mockHttpClientFactory.Object))
+ .ToArray();
+
+ foreach (var participant in participants)
+ {
+ await participant.GenerateSourceCoinAsync(cts.Token);
+ }
+ var dummyWallet = new TestWallet("dummy", rpc);
+ await dummyWallet.GenerateAsync(101, cts.Token);
+ foreach (var participant in participants)
+ {
+ await participant.GenerateCoinsAsync(NumberOfCoinsPerParticipant, seed, cts.Token);
+ }
+ await dummyWallet.GenerateAsync(101, cts.Token);
+
+ var tasks = participants.Select(x => x.StartParticipatingAsync(cts.Token)).ToArray();
+
+ while ((await rpc.GetRawMempoolAsync()).Length == 0)
+ {
+ if (cts.IsCancellationRequested)
+ {
+ throw new TimeoutException("Coinjoin was not propagated.");
+ }
+
+ await Task.Delay(500, cts.Token);
+
+ if (tasks.FirstOrDefault(t => t.IsFaulted)?.Exception is { } exc)
+ {
+ throw exc;
+ }
+ }
+ var mempool = await rpc.GetRawMempoolAsync();
+ var coinjoin = await rpc.GetRawTransactionAsync(mempool.Single());
+
+ Assert.True(coinjoin.Outputs.Count <= ExpectedInputNumber);
+ Assert.Equal(ExpectedInputNumber, coinjoin.Inputs.Count);
+ }
+ finally
+ {
+ await node.TryStopAsync();
+ }
+ }
[Fact]
public async Task RegisterCoinIdempotencyAsync()

Make TestWallet behave like a HD wallet