Pull Payment LNURLW have a paylink

This commit is contained in:
nicolas.dorier 2024-02-19 18:01:59 +09:00
parent 692a13e0c8
commit b1c171a5d9
No known key found for this signature in database
GPG key ID: 6618763EF09186FE
10 changed files with 175 additions and 26 deletions

View file

@ -43,7 +43,7 @@ public class LightningAddressDataBlob
public decimal? Max { get; set; } public decimal? Max { get; set; }
public JObject InvoiceMetadata { get; set; } public JObject InvoiceMetadata { get; set; }
public string PullPaymentId { get; set; }
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; } [JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
} }

View file

@ -18,6 +18,7 @@ using NBitcoin;
using NBitcoin.RPC; using NBitcoin.RPC;
using OpenQA.Selenium; using OpenQA.Selenium;
using OpenQA.Selenium.Chrome; using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.Extensions;
using OpenQA.Selenium.Support.UI; using OpenQA.Selenium.Support.UI;
using Xunit; using Xunit;

View file

@ -16,6 +16,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Controllers; using BTCPayServer.Controllers;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.NTag424; using BTCPayServer.NTag424;
@ -2118,7 +2119,6 @@ namespace BTCPayServer.Tests
}); });
s.GoToHome(); s.GoToHome();
//offline/external payout test //offline/external payout test
var newStore = s.CreateNewStore(); var newStore = s.CreateNewStore();
s.GenerateWallet("BTC", "", true, true); s.GenerateWallet("BTC", "", true, true);
s.GoToStore(s.StoreId, StoreNavPages.PullPayments); s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
@ -2322,6 +2322,23 @@ namespace BTCPayServer.Tests
// p and c should work so long as no bolt11 has been submitted // p and c should work so long as no bolt11 has been submitted
info = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient); info = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient);
info = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient); info = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient);
Assert.NotNull(info.PayLink);
Assert.StartsWith("lnurlp://", info.PayLink.AbsoluteUri);
// Ignore certs issue
info.PayLink = new Uri(info.PayLink.AbsoluteUri.Replace("lnurlp://", "http://"), UriKind.Absolute);
var payReq = (LNURLPayRequest)await LNURL.LNURL.FetchInformation(info.PayLink, s.Server.PayTester.HttpClient);
var callback = await payReq.SendRequest(LightMoney.Satoshis(100), Network.RegTest, s.Server.PayTester.HttpClient);
Assert.NotNull(callback.Pr);
var res = await s.Server.CustomerLightningD.Pay(callback.Pr);
Assert.Equal(PayResult.Ok, res.Result);
var ppService = s.Server.PayTester.GetService<PullPaymentHostedService>();
var serializer = s.Server.PayTester.GetService<BTCPayNetworkJsonSerializerSettings>();
await TestUtils.EventuallyAsync(async () =>
{
var pp = await ppService.GetPullPayment(ppid, true);
Assert.Contains(pp.Payouts.Select(p => p.GetBlob(serializer)), p => p.CryptoAmount == -LightMoney.Satoshis(100).ToUnit(LightMoneyUnit.BTC));
});
var fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "p=([A-F0-9]{32})", $"p={RandomBytes(16)}")); var fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "p=([A-F0-9]{32})", $"p={RandomBytes(16)}"));
await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(fakeBoltcardUrl, s.Server.PayTester.HttpClient)); await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(fakeBoltcardUrl, s.Server.PayTester.HttpClient));
fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "c=([A-F0-9]{16})", $"c={RandomBytes(8)}")); fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "c=([A-F0-9]{16})", $"c={RandomBytes(8)}"));

View file

