Example #1
0
        protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
        {
            if (evt is InvoiceEvent invoiceEvent && invoiceEvent.Name == InvoiceEvent.ReceivedPayment &&
                invoiceEvent.Payment.GetPaymentMethodId()?.PaymentType == BitcoinPaymentType.Instance &&
                invoiceEvent.Payment.GetCryptoPaymentData() is BitcoinLikePaymentData bitcoinLikePaymentData)
            {
                var walletId      = new WalletId(invoiceEvent.Invoice.StoreId, invoiceEvent.Payment.GetCryptoCode());
                var transactionId = bitcoinLikePaymentData.Outpoint.Hash;
                var labels        = new List <(string color, Label label)>
                {
                    UpdateTransactionLabel.InvoiceLabelTemplate(invoiceEvent.Invoice.Id)
                };

                if (invoiceEvent.Invoice.GetPayments(invoiceEvent.Payment.GetCryptoCode(), false).Any(entity =>
                                                                                                      entity.GetCryptoPaymentData() is BitcoinLikePaymentData pData &&
                                                                                                      pData.PayjoinInformation?.CoinjoinTransactionHash == transactionId))
                {
                    labels.Add(UpdateTransactionLabel.PayjoinLabelTemplate());
                }

                foreach (var paymentId in PaymentRequestRepository.GetPaymentIdsFromInternalTags(invoiceEvent.Invoice))
                {
                    labels.Add(UpdateTransactionLabel.PaymentRequestLabelTemplate(paymentId));
                }
                foreach (var appId in  AppService.GetAppInternalTags(invoiceEvent.Invoice))
                {
                    labels.Add(UpdateTransactionLabel.AppLabelTemplate(appId));
                }



                _eventAggregator.Publish(new UpdateTransactionLabel(walletId, transactionId, labels));
            }
Example #2
0
        protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
        {
            if (evt is InvoiceEvent invoiceEvent && invoiceEvent.Name == InvoiceEvent.ReceivedPayment &&
                invoiceEvent.Payment.GetPaymentMethodId().PaymentType == BitcoinPaymentType.Instance &&
                invoiceEvent.Payment.GetCryptoPaymentData() is BitcoinLikePaymentData bitcoinLikePaymentData)
            {
                var walletId      = new WalletId(invoiceEvent.Invoice.StoreId, invoiceEvent.Payment.GetCryptoCode());
                var transactionId = bitcoinLikePaymentData.Outpoint.Hash;
                var labels        = new List <(string color, string label)>
                {
                    UpdateTransactionLabel.InvoiceLabelTemplate(invoiceEvent.Invoice.Id)
                };

                if (invoiceEvent.Invoice.GetPayments(invoiceEvent.Payment.GetCryptoCode()).Any(entity =>
                                                                                               entity.GetCryptoPaymentData() is BitcoinLikePaymentData pData &&
                                                                                               pData.PayjoinInformation?.CoinjoinTransactionHash == transactionId))
                {
                    labels.Add(UpdateTransactionLabel.PayjoinLabelTemplate());
                }

                _eventAggregator.Publish(new UpdateTransactionLabel()
                {
                    WalletId          = walletId,
                    TransactionLabels =
                        new Dictionary <uint256, List <(string color, string label)> >()
                    {
                        { transactionId, labels }
                    }
                });
            }
Example #3
0
        public async Task <IActionResult> Submit(string cryptoCode,
                                                 long?maxadditionalfeecontribution,
                                                 int?additionalfeeoutputindex,
                                                 decimal minfeerate             = -1.0m,
                                                 bool disableoutputsubstitution = false,
                                                 int v = 1)
        {
            var network = _btcPayNetworkProvider.GetNetwork <BTCPayNetwork>(cryptoCode);

            if (network == null)
            {
                return(NotFound());
            }

            if (v != 1)
            {
                return(BadRequest(new JObject
                {
                    new JProperty("errorCode", "version-unsupported"),
                    new JProperty("supported", new JArray(1)),
                    new JProperty("message", "This version of payjoin is not supported.")
                }));
            }

            await using var ctx = new PayjoinReceiverContext(_invoiceRepository, _explorerClientProvider.GetExplorerClient(network), _payJoinRepository);
            ObjectResult CreatePayjoinErrorAndLog(int httpCode, PayjoinReceiverWellknownErrors err, string debug)
            {
                ctx.Logs.Write($"Payjoin error: {debug}");
                return(StatusCode(httpCode, CreatePayjoinError(err, debug)));
            }

            var explorer = _explorerClientProvider.GetExplorerClient(network);

            if (Request.ContentLength is long length)
            {
                if (length > 1_000_000)
                {
                    return(this.StatusCode(413,
                                           CreatePayjoinError("payload-too-large", "The transaction is too big to be processed")));
                }
            }
            else
            {
                return(StatusCode(411,
                                  CreatePayjoinError("missing-content-length",
                                                     "The http header Content-Length should be filled")));
            }

            string rawBody;

            using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8))
            {
                rawBody = (await reader.ReadToEndAsync()) ?? string.Empty;
            }

            FeeRate originalFeeRate = null;
            bool    psbtFormat      = true;

            if (PSBT.TryParse(rawBody, network.NBitcoinNetwork, out var psbt))
            {
                if (!psbt.IsAllFinalized())
                {
                    return(BadRequest(CreatePayjoinError("original-psbt-rejected", "The PSBT should be finalized")));
                }
                ctx.OriginalTransaction = psbt.ExtractTransaction();
            }
            // BTCPay Server implementation support a transaction instead of PSBT
            else
            {
                psbtFormat = false;
                if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var tx))
                {
                    return(BadRequest(CreatePayjoinError("original-psbt-rejected", "invalid transaction or psbt")));
                }
                ctx.OriginalTransaction = tx;
                psbt = PSBT.FromTransaction(tx, network.NBitcoinNetwork);
                psbt = (await explorer.UpdatePSBTAsync(new UpdatePSBTRequest()
                {
                    PSBT = psbt
                })).PSBT;
                for (int i = 0; i < tx.Inputs.Count; i++)
                {
                    psbt.Inputs[i].FinalScriptSig     = tx.Inputs[i].ScriptSig;
                    psbt.Inputs[i].FinalScriptWitness = tx.Inputs[i].WitScript;
                }
            }

            FeeRate senderMinFeeRate             = minfeerate >= 0.0m ? new FeeRate(minfeerate) : null;
            Money   allowedSenderFeeContribution = Money.Satoshis(maxadditionalfeecontribution is long t && t >= 0 ? t : 0);

            var sendersInputType = psbt.GetInputsScriptPubKeyType();

            if (psbt.CheckSanity() is var errors && errors.Count != 0)
            {
                return(BadRequest(CreatePayjoinError("original-psbt-rejected", $"This PSBT is insane ({errors[0]})")));
            }
            if (!psbt.TryGetEstimatedFeeRate(out originalFeeRate))
            {
                return(BadRequest(CreatePayjoinError("original-psbt-rejected",
                                                     "You need to provide Witness UTXO information to the PSBT.")));
            }

            // This is actually not a mandatory check, but we don't want implementers
            // to leak global xpubs
            if (psbt.GlobalXPubs.Any())
            {
                return(BadRequest(CreatePayjoinError("original-psbt-rejected",
                                                     "GlobalXPubs should not be included in the PSBT")));
            }

            if (psbt.Outputs.Any(o => o.HDKeyPaths.Count != 0) || psbt.Inputs.Any(o => o.HDKeyPaths.Count != 0))
            {
                return(BadRequest(CreatePayjoinError("original-psbt-rejected",
                                                     "Keypath information should not be included in the PSBT")));
            }

            if (psbt.Inputs.Any(o => !o.IsFinalized()))
            {
                return(BadRequest(CreatePayjoinError("original-psbt-rejected", "The PSBT Should be finalized")));
            }
            ////////////

            var mempool = await explorer.BroadcastAsync(ctx.OriginalTransaction, true);

            if (!mempool.Success)
            {
                ctx.DoNotBroadcast();
                return(BadRequest(CreatePayjoinError("original-psbt-rejected",
                                                     $"Provided transaction isn't mempool eligible {mempool.RPCCodeMessage}")));
            }
            var   enforcedLowR    = ctx.OriginalTransaction.Inputs.All(IsLowR);
            var   paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
            bool  paidSomething   = false;
            Money due             = null;
            Dictionary <OutPoint, UTXO> selectedUTXOs      = new Dictionary <OutPoint, UTXO>();
            PSBTOutput               originalPaymentOutput = null;
            BitcoinAddress           paymentAddress        = null;
            InvoiceEntity            invoice = null;
            DerivationSchemeSettings derivationSchemeSettings = null;

            foreach (var output in psbt.Outputs)
            {
                var key = output.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
                invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] { key })).FirstOrDefault();
                if (invoice is null)
                {
                    continue;
                }
                derivationSchemeSettings = invoice.GetSupportedPaymentMethod <DerivationSchemeSettings>(paymentMethodId)
                                           .SingleOrDefault();
                if (derivationSchemeSettings is null)
                {
                    continue;
                }

                var receiverInputsType = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType();
                if (!PayjoinClient.SupportedFormats.Contains(receiverInputsType))
                {
                    //this should never happen, unless the store owner changed the wallet mid way through an invoice
                    return(CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "Our wallet does not support payjoin"));
                }
                if (sendersInputType is ScriptPubKeyType t1 && t1 != receiverInputsType)
                {
                    return(CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "We do not have any UTXO available for making a payjoin with the sender's inputs type"));
                }
                var paymentMethod  = invoice.GetPaymentMethod(paymentMethodId);
                var paymentDetails =
                    paymentMethod.GetPaymentMethodDetails() as Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod;
                if (paymentDetails is null || !paymentDetails.PayjoinEnabled)
                {
                    continue;
                }
                if (invoice.GetAllBitcoinPaymentData().Any())
                {
                    ctx.DoNotBroadcast();
                    return(UnprocessableEntity(CreatePayjoinError("already-paid",
                                                                  $"The invoice this PSBT is paying has already been partially or completely paid")));
                }

                paidSomething = true;
                due           = paymentMethod.Calculate().TotalDue - output.Value;
                if (due > Money.Zero)
                {
                    break;
                }

                if (!await _payJoinRepository.TryLockInputs(ctx.OriginalTransaction.Inputs.Select(i => i.PrevOut).ToArray()))
                {
                    return(CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "Some of those inputs have already been used to make another payjoin transaction"));
                }

                var utxos = (await explorer.GetUTXOsAsync(derivationSchemeSettings.AccountDerivation))
                            .GetUnspentUTXOs(false);
                // In case we are paying ourselves, be need to make sure
                // we can't take spent outpoints.
                var prevOuts = ctx.OriginalTransaction.Inputs.Select(o => o.PrevOut).ToHashSet();
                utxos = utxos.Where(u => !prevOuts.Contains(u.Outpoint)).ToArray();
                Array.Sort(utxos, UTXODeterministicComparer.Instance);
                foreach (var utxo in (await SelectUTXO(network, utxos, psbt.Inputs.Select(input => input.WitnessUtxo.Value.ToDecimal(MoneyUnit.BTC)), output.Value.ToDecimal(MoneyUnit.BTC),
                                                       psbt.Outputs.Where(psbtOutput => psbtOutput.Index != output.Index).Select(psbtOutput => psbtOutput.Value.ToDecimal(MoneyUnit.BTC)))).selectedUTXO)
                {
                    selectedUTXOs.Add(utxo.Outpoint, utxo);
                }
                ctx.LockedUTXOs       = selectedUTXOs.Select(u => u.Key).ToArray();
                originalPaymentOutput = output;
                paymentAddress        = paymentDetails.GetDepositAddress(network.NBitcoinNetwork);
                break;
            }

            if (!paidSomething)
            {
                return(BadRequest(CreatePayjoinError("invoice-not-found",
                                                     "This transaction does not pay any invoice with payjoin")));
            }

            if (due is null || due > Money.Zero)
            {
                return(BadRequest(CreatePayjoinError("invoice-not-fully-paid",
                                                     "The transaction must pay the whole invoice")));
            }

            if (selectedUTXOs.Count == 0)
            {
                return(CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "We do not have any UTXO available for contributing to a payjoin"));
            }

            var originalPaymentValue = originalPaymentOutput.Value;
            await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), ctx.OriginalTransaction, network);

            //check if wallet of store is configured to be hot wallet
            var extKeyStr = await explorer.GetMetadataAsync <string>(
                derivationSchemeSettings.AccountDerivation,
                WellknownMetadataKeys.AccountHDKey);

            if (extKeyStr == null)
            {
                // This should not happen, as we check the existance of private key before creating invoice with payjoin
                return(CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "The HD Key of the store changed"));
            }

            Money           contributedAmount = Money.Zero;
            var             newTx             = ctx.OriginalTransaction.Clone();
            var             ourNewOutput      = newTx.Outputs[originalPaymentOutput.Index];
            HashSet <TxOut> isOurOutput       = new HashSet <TxOut>();

            isOurOutput.Add(ourNewOutput);
            TxOut feeOutput =
                additionalfeeoutputindex is int feeOutputIndex &&
                maxadditionalfeecontribution is long v3 &&
                v3 >= 0 &&
                feeOutputIndex >= 0 &&
                feeOutputIndex < newTx.Outputs.Count &&
                !isOurOutput.Contains(newTx.Outputs[feeOutputIndex])
                ? newTx.Outputs[feeOutputIndex] : null;
            var rand             = new Random();
            int senderInputCount = newTx.Inputs.Count;

            foreach (var selectedUTXO in selectedUTXOs.Select(o => o.Value))
            {
                contributedAmount += (Money)selectedUTXO.Value;
                var newInput = newTx.Inputs.Add(selectedUTXO.Outpoint);
                newInput.Sequence = newTx.Inputs[rand.Next(0, senderInputCount)].Sequence;
            }
            ourNewOutput.Value += contributedAmount;
            var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ??
                                new FeeRate(1.0m);

            // Remove old signatures as they are not valid anymore
            foreach (var input in newTx.Inputs)
            {
                input.WitScript = WitScript.Empty;
            }

            Money ourFeeContribution = Money.Zero;
            // We need to adjust the fee to keep a constant fee rate
            var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder();
            var coins     = psbt.Inputs.Select(i => i.GetSignableCoin())
                            .Concat(selectedUTXOs.Select(o => o.Value.AsCoin(derivationSchemeSettings.AccountDerivation))).ToArray();

            txBuilder.AddCoins(coins);
            Money expectedFee   = txBuilder.EstimateFees(newTx, originalFeeRate);
            Money actualFee     = newTx.GetFee(txBuilder.FindSpentCoins(newTx));
            Money additionalFee = expectedFee - actualFee;

            if (additionalFee > Money.Zero)
            {
                // If the user overpaid, taking fee on our output (useful if sender dump a full UTXO for privacy)
                for (int i = 0; i < newTx.Outputs.Count && additionalFee > Money.Zero && due < Money.Zero; i++)
                {
                    if (disableoutputsubstitution)
                    {
                        break;
                    }
                    if (isOurOutput.Contains(newTx.Outputs[i]))
                    {
                        var outputContribution = Money.Min(additionalFee, -due);
                        outputContribution = Money.Min(outputContribution,
                                                       newTx.Outputs[i].Value - newTx.Outputs[i].GetDustThreshold(minRelayTxFee));
                        newTx.Outputs[i].Value -= outputContribution;
                        additionalFee          -= outputContribution;
                        due += outputContribution;
                        ourFeeContribution += outputContribution;
                    }
                }

                // The rest, we take from user's change
                if (feeOutput != null)
                {
                    var outputContribution = Money.Min(additionalFee, feeOutput.Value);
                    outputContribution = Money.Min(outputContribution,
                                                   feeOutput.Value - feeOutput.GetDustThreshold(minRelayTxFee));
                    outputContribution            = Money.Min(outputContribution, allowedSenderFeeContribution);
                    feeOutput.Value              -= outputContribution;
                    additionalFee                -= outputContribution;
                    allowedSenderFeeContribution -= outputContribution;
                }

                if (additionalFee > Money.Zero)
                {
                    // We could not pay fully the additional fee, however, as long as
                    // we are not under the relay fee, it should be OK.
                    var newVSize   = txBuilder.EstimateSize(newTx, true);
                    var newFeePaid = newTx.GetFee(txBuilder.FindSpentCoins(newTx));
                    if (new FeeRate(newFeePaid, newVSize) < (senderMinFeeRate ?? minRelayTxFee))
                    {
                        return(CreatePayjoinErrorAndLog(422, PayjoinReceiverWellknownErrors.NotEnoughMoney, "Not enough money is sent to pay for the additional payjoin inputs"));
                    }
                }
            }

            var accountKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork);
            var newPsbt    = PSBT.FromTransaction(newTx, network.NBitcoinNetwork);

            foreach (var selectedUtxo in selectedUTXOs.Select(o => o.Value))
            {
                var signedInput = newPsbt.Inputs.FindIndexedInput(selectedUtxo.Outpoint);
                var coin        = selectedUtxo.AsCoin(derivationSchemeSettings.AccountDerivation);
                signedInput.UpdateFromCoin(coin);
                var privateKey = accountKey.Derive(selectedUtxo.KeyPath).PrivateKey;
                signedInput.Sign(privateKey, new SigningOptions()
                {
                    EnforceLowR = enforcedLowR
                });
                signedInput.FinalizeInput();
                newTx.Inputs[signedInput.Index].WitScript = newPsbt.Inputs[(int)signedInput.Index].FinalScriptWitness;
            }

            // Add the transaction to the payments with a confirmation of -1.
            // This will make the invoice paid even if the user do not
            // broadcast the payjoin.
            var originalPaymentData = new BitcoinLikePaymentData(paymentAddress,
                                                                 originalPaymentOutput.Value,
                                                                 new OutPoint(ctx.OriginalTransaction.GetHash(), originalPaymentOutput.Index),
                                                                 ctx.OriginalTransaction.RBF);

            originalPaymentData.ConfirmationCount  = -1;
            originalPaymentData.PayjoinInformation = new PayjoinInformation()
            {
                CoinjoinTransactionHash = GetExpectedHash(newPsbt, coins),
                CoinjoinValue           = originalPaymentValue - ourFeeContribution,
                ContributedOutPoints    = selectedUTXOs.Select(o => o.Key).ToArray()
            };
            var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, originalPaymentData, network, true);

            if (payment is null)
            {
                return(UnprocessableEntity(CreatePayjoinError("already-paid",
                                                              $"The original transaction has already been accounted")));
            }
            await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(ctx.OriginalTransaction);

            _eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment)
            {
                Payment = payment
            });
            _eventAggregator.Publish(new UpdateTransactionLabel()
            {
                WalletId          = new WalletId(invoice.StoreId, network.CryptoCode),
                TransactionLabels = selectedUTXOs.GroupBy(pair => pair.Key.Hash).Select(utxo =>
                                                                                        new KeyValuePair <uint256, List <(string color, string label)> >(utxo.Key,
                                                                                                                                                         new List <(string color, string label)>()
                {
                    UpdateTransactionLabel.PayjoinExposedLabelTemplate(invoice.Id)
                }))
                                    .ToDictionary(pair => pair.Key, pair => pair.Value)
            });
