Introduce PaymentIntent instead of operation.
MODIFY
WalletWasabi.Gui/Controls/WalletExplorer/SendTabViewModel.cs
WalletWasabi.Gui/Controls/WalletExplorer/SendTabViewModel.cs
@@ -26,6 +26,7 @@ using WalletWasabi.Hwi;
using WalletWasabi.Hwi.Models;
using WalletWasabi.KeyManagement;
using WalletWasabi.Models;
+using WalletWasabi.Models.TransactionBuilding;
using WalletWasabi.Services;
namespace WalletWasabi.Gui.Controls.WalletExplorer
@@ -268,11 +269,14 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
return;
}
- var script = address.ScriptPubKey;
- var amount = Money.Zero;
- if (!IsMax)
+ MoneyRequest moneyRequest;
+ if (IsMax)
+ {
+ moneyRequest = MoneyRequest.CreateAllRemaining();
+ }
+ else
{
- if (!Money.TryParse(AmountText, out amount) || amount == Money.Zero)
+ if (!Money.TryParse(AmountText, out Money amount) || amount == Money.Zero)
{
SetWarningMessage($"Invalid amount.");
return;
@@ -283,6 +287,7 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
SetWarningMessage("Looks like you want to spend a whole coin. Try Max button instead.");
return;
}
+ moneyRequest = MoneyRequest.Create(amount);
}
if (FeeRate is null || FeeRate.SatoshiPerByte < 1)
@@ -295,8 +300,7 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
FeeRate feeRate = FeeRate;
var label = Label;
- var operation = new WalletService.Operation(script, amount, label);
-
+ var intent = new PaymentIntent(new DestinationRequest(address, moneyRequest, label));
try
{
MainWindowViewModel.Instance.StatusBar.TryAddStatus(StatusBarStatus.DequeuingSelectedCoins);
@@ -331,7 +335,7 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
return;
}
- BuildTransactionResult result = await Task.Run(() => Global.WalletService.BuildTransaction(Password, new[] { operation }, feeTarget: null, feeRate: feeRate, allowUnconfirmed: true, allowedInputs: selectedCoinReferences));
+ BuildTransactionResult result = await Task.Run(() => Global.WalletService.BuildTransaction(Password, intent, feeTarget: null, feeRate: feeRate, allowUnconfirmed: true, allowedInputs: selectedCoinReferences));
if (IsTransactionBuilder)
{

MODIFY
WalletWasabi.Tests/RegTests.cs
WalletWasabi.Tests/RegTests.cs
@@ -27,6 +27,7 @@ using WalletWasabi.KeyManagement;
using WalletWasabi.Logging;
using WalletWasabi.Models;
using WalletWasabi.Models.ChaumianCoinJoin;
+using WalletWasabi.Models.TransactionBuilding;
using WalletWasabi.Services;
using WalletWasabi.Stores;
using WalletWasabi.Tests.XunitConfiguration;
@@ -816,7 +817,7 @@ namespace WalletWasabi.Tests
}
var scp = new Key().ScriptPubKey;
- var res2 = wallet.BuildTransaction(password, new[] { new WalletService.Operation(scp, Money.Coins(0.05m), "foo") }, 5, null, false);
+ var res2 = wallet.BuildTransaction(password, new PaymentIntent(new DestinationRequest(scp, Money.Coins(0.05m), "foo")), 5, null, false);
Assert.NotNull(res2.Transaction);
Assert.Single(res2.OuterWalletOutputs);
@@ -837,7 +838,7 @@ namespace WalletWasabi.Tests
Script receive = wallet.GetReceiveKey("Basic").P2wpkhScript;
Money amountToSend = wallet.Coins.Where(x => !x.Unavailable).Sum(x => x.Amount) / 2;
- var res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(receive, amountToSend, "foo") }, Constants.SevenDaysConfirmationTarget, allowUnconfirmed: true);
+ var res = wallet.BuildTransaction(password, new PaymentIntent(new DestinationRequest(receive, amountToSend, "foo")), Constants.SevenDaysConfirmationTarget, allowUnconfirmed: true);
foreach (SmartCoin coin in res.SpentCoins)
{
@@ -885,7 +886,7 @@ namespace WalletWasabi.Tests
receive = wallet.GetReceiveKey("SubtractFeeFromAmount").P2wpkhScript;
amountToSend = wallet.Coins.Where(x => !x.Unavailable).Sum(x => x.Amount) / 3;
- res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(receive, amountToSend, "foo") }, Constants.SevenDaysConfirmationTarget, allowUnconfirmed: true, subtractFeeFromAmountIndex: 0);
+ res = wallet.BuildTransaction(password, new PaymentIntent(receive, amountToSend, "foo"), Constants.SevenDaysConfirmationTarget, allowUnconfirmed: true, subtractFeeFromAmountIndex: 0);
Assert.Equal(2, res.InnerWalletOutputs.Count());
Assert.Empty(res.OuterWalletOutputs);
@@ -918,7 +919,7 @@ namespace WalletWasabi.Tests
#region LowFee
- res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(receive, amountToSend, "foo") }, Constants.SevenDaysConfirmationTarget, allowUnconfirmed: true);
+ res = wallet.BuildTransaction(password, new PaymentIntent(receive, amountToSend, "foo"), Constants.SevenDaysConfirmationTarget, allowUnconfirmed: true);
Assert.Equal(2, res.InnerWalletOutputs.Count());
Assert.Empty(res.OuterWalletOutputs);
@@ -951,7 +952,7 @@ namespace WalletWasabi.Tests
#region MediumFee
- res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(receive, amountToSend, "foo") }, Constants.OneDayConfirmationTarget, allowUnconfirmed: true);
+ res = wallet.BuildTransaction(password, new PaymentIntent(receive, amountToSend, "foo"), Constants.OneDayConfirmationTarget, allowUnconfirmed: true);
Assert.Equal(2, res.InnerWalletOutputs.Count());
Assert.Empty(res.OuterWalletOutputs);
@@ -984,7 +985,7 @@ namespace WalletWasabi.Tests
#region HighFee
- res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(receive, amountToSend, "foo") }, 2, allowUnconfirmed: true);
+ res = wallet.BuildTransaction(password, new PaymentIntent(receive, amountToSend, "foo"), 2, allowUnconfirmed: true);
Assert.Equal(2, res.InnerWalletOutputs.Count());
Assert.Empty(res.OuterWalletOutputs);
@@ -1023,7 +1024,8 @@ namespace WalletWasabi.Tests
#region MaxAmount
receive = wallet.GetReceiveKey("MaxAmount").P2wpkhScript;
- res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(receive, Money.Zero, "foo") }, Constants.SevenDaysConfirmationTarget, allowUnconfirmed: true);
+
+ res = wallet.BuildTransaction(password, new PaymentIntent(receive, MoneyRequest.CreateAllRemaining(), "foo"), Constants.SevenDaysConfirmationTarget, allowUnconfirmed: true);
Assert.Single(res.InnerWalletOutputs);
Assert.Empty(res.OuterWalletOutputs);
@@ -1051,7 +1053,8 @@ namespace WalletWasabi.Tests
receive = wallet.GetReceiveKey("InputSelection").P2wpkhScript;
var inputCountBefore = res.SpentCoins.Count();
- res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(receive, Money.Zero, "foo") }, Constants.SevenDaysConfirmationTarget,
+
+ res = wallet.BuildTransaction(password, new PaymentIntent(receive, MoneyRequest.CreateAllRemaining(), "foo"), Constants.SevenDaysConfirmationTarget,
allowUnconfirmed: true,
allowedInputs: wallet.Coins.Where(x => !x.Unavailable).Select(x => new TxoRef(x.TransactionId, x.Index)).Take(1));
@@ -1071,7 +1074,7 @@ namespace WalletWasabi.Tests
Assert.Single(res.Transaction.Transaction.Outputs);
- res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(receive, Money.Zero, "foo") }, Constants.SevenDaysConfirmationTarget,
+ res = wallet.BuildTransaction(password, new PaymentIntent(receive, MoneyRequest.CreateAllRemaining(), "foo"), Constants.SevenDaysConfirmationTarget,
allowUnconfirmed: true,
allowedInputs: new[] { res.SpentCoins.Select(x => new TxoRef(x.TransactionId, x.Index)).First() });
@@ -1087,17 +1090,19 @@ namespace WalletWasabi.Tests
#region Labeling
- res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(receive, Money.Zero, "my label") }, Constants.SevenDaysConfirmationTarget,
+ res = wallet.BuildTransaction(password, new PaymentIntent(receive, MoneyRequest.CreateAllRemaining(), "my label"), Constants.SevenDaysConfirmationTarget,
allowUnconfirmed: true);
Assert.Single(res.InnerWalletOutputs);
Assert.Equal("my label", res.InnerWalletOutputs.Single().Label);
amountToSend = wallet.Coins.Where(x => !x.Unavailable).Sum(x => x.Amount) / 3;
- res = wallet.BuildTransaction(password, new[] {
- new WalletService.Operation(new Key().ScriptPubKey, amountToSend, "outgoing"),
- new WalletService.Operation(new Key().ScriptPubKey, amountToSend, "outgoing2")
- }, Constants.SevenDaysConfirmationTarget,
+ res = wallet.BuildTransaction(
+ password,
+ new PaymentIntent(
+ new DestinationRequest(new Key(), amountToSend, "outgoing"),
+ new DestinationRequest(new Key(), amountToSend, "outgoing2")),
+ Constants.SevenDaysConfirmationTarget,
allowUnconfirmed: true);
Assert.Single(res.InnerWalletOutputs);
@@ -1126,7 +1131,7 @@ namespace WalletWasabi.Tests
receive = wallet.GetReceiveKey("AllowedInputsDisallowUnconfirmed").P2wpkhScript;
var allowedInputs = wallet.Coins.Where(x => !x.Unavailable).Select(x => new TxoRef(x.TransactionId, x.Index)).Take(1);
- var toSend = new[] { new WalletService.Operation(receive, Money.Zero, "fizz") };
+ var toSend = new PaymentIntent(receive, MoneyRequest.CreateAllRemaining(), "fizz");
// covers:
// disallow unconfirmed with allowed inputs
@@ -1161,7 +1166,12 @@ namespace WalletWasabi.Tests
// covers:
// customchange
// feePc > 1
- res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(new Key().ScriptPubKey, Money.Coins(0.0003m), "outgoing") }, 2, customChange: new Key().ScriptPubKey);
+ res = wallet.BuildTransaction(
+ password,
+ new PaymentIntent(
+ new DestinationRequest(new Key(), MoneyRequest.CreateAllRemaining()),
+ new DestinationRequest(new Key(), Money.Coins(0.0003m), "outgoing")),
+ 2);
Assert.True(res.FeePercentOfSent > 1);
@@ -1224,66 +1234,61 @@ namespace WalletWasabi.Tests
wallet.NewFilterProcessed += Wallet_NewFilterProcessed;
var scp = new Key().ScriptPubKey;
- var validOperationList = new[] { new WalletService.Operation(scp, Money.Coins(1), "") };
- var invalidOperationList = new[]
- {
- new WalletService.Operation(scp, Money.Coins(10 * 1000 * 1000), ""),
- new WalletService.Operation(scp, Money.Coins(12 * 1000 * 1000), "")
- };
- var overflowOperationList = new[]
- {
- new WalletService.Operation(scp, Money.Satoshis(long.MaxValue), ""),
- new WalletService.Operation(scp, Money.Satoshis(long.MaxValue), ""),
- new WalletService.Operation(scp, Money.Satoshis(5), "")
- };
+
+ var validIntent = new PaymentIntent(scp, Money.Coins(1));
+ var invalidIntent = new PaymentIntent(
+ new DestinationRequest(scp, Money.Coins(10 * 1000 * 1000)),
+ new DestinationRequest(scp, Money.Coins(12 * 1000 * 1000)));
+
+ Assert.Throws<OverflowException>(() => new PaymentIntent(
+ new DestinationRequest(scp, Money.Satoshis(long.MaxValue)),
+ new DestinationRequest(scp, Money.Satoshis(long.MaxValue)),
+ new DestinationRequest(scp, Money.Satoshis(5))));
Logger.TurnOff();
// toSend cannot be null
Assert.Throws<ArgumentNullException>(() => wallet.BuildTransaction(null, null, 0));
// toSend cannot have a null element
- Assert.Throws<ArgumentNullException>(() => wallet.BuildTransaction(null, new[] { (WalletService.Operation)null }, 0));
+ Assert.Throws<ArgumentNullException>(() => wallet.BuildTransaction(null, new PaymentIntent(new[] { (DestinationRequest)null }), 0));
// toSend cannot have a zero element
- Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(null, new WalletService.Operation[0], 0));
+ Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(null, new PaymentIntent(new DestinationRequest[] { }), 0));
// feeTarget has to be in the range 0 to 1008
- Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(null, validOperationList, -10));
- Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(null, validOperationList, 2000));
+ Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(null, validIntent, -10));
+ Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(null, validIntent, 2000));
// subtractFeeFromAmountIndex has to be valid
- Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(null, validOperationList, 2, null, false, -10));
- Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(null, validOperationList, 2, null, false, 1));
+ Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(null, validIntent, 2, null, false, -10));
+ Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(null, validIntent, 2, null, false, 1));
// toSend amount sum has to be in range 0 to 2099999997690000
- Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(null, invalidOperationList, 2));
+ Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(null, invalidIntent, 2));
// toSend negative sum amount
- var operations = new[] { new WalletService.Operation(scp, Money.Satoshis(-10000), "") };
- Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(null, operations, 2));
+ Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(null, new PaymentIntent(scp, Money.Satoshis(-10000), ""), 2));
// toSend negative operation amount
- operations = new[]
- {
- new WalletService.Operation(scp, Money.Satoshis(20000), ""),
- new WalletService.Operation(scp, Money.Satoshis(-10000), "")
- };
- Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(null, operations, 2));
+ Assert.Throws<ArgumentOutOfRangeException>(() => wallet.BuildTransaction(
+ null,
+ new PaymentIntent(
+ new DestinationRequest(scp, Money.Satoshis(20000)),
+ new DestinationRequest(scp, Money.Satoshis(-10000))),
+ 2));
// allowedInputs cannot be empty
- Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(null, validOperationList, 2, null, false, null, null, new TxoRef[0]));
+ Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(null, validIntent, 2, null, false, null, new TxoRef[0]));
// "Only one element can contain Money.Zero
- var toSendWithTwoZeros = new[]
- {
- new WalletService.Operation(scp, Money.Zero, "zero"),
- new WalletService.Operation(scp, Money.Zero, "zero")
- };
- Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(password, toSendWithTwoZeros, Constants.SevenDaysConfirmationTarget, null, false));
-
- // cannot specify spend all and custom change
- var spendAll = new[] { new WalletService.Operation(scp, Money.Zero, "spendAll") };
- Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(password, spendAll, Constants.SevenDaysConfirmationTarget, null, false, customChange: new Key().ScriptPubKey));
+ Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(
+ password,
+ new PaymentIntent(
+ new DestinationRequest(scp, MoneyRequest.CreateAllRemaining(), "zero"),
+ new DestinationRequest(scp, MoneyRequest.CreateAllRemaining(), "zero")),
+ Constants.SevenDaysConfirmationTarget,
+ null,
+ false));
// Get some money, make it confirm.
var key = wallet.GetReceiveKey("foo label");
@@ -1309,15 +1314,13 @@ namespace WalletWasabi.Tests
}
// subtract Fee from amount index with no enough money
- operations = new[]
- {
- new WalletService.Operation(scp, Money.Coins(1m), ""),
- new WalletService.Operation(scp, Money.Coins(0.5m), "")
- };
+ var operations = new PaymentIntent(
+ new DestinationRequest(scp, Money.Coins(1m)),
+ new DestinationRequest(scp, Money.Coins(0.5m)));
Assert.Throws<InsufficientBalanceException>(() => wallet.BuildTransaction(password, operations, 2, null, false, 0));
// No enough money (only one confirmed coin, no unconfirmed allowed)
- operations = new[] { new WalletService.Operation(scp, Money.Coins(1.5m), "") };
+ operations = new PaymentIntent(scp, Money.Coins(1.5m), "");
Assert.Throws<InsufficientBalanceException>(() => wallet.BuildTransaction(null, operations, 2));
// No enough money (only one confirmed coin, unconfirmed allowed)
@@ -1337,18 +1340,17 @@ namespace WalletWasabi.Tests
Assert.Equal(1, btx.SpentCoins.Count(c => !c.Confirmed));
// Only one operation with Zero money
- operations = new[]
- {
- new WalletService.Operation(scp, Money.Zero, ""),
- new WalletService.Operation(scp, Money.Zero, "")
- };
- Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(null, operations, 2));
- // `Custom change` and `spend all` cannot be specified at the same time
- Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(null, operations, 2, null, false, null, Script.Empty));
+ Assert.Throws<ArgumentException>(() => wallet.BuildTransaction(
+ null,
+ new PaymentIntent(
+ new DestinationRequest(scp, MoneyRequest.CreateAllRemaining()),
+ new DestinationRequest(scp, MoneyRequest.CreateAllRemaining())),
+ 2));
+
Logger.TurnOn();
- operations = new[] { new WalletService.Operation(scp, Money.Coins(0.5m), "") };
+ operations = new PaymentIntent(scp, Money.Coins(0.5m), "");
btx = wallet.BuildTransaction(password, operations, 2);
}
finally
@@ -1431,11 +1433,11 @@ namespace WalletWasabi.Tests
Assert.Single(wallet.Coins);
// Send money before reorg.
- var operations = new[] { new WalletService.Operation(scp, Money.Coins(0.011m), "") };
+ var operations = new PaymentIntent(scp, Money.Coins(0.011m), "");
var btx1 = wallet.BuildTransaction(password, operations, 2);
await wallet.SendTransactionAsync(btx1.Transaction);
- operations = new[] { new WalletService.Operation(scp, Money.Coins(0.012m), "") };
+ operations = new PaymentIntent(scp, Money.Coins(0.012m), "");
var btx2 = wallet.BuildTransaction(password, operations, 2, allowUnconfirmed: true);
await wallet.SendTransactionAsync(btx2.Transaction);
@@ -1454,11 +1456,11 @@ namespace WalletWasabi.Tests
// Send money after reorg.
// When we invalidate a block, the transactions set in the invalidated block
// are reintroduced when we generate a new block through the rpc call
- operations = new[] { new WalletService.Operation(scp, Money.Coins(0.013m), "") };
+ operations = new PaymentIntent(scp, Money.Coins(0.013m), "");
var btx3 = wallet.BuildTransaction(password, operations, 2);
await wallet.SendTransactionAsync(btx3.Transaction);
- operations = new[] { new WalletService.Operation(scp, Money.Coins(0.014m), "") };
+ operations = new PaymentIntent(scp, Money.Coins(0.014m), "");
var btx4 = wallet.BuildTransaction(password, operations, 2, allowUnconfirmed: true);
await wallet.SendTransactionAsync(btx4.Transaction);
@@ -1598,17 +1600,15 @@ namespace WalletWasabi.Tests
Assert.Single(wallet.Coins);
// Test mixin
- var operations = new[]
- {
- new WalletService.Operation(key.P2wpkhScript, Money.Coins(0.01m), ""),
- new WalletService.Operation(new Key().ScriptPubKey, Money.Coins(0.01m), ""),
- new WalletService.Operation(new Key().ScriptPubKey, Money.Coins(0.01m), "")
- };
+ var operations = new PaymentIntent(
+ new DestinationRequest(key.P2wpkhScript, Money.Coins(0.01m)),
+ new DestinationRequest(new Key().ScriptPubKey, Money.Coins(0.01m)),
+ new DestinationRequest(new Key().ScriptPubKey, Money.Coins(0.01m)));
var tx1Res = wallet.BuildTransaction(password, operations, 2, allowUnconfirmed: true);
Assert.Equal(1, tx1Res.OuterWalletOutputs.Single(x => x.ScriptPubKey == key.P2wpkhScript).AnonymitySet);
// Spend the unconfirmed coin (send it to ourself)
- operations = new[] { new WalletService.Operation(key.PubKey.WitHash.ScriptPubKey, Money.Coins(0.5m), "") };
+ operations = new PaymentIntent(key.PubKey.WitHash.ScriptPubKey, Money.Coins(0.5m));
tx1Res = wallet.BuildTransaction(password, operations, 2, allowUnconfirmed: true);
await wallet.SendTransactionAsync(tx1Res.Transaction);
@@ -1630,7 +1630,7 @@ namespace WalletWasabi.Tests
Assert.Equal((1 * Money.COIN) - tx1Res.Fee.Satoshi, totalWallet);
// Spend the unconfirmed and unspent coin (send it to ourself)
- operations = new[] { new WalletService.Operation(key.PubKey.WitHash.ScriptPubKey, Money.Coins(0.5m), "") };
+ operations = new PaymentIntent(key.PubKey.WitHash.ScriptPubKey, Money.Coins(0.5m), "");
var tx2Res = wallet.BuildTransaction(password, operations, 2, allowUnconfirmed: true, subtractFeeFromAmountIndex: 0);
await wallet.SendTransactionAsync(tx2Res.Transaction);
@@ -1674,7 +1674,7 @@ namespace WalletWasabi.Tests
// Test coin basic count.
var coinCount = wallet.Coins.Count;
var to = wallet.GetReceiveKey("foo");
- var res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(to.P2wpkhScript, Money.Coins(0.2345m), "bar") }, 2, allowUnconfirmed: true);
+ var res = wallet.BuildTransaction(password, new PaymentIntent(to.P2wpkhScript, Money.Coins(0.2345m), "bar"), 2, allowUnconfirmed: true);
await wallet.SendTransactionAsync(res.Transaction);
Assert.Equal(coinCount + 2, wallet.Coins.Count);
Assert.Equal(2, wallet.Coins.Count(x => !x.Confirmed));