@ -14,11 +14,20 @@ using System.Threading;
using System; using System;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using BTCPayServer.HostedServices;
using BTCPayServer.Services.Stores;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using System.Reflection.Metadata;
namespace BTCPayServer.Controllers; namespace BTCPayServer.Controllers;
public class UIBoltcardController : Controller public class UIBoltcardController : Controller
{ {
private readonly PullPaymentHostedService _ppService;
private readonly StoreRepository _storeRepository;
public class BoltcardSettings public class BoltcardSettings
{ {
[JsonConverter(typeof(NBitcoin.JsonConverters.HexJsonConverter))] [JsonConverter(typeof(NBitcoin.JsonConverters.HexJsonConverter))]
@ -28,11 +37,15 @@ public class UIBoltcardController : Controller
UILNURLController lnUrlController, UILNURLController lnUrlController,
SettingsRepository settingsRepository, SettingsRepository settingsRepository,
ApplicationDbContextFactory contextFactory, ApplicationDbContextFactory contextFactory,
PullPaymentHostedService ppService,
StoreRepository storeRepository,
BTCPayServerEnvironment env) BTCPayServerEnvironment env)
{ {
LNURLController = lnUrlController; LNURLController = lnUrlController;
SettingsRepository = settingsRepository; SettingsRepository = settingsRepository;
ContextFactory = contextFactory; ContextFactory = contextFactory;
_ppService = ppService;
_storeRepository = storeRepository;
Env = env; Env = env;
} }
@ -41,6 +54,50 @@ public class UIBoltcardController : Controller
public ApplicationDbContextFactory ContextFactory { get; } public ApplicationDbContextFactory ContextFactory { get; }
public BTCPayServerEnvironment Env { get; } public BTCPayServerEnvironment Env { get; }
[AllowAnonymous]
[HttpGet("~/boltcard/pay")]
public async Task<IActionResult> GetPayRequest([FromQuery] string? p, [FromQuery] long? amount = null)
{
var issuerKey = await SettingsRepository.GetIssuerKey(Env);
var piccData = issuerKey.TryDecrypt(p);
if (piccData is null)
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Invalid PICCData" });
piccData = new BoltcardPICCData(piccData.Uid, int.MaxValue - 10); // do not check the counter
var registration = await ContextFactory.GetBoltcardRegistration(issuerKey, piccData, false);
var pp = await _ppService.GetPullPayment(registration!.PullPaymentId, false);
var store = await _storeRepository.FindStore(pp.StoreId);
var payRequest = new LNURLPayRequest
{
Tag = "payRequest",
MinSendable = LightMoney.Satoshis(1.0m),
MaxSendable = LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC),
Callback = new Uri(GetPayLink(p, Request.Scheme), UriKind.Absolute),
CommentAllowed = 0
};
if (amount is null)
return Ok(payRequest);
var cryptoCode = "BTC";
LNURLController.ControllerContext.HttpContext = HttpContext;
var result = await LNURLController.GetLNURLRequest(
cryptoCode,
store,
store.GetStoreBlob(),
new CreateInvoiceRequest()
{
Currency = "BTC",
Amount = LightMoney.FromUnit(amount.Value, LightMoneyUnit.MilliSatoshi).ToUnit(LightMoneyUnit.BTC)
},
payRequest,
null,
[PullPaymentHostedService.GetInternalTag(pp.Id)]);
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest2)
return result;
payRequest = payRequest2;
var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last();
return await LNURLController.GetLNURLForInvoice(invoiceId, cryptoCode, amount.Value, null);
}
[AllowAnonymous] [AllowAnonymous]
[HttpGet("~/boltcard")] [HttpGet("~/boltcard")]
public async Task<IActionResult> GetWithdrawRequest([FromQuery] string? p, [FromQuery] string? c, [FromQuery] string? pr, [FromQuery] string? k1, CancellationToken cancellationToken) public async Task<IActionResult> GetWithdrawRequest([FromQuery] string? p, [FromQuery] string? c, [FromQuery] string? pr, [FromQuery] string? k1, CancellationToken cancellationToken)
@ -65,6 +122,16 @@ public class UIBoltcardController : Controller
if (!cardKey.CheckSunMac(c, piccData)) if (!cardKey.CheckSunMac(c, piccData))
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Replayed or expired query" }); return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Replayed or expired query" });
LNURLController.ControllerContext.HttpContext = HttpContext; LNURLController.ControllerContext.HttpContext = HttpContext;
return await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken); var res = await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken);
if (res is not OkObjectResult ok || ok.Value is not LNURLWithdrawRequest withdrawRequest)
return res;
var paylink = GetPayLink(p, "lnurlp");
withdrawRequest.PayLink = new Uri(paylink, UriKind.Absolute);
return res;
}
private string GetPayLink(string? p, string scheme)
{
return Url.Action(nameof(GetPayRequest), "UIBoltcard", new { p }, scheme)!;
} }
} }