Example #4
0
        protected override async Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts)
        {
            var storePaymentMethod = paymentMethod as DerivationSchemeSettings;

            if (storePaymentMethod?.IsHotWallet is not true)
            {
                Logs.PayServer.LogInformation($"Wallet is not a hot wallet.");
                return;
            }

            if (!_explorerClientProvider.IsAvailable(PaymentMethodId.CryptoCode))
            {
                Logs.PayServer.LogInformation($"{paymentMethod.PaymentId.CryptoCode} node is not available");
                return;
            }
            var explorerClient  = _explorerClientProvider.GetExplorerClient(PaymentMethodId.CryptoCode);
            var paymentMethodId = PaymentMethodId.Parse(PaymentMethodId.CryptoCode);
            var network         = _btcPayNetworkProvider.GetNetwork <BTCPayNetwork>(paymentMethodId.CryptoCode);

            var extKeyStr = await explorerClient.GetMetadataAsync <string>(
                storePaymentMethod.AccountDerivation,
                WellknownMetadataKeys.AccountHDKey);

            if (extKeyStr == null)
            {
                Logs.PayServer.LogInformation($"Wallet keys not found.");
                return;
            }

            var wallet = _btcPayWalletProvider.GetWallet(PaymentMethodId.CryptoCode);

            var reccoins = (await wallet.GetUnspentCoins(storePaymentMethod.AccountDerivation)).ToArray();
            var coins    = reccoins.Select(coin => coin.Coin).ToArray();

            var         accountKey    = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork);
            var         keys          = reccoins.Select(coin => accountKey.Derive(coin.KeyPath).PrivateKey).ToArray();
            Transaction workingTx     = null;
            decimal?    failedAmount  = null;
            var         changeAddress = await explorerClient.GetUnusedAsync(
                storePaymentMethod.AccountDerivation, DerivationFeature.Change, 0, true);

            var feeRate = await explorerClient.GetFeeRateAsync(1, new FeeRate(1m));

            var transfersProcessing = new List <PayoutData>();

            foreach (var transferRequest in payouts)
            {
                var blob = transferRequest.GetBlob(_btcPayNetworkJsonSerializerSettings);
                if (failedAmount.HasValue && blob.CryptoAmount >= failedAmount)
                {
                    continue;
                }

                var claimDestination =
                    await _bitcoinLikePayoutHandler.ParseClaimDestination(paymentMethodId, blob.Destination);

                if (!string.IsNullOrEmpty(claimDestination.error))
                {
                    Logs.PayServer.LogInformation($"Could not process payout {transferRequest.Id} because {claimDestination.error}.");
                    continue;
                }

                var bitcoinClaimDestination = (IBitcoinLikeClaimDestination)claimDestination.destination;
                var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder()
                                .AddCoins(coins)
                                .AddKeys(keys);

                if (workingTx is not null)
                {
                    foreach (var txout in workingTx.Outputs.Where(txout =>
                                                                  !txout.IsTo(changeAddress.Address)))
                    {
                        txBuilder.Send(txout.ScriptPubKey, txout.Value);
                    }
                }

                txBuilder.Send(bitcoinClaimDestination.Address,
                               new Money(blob.CryptoAmount.Value, MoneyUnit.BTC));

                try
                {
                    txBuilder.SetChange(changeAddress.Address);
                    txBuilder.SendEstimatedFees(feeRate.FeeRate);
                    workingTx = txBuilder.BuildTransaction(true);
                    transfersProcessing.Add(transferRequest);
                }
                catch (NotEnoughFundsException e)
                {
                    Logs.PayServer.LogInformation($"Could not process payout {transferRequest.Id} because of not enough funds. ({e.Missing.GetValue(network)})");
                    failedAmount = blob.CryptoAmount;
                    //keep going, we prioritize withdraws by time but if there is some other we can fit, we should
                }
            }

            if (workingTx is not null)
            {
                try
                {
                    await using var context = _applicationDbContextFactory.CreateContext();
                    var txHash = workingTx.GetHash();
                    Logs.PayServer.LogInformation($"Processing {transfersProcessing.Count} payouts in tx {txHash}");
                    foreach (PayoutData payoutData in transfersProcessing)
                    {
                        context.Attach(payoutData);
                        payoutData.State = PayoutState.InProgress;
                        _bitcoinLikePayoutHandler.SetProofBlob(payoutData,
                                                               new PayoutTransactionOnChainBlob()
                        {
                            Accounted     = true,
                            TransactionId = txHash,
                            Candidates    = new HashSet <uint256>()
                            {
                                txHash
                            }
                        });
                        await context.SaveChangesAsync();
                    }
                    TaskCompletionSource <bool> tcs = new();
                    var cts = new CancellationTokenSource();
                    cts.CancelAfter(TimeSpan.FromSeconds(20));
                    var task = _eventAggregator.WaitNext <NewOnChainTransactionEvent>(
                        e => e.NewTransactionEvent.TransactionData.TransactionHash == txHash,
                        cts.Token);
                    var broadcastResult = await explorerClient.BroadcastAsync(workingTx, cts.Token);

                    if (!broadcastResult.Success)
                    {
                        tcs.SetResult(false);
                    }
                    var walletId = new WalletId(_PayoutProcesserSettings.StoreId, PaymentMethodId.CryptoCode);
                    foreach (PayoutData payoutData in transfersProcessing)
                    {
                        _eventAggregator.Publish(new UpdateTransactionLabel(walletId,
                                                                            txHash,
                                                                            UpdateTransactionLabel.PayoutTemplate(payoutData.Id, payoutData.PullPaymentDataId,
                                                                                                                  walletId.ToString())));
                    }
                    await Task.WhenAny(tcs.Task, task);
                }
                catch (OperationCanceledException)
                {
                }
                catch (Exception e)
                {
                    Logs.PayServer.LogError(e, "Could not finalize and broadcast");
                }
            }
        }
