mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-12 10:30:47 +01:00
Pull Payment LNURLW have a paylink
This commit is contained in:
parent
692a13e0c8
commit
b1c171a5d9
10 changed files with 175 additions and 26 deletions
|
@ -43,7 +43,7 @@ public class LightningAddressDataBlob
|
|||
public decimal? Max { get; set; }
|
||||
|
||||
public JObject InvoiceMetadata { get; set; }
|
||||
|
||||
public string PullPaymentId { get; set; }
|
||||
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
|
||||
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ using NBitcoin;
|
|||
using NBitcoin.RPC;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Chrome;
|
||||
using OpenQA.Selenium.Support.Extensions;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
using Xunit;
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ using BTCPayServer.Client;
|
|||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Models.InvoicingModels;
|
||||
using BTCPayServer.NTag424;
|
||||
|
@ -2118,7 +2119,6 @@ namespace BTCPayServer.Tests
|
|||
});
|
||||
s.GoToHome();
|
||||
//offline/external payout test
|
||||
|
||||
var newStore = s.CreateNewStore();
|
||||
s.GenerateWallet("BTC", "", true, true);
|
||||
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
|
||||
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)}"));
|
||||
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)}"));
|
||||
|
|
|
@ -14,11 +14,20 @@ using System.Threading;
|
|||
using System;
|
||||
using NBitcoin.DataEncoders;
|
||||
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;
|
||||
|
||||
public class UIBoltcardController : Controller
|
||||
{
|
||||
private readonly PullPaymentHostedService _ppService;
|
||||
private readonly StoreRepository _storeRepository;
|
||||
|
||||
public class BoltcardSettings
|
||||
{
|
||||
[JsonConverter(typeof(NBitcoin.JsonConverters.HexJsonConverter))]
|
||||
|
@ -28,11 +37,15 @@ public class UIBoltcardController : Controller
|
|||
UILNURLController lnUrlController,
|
||||
SettingsRepository settingsRepository,
|
||||
ApplicationDbContextFactory contextFactory,
|
||||
PullPaymentHostedService ppService,
|
||||
StoreRepository storeRepository,
|
||||
BTCPayServerEnvironment env)
|
||||
{
|
||||
LNURLController = lnUrlController;
|
||||
SettingsRepository = settingsRepository;
|
||||
ContextFactory = contextFactory;
|
||||
_ppService = ppService;
|
||||
_storeRepository = storeRepository;
|
||||
Env = env;
|
||||
}
|
||||
|
||||
|
@ -41,6 +54,50 @@ public class UIBoltcardController : Controller
|
|||
public ApplicationDbContextFactory ContextFactory { 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]
|
||||
[HttpGet("~/boltcard")]
|
||||
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))
|
||||
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Replayed or expired query" });
|
||||
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)!;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ using BTCPayServer.Plugins.PointOfSale.Models;
|
|||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.PaymentRequests;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using LNURL;
|
||||
|
@ -436,6 +437,13 @@ namespace BTCPayServer
|
|||
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
|
||||
if (store is null)
|
||||
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(
|
||||
cryptoCode,
|
||||
store,
|
||||
|
@ -453,7 +461,7 @@ namespace BTCPayServer
|
|||
new Dictionary<string, string>
|
||||
{
|
||||
{ "text/identifier", $"{username}@{Request.Host}" }
|
||||
});
|
||||
}, additionalTags);
|
||||
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest)
|
||||
return result;
|
||||
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,
|
||||
Data.StoreData store,
|
||||
Data.StoreBlob blob,
|
||||
|
|
|
@ -276,7 +276,8 @@ namespace BTCPayServer.Controllers
|
|||
Destination = destination,
|
||||
PullPaymentId = pullPaymentId,
|
||||
Value = vm.ClaimedAmount,
|
||||
PaymentMethodId = paymentMethodId
|
||||
PaymentMethodId = paymentMethodId,
|
||||
StoreId = pp.StoreId
|
||||
});
|
||||
|
||||
if (result.Result != ClaimRequest.ClaimResult.Ok)
|
||||
|
|
|
@ -7,11 +7,13 @@ using System.Threading.Tasks;
|
|||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Models.WalletViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Notifications;
|
||||
using BTCPayServer.Services.Notifications.Blobs;
|
||||
using BTCPayServer.Services.Rates;
|
||||
|
@ -47,7 +49,7 @@ namespace BTCPayServer.HostedServices
|
|||
public class PullPaymentHostedService : BaseAsyncService
|
||||
{
|
||||
private readonly string[] _lnurlSupportedCurrencies = { "BTC", "SATS" };
|
||||
|
||||
|
||||
public class CancelRequest
|
||||
{
|
||||
public CancelRequest(string pullPaymentId)
|
||||
|
@ -282,7 +284,7 @@ namespace BTCPayServer.HostedServices
|
|||
|
||||
return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId);
|
||||
}
|
||||
|
||||
record TopUpRequest(string PullPaymentId, InvoiceEntity InvoiceEntity);
|
||||
class PayoutRequest
|
||||
{
|
||||
public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> completionSource,
|
||||
|
@ -292,6 +294,8 @@ namespace BTCPayServer.HostedServices
|
|||
ArgumentNullException.ThrowIfNull(completionSource);
|
||||
Completion = completionSource;
|
||||
ClaimRequest = request;
|
||||
if (request.StoreId is null)
|
||||
throw new ArgumentNullException(nameof(request.StoreId));
|
||||
}
|
||||
|
||||
public TaskCompletionSource<ClaimRequest.ClaimResponse> Completion { get; set; }
|
||||
|
@ -342,10 +346,20 @@ namespace BTCPayServer.HostedServices
|
|||
{
|
||||
payoutHandler.StartBackgroundCheck(Subscribe);
|
||||
}
|
||||
|
||||
_eventAggregator.Subscribe<Events.InvoiceEvent>(TopUpInvoice);
|
||||
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)
|
||||
{
|
||||
foreach (Type @event in events)
|
||||
|
@ -358,6 +372,10 @@ namespace BTCPayServer.HostedServices
|
|||
{
|
||||
await foreach (var o in _Channel.Reader.ReadAllAsync())
|
||||
{
|
||||
if (o is TopUpRequest topUp)
|
||||
{
|
||||
await HandleTopUp(topUp);
|
||||
}
|
||||
if (o is PayoutRequest req)
|
||||
{
|
||||
await HandleCreatePayout(req);
|
||||
|
@ -392,10 +410,40 @@ 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)
|
||||
{
|
||||
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
|
||||
id.PaymentType == LightningPaymentType.Instance &&
|
||||
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
|
||||
id.PaymentType == LightningPaymentType.Instance &&
|
||||
_networkProvider.DefaultNetwork.CryptoCode == id.CryptoCode);
|
||||
return pms is not null && _lnurlSupportedCurrencies.Contains(blob.Currency);
|
||||
}
|
||||
|
@ -652,7 +700,7 @@ namespace BTCPayServer.HostedServices
|
|||
{
|
||||
Amount = claimed,
|
||||
Destination = req.ClaimRequest.Destination.ToString(),
|
||||
Metadata = req.ClaimRequest.Metadata?? new JObject(),
|
||||
Metadata = req.ClaimRequest.Metadata ?? new JObject(),
|
||||
};
|
||||
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
|
||||
await ctx.Payouts.AddAsync(payout);
|
||||
|
@ -845,6 +893,10 @@ namespace BTCPayServer.HostedServices
|
|||
return time;
|
||||
}
|
||||
|
||||
public static string GetInternalTag(string id)
|
||||
{
|
||||
return $"PULLPAY#{id}";
|
||||
}
|
||||
|
||||
class InternalPayoutPaidRequest
|
||||
{
|
||||
|
@ -899,25 +951,25 @@ namespace BTCPayServer.HostedServices
|
|||
{
|
||||
null when destination.Amount is null && ppCurrency is null => ("Amount is not specified in destination or payout request", null),
|
||||
null when destination.Amount is null => (null, null),
|
||||
null when destination.Amount != null => (null,destination.Amount),
|
||||
not null when destination.Amount is null => (null,amount),
|
||||
null when destination.Amount != null => (null, destination.Amount),
|
||||
not null when destination.Amount is null => (null, amount),
|
||||
not null when destination.Amount != null && amount != destination.Amount &&
|
||||
destination.IsExplicitAmountMinimum &&
|
||||
payoutCurrency == "BTC" && ppCurrency == "SATS" &&
|
||||
new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount =>
|
||||
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
|
||||
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null),
|
||||
not null when destination.Amount != null && amount != destination.Amount &&
|
||||
destination.IsExplicitAmountMinimum &&
|
||||
!(payoutCurrency == "BTC" && ppCurrency == "SATS") &&
|
||||
amount < destination.Amount =>
|
||||
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
|
||||
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null),
|
||||
not null when destination.Amount != null && amount != destination.Amount &&
|
||||
!destination.IsExplicitAmountMinimum =>
|
||||
($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null),
|
||||
_ => (null, amount)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public static string GetErrorMessage(ClaimResult result)
|
||||
{
|
||||
switch (result)
|
||||
|
|
|
@ -184,7 +184,7 @@ namespace BTCPayServer.Plugins.BoltcardTopUp.Controllers
|
|||
{
|
||||
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
|
||||
Date = DateTimeOffset.UtcNow,
|
||||
State = PayoutState.AwaitingApproval,
|
||||
State = PayoutState.Completed,
|
||||
PullPaymentDataId = registration.PullPaymentId,
|
||||
PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(),
|
||||
Destination = null,
|
||||
|
|
|
@ -7,12 +7,14 @@
|
|||
@using BTCPayServer
|
||||
|
||||
@{
|
||||
var storeId = Context.GetStoreData().Id;
|
||||
var storeId = Context.GetStoreData()?.Id;
|
||||
}
|
||||
@if (storeId != null)
|
||||
{
|
||||
<li class="nav-item">
|
||||
<a asp-area="" asp-controller="UIBoltcardTopUp" asp-action="Keypad" asp-route-storeId="@storeId" class="nav-link">
|
||||
<vc:icon symbol="pay-button" />
|
||||
<span>Boltcard Top-Up</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li class="nav-item">
|
||||
<a asp-area="" asp-controller="UIBoltcardTopUp" asp-action="Keypad" asp-route-storeId="@storeId" class="nav-link">
|
||||
<vc:icon symbol="pay-button" />
|
||||
<span>Boltcard Top-Up</span>
|
||||
</a>
|
||||
</li>
|
||||
|
|
|
@ -487,6 +487,7 @@ namespace BTCPayServer.Services
|
|||
|
||||
public static WalletObjectData NewWalletObjectData(WalletObjectId id, JObject? data = null)
|
||||
{
|
||||
|
||||
return new WalletObjectData()
|
||||
{
|
||||
WalletId = id.WalletId.ToString(),
|
||||
|
|
Loading…
Add table
Reference in a new issue