ADD
WalletWasabi/Models/TransactionBuilding/ChangeStrategy.cs
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace WalletWasabi.Models.TransactionBuilding
+{
+ public enum ChangeStrategy
+ {
+ Auto,
+ Custom
+ }
+}

ADD
WalletWasabi/Models/TransactionBuilding/DestinationRequest.cs
+using NBitcoin;
+using System;
+using System.Collections.Generic;
+using System.Text;
+using WalletWasabi.Helpers;
+
+namespace WalletWasabi.Models.TransactionBuilding
+{
+ public class DestinationRequest
+ {
+ public IDestination Destination { get; }
+ public MoneyRequest Amount { get; }
+ public string Label { get; }
+
+ public DestinationRequest(Script scriptPubKey, Money amount, string label = "") : this(scriptPubKey, MoneyRequest.Create(amount), label)
+ {
+ }
+
+ public DestinationRequest(Script scriptPubKey, MoneyRequest amount, string label = "") : this(scriptPubKey.GetDestination(), amount, label)
+ {
+ }
+
+ public DestinationRequest(IDestination destination, Money amount, string label = "") : this(destination, MoneyRequest.Create(amount), label)
+ {
+ }
+
+ public DestinationRequest(IDestination destination, MoneyRequest amount, string label = "")
+ {
+ Destination = Guard.NotNull(nameof(destination), destination);
+ Amount = Guard.NotNull(nameof(amount), amount);
+ Label = Guard.Correct(label);
+ }
+ }
+}