Example #5
0
        public async Task <IActionResult> WalletPSBTReady(
            [ModelBinder(typeof(WalletIdModelBinder))]
            WalletId walletId, WalletPSBTViewModel vm, string command, CancellationToken cancellationToken = default)
        {
            var  network = NetworkProvider.GetNetwork <BTCPayNetwork>(walletId.CryptoCode);
            PSBT psbt    = await vm.GetPSBT(network.NBitcoinNetwork);

            if (vm.InvalidPSBT || psbt is null)
            {
                if (vm.InvalidPSBT)
                {
                    vm.GlobalError = "Invalid PSBT";
                }
                return(View(nameof(WalletPSBT), vm));
            }
            DerivationSchemeSettings derivationSchemeSettings = GetDerivationSchemeSettings(walletId);

            if (derivationSchemeSettings == null)
            {
                return(NotFound());
            }

            await FetchTransactionDetails(derivationSchemeSettings, vm, network);

            switch (command)
            {
            case "payjoin":
                string error;
                try
                {
                    var proposedPayjoin = await GetPayjoinProposedTX(new BitcoinUrlBuilder(vm.SigningContext.PayJoinBIP21, network.NBitcoinNetwork), psbt,
                                                                     derivationSchemeSettings, network, cancellationToken);

                    try
                    {
                        proposedPayjoin.Settings.SigningOptions = new SigningOptions
                        {
                            EnforceLowR = !(vm.SigningContext?.EnforceLowR is false)
                        };
                        var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork);
                        proposedPayjoin = proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation,
                                                                  extKey,
                                                                  RootedKeyPath.Parse(vm.SigningKeyPath));
                        vm.SigningContext.PSBT         = proposedPayjoin.ToBase64();
                        vm.SigningContext.OriginalPSBT = psbt.ToBase64();
                        proposedPayjoin.Finalize();
                        var hash = proposedPayjoin.ExtractTransaction().GetHash();
                        _EventAggregator.Publish(new UpdateTransactionLabel(walletId, hash, UpdateTransactionLabel.PayjoinLabelTemplate()));
                        TempData.SetStatusMessageModel(new StatusMessageModel
                        {
                            Severity     = StatusMessageModel.StatusSeverity.Success,
                            AllowDismiss = false,
                            Html         = $"The payjoin transaction has been successfully broadcasted ({proposedPayjoin.ExtractTransaction().GetHash()})"
                        });
                        return(await WalletPSBTReady(walletId, vm, "broadcast"));
                    }
                    catch (Exception)
                    {
                        TempData.SetStatusMessageModel(new StatusMessageModel()
                        {
                            Severity     = StatusMessageModel.StatusSeverity.Warning,
                            AllowDismiss = false,
                            Html         =
                                "This transaction has been coordinated between the receiver and you to create a <a href='https://en.bitcoin.it/wiki/PayJoin' target='_blank'>payjoin transaction</a> by adding inputs from the receiver.<br/>" +
                                "The amount being sent may appear higher but is in fact almost same.<br/><br/>" +
                                "If you cancel or refuse to sign this transaction, the payment will proceed without payjoin"
                        });
                        vm.SigningContext.PSBT         = proposedPayjoin.ToBase64();
                        vm.SigningContext.OriginalPSBT = psbt.ToBase64();
                        return(ViewVault(walletId, vm.SigningContext));
                    }
                }
                catch (PayjoinReceiverException ex)
                {
                    error = $"The payjoin receiver could not complete the payjoin: {ex.Message}";
                }
                catch (PayjoinSenderException ex)
                {
                    error = $"We rejected the receiver's payjoin proposal: {ex.Message}";
                }
                catch (Exception ex)
                {
                    error = $"Unexpected payjoin error: {ex.Message}";
                }

                //we possibly exposed the tx to the receiver, so we need to broadcast straight away
                psbt.Finalize();
                TempData.SetStatusMessageModel(new StatusMessageModel
                {
                    Severity     = StatusMessageModel.StatusSeverity.Warning,
                    AllowDismiss = false,
                    Html         = $"The payjoin transaction could not be created.<br/>" +
                                   $"The original transaction was broadcasted instead. ({psbt.ExtractTransaction().GetHash()})<br/><br/>" +
                                   $"{error}"
                });
                return(await WalletPSBTReady(walletId, vm, "broadcast"));

            case "broadcast" when !psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors):
                vm.SetErrors(errors);
                return(View(nameof(WalletPSBT), vm));

            case "broadcast":
            {
                var transaction = psbt.ExtractTransaction();
                try
                {
                    var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction);

                    if (!broadcastResult.Success)
                    {
                        if (!string.IsNullOrEmpty(vm.SigningContext.OriginalPSBT))
                        {
                            TempData.SetStatusMessageModel(new StatusMessageModel
                                {
                                    Severity     = StatusMessageModel.StatusSeverity.Warning,
                                    AllowDismiss = false,
                                    Html         = $"The payjoin transaction could not be broadcasted.<br/>({broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}).<br/>The transaction has been reverted back to its original format and has been broadcast."
                                });
                            vm.SigningContext.PSBT         = vm.SigningContext.OriginalPSBT;
                            vm.SigningContext.OriginalPSBT = null;
                            return(await WalletPSBTReady(walletId, vm, "broadcast"));
                        }

                        vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}";
                        return(View(nameof(WalletPSBT), vm));
                    }
                    else
                    {
                        var wallet             = _walletProvider.GetWallet(network);
                        var derivationSettings = GetDerivationSchemeSettings(walletId);
                        wallet.InvalidateCache(derivationSettings.AccountDerivation);
                    }
                }
                catch (Exception ex)
                {
                    vm.GlobalError = "Error while broadcasting: " + ex.Message;
                    return(View(nameof(WalletPSBT), vm));
                }

                if (!TempData.HasStatusMessage())
                {
                    TempData[WellKnownTempData.SuccessMessage] = $"Transaction broadcasted successfully ({transaction.GetHash()})";
                }
                var returnUrl = this.HttpContext.Request.Query["returnUrl"].FirstOrDefault();
                if (returnUrl is not null)
                {
                    return(Redirect(returnUrl));
                }
                return(RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }));
            }

            case "analyze-psbt":
                return(RedirectToWalletPSBT(new WalletPSBTViewModel()
                {
                    PSBT = psbt.ToBase64()
                }));

            case "decode":
                await FetchTransactionDetails(derivationSchemeSettings, vm, network);

                return(View("WalletPSBTDecoded", vm));

            default:
                vm.GlobalError = "Unknown command";
                return(View(nameof(WalletPSBT), vm));
            }
        }
