Enable specifying custom scripts
MODIFY
WalletWasabi.Tests/RegTests.cs
WalletWasabi.Tests/RegTests.cs
@@ -2346,6 +2346,99 @@ namespace WalletWasabi.Tests
}
}
+ [Fact]
+ public async Task CcjClientCustomOutputScriptTestsAsync()
+ {
+ string password = "password";
+ var rpc = Global.RpcClient;
+ var network = Global.RpcClient.Network;
+ var coordinator = Global.Coordinator;
+ Money denomination = new Money(0.1m, MoneyUnit.BTC);
+ decimal coordinatorFeePercent = 0.1m;
+ int anonymitySet = 2;
+ int connectionConfirmationTimeout = 14;
+ var roundConfig = new CcjRoundConfig(denomination, 140, coordinatorFeePercent, anonymitySet, 240, connectionConfirmationTimeout, 50, 50, 1);
+ coordinator.UpdateRoundConfig(roundConfig);
+ coordinator.FailAllRoundsInInputRegistration();
+ await rpc.GenerateAsync(3); // So to make sure we have enough money.
+ var keyManager = KeyManager.CreateNew(out _, password);
+ var key1 = keyManager.GenerateNewKey("foo", KeyState.Clean, false);
+ var key2 = keyManager.GenerateNewKey("bar", KeyState.Clean, false);
+ var key3 = keyManager.GenerateNewKey("baz", KeyState.Clean, false);
+ var bech1 = key1.GetP2wpkhAddress(network);
+ var bech2 = key2.GetP2wpkhAddress(network);
+ var bech3 = key3.GetP2wpkhAddress(network);
+ var amount1 = new Money(0.03m, MoneyUnit.BTC);
+ var amount2 = new Money(0.08m, MoneyUnit.BTC);
+ var amount3 = new Money(0.3m, MoneyUnit.BTC);
+ var txid1 = await rpc.SendToAddressAsync(bech1, amount1, replaceable: false);
+ var txid2 = await rpc.SendToAddressAsync(bech2, amount2, replaceable: false);
+ var txid3 = await rpc.SendToAddressAsync(bech3, amount3, replaceable: false);
+ key1.KeyState = KeyState.Used;
+ key2.KeyState = KeyState.Used;
+ key3.KeyState = KeyState.Used;
+ var tx1 = await rpc.GetRawTransactionAsync(txid1);
+ var tx2 = await rpc.GetRawTransactionAsync(txid2);
+ var tx3 = await rpc.GetRawTransactionAsync(txid3);
+ await rpc.GenerateAsync(1);
+ var height = await rpc.GetBlockCountAsync();
+ var smartCoin1 = new SmartCoin(txid1, tx1.Outputs.GetIndex(bech1.ScriptPubKey), bech1.ScriptPubKey, amount1, tx1.Inputs.Select(x => new TxoRef(x.PrevOut)).ToArray(), height, rbf: false);
+ var smartCoin2 = new SmartCoin(txid2, tx2.Outputs.GetIndex(bech2.ScriptPubKey), bech2.ScriptPubKey, amount2, tx2.Inputs.Select(x => new TxoRef(x.PrevOut)).ToArray(), height, rbf: false);
+ var smartCoin3 = new SmartCoin(txid3, tx3.Outputs.GetIndex(bech3.ScriptPubKey), bech3.ScriptPubKey, amount3, tx3.Inputs.Select(x => new TxoRef(x.PrevOut)).ToArray(), height, rbf: false);
+
+ var chaumianClient1 = new CcjClient(Global.RpcClient.Network, Global.Coordinator.RsaKey.PubKey, keyManager, new Uri(RegTestFixture.BackendEndPoint));
+ var chaumianClient2 = new CcjClient(Global.RpcClient.Network, Global.Coordinator.RsaKey.PubKey, keyManager, new Uri(RegTestFixture.BackendEndPoint));
+ try
+ {
+ chaumianClient1.Start();
+ chaumianClient2.Start();
+
+ var customChange1 = new Key().ScriptPubKey;
+ var customActive1 = new Key().ScriptPubKey;
+ var customChange2 = new Key().ScriptPubKey;
+ var customActive2 = new Key().ScriptPubKey;
+ chaumianClient1.AddCustomChangeScript(customChange1);
+ chaumianClient2.AddCustomChangeScript(customChange2);
+ chaumianClient1.AddCustomActiveScript(customActive1);
+ chaumianClient2.AddCustomActiveScript(customActive2);
+
+ Assert.True(2 == (await chaumianClient1.QueueCoinsToMixAsync(password, smartCoin1, smartCoin2)).Count());
+ Assert.True(1 == (await chaumianClient2.QueueCoinsToMixAsync(password, smartCoin3)).Count());
+
+ Task timeout = Task.Delay(TimeSpan.FromSeconds(connectionConfirmationTimeout * 2 + 7 * 2 + 7 * 2 + 7 * 2));
+ while ((await rpc.GetRawMempoolAsync()).Length == 0)
+ {
+ if (timeout.IsCompletedSuccessfully)
+ {
+ throw new TimeoutException("CoinJoin wasn't propagated.");
+ }
+ await Task.Delay(1000);
+ }
+
+ var cjid = (await rpc.GetRawMempoolAsync()).Single();
+ smartCoin1.SpenderTransactionId = cjid;
+ smartCoin2.SpenderTransactionId = cjid;
+ smartCoin3.SpenderTransactionId = cjid;
+
+ var cj = await rpc.GetRawTransactionAsync(cjid);
+ Assert.Contains(customActive1, cj.Outputs.Select(x => x.ScriptPubKey));
+ Assert.DoesNotContain(customChange1, cj.Outputs.Select(x => x.ScriptPubKey)); // The smallest of CJ won't generate change.
+ Assert.Contains(customActive2, cj.Outputs.Select(x => x.ScriptPubKey));
+ Assert.Contains(customChange2, cj.Outputs.Select(x => x.ScriptPubKey));
+ }
+ finally
+ {
+ if (chaumianClient1 != null)
+ {
+ await chaumianClient1.StopAsync();
+ }
+ if (chaumianClient2 != null)
+ {
+ await chaumianClient2.StopAsync();
+ }
+ }
+ }
+
#endregion
}
}