View file

@ -26,6 +26,7 @@ using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using LNURL; using LNURL;
@ -436,6 +437,13 @@ namespace BTCPayServer
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId); var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
if (store is null) if (store is null)
return NotFound("Unknown username"); return NotFound("Unknown username");
List<string> additionalTags = new List<string>();
if (blob?.PullPaymentId is not null)
{
var pp = await _pullPaymentHostedService.GetPullPayment(blob.PullPaymentId, false);
if (pp != null)
additionalTags.Add(PullPaymentHostedService.GetInternalTag(blob.PullPaymentId));
}
var result = await GetLNURLRequest( var result = await GetLNURLRequest(
cryptoCode, cryptoCode,
store, store,
@ -453,7 +461,7 @@ namespace BTCPayServer
new Dictionary<string, string> new Dictionary<string, string>
{ {
{ "text/identifier", $"{username}@{Request.Host}" } { "text/identifier", $"{username}@{Request.Host}" }
}); }, additionalTags);
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest) if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest)
return result; return result;
var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last(); var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last();
@ -495,7 +503,7 @@ namespace BTCPayServer
}); });
} }
private async Task<IActionResult> GetLNURLRequest( internal async Task<IActionResult> GetLNURLRequest(
string cryptoCode, string cryptoCode,
Data.StoreData store, Data.StoreData store,
Data.StoreBlob blob, Data.StoreBlob blob,

View file

@ -276,7 +276,8 @@ namespace BTCPayServer.Controllers
Destination = destination, Destination = destination,
PullPaymentId = pullPaymentId, PullPaymentId = pullPaymentId,
Value = vm.ClaimedAmount, Value = vm.ClaimedAmount,
PaymentMethodId = paymentMethodId PaymentMethodId = paymentMethodId,
StoreId = pp.StoreId
}); });
if (result.Result != ClaimRequest.ClaimResult.Ok) if (result.Result != ClaimRequest.ClaimResult.Ok)

View file

