Payouts: Detect External OnChain Payouts (#2462)

* Refactor and decouple Payout logic

So that we can support lightning + external payout payments

Fixes & refactoring

almost there

final

Remove uneeded payment method checks

Refactor payouts to handle custom payment method specific actions

External onchain payments to approved payouts will now require "confirmation" from the merchant that it was sent by them.

add pill tabs for payout status

* Improve some UX around feature

* add test and some fixes

* Only listen to address tracked source and determine based on wallet get tx call from nbx

* Simplify isInternal for Payout detection

* fix test

* Fix Noreferrer test

* Make EnsureNewLightningInvoiceOnPartialPayment more resilient

* Make notifications section test more resilient in CanUsePullPaymentsViaUI
This commit is contained in:
Andrew Camilleri 2021-07-16 09:57:37 +02:00 committed by GitHub
parent eb2b523800
commit 04726b3ee4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 356 additions and 105 deletions

View file

@ -89,7 +89,12 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("AddApiKey")).Click();
s.Driver.FindElement(By.CssSelector("button[value='btcpay.store.canmodifystoresettings:change-store-mode']")).Click();
//there should be a store already by default in the dropdown
var dropdown = s.Driver.FindElement(By.Name("PermissionValues[4].SpecificStores[0]"));
var src = s.Driver.PageSource;
var getPermissionValueIndex =
s.Driver.FindElement(By.CssSelector("input[value='btcpay.store.canmodifystoresettings']"))
.GetAttribute("name")
.Replace(".Permission", ".SpecificStores[0]");
var dropdown = s.Driver.FindElement(By.Name(getPermissionValueIndex));
var option = dropdown.FindElement(By.TagName("option"));
var storeId = option.GetAttribute("value");
option.Click();

View file

@ -1019,6 +1019,51 @@ namespace BTCPayServer.Tests
var payoutsData = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId).ToListAsync();
Assert.True(payoutsData.All(p => p.State == PayoutState.Completed));
});
s.GoToHome();
//offline/external payout test
s.Driver.FindElement(By.Id("NotificationsDropdownToggle")).Click();
s.Driver.FindElement(By.CssSelector("#notificationsForm button")).Click();
var newStore = s.CreateNewStore();
s.GenerateWallet("BTC", "", true, true);
var newWalletId = new WalletId(newStore.storeId, "BTC");
s.GoToWallet(newWalletId, WalletsNavPages.PullPayments);
s.Driver.FindElement(By.Id("NewPullPayment")).Click();
s.Driver.FindElement(By.Id("Name")).SendKeys("External Test");
s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys("0.001");
s.Driver.FindElement(By.Id("Currency")).Clear();
s.Driver.FindElement(By.Id("Currency")).SendKeys("BTC");
s.Driver.FindElement(By.Id("Create")).Click();
s.Driver.FindElement(By.LinkText("View")).Click();
address = await s.Server.ExplorerNode.GetNewAddressAsync();
s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString());
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys(Keys.Enter);
s.FindAlertMessage();
Assert.Contains(PayoutState.AwaitingApproval.GetStateString(), s.Driver.PageSource);
s.GoToWallet(newWalletId, WalletsNavPages.Payouts);
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-view")).Click();
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-selectAllCheckbox")).Click();
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-actions")).Click();
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-approve")).Click();
s.FindAlertMessage();
var tx =await s.Server.ExplorerNode.SendToAddressAsync(address, Money.FromUnit(0.001m, MoneyUnit.BTC));
s.GoToWallet(newWalletId, WalletsNavPages.Payouts);
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingPayment}-view")).Click();
Assert.Contains(PayoutState.AwaitingPayment.GetStateString(), s.Driver.PageSource);
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingPayment}-selectAllCheckbox")).Click();
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingPayment}-actions")).Click();
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingPayment}-confirm-payment")).Click();
s.FindAlertMessage();
s.Driver.FindElement(By.Id("InProgress-view")).Click();
Assert.Contains(tx.ToString(), s.Driver.PageSource);
}
private static void CanBrowseContent(SeleniumTester s)

View file

