protected SendControlViewModel(Wallet wallet, string title) : base(title) { Global = Locator.Current.GetService <Global>(); Wallet = wallet; LabelSuggestion = new SuggestLabelViewModel(); BuildTransactionButtonText = DoButtonText; this.ValidateProperty(x => x.Address, ValidateAddress); this.ValidateProperty(x => x.CustomChangeAddress, ValidateCustomChangeAddress); this.ValidateProperty(x => x.Password, ValidatePassword); this.ValidateProperty(x => x.UserFeeText, ValidateUserFeeText); ResetUi(); SetAmountWatermark(Money.Zero); CoinList = new CoinListViewModel(Wallet, displayCommonOwnershipWarning: true); Observable.FromEventPattern(CoinList, nameof(CoinList.SelectionChanged)) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => SetFeesAndTexts()); _minMaxFeeTargetsEqual = this.WhenAnyValue(x => x.MinimumFeeTarget, x => x.MaximumFeeTarget, (x, y) => x == y) .ToProperty(this, x => x.MinMaxFeeTargetsEqual, scheduler: RxApp.MainThreadScheduler); SetFeeTargetLimits(); FeeTarget = Global.UiConfig.FeeTarget; FeeDisplayFormat = (FeeDisplayFormat)(Enum.ToObject(typeof(FeeDisplayFormat), Global.UiConfig.FeeDisplayFormat) ?? FeeDisplayFormat.SatoshiPerByte); SetFeesAndTexts(); this.WhenAnyValue(x => x.AmountText) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(x => { if (Money.TryParse(x.TrimStart('~', ' '), out Money amountBtc)) { SetAmountWatermark(amountBtc); } else { SetAmountWatermark(Money.Zero); } SetFees(); }); AmountKeyUpCommand = ReactiveCommand.Create((KeyEventArgs key) => { var amount = AmountText; if (IsMax) { SetFeesAndTexts(); } else { // Correct amount Regex digitsOnly = new Regex(@"[^\d,.]"); string betterAmount = digitsOnly.Replace(amount, ""); // Make it digits , and . only. betterAmount = betterAmount.Replace(',', '.'); int countBetterAmount = betterAmount.Count(x => x == '.'); if (countBetterAmount > 1) // Do not enable typing two dots. { var index = betterAmount.IndexOf('.', betterAmount.IndexOf('.') + 1); if (index > 0) { betterAmount = betterAmount.Substring(0, index); } } var dotIndex = betterAmount.IndexOf('.'); if (dotIndex != -1 && betterAmount.Length - dotIndex > 8) // Enable max 8 decimals. { betterAmount = betterAmount.Substring(0, dotIndex + 1 + 8); } if (betterAmount != amount) { AmountText = betterAmount; } } }); this.WhenAnyValue(x => x.IsBusy, x => x.IsHardwareBusy) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => BuildTransactionButtonText = IsHardwareBusy ? WaitingForHardwareWalletButtonTextString : IsBusy ? DoingButtonText : DoButtonText); this.WhenAnyValue(x => x.FeeTarget) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => { IsSliderFeeUsed = true; SetFeesAndTexts(); }); this.WhenAnyValue(x => x.IsSliderFeeUsed) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(enabled => FeeControlOpacity = enabled ? 1 : 0.5); // Give the control the disabled feeling. Real Disable it not a solution as we have to detect if the slider is moved. MaxCommand = ReactiveCommand.Create(() => IsMax = !IsMax, outputScheduler: RxApp.MainThreadScheduler); this.WhenAnyValue(x => x.IsMax) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => { if (IsMax) { SetFeesAndTexts(); LabelToolTip = "Spending whole coins does not generate change, thus labeling is unnecessary."; } else { AmountText = "0.0"; LabelToolTip = "Who can link this transaction to you? E.g.: \"Max, BitPay\""; } }); // Triggering the detection of same address values. this.WhenAnyValue(x => x.Address) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => this.RaisePropertyChanged(nameof(CustomChangeAddress))); this.WhenAnyValue(x => x.CustomChangeAddress) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => this.RaisePropertyChanged(nameof(Address))); this.WhenAnyValue(x => x.IsCustomChangeAddressVisible) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => { this.RaisePropertyChanged(nameof(Address)); this.RaisePropertyChanged(nameof(CustomChangeAddress)); }); FeeRateCommand = ReactiveCommand.Create(ChangeFeeRateDisplay, outputScheduler: RxApp.MainThreadScheduler); OnAddressPasteCommand = ReactiveCommand.Create((BitcoinUrlBuilder url) => OnAddressPaste(url)); BuildTransactionCommand = ReactiveCommand.CreateFromTask(async() => { try { IsBusy = true; MainWindowViewModel.Instance.StatusBar.TryAddStatus(StatusType.BuildingTransaction); var label = new SmartLabel(LabelSuggestion.Label); LabelSuggestion.Label = label; if (!IsMax && label.IsEmpty) { NotificationHelpers.Warning("Observers are required.", ""); return; } var selectedCoinViewModels = CoinList.Coins.Where(cvm => cvm.IsSelected); var selectedCoinReferences = selectedCoinViewModels.Select(cvm => cvm.Model.OutPoint).ToList(); if (!selectedCoinReferences.Any()) { NotificationHelpers.Warning("No coins are selected to spend.", ""); return; } BitcoinAddress address; try { address = BitcoinAddress.Create(Address, Global.Network); } catch (FormatException) { NotificationHelpers.Warning("Invalid address.", ""); return; } var requests = new List <DestinationRequest>(); if (IsCustomChangeAddressVisible && !string.IsNullOrWhiteSpace(CustomChangeAddress)) { try { var customChangeAddress = BitcoinAddress.Create(CustomChangeAddress, Global.Network); if (customChangeAddress == address) { NotificationHelpers.Warning("The active address and the change address cannot be the same.", ""); return; } requests.Add(new DestinationRequest(customChangeAddress, MoneyRequest.CreateChange(subtractFee: true), label)); } catch (FormatException) { NotificationHelpers.Warning("Invalid custom change address.", ""); return; } } MoneyRequest moneyRequest; if (IsMax) { moneyRequest = MoneyRequest.CreateAllRemaining(subtractFee: true); } else { if (!Money.TryParse(AmountText, out Money amount) || amount == Money.Zero) { NotificationHelpers.Warning("Invalid amount."); return; } if (amount == selectedCoinViewModels.Sum(x => x.Amount)) { NotificationHelpers.Warning("Looks like you want to spend whole coins. Try Max button instead.", ""); return; } moneyRequest = MoneyRequest.Create(amount, subtractFee: false); } if (FeeRate is null || FeeRate.SatoshiPerByte < 1) { NotificationHelpers.Warning("Invalid fee rate.", ""); return; } var feeStrategy = FeeStrategy.CreateFromFeeRate(FeeRate); var activeDestinationRequest = new DestinationRequest(address, moneyRequest, label); requests.Add(activeDestinationRequest); var intent = new PaymentIntent(requests); try { MainWindowViewModel.Instance.StatusBar.TryAddStatus(StatusType.DequeuingSelectedCoins); OutPoint[] toDequeue = selectedCoinViewModels.Where(x => x.CoinJoinInProgress).Select(x => x.Model.OutPoint).ToArray(); if (toDequeue != null && toDequeue.Any()) { await Wallet.ChaumianClient.DequeueCoinsFromMixAsync(toDequeue, DequeueReason.TransactionBuilding); } } catch { NotificationHelpers.Error("Cannot spend mixing coins.", ""); return; } finally { MainWindowViewModel.Instance.StatusBar.TryRemoveStatus(StatusType.DequeuingSelectedCoins); } if (!Wallet.KeyManager.IsWatchOnly) { try { PasswordHelper.GetMasterExtKey(Wallet.KeyManager, Password, out string compatiblityPasswordUsed); // We could use TryPassword but we need the exception. if (compatiblityPasswordUsed != null) { Password = compatiblityPasswordUsed; // Overwrite the password for BuildTransaction function. NotificationHelpers.Warning(PasswordHelper.CompatibilityPasswordWarnMessage); } } catch (SecurityException ex) { NotificationHelpers.Error(ex.Message, ""); return; } catch (Exception ex) { Logger.LogError(ex); NotificationHelpers.Error(ex.ToUserFriendlyString()); return; } } await BuildTransaction(Password, intent, feeStrategy, allowUnconfirmed: true, allowedInputs: selectedCoinReferences); } catch (InsufficientBalanceException ex) { Money needed = ex.Minimum - ex.Actual; NotificationHelpers.Error($"Not enough coins selected. You need an estimated {needed.ToString(false, true)} BTC more to make this transaction.", ""); } catch (HttpRequestException ex) { NotificationHelpers.Error(ex.ToUserFriendlyString()); Logger.LogError(ex); } catch (Exception ex) { NotificationHelpers.Error(ex.ToUserFriendlyString(), sender: Wallet); Logger.LogError(ex); } finally { MainWindowViewModel.Instance.StatusBar.TryRemoveStatus(StatusType.BuildingTransaction, StatusType.SigningTransaction, StatusType.BroadcastingTransaction); IsBusy = false; } }, this.WhenAny(x => x.IsMax, x => x.AmountText, x => x.Address, x => x.IsBusy, (isMax, amount, address, busy) => (isMax.Value || !string.IsNullOrWhiteSpace(amount.Value)) && !string.IsNullOrWhiteSpace(Address) && !IsBusy) .ObserveOn(RxApp.MainThreadScheduler)); UserFeeTextKeyUpCommand = ReactiveCommand.Create((KeyEventArgs key) => { IsSliderFeeUsed = !IsCustomFee; SetFeesAndTexts(); }); FeeSliderClickedCommand = ReactiveCommand.Create((PointerPressedEventArgs mouse) => IsSliderFeeUsed = true); HighLightFeeSliderCommand = ReactiveCommand.Create((bool entered) => { if (IsSliderFeeUsed) { return; } FeeControlOpacity = entered ? 0.8 : 0.5; }); Observable .Merge(MaxCommand.ThrownExceptions) .Merge(FeeRateCommand.ThrownExceptions) .Merge(OnAddressPasteCommand.ThrownExceptions) .Merge(BuildTransactionCommand.ThrownExceptions) .Merge(UserFeeTextKeyUpCommand.ThrownExceptions) .Merge(FeeSliderClickedCommand.ThrownExceptions) .Merge(HighLightFeeSliderCommand.ThrownExceptions) .Merge(AmountKeyUpCommand.ThrownExceptions) .ObserveOn(RxApp.TaskpoolScheduler) .Subscribe(ex => { NotificationHelpers.Error(ex.ToUserFriendlyString()); Logger.LogError(ex); }); }
protected SendControlViewModel(Wallet wallet, string title) : base(title) { Global = Locator.Current.GetService <Global>(); Wallet = wallet; LabelSuggestion = new SuggestLabelViewModel(); _buildTransactionButtonText = DoButtonText; this.ValidateProperty(x => x.Address, ValidateAddress); this.ValidateProperty(x => x.CustomChangeAddress, ValidateCustomChangeAddress); this.ValidateProperty(x => x.Password, ValidatePassword); this.ValidateProperty(x => x.UserFeeText, ValidateUserFeeText); ResetUi(); CoinList = new CoinListViewModel(Wallet, Global.Config, Global.UiConfig, displayCommonOwnershipWarning: true); Observable.FromEventPattern(CoinList, nameof(CoinList.SelectionChanged)) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => SetFeesAndTexts()); _minMaxFeeTargetsEqual = this.WhenAnyValue(x => x.MinimumFeeTarget, x => x.MaximumFeeTarget, (x, y) => x == y) .ToProperty(this, x => x.MinMaxFeeTargetsEqual, scheduler: RxApp.MainThreadScheduler); SetFeeTargetLimits(); FeeTarget = Global.UiConfig.FeeTarget; FeeDisplayFormat = (FeeDisplayFormat)(Enum.ToObject(typeof(FeeDisplayFormat), Global.UiConfig.FeeDisplayFormat) ?? FeeDisplayFormat.SatoshiPerByte); SetFeesAndTexts(); this.WhenAnyValue(x => x.AmountText) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(x => { if (Money.TryParse(x.TrimStart('~', ' '), out Money amountBtc)) { SetAmountWatermark(amountBtc); } else { SetAmountWatermark(Money.Zero); } SetFees(); }); AmountKeyUpCommand = ReactiveCommand.Create((KeyEventArgs key) => { if (IsMax) { SetFeesAndTexts(); } else if (BitcoinInput.TryCorrectAmount(AmountText, out var betterAmount)) { AmountText = betterAmount; } }); this.WhenAnyValue(x => x.IsBusy, x => x.IsHardwareBusy) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => BuildTransactionButtonText = IsHardwareBusy ? WaitingForHardwareWalletButtonTextString : IsBusy ? DoingButtonText : DoButtonText); Observable .Merge(this.WhenAnyValue(x => x.FeeTarget).Select(_ => true)) .Merge(this.WhenAnyValue(x => x.IsEstimateAvailable).Select(_ => true)) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => { IsSliderFeeUsed = IsEstimateAvailable; SetFeesAndTexts(); }); this.WhenAnyValue(x => x.IsSliderFeeUsed) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(enabled => FeeControlOpacity = enabled ? 1 : 0.5); // Give the control the disabled feeling. Real Disable it not a solution as we have to detect if the slider is moved. MaxCommand = ReactiveCommand.Create(() => IsMax = !IsMax, outputScheduler: RxApp.MainThreadScheduler); this.WhenAnyValue(x => x.IsMax) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => { if (IsMax) { SetFeesAndTexts(); LabelToolTip = "Spending whole coins does not generate change, thus labeling is unnecessary."; } else { AmountText = "0.0"; LabelToolTip = "Who can link this transaction to you? E.g.: \"Max, BitPay\""; } }); // Triggering the detection of same address values. this.WhenAnyValue(x => x.Address) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => this.RaisePropertyChanged(nameof(CustomChangeAddress))); this.WhenAnyValue(x => x.CustomChangeAddress) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => this.RaisePropertyChanged(nameof(Address))); this.WhenAnyValue(x => x.IsCustomChangeAddressVisible) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(_ => { this.RaisePropertyChanged(nameof(Address)); this.RaisePropertyChanged(nameof(CustomChangeAddress)); }); FeeRateCommand = ReactiveCommand.Create(ChangeFeeRateDisplay, outputScheduler: RxApp.MainThreadScheduler); OnAddressPasteCommand = ReactiveCommand.Create((BitcoinUrlBuilder url) => OnAddressPaste(url)); BuildTransactionCommand = ReactiveCommand.CreateFromTask(async() => { try { IsBusy = true; MainWindowViewModel.Instance.StatusBar.TryAddStatus(StatusType.BuildingTransaction); var label = new SmartLabel(LabelSuggestion.Label); LabelSuggestion.Label = label; if (!IsMax && label.IsEmpty) { NotificationHelpers.Warning("Label is required.", ""); return; } var selectedCoinViewModels = CoinList.Coins.Where(cvm => cvm.IsSelected); var selectedCoinReferences = selectedCoinViewModels.Select(cvm => cvm.Model.OutPoint).ToList(); if (!selectedCoinReferences.Any()) { NotificationHelpers.Warning("No coins are selected to spend.", ""); return; } BitcoinAddress address; try { address = BitcoinAddress.Create(Address, Global.Network); } catch (FormatException) { NotificationHelpers.Warning("Invalid address.", ""); return; } var requests = new List <DestinationRequest>(); if (IsCustomChangeAddressVisible && !string.IsNullOrWhiteSpace(CustomChangeAddress)) { try { var customChangeAddress = BitcoinAddress.Create(CustomChangeAddress, Global.Network); if (customChangeAddress == address) { NotificationHelpers.Warning("The active address and the change address cannot be the same.", ""); return; } requests.Add(new DestinationRequest(customChangeAddress, MoneyRequest.CreateChange(subtractFee: true), label)); } catch (FormatException) { NotificationHelpers.Warning("Invalid custom change address.", ""); return; } } MoneyRequest moneyRequest; if (IsMax) { moneyRequest = MoneyRequest.CreateAllRemaining(subtractFee: true); } else { if (!Money.TryParse(AmountText, out Money amount) || amount == Money.Zero) { NotificationHelpers.Warning("Invalid amount."); return; } if (amount == selectedCoinViewModels.Sum(x => x.Amount)) { NotificationHelpers.Warning("Looks like you want to spend whole coins. Try Max button instead.", ""); return; } moneyRequest = MoneyRequest.Create(amount, subtractFee: false); } if (FeeRate is null || FeeRate.SatoshiPerByte < 1) { NotificationHelpers.Warning("Invalid fee rate.", ""); return; } var feeStrategy = FeeStrategy.CreateFromFeeRate(FeeRate); var activeDestinationRequest = new DestinationRequest(address, moneyRequest, label); requests.Add(activeDestinationRequest); var intent = new PaymentIntent(requests); try { MainWindowViewModel.Instance.StatusBar.TryAddStatus(StatusType.DequeuingSelectedCoins); OutPoint[] toDequeue = selectedCoinViewModels.Where(x => x.CoinJoinInProgress).Select(x => x.Model.OutPoint).ToArray(); if (toDequeue is { } && toDequeue.Any()) { await Wallet.ChaumianClient.DequeueCoinsFromMixAsync(toDequeue, DequeueReason.TransactionBuilding); } } catch { NotificationHelpers.Error("Cannot spend mixing coins.", ""); return; } finally { MainWindowViewModel.Instance.StatusBar.TryRemoveStatus(StatusType.DequeuingSelectedCoins); } if (!Wallet.KeyManager.IsWatchOnly) { try { PasswordHelper.GetMasterExtKey(Wallet.KeyManager, Password, out var compatiblityPasswordUsed); // We could use TryPassword but we need the exception. if (compatiblityPasswordUsed is { }) { Password = compatiblityPasswordUsed; // Overwrite the password for BuildTransaction function. NotificationHelpers.Warning(PasswordHelper.CompatibilityPasswordWarnMessage); } } catch (SecurityException ex) { NotificationHelpers.Error(ex.Message, ""); return; } catch (Exception ex) { Logger.LogError(ex); NotificationHelpers.Error(ex.ToUserFriendlyString()); return; } }
public string BuildTransaction(PaymentInfo[] payments, TxoRef[] coins, int feeTarget, string password = null) { Guard.NotNull(nameof(payments), payments); Guard.NotNull(nameof(coins), coins); Guard.InRangeAndNotNull(nameof(feeTarget), feeTarget, 2, Constants.SevenDaysConfirmationTarget); password = Guard.Correct(password); AssertWalletIsLoaded(); var sync = Global.Synchronizer; var payment = new PaymentIntent(payments.Select(p => new DestinationRequest(p.Sendto.ScriptPubKey, MoneyRequest.Create(p.Amount, p.SubtractFee), new SmartLabel(p.Label)))); var feeStrategy = FeeStrategy.CreateFromConfirmationTarget(feeTarget); var result = Global.WalletService.BuildTransaction( password, payment, feeStrategy, allowUnconfirmed: true, allowedInputs: coins); var smartTx = result.Transaction; return(smartTx.Transaction.ToHex()); }
public DestinationRequest(Script scriptPubKey, Money amount, bool subtractFee = false, SmartLabel?label = null) : this(scriptPubKey, MoneyRequest.Create(amount, subtractFee), label) { }
public DestinationRequest(IDestination destination, Money amount, bool subtractFee = false, SmartLabel?label = null) : this(destination, MoneyRequest.Create(amount, subtractFee), label) { }
public async Task <object> SendTransactionAsync(PaymentInfo[] payments, TxoRef[] coins, int feeTarget, string password = null) { Guard.NotNull(nameof(payments), payments); Guard.NotNull(nameof(coins), coins); Guard.InRangeAndNotNull(nameof(feeTarget), feeTarget, 2, Constants.SevenDaysConfirmationTarget); password = Guard.Correct(password); AssertWalletIsLoaded(); var sync = Global.Synchronizer; var payment = new PaymentIntent(payments.Select(p => new DestinationRequest(p.Sendto.ScriptPubKey, MoneyRequest.Create(p.Amount, p.SubtractFee), new SmartLabel(p.Label)))); var feeStrategy = FeeStrategy.CreateFromConfirmationTarget(feeTarget); var result = Global.WalletService.BuildTransaction( password, payment, feeStrategy, allowUnconfirmed: true, allowedInputs: coins); var smartTx = result.Transaction; // dequeue the coins we are going to spend var toDequeue = Global.WalletService.Coins .Where(x => x.CoinJoinInProgress && coins.Contains(x.GetTxoRef())) .ToArray(); if (toDequeue.Any()) { await Global.ChaumianClient.DequeueCoinsFromMixAsync(toDequeue, DequeueReason.TransactionBuilding).ConfigureAwait(false); } await Global.TransactionBroadcaster.SendTransactionAsync(smartTx).ConfigureAwait(false); return(new { txid = smartTx.Transaction.GetHash(), tx = smartTx.Transaction.ToHex() }); }
public async Task <bool> BuildTransaction(string password) { try { IsBusy = true; password = Guard.Correct(password); Memo = Memo.Trim(',', ' ').Trim(); var selectedCoinViewModels = SendAmountViewModel.CoinList.CoinList.Where(cvm => cvm.IsSelected); var selectedCoinReferences = selectedCoinViewModels.Select(cvm => cvm.Model.OutPoint).ToList(); if (!selectedCoinReferences.Any()) { //SetWarningMessage("No coins are selected to spend."); return(false); } BitcoinAddress address; try { address = BitcoinAddress.Create(Address.Trim(), Global.Network); } catch (FormatException) { // SetWarningMessage("Invalid address."); return(false); } var amount = Money.Zero; var requests = new List <DestinationRequest>(); MoneyRequest moneyRequest; if (SendAmountViewModel.IsMax) { moneyRequest = MoneyRequest.CreateAllRemaining(subtractFee: true); } else { if (!Money.TryParse(SendAmountViewModel.AmountText, out amount) || amount == Money.Zero) { // SetWarningMessage($"Invalid amount."); return(false); } if (amount == selectedCoinViewModels.Sum(x => x.Amount)) { // NotificationHelpers.Warning("Looks like you want to spend whole coins. Try Max button instead.", ""); return(false); } moneyRequest = MoneyRequest.Create(amount, subtractFee: false); } if (SendAmountViewModel.FeeRate is null || SendAmountViewModel.FeeRate.SatoshiPerByte < 1) { return(false); } var feeStrategy = FeeStrategy.CreateFromFeeRate(SendAmountViewModel.FeeRate); var smartLabel = new SmartLabel(Memo); var activeDestinationRequest = new DestinationRequest(address, moneyRequest, smartLabel); requests.Add(activeDestinationRequest); var intent = new PaymentIntent(requests); var result = await Task.Run(() => Global.Wallet.BuildTransaction( password, intent, feeStrategy, allowUnconfirmed: true, allowedInputs: selectedCoinReferences)); SmartTransaction signedTransaction = result.Transaction; await Global.TransactionBroadcaster.SendTransactionAsync(signedTransaction); // put this on non-ui theread? return(true); } catch (InsufficientBalanceException ex) { Money needed = ex.Minimum - ex.Actual; Logger.LogDebug(ex); //SetWarningMessage($"Not enough coins selected. You need an estimated {needed.ToString(false, true)} BTC more to make this transaction."); } catch (Exception ex) { Logger.LogDebug(ex); //SetWarningMessage(ex.ToTypeMessageString()); } finally { IsBusy = false; } return(false); }