ADD
WalletWasabi/Models/TransactionBuilding/MoneyRequest.cs
+using NBitcoin;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace WalletWasabi.Models.TransactionBuilding
+{
+ public class MoneyRequest
+ {
+ public static MoneyRequest Create(Money amount) => new MoneyRequest(amount, MoneyRequestType.Value);
+
+ public static MoneyRequest CreateAllRemaining() => new MoneyRequest(null, MoneyRequestType.AllRemaining);
+
+ public Money Amount { get; }
+ public MoneyRequestType Type { get; }
+
+ private MoneyRequest(Money amount, MoneyRequestType type)
+ {
+ if (type == MoneyRequestType.AllRemaining)
+ {
+ if (amount != null)
+ {
+ throw new ArgumentException($"{nameof(amount)} must be null.");
+ }
+ }
+ else if (type == MoneyRequestType.Value)
+ {
+ if (amount is null)
+ {
+ throw new ArgumentNullException($"{nameof(amount)} cannot be null.");
+ }
+ else if (amount <= Money.Zero)
+ {
+ throw new ArgumentOutOfRangeException($"{nameof(amount)} must be positive.");
+ }
+ }
+ else
+ {
+ throw new NotSupportedException($"{nameof(type)} is not supported: {type}.");
+ }
+
+ Amount = amount;
+ Type = type;
+ }
+ }
+}