@ -1018,13 +1018,18 @@ namespace BTCPayServer.Tests
BitcoinAddress.Create(invoice.BitcoinAddress, Network.RegTest), Money.Coins(0.00005m));
}, e => e.InvoiceId == invoice.Id && e.PaymentMethodId.PaymentType == LightningPaymentType.Instance );
await tester.ExplorerNode.GenerateAsync(1);
Invoice newInvoice = null;
await Task.Delay(100); // wait a bit for payment to process before fetching new invoice
var newInvoice = await user.BitPay.GetInvoiceAsync(invoice.Id);
var newBolt11 = newInvoice.CryptoInfo.First(o => o.PaymentUrls.BOLT11 != null).PaymentUrls.BOLT11;
var oldBolt11 = invoice.CryptoInfo.First(o => o.PaymentUrls.BOLT11 != null).PaymentUrls.BOLT11;
Assert.NotEqual(newBolt11, oldBolt11);
Assert.Equal(newInvoice.BtcDue.GetValue(), BOLT11PaymentRequest.Parse(newBolt11, Network.RegTest).MinimumAmount.ToDecimal(LightMoneyUnit.BTC));
await TestUtils.EventuallyAsync(async () =>
{
newInvoice = await user.BitPay.GetInvoiceAsync(invoice.Id);
var newBolt11 = newInvoice.CryptoInfo.First(o => o.PaymentUrls.BOLT11 != null).PaymentUrls.BOLT11;
var oldBolt11 = invoice.CryptoInfo.First(o => o.PaymentUrls.BOLT11 != null).PaymentUrls.BOLT11;
Assert.NotEqual(newBolt11, oldBolt11);
Assert.Equal(newInvoice.BtcDue.GetValue(),
BOLT11PaymentRequest.Parse(newBolt11, Network.RegTest).MinimumAmount.ToDecimal(LightMoneyUnit.BTC));
});
Logs.Tester.LogInformation($"Paying invoice {newInvoice.Id} remaining due amount {newInvoice.BtcDue.GetValue()} via lightning");
var evt = await tester.WaitForEvent<InvoiceDataChangedEvent>(async () =>
{

View file

@ -17,7 +17,7 @@
<div class="dropdown-menu dropdown-menu-end text-center notification-dropdown" aria-labelledby="NotificationsDropdownToggle">
<div class="d-flex align-items-center justify-content-between py-3 px-4 border-bottom border-light">
<h5 class="m-0">Notifications</h5>
<form asp-controller="Notifications" asp-action="MarkAllAsSeen" asp-route-returnUrl="@Context.Request.GetCurrentPathWithQueryString()" method="post">
<form id="notificationsForm" asp-controller="Notifications" asp-action="MarkAllAsSeen" asp-route-returnUrl="@Context.Request.GetCurrentPathWithQueryString()" method="post">
<button class="btn btn-link p-0" type="submit">Mark all as seen</button>
</form>
</div>

View file

@ -202,7 +202,6 @@ namespace BTCPayServer.Controllers
pullPaymentId = vm.PullPaymentId
});
}
var command = vm.Command.Substring(vm.Command.IndexOf('-', StringComparison.InvariantCulture) + 1);
switch (command)
@ -278,16 +277,29 @@ namespace BTCPayServer.Controllers
walletSend.Outputs.Clear();
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
List<string> bip21 = new List<string>();
foreach (var payout in payouts)
{
var blob = payout.GetBlob(_jsonSerializerSettings);
if (payout.GetPaymentMethodId() != paymentMethodId)
if (payout.Proof != null)
{
continue;
}
var blob = payout.GetBlob(_jsonSerializerSettings);
bip21.Add(network.GenerateBIP21(payout.Destination, new Money(blob.CryptoAmount.Value, MoneyUnit.BTC)));
}
return RedirectToAction(nameof(WalletSend), new {walletId, bip21});
if(bip21.Any())
return RedirectToAction(nameof(WalletSend), new {walletId, bip21});
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "There were no payouts eligible to pay from the selection. You may have selected payouts which have detected a transaction to the payout address with the payout amount that you need to accept or reject as the payout."
});
return RedirectToAction(nameof(Payouts), new
{
walletId = walletId.ToString(),
pullPaymentId = vm.PullPaymentId
});
}
case "mark-paid":
@ -338,7 +350,21 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(Payouts),
new {walletId = walletId.ToString(), pullPaymentId = vm.PullPaymentId});
}
var handler = _payoutHandlers
.FirstOrDefault(handler => handler.CanHandle(paymentMethodId));
if (handler != null)
{
var result = await handler.DoSpecificAction(command, payoutIds, walletId.StoreId);
TempData.SetStatusMessageModel(result);
return RedirectToAction(nameof(Payouts), new
{
walletId = walletId.ToString(),
pullPaymentId = vm.PullPaymentId
});
}
return NotFound();
}