@ -7,11 +7,13 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Rating; using BTCPayServer.Rating;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
@ -282,7 +284,7 @@ namespace BTCPayServer.HostedServices
return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId); return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId);
} }
record TopUpRequest(string PullPaymentId, InvoiceEntity InvoiceEntity);
class PayoutRequest class PayoutRequest
{ {
public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> completionSource, public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> completionSource,
@ -292,6 +294,8 @@ namespace BTCPayServer.HostedServices
ArgumentNullException.ThrowIfNull(completionSource); ArgumentNullException.ThrowIfNull(completionSource);
Completion = completionSource; Completion = completionSource;
ClaimRequest = request; ClaimRequest = request;
if (request.StoreId is null)
throw new ArgumentNullException(nameof(request.StoreId));
} }
public TaskCompletionSource<ClaimRequest.ClaimResponse> Completion { get; set; } public TaskCompletionSource<ClaimRequest.ClaimResponse> Completion { get; set; }
@ -342,10 +346,20 @@ namespace BTCPayServer.HostedServices
{ {
payoutHandler.StartBackgroundCheck(Subscribe); payoutHandler.StartBackgroundCheck(Subscribe);
} }
_eventAggregator.Subscribe<Events.InvoiceEvent>(TopUpInvoice);
return new[] { Loop() }; return new[] { Loop() };
} }
private void TopUpInvoice(InvoiceEvent evt)
{
if (evt.EventCode == InvoiceEventCode.Completed)
{
foreach (var pullPaymentId in evt.Invoice.GetInternalTags("PULLPAY#"))
{
_Channel.Writer.TryWrite(new TopUpRequest(pullPaymentId, evt.Invoice));
}
}
}
private void Subscribe(params Type[] events) private void Subscribe(params Type[] events)
{ {
foreach (Type @event in events) foreach (Type @event in events)
@ -358,6 +372,10 @@ namespace BTCPayServer.HostedServices
{ {
await foreach (var o in _Channel.Reader.ReadAllAsync()) await foreach (var o in _Channel.Reader.ReadAllAsync())
{ {
if (o is TopUpRequest topUp)
{
await HandleTopUp(topUp);
}
if (o is PayoutRequest req) if (o is PayoutRequest req)
{ {
await HandleCreatePayout(req); await HandleCreatePayout(req);
@ -392,6 +410,36 @@ namespace BTCPayServer.HostedServices
} }
} }
private async Task HandleTopUp(TopUpRequest topUp)
{
var pp = await this.GetPullPayment(topUp.PullPaymentId, false);
using var ctx = _dbContextFactory.CreateContext();
var payout = new Data.PayoutData()
{
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
Date = DateTimeOffset.UtcNow,
State = PayoutState.Completed,
PullPaymentDataId = pp.Id,
PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(),
Destination = null,
StoreDataId = pp.StoreId
};
var rate = topUp.InvoiceEntity.Rates["BTC"];
var cryptoAmount = Math.Round(topUp.InvoiceEntity.PaidAmount.Net / rate, 11);
var payoutBlob = new PayoutBlob()
{
CryptoAmount = -cryptoAmount,
Amount = -topUp.InvoiceEntity.PaidAmount.Net,
Destination = null,
Metadata = new JObject(),
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
await ctx.SaveChangesAsync();
}
public bool SupportsLNURL(PullPaymentBlob blob) public bool SupportsLNURL(PullPaymentBlob blob)
{ {
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id => var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
@ -845,6 +893,10 @@ namespace BTCPayServer.HostedServices
return time; return time;
} }
public static string GetInternalTag(string id)
{
return $"PULLPAY#{id}";
}
class InternalPayoutPaidRequest class InternalPayoutPaidRequest
{ {

View file

@ -184,7 +184,7 @@ namespace BTCPayServer.Plugins.BoltcardTopUp.Controllers
{ {
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)), Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
Date = DateTimeOffset.UtcNow, Date = DateTimeOffset.UtcNow,
State = PayoutState.AwaitingApproval, State = PayoutState.Completed,
PullPaymentDataId = registration.PullPaymentId, PullPaymentDataId = registration.PullPaymentId,
PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(), PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(),
Destination = null, Destination = null,

View file

@ -7,12 +7,14 @@
@using BTCPayServer @using BTCPayServer
@{ @{
var storeId = Context.GetStoreData().Id; var storeId = Context.GetStoreData()?.Id;
} }
@if (storeId != null)
{
<li class="nav-item"> <li class="nav-item">
<a asp-area="" asp-controller="UIBoltcardTopUp" asp-action="Keypad" asp-route-storeId="@storeId" class="nav-link"> <a asp-area="" asp-controller="UIBoltcardTopUp" asp-action="Keypad" asp-route-storeId="@storeId" class="nav-link">
<vc:icon symbol="pay-button" /> <vc:icon symbol="pay-button" />
<span>Boltcard Top-Up</span> <span>Boltcard Top-Up</span>
</a> </a>
</li> </li>
}

View file

@ -487,6 +487,7 @@ namespace BTCPayServer.Services
public static WalletObjectData NewWalletObjectData(WalletObjectId id, JObject? data = null) public static WalletObjectData NewWalletObjectData(WalletObjectId id, JObject? data = null)
{ {
return new WalletObjectData() return new WalletObjectData()
{ {
WalletId = id.WalletId.ToString(), WalletId = id.WalletId.ToString(),