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.Id("AddApiKey")).Click();
s.Driver.FindElement(By.CssSelector("button[value='btcpay.store.canmodifystoresettings:change-store-mode']")).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 //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 option = dropdown.FindElement(By.TagName("option"));
var storeId = option.GetAttribute("value"); var storeId = option.GetAttribute("value");
option.Click(); option.Click();

View file

@ -1019,6 +1019,51 @@ namespace BTCPayServer.Tests
var payoutsData = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId).ToListAsync(); var payoutsData = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId).ToListAsync();
Assert.True(payoutsData.All(p => p.State == PayoutState.Completed)); 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) private static void CanBrowseContent(SeleniumTester s)

View file

@ -1018,12 +1018,17 @@ namespace BTCPayServer.Tests
BitcoinAddress.Create(invoice.BitcoinAddress, Network.RegTest), Money.Coins(0.00005m)); BitcoinAddress.Create(invoice.BitcoinAddress, Network.RegTest), Money.Coins(0.00005m));
}, e => e.InvoiceId == invoice.Id && e.PaymentMethodId.PaymentType == LightningPaymentType.Instance ); }, e => e.InvoiceId == invoice.Id && e.PaymentMethodId.PaymentType == LightningPaymentType.Instance );
await tester.ExplorerNode.GenerateAsync(1); await tester.ExplorerNode.GenerateAsync(1);
Invoice newInvoice = null;
await Task.Delay(100); // wait a bit for payment to process before fetching new invoice await Task.Delay(100); // wait a bit for payment to process before fetching new invoice
var newInvoice = await user.BitPay.GetInvoiceAsync(invoice.Id); await TestUtils.EventuallyAsync(async () =>
var newBolt11 = newInvoice.CryptoInfo.First(o => o.PaymentUrls.BOLT11 != null).PaymentUrls.BOLT11; {
var oldBolt11 = invoice.CryptoInfo.First(o => o.PaymentUrls.BOLT11 != null).PaymentUrls.BOLT11; newInvoice = await user.BitPay.GetInvoiceAsync(invoice.Id);
Assert.NotEqual(newBolt11, oldBolt11); var newBolt11 = newInvoice.CryptoInfo.First(o => o.PaymentUrls.BOLT11 != null).PaymentUrls.BOLT11;
Assert.Equal(newInvoice.BtcDue.GetValue(), BOLT11PaymentRequest.Parse(newBolt11, Network.RegTest).MinimumAmount.ToDecimal(LightMoneyUnit.BTC)); 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"); Logs.Tester.LogInformation($"Paying invoice {newInvoice.Id} remaining due amount {newInvoice.BtcDue.GetValue()} via lightning");
var evt = await tester.WaitForEvent<InvoiceDataChangedEvent>(async () => 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="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"> <div class="d-flex align-items-center justify-content-between py-3 px-4 border-bottom border-light">
<h5 class="m-0">Notifications</h5> <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> <button class="btn btn-link p-0" type="submit">Mark all as seen</button>
</form> </form>
</div> </div>

View file

@ -202,7 +202,6 @@ namespace BTCPayServer.Controllers
pullPaymentId = vm.PullPaymentId pullPaymentId = vm.PullPaymentId
}); });
} }
var command = vm.Command.Substring(vm.Command.IndexOf('-', StringComparison.InvariantCulture) + 1); var command = vm.Command.Substring(vm.Command.IndexOf('-', StringComparison.InvariantCulture) + 1);
switch (command) switch (command)
@ -278,16 +277,29 @@ namespace BTCPayServer.Controllers
walletSend.Outputs.Clear(); walletSend.Outputs.Clear();
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode); var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
List<string> bip21 = new List<string>(); List<string> bip21 = new List<string>();
foreach (var payout in payouts) foreach (var payout in payouts)
{ {
var blob = payout.GetBlob(_jsonSerializerSettings); if (payout.Proof != null)
if (payout.GetPaymentMethodId() != paymentMethodId) {
continue; continue;
}
var blob = payout.GetBlob(_jsonSerializerSettings);
bip21.Add(network.GenerateBIP21(payout.Destination, new Money(blob.CryptoAmount.Value, MoneyUnit.BTC))); bip21.Add(network.GenerateBIP21(payout.Destination, new Money(blob.CryptoAmount.Value, MoneyUnit.BTC)));
} }
if(bip21.Any())
return RedirectToAction(nameof(WalletSend), new {walletId, bip21}); 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": case "mark-paid":
@ -339,6 +351,20 @@ namespace BTCPayServer.Controllers
new {walletId = walletId.ToString(), pullPaymentId = vm.PullPaymentId}); 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(); return NotFound();
} }