View file

@ -1,16 +1,23 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
@ -29,16 +36,18 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly EventAggregator _eventAggregator;
private readonly NotificationSender _notificationSender;
public BitcoinLikePayoutHandler(BTCPayNetworkProvider btcPayNetworkProvider,
ExplorerClientProvider explorerClientProvider, BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
ApplicationDbContextFactory dbContextFactory, EventAggregator eventAggregator)
ApplicationDbContextFactory dbContextFactory, EventAggregator eventAggregator, NotificationSender notificationSender)
{
_btcPayNetworkProvider = btcPayNetworkProvider;
_explorerClientProvider = explorerClientProvider;
_jsonSerializerSettings = jsonSerializerSettings;
_dbContextFactory = dbContextFactory;
_eventAggregator = eventAggregator;
_notificationSender = notificationSender;
}
public bool CanHandle(PaymentMethodId paymentMethod)
@ -47,6 +56,14 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
_btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethod.CryptoCode)?.ReadonlyWallet is false;
}
public async Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination)
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
var explorerClient = _explorerClientProvider.GetExplorerClient(network);
if (claimDestination is IBitcoinLikeClaimDestination bitcoinLikeClaimDestination)
await explorerClient.TrackAsync(TrackedSource.Create(bitcoinLikeClaimDestination.Address));
}
public Task<IClaimDestination> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination)
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
@ -92,9 +109,9 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
public async Task BackgroundCheck(object o)
{
if (o is NewOnChainTransactionEvent newTransaction)
if (o is NewOnChainTransactionEvent newTransaction && newTransaction.NewTransactionEvent.TrackedSource is AddressTrackedSource addressTrackedSource)
{
await UpdatePayoutsAwaitingForPayment(newTransaction);
await UpdatePayoutsAwaitingForPayment(newTransaction, addressTrackedSource);
}
if (o is NewBlockEvent || o is NewOnChainTransactionEvent)
@ -119,11 +136,83 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
return Task.FromResult(0m);
}
public Dictionary<PayoutState, List<(string Action, string Text)>> GetPayoutSpecificActions()
{
return new Dictionary<PayoutState, List<(string Action, string Text)>>()
{
{PayoutState.AwaitingPayment, new List<(string Action, string Text)>()
{
("confirm-payment", "Confirm payouts as paid"),
("reject-payment", "Reject payout transaction")
}}
};
}
public async Task<StatusMessageModel> DoSpecificAction(string action, string[] payoutIds, string storeId)
{
switch (action)
{
case "confirm-payment":
await using (var context = _dbContextFactory.CreateContext())
{
var payouts = (await context.Payouts
.Include(p => p.PullPaymentData)
.Include(p => p.PullPaymentData.StoreData)
.Where(p => payoutIds.Contains(p.Id))
.Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived && p.State == PayoutState.AwaitingPayment)
.ToListAsync()).Where(data => CanHandle(PaymentMethodId.Parse(data.PaymentMethodId)))
.Select(data => (data, ParseProof(data) as PayoutTransactionOnChainBlob)).Where(tuple=> tuple.Item2 != null && tuple.Item2.TransactionId != null && tuple.Item2.Accounted == false);
foreach (var valueTuple in payouts)
{
valueTuple.Item2.Accounted = true;
valueTuple.data.State = PayoutState.InProgress;
SetProofBlob(valueTuple.data, valueTuple.Item2);
}
await context.SaveChangesAsync();
}
return new StatusMessageModel()
{
Message = "Payout payments have been marked confirmed",
Severity = StatusMessageModel.StatusSeverity.Success
};
case "reject-payment":
await using (var context = _dbContextFactory.CreateContext())
{
var payouts = (await context.Payouts
.Include(p => p.PullPaymentData)
.Include(p => p.PullPaymentData.StoreData)
.Where(p => payoutIds.Contains(p.Id))
.Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived && p.State == PayoutState.AwaitingPayment)
.ToListAsync()).Where(data => CanHandle(PaymentMethodId.Parse(data.PaymentMethodId)))
.Select(data => (data, ParseProof(data) as PayoutTransactionOnChainBlob)).Where(tuple=> tuple.Item2 != null && tuple.Item2.TransactionId != null && tuple.Item2.Accounted == true);
foreach (var valueTuple in payouts)
{
valueTuple.Item2.TransactionId = null;
SetProofBlob(valueTuple.data, valueTuple.Item2);
}
await context.SaveChangesAsync();
}
return new StatusMessageModel()
{
Message = "Payout payments have been unmarked",
Severity = StatusMessageModel.StatusSeverity.Success
};
}
return new StatusMessageModel()
{
Message = "Unknown action",
Severity = StatusMessageModel.StatusSeverity.Error
};;
}
private async Task UpdatePayoutsInProgress()
{
try
{
using var ctx = _dbContextFactory.CreateContext();
await using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts
.Include(p => p.PullPaymentData)
.Where(p => p.State == PayoutState.InProgress)
@ -196,76 +285,72 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
}
}
private async Task UpdatePayoutsAwaitingForPayment(NewOnChainTransactionEvent newTransaction)
private async Task UpdatePayoutsAwaitingForPayment(NewOnChainTransactionEvent newTransaction,
AddressTrackedSource addressTrackedSource)
{
try
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(newTransaction.CryptoCode);
Dictionary<string, decimal> destinations;
if (newTransaction.NewTransactionEvent.TrackedSource is AddressTrackedSource addressTrackedSource)
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())
.Where(p => destination.Equals(p.Destination))
.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)
{
destinations = new Dictionary<string, decimal>()
{
{
addressTrackedSource.Address.ToString(),
newTransaction.NewTransactionEvent.Outputs.Sum(output => output.Value.GetValue(network))
}
};
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
{
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)));
await _notificationSender.SendNotification(new StoreScope(payout.PullPaymentData.StoreId),
new ExternalPayoutTransactionNotification()
{
PaymentMethod = payout.PaymentMethodId,
PayoutId = payout.Id,
StoreId = payout.PullPaymentData.StoreId
});
}
var paymentMethodId = new PaymentMethodId(newTransaction.CryptoCode, BitcoinPaymentType.Instance);
proof.TransactionId ??= txId;
SetProofBlob(payout, proof);
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();
}
@ -274,7 +359,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
Logs.PayServer.LogWarning(ex, "Error while processing a transaction in the pull payment hosted service");
}
}
private void SetProofBlob(PayoutData data, PayoutTransactionOnChainBlob blob)
{
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, _jsonSerializerSettings.GetSerializer(data.GetPaymentMethodId().CryptoCode)));

