public async Task <IActionResult> CreateOnChainTransaction(string storeId, string cryptoCode, [FromBody] CreateOnChainTransactionRequest request) { if (IsInvalidWalletRequest(cryptoCode, out var network, out var derivationScheme, out var actionResult)) { return(actionResult); } if (network.ReadonlyWallet) { return(this.CreateAPIError(503, "not-available", $"{cryptoCode} sending services are not currently available")); } //This API is only meant for hot wallet usage for now. We can expand later when we allow PSBT manipulation. if (!(await CanUseHotWallet()).HotWallet) { return(this.CreateAPIError(503, "not-available", $"You need to allow non-admins to use hotwallets for their stores (in /server/policies)")); } if (request.Destinations == null || !request.Destinations.Any()) { ModelState.AddModelError( nameof(request.Destinations), "At least one destination must be specified" ); return(this.CreateValidationError(ModelState)); } if (request.SelectedInputs != null && request.ExcludeUnconfirmed == true) { ModelState.AddModelError( nameof(request.ExcludeUnconfirmed), "Can't automatically exclude unconfirmed UTXOs while selection custom inputs" ); return(this.CreateValidationError(ModelState)); } var explorerClient = _explorerClientProvider.GetExplorerClient(cryptoCode); var wallet = _btcPayWalletProvider.GetWallet(network); var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation, request.ExcludeUnconfirmed); if (request.SelectedInputs != null || !utxos.Any()) { utxos = utxos.Where(coin => request.SelectedInputs?.Contains(coin.OutPoint) ?? true) .ToArray(); if (utxos.Any() is false) { //no valid utxos selected request.AddModelError(transactionRequest => transactionRequest.SelectedInputs, "There are no available utxos based on your request", this); } } var balanceAvailable = utxos.Sum(coin => coin.Value.GetValue(network)); var subtractFeesOutputsCount = new List <int>(); var subtractFees = request.Destinations.Any(o => o.SubtractFromAmount); int?payjoinOutputIndex = null; var sum = 0m; var outputs = new List <WalletSendModel.TransactionOutput>(); for (var index = 0; index < request.Destinations.Count; index++) { var destination = request.Destinations[index]; if (destination.SubtractFromAmount) { subtractFeesOutputsCount.Add(index); } BitcoinUrlBuilder?bip21 = null; var amount = destination.Amount; if (amount.GetValueOrDefault(0) <= 0) { amount = null; } var address = string.Empty; try { bip21 = new BitcoinUrlBuilder(destination.Destination, network.NBitcoinNetwork); amount ??= bip21.Amount.GetValue(network); if (bip21.Address is null) { request.AddModelError(transactionRequest => transactionRequest.Destinations[index], "This BIP21 destination is missing a bitcoin address", this); } else { address = bip21.Address.ToString(); } if (destination.SubtractFromAmount) { request.AddModelError(transactionRequest => transactionRequest.Destinations[index], "You cannot use a BIP21 destination along with SubtractFromAmount", this); } } catch (FormatException) { try { address = BitcoinAddress.Create(destination.Destination, network.NBitcoinNetwork).ToString(); } catch (Exception) { request.AddModelError(transactionRequest => transactionRequest.Destinations[index], "Destination must be a BIP21 payment link or an address", this); } } if (amount is null || amount <= 0) { request.AddModelError(transactionRequest => transactionRequest.Destinations[index], "Amount must be specified or destination must be a BIP21 payment link, and greater than 0", this); } if (request.ProceedWithPayjoin && bip21?.UnknownParameters?.ContainsKey(PayjoinClient.BIP21EndpointKey) is true) { payjoinOutputIndex = index; } outputs.Add(new WalletSendModel.TransactionOutput() { DestinationAddress = address, Amount = amount, SubtractFeesFromOutput = destination.SubtractFromAmount }); sum += destination.Amount ?? 0; } if (subtractFeesOutputsCount.Count > 1) { foreach (var subtractFeesOutput in subtractFeesOutputsCount) { request.AddModelError(model => model.Destinations[subtractFeesOutput].SubtractFromAmount, "You can only subtract fees from one destination", this); } } if (balanceAvailable < sum) { request.AddModelError(transactionRequest => transactionRequest.Destinations, "You are attempting to send more than is available", this); } else if (balanceAvailable == sum && !subtractFees) { request.AddModelError(transactionRequest => transactionRequest.Destinations, "You are sending your entire balance, you should subtract the fees from a destination", this); } var minRelayFee = _nbXplorerDashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ?? new FeeRate(1.0m); if (request.FeeRate != null && request.FeeRate < minRelayFee) { ModelState.AddModelError(nameof(request.FeeRate), "The fee rate specified is lower than the current minimum relay fee"); } if (!ModelState.IsValid) { return(this.CreateValidationError(ModelState)); } CreatePSBTResponse psbt; try { psbt = await _walletsController.CreatePSBT(network, derivationScheme, new WalletSendModel() { SelectedInputs = request.SelectedInputs?.Select(point => point.ToString()), Outputs = outputs, AlwaysIncludeNonWitnessUTXO = true, InputSelection = request.SelectedInputs?.Any() is true, AllowFeeBump = !request.RBF.HasValue ? WalletSendModel.ThreeStateBool.Maybe : request.RBF.Value ? WalletSendModel.ThreeStateBool.Yes : WalletSendModel.ThreeStateBool.No, FeeSatoshiPerByte = request.FeeRate?.SatoshiPerByte, NoChange = request.NoChange }, CancellationToken.None); }