View file

@ -1,16 +1,23 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer; using BTCPayServer;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NBitcoin; using NBitcoin;
@ -29,16 +36,18 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings; private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly ApplicationDbContextFactory _dbContextFactory; private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly EventAggregator _eventAggregator; private readonly EventAggregator _eventAggregator;
private readonly NotificationSender _notificationSender;
public BitcoinLikePayoutHandler(BTCPayNetworkProvider btcPayNetworkProvider, public BitcoinLikePayoutHandler(BTCPayNetworkProvider btcPayNetworkProvider,
ExplorerClientProvider explorerClientProvider, BTCPayNetworkJsonSerializerSettings jsonSerializerSettings, ExplorerClientProvider explorerClientProvider, BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
ApplicationDbContextFactory dbContextFactory, EventAggregator eventAggregator) ApplicationDbContextFactory dbContextFactory, EventAggregator eventAggregator, NotificationSender notificationSender)
{ {
_btcPayNetworkProvider = btcPayNetworkProvider; _btcPayNetworkProvider = btcPayNetworkProvider;
_explorerClientProvider = explorerClientProvider; _explorerClientProvider = explorerClientProvider;
_jsonSerializerSettings = jsonSerializerSettings; _jsonSerializerSettings = jsonSerializerSettings;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_notificationSender = notificationSender;
} }
public bool CanHandle(PaymentMethodId paymentMethod) public bool CanHandle(PaymentMethodId paymentMethod)
@ -47,6 +56,14 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
_btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethod.CryptoCode)?.ReadonlyWallet is false; _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) public Task<IClaimDestination> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination)
{ {
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode); var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
@ -92,9 +109,9 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
public async Task BackgroundCheck(object o) 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) if (o is NewBlockEvent || o is NewOnChainTransactionEvent)
@ -119,11 +136,83 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
return Task.FromResult(0m); 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() private async Task UpdatePayoutsInProgress()
{ {
try try
{ {
using var ctx = _dbContextFactory.CreateContext(); await using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts var payouts = await ctx.Payouts
.Include(p => p.PullPaymentData) .Include(p => p.PullPaymentData)
.Where(p => p.State == PayoutState.InProgress) .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 try
{ {
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(newTransaction.CryptoCode); var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(newTransaction.CryptoCode);
Dictionary<string, decimal> destinations; var destinationSum =
if (newTransaction.NewTransactionEvent.TrackedSource is AddressTrackedSource addressTrackedSource) 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>() payout.State = PayoutState.InProgress;
{ var walletId = new WalletId(payout.PullPaymentData.StoreId, newTransaction.CryptoCode);
{ _eventAggregator.Publish(new UpdateTransactionLabel(walletId,
addressTrackedSource.Address.ToString(), newTransaction.NewTransactionEvent.TransactionData.TransactionHash,
newTransaction.NewTransactionEvent.Outputs.Sum(output => output.Value.GetValue(network)) UpdateTransactionLabel.PayoutTemplate(payout.Id, payout.PullPaymentDataId, walletId.ToString())));
}
};
} }
else else
{ {
destinations = newTransaction.NewTransactionEvent.TransactionData.Transaction.Outputs await _notificationSender.SendNotification(new StoreScope(payout.PullPaymentData.StoreId),
.GroupBy(txout => txout.ScriptPubKey) new ExternalPayoutTransactionNotification()
.ToDictionary( {
txoutSet => txoutSet.Key.GetDestinationAddress(network.NBitcoinNetwork).ToString(), PaymentMethod = payout.PaymentMethodId,
txoutSet => txoutSet.Sum(txout => txout.Value.ToDecimal(MoneyUnit.BTC))); 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(); await ctx.SaveChangesAsync();
} }

View file

@ -1,11 +1,16 @@
using System; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using PayoutData = BTCPayServer.Data.PayoutData;
public interface IPayoutHandler public interface IPayoutHandler
{ {
public bool CanHandle(PaymentMethodId paymentMethod); public bool CanHandle(PaymentMethodId paymentMethod);
public Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination);
//Allows payout handler to parse payout destinations on its own //Allows payout handler to parse payout destinations on its own
public Task<IClaimDestination> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination); public Task<IClaimDestination> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination);
public IPayoutProof ParseProof(PayoutData payout); 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 //allows you to process events that the main pull payment hosted service is subscribed to
Task BackgroundCheck(object o); Task BackgroundCheck(object o);
Task<decimal> GetMinimumPayoutAmount(PaymentMethodId paymentMethod, IClaimDestination claimDestination); 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.Notifications.Blobs;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using NBXplorer; using NBXplorer;
@ -152,7 +153,8 @@ namespace BTCPayServer.HostedServices
BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider networkProvider,
NotificationSender notificationSender, NotificationSender notificationSender,
RateFetcher rateFetcher, RateFetcher rateFetcher,
IEnumerable<IPayoutHandler> payoutHandlers) IEnumerable<IPayoutHandler> payoutHandlers,
ILogger<PullPaymentHostedService> logger)
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_jsonSerializerSettings = jsonSerializerSettings; _jsonSerializerSettings = jsonSerializerSettings;
@ -162,6 +164,7 @@ namespace BTCPayServer.HostedServices
_notificationSender = notificationSender; _notificationSender = notificationSender;
_rateFetcher = rateFetcher; _rateFetcher = rateFetcher;
_payoutHandlers = payoutHandlers; _payoutHandlers = payoutHandlers;
_logger = logger;
} }
Channel<object> _Channel; Channel<object> _Channel;
@ -173,6 +176,7 @@ namespace BTCPayServer.HostedServices
private readonly NotificationSender _notificationSender; private readonly NotificationSender _notificationSender;
private readonly RateFetcher _rateFetcher; private readonly RateFetcher _rateFetcher;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers; private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly ILogger<PullPaymentHostedService> _logger;
private readonly CompositeDisposable _subscriptions = new CompositeDisposable(); private readonly CompositeDisposable _subscriptions = new CompositeDisposable();
internal override Task[] InitializeTasks() internal override Task[] InitializeTasks()
@ -216,7 +220,14 @@ namespace BTCPayServer.HostedServices
} }
foreach (IPayoutHandler payoutHandler in _payoutHandlers) 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); await ctx.Payouts.AddAsync(payout);
try try
{ {
await payoutHandler.TrackClaim(req.ClaimRequest.PaymentMethodId, req.ClaimRequest.Destination);
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout)); req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout));
await _notificationSender.SendNotification(new StoreScope(pp.StoreId), new PayoutNotification() 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, InvoiceEventNotification.Handler>();
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>(); services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
services.AddSingleton<INotificationHandler, ExternalPayoutTransactionNotification.Handler>();
services.AddSingleton<IHostedService, DbMigrationsHostedService>(); services.AddSingleton<IHostedService, DbMigrationsHostedService>();
#if DEBUG #if DEBUG
services.AddSingleton<INotificationHandler, JunkNotification.Handler>(); services.AddSingleton<INotificationHandler, JunkNotification.Handler>();

View file

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