Example #6
0
    private async Task UpdatePayoutsAwaitingForPayment(NewOnChainTransactionEvent newTransaction)
    {
        try
        {
            var network = _btcPayNetworkProvider.GetNetwork <BTCPayNetwork>(newTransaction.CryptoCode);
            Dictionary <string, decimal> destinations;
            if (newTransaction.NewTransactionEvent.TrackedSource is AddressTrackedSource addressTrackedSource)
            {
                destinations = new Dictionary <string, decimal>()
                {
                    {
                        addressTrackedSource.Address.ToString(),
                            newTransaction.NewTransactionEvent.Outputs.Sum(output => output.Value.GetValue(network))
                    }
                };
            }
            else
            {
                destinations = newTransaction.NewTransactionEvent.TransactionData.Transaction.Outputs
                               .GroupBy(txout => txout.ScriptPubKey)
                               .ToDictionary(
                    txoutSet => txoutSet.Key.GetDestinationAddress(network.NBitcoinNetwork).ToString(),
                    txoutSet => txoutSet.Sum(txout => txout.Value.ToDecimal(MoneyUnit.BTC)));
            }

            var paymentMethodId = new PaymentMethodId(newTransaction.CryptoCode, BitcoinPaymentType.Instance);

            using var ctx = _dbContextFactory.CreateContext();
            var payouts = await ctx.Payouts
                          .Include(o => o.PullPaymentData)
                          .Where(p => p.State == PayoutState.AwaitingPayment)
                          .Where(p => p.PaymentMethodId == paymentMethodId.ToString())
                          .Where(p => destinations.Keys.Contains(p.Destination))
                          .ToListAsync();

            var payoutByDestination = payouts.ToDictionary(p => p.Destination);
            foreach (var destination in destinations)
            {
                if (!payoutByDestination.TryGetValue(destination.Key, out var payout))
                {
                    continue;
                }
                var payoutBlob = payout.GetBlob(_jsonSerializerSettings);
                if (payoutBlob.CryptoAmount is null ||
                    // The round up here is not strictly necessary, this is temporary to fix existing payout before we
                    // were properly roundup the crypto amount
                    destination.Value != BTCPayServer.Extensions.RoundUp(payoutBlob.CryptoAmount.Value, network.Divisibility))
                {
                    continue;
                }
                var proof = ParseProof(payout) as PayoutTransactionOnChainBlob;
                if (proof is null)
                {
                    proof = new PayoutTransactionOnChainBlob()
                    {
                        Accounted = !(newTransaction.NewTransactionEvent.TrackedSource is AddressTrackedSource),
                    };
                }
                var txId = newTransaction.NewTransactionEvent.TransactionData.TransactionHash;
                if (proof.Candidates.Add(txId))
                {
                    if (proof.Accounted is true)
                    {
                        payout.State = PayoutState.InProgress;
                        var walletId = new WalletId(payout.PullPaymentData.StoreId, newTransaction.CryptoCode);
                        _eventAggregator.Publish(new UpdateTransactionLabel(walletId,
                                                                            newTransaction.NewTransactionEvent.TransactionData.TransactionHash,
                                                                            UpdateTransactionLabel.PayoutTemplate(payout.Id, payout.PullPaymentDataId, walletId.ToString())));
                    }
                    if (proof.TransactionId is null)
                    {
                        proof.TransactionId = txId;
                    }
                    SetProofBlob(payout, proof);
                }
            }

            await ctx.SaveChangesAsync();
        }
        catch (Exception ex)
        {
            Logs.PayServer.LogWarning(ex, "Error while processing a transaction in the pull payment hosted service");
        }
    }