ADD
WalletWasabi/Models/TransactionBuilding/MoneyRequestType.cs
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace WalletWasabi.Models.TransactionBuilding
+{
+ public enum MoneyRequestType
+ {
+ Value,
+ AllRemaining
+ }
+}

ADD
WalletWasabi/Models/TransactionBuilding/PaymentIntent.cs
+using NBitcoin;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using WalletWasabi.Helpers;
+
+namespace WalletWasabi.Models.TransactionBuilding
+{
+ public class PaymentIntent
+ {
+ public IEnumerable<DestinationRequest> Requests { get; }
+ public ChangeStrategy ChangeStrategy { get; }
+ public Money TotalAmount { get; }
+ public int Count { get; }
+
+ public PaymentIntent(Script scriptPubKey, Money amount, string label = "") : this(scriptPubKey, MoneyRequest.Create(amount), label)
+ {
+ }
+
+ public PaymentIntent(Script scriptPubKey, MoneyRequest amount, string label = "") : this(scriptPubKey.GetDestination(), amount, label)
+ {
+ }
+
+ public PaymentIntent(IDestination destination, Money amount, string label = "") : this(destination, MoneyRequest.Create(amount), label)
+ {
+ }
+
+ public PaymentIntent(IDestination destination, MoneyRequest amount, string label = "") : this(new DestinationRequest(destination, amount, label))
+ {
+ }
+
+ public PaymentIntent(params DestinationRequest[] requests) : this(requests as IEnumerable<DestinationRequest>)
+ {
+ }
+
+ public PaymentIntent(IEnumerable<DestinationRequest> requests)
+ {
+ Guard.NotNullOrEmpty(nameof(requests), requests);
+ foreach (var request in requests)
+ {
+ Guard.NotNull(nameof(request), request);
+ }
+
+ var allSpendCount = requests.Count(x => x.Amount.Type == MoneyRequestType.AllRemaining);
+ if (allSpendCount == 0)
+ {
+ ChangeStrategy = ChangeStrategy.Auto;
+ }
+ else if (allSpendCount == 1)
+ {
+ ChangeStrategy = ChangeStrategy.Custom;
+ }
+ else
+ {
+ throw new ArgumentException($"Only one request can contain an all remaining money request.");
+ }
+
+ Requests = requests;
+
+ TotalAmount = requests.Where(x => x.Amount.Type == MoneyRequestType.Value).Sum(x => x.Amount.Amount);
+ Count = requests.Count();
+ }
+
+ public DestinationRequest GetCustomChange()
+ {
+ if (ChangeStrategy != ChangeStrategy.Custom)
+ {
+ throw new InvalidOperationException($"{nameof(ChangeStrategy)} must be {ChangeStrategy.Custom}");
+ }
+ else
+ {
+ return Requests.Single(x => x.Amount.Type == MoneyRequestType.AllRemaining);
+ }
+ }
+ }
+}