MODIFY
WalletWasabi/Services/CcjClient.cs
WalletWasabi/Services/CcjClient.cs
@@ -25,6 +25,11 @@ namespace WalletWasabi.Services
BlindingRsaPubKey CoordinatorPubKey { get; }
public KeyManager KeyManager { get; }
+ public List<Script> CustomChangeScripts { get; }
+ private object CustomChangeScriptsLock { get; }
+ public List<Script> CustomActiveScripts { get; }
+ private object CustomActiveScriptsLock { get; }
+
public SatoshiClient SatoshiClient { get; }
public Uri CcjHostUri { get; }
IPEndPoint TorSocks5EndPoint { get; }
@@ -60,6 +65,11 @@ namespace WalletWasabi.Services
_frequentStatusProcessingIfNotMixing = 0;
State = new CcjClientState();
MixLock = new AsyncLock();
+
+ CustomChangeScripts = new List<Script>();
+ CustomActiveScripts = new List<Script>();
+ CustomChangeScriptsLock = new object();
+ CustomActiveScriptsLock = new object();
}
public void Start()
@@ -173,9 +183,27 @@ namespace WalletWasabi.Services
if (registrableCoins.Count() > 0)
{
- var changeKey = KeyManager.GenerateNewKey("CoinJoin Change Output", KeyState.Locked, isInternal: true);
- var activeKey = KeyManager.GenerateNewKey("CoinJoin Active Output", KeyState.Locked, isInternal: true);
- var blind = CoordinatorPubKey.Blind(activeKey.GetP2wpkhScript().ToBytes());
+ Script changeKey = null;
+ Script activeKey = null;
+ lock (CustomChangeScriptsLock)
+ {
+ if(CustomChangeScripts.Count > 0)
+ {
+ changeKey = CustomChangeScripts.First();
+ CustomChangeScripts.RemoveFirst();
+ }
+ }
+ lock (CustomActiveScriptsLock)
+ {
+ if (CustomActiveScripts.Count > 0)
+ {
+ activeKey = CustomActiveScripts.First();
+ CustomActiveScripts.RemoveFirst();
+ }
+ }
+ changeKey = changeKey ?? KeyManager.GenerateNewKey("CoinJoin Change Output", KeyState.Locked, isInternal: true).GetP2wpkhScript();
+ activeKey = activeKey ?? KeyManager.GenerateNewKey("CoinJoin Active Output", KeyState.Locked, isInternal: true).GetP2wpkhScript();
+ var blind = CoordinatorPubKey.Blind(activeKey.ToBytes());
var inputProofs = new List<InputProofModel>();
foreach ((uint256 txid, int index) coinReference in registrableCoins)
@@ -189,11 +217,11 @@ namespace WalletWasabi.Services
};
inputProofs.Add(inputProof);
}
- AliceClient aliceClient = await AliceClient.CreateNewAsync(changeKey.GetP2wpkhScript(), blind.BlindedData, inputProofs, CcjHostUri, TorSocks5EndPoint);
+ AliceClient aliceClient = await AliceClient.CreateNewAsync(changeKey, blind.BlindedData, inputProofs, CcjHostUri, TorSocks5EndPoint);
byte[] unblindedSignature = CoordinatorPubKey.UnblindSignature(aliceClient.BlindedOutputSignature, blind.BlindingFactor);
- if (!CoordinatorPubKey.Verify(unblindedSignature, activeKey.GetP2wpkhScript().ToBytes()))
+ if (!CoordinatorPubKey.Verify(unblindedSignature, activeKey.ToBytes()))
{
throw new NotSupportedException("Coordinator did not sign the blinded output properly.");
}
@@ -214,8 +242,8 @@ namespace WalletWasabi.Services
roundRegistered.CoinsRegistered.Add(coin);
State.RemoveCoinFromWaitingList(coin);
}
- roundRegistered.ActiveOutput = activeKey;
- roundRegistered.ChangeOutput = changeKey;
+ roundRegistered.ActiveOutputScript = activeKey;
+ roundRegistered.ChangeOutputScript = changeKey;
roundRegistered.UnblindedSignature = unblindedSignature;
roundRegistered.AliceClient = aliceClient;
}
@@ -280,7 +308,7 @@ namespace WalletWasabi.Services
using (var bobClient = new BobClient(CcjHostUri, TorSocks5EndPoint))
{
- await bobClient.PostOutputAsync(ongoingRound.RoundHash, ongoingRound.ActiveOutput.GetP2wpkhScript(), ongoingRound.UnblindedSignature);
+ await bobClient.PostOutputAsync(ongoingRound.RoundHash, ongoingRound.ActiveOutputScript, ongoingRound.UnblindedSignature);
ongoingRound.PostedOutput = true;
Logger.LogInfo<AliceClient>($"Round ({ongoingRound.State.RoundId})Bob Posted output.");
}
@@ -296,7 +324,7 @@ namespace WalletWasabi.Services
throw new NotSupportedException("Coordinator provided invalid roundHash.");
}
Money amountBack = unsignedCoinJoin.Outputs
- .Where(x => x.ScriptPubKey == ongoingRound.ActiveOutput.GetP2wpkhScript() || x.ScriptPubKey == ongoingRound.ChangeOutput.GetP2wpkhScript())
+ .Where(x => x.ScriptPubKey == ongoingRound.ActiveOutputScript || x.ScriptPubKey == ongoingRound.ChangeOutputScript)
.Sum(y => y.Value);
Money minAmountBack = ongoingRound.CoinsRegistered.Sum(x => x.Amount); // Start with input sum.
minAmountBack -= ongoingRound.State.FeePerOutputs * 2 + ongoingRound.State.FeePerInputs * ongoingRound.CoinsRegistered.Count; // Minus miner fee.
@@ -305,7 +333,7 @@ namespace WalletWasabi.Services
minAmountBack -= expectedCoordinatorFee; // Minus expected coordinator fee.
// If there's no change output then coordinator protection may happened:
- if (unsignedCoinJoin.Outputs.All(x => x.ScriptPubKey != ongoingRound.ChangeOutput.GetP2wpkhScript()))
+ if (unsignedCoinJoin.Outputs.All(x => x.ScriptPubKey != ongoingRound.ChangeOutputScript))
{
Money minimumOutputAmount = new Money(0.0001m, MoneyUnit.BTC); // If the change would be less than about $1 then add it to the coordinator.
Money onePercentOfDenomination = new Money(actualDenomination.ToDecimal(MoneyUnit.BTC) * 0.01m, MoneyUnit.BTC); // If the change is less than about 1% of the newDenomination then add it to the coordinator fee.
@@ -371,6 +399,92 @@ namespace WalletWasabi.Services
Interlocked.Exchange(ref _frequentStatusProcessingIfNotMixing, 0);
}
+ #region ScriptLists
+
+ public IEnumerable<Script> GetCustomChangeScripts()
+ {
+ lock(CustomChangeScriptsLock)
+ {
+ return CustomChangeScripts;
+ }
+ }
+
+ public IEnumerable<Script> GetCustomActiveScripts()
+ {
+ lock (CustomActiveScriptsLock)
+ {
+ return CustomActiveScripts;
+ }
+ }
+
+ /// <summary>
+ /// Best effort. For example if a round is disrupted my malicious actors, the script won't be registered again, therefore it's not guaranteed money will arrive.
+ /// </summary>
+ public void AddCustomActiveScript(Script script, bool beginning = false)
+ {
+ lock (CustomActiveScriptsLock)
+ {
+ if(CustomActiveScripts.Contains(script))
+ {
+ CustomActiveScripts.Remove(script);
+ }
+ if (beginning)
+ {
+ CustomActiveScripts.Insert(0, script);
+ }
+ else
+ {
+ CustomActiveScripts.Add(script);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Best effort. For example if a round is disrupted my malicious actors, the script won't be registered again, therefore it's not guaranteed money will arrive.
+ /// </summary>
+ public void AddCustomChangeScript(Script script, bool beginning = false)
+ {
+ lock (CustomChangeScriptsLock)
+ {
+ if (CustomChangeScripts.Contains(script))
+ {
+ CustomChangeScripts.Remove(script);
+ }
+ if (beginning)
+ {
+ CustomChangeScripts.Insert(0, script);
+ }
+ else
+ {
+ CustomChangeScripts.Add(script);
+ }
+ }
+ }
+
+ public void RemoveCustomChangeScript(Script script)
+ {
+ lock (CustomChangeScriptsLock)
+ {
+ if (CustomChangeScripts.Contains(script))
+ {
+ CustomChangeScripts.Remove(script);
+ }
+ }
+ }
+
+ public void RemoveCustomActiveScript(Script script)
+ {
+ lock (CustomActiveScriptsLock)
+ {
+ if (CustomActiveScripts.Contains(script))
+ {
+ CustomActiveScripts.Remove(script);
+ }
+ }
+ }
+
+ #endregion
+
public async Task<IEnumerable<SmartCoin>> QueueCoinsToMixAsync(string password, params SmartCoin[] coins)
{
using (await MixLock.LockAsync())

Enable specifying custom scripts