Example #7
0
        public async Task <IActionResult> WalletPSBTReady(
            [ModelBinder(typeof(WalletIdModelBinder))]
            WalletId walletId, WalletPSBTReadyViewModel vm, string command = null, CancellationToken cancellationToken = default)
        {
            if (command == null)
            {
                return(await WalletPSBTReady(walletId, vm));
            }
            PSBT psbt    = null;
            var  network = NetworkProvider.GetNetwork <BTCPayNetwork>(walletId.CryptoCode);
            DerivationSchemeSettings derivationSchemeSettings = null;

            try
            {
                psbt = PSBT.Parse(vm.SigningContext.PSBT, network.NBitcoinNetwork);
                derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
                if (derivationSchemeSettings == null)
                {
                    return(NotFound());
                }
                await FetchTransactionDetails(derivationSchemeSettings, vm, network);
            }
            catch
            {
                vm.GlobalError = "Invalid PSBT";
                return(View(nameof(WalletPSBTReady), vm));
            }

            switch (command)
            {
            case "payjoin":
                string error = null;
                try
                {
                    var proposedPayjoin = await GetPayjoinProposedTX(new BitcoinUrlBuilder(vm.SigningContext.PayJoinBIP21, network.NBitcoinNetwork), psbt,
                                                                     derivationSchemeSettings, network, cancellationToken);

                    try
                    {
                        var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork);
                        proposedPayjoin = proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation,
                                                                  extKey,
                                                                  RootedKeyPath.Parse(vm.SigningKeyPath), new SigningOptions()
                        {
                            EnforceLowR = !(vm.SigningContext?.EnforceLowR is false)
                        });
                        vm.SigningContext.PSBT         = proposedPayjoin.ToBase64();
                        vm.SigningContext.OriginalPSBT = psbt.ToBase64();
                        proposedPayjoin.Finalize();
                        var hash = proposedPayjoin.ExtractTransaction().GetHash();
                        _EventAggregator.Publish(new UpdateTransactionLabel(walletId, hash, UpdateTransactionLabel.PayjoinLabelTemplate()));
                        TempData.SetStatusMessageModel(new StatusMessageModel()
                        {
                            Severity     = StatusMessageModel.StatusSeverity.Success,
                            AllowDismiss = false,
                            Html         = $"The payjoin transaction has been successfully broadcasted ({proposedPayjoin.ExtractTransaction().GetHash()})"
                        });
                        return(await WalletPSBTReady(walletId, vm, "broadcast"));
                    }
                    catch (Exception)
                    {
                        TempData.SetStatusMessageModel(new StatusMessageModel()
                        {
                            Severity     = StatusMessageModel.StatusSeverity.Warning,
                            AllowDismiss = false,
                            Html         =
                                $"This transaction has been coordinated between the receiver and you to create a <a href='https://en.bitcoin.it/wiki/PayJoin' target='_blank'>payjoin transaction</a> by adding inputs from the receiver.<br/>" +
                                $"The amount being sent may appear higher but is in fact almost same.<br/><br/>" +
                                $"If you cancel or refuse to sign this transaction, the payment will proceed without payjoin"
                        });
                        vm.SigningContext.PSBT         = proposedPayjoin.ToBase64();
                        vm.SigningContext.OriginalPSBT = psbt.ToBase64();
                        return(ViewVault(walletId, vm.SigningContext));
                    }
                }
    private async Task UpdatePayoutsAwaitingForPayment(NewOnChainTransactionEvent newTransaction,
                                                       AddressTrackedSource addressTrackedSource)
    {
        try
        {
            var network        = _btcPayNetworkProvider.GetNetwork <BTCPayNetwork>(newTransaction.CryptoCode);
            var destinationSum =
                newTransaction.NewTransactionEvent.Outputs.Sum(output => output.Value.GetValue(network));
            var destination     = addressTrackedSource.Address.ToString();
            var paymentMethodId = new PaymentMethodId(newTransaction.CryptoCode, BitcoinPaymentType.Instance);

            await using var ctx = _dbContextFactory.CreateContext();
            var payouts = await ctx.Payouts
                          .Include(o => o.PullPaymentData)
                          .ThenInclude(o => o.StoreData)
                          .Where(p => p.State == PayoutState.AwaitingPayment)
                          .Where(p => p.PaymentMethodId == paymentMethodId.ToString())
#pragma warning disable CA1307 // Specify StringComparison
                          .Where(p => destination.Equals(p.Destination))
#pragma warning restore CA1307 // Specify StringComparison
                          .ToListAsync();

            var payoutByDestination = payouts.ToDictionary(p => p.Destination);

            if (!payoutByDestination.TryGetValue(destination, out var payout))
            {
                return;
            }
            var payoutBlob = payout.GetBlob(_jsonSerializerSettings);
            if (payoutBlob.CryptoAmount is null ||
                // The round up here is not strictly necessary, this is temporary to fix existing payout before we
                // were properly roundup the crypto amount
                destinationSum !=
                BTCPayServer.Extensions.RoundUp(payoutBlob.CryptoAmount.Value, network.Divisibility))
            {
                return;
            }

            var derivationSchemeSettings = payout.PullPaymentData.StoreData
                                           .GetDerivationSchemeSettings(_btcPayNetworkProvider, newTransaction.CryptoCode).AccountDerivation;

            var storeWalletMatched = (await _explorerClientProvider.GetExplorerClient(newTransaction.CryptoCode)
                                      .GetTransactionAsync(derivationSchemeSettings,
                                                           newTransaction.NewTransactionEvent.TransactionData.TransactionHash));
            //if the wallet related to the store related to the payout does not have the tx: it is external
            var isInternal = storeWalletMatched is { };

            var proof = ParseProof(payout) as PayoutTransactionOnChainBlob ??
                        new PayoutTransactionOnChainBlob()
            {
                Accounted = isInternal
            };
            var txId = newTransaction.NewTransactionEvent.TransactionData.TransactionHash;
            if (!proof.Candidates.Add(txId))
            {
                return;
            }
            if (isInternal)
            {
                payout.State = PayoutState.InProgress;
                var walletId = new WalletId(payout.PullPaymentData.StoreId, newTransaction.CryptoCode);
                _eventAggregator.Publish(new UpdateTransactionLabel(walletId,
                                                                    newTransaction.NewTransactionEvent.TransactionData.TransactionHash,
                                                                    UpdateTransactionLabel.PayoutTemplate(payout.Id, payout.PullPaymentDataId, walletId.ToString())));
            }
            else
            {
                await _notificationSender.SendNotification(new StoreScope(payout.PullPaymentData.StoreId),
                                                           new ExternalPayoutTransactionNotification()
                {
                    PaymentMethod = payout.PaymentMethodId,
                    PayoutId      = payout.Id,
                    StoreId       = payout.PullPaymentData.StoreId
                });
            }

            proof.TransactionId ??= txId;
            SetProofBlob(payout, proof);


            await ctx.SaveChangesAsync();
        }
        catch (Exception ex)
        {
            Logs.PayServer.LogWarning(ex, "Error while processing a transaction in the pull payment hosted service");
        }
    }
        public async Task <IActionResult> Submit(string cryptoCode)
        {
            var network = _btcPayNetworkProvider.GetNetwork <BTCPayNetwork>(cryptoCode);

            if (network == null)
            {
                return(BadRequest(CreatePayjoinError(400, "invalid-network", "Incorrect network")));
            }

            var explorer = _explorerClientProvider.GetExplorerClient(network);

            if (Request.ContentLength is long length)
            {
                if (length > 1_000_000)
                {
                    return(this.StatusCode(413,
                                           CreatePayjoinError(413, "payload-too-large", "The transaction is too big to be processed")));
                }
            }
            else
            {
                return(StatusCode(411,
                                  CreatePayjoinError(411, "missing-content-length",
                                                     "The http header Content-Length should be filled")));
            }

            string rawBody;

            using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8))
            {
                rawBody = (await reader.ReadToEndAsync()) ?? string.Empty;
            }

            Transaction originalTx      = null;
            FeeRate     originalFeeRate = null;
            bool        psbtFormat      = true;

            if (!PSBT.TryParse(rawBody, network.NBitcoinNetwork, out var psbt))
            {
                psbtFormat = false;
                if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var tx))
                {
                    return(BadRequest(CreatePayjoinError(400, "invalid-format", "invalid transaction or psbt")));
                }
                originalTx = tx;
                psbt       = PSBT.FromTransaction(tx, network.NBitcoinNetwork);
                psbt       = (await explorer.UpdatePSBTAsync(new UpdatePSBTRequest()
                {
                    PSBT = psbt
                })).PSBT;
                for (int i = 0; i < tx.Inputs.Count; i++)
                {
                    psbt.Inputs[i].FinalScriptSig     = tx.Inputs[i].ScriptSig;
                    psbt.Inputs[i].FinalScriptWitness = tx.Inputs[i].WitScript;
                }
            }
            else
            {
                if (!psbt.IsAllFinalized())
                {
                    return(BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT should be finalized")));
                }
                originalTx = psbt.ExtractTransaction();
            }

            async Task BroadcastNow()
            {
                await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx);
            }

            var sendersInputType = psbt.GetInputsScriptPubKeyType();

            if (sendersInputType is null)
            {
                return(BadRequest(CreatePayjoinError(400, "unsupported-inputs", "Payjoin only support segwit inputs (of the same type)")));
            }
            if (psbt.CheckSanity() is var errors && errors.Count != 0)
            {
                return(BadRequest(CreatePayjoinError(400, "insane-psbt", $"This PSBT is insane ({errors[0]})")));
            }
            if (!psbt.TryGetEstimatedFeeRate(out originalFeeRate))
            {
                return(BadRequest(CreatePayjoinError(400, "need-utxo-information",
                                                     "You need to provide Witness UTXO information to the PSBT.")));
            }

            // This is actually not a mandatory check, but we don't want implementers
            // to leak global xpubs
            if (psbt.GlobalXPubs.Any())
            {
                return(BadRequest(CreatePayjoinError(400, "leaking-data",
                                                     "GlobalXPubs should not be included in the PSBT")));
            }

            if (psbt.Outputs.Any(o => o.HDKeyPaths.Count != 0) || psbt.Inputs.Any(o => o.HDKeyPaths.Count != 0))
            {
                return(BadRequest(CreatePayjoinError(400, "leaking-data",
                                                     "Keypath information should not be included in the PSBT")));
            }

            if (psbt.Inputs.Any(o => !o.IsFinalized()))
            {
                return(BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT Should be finalized")));
            }
            ////////////

            var mempool = await explorer.BroadcastAsync(originalTx, true);

            if (!mempool.Success)
            {
                return(BadRequest(CreatePayjoinError(400, "invalid-transaction",
                                                     $"Provided transaction isn't mempool eligible {mempool.RPCCodeMessage}")));
            }

            var   paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
            bool  paidSomething   = false;
            Money due             = null;
            Dictionary <OutPoint, UTXO> selectedUTXOs = new Dictionary <OutPoint, UTXO>();

            async Task UnlockUTXOs()
            {
                await _payJoinRepository.TryUnlock(selectedUTXOs.Select(o => o.Key).ToArray());
            }

            PSBTOutput               originalPaymentOutput = null;
            BitcoinAddress           paymentAddress        = null;
            InvoiceEntity            invoice = null;
            DerivationSchemeSettings derivationSchemeSettings = null;

            foreach (var output in psbt.Outputs)
            {
                var key = output.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
                invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] { key })).FirstOrDefault();
                if (invoice is null)
                {
                    continue;
                }
                derivationSchemeSettings = invoice.GetSupportedPaymentMethod <DerivationSchemeSettings>(paymentMethodId)
                                           .SingleOrDefault();
                if (derivationSchemeSettings is null)
                {
                    continue;
                }

                var receiverInputsType = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType();
                if (!PayjoinClient.SupportedFormats.Contains(receiverInputsType))
                {
                    //this should never happen, unless the store owner changed the wallet mid way through an invoice
                    return(StatusCode(500, CreatePayjoinError(500, "unavailable", $"This service is unavailable for now")));
                }
                if (sendersInputType != receiverInputsType)
                {
                    return(StatusCode(503,
                                      CreatePayjoinError(503, "out-of-utxos",
                                                         "We do not have any UTXO available for making a payjoin with the sender's inputs type")));
                }
                var paymentMethod  = invoice.GetPaymentMethod(paymentMethodId);
                var paymentDetails =
                    paymentMethod.GetPaymentMethodDetails() as Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod;
                if (paymentDetails is null || !paymentDetails.PayjoinEnabled)
                {
                    continue;
                }
                if (invoice.GetAllBitcoinPaymentData().Any())
                {
                    return(UnprocessableEntity(CreatePayjoinError(422, "already-paid",
                                                                  $"The invoice this PSBT is paying has already been partially or completely paid")));
                }

                paidSomething = true;
                due           = paymentMethod.Calculate().TotalDue - output.Value;
                if (due > Money.Zero)
                {
                    break;
                }

                if (!await _payJoinRepository.TryLockInputs(originalTx.Inputs.Select(i => i.PrevOut).ToArray()))
                {
                    return(BadRequest(CreatePayjoinError(400, "inputs-already-used",
                                                         "Some of those inputs have already been used to make payjoin transaction")));
                }

                var utxos = (await explorer.GetUTXOsAsync(derivationSchemeSettings.AccountDerivation))
                            .GetUnspentUTXOs(false);
                // In case we are paying ourselves, be need to make sure
                // we can't take spent outpoints.
                var prevOuts = originalTx.Inputs.Select(o => o.PrevOut).ToHashSet();
                utxos = utxos.Where(u => !prevOuts.Contains(u.Outpoint)).ToArray();
                Array.Sort(utxos, UTXODeterministicComparer.Instance);

                foreach (var utxo in (await SelectUTXO(network, utxos, psbt.Inputs.Select(input => input.WitnessUtxo.Value.ToDecimal(MoneyUnit.BTC)), output.Value.ToDecimal(MoneyUnit.BTC),
                                                       psbt.Outputs.Where(psbtOutput => psbtOutput.Index != output.Index).Select(psbtOutput => psbtOutput.Value.ToDecimal(MoneyUnit.BTC)))).selectedUTXO)
                {
                    selectedUTXOs.Add(utxo.Outpoint, utxo);
                }

                originalPaymentOutput = output;
                paymentAddress        = paymentDetails.GetDepositAddress(network.NBitcoinNetwork);
                break;
            }

            if (!paidSomething)
            {
                return(BadRequest(CreatePayjoinError(400, "invoice-not-found",
                                                     "This transaction does not pay any invoice with payjoin")));
            }

            if (due is null || due > Money.Zero)
            {
                return(BadRequest(CreatePayjoinError(400, "invoice-not-fully-paid",
                                                     "The transaction must pay the whole invoice")));
            }

            if (selectedUTXOs.Count == 0)
            {
                await BroadcastNow();

                return(StatusCode(503,
                                  CreatePayjoinError(503, "out-of-utxos",
                                                     "We do not have any UTXO available for making a payjoin for now")));
            }

            var originalPaymentValue = originalPaymentOutput.Value;
            await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), originalTx, network);

            //check if wallet of store is configured to be hot wallet
            var extKeyStr = await explorer.GetMetadataAsync <string>(
                derivationSchemeSettings.AccountDerivation,
                WellknownMetadataKeys.AccountHDKey);

            if (extKeyStr == null)
            {
                // This should not happen, as we check the existance of private key before creating invoice with payjoin
                await UnlockUTXOs();
                await BroadcastNow();

                return(StatusCode(500, CreatePayjoinError(500, "unavailable", $"This service is unavailable for now")));
            }

            Money           contributedAmount = Money.Zero;
            var             newTx             = originalTx.Clone();
            var             ourNewOutput      = newTx.Outputs[originalPaymentOutput.Index];
            HashSet <TxOut> isOurOutput       = new HashSet <TxOut>();

            isOurOutput.Add(ourNewOutput);
            var rand             = new Random();
            int senderInputCount = newTx.Inputs.Count;

            foreach (var selectedUTXO in selectedUTXOs.Select(o => o.Value))
            {
                contributedAmount += (Money)selectedUTXO.Value;
                var newInput = newTx.Inputs.Add(selectedUTXO.Outpoint);
                newInput.Sequence = newTx.Inputs[rand.Next(0, senderInputCount)].Sequence;
            }
            ourNewOutput.Value += contributedAmount;
            var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ??
                                new FeeRate(1.0m);

            // Probably receiving some spare change, let's add an output to make
            // it looks more like a normal transaction
            if (newTx.Outputs.Count == 1)
            {
                var change = await explorer.GetUnusedAsync(derivationSchemeSettings.AccountDerivation, DerivationFeature.Change);

                var randomChangeAmount = RandomUtils.GetUInt64() % (ulong)contributedAmount.Satoshi;

                // Randomly round the amount to make the payment output look like a change output
                var roundMultiple = (ulong)Math.Pow(10, (ulong)Math.Log10(randomChangeAmount));
                while (roundMultiple > 1_000UL)
                {
                    if (RandomUtils.GetUInt32() % 2 == 0)
                    {
                        roundMultiple = roundMultiple / 10;
                    }
                    else
                    {
                        randomChangeAmount = (randomChangeAmount / roundMultiple) * roundMultiple;
                        break;
                    }
                }

                var fakeChange = newTx.Outputs.CreateNewTxOut(randomChangeAmount, change.ScriptPubKey);
                if (fakeChange.IsDust(minRelayTxFee))
                {
                    randomChangeAmount = fakeChange.GetDustThreshold(minRelayTxFee);
                    fakeChange.Value   = randomChangeAmount;
                }
                if (randomChangeAmount < contributedAmount)
                {
                    ourNewOutput.Value -= fakeChange.Value;
                    newTx.Outputs.Add(fakeChange);
                    isOurOutput.Add(fakeChange);
                }
            }

            Utils.Shuffle(newTx.Inputs, rand);
            Utils.Shuffle(newTx.Outputs, rand);

            // Remove old signatures as they are not valid anymore
            foreach (var input in newTx.Inputs)
            {
                input.WitScript = WitScript.Empty;
            }

            Money ourFeeContribution = Money.Zero;
            // We need to adjust the fee to keep a constant fee rate
            var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder();
            var coins     = psbt.Inputs.Select(i => i.GetSignableCoin())
                            .Concat(selectedUTXOs.Select(o => o.Value.AsCoin(derivationSchemeSettings.AccountDerivation))).ToArray();

            txBuilder.AddCoins(coins);
            Money expectedFee   = txBuilder.EstimateFees(newTx, originalFeeRate);
            Money actualFee     = newTx.GetFee(txBuilder.FindSpentCoins(newTx));
            Money additionalFee = expectedFee - actualFee;

            if (additionalFee > Money.Zero)
            {
                // If the user overpaid, taking fee on our output (useful if sender dump a full UTXO for privacy)
                for (int i = 0; i < newTx.Outputs.Count && additionalFee > Money.Zero && due < Money.Zero; i++)
                {
                    if (isOurOutput.Contains(newTx.Outputs[i]))
                    {
                        var outputContribution = Money.Min(additionalFee, -due);
                        outputContribution = Money.Min(outputContribution,
                                                       newTx.Outputs[i].Value - newTx.Outputs[i].GetDustThreshold(minRelayTxFee));
                        newTx.Outputs[i].Value -= outputContribution;
                        additionalFee          -= outputContribution;
                        due += outputContribution;
                        ourFeeContribution += outputContribution;
                    }
                }

                // The rest, we take from user's change
                for (int i = 0; i < newTx.Outputs.Count && additionalFee > Money.Zero; i++)
                {
                    if (!isOurOutput.Contains(newTx.Outputs[i]))
                    {
                        var outputContribution = Money.Min(additionalFee, newTx.Outputs[i].Value);
                        outputContribution = Money.Min(outputContribution,
                                                       newTx.Outputs[i].Value - newTx.Outputs[i].GetDustThreshold(minRelayTxFee));
                        newTx.Outputs[i].Value -= outputContribution;
                        additionalFee          -= outputContribution;
                    }
                }

                if (additionalFee > Money.Zero)
                {
                    // We could not pay fully the additional fee, however, as long as
                    // we are not under the relay fee, it should be OK.
                    var newVSize   = txBuilder.EstimateSize(newTx, true);
                    var newFeePaid = newTx.GetFee(txBuilder.FindSpentCoins(newTx));
                    if (new FeeRate(newFeePaid, newVSize) < minRelayTxFee)
                    {
                        await UnlockUTXOs();
                        await BroadcastNow();

                        return(UnprocessableEntity(CreatePayjoinError(422, "not-enough-money",
                                                                      "Not enough money is sent to pay for the additional payjoin inputs")));
                    }
                }
            }

            var accountKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork);
            var newPsbt    = PSBT.FromTransaction(newTx, network.NBitcoinNetwork);

            foreach (var selectedUtxo in selectedUTXOs.Select(o => o.Value))
            {
                var signedInput = newPsbt.Inputs.FindIndexedInput(selectedUtxo.Outpoint);
                var coin        = selectedUtxo.AsCoin(derivationSchemeSettings.AccountDerivation);
                signedInput.UpdateFromCoin(coin);
                var privateKey = accountKey.Derive(selectedUtxo.KeyPath).PrivateKey;
                signedInput.Sign(privateKey);
                signedInput.FinalizeInput();
                newTx.Inputs[signedInput.Index].WitScript = newPsbt.Inputs[(int)signedInput.Index].FinalScriptWitness;
            }

            // Add the transaction to the payments with a confirmation of -1.
            // This will make the invoice paid even if the user do not
            // broadcast the payjoin.
            var originalPaymentData = new BitcoinLikePaymentData(paymentAddress,
                                                                 originalPaymentOutput.Value,
                                                                 new OutPoint(originalTx.GetHash(), originalPaymentOutput.Index),
                                                                 originalTx.RBF);

            originalPaymentData.ConfirmationCount  = -1;
            originalPaymentData.PayjoinInformation = new PayjoinInformation()
            {
                CoinjoinTransactionHash = GetExpectedHash(newPsbt, coins),
                CoinjoinValue           = originalPaymentValue - ourFeeContribution,
                ContributedOutPoints    = selectedUTXOs.Select(o => o.Key).ToArray()
            };
            var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, originalPaymentData, network, true);

            if (payment is null)
            {
                await UnlockUTXOs();
                await BroadcastNow();

                return(UnprocessableEntity(CreatePayjoinError(422, "already-paid",
                                                              $"The original transaction has already been accounted")));
            }
            await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(originalTx);

            _eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment)
            {
                Payment = payment
            });
            _eventAggregator.Publish(new UpdateTransactionLabel()
            {
                WalletId          = new WalletId(invoice.StoreId, network.CryptoCode),
                TransactionLabels = selectedUTXOs.GroupBy(pair => pair.Key.Hash).Select(utxo =>
                                                                                        new KeyValuePair <uint256, List <(string color, string label)> >(utxo.Key,
                                                                                                                                                         new List <(string color, string label)>()
                {
                    UpdateTransactionLabel.PayjoinExposedLabelTemplate(invoice.Id)
                }))
                                    .ToDictionary(pair => pair.Key, pair => pair.Value)
            });