MODIFY
WalletWasabi/Services/WalletService.cs
WalletWasabi/Services/WalletService.cs
@@ -25,6 +25,7 @@ using WalletWasabi.Helpers;
using WalletWasabi.KeyManagement;
using WalletWasabi.Logging;
using WalletWasabi.Models;
+using WalletWasabi.Models.TransactionBuilding;
using WalletWasabi.Stores;
using WalletWasabi.WebClients.Wasabi;
@@ -866,21 +867,6 @@ namespace WalletWasabi.Services
}
}
- public class Operation
- {
- public Script Script { get; }
- public Money Amount { get; }
- public string Label { get; }
-
- public Operation(Script script, Money amount, string label)
- {
- Script = Guard.NotNull(nameof(script), script);
- Amount = Guard.NotNull(nameof(amount), amount);
- Label = label ?? "";
- }
- }
-
- /// <param name="toSend">If Money.Zero then spends all available amount. Does not generate change.</param>
/// <param name="allowUnconfirmed">Allow to spend unconfirmed transactions, if necessary.</param>
/// <param name="allowedInputs">Only these inputs allowed to be used to build the transaction. The wallet must know the corresponding private keys.</param>
/// <param name="subtractFeeFromAmountIndex">If null, fee is substracted from the change. Otherwise it denotes the index in the toSend array.</param>
@@ -890,16 +876,15 @@ namespace WalletWasabi.Services
/// <exception cref="ArgumentNullException"></exception>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public BuildTransactionResult BuildTransaction(string password,
- Operation[] toSend,
+ PaymentIntent payments,
int? feeTarget = null,
FeeRate feeRate = null,
bool allowUnconfirmed = false,
int? subtractFeeFromAmountIndex = null,
- Script customChange = null,
IEnumerable<TxoRef> allowedInputs = null)
{
password = password ?? ""; // Correction.
- toSend = Guard.NotNullOrEmpty(nameof(toSend), toSend);
+ payments = Guard.NotNull(nameof(payments), payments);
if ((feeRate is null && feeTarget is null)
|| (feeRate != null && feeTarget != null))
@@ -907,29 +892,10 @@ namespace WalletWasabi.Services
throw new NotSupportedException($"Either {nameof(feeRate)} or {nameof(feeTarget)} must be set and the other should be null.");
}
- if (toSend.Any(x => x is null))
- {
- throw new ArgumentNullException($"{nameof(toSend)} cannot contain null element.");
- }
- if (toSend.Any(x => x.Amount < Money.Zero))
- {
- throw new ArgumentException($"{nameof(toSend)} cannot contain negative element.");
- }
-
- long sum = toSend.Select(x => x.Amount).Sum().Satoshi;
- if (sum < 0 || sum > Constants.MaximumNumberOfSatoshis)
- {
- throw new ArgumentOutOfRangeException($"{nameof(toSend)} sum cannot be smaller than 0 or greater than {Constants.MaximumNumberOfSatoshis}.");
- }
-
- int spendAllCount = toSend.Count(x => x.Amount == Money.Zero);
- if (spendAllCount > 1)
- {
- throw new ArgumentException($"Only one {nameof(toSend)} element can contain Money.Zero. Money.Zero means add the change to the value of this output.");
- }
- if (spendAllCount == 1 && !(customChange is null))
+ long totalAmount = payments.TotalAmount.Satoshi;
+ if (totalAmount < 0 || totalAmount > Constants.MaximumNumberOfSatoshis)
{
- throw new ArgumentException($"{nameof(customChange)} and send all to destination cannot be specified at the same time.");
+ throw new ArgumentOutOfRangeException($"{nameof(payments)}.{nameof(payments.TotalAmount)} sum cannot be smaller than 0 or greater than {Constants.MaximumNumberOfSatoshis}.");
}
if (feeTarget.HasValue)
@@ -948,9 +914,10 @@ namespace WalletWasabi.Services
{
throw new ArgumentOutOfRangeException($"{nameof(subtractFeeFromAmountIndex)} cannot be smaller than 0.");
}
- if (subtractFeeFromAmountIndex > toSend.Length - 1)
+ int maxIndex = payments.Count - 1;
+ if (subtractFeeFromAmountIndex > maxIndex)
{
- throw new ArgumentOutOfRangeException($"{nameof(subtractFeeFromAmountIndex)} can be maximum {nameof(toSend)}.Length - 1. {nameof(subtractFeeFromAmountIndex)}: {subtractFeeFromAmountIndex}, {nameof(toSend)}.Length - 1: {toSend.Length - 1}.");
+ throw new ArgumentOutOfRangeException($"{nameof(subtractFeeFromAmountIndex)} can be maximum {maxIndex}. Actual: {subtractFeeFromAmountIndex}.");
}
}
@@ -984,18 +951,23 @@ namespace WalletWasabi.Services
builder.SetCoinSelector(new SmartCoinSelector(smartCoinsByOutpoint));
builder.AddCoins(allowedSmartCoinInputs.Select(c => c.GetCoin()));
- for (int i = 0; i < toSend.Length; i++) // clone
+ for (int i = 0; i < payments.Count; i++) // clone
{
- var destination = toSend[i].Script;
- var amount = toSend[i].Amount;
+ var toSend = payments.Requests.ToArray();
+ var destination = toSend[i].Destination;
+ var amountRequest = toSend[i].Amount;
var label = toSend[i].Label;
- if (amount == Money.Zero)
+ if (amountRequest.Type == MoneyRequestType.AllRemaining)
{
builder.SendAll(destination);
}
+ else if (amountRequest.Type == MoneyRequestType.Value)
+ {
+ builder.Send(destination, amountRequest.Amount);
+ }
else
{
- builder.Send(destination, amount);
+ throw new NotSupportedException($"{nameof(amountRequest.Type)} is not supported: {amountRequest.Type}.");
}
if (subtractFeeFromAmountIndex is int inputIndex && inputIndex == i)
@@ -1005,16 +977,22 @@ namespace WalletWasabi.Services
}
HdPubKey changeHdPubKey = null;
- if (customChange is null)
+ if (payments.ChangeStrategy == ChangeStrategy.Auto)
{
KeyManager.AssertCleanKeysIndexed(isInternal: true);
KeyManager.AssertLockedInternalKeysIndexed(14);
changeHdPubKey = KeyManager.GetKeys(KeyState.Clean, true).RandomElement();
builder.SetChange(changeHdPubKey.P2wpkhScript);
}
+ else if (payments.ChangeStrategy == ChangeStrategy.Custom)
+ {
+ IDestination customDestination = payments.GetCustomChange().Destination;
+ builder.SetChange(customDestination);
+ changeHdPubKey = KeyManager.GetKeyForScriptPubKey(customDestination.ScriptPubKey);
+ }
else
{
- builder.SetChange(customChange);
+ throw new NotSupportedException($"{nameof(payments.ChangeStrategy)} is not supported: {payments.ChangeStrategy}.");
}
builder.SendEstimatedFees(feePerBytes);
@@ -1023,9 +1001,9 @@ namespace WalletWasabi.Services
string changeLabel = null;
var spentCoins = psbt.Inputs.Select(txin => smartCoinsByOutpoint[txin.PrevOut]).ToArray();
- var realToSend = toSend.Select(t => (label: t.Label,
- script: t.Script,
- amount: psbt.Outputs.FirstOrDefault(o => o.ScriptPubKey == t.Script)?.Value))
+ var realToSend = payments.Requests.Select(t => (label: t.Label,
+ destination: t.Destination,
+ amount: psbt.Outputs.FirstOrDefault(o => o.ScriptPubKey == t.Destination.ScriptPubKey)?.Value))
.Where(i => i.amount != null)
.ToArray();

Introduce PaymentIntent instead of operation.