public Wallet(string dataDir, Network network, KeyManager keyManager) { DataDir = Guard.NotNullOrEmptyOrWhitespace(nameof(dataDir), dataDir); Network = Guard.NotNull(nameof(network), network); KeyManager = Guard.NotNull(nameof(keyManager), keyManager); RuntimeParams.SetDataDir(dataDir); HandleFiltersLock = new AsyncLock(); KeyManager.AssertCleanKeysIndexed(); KeyManager.AssertLockedInternalKeysIndexed(14); }
public WalletService( BitcoinStore bitcoinStore, KeyManager keyManager, WasabiSynchronizer syncer, CoinJoinClient chaumianClient, NodesGroup nodes, string workFolderDir, ServiceConfiguration serviceConfiguration, IFeeProvider feeProvider, CoreNode coreNode = null) { BitcoinStore = Guard.NotNull(nameof(bitcoinStore), bitcoinStore); KeyManager = Guard.NotNull(nameof(keyManager), keyManager); Nodes = Guard.NotNull(nameof(nodes), nodes); Synchronizer = Guard.NotNull(nameof(syncer), syncer); ChaumianClient = Guard.NotNull(nameof(chaumianClient), chaumianClient); ServiceConfiguration = Guard.NotNull(nameof(serviceConfiguration), serviceConfiguration); FeeProvider = Guard.NotNull(nameof(feeProvider), feeProvider); CoreNode = coreNode; HandleFiltersLock = new AsyncLock(); BlocksFolderPath = Path.Combine(workFolderDir, "Blocks", Network.ToString()); RuntimeParams.SetDataDir(workFolderDir); BlockFolderLock = new AsyncLock(); KeyManager.AssertCleanKeysIndexed(); KeyManager.AssertLockedInternalKeysIndexed(14); TransactionProcessor = new TransactionProcessor(BitcoinStore.TransactionStore, KeyManager, ServiceConfiguration.DustThreshold, ServiceConfiguration.PrivacyLevelStrong); Coins = TransactionProcessor.Coins; TransactionProcessor.WalletRelevantTransactionProcessed += TransactionProcessor_WalletRelevantTransactionProcessedAsync; if (Directory.Exists(BlocksFolderPath)) { if (Synchronizer.Network == Network.RegTest) { Directory.Delete(BlocksFolderPath, true); Directory.CreateDirectory(BlocksFolderPath); } } else { Directory.CreateDirectory(BlocksFolderPath); } BitcoinStore.IndexStore.NewFilter += IndexDownloader_NewFilterAsync; BitcoinStore.IndexStore.Reorged += IndexDownloader_ReorgedAsync; BitcoinStore.MempoolService.TransactionReceived += Mempool_TransactionReceived; }
public bool Process(SmartTransaction tx) { if (!tx.Transaction.PossiblyP2WPKHInvolved()) { return(false); // We do not care about non-witness transactions for other than mempool cleanup. } uint256 txId = tx.GetHash(); var walletRelevant = false; if (tx.Confirmed) { foreach (var coin in Coins.Where(x => x.TransactionId == txId)) { coin.Height = tx.Height; walletRelevant = true; // relevant } } if (!tx.Transaction.IsCoinBase) // Transactions we already have and processed would be "double spends" but they shouldn't. { var doubleSpends = new List <SmartCoin>(); foreach (SmartCoin coin in Coins) { var spent = false; foreach (TxoRef spentOutput in coin.SpentOutputs) { foreach (TxIn txIn in tx.Transaction.Inputs) { if (spentOutput.TransactionId == txIn.PrevOut.Hash && spentOutput.Index == txIn.PrevOut.N) // Do not do (spentOutput == txIn.PrevOut), it's faster this way, because it won't check for null. { doubleSpends.Add(coin); spent = true; walletRelevant = true; break; } } if (spent) { break; } } } if (doubleSpends.Any()) { if (tx.Height == Height.Mempool) { // if the received transaction is spending at least one input already // spent by a previous unconfirmed transaction signaling RBF then it is not a double // spanding transaction but a replacement transaction. if (doubleSpends.Any(x => x.IsReplaceable)) { // remove double spent coins (if other coin spends it, remove that too and so on) // will add later if they came to our keys foreach (SmartCoin doubleSpentCoin in doubleSpends.Where(x => !x.Confirmed)) { Coins.TryRemove(doubleSpentCoin); } tx.SetReplacement(); walletRelevant = true; } else { return(false); } } else // new confirmation always enjoys priority { // remove double spent coins recursively (if other coin spends it, remove that too and so on), will add later if they came to our keys foreach (SmartCoin doubleSpentCoin in doubleSpends) { Coins.TryRemove(doubleSpentCoin); } walletRelevant = true; } } } var isLikelyCoinJoinOutput = false; bool hasEqualOutputs = tx.Transaction.GetIndistinguishableOutputs(includeSingle: false).FirstOrDefault() != default; if (hasEqualOutputs) { var receiveKeys = KeyManager.GetKeys(x => tx.Transaction.Outputs.Any(y => y.ScriptPubKey == x.P2wpkhScript)); bool allReceivedInternal = receiveKeys.All(x => x.IsInternal); if (allReceivedInternal) { // It is likely a coinjoin if the diff between receive and sent amount is small and have at least 2 equal outputs. Money spentAmount = Coins.Where(x => tx.Transaction.Inputs.Any(y => y.PrevOut.Hash == x.TransactionId && y.PrevOut.N == x.Index)).Sum(x => x.Amount); Money receivedAmount = tx.Transaction.Outputs.Where(x => receiveKeys.Any(y => y.P2wpkhScript == x.ScriptPubKey)).Sum(x => x.Value); bool receivedAlmostAsMuchAsSpent = spentAmount.Almost(receivedAmount, Money.Coins(0.005m)); if (receivedAlmostAsMuchAsSpent) { isLikelyCoinJoinOutput = true; } } } List <SmartCoin> spentOwnCoins = null; for (var i = 0U; i < tx.Transaction.Outputs.Count; i++) { // If transaction received to any of the wallet keys: var output = tx.Transaction.Outputs[i]; HdPubKey foundKey = KeyManager.GetKeyForScriptPubKey(output.ScriptPubKey); if (foundKey != default) { walletRelevant = true; if (output.Value <= DustThreshold) { continue; } foundKey.SetKeyState(KeyState.Used, KeyManager); spentOwnCoins ??= Coins.Where(x => tx.Transaction.Inputs.Any(y => y.PrevOut.Hash == x.TransactionId && y.PrevOut.N == x.Index)).ToList(); var anonset = tx.Transaction.GetAnonymitySet(i); if (spentOwnCoins.Count != 0) { anonset += spentOwnCoins.Min(x => x.AnonymitySet) - 1; // Minus 1, because do not count own. } SmartCoin newCoin = new SmartCoin(txId, i, output.ScriptPubKey, output.Value, tx.Transaction.Inputs.ToTxoRefs().ToArray(), tx.Height, tx.IsRBF, anonset, isLikelyCoinJoinOutput, foundKey.Label, spenderTransactionId: null, false, pubKey: foundKey); // Do not inherit locked status from key, that's different. // If we did not have it. if (Coins.TryAdd(newCoin)) { CoinReceived?.Invoke(this, newCoin); // Make sure there's always 21 clean keys generated and indexed. KeyManager.AssertCleanKeysIndexed(isInternal: foundKey.IsInternal); if (foundKey.IsInternal) { // Make sure there's always 14 internal locked keys generated and indexed. KeyManager.AssertLockedInternalKeysIndexed(14); } } else // If we had this coin already. { if (newCoin.Height != Height.Mempool) // Update the height of this old coin we already had. { SmartCoin oldCoin = Coins.FirstOrDefault(x => x.TransactionId == txId && x.Index == i); if (oldCoin != null) // Just to be sure, it is a concurrent collection. { oldCoin.Height = newCoin.Height; } } } } } // If spends any of our coin for (var i = 0; i < tx.Transaction.Inputs.Count; i++) { var input = tx.Transaction.Inputs[i]; var foundCoin = Coins.FirstOrDefault(x => x.TransactionId == input.PrevOut.Hash && x.Index == input.PrevOut.N); if (foundCoin != null) { walletRelevant = true; var alreadyKnown = foundCoin.SpenderTransactionId == txId; foundCoin.SpenderTransactionId = txId; if (!alreadyKnown) { CoinSpent?.Invoke(this, foundCoin); } if (tx.Confirmed) { SpenderConfirmed?.Invoke(this, foundCoin); } } } if (walletRelevant) { TransactionStore.AddOrUpdate(tx); } return(walletRelevant); }
/// <exception cref="ArgumentException"></exception> /// <exception cref="ArgumentNullException"></exception> /// <exception cref="ArgumentOutOfRangeException"></exception> public BuildTransactionResult BuildTransaction( PaymentIntent payments, Func <FeeRate> feeRateFetcher, IEnumerable <OutPoint> allowedInputs = null, Func <LockTime> lockTimeSelector = null, IPayjoinClient payjoinClient = null) { payments = Guard.NotNull(nameof(payments), payments); lockTimeSelector ??= () => LockTime.Zero; 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. var availableCoinsView = Coins.Available(); List <SmartCoin> allowedSmartCoinInputs = AllowUnconfirmed // Inputs that can be used to build the transaction. ? availableCoinsView.ToList() : availableCoinsView.Confirmed().ToList(); 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 = 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.GetCoin())); 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 = 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); } builder.OptInRBF = new Random().NextDouble() < Constants.TransactionRBFSignalRate; FeeRate feeRate = feeRateFetcher(); builder.SendEstimatedFees(feeRate); 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 != 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 InvalidOperationException($"The transaction fee is more than twice the sent 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); UpdatePSBTInfo(psbt, spentCoins, changeHdPubKey); if (!KeyManager.IsWatchOnly) { // Try to pay using payjoin if (payjoinClient is { })
/// <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)); }
private ProcessedResult ProcessNoLock(SmartTransaction tx) { var result = new ProcessedResult(tx); // We do not care about non-witness transactions for other than mempool cleanup. if (tx.Transaction.PossiblyP2WPKHInvolved()) { uint256 txId = tx.GetHash(); // Performance ToDo: txids could be cached in a hashset here by the AllCoinsView and then the contains would be fast. if (!tx.Transaction.IsCoinBase && !Coins.AsAllCoinsView().CreatedBy(txId).Any()) // Transactions we already have and processed would be "double spends" but they shouldn't. { var doubleSpends = new List <SmartCoin>(); foreach (var txin in tx.Transaction.Inputs) { if (Coins.TryGetSpenderSmartCoinsByOutPoint(txin.PrevOut, out var coins)) { doubleSpends.AddRange(coins); } } if (doubleSpends.Any()) { if (tx.Height == Height.Mempool) { // if the received transaction is spending at least one input already // spent by a previous unconfirmed transaction signaling RBF then it is not a double // spending transaction but a replacement transaction. var isReplacemenetTx = doubleSpends.Any(x => x.IsReplaceable && !x.Confirmed); if (isReplacemenetTx) { // Undo the replaced transaction by removing the coins it created (if other coin // spends it, remove that too and so on) and restoring those that it replaced. // After undoing the replaced transaction it will process the replacement transaction. var replacedTxId = doubleSpends.First().TransactionId; var(replaced, restored) = Coins.Undo(replacedTxId); result.ReplacedCoins.AddRange(replaced); result.RestoredCoins.AddRange(restored); foreach (var replacedTransactionId in replaced.Select(coin => coin.TransactionId)) { TransactionStore.MempoolStore.TryRemove(replacedTransactionId, out _); } tx.SetReplacement(); } else { return(result); } } else // new confirmation always enjoys priority { // remove double spent coins recursively (if other coin spends it, remove that too and so on), will add later if they came to our keys foreach (SmartCoin doubleSpentCoin in doubleSpends) { Coins.Remove(doubleSpentCoin); } result.SuccessfullyDoubleSpentCoins.AddRange(doubleSpends); var unconfirmedDoubleSpentTxId = doubleSpends.First().TransactionId; TransactionStore.MempoolStore.TryRemove(unconfirmedDoubleSpentTxId, out _); } } } List <SmartCoin> spentOwnCoins = null; for (var i = 0U; i < tx.Transaction.Outputs.Count; i++) { // If transaction received to any of the wallet keys: var output = tx.Transaction.Outputs[i]; HdPubKey foundKey = KeyManager.GetKeyForScriptPubKey(output.ScriptPubKey); if (foundKey != default) { if (output.Value <= DustThreshold) { result.ReceivedDusts.Add(output); continue; } foundKey.SetKeyState(KeyState.Used, KeyManager); spentOwnCoins ??= Coins.OutPoints(tx.Transaction.Inputs.ToTxoRefs()).ToList(); var anonset = tx.Transaction.GetAnonymitySet(i); if (spentOwnCoins.Count != 0) { anonset += spentOwnCoins.Min(x => x.AnonymitySet) - 1; // Minus 1, because do not count own. } SmartCoin newCoin = new SmartCoin(txId, i, output.ScriptPubKey, output.Value, tx.Transaction.Inputs.ToTxoRefs().ToArray(), tx.Height, tx.IsRBF, anonset, foundKey.Label, spenderTransactionId: null, false, pubKey: foundKey); // Do not inherit locked status from key, that's different. result.ReceivedCoins.Add(newCoin); // If we did not have it. if (Coins.TryAdd(newCoin)) { result.NewlyReceivedCoins.Add(newCoin); // Make sure there's always 21 clean keys generated and indexed. KeyManager.AssertCleanKeysIndexed(isInternal: foundKey.IsInternal); if (foundKey.IsInternal) { // Make sure there's always 14 internal locked keys generated and indexed. KeyManager.AssertLockedInternalKeysIndexed(14); } } else // If we had this coin already. { if (newCoin.Height != Height.Mempool) // Update the height of this old coin we already had. { SmartCoin oldCoin = Coins.AsAllCoinsView().GetByOutPoint(new OutPoint(txId, i)); if (oldCoin is { }) // Just to be sure, it is a concurrent collection.
/// <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)); }