Example #10
0
        public async Task <IActionResult> WalletPSBTReady(
            [ModelBinder(typeof(WalletIdModelBinder))]
            WalletId walletId, WalletPSBTReadyViewModel vm, string command = null, CancellationToken cancellationToken = default)
        {
            if (command == null)
            {
                return(await WalletPSBTReady(walletId, vm));
            }
            PSBT psbt    = null;
            var  network = NetworkProvider.GetNetwork <BTCPayNetwork>(walletId.CryptoCode);
            DerivationSchemeSettings derivationSchemeSettings = null;

            try
            {
                psbt = PSBT.Parse(vm.SigningContext.PSBT, network.NBitcoinNetwork);
                derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
                if (derivationSchemeSettings == null)
                {
                    return(NotFound());
                }
                await FetchTransactionDetails(derivationSchemeSettings, vm, network);
            }
            catch
            {
                vm.GlobalError = "Invalid PSBT";
                return(View(nameof(WalletPSBTReady), vm));
            }

            switch (command)
            {
            case "payjoin":
                string error = null;
                try
                {
                    var proposedPayjoin = await GetPayjoinProposedTX(vm.SigningContext.PayJoinEndpointUrl, psbt,
                                                                     derivationSchemeSettings, network, cancellationToken);

                    try
                    {
                        var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork);
                        proposedPayjoin = proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation,
                                                                  extKey,
                                                                  RootedKeyPath.Parse(vm.SigningKeyPath), new SigningOptions()
                        {
                            EnforceLowR = !(vm.SigningContext?.EnforceLowR is false)
                        });
                        vm.SigningContext.PSBT         = proposedPayjoin.ToBase64();
                        vm.SigningContext.OriginalPSBT = psbt.ToBase64();
                        proposedPayjoin.Finalize();
                        var hash = proposedPayjoin.ExtractTransaction().GetHash();
                        _EventAggregator.Publish(new UpdateTransactionLabel()
                        {
                            WalletId          = walletId,
                            TransactionLabels = new Dictionary <uint256, List <(string color, string label)> >()
                            {
                                {
                                    hash,
                                    new List <(string color, string label)>
                                    {
                                        UpdateTransactionLabel.PayjoinLabelTemplate()
                                    }
                                }
                            }
                        });
                        TempData.SetStatusMessageModel(new StatusMessageModel()
                        {
                            Severity     = StatusMessageModel.StatusSeverity.Success,
                            AllowDismiss = false,
                            Html         = $"The payjoin transaction has been successfully broadcasted ({proposedPayjoin.ExtractTransaction().GetHash()})"
                        });
                        return(await WalletPSBTReady(walletId, vm, "broadcast"));
                    }