diff --git a/WalletWasabi.Gui/Controls/WalletExplorer/SendTabViewModel.cs b/WalletWasabi.Gui/Controls/WalletExplorer/SendTabViewModel.cs index 277f6b09f71..70942b4f968 100644 --- a/WalletWasabi.Gui/Controls/WalletExplorer/SendTabViewModel.cs +++ b/WalletWasabi.Gui/Controls/WalletExplorer/SendTabViewModel.cs @@ -26,6 +26,7 @@ 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 @@ public SendTabViewModel(WalletViewModel walletViewModel, bool isTransactionBuild return; } - var script = address.ScriptPubKey; - var amount = Money.Zero; - if (!IsMax) + MoneyRequest moneyRequest; + if (IsMax) { - if (!Money.TryParse(AmountText, out amount) || amount == Money.Zero) + moneyRequest = MoneyRequest.CreateAllRemaining(); + } + else + { + if (!Money.TryParse(AmountText, out Money amount) || amount == Money.Zero) { SetWarningMessage($"Invalid amount."); return; @@ -283,6 +287,7 @@ public SendTabViewModel(WalletViewModel walletViewModel, bool isTransactionBuild 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) @@ -292,11 +297,10 @@ public SendTabViewModel(WalletViewModel walletViewModel, bool isTransactionBuild } bool useCustomFee = !IsSliderFeeUsed; - FeeRate feeRate = FeeRate; + var feeStrategy = FeeStrategy.CreateFromFeeRate(FeeRate); var label = Label; - var operation = new WalletService.Operation(script, amount, label); - + var intent = new PaymentIntent(address, moneyRequest, label); try { MainWindowViewModel.Instance.StatusBar.TryAddStatus(StatusBarStatus.DequeuingSelectedCoins); @@ -331,7 +335,7 @@ public SendTabViewModel(WalletViewModel walletViewModel, bool isTransactionBuild 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, feeStrategy, allowUnconfirmed: true, allowedInputs: selectedCoinReferences)); if (IsTransactionBuilder) { @@ -673,7 +677,7 @@ private void SetFeesAndTexts() } else if (feeTarget == Constants.SevenDaysConfirmationTarget) { - ConfirmationExpectedText = $"two weeks™"; + ConfirmationExpectedText = "one week"; } else if (feeTarget == -1) { diff --git a/WalletWasabi.Gui/Controls/WalletExplorer/TransactionViewerViewModel.cs b/WalletWasabi.Gui/Controls/WalletExplorer/TransactionViewerViewModel.cs index 0074c67cfde..97cc57576d9 100644 --- a/WalletWasabi.Gui/Controls/WalletExplorer/TransactionViewerViewModel.cs +++ b/WalletWasabi.Gui/Controls/WalletExplorer/TransactionViewerViewModel.cs @@ -11,6 +11,7 @@ using System.Runtime.InteropServices; using System.Threading.Tasks; using WalletWasabi.Models; +using WalletWasabi.Models.TransactionBuilding; namespace WalletWasabi.Gui.Controls.WalletExplorer { diff --git a/WalletWasabi.Tests/RegTests.cs b/WalletWasabi.Tests/RegTests.cs index 5277f4d45fe..1cbb3868b0a 100644 --- a/WalletWasabi.Tests/RegTests.cs +++ b/WalletWasabi.Tests/RegTests.cs @@ -27,6 +27,7 @@ using WalletWasabi.Logging; using WalletWasabi.Models; using WalletWasabi.Models.ChaumianCoinJoin; +using WalletWasabi.Models.TransactionBuilding; using WalletWasabi.Services; using WalletWasabi.Stores; using WalletWasabi.Tests.XunitConfiguration; @@ -749,7 +750,7 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, } [Fact] - public async Task SendTestsFromHiddenWalletAsync() // These tests are taken from HiddenWallet, they were tests on the testnet. + public async Task SendTestsAsync() { (string password, RPCClient rpc, Network network, CcjCoordinator coordinator, ServiceConfiguration serviceConfiguration, BitcoinStore bitcoinStore, Backend.Global global) = await InitializeTestEnvironmentAsync(1); @@ -773,16 +774,17 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, var chaumianClient = new CcjClient(synchronizer, rpc.Network, keyManager, new Uri(RegTestFixture.BackendEndPoint), null); // 6. Create wallet service. - var workDir = Path.Combine(Global.Instance.DataDir, nameof(SendTestsFromHiddenWalletAsync)); + var workDir = Path.Combine(Global.Instance.DataDir, nameof(SendTestsAsync)); var wallet = new WalletService(bitcoinStore, keyManager, synchronizer, chaumianClient, mempoolService, nodes, workDir, serviceConfiguration); wallet.NewFilterProcessed += Wallet_NewFilterProcessed; // Get some money, make it confirm. var key = wallet.GetReceiveKey("foo label"); + var key2 = wallet.GetReceiveKey("foo label"); var txId = await rpc.SendToAddressAsync(key.GetP2wpkhAddress(network), Money.Coins(1m)); Assert.NotNull(txId); await rpc.GenerateAsync(1); - var txId2 = await rpc.SendToAddressAsync(key.GetP2wpkhAddress(network), Money.Coins(1m)); + var txId2 = await rpc.SendToAddressAsync(key2.GetP2wpkhAddress(network), Money.Coins(1m)); Assert.NotNull(txId2); await rpc.GenerateAsync(1); @@ -816,7 +818,7 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, } 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(scp, Money.Coins(0.05m), label: "foo"), FeeStrategy.CreateFromConfirmationTarget(5), allowUnconfirmed: false); Assert.NotNull(res2.Transaction); Assert.Single(res2.OuterWalletOutputs); @@ -825,7 +827,7 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, Assert.True(res2.Fee > Money.Satoshis(2 * 100)); // since there is a sanity check of 2sat/vb in the server Assert.InRange(res2.FeePercentOfSent, 0, 1); Assert.Single(res2.SpentCoins); - Assert.Equal(key.P2wpkhScript, res2.SpentCoins.Single().ScriptPubKey); + Assert.Equal(key2.P2wpkhScript, res2.SpentCoins.Single().ScriptPubKey); Assert.Equal(Money.Coins(1m), res2.SpentCoins.Single().Amount); Assert.False(res2.SpendsUnconfirmed); @@ -837,7 +839,7 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, 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(receive, amountToSend, label: "foo"), FeeStrategy.SevenDaysConfirmationTargetStrategy, allowUnconfirmed: true); foreach (SmartCoin coin in res.SpentCoins) { @@ -885,7 +887,7 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, 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, subtractFee: true, label: "foo"), FeeStrategy.SevenDaysConfirmationTargetStrategy, allowUnconfirmed: true); Assert.Equal(2, res.InnerWalletOutputs.Count()); Assert.Empty(res.OuterWalletOutputs); @@ -918,7 +920,7 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, #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, label: "foo"), FeeStrategy.SevenDaysConfirmationTargetStrategy, allowUnconfirmed: true); Assert.Equal(2, res.InnerWalletOutputs.Count()); Assert.Empty(res.OuterWalletOutputs); @@ -951,7 +953,7 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, #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, label: "foo"), FeeStrategy.OneDayConfirmationTargetStrategy, allowUnconfirmed: true); Assert.Equal(2, res.InnerWalletOutputs.Count()); Assert.Empty(res.OuterWalletOutputs); @@ -984,7 +986,7 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, #region HighFee - res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(receive, amountToSend, "foo") }, 2, allowUnconfirmed: true); + res = wallet.BuildTransaction(password, new PaymentIntent(receive, amountToSend, label: "foo"), FeeStrategy.TwentyMinutesConfirmationTargetStrategy, allowUnconfirmed: true); Assert.Equal(2, res.InnerWalletOutputs.Count()); Assert.Empty(res.OuterWalletOutputs); @@ -1023,7 +1025,8 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, #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"), FeeStrategy.SevenDaysConfirmationTargetStrategy, allowUnconfirmed: true); Assert.Single(res.InnerWalletOutputs); Assert.Empty(res.OuterWalletOutputs); @@ -1031,12 +1034,6 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, Assert.Equal(receive, activeOutput.ScriptPubKey); - Logger.LogInfo($"Fee: {res.Fee}"); - Logger.LogInfo($"FeePercentOfSent: {res.FeePercentOfSent} %"); - Logger.LogInfo($"SpendsUnconfirmed: {res.SpendsUnconfirmed}"); - Logger.LogInfo($"Active Output: {activeOutput.Amount.ToString(false, true)} {activeOutput.ScriptPubKey.GetDestinationAddress(network)}"); - Logger.LogInfo($"TxId: {res.Transaction.GetHash()}"); - Assert.Single(res.Transaction.Transaction.Outputs); var maxBuiltTxOutput = res.Transaction.Transaction.Outputs.Single(); Assert.Equal(receive, maxBuiltTxOutput.ScriptPubKey); @@ -1051,7 +1048,8 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, 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"), FeeStrategy.SevenDaysConfirmationTargetStrategy, allowUnconfirmed: true, allowedInputs: wallet.Coins.Where(x => !x.Unavailable).Select(x => new TxoRef(x.TransactionId, x.Index)).Take(1)); @@ -1071,7 +1069,7 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, 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"), FeeStrategy.SevenDaysConfirmationTargetStrategy, allowUnconfirmed: true, allowedInputs: new[] { res.SpentCoins.Select(x => new TxoRef(x.TransactionId, x.Index)).First() }); @@ -1087,17 +1085,19 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, #region Labeling - res = wallet.BuildTransaction(password, new[] { new WalletService.Operation(receive, Money.Zero, "my label") }, Constants.SevenDaysConfirmationTarget, - allowUnconfirmed: true); + Script receive2 = wallet.GetReceiveKey("").P2wpkhScript; + res = wallet.BuildTransaction(password, new PaymentIntent(receive2, MoneyRequest.CreateAllRemaining(), "my label"), FeeStrategy.SevenDaysConfirmationTargetStrategy, 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, label: "outgoing"), + new DestinationRequest(new Key(), amountToSend, label: "outgoing2")), + FeeStrategy.SevenDaysConfirmationTargetStrategy, allowUnconfirmed: true); Assert.Single(res.InnerWalletOutputs); @@ -1126,12 +1126,11 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, 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 - // feeTarget < 2 // NOTE: need to correct alowing 0 and 1 - res = wallet.BuildTransaction(password, toSend, 0, null, false, allowedInputs: allowedInputs); + res = wallet.BuildTransaction(password, toSend, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, false, allowedInputs: allowedInputs); activeOutput = res.InnerWalletOutputs.Single(x => x.ScriptPubKey == receive); Assert.Single(res.InnerWalletOutputs); @@ -1161,17 +1160,46 @@ private void WalletTestsAsync_MempoolService_TransactionReceived(object sender, // 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); + var k1 = new Key(); + var k2 = new Key(); + res = wallet.BuildTransaction( + password, + new PaymentIntent( + new DestinationRequest(k1, MoneyRequest.CreateChange()), + new DestinationRequest(k2, Money.Coins(0.0003m), label: "outgoing")), + FeeStrategy.TwentyMinutesConfirmationTargetStrategy); + + Assert.Contains(k1.ScriptPubKey, res.OuterWalletOutputs.Select(x => x.ScriptPubKey)); + Assert.Contains(k2.ScriptPubKey, res.OuterWalletOutputs.Select(x => x.ScriptPubKey)); + + #endregion CustomChange + + #region FeePcHigh + + res = wallet.BuildTransaction( + password, + new PaymentIntent(new Key(), Money.Coins(0.0003m), label: "outgoing"), + FeeStrategy.TwentyMinutesConfirmationTargetStrategy); Assert.True(res.FeePercentOfSent > 1); - Logger.LogDebug($"Fee: {res.Fee}"); - Logger.LogDebug($"FeePercentOfSent: {res.FeePercentOfSent} %"); - Logger.LogDebug($"SpendsUnconfirmed: {res.SpendsUnconfirmed}"); - Logger.LogDebug($"Active Output: {activeOutput.Amount.ToString(false, true)} {activeOutput.ScriptPubKey.GetDestinationAddress(network)}"); - Logger.LogDebug($"TxId: {res.Transaction.GetHash()}"); + var newChangeK = keyManager.GenerateNewKey("foo", KeyState.Clean, isInternal: true); + res = wallet.BuildTransaction( + password, + new PaymentIntent( + new DestinationRequest(newChangeK.P2wpkhScript, MoneyRequest.CreateChange(), "boo"), + new DestinationRequest(new Key(), Money.Coins(0.0003m), label: "outgoing")), + FeeStrategy.TwentyMinutesConfirmationTargetStrategy); - #endregion CustomChange + Assert.True(res.FeePercentOfSent > 1); + Assert.Single(res.OuterWalletOutputs); + Assert.Single(res.InnerWalletOutputs); + SmartCoin changeRes = res.InnerWalletOutputs.Single(); + Assert.Equal(newChangeK.P2wpkhScript, changeRes.ScriptPubKey); + Assert.Equal(newChangeK.Label, changeRes.Label); + Assert.Equal(KeyState.Clean, newChangeK.KeyState); // Still clean, because the tx wasn't yet propagated. + + #endregion FeePcHigh } finally { @@ -1219,71 +1247,60 @@ public async Task BuildTransactionValidationsTestAsync() var chaumianClient = new CcjClient(synchronizer, rpc.Network, keyManager, new Uri(RegTestFixture.BackendEndPoint), null); // 6. Create wallet service. - var workDir = Path.Combine(Global.Instance.DataDir, nameof(SendTestsFromHiddenWalletAsync)); + var workDir = Path.Combine(Global.Instance.DataDir, nameof(BuildTransactionValidationsTestAsync)); var wallet = new WalletService(bitcoinStore, keyManager, synchronizer, chaumianClient, mempoolService, nodes, workDir, serviceConfiguration); 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(() => 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(() => wallet.BuildTransaction(null, null, 0)); + Assert.Throws(() => wallet.BuildTransaction(null, null, FeeStrategy.CreateFromConfirmationTarget(4))); // toSend cannot have a null element - Assert.Throws(() => wallet.BuildTransaction(null, new[] { (WalletService.Operation)null }, 0)); + Assert.Throws(() => wallet.BuildTransaction(null, new PaymentIntent(new[] { (DestinationRequest)null }), FeeStrategy.CreateFromConfirmationTarget(0))); // toSend cannot have a zero element - Assert.Throws(() => wallet.BuildTransaction(null, new WalletService.Operation[0], 0)); + Assert.Throws(() => wallet.BuildTransaction(null, new PaymentIntent(new DestinationRequest[] { }), FeeStrategy.SevenDaysConfirmationTargetStrategy)); // feeTarget has to be in the range 0 to 1008 - Assert.Throws(() => wallet.BuildTransaction(null, validOperationList, -10)); - Assert.Throws(() => wallet.BuildTransaction(null, validOperationList, 2000)); - - // subtractFeeFromAmountIndex has to be valid - Assert.Throws(() => wallet.BuildTransaction(null, validOperationList, 2, null, false, -10)); - Assert.Throws(() => wallet.BuildTransaction(null, validOperationList, 2, null, false, 1)); + Assert.Throws(() => wallet.BuildTransaction(null, validIntent, FeeStrategy.CreateFromConfirmationTarget(-10))); + Assert.Throws(() => wallet.BuildTransaction(null, validIntent, FeeStrategy.CreateFromConfirmationTarget(2000))); // toSend amount sum has to be in range 0 to 2099999997690000 - Assert.Throws(() => wallet.BuildTransaction(null, invalidOperationList, 2)); + Assert.Throws(() => wallet.BuildTransaction(null, invalidIntent, FeeStrategy.TwentyMinutesConfirmationTargetStrategy)); // toSend negative sum amount - var operations = new[] { new WalletService.Operation(scp, Money.Satoshis(-10000), "") }; - Assert.Throws(() => wallet.BuildTransaction(null, operations, 2)); + Assert.Throws(() => wallet.BuildTransaction(null, new PaymentIntent(scp, Money.Satoshis(-10000)), FeeStrategy.TwentyMinutesConfirmationTargetStrategy)); // toSend negative operation amount - operations = new[] - { - new WalletService.Operation(scp, Money.Satoshis(20000), ""), - new WalletService.Operation(scp, Money.Satoshis(-10000), "") - }; - Assert.Throws(() => wallet.BuildTransaction(null, operations, 2)); + Assert.Throws(() => wallet.BuildTransaction( + null, + new PaymentIntent( + new DestinationRequest(scp, Money.Satoshis(20000)), + new DestinationRequest(scp, Money.Satoshis(-10000))), + FeeStrategy.TwentyMinutesConfirmationTargetStrategy)); // allowedInputs cannot be empty - Assert.Throws(() => wallet.BuildTransaction(null, validOperationList, 2, null, false, null, null, new TxoRef[0])); + Assert.Throws(() => wallet.BuildTransaction(null, validIntent, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, allowedInputs: 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(() => 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(() => wallet.BuildTransaction(password, spendAll, Constants.SevenDaysConfirmationTarget, null, false, customChange: new Key().ScriptPubKey)); + // "Only one element can contain the AllRemaining flag. + Assert.Throws(() => wallet.BuildTransaction( + password, + new PaymentIntent( + new DestinationRequest(scp, MoneyRequest.CreateAllRemaining(), "zero"), + new DestinationRequest(scp, MoneyRequest.CreateAllRemaining(), "zero")), + FeeStrategy.SevenDaysConfirmationTargetStrategy, + false)); // Get some money, make it confirm. var key = wallet.GetReceiveKey("foo label"); @@ -1309,47 +1326,44 @@ public async Task BuildTransactionValidationsTestAsync() } // 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), "") - }; - Assert.Throws(() => wallet.BuildTransaction(password, operations, 2, null, false, 0)); + var operations = new PaymentIntent( + new DestinationRequest(scp, Money.Coins(1m), subtractFee: true), + new DestinationRequest(scp, Money.Coins(0.5m))); + Assert.Throws(() => wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, false)); // No enough money (only one confirmed coin, no unconfirmed allowed) - operations = new[] { new WalletService.Operation(scp, Money.Coins(1.5m), "") }; - Assert.Throws(() => wallet.BuildTransaction(null, operations, 2)); + operations = new PaymentIntent(scp, Money.Coins(1.5m)); + Assert.Throws(() => wallet.BuildTransaction(null, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy)); // No enough money (only one confirmed coin, unconfirmed allowed) - Assert.Throws(() => wallet.BuildTransaction(null, operations, 2, null, true)); + Assert.Throws(() => wallet.BuildTransaction(null, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, true)); // Add new money with no confirmation var txId2 = await rpc.SendToAddressAsync(key.GetP2wpkhAddress(network), Money.Coins(2m)); await Task.Delay(1000); // Wait tx to arrive and get processed. // Enough money (one confirmed coin and one unconfirmed coin, unconfirmed are NOT allowed) - Assert.Throws(() => wallet.BuildTransaction(null, operations, 2, null, false)); + Assert.Throws(() => wallet.BuildTransaction(null, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, false)); // Enough money (one confirmed coin and one unconfirmed coin, unconfirmed are allowed) - var btx = wallet.BuildTransaction(password, operations, 2, null, true); + var btx = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, true); Assert.Equal(2, btx.SpentCoins.Count()); Assert.Equal(1, btx.SpentCoins.Count(c => c.Confirmed)); 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(() => wallet.BuildTransaction(null, operations, 2)); + // Only one operation with AllRemainingFlag + + Assert.Throws(() => wallet.BuildTransaction( + null, + new PaymentIntent( + new DestinationRequest(scp, MoneyRequest.CreateAllRemaining()), + new DestinationRequest(scp, MoneyRequest.CreateAllRemaining())), + FeeStrategy.TwentyMinutesConfirmationTargetStrategy)); - // `Custom change` and `spend all` cannot be specified at the same time - Assert.Throws(() => wallet.BuildTransaction(null, operations, 2, null, false, null, Script.Empty)); Logger.TurnOn(); - operations = new[] { new WalletService.Operation(scp, Money.Coins(0.5m), "") }; - btx = wallet.BuildTransaction(password, operations, 2); + operations = new PaymentIntent(scp, Money.Coins(0.5m)); + btx = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy); } finally { @@ -1396,7 +1410,7 @@ public async Task BuildTransactionReorgsTestAsync() var chaumianClient = new CcjClient(synchronizer, rpc.Network, keyManager, new Uri(RegTestFixture.BackendEndPoint), null); // 6. Create wallet service. - var workDir = Path.Combine(Global.Instance.DataDir, nameof(SendTestsFromHiddenWalletAsync)); + var workDir = Path.Combine(Global.Instance.DataDir, nameof(BuildTransactionReorgsTestAsync)); var wallet = new WalletService(bitcoinStore, keyManager, synchronizer, chaumianClient, mempoolService, nodes, workDir, serviceConfiguration); wallet.NewFilterProcessed += Wallet_NewFilterProcessed; @@ -1431,12 +1445,12 @@ public async Task BuildTransactionReorgsTestAsync() Assert.Single(wallet.Coins); // Send money before reorg. - var operations = new[] { new WalletService.Operation(scp, Money.Coins(0.011m), "") }; - var btx1 = wallet.BuildTransaction(password, operations, 2); + var operations = new PaymentIntent(scp, Money.Coins(0.011m)); + var btx1 = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy); await wallet.SendTransactionAsync(btx1.Transaction); - operations = new[] { new WalletService.Operation(scp, Money.Coins(0.012m), "") }; - var btx2 = wallet.BuildTransaction(password, operations, 2, allowUnconfirmed: true); + operations = new PaymentIntent(scp, Money.Coins(0.012m)); + var btx2 = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, allowUnconfirmed: true); await wallet.SendTransactionAsync(btx2.Transaction); // Test synchronization after fork. @@ -1454,12 +1468,12 @@ public async Task BuildTransactionReorgsTestAsync() // 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), "") }; - var btx3 = wallet.BuildTransaction(password, operations, 2); + operations = new PaymentIntent(scp, Money.Coins(0.013m)); + var btx3 = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy); await wallet.SendTransactionAsync(btx3.Transaction); - operations = new[] { new WalletService.Operation(scp, Money.Coins(0.014m), "") }; - var btx4 = wallet.BuildTransaction(password, operations, 2, allowUnconfirmed: true); + operations = new PaymentIntent(scp, Money.Coins(0.014m)); + var btx4 = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, allowUnconfirmed: true); await wallet.SendTransactionAsync(btx4.Transaction); // Test synchronization after fork with different transactions. @@ -1559,7 +1573,7 @@ public async Task SpendUnconfirmedTxTestAsync() var chaumianClient = new CcjClient(synchronizer, rpc.Network, keyManager, new Uri(RegTestFixture.BackendEndPoint), null); // 6. Create wallet service. - var workDir = Path.Combine(Global.Instance.DataDir, nameof(SendTestsFromHiddenWalletAsync)); + var workDir = Path.Combine(Global.Instance.DataDir, nameof(SpendUnconfirmedTxTestAsync)); var wallet = new WalletService(bitcoinStore, keyManager, synchronizer, chaumianClient, mempoolService, nodes, workDir, serviceConfiguration); wallet.NewFilterProcessed += Wallet_NewFilterProcessed; @@ -1597,19 +1611,17 @@ public async Task SpendUnconfirmedTxTestAsync() 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 tx1Res = wallet.BuildTransaction(password, operations, 2, allowUnconfirmed: true); - Assert.Equal(1, tx1Res.OuterWalletOutputs.Single(x => x.ScriptPubKey == key.P2wpkhScript).AnonymitySet); + 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, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, allowUnconfirmed: true); + Assert.Equal(2, tx1Res.InnerWalletOutputs.Count()); + Assert.Equal(2, tx1Res.OuterWalletOutputs.Count()); // Spend the unconfirmed coin (send it to ourself) - operations = new[] { new WalletService.Operation(key.PubKey.WitHash.ScriptPubKey, Money.Coins(0.5m), "") }; - tx1Res = wallet.BuildTransaction(password, operations, 2, allowUnconfirmed: true); + operations = new PaymentIntent(key.PubKey.WitHash.ScriptPubKey, Money.Coins(0.5m)); + tx1Res = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, allowUnconfirmed: true); await wallet.SendTransactionAsync(tx1Res.Transaction); while (wallet.Coins.Count != 3) @@ -1630,8 +1642,8 @@ public async Task SpendUnconfirmedTxTestAsync() 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), "") }; - var tx2Res = wallet.BuildTransaction(password, operations, 2, allowUnconfirmed: true, subtractFeeFromAmountIndex: 0); + operations = new PaymentIntent(key.PubKey.WitHash.ScriptPubKey, Money.Coins(0.5m), subtractFee: true); + var tx2Res = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, allowUnconfirmed: true); await wallet.SendTransactionAsync(tx2Res.Transaction); while (wallet.Coins.Count != 4) @@ -1674,7 +1686,7 @@ public async Task SpendUnconfirmedTxTestAsync() // 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), label: "bar"), FeeStrategy.TwentyMinutesConfirmationTargetStrategy, allowUnconfirmed: true); await wallet.SendTransactionAsync(res.Transaction); Assert.Equal(coinCount + 2, wallet.Coins.Count); Assert.Equal(2, wallet.Coins.Count(x => !x.Confirmed)); @@ -1729,7 +1741,7 @@ public async Task ReplaceByFeeTxTestAsync() var chaumianClient = new CcjClient(synchronizer, rpc.Network, keyManager, new Uri(RegTestFixture.BackendEndPoint), null); // 6. Create wallet service. - var workDir = Path.Combine(Global.Instance.DataDir, nameof(SendTestsFromHiddenWalletAsync)); + var workDir = Path.Combine(Global.Instance.DataDir, nameof(ReplaceByFeeTxTestAsync)); var wallet = new WalletService(bitcoinStore, keyManager, synchronizer, chaumianClient, mempoolService, nodes, workDir, serviceConfiguration); wallet.NewFilterProcessed += Wallet_NewFilterProcessed; diff --git a/WalletWasabi/Helpers/Constants.cs b/WalletWasabi/Helpers/Constants.cs index 0beb5cfbfc6..4f7f3afbbe4 100644 --- a/WalletWasabi/Helpers/Constants.cs +++ b/WalletWasabi/Helpers/Constants.cs @@ -76,11 +76,12 @@ public static BitcoinWitPubKeyAddress GetCoordinatorAddress(Network network) } } - public const int BigFileReadWriteBufferSize = 1 * 1024 * 1024; - + public const int TwentyMinutesConfirmationTarget = 2; public const int OneDayConfirmationTarget = 144; public const int SevenDaysConfirmationTarget = 1008; + public const int BigFileReadWriteBufferSize = 1 * 1024 * 1024; + public const int DefaultTorSocksPort = 9050; public const int DefaultTorBrowserSocksPort = 9150; public const int DefaultTorControlPort = 9051; diff --git a/WalletWasabi/Helpers/LabelBuilder.cs b/WalletWasabi/Helpers/LabelBuilder.cs new file mode 100644 index 00000000000..5d9a0b160dc --- /dev/null +++ b/WalletWasabi/Helpers/LabelBuilder.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace WalletWasabi.Helpers +{ + public class LabelBuilder + { + public List Labels { get; } = new List(); + + public LabelBuilder(params string[] labels) + { + labels = labels ?? new string[] { }; + foreach (var label in labels) + { + Add(label); + } + } + + public void Add(string label) + { + var parts = Guard.Correct(label).Split(',', StringSplitOptions.RemoveEmptyEntries); + foreach (var corrected in parts.Select(Guard.Correct)) + { + if (corrected == "") + { + return; + } + + if (!Labels.Contains(label)) + { + Labels.Add(label); + } + } + } + + public override string ToString() + { + return string.Join(", ", Labels); + } + } +} diff --git a/WalletWasabi/KeyManagement/HdPubKey.cs b/WalletWasabi/KeyManagement/HdPubKey.cs index 9d5b709d275..f0104b2e780 100644 --- a/WalletWasabi/KeyManagement/HdPubKey.cs +++ b/WalletWasabi/KeyManagement/HdPubKey.cs @@ -1,6 +1,7 @@ using NBitcoin; using Newtonsoft.Json; using System; +using System.Linq; using WalletWasabi.Helpers; using WalletWasabi.JsonConverters; @@ -114,6 +115,19 @@ public void SetKeyState(KeyState state, KeyManager kmToFile = null) public BitcoinScriptAddress GetP2shOverP2wpkhAddress(Network network) => P2wpkhScript.GetScriptAddress(network); + public bool ContainsScript(Script scriptPubKey) + { + var scripts = new[] + { + P2pkScript, + P2pkhScript, + P2wpkhScript, + P2shOverP2wpkhScript + }; + + return scripts.Contains(scriptPubKey); + } + #region Equality public override bool Equals(object obj) => obj is HdPubKey pubKey && this == pubKey; diff --git a/WalletWasabi/Models/SmartCoin.cs b/WalletWasabi/Models/SmartCoin.cs index f01504c070c..2abbba6ec37 100644 --- a/WalletWasabi/Models/SmartCoin.cs +++ b/WalletWasabi/Models/SmartCoin.cs @@ -329,7 +329,6 @@ private void Create(uint256 transactionId, uint index, Script scriptPubKey, Mone ScriptPubKey = Guard.NotNull(nameof(scriptPubKey), scriptPubKey); Amount = Guard.NotNull(nameof(amount), amount); Height = height; - Label = Guard.Correct(label); SpentOutputs = Guard.NotNullOrEmpty(nameof(spentOutputs), spentOutputs); IsReplaceable = replaceable; AnonymitySet = Guard.InRangeAndNotNull(nameof(anonymitySet), anonymitySet, 1, int.MaxValue); @@ -343,6 +342,9 @@ private void Create(uint256 transactionId, uint index, Script scriptPubKey, Mone HdPubKey = pubKey; + var labelBuilder = new LabelBuilder(label, pubKey?.Label); + Label = labelBuilder.ToString(); + SetConfirmed(); SetUnspent(); SetIsBanned(); diff --git a/WalletWasabi/Models/BuildTransactionResult.cs b/WalletWasabi/Models/TransactionBuilding/BuildTransactionResult.cs similarity index 96% rename from WalletWasabi/Models/BuildTransactionResult.cs rename to WalletWasabi/Models/TransactionBuilding/BuildTransactionResult.cs index 17c2554487a..54de2c3d56a 100644 --- a/WalletWasabi/Models/BuildTransactionResult.cs +++ b/WalletWasabi/Models/TransactionBuilding/BuildTransactionResult.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using WalletWasabi.Helpers; -namespace WalletWasabi.Models +namespace WalletWasabi.Models.TransactionBuilding { public class BuildTransactionResult { diff --git a/WalletWasabi/Models/TransactionBuilding/ChangeStrategy.cs b/WalletWasabi/Models/TransactionBuilding/ChangeStrategy.cs new file mode 100644 index 00000000000..d5bdf501664 --- /dev/null +++ b/WalletWasabi/Models/TransactionBuilding/ChangeStrategy.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace WalletWasabi.Models.TransactionBuilding +{ + public enum ChangeStrategy + { + Auto, + Custom, + AllRemainingCustom + } +} diff --git a/WalletWasabi/Models/TransactionBuilding/DestinationRequest.cs b/WalletWasabi/Models/TransactionBuilding/DestinationRequest.cs new file mode 100644 index 00000000000..f52bffb7818 --- /dev/null +++ b/WalletWasabi/Models/TransactionBuilding/DestinationRequest.cs @@ -0,0 +1,34 @@ +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, bool subtractFee = false, string label = "") : this(scriptPubKey, MoneyRequest.Create(amount, subtractFee), label) + { + } + + public DestinationRequest(Script scriptPubKey, MoneyRequest amount, string label = "") : this(scriptPubKey.GetDestination(), amount, label) + { + } + + public DestinationRequest(IDestination destination, Money amount, bool subtractFee = false, string label = "") : this(destination, MoneyRequest.Create(amount, subtractFee), 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); + } + } +} diff --git a/WalletWasabi/Models/TransactionBuilding/FeeStrategy.cs b/WalletWasabi/Models/TransactionBuilding/FeeStrategy.cs new file mode 100644 index 00000000000..382abb7f867 --- /dev/null +++ b/WalletWasabi/Models/TransactionBuilding/FeeStrategy.cs @@ -0,0 +1,79 @@ +using NBitcoin; +using System; +using System.Collections.Generic; +using System.Text; +using WalletWasabi.Helpers; + +namespace WalletWasabi.Models.TransactionBuilding +{ + public class FeeStrategy + { + private int _target; + private FeeRate _rate; + + public FeeStrategyType Type { get; } + + public int Target + { + get + { + if (Type != FeeStrategyType.Target) + { + throw new NotSupportedException($"Cannot get {nameof(Target)} with {nameof(FeeStrategyType)} {Type}."); + } + return _target; + } + } + + public FeeRate Rate + { + get + { + if (Type != FeeStrategyType.Rate) + { + throw new NotSupportedException($"Cannot get {nameof(Rate)} with {nameof(FeeStrategyType)} {Type}."); + } + return _rate; + } + } + + public static FeeStrategy TwentyMinutesConfirmationTargetStrategy { get; } = CreateFromConfirmationTarget(Constants.TwentyMinutesConfirmationTarget); + public static FeeStrategy OneDayConfirmationTargetStrategy { get; } = CreateFromConfirmationTarget(Constants.OneDayConfirmationTarget); + public static FeeStrategy SevenDaysConfirmationTargetStrategy { get; } = CreateFromConfirmationTarget(Constants.SevenDaysConfirmationTarget); + + public static FeeStrategy CreateFromConfirmationTarget(int confirmationTarget) => new FeeStrategy(FeeStrategyType.Target, confirmationTarget: confirmationTarget, feeRate: null); + + public static FeeStrategy CreateFromFeeRate(FeeRate feeRate) => new FeeStrategy(FeeStrategyType.Rate, confirmationTarget: null, feeRate: feeRate); + + private FeeStrategy(FeeStrategyType type, int? confirmationTarget, FeeRate feeRate) + { + Type = type; + if (type == FeeStrategyType.Rate) + { + if (confirmationTarget != null) + { + throw new ArgumentException($"{nameof(confirmationTarget)} must be null."); + } + Guard.NotNull(nameof(feeRate), feeRate); + if (feeRate < new FeeRate(1m)) + { + throw new ArgumentOutOfRangeException(nameof(feeRate), feeRate, "Cannot be less than 1 sat/byte."); + } + _rate = feeRate; + } + else if (type == FeeStrategyType.Target) + { + if (feeRate != null) + { + throw new ArgumentException($"{nameof(feeRate)} must be null."); + } + Guard.NotNull(nameof(confirmationTarget), confirmationTarget); + _target = Guard.InRangeAndNotNull(nameof(confirmationTarget), confirmationTarget.Value, Constants.TwentyMinutesConfirmationTarget, Constants.SevenDaysConfirmationTarget); + } + else + { + throw new NotSupportedException(type.ToString()); + } + } + } +} diff --git a/WalletWasabi/Models/TransactionBuilding/FeeStrategyType.cs b/WalletWasabi/Models/TransactionBuilding/FeeStrategyType.cs new file mode 100644 index 00000000000..eec4abca73e --- /dev/null +++ b/WalletWasabi/Models/TransactionBuilding/FeeStrategyType.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace WalletWasabi.Models.TransactionBuilding +{ + public enum FeeStrategyType + { + Target, + Rate + } +} diff --git a/WalletWasabi/Models/TransactionBuilding/MoneyRequest.cs b/WalletWasabi/Models/TransactionBuilding/MoneyRequest.cs new file mode 100644 index 00000000000..d320b98d1c0 --- /dev/null +++ b/WalletWasabi/Models/TransactionBuilding/MoneyRequest.cs @@ -0,0 +1,50 @@ +using NBitcoin; +using System; +using System.Collections.Generic; +using System.Text; + +namespace WalletWasabi.Models.TransactionBuilding +{ + public class MoneyRequest + { + public static MoneyRequest Create(Money amount, bool subtractFee = false) => new MoneyRequest(amount, MoneyRequestType.Value, subtractFee); + + public static MoneyRequest CreateChange(bool subtractFee = true) => new MoneyRequest(null, MoneyRequestType.Change, subtractFee); + + public static MoneyRequest CreateAllRemaining(bool subtractFee = true) => new MoneyRequest(null, MoneyRequestType.AllRemaining, subtractFee); + + public Money Amount { get; } + public MoneyRequestType Type { get; } + public bool SubtractFee { get; } + + private MoneyRequest(Money amount, MoneyRequestType type, bool subtractFee) + { + if (type == MoneyRequestType.AllRemaining || type == MoneyRequestType.Change) + { + 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; + SubtractFee = subtractFee; + } + } +} diff --git a/WalletWasabi/Models/TransactionBuilding/MoneyRequestType.cs b/WalletWasabi/Models/TransactionBuilding/MoneyRequestType.cs new file mode 100644 index 00000000000..d7148ba74bf --- /dev/null +++ b/WalletWasabi/Models/TransactionBuilding/MoneyRequestType.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace WalletWasabi.Models.TransactionBuilding +{ + public enum MoneyRequestType + { + Value, + Change, + AllRemaining + } +} diff --git a/WalletWasabi/Models/TransactionBuilding/PaymentIntent.cs b/WalletWasabi/Models/TransactionBuilding/PaymentIntent.cs new file mode 100644 index 00000000000..278e382a1a1 --- /dev/null +++ b/WalletWasabi/Models/TransactionBuilding/PaymentIntent.cs @@ -0,0 +1,103 @@ +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 Requests { get; } + public ChangeStrategy ChangeStrategy { get; } + public Money TotalAmount { get; } + public int Count => Requests.Count(); + + public PaymentIntent(Script scriptPubKey, Money amount, bool subtractFee = false, string label = "") : this(scriptPubKey, MoneyRequest.Create(amount, subtractFee), label) + { + } + + public PaymentIntent(Script scriptPubKey, MoneyRequest amount, string label = "") : this(scriptPubKey.GetDestination(), amount, label) + { + } + + public PaymentIntent(IDestination destination, Money amount, bool subtractFee = false, string label = "") : this(destination, MoneyRequest.Create(amount, subtractFee), label) + { + } + + public PaymentIntent(IDestination destination, MoneyRequest amount, string label = "") : this(new DestinationRequest(destination, amount, label)) + { + } + + public PaymentIntent(params DestinationRequest[] requests) : this(requests as IEnumerable) + { + } + + public PaymentIntent(IEnumerable requests) + { + Guard.NotNullOrEmpty(nameof(requests), requests); + foreach (var request in requests) + { + Guard.NotNull(nameof(request), request); + } + + var subtractFeeCount = requests.Count(x => x.Amount.SubtractFee); + if (subtractFeeCount > 1) + { + // Note: It'd be possible tod implement that the fees to be subtracted equally from more outputs, but I guess nobody would use it. + throw new ArgumentException($"Only one request can specify fee subtraction."); + } + + var allRemainingCount = requests.Count(x => x.Amount.Type == MoneyRequestType.AllRemaining); + var changeCount = requests.Count(x => x.Amount.Type == MoneyRequestType.Change); + int specialCount = allRemainingCount + changeCount; + if (specialCount == 0) + { + ChangeStrategy = ChangeStrategy.Auto; + } + else if (specialCount == 1) + { + if (subtractFeeCount != 1) + { + throw new ArgumentException($"You must specifiy fee subtraction strategy if custom change is specified."); + } + + if (allRemainingCount == 1) + { + ChangeStrategy = ChangeStrategy.AllRemainingCustom; + } + else if (changeCount == 1) + { + ChangeStrategy = ChangeStrategy.Custom; + } + else + { + throw new NotSupportedException("This is impossible."); + } + } + else + { + throw new ArgumentException($"Only one request can contain an all remaining money or change request."); + } + + Requests = requests; + + TotalAmount = requests.Where(x => x.Amount.Type == MoneyRequestType.Value).Sum(x => x.Amount.Amount); + } + + public bool TryGetCustomRequest(out DestinationRequest request) + { + request = Requests.SingleOrDefault(x => x.Amount.Type == MoneyRequestType.Change || x.Amount.Type == MoneyRequestType.AllRemaining); + + return request != null; + } + + public bool TryGetFeeSubtractionRequest(out DestinationRequest request) + { + request = Requests.SingleOrDefault(x => x.Amount.SubtractFee); + + return request != null; + } + } +} diff --git a/WalletWasabi/Models/TransactionBuilding/SmartCoinSelector.cs b/WalletWasabi/Models/TransactionBuilding/SmartCoinSelector.cs new file mode 100644 index 00000000000..9376fe6bc38 --- /dev/null +++ b/WalletWasabi/Models/TransactionBuilding/SmartCoinSelector.cs @@ -0,0 +1,103 @@ +using NBitcoin; +using System.Linq; +using System; +using System.Collections.Generic; +using System.Text; +using WalletWasabi.Models; +using WalletWasabi.Exceptions; +using WalletWasabi.Helpers; + +namespace WalletWasabi.Models.TransactionBuilding +{ + public class SmartCoinSelector : ICoinSelector + { + private Dictionary SmartCoinsByOutpoint { get; } + + public SmartCoinSelector(Dictionary smartCoinsByOutpoint) + { + SmartCoinsByOutpoint = Guard.NotNull(nameof(smartCoinsByOutpoint), smartCoinsByOutpoint); + } + + public IEnumerable Select(IEnumerable coins, IMoney target) + { + var coinsByOutpoint = coins.ToDictionary(c => c.Outpoint); + var totalOutAmount = (Money)target; + var unspentCoins = coins.Select(c => SmartCoinsByOutpoint[c.Outpoint]).ToArray(); + var coinsToSpend = new HashSet(); + var unspentConfirmedCoins = new List(); + var unspentUnconfirmedCoins = new List(); + foreach (SmartCoin coin in unspentCoins) + { + if (coin.Confirmed) + { + unspentConfirmedCoins.Add(coin); + } + else + { + unspentUnconfirmedCoins.Add(coin); + } + } + + bool haveEnough = TrySelectCoins(coinsToSpend, totalOutAmount, unspentConfirmedCoins); + if (!haveEnough) + { + haveEnough = TrySelectCoins(coinsToSpend, totalOutAmount, unspentUnconfirmedCoins); + } + + if (!haveEnough) + { + throw new InsufficientBalanceException(totalOutAmount, unspentConfirmedCoins.Select(x => x.Amount).Sum() + unspentUnconfirmedCoins.Select(x => x.Amount).Sum()); + } + + return coinsToSpend.Select(c => coinsByOutpoint[c.GetOutPoint()]); + } + + /// If the selection was successful. If there's enough coins to spend from. + private bool TrySelectCoins(HashSet coinsToSpend, Money totalOutAmount, IEnumerable unspentCoins) + { + // If there's no need for input merging, then use the largest selected. + // Do not prefer anonymity set. You can assume the user prefers anonymity set manually through the GUI. + SmartCoin largestCoin = unspentCoins.OrderByDescending(x => x.Amount).FirstOrDefault(); + if (largestCoin == default) + { + return false; // If there's no coin then unsuccessful selection. + } + else // Check if we can do without input merging. + { + var largestCoins = unspentCoins.Where(x => x.ScriptPubKey == largestCoin.ScriptPubKey); + + if (largestCoins.Sum(x => x.Amount) >= totalOutAmount) + { + foreach (var c in largestCoins) + { + coinsToSpend.Add(c); + } + return true; + } + } + + // If there's a need for input merging. + foreach (var coin in unspentCoins + .OrderByDescending(x => x.AnonymitySet) // Always try to spend/merge the largest anonset coins first. + .ThenByDescending(x => x.Amount)) // Then always try to spend by amount. + { + coinsToSpend.Add(coin); + // If reaches the amount, then return true, else just go with the largest coin. + if (coinsToSpend.Select(x => x.Amount).Sum() >= totalOutAmount) + { + // Add if we can find address reuse. + foreach (var c in unspentCoins + .Except(coinsToSpend) // So we're choosing from the non selected coins. + .Where(x => coinsToSpend.Any(y => y.ScriptPubKey == x.ScriptPubKey)))// Where the selected coins contains the same script. + { + coinsToSpend.Add(c); + } + + return true; + } + } + + return false; + } + } +} diff --git a/WalletWasabi/Services/WalletService.cs b/WalletWasabi/Services/WalletService.cs index 32d33130a6e..4bfa0583c46 100644 --- a/WalletWasabi/Services/WalletService.cs +++ b/WalletWasabi/Services/WalletService.cs @@ -25,6 +25,7 @@ using WalletWasabi.KeyManagement; using WalletWasabi.Logging; using WalletWasabi.Models; +using WalletWasabi.Models.TransactionBuilding; using WalletWasabi.Stores; using WalletWasabi.WebClients.Wasabi; @@ -635,7 +636,7 @@ public async Task GetOrDownloadBlockAsync(uint256 hash, CancellationToken // Try to get block information from local running Core node first. try { - if (LocalBitcoinCoreNode is null || !LocalBitcoinCoreNode.IsConnected && Network != Network.RegTest) // If RegTest then we're already connected do not try again. + if (LocalBitcoinCoreNode is null || (!LocalBitcoinCoreNode.IsConnected && Network != Network.RegTest)) // If RegTest then we're already connected do not try again. { DisconnectDisposeNullLocalBitcoinCoreNode(); using (var handshakeTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancel)) @@ -656,32 +657,34 @@ public async Task GetOrDownloadBlockAsync(uint256 hash, CancellationToken } var localEndPoint = ServiceConfiguration.BitcoinCoreEndPoint; - var localNode = await Node.ConnectAsync(Network, localEndPoint, nodeConnectionParameters); - try + using (var localNode = await Node.ConnectAsync(Network, localEndPoint, nodeConnectionParameters)) { - Logger.LogInfo($"TCP Connection succeeded, handshaking..."); - localNode.VersionHandshake(Constants.LocalNodeRequirements, handshakeTimeout.Token); - var peerServices = localNode.PeerVersion.Services; - - //if (!peerServices.HasFlag(NodeServices.Network) && !peerServices.HasFlag(NodeServices.NODE_NETWORK_LIMITED)) - //{ - // throw new InvalidOperationException($"Wasabi cannot use the local node because it does not provide blocks."); - //} - - Logger.LogInfo($"Handshake completed successfully."); - - if (!localNode.IsConnected) + try { - throw new InvalidOperationException($"Wasabi could not complete the handshake with the local node and dropped the connection.{Environment.NewLine}" + - $"Probably this is because the node does not support retrieving full blocks or segwit serialization."); + Logger.LogInfo($"TCP Connection succeeded, handshaking..."); + localNode.VersionHandshake(Constants.LocalNodeRequirements, handshakeTimeout.Token); + var peerServices = localNode.PeerVersion.Services; + + //if (!peerServices.HasFlag(NodeServices.Network) && !peerServices.HasFlag(NodeServices.NODE_NETWORK_LIMITED)) + //{ + // throw new InvalidOperationException($"Wasabi cannot use the local node because it does not provide blocks."); + //} + + Logger.LogInfo($"Handshake completed successfully."); + + if (!localNode.IsConnected) + { + throw new InvalidOperationException($"Wasabi could not complete the handshake with the local node and dropped the connection.{Environment.NewLine}" + + $"Probably this is because the node does not support retrieving full blocks or segwit serialization."); + } + LocalBitcoinCoreNode = localNode; + } + catch (OperationCanceledException) when (handshakeTimeout.IsCancellationRequested) + { + Logger.LogWarning($"Wasabi could not complete the handshake with the local node. Probably Wasabi is not whitelisted by the node.{Environment.NewLine}" + + $"Use \"whitebind\" in the node configuration. (Typically whitebind=127.0.0.1:8333 if Wasabi and the node are on the same machine and whitelist=1.2.3.4 if they are not.)"); + throw; } - LocalBitcoinCoreNode = localNode; - } - catch (OperationCanceledException) when (handshakeTimeout.IsCancellationRequested) - { - Logger.LogWarning($"Wasabi could not complete the handshake with the local node. Probably Wasabi is not whitelisted by the node.{Environment.NewLine}" + - $"Use \"whitebind\" in the node configuration. (Typically whitebind=127.0.0.1:8333 if Wasabi and the node are on the same machine and whitelist=1.2.3.4 if they are not.)"); - throw; } } } @@ -866,92 +869,24 @@ public async Task CountBlocksAsync() } } - 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 ?? ""; - } - } - - /// If Money.Zero then spends all available amount. Does not generate change. /// Allow to spend unconfirmed transactions, if necessary. /// Only these inputs allowed to be used to build the transaction. The wallet must know the corresponding private keys. - /// If null, fee is substracted from the change. Otherwise it denotes the index in the toSend array. - /// The target number of blocks to estimate the fee. Null if feeRate is being used instead. - /// The fee rate for the transaction. Null if feeTarget is being used instead. /// /// /// public BuildTransactionResult BuildTransaction(string password, - Operation[] toSend, - int? feeTarget = null, - FeeRate feeRate = null, + PaymentIntent payments, + FeeStrategy feeStrategy, bool allowUnconfirmed = false, - int? subtractFeeFromAmountIndex = null, - Script customChange = null, IEnumerable allowedInputs = null) { password = password ?? ""; // Correction. - toSend = Guard.NotNullOrEmpty(nameof(toSend), toSend); - - if ((feeRate is null && feeTarget is null) - || (feeRate != null && feeTarget != null)) - { - throw new NotSupportedException($"Either {nameof(feeRate)} or {nameof(feeTarget)} must be set and the other should be null."); - } + payments = Guard.NotNull(nameof(payments), payments); - if (toSend.Any(x => x is null)) - { - throw new ArgumentNullException($"{nameof(toSend)} cannot contain null element."); - } - if (toSend.Any(x => x.Amount < Money.Zero)) + long totalAmount = payments.TotalAmount.Satoshi; + if (totalAmount < 0 || totalAmount > Constants.MaximumNumberOfSatoshis) { - 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)) - { - throw new ArgumentException($"{nameof(customChange)} and send all to destination cannot be specified at the same time."); - } - - if (feeTarget.HasValue) - { - Guard.InRangeAndNotNull(nameof(feeTarget), feeTarget.Value, 0, Constants.SevenDaysConfirmationTarget); // Allow 0 and 1, and correct later. - - if (feeTarget < 2) // Correct 0 and 1 to 2. - { - feeTarget = 2; - } - } - - if (subtractFeeFromAmountIndex != null) // If not null, make sure not out of range. If null fee is substracted from the change. - { - if (subtractFeeFromAmountIndex < 0) - { - throw new ArgumentOutOfRangeException($"{nameof(subtractFeeFromAmountIndex)} cannot be smaller than 0."); - } - if (subtractFeeFromAmountIndex > toSend.Length - 1) - { - 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(payments)}.{nameof(payments.TotalAmount)} sum cannot be smaller than 0 or greater than {Constants.MaximumNumberOfSatoshis}."); } // Get allowed coins to spend. @@ -966,120 +901,142 @@ public Operation(Script script, Money amount, string label) allowedSmartCoinInputs = allowUnconfirmed ? Coins.Where(x => !x.Unavailable && allowedInputs.Any(y => y.TransactionId == x.TransactionId && y.Index == x.Index)).ToList() : Coins.Where(x => !x.Unavailable && x.Confirmed && allowedInputs.Any(y => y.TransactionId == x.TransactionId && y.Index == x.Index)).ToList(); + + // Add those that have the same script, because common ownership is already exposed. + // But only if the user didn't click the "max" button. In this case he'd send more money than what he'd think. + if (payments.ChangeStrategy != ChangeStrategy.AllRemainingCustom) + { + var allScripts = allowedSmartCoinInputs.Select(x => x.ScriptPubKey).ToHashSet(); + foreach (var coin in Coins.Where(x => !x.Unavailable && !allowedSmartCoinInputs.Any(y => x.TransactionId == y.TransactionId && x.Index == y.Index))) + { + if (!(allowUnconfirmed || coin.Confirmed)) + { + continue; + } + + if (allScripts.Contains(coin.ScriptPubKey)) + { + allowedSmartCoinInputs.Add(coin); + } + } + } } else { allowedSmartCoinInputs = allowUnconfirmed ? Coins.Where(x => !x.Unavailable).ToList() : Coins.Where(x => !x.Unavailable && x.Confirmed).ToList(); } - // 4. Get and calculate fee + // Get and calculate fee Logger.LogInfo("Calculating dynamic transaction fee..."); - FeeRate feePerBytes = (feeTarget.HasValue) ? Synchronizer.GetFeeRate(feeTarget.Value) : feeRate; - - feePerBytes = feePerBytes.SatoshiPerByte < 1 ? new FeeRate(1m) : feePerBytes; // Use the sanity check that under 2 satoshi per bytes should not be displayed. To correct possible rounding errors. - - bool spendAll = spendAllCount == 1; - int inNum; - if (spendAll) + FeeRate feeRate; + if (feeStrategy.Type == FeeStrategyType.Target) + { + feeRate = Synchronizer.GetFeeRate(feeStrategy.Target); + } + else if (feeStrategy.Type == FeeStrategyType.Rate) { - inNum = allowedSmartCoinInputs.Count; + feeRate = feeStrategy.Rate; } else { - int expectedMinTxSize = 1 * Constants.P2wpkhInputSizeInBytes + 1 * Constants.OutputSizeInBytes + 10; - inNum = SelectCoinsToSpend(allowedSmartCoinInputs, toSend.Select(x => x.Amount).Sum() + feePerBytes.GetTotalFee(expectedMinTxSize)).Count(); + throw new NotSupportedException(feeStrategy.Type.ToString()); } - // https://bitcoincore.org/en/segwit_wallet_dev/#transaction-fee-estimation - // https://bitcoin.stackexchange.com/a/46379/26859 - int outNum = spendAll ? toSend.Length : toSend.Length + 1; // number of addresses to send + 1 for change - int vSize = NBitcoinHelpers.CalculateVsizeAssumeSegwit(inNum, outNum); - Logger.LogInfo($"Estimated tx size: {vSize} vbytes."); + var smartCoinsByOutpoint = allowedSmartCoinInputs.ToDictionary(s => s.GetOutPoint()); + TransactionBuilder builder = Network.CreateTransactionBuilder(); + builder.SetCoinSelector(new SmartCoinSelector(smartCoinsByOutpoint)); + builder.AddCoins(allowedSmartCoinInputs.Select(c => c.GetCoin())); - // Multiply the standard by 1.1 to avoid possible off by one errors in flawed implementations. - var minFeeRate = new FeeRate(1.1m * new StandardTransactionPolicy().MinRelayTxFee.SatoshiPerByte); - if (feePerBytes < minFeeRate) + foreach (var request in payments.Requests.Where(x => x.Amount.Type == MoneyRequestType.Value)) { - feePerBytes = minFeeRate; - } + var amountRequest = request.Amount; - Money fee = feePerBytes.GetTotalFee(vSize); + builder.Send(request.Destination, amountRequest.Amount); + if (amountRequest.SubtractFee) + { + builder.SubtractFees(); + } + } - Logger.LogInfo($"Fee: {fee.Satoshi} Satoshi."); + HdPubKey changeHdPubKey = null; - // 5. How much to spend? - long toSendAmountSumInSatoshis = toSend.Select(x => x.Amount).Sum(); // Does it work if I simply go with Money class here? Is that copied by reference of value? - var realToSend = new (Script script, Money amount, string label)[toSend.Length]; - for (int i = 0; i < toSend.Length; i++) // clone - { - realToSend[i] = ( - new Script(toSend[i].Script.ToString()), - new Money(toSend[i].Amount.Satoshi), - toSend[i].Label); - } - for (int i = 0; i < realToSend.Length; i++) + if (payments.TryGetCustomRequest(out DestinationRequest custChange)) { - if (realToSend[i].amount == Money.Zero) // means spend all - { - realToSend[i].amount = allowedSmartCoinInputs.Select(x => x.Amount).Sum(); - - realToSend[i].amount -= new Money(toSendAmountSumInSatoshis); + var changeScript = custChange.Destination.ScriptPubKey; + changeHdPubKey = KeyManager.GetKeyForScriptPubKey(changeScript); - if (subtractFeeFromAmountIndex is null) - { - realToSend[i].amount -= fee; - } + var changeStrategy = payments.ChangeStrategy; + if (changeStrategy == ChangeStrategy.Custom) + { + builder.SetChange(changeScript); } - - if (subtractFeeFromAmountIndex == i) + else if (changeStrategy == ChangeStrategy.AllRemainingCustom) { - realToSend[i].amount -= fee; + builder.SendAllRemaining(changeScript); } - - if (realToSend[i].amount < Money.Zero) + else { - throw new InsufficientBalanceException(fee + Money.Satoshis(1), realToSend[i].amount + fee); + throw new NotSupportedException(payments.ChangeStrategy.ToString()); } } + else + { + KeyManager.AssertCleanKeysIndexed(isInternal: true); + KeyManager.AssertLockedInternalKeysIndexed(14); + changeHdPubKey = KeyManager.GetKeys(KeyState.Clean, true).RandomElement(); + + builder.SetChange(changeHdPubKey.P2wpkhScript); + } - var toRemoveList = new List<(Script script, Money money, string label)>(realToSend); - toRemoveList.RemoveAll(x => x.money == Money.Zero); - realToSend = toRemoveList.ToArray(); + builder.SendEstimatedFees(feeRate); - // 1. Get the possible changes. - Script changeScriptPubKey; - var sb = new StringBuilder(); - foreach (var item in realToSend) + var psbt = builder.BuildPSBT(false); + + var spentCoins = psbt.Inputs.Select(txin => smartCoinsByOutpoint[txin.PrevOut]).ToArray(); + + 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); + + if (!psbt.TryGetFee(out var fee)) { - var corrected = Guard.Correct(item.label); - sb.Append($"{corrected}, "); + throw new InvalidOperationException($"Impossible to get the fees of the PSBT, this should never happen."); } - var changeLabel = sb.ToString().TrimEnd(',', ' '); + Logger.LogInfo($"Fee: {fee.Satoshi} Satoshi."); + + var vSize = builder.EstimateSize(psbt.GetOriginalTransaction(), true); + Logger.LogInfo($"Estimated tx size: {vSize} vbytes."); - if (customChange is null) + // Do some checks + Money totalSendAmountNoFee = realToSend.Sum(x => x.amount); + if (totalSendAmountNoFee == Money.Zero) { - KeyManager.AssertCleanKeysIndexed(isInternal: true); - KeyManager.AssertLockedInternalKeysIndexed(14); - var changeHdPubKey = KeyManager.GetKeys(KeyState.Clean, true).RandomElement(); + throw new InvalidOperationException($"The amount after subtracting the fee is too small to be sent."); + } + Money totalSendAmount = totalSendAmountNoFee + fee; - changeHdPubKey.SetLabel(changeLabel, KeyManager); - changeScriptPubKey = changeHdPubKey.P2wpkhScript; + Money totalOutgoingAmountNoFee; + if (changeHdPubKey is null) + { + totalOutgoingAmountNoFee = totalSendAmountNoFee; } else { - changeScriptPubKey = customChange; + totalOutgoingAmountNoFee = realToSend.Where(x => !changeHdPubKey.ContainsScript(x.destination.ScriptPubKey)).Sum(x => x.amount); } - - // 6. Do some checks - Money totalOutgoingAmountNoFee = realToSend.Select(x => x.amount).Sum(); - Money totalOutgoingAmount = totalOutgoingAmountNoFee + fee; - decimal feePc = (100 * fee.ToDecimal(MoneyUnit.BTC)) / totalOutgoingAmountNoFee.ToDecimal(MoneyUnit.BTC); + decimal totalOutgoingAmountNoFeeDecimal = totalOutgoingAmountNoFee.ToDecimal(MoneyUnit.BTC); + // Cannot divide by zero, so use the closest number we have to zero. + decimal totalOutgoingAmountNoFeeDecimalDivisor = totalOutgoingAmountNoFeeDecimal == 0 ? decimal.MinValue : totalOutgoingAmountNoFeeDecimal; + decimal feePc = (100 * fee.ToDecimal(MoneyUnit.BTC)) / totalOutgoingAmountNoFeeDecimalDivisor; if (feePc > 1) { - Logger.LogInfo($"The transaction fee is {feePc:0.#}% of your transaction amount.{Environment.NewLine}" - + $"Sending:\t {totalOutgoingAmount.ToString(fplus: false, trimExcessZero: true)} BTC.{Environment.NewLine}" + Logger.LogInfo($"The transaction fee is {totalOutgoingAmountNoFee:0.#}% of your transaction amount.{Environment.NewLine}" + + $"Sending:\t {totalSendAmount.ToString(fplus: false, trimExcessZero: true)} BTC.{Environment.NewLine}" + $"Fee:\t\t {fee.Satoshi} Satoshi."); } if (feePc > 100) @@ -1087,179 +1044,104 @@ public Operation(Script script, Money amount, string label) throw new InvalidOperationException($"The transaction fee is more than twice as much as your transaction amount: {feePc:0.#}%."); } - var confirmedAvailableAmount = allowedSmartCoinInputs.Where(x => x.Confirmed).Select(x => x.Amount).Sum(); - var spendsUnconfirmed = false; - if (confirmedAvailableAmount < totalOutgoingAmount) + if (spentCoins.Any(u => !u.Confirmed)) { - spendsUnconfirmed = true; - Logger.LogInfo("Unconfirmed transaction are being spent."); + Logger.LogInfo("Unconfirmed transaction is spent."); } - // 7. Select coins - Logger.LogInfo("Selecting coins..."); - IEnumerable coinsToSpend = SelectCoinsToSpend(allowedSmartCoinInputs, totalOutgoingAmount); - - // 9. Build the transaction + // Build the transaction Logger.LogInfo("Signing transaction..."); - TransactionBuilder builder = Network.CreateTransactionBuilder(); // It must be watch only, too, because if we have the key and also hardware wallet, we do not care we can sign. - bool sign = !KeyManager.IsWatchOnly; - if (sign) - { - // 8. Get signing keys - IEnumerable signingKeys = KeyManager.GetSecrets(password, coinsToSpend.Select(x => x.ScriptPubKey).ToArray()); - builder = builder - .AddCoins(coinsToSpend.Select(x => x.GetCoin())) - .AddKeys(signingKeys.ToArray()); - } - else + Transaction tx = null; + if (KeyManager.IsWatchOnly) { - builder = builder - .AddCoins(coinsToSpend.Select(x => x.GetCoin())); + tx = psbt.GetGlobalTransaction(); } - - foreach ((Script scriptPubKey, Money amount, string label) output in realToSend) + else { - builder = builder.Send(output.scriptPubKey, output.amount); - } + IEnumerable signingKeys = KeyManager.GetSecrets(password, spentCoins.Select(x => x.ScriptPubKey).ToArray()); + builder = builder.AddKeys(signingKeys.ToArray()); + builder.SignPSBT(psbt); + psbt.Finalize(); + tx = psbt.ExtractTransaction(); - Transaction tx = builder - .SetChange(changeScriptPubKey) - .SendFees(fee) - .BuildTransaction(sign); + var checkResults = builder.Check(tx).ToList(); + if (!psbt.TryGetEstimatedFeeRate(out FeeRate actualFeeRate)) + { + throw new InvalidOperationException($"Impossible to get the fee rate of the PSBT, this should never happen."); + } - if (sign) - { - TransactionPolicyError[] checkResults = builder.Check(tx, fee); - if (checkResults.Length > 0) + // Manually check the feerate, because some inaccuracy is possible. + var sb1 = feeRate.SatoshiPerByte; + var sb2 = actualFeeRate.SatoshiPerByte; + if (Math.Abs(sb1 - sb2) > 2) // 2s/b inaccuracy ok. + { + // So it'll generate a transactionpolicy error thrown below. + checkResults.Add(new NotEnoughFundsPolicyError("Fees different than expected")); + } + if (checkResults.Count > 0) { throw new InvalidTxException(tx, checkResults); } } - List spentCoins = Coins.Where(x => tx.Inputs.Any(y => y.PrevOut.Hash == x.TransactionId && y.PrevOut.N == x.Index)).ToList(); + if (KeyManager.MasterFingerprint is HDFingerprint fp) + { + foreach (var coin in spentCoins) + { + var rootKeyPath = new RootedKeyPath(fp, coin.HdPubKey.FullKeyPath); + psbt.AddKeyPath(coin.HdPubKey.PubKey, rootKeyPath, coin.ScriptPubKey); + } + } + var labelBuilder = new LabelBuilder(); + foreach (var label in payments.Requests.Select(x => x.Label)) + { + labelBuilder.Add(label); + } var outerWalletOutputs = new List(); var innerWalletOutputs = new List(); for (var i = 0U; i < tx.Outputs.Count; i++) { TxOut output = tx.Outputs[i]; var anonset = (tx.GetAnonymitySet(i) + spentCoins.Min(x => x.AnonymitySet)) - 1; // Minus 1, because count own only once. - var foundKey = KeyManager.GetKeys(KeyState.Clean).FirstOrDefault(x => output.ScriptPubKey == x.P2wpkhScript); + var foundKey = KeyManager.GetKeyForScriptPubKey(output.ScriptPubKey); var coin = new SmartCoin(tx.GetHash(), i, output.ScriptPubKey, output.Value, tx.Inputs.ToTxoRefs().ToArray(), Height.Unknown, tx.RBF, anonset, isLikelyCoinJoinOutput: false, pubKey: foundKey); + labelBuilder.Add(coin.Label); // foundKey's label is already added to the coinlabel. - if (foundKey != null) + if (foundKey is null) { - coin.Label = changeLabel; - innerWalletOutputs.Add(coin); + outerWalletOutputs.Add(coin); } else { - outerWalletOutputs.Add(coin); + innerWalletOutputs.Add(coin); } } - PSBT psbt = builder.BuildPSBT(sign); - HashSet allTxCoins = spentCoins.Concat(innerWalletOutputs).Concat(outerWalletOutputs).ToHashSet(); - foreach (var coin in allTxCoins) + foreach (var coin in outerWalletOutputs.Concat(innerWalletOutputs)) { - if (coin.HdPubKey != null) - { - var index = -1; - var isInput = false; - for (int i = 0; i < tx.Inputs.Count; i++) - { - var input = tx.Inputs[i]; - if (input.PrevOut == coin.GetOutPoint()) - { - index = i; - isInput = true; - break; - } - } - if (!isInput) - { - index = (int)coin.Index; - } - - if (KeyManager.MasterFingerprint.HasValue) - { - var rootKeyPath = new RootedKeyPath(KeyManager.MasterFingerprint.Value, coin.HdPubKey.FullKeyPath); - psbt.AddKeyPath(coin.HdPubKey.PubKey, rootKeyPath, coin.ScriptPubKey); - } - } - } + var foundPaymentRequest = payments.Requests.FirstOrDefault(x => x.Destination.ScriptPubKey == coin.ScriptPubKey); - Logger.LogInfo($"Transaction is successfully built: {tx.GetHash()}."); - - return new BuildTransactionResult(new SmartTransaction(tx, Height.Unknown), psbt, spendsUnconfirmed, sign, fee, feePc, outerWalletOutputs, innerWalletOutputs, spentCoins); - } - - private IEnumerable SelectCoinsToSpend(IEnumerable unspentCoins, Money totalOutAmount) - { - var coinsToSpend = new HashSet(); - var unspentConfirmedCoins = new List(); - var unspentUnconfirmedCoins = new List(); - foreach (SmartCoin coin in unspentCoins) - { - if (coin.Confirmed) + // If change then we concatenate all the labels. + if (foundPaymentRequest is null) // Then it's autochange. { - unspentConfirmedCoins.Add(coin); + coin.Label = labelBuilder.ToString(); } else { - unspentUnconfirmedCoins.Add(coin); - } - } - - bool haveEnough = TrySelectCoins(ref coinsToSpend, totalOutAmount, unspentConfirmedCoins); - if (!haveEnough) - { - haveEnough = TrySelectCoins(ref coinsToSpend, totalOutAmount, unspentUnconfirmedCoins); - } - - if (!haveEnough) - { - throw new InsufficientBalanceException(totalOutAmount, unspentConfirmedCoins.Select(x => x.Amount).Sum() + unspentUnconfirmedCoins.Select(x => x.Amount).Sum()); - } - - return coinsToSpend; - } - - /// If the selection was successful. If there's enough coins to spend from. - private bool TrySelectCoins(ref HashSet coinsToSpend, Money totalOutAmount, IEnumerable unspentCoins) - { - // If there's no need for input merging, then use the largest selected. - // Do not prefer anonymity set. You can assume the user prefers anonymity set manually through the GUI. - SmartCoin largestCoin = unspentCoins.OrderByDescending(x => x.Amount).FirstOrDefault(); - if (largestCoin == default) - { - return false; // If there's no coin then unsuccessful selection. - } - else // Check if we can do without input merging. - { - if (largestCoin.Amount >= totalOutAmount) - { - coinsToSpend.Add(largestCoin); - return true; + coin.Label = new LabelBuilder(coin.Label, foundPaymentRequest.Label).ToString(); } - } - // If there's a need for input merging. - foreach (var coin in unspentCoins - .OrderByDescending(x => x.AnonymitySet) // Always try to spend/merge the largest anonset coins first. - .ThenByDescending(x => x.Amount)) // Then always try to spend by amount. - { - coinsToSpend.Add(coin); - // If reaches the amount, then return true, else just go with the largest coin. - if (coinsToSpend.Select(x => x.Amount).Sum() >= totalOutAmount) - { - return true; - } + var foundKey = KeyManager.GetKeyForScriptPubKey(coin.ScriptPubKey); + foundKey?.SetLabel(coin.Label); // The foundkeylabel has already been added previously, so no need to concatenate. } - return false; + Logger.LogInfo($"Transaction is successfully built: {tx.GetHash()}."); + var sign = !KeyManager.IsWatchOnly; + var spendsUnconfirmed = spentCoins.Any(c => !c.Confirmed); + return new BuildTransactionResult(new SmartTransaction(tx, Height.Unknown), psbt, spendsUnconfirmed, sign, fee, feePc, outerWalletOutputs, innerWalletOutputs, spentCoins); } public void RenameLabel(SmartCoin coin, string newLabel) @@ -1440,7 +1322,13 @@ public void RefreshCoinHistories() Parallel.ForEach(unspentCoins, new ParallelOptions { MaxDegreeOfParallelism = simultaneousThread }, coin => { - var result = string.Join(", ", GetClusters(coin, new List(), lookupScriptPubKey, lookupSpenderTransactionId, lookupTransactionId).Select(x => x.Label).Distinct()); + var result = string.Join( + ", ", + GetClusters(coin, new List(), lookupScriptPubKey, lookupSpenderTransactionId, lookupTransactionId) + .SelectMany(x => x.Label + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(y => y.Trim())) + .Distinct()); coin.SetClusters(result); }); } diff --git a/WalletWasabi/WalletWasabi.csproj b/WalletWasabi/WalletWasabi.csproj index b62f86a75d2..992a9627a69 100644 --- a/WalletWasabi/WalletWasabi.csproj +++ b/WalletWasabi/WalletWasabi.csproj @@ -21,7 +21,7 @@ - +