Пример #1
0
    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);
    }
Пример #2
0
 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;
    }
Пример #4
0
 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());
     }
 }
Пример #5
0
    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();
    }
Пример #6
0
        /// <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);
        }
Пример #7
0
    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);
    }
Пример #8
0
    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();
    }
Пример #9
0
        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);
        }
Пример #10
0
        /// <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));
        }
Пример #11
0
    /// <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));
    }