public async Task BuildPrivacySuggestionsAsync(Wallet wallet, TransactionInfo info, BitcoinAddress destination, BuildTransactionResult transaction, bool isFixedAmount, CancellationToken cancellationToken) { _suggestionCancellationTokenSource?.Cancel(); _suggestionCancellationTokenSource?.Dispose(); _suggestionCancellationTokenSource = new(TimeSpan.FromSeconds(15)); using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_suggestionCancellationTokenSource.Token, cancellationToken); Suggestions.Clear(); SelectedSuggestion = null; if (!info.IsPrivate) { Suggestions.Add(new PocketSuggestionViewModel(SmartLabel.Merge(transaction.SpentCoins.Select(x => x.GetLabels(wallet.KeyManager.MinAnonScoreTarget))))); } var loadingRing = new LoadingSuggestionViewModel(); Suggestions.Add(loadingRing); var hasChange = transaction.InnerWalletOutputs.Any(x => x.ScriptPubKey != destination.ScriptPubKey); if (hasChange && !isFixedAmount && !info.IsPayJoin) { var suggestions = ChangeAvoidanceSuggestionViewModel.GenerateSuggestionsAsync(info, destination, wallet, linkedCts.Token); await foreach (var suggestion in suggestions) { Suggestions.Insert(Suggestions.Count - 1, suggestion); } } Suggestions.Remove(loadingRing); }
public Cluster(IEnumerable <HdPubKey> keys) { Lock = new object(); Keys = keys.ToList(); KeysSet = Keys.ToHashSet(); _labels = SmartLabel.Merge(Keys.Select(x => x.Label)); }
public void UpdateTransaction(BuildTransactionResult transactionResult, TransactionInfo info) { _transaction = transactionResult; ConfirmationTimeText = $"Approximately {TextHelpers.TimeSpanToFriendlyString(info.ConfirmationTimeSpan)} "; var destinationAmount = _transaction.CalculateDestinationAmount(); var btcAmountText = $"{destinationAmount.ToFormattedString()} BTC"; var fiatAmountText = destinationAmount.ToDecimal(MoneyUnit.BTC).GenerateFiatText(_wallet.Synchronizer.UsdExchangeRate, "USD"); AmountText = $"{btcAmountText} {fiatAmountText}"; var fee = _transaction.Fee; var feeText = fee.ToFeeDisplayUnitString(); var fiatFeeText = fee.ToDecimal(MoneyUnit.BTC).GenerateFiatText(_wallet.Synchronizer.UsdExchangeRate, "USD"); FeeText = $"{feeText} {fiatFeeText}"; TransactionHasChange = _transaction.InnerWalletOutputs.Any(x => x.ScriptPubKey != _address.ScriptPubKey); TransactionHasPockets = !info.IsPrivate; Labels = SmartLabel.Merge(transactionResult.SpentCoins.Select(x => x.GetLabels(info.PrivateCoinThreshold))); var exactPocketUsed = Labels.Count() == info.UserLabels.Count() && Labels.All(label => info.UserLabels.Contains(label, StringComparer.OrdinalIgnoreCase)); TransactionHasPockets = Labels.Any() && !exactPocketUsed; IsCustomFeeUsed = info.IsCustomFeeUsed; IsOtherPocketSelectionPossible = info.IsOtherPocketSelectionPossible; }
public LabelViewModel[] GetAssociatedLabels(LabelViewModel labelViewModel) { if (labelViewModel.IsBlackListed) { var associatedPocketLabels = NonPrivatePockets.OrderBy(x => x.Labels.Count()).First(x => x.Labels.Contains(labelViewModel.Value)).Labels; return(LabelsBlackList.Where(x => associatedPocketLabels.Contains(x.Value)).ToArray()); } else { var associatedPockets = NonPrivatePockets.Where(x => x.Labels.Contains(labelViewModel.Value)); var notAssociatedPockets = NonPrivatePockets.Except(associatedPockets); var allNotAssociatedLabels = SmartLabel.Merge(notAssociatedPockets.Select(x => x.Labels)); return(LabelsWhiteList.Where(x => !allNotAssociatedLabels.Contains(x.Value)).ToArray()); } }
public void SetUsedLabel(IEnumerable <SmartCoin>?usedCoins, int privateThreshold) { if (usedCoins is null) { return; } var usedLabels = SmartLabel.Merge(usedCoins.Select(x => x.GetLabels(privateThreshold))); var usedLabelViewModels = AllLabelsViewModel.Where(x => usedLabels.Contains(x.Value)).ToArray(); var notUsedLabelViewModels = AllLabelsViewModel.Except(usedLabelViewModels); foreach (LabelViewModel label in notUsedLabelViewModels) { label.Swap(); } OnSelectionChanged(); }
/// <summary> /// Update the transaction with the data acquired from another transaction. (For example merge their labels.) /// </summary> public bool TryUpdate(SmartTransaction tx) { var updated = false; // If this is not the same tx, then don't update. if (this != tx) { throw new InvalidOperationException($"{GetHash()} != {tx.GetHash()}"); } // Set the height related properties, only if confirmed. if (tx.Confirmed) { if (Height != tx.Height) { Height = tx.Height; updated = true; } if (tx.BlockHash != null && BlockHash != tx.BlockHash) { BlockHash = tx.BlockHash; BlockIndex = tx.BlockIndex; updated = true; } } // Always the earlier seen is the firstSeen. if (tx.FirstSeen < FirstSeen) { FirstSeen = tx.FirstSeen; updated = true; } // Merge labels. if (Label != tx.Label) { Label = SmartLabel.Merge(Label, tx.Label); updated = true; } return(updated); }
public async Task BuildPrivacySuggestionsAsync(Wallet wallet, TransactionInfo info, BitcoinAddress destination, BuildTransactionResult transaction, bool isFixedAmount, CancellationToken cancellationToken) { _suggestionCancellationTokenSource?.Cancel(); _suggestionCancellationTokenSource?.Dispose(); _suggestionCancellationTokenSource = new(TimeSpan.FromSeconds(15)); using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_suggestionCancellationTokenSource.Token, cancellationToken); Suggestions.Clear(); SelectedSuggestion = null; if (!info.IsPrivate) { Suggestions.Add(new PocketSuggestionViewModel(SmartLabel.Merge(transaction.SpentCoins.Select(x => x.GetLabels(wallet.KeyManager.MinAnonScoreTarget))))); } var loadingRing = new LoadingSuggestionViewModel(); Suggestions.Add(loadingRing); var hasChange = transaction.InnerWalletOutputs.Any(x => x.ScriptPubKey != destination.ScriptPubKey); if (hasChange && !isFixedAmount && !info.IsPayJoin) { // Exchange rate can change substantially during computation itself. // Reporting up-to-date exchange rates would just confuse users. decimal usdExchangeRate = wallet.Synchronizer.UsdExchangeRate; int originalInputCount = transaction.SpentCoins.Count(); int maxInputCount = (int)(Math.Max(3, originalInputCount * 1.3)); IAsyncEnumerable <ChangeAvoidanceSuggestionViewModel> suggestions = ChangeAvoidanceSuggestionViewModel.GenerateSuggestionsAsync(info, destination, wallet, maxInputCount, usdExchangeRate, linkedCts.Token); await foreach (var suggestion in suggestions) { Suggestions.Insert(Suggestions.Count - 1, suggestion); } } Suggestions.Remove(loadingRing); }
public void Reset(Pocket[] pockets) { _allPockets = pockets; if (pockets.FirstOrDefault(x => x.Labels == CoinPocketHelper.PrivateFundsText) is { } privatePocket) { _privatePocket = privatePocket; } NonPrivatePockets = pockets.Where(x => x != _privatePocket).ToArray(); var allLabels = SmartLabel.Merge(NonPrivatePockets.Select(x => x.Labels)); AllLabelsViewModel = allLabels.Select(x => new LabelViewModel(this, x)).ToArray(); if (AllLabelsViewModel.FirstOrDefault(x => x.Value == CoinPocketHelper.UnlabelledFundsText) is { } unlabelledViewModel) { unlabelledViewModel.IsDangerous = true; unlabelledViewModel.ToolTip = "There is no information about these people, only use it when necessary!"; } OnSelectionChanged(); }
public void SpecialLabelTests() { var label = new SmartLabel(""); Assert.Equal(label, SmartLabel.Empty); Assert.True(label.IsEmpty); label = new SmartLabel("foo, bar, buz"); var label2 = new SmartLabel("qux"); label = SmartLabel.Merge(label, label2); Assert.Equal("bar, buz, foo, qux", label); label2 = new SmartLabel("qux", "bar"); label = SmartLabel.Merge(label, label2); Assert.Equal(4, label.Labels.Count()); Assert.Equal("bar, buz, foo, qux", label); label2 = new SmartLabel("Qux", "Bar"); label = SmartLabel.Merge(label, label2, null); Assert.Equal(4, label.Labels.Count()); Assert.Equal("bar, buz, foo, qux", label); }
/// <exception cref="ArgumentException"></exception> /// <exception cref="ArgumentNullException"></exception> /// <exception cref="ArgumentOutOfRangeException"></exception> public BuildTransactionResult BuildTransaction( PaymentIntent payments, Func <FeeRate> feeRateFetcher, IEnumerable <TxoRef> allowedInputs = null) { payments = Guard.NotNull(nameof(payments), payments); long totalAmount = payments.TotalAmount.Satoshi; if (totalAmount < 0 || totalAmount > Constants.MaximumNumberOfSatoshis) { throw new ArgumentOutOfRangeException($"{nameof(payments)}.{nameof(payments.TotalAmount)} sum cannot be smaller than 0 or greater than {Constants.MaximumNumberOfSatoshis}."); } // Get allowed coins to spend. List <SmartCoin> allowedSmartCoinInputs; // Inputs that can be used to build the transaction. if (allowedInputs != null) // If allowedInputs are specified then select the coins from them. { if (!allowedInputs.Any()) { throw new ArgumentException($"{nameof(allowedInputs)} is not null, but empty."); } 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(); } // Get and calculate fee Logger.LogInfo("Calculating dynamic transaction fee..."); TransactionBuilder builder = Network.CreateTransactionBuilder(); builder.SetCoinSelector(new SmartCoinSelector(allowedSmartCoinInputs)); builder.AddCoins(allowedSmartCoinInputs.Select(c => c.GetCoin())); foreach (var request in payments.Requests.Where(x => x.Amount.Type == MoneyRequestType.Value)) { var amountRequest = request.Amount; builder.Send(request.Destination, amountRequest.Amount); if (amountRequest.SubtractFee) { builder.SubtractFees(); } } HdPubKey changeHdPubKey = null; if (payments.TryGetCustomRequest(out DestinationRequest custChange)) { var changeScript = custChange.Destination.ScriptPubKey; changeHdPubKey = KeyManager.GetKeyForScriptPubKey(changeScript); var changeStrategy = payments.ChangeStrategy; if (changeStrategy == ChangeStrategy.Custom) { builder.SetChange(changeScript); } else if (changeStrategy == ChangeStrategy.AllRemainingCustom) { builder.SendAllRemaining(changeScript); } else { 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); } FeeRate feeRate = feeRateFetcher(); builder.SendEstimatedFees(feeRate); var psbt = builder.BuildPSBT(false); var spentCoins = psbt.Inputs.Select(txin => allowedSmartCoinInputs.First(y => y.GetOutPoint() == 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)) { throw new InvalidOperationException("Impossible to get the fees of the PSBT, this should never happen."); } Logger.LogInfo($"Fee: {fee.Satoshi} Satoshi."); var vSize = builder.EstimateSize(psbt.GetOriginalTransaction(), true); Logger.LogInfo($"Estimated tx size: {vSize} vbytes."); // Do some checks Money totalSendAmountNoFee = realToSend.Sum(x => x.amount); if (totalSendAmountNoFee == Money.Zero) { throw new InvalidOperationException("The amount after subtracting the fee is too small to be sent."); } Money totalSendAmount = totalSendAmountNoFee + fee; Money totalOutgoingAmountNoFee; if (changeHdPubKey is null) { totalOutgoingAmountNoFee = totalSendAmountNoFee; } else { totalOutgoingAmountNoFee = realToSend.Where(x => !changeHdPubKey.ContainsScript(x.destination.ScriptPubKey)).Sum(x => x.amount); } 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 {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) { throw new InvalidOperationException($"The transaction fee is more than twice the transaction amount: {feePc:0.#}%."); } if (spentCoins.Any(u => !u.Confirmed)) { Logger.LogInfo("Unconfirmed transaction is spent."); } // Build the transaction Logger.LogInfo("Signing transaction..."); // It must be watch only, too, because if we have the key and also hardware wallet, we do not care we can sign. Transaction tx = null; if (KeyManager.IsWatchOnly) { tx = psbt.GetGlobalTransaction(); } else { IEnumerable <ExtKey> signingKeys = KeyManager.GetSecrets(Password, spentCoins.Select(x => x.ScriptPubKey).ToArray()); builder = builder.AddKeys(signingKeys.ToArray()); builder.SignPSBT(psbt); psbt.Finalize(); tx = psbt.ExtractTransaction(); 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."); } // 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); } } 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 label = SmartLabel.Merge(payments.Requests.Select(x => x.Label).Concat(spentCoins.Select(x => x.Label))); var outerWalletOutputs = new List <SmartCoin>(); var innerWalletOutputs = new List <SmartCoin>(); 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.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); label = SmartLabel.Merge(label, coin.Label); // foundKey's label is already added to the coinlabel. if (foundKey is null) { outerWalletOutputs.Add(coin); } else { innerWalletOutputs.Add(coin); } } foreach (var coin in outerWalletOutputs.Concat(innerWalletOutputs)) { var foundPaymentRequest = payments.Requests.FirstOrDefault(x => x.Destination.ScriptPubKey == coin.ScriptPubKey); // If change then we concatenate all the labels. if (foundPaymentRequest is null) // Then it's autochange. { coin.Label = label; } else { coin.Label = SmartLabel.Merge(coin.Label, foundPaymentRequest.Label); } var foundKey = KeyManager.GetKeyForScriptPubKey(coin.ScriptPubKey); foundKey?.SetLabel(coin.Label); // The foundkeylabel has already been added previously, so no need to concatenate. } 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)); }
/// <exception cref="ArgumentException"/> /// <exception cref="ArgumentNullException"/> /// <exception cref="ArgumentOutOfRangeException"/> public BuildTransactionResult BuildTransaction( PaymentIntent payments, Func <FeeRate> feeRateFetcher, IEnumerable <OutPoint>?allowedInputs = null, Func <LockTime>?lockTimeSelector = null, IPayjoinClient?payjoinClient = null, bool tryToSign = true) { lockTimeSelector ??= () => LockTime.Zero; long totalAmount = payments.TotalAmount.Satoshi; if (totalAmount is < 0 or > Constants.MaximumNumberOfSatoshis) { throw new ArgumentOutOfRangeException($"{nameof(payments)}.{nameof(payments.TotalAmount)} sum cannot be smaller than 0 or greater than {Constants.MaximumNumberOfSatoshis}."); } // Get allowed coins to spend. var availableCoinsView = Coins.Unspent(); List <SmartCoin> allowedSmartCoinInputs = AllowUnconfirmed // Inputs that can be used to build the transaction. ? availableCoinsView.ToList() : availableCoinsView.Confirmed().ToList(); if (allowedInputs is not null) // If allowedInputs are specified then select the coins from them. { if (!allowedInputs.Any()) { throw new ArgumentException($"{nameof(allowedInputs)} is not null, but empty."); } allowedSmartCoinInputs = allowedSmartCoinInputs .Where(x => allowedInputs.Any(y => y.Hash == x.TransactionId && y.N == 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 availableCoinsView.Where(x => !allowedSmartCoinInputs.Any(y => x.TransactionId == y.TransactionId && x.Index == y.Index))) { if (!(AllowUnconfirmed || coin.Confirmed)) { continue; } if (allScripts.Contains(coin.ScriptPubKey)) { allowedSmartCoinInputs.Add(coin); } } } } // Get and calculate fee Logger.LogInfo("Calculating dynamic transaction fee..."); TransactionBuilder builder = Network.CreateTransactionBuilder(); builder.SetCoinSelector(new SmartCoinSelector(allowedSmartCoinInputs)); builder.AddCoins(allowedSmartCoinInputs.Select(c => c.Coin)); builder.SetLockTime(lockTimeSelector()); foreach (var request in payments.Requests.Where(x => x.Amount.Type == MoneyRequestType.Value)) { var amountRequest = request.Amount; builder.Send(request.Destination, amountRequest.Amount); if (amountRequest.SubtractFee) { builder.SubtractFees(); } } HdPubKey?changeHdPubKey; if (payments.TryGetCustomRequest(out DestinationRequest? custChange)) { var changeScript = custChange.Destination.ScriptPubKey; KeyManager.TryGetKeyForScriptPubKey(changeScript, out HdPubKey? hdPubKey); changeHdPubKey = hdPubKey; var changeStrategy = payments.ChangeStrategy; if (changeStrategy == ChangeStrategy.Custom) { builder.SetChange(changeScript); } else if (changeStrategy == ChangeStrategy.AllRemainingCustom) { builder.SendAllRemaining(changeScript); } else { throw new NotSupportedException(payments.ChangeStrategy.ToString()); } } else { KeyManager.AssertCleanKeysIndexed(isInternal: true); changeHdPubKey = KeyManager.GetKeys(KeyState.Clean, true).First(); builder.SetChange(changeHdPubKey.P2wpkhScript); } builder.OptInRBF = true; builder.SendEstimatedFees(feeRateFetcher()); var psbt = builder.BuildPSBT(false); var spentCoins = psbt.Inputs.Select(txin => allowedSmartCoinInputs.First(y => y.OutPoint == 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 is not null); if (!psbt.TryGetFee(out var fee)) { throw new InvalidOperationException("Impossible to get the fees of the PSBT, this should never happen."); } Logger.LogInfo($"Fee: {fee.Satoshi} Satoshi."); var vSize = builder.EstimateSize(psbt.GetOriginalTransaction(), true); Logger.LogInfo($"Estimated tx size: {vSize} vBytes."); // Do some checks Money totalSendAmountNoFee = realToSend.Sum(x => x.amount); if (totalSendAmountNoFee == Money.Zero) { throw new InvalidOperationException("The amount after subtracting the fee is too small to be sent."); } Money totalOutgoingAmountNoFee; if (changeHdPubKey is null) { totalOutgoingAmountNoFee = totalSendAmountNoFee; } else { totalOutgoingAmountNoFee = realToSend.Where(x => !changeHdPubKey.ContainsScript(x.destination.ScriptPubKey)).Sum(x => x.amount); } 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 the sent amount.{Environment.NewLine}" + $"Sending:\t {totalOutgoingAmountNoFee.ToString(fplus: false, trimExcessZero: true)} BTC.{Environment.NewLine}" + $"Fee:\t\t {fee.Satoshi} Satoshi."); } if (feePc > 100) { throw new TransactionFeeOverpaymentException(feePc); } if (spentCoins.Any(u => !u.Confirmed)) { Logger.LogInfo("Unconfirmed transaction is spent."); } // Build the transaction // It must be watch only, too, because if we have the key and also hardware wallet, we do not care we can sign. psbt.AddKeyPaths(KeyManager); psbt.AddPrevTxs(TransactionStore); Transaction tx; if (KeyManager.IsWatchOnly || !tryToSign) { tx = psbt.GetGlobalTransaction(); } else { Logger.LogInfo("Signing transaction..."); IEnumerable <ExtKey> signingKeys = KeyManager.GetSecrets(Password, spentCoins.Select(x => x.ScriptPubKey).ToArray()); builder = builder.AddKeys(signingKeys.ToArray()); builder.SignPSBT(psbt); // Try to pay using payjoin if (payjoinClient is not null) { psbt = TryNegotiatePayjoin(payjoinClient, builder, psbt, changeHdPubKey); psbt.AddKeyPaths(KeyManager); psbt.AddPrevTxs(TransactionStore); } psbt.Finalize(); tx = psbt.ExtractTransaction(); if (payjoinClient is not null) { builder.CoinFinder = (outpoint) => psbt.Inputs.Select(x => x.GetCoin()).Single(x => x?.Outpoint == outpoint) !; } var checkResults = builder.Check(tx).ToList(); if (checkResults.Count > 0) { Logger.LogDebug($"Found policy error(s)! First error: '{checkResults[0]}'."); throw new InvalidTxException(tx, checkResults); } } var smartTransaction = new SmartTransaction(tx, Height.Unknown, label: SmartLabel.Merge(payments.Requests.Select(x => x.Label))); foreach (var coin in spentCoins) { smartTransaction.WalletInputs.Add(coin); } var label = SmartLabel.Merge(payments.Requests.Select(x => x.Label).Concat(smartTransaction.WalletInputs.Select(x => x.HdPubKey.Label))); for (var i = 0U; i < tx.Outputs.Count; i++) { TxOut output = tx.Outputs[i]; if (KeyManager.TryGetKeyForScriptPubKey(output.ScriptPubKey, out HdPubKey? foundKey)) { var smartCoin = new SmartCoin(smartTransaction, i, foundKey); label = SmartLabel.Merge(label, smartCoin.HdPubKey.Label); // foundKey's label is already added to the coinlabel. smartTransaction.WalletOutputs.Add(smartCoin); } } foreach (var coin in smartTransaction.WalletOutputs) { var foundPaymentRequest = payments.Requests.FirstOrDefault(x => x.Destination.ScriptPubKey == coin.ScriptPubKey); // If change then we concatenate all the labels. // The foundkeylabel has already been added previously, so no need to concatenate. if (foundPaymentRequest is null) // Then it's autochange. { coin.HdPubKey.SetLabel(label); } else { coin.HdPubKey.SetLabel(SmartLabel.Merge(coin.HdPubKey.Label, foundPaymentRequest.Label)); } } Logger.LogInfo($"Transaction is successfully built: {tx.GetHash()}."); var sign = !KeyManager.IsWatchOnly; return(new BuildTransactionResult(smartTransaction, psbt, sign, fee, feePc)); }