View file

@ -1,11 +1,16 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Payments;
using PayoutData = BTCPayServer.Data.PayoutData;
public interface IPayoutHandler
{
public bool CanHandle(PaymentMethodId paymentMethod);
public Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination);
//Allows payout handler to parse payout destinations on its own
public Task<IClaimDestination> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination);
public IPayoutProof ParseProof(PayoutData payout);
@ -14,4 +19,6 @@ public interface IPayoutHandler
//allows you to process events that the main pull payment hosted service is subscribed to
Task BackgroundCheck(object o);
Task<decimal> GetMinimumPayoutAmount(PaymentMethodId paymentMethod, IClaimDestination claimDestination);
Dictionary<PayoutState, List<(string Action, string Text)>> GetPayoutSpecificActions();
Task<StatusMessageModel> DoSpecificAction(string action, string[] payoutIds, string storeId);
}

View file

@ -12,6 +12,7 @@ using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Rates;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBXplorer;
@ -152,7 +153,8 @@ namespace BTCPayServer.HostedServices
BTCPayNetworkProvider networkProvider,
NotificationSender notificationSender,
RateFetcher rateFetcher,
IEnumerable<IPayoutHandler> payoutHandlers)
IEnumerable<IPayoutHandler> payoutHandlers,
ILogger<PullPaymentHostedService> logger)
{
_dbContextFactory = dbContextFactory;
_jsonSerializerSettings = jsonSerializerSettings;
@ -162,6 +164,7 @@ namespace BTCPayServer.HostedServices
_notificationSender = notificationSender;
_rateFetcher = rateFetcher;
_payoutHandlers = payoutHandlers;
_logger = logger;
}
Channel<object> _Channel;
@ -173,6 +176,7 @@ namespace BTCPayServer.HostedServices
private readonly NotificationSender _notificationSender;
private readonly RateFetcher _rateFetcher;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly ILogger<PullPaymentHostedService> _logger;
private readonly CompositeDisposable _subscriptions = new CompositeDisposable();
internal override Task[] InitializeTasks()
@ -216,7 +220,14 @@ namespace BTCPayServer.HostedServices
}
foreach (IPayoutHandler payoutHandler in _payoutHandlers)
{
await payoutHandler.BackgroundCheck(o);
try
{
await payoutHandler.BackgroundCheck(o);
}
catch (Exception e)
{
_logger.LogError(e, "PayoutHandler failed during BackgroundCheck");
}
}
}
}
@ -400,6 +411,7 @@ namespace BTCPayServer.HostedServices
await ctx.Payouts.AddAsync(payout);
try
{
await payoutHandler.TrackClaim(req.ClaimRequest.PaymentMethodId, req.ClaimRequest.Destination);
await ctx.SaveChangesAsync();
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout));
await _notificationSender.SendNotification(new StoreScope(pp.StoreId), new PayoutNotification()

View file

@ -355,7 +355,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
services.AddSingleton<INotificationHandler, ExternalPayoutTransactionNotification.Handler>();
services.AddSingleton<IHostedService, DbMigrationsHostedService>();
#if DEBUG
services.AddSingleton<INotificationHandler, JunkNotification.Handler>();

View file

@ -125,7 +125,7 @@ namespace BTCPayServer.Payments.Bitcoin
using (session)
{
await session.ListenNewBlockAsync(_Cts.Token).ConfigureAwait(false);
await session.ListenAllDerivationSchemesAsync(cancellation: _Cts.Token).ConfigureAwait(false);
await session.ListenAllTrackedSourceAsync(cancellation: _Cts.Token).ConfigureAwait(false);
Logs.PayServer.LogInformation($"{network.CryptoCode}: Checking if any pending invoice got paid while offline...");
int paymentCount = await FindPaymentViaPolling(wallet, network);
@ -142,35 +142,44 @@ namespace BTCPayServer.Payments.Bitcoin
_Aggregator.Publish(new Events.NewBlockEvent() { CryptoCode = evt.CryptoCode });
break;
case NBXplorer.Models.NewTransactionEvent evt:
wallet.InvalidateCache(evt.DerivationStrategy);
foreach (var output in network.GetValidOutputs(evt))
if (evt.DerivationStrategy != null)
{
var key = output.Item1.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
var invoice = (await _InvoiceRepository.GetInvoicesFromAddresses(new[] { key })).FirstOrDefault();
if (invoice != null)
wallet.InvalidateCache(evt.DerivationStrategy);
foreach (var output in network.GetValidOutputs(evt))
{
var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy,
output.Item1.KeyPath, output.Item1.ScriptPubKey);
var paymentData = new BitcoinLikePaymentData(address,
output.matchedOutput.Value, output.outPoint,
evt.TransactionData.Transaction.RBF, output.Item1.KeyPath);
var alreadyExist = invoice.GetAllBitcoinPaymentData(false).Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any();
if (!alreadyExist)
var key = output.Item1.ScriptPubKey.Hash + "#" +
network.CryptoCode.ToUpperInvariant();
var invoice = (await _InvoiceRepository.GetInvoicesFromAddresses(new[] {key}))
.FirstOrDefault();
if (invoice != null)
{
var payment = await _InvoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, paymentData, network);
if (payment != null)
await ReceivedPayment(wallet, invoice, payment, evt.DerivationStrategy);
}
else
{
await UpdatePaymentStates(wallet, invoice.Id);
var address = network.NBXplorerNetwork.CreateAddress(evt.DerivationStrategy,
output.Item1.KeyPath, output.Item1.ScriptPubKey);
var paymentData = new BitcoinLikePaymentData(address,
output.matchedOutput.Value, output.outPoint,
evt.TransactionData.Transaction.RBF, output.matchedOutput.KeyPath);
var alreadyExist = invoice
.GetAllBitcoinPaymentData(false).Any(c => c.GetPaymentId() == paymentData.GetPaymentId());
if (!alreadyExist)
{
var payment = await _InvoiceRepository.AddPayment(invoice.Id,
DateTimeOffset.UtcNow, paymentData, network);
if (payment != null)
await ReceivedPayment(wallet, invoice, payment,
evt.DerivationStrategy);
}
else
{
await UpdatePaymentStates(wallet, invoice.Id);
}
}
}
}
_Aggregator.Publish(new NewOnChainTransactionEvent()
{
CryptoCode = wallet.Network.CryptoCode,

View file

@ -0,0 +1,55 @@
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Controllers;
using Microsoft.AspNetCore.Routing;
namespace BTCPayServer.Services.Notifications.Blobs
{
public class ExternalPayoutTransactionNotification : BaseNotification
{
private const string TYPE = "external-payout-transaction";
internal class Handler : NotificationHandler<ExternalPayoutTransactionNotification>
{
private readonly LinkGenerator _linkGenerator;
private readonly BTCPayServerOptions _options;
public Handler(LinkGenerator linkGenerator, BTCPayServerOptions options)
{
_linkGenerator = linkGenerator;
_options = options;
}
public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return new (string identifier, string name)[] {(TYPE, "External payout approval")};
}
}
protected override void FillViewModel(ExternalPayoutTransactionNotification notification,
NotificationViewModel vm)
{
vm.Body =
"A payment that was made to an approved payout by an external wallet is waiting for your confirmation.";
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(WalletsController.Payouts),
"Wallets",
new
{
walletId = new WalletId(notification.StoreId, notification.PaymentMethod),
payoutState = PayoutState.AwaitingPayment
}, _options.RootPath);
}
}
public string PayoutId { get; set; }
public string StoreId { get; set; }
public string PaymentMethod { get; set; }
public override string Identifier => TYPE;
public override string NotificationType => TYPE;
}
}

View file

@ -88,7 +88,7 @@
</tr>
<tr>
<th>Payment Request Id</th>
<td><a href="@Model.PaymentRequestLink">@Model.TypedMetadata.PaymentRequestId</a></td>
<td><a href="@Model.PaymentRequestLink" rel="noreferrer noopener">@Model.TypedMetadata.PaymentRequestId</a></td>
</tr>
<tr>
<th>State</th>

View file

@ -7,6 +7,8 @@
ViewData.SetActivePageAndTitle(WalletsNavPages.Payouts, $"Manage {Model.PaymentMethodId.ToPrettyString()} payouts{(string.IsNullOrEmpty(Model.PullPaymentName) ? string.Empty : " for pull payment " + Model.PullPaymentName)}", Context.GetStoreData().StoreName);
var stateActions = new List<(string Action, string Text)>();
var payoutHandler = PayoutHandlers.First(handler => handler.CanHandle(Model.PaymentMethodId));
stateActions.AddRange(payoutHandler.GetPayoutSpecificActions().Where(pair => pair.Key == Model.PayoutState).SelectMany(pair => pair.Value));
switch (Model.PayoutState)
{
case PayoutState.AwaitingApproval: