If pull payment opened in mobile, use deeplink to setup card (#5613)

* If pull payment opened in mobile, use deeplink to setup card

* Allow passing LNURLW to register boltcard

* debug

* debug

* debug

* Only show setup/reset when the page is fully loaded

* Apply suggestions from code review

---------

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
This commit is contained in:
Nicolas Dorier
2024-03-14 19:07:11 +09:00
committed by GitHub
parent c56c6401d6
commit e5adc630af
6 changed files with 143 additions and 15 deletions

View File

@@ -4,6 +4,7 @@ using System.Text;
using NBitcoin.JsonConverters; using NBitcoin.JsonConverters;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models namespace BTCPayServer.Client.Models
{ {
@@ -14,11 +15,15 @@ namespace BTCPayServer.Client.Models
} }
public class RegisterBoltcardRequest public class RegisterBoltcardRequest
{ {
[JsonProperty("LNURLW")]
public string LNURLW { get; set; }
[JsonConverter(typeof(HexJsonConverter))] [JsonConverter(typeof(HexJsonConverter))]
[JsonProperty("UID")] [JsonProperty("UID")]
public byte[] UID { get; set; } public byte[] UID { get; set; }
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
public OnExistingBehavior? OnExisting { get; set; } public OnExistingBehavior? OnExisting { get; set; }
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } = new Dictionary<string, JToken>();
} }
public class RegisterBoltcardResponse public class RegisterBoltcardResponse
{ {

View File

@@ -13,6 +13,7 @@ using BTCPayServer.Controllers;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.NTag424;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors; using BTCPayServer.PayoutProcessors;
@@ -24,6 +25,7 @@ using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient; using NBitpayClient;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -1135,6 +1137,35 @@ namespace BTCPayServer.Tests
OnExisting = OnExistingBehavior.KeepVersion OnExisting = OnExistingBehavior.KeepVersion
}); });
Assert.Equal(card2.Version, card3.Version); Assert.Equal(card2.Version, card3.Version);
var p = new byte[] { 0xc7 }.Concat(uid).Concat(new byte[8]).ToArray();
var card4 = await client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
});
Assert.Equal(card2.Version, card4.Version);
Assert.Equal(card2.K4, card4.K4);
// Can't define both properties
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
UID = uid,
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
}));
// p is malformed
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
UID = uid,
LNURLW = card2.LNURLW + $"?p=lol"
}));
// p is invalid
p[0] = 0;
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
}));
// Test with SATS denomination values // Test with SATS denomination values
var testSats = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest() var testSats = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest()
{ {

View File

@@ -1,6 +1,8 @@
#nullable enable #nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics.CodeAnalysis;
using System.IO.IsolatedStorage; using System.IO.IsolatedStorage;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -12,6 +14,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.NTag424; using BTCPayServer.NTag424;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Security; using BTCPayServer.Security;
@@ -22,8 +25,11 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Org.BouncyCastle.Bcpg.OpenPgp;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest; using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
namespace BTCPayServer.Controllers.Greenfield namespace BTCPayServer.Controllers.Greenfield
@@ -43,6 +49,7 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly IAuthorizationService _authorizationService; private readonly IAuthorizationService _authorizationService;
private readonly SettingsRepository _settingsRepository; private readonly SettingsRepository _settingsRepository;
private readonly BTCPayServerEnvironment _env; private readonly BTCPayServerEnvironment _env;
private readonly Logs _logs;
public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService, public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService,
LinkGenerator linkGenerator, LinkGenerator linkGenerator,
@@ -53,7 +60,7 @@ namespace BTCPayServer.Controllers.Greenfield
BTCPayNetworkProvider btcPayNetworkProvider, BTCPayNetworkProvider btcPayNetworkProvider,
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
SettingsRepository settingsRepository, SettingsRepository settingsRepository,
BTCPayServerEnvironment env) BTCPayServerEnvironment env, Logs logs)
{ {
_pullPaymentService = pullPaymentService; _pullPaymentService = pullPaymentService;
_linkGenerator = linkGenerator; _linkGenerator = linkGenerator;
@@ -65,6 +72,7 @@ namespace BTCPayServer.Controllers.Greenfield
_authorizationService = authorizationService; _authorizationService = authorizationService;
_settingsRepository = settingsRepository; _settingsRepository = settingsRepository;
_env = env; _env = env;
_logs = logs;
} }
[HttpGet("~/api/v1/stores/{storeId}/pull-payments")] [HttpGet("~/api/v1/stores/{storeId}/pull-payments")]
@@ -200,13 +208,39 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpPost] [HttpPost]
[Route("~/api/v1/pull-payments/{pullPaymentId}/boltcards")] [Route("~/api/v1/pull-payments/{pullPaymentId}/boltcards")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> RegisterBoltcard(string pullPaymentId, RegisterBoltcardRequest request) public async Task<IActionResult> RegisterBoltcard(string pullPaymentId, RegisterBoltcardRequest request, string? onExisting = null)
{ {
if (pullPaymentId is null) if (pullPaymentId is null)
return PullPaymentNotFound(); return PullPaymentNotFound();
this._logs.PayServer.LogInformation($"RegisterBoltcard: onExisting queryParam: {onExisting}");
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(request)}");
var pp = await _pullPaymentService.GetPullPayment(pullPaymentId, false); var pp = await _pullPaymentService.GetPullPayment(pullPaymentId, false);
if (pp is null) if (pp is null)
return PullPaymentNotFound(); return PullPaymentNotFound();
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
// LNURLW is used by deeplinks
if (request?.LNURLW is not null)
{
if (request.UID is not null)
{
ModelState.AddModelError(nameof(request.LNURLW), "You should pass either LNURLW or UID but not both");
return this.CreateValidationError(ModelState);
}
var p = ExtractP(request.LNURLW);
if (p is null)
{
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW should contains a 'p=' parameter");
return this.CreateValidationError(ModelState);
}
if (issuerKey.TryDecrypt(p) is not BoltcardPICCData picc)
{
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW 'p=' parameter cannot be decrypted");
return this.CreateValidationError(ModelState);
}
request.UID = picc.Uid;
}
if (request?.UID is null || request.UID.Length != 7) if (request?.UID is null || request.UID.Length != 7)
{ {
ModelState.AddModelError(nameof(request.UID), "The UID is required and should be 7 bytes"); ModelState.AddModelError(nameof(request.UID), "The UID is required and should be 7 bytes");
@@ -217,15 +251,28 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateAPIError(400, "lnurl-not-supported", "This pull payment currency should be BTC or SATS and accept lightning"); return this.CreateAPIError(400, "lnurl-not-supported", "This pull payment currency should be BTC or SATS and accept lightning");
} }
var issuerKey = await _settingsRepository.GetIssuerKey(_env); // Passing onExisting as a query parameter is used by deeplink
request.OnExisting = onExisting switch
{
nameof(OnExistingBehavior.UpdateVersion) => OnExistingBehavior.UpdateVersion,
nameof(OnExistingBehavior.KeepVersion) => OnExistingBehavior.KeepVersion,
_ => request.OnExisting
};
this._logs.PayServer.LogInformation($"After");
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(request)}");
var version = await _dbContextFactory.LinkBoltcardToPullPayment(pullPaymentId, issuerKey, request.UID, request.OnExisting); var version = await _dbContextFactory.LinkBoltcardToPullPayment(pullPaymentId, issuerKey, request.UID, request.OnExisting);
this._logs.PayServer.LogInformation($"Version: " + version);
this._logs.PayServer.LogInformation($"ID: " + Encoders.Hex.EncodeData(issuerKey.GetId(request.UID)));
var keys = issuerKey.CreatePullPaymentCardKey(request.UID, version, pullPaymentId).DeriveBoltcardKeys(issuerKey); var keys = issuerKey.CreatePullPaymentCardKey(request.UID, version, pullPaymentId).DeriveBoltcardKeys(issuerKey);
var boltcardUrl = Url.Action(nameof(UIBoltcardController.GetWithdrawRequest), "UIBoltcard"); var boltcardUrl = Url.Action(nameof(UIBoltcardController.GetWithdrawRequest), "UIBoltcard");
boltcardUrl = Request.GetAbsoluteUri(boltcardUrl); boltcardUrl = Request.GetAbsoluteUri(boltcardUrl);
boltcardUrl = Regex.Replace(boltcardUrl, "^https?://", "lnurlw://"); boltcardUrl = Regex.Replace(boltcardUrl, "^https?://", "lnurlw://");
return Ok(new RegisterBoltcardResponse() var resp = new RegisterBoltcardResponse()
{ {
LNURLW = boltcardUrl, LNURLW = boltcardUrl,
Version = version, Version = version,
@@ -234,7 +281,25 @@ namespace BTCPayServer.Controllers.Greenfield
K2 = Encoders.Hex.EncodeData(keys.AuthenticationKey.ToBytes()).ToUpperInvariant(), K2 = Encoders.Hex.EncodeData(keys.AuthenticationKey.ToBytes()).ToUpperInvariant(),
K3 = Encoders.Hex.EncodeData(keys.K3.ToBytes()).ToUpperInvariant(), K3 = Encoders.Hex.EncodeData(keys.K3.ToBytes()).ToUpperInvariant(),
K4 = Encoders.Hex.EncodeData(keys.K4.ToBytes()).ToUpperInvariant(), K4 = Encoders.Hex.EncodeData(keys.K4.ToBytes()).ToUpperInvariant(),
}); };
this._logs.PayServer.LogInformation($"Response");
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(resp)}");
return Ok(resp);
}
private string? ExtractP(string? url)
{
if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out var uri))
return null;
int num = uri.AbsoluteUri.IndexOf('?');
if (num == -1)
return null;
string input = uri.AbsoluteUri.Substring(num);
Match match = Regex.Match(input, "p=([a-f0-9A-F]{32})");
if (!match.Success)
return null;
return match.Groups[1].Value;
} }
[HttpGet("~/api/v1/pull-payments/{pullPaymentId}")] [HttpGet("~/api/v1/pull-payments/{pullPaymentId}")]

View File

@@ -11,6 +11,7 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
@@ -127,13 +128,27 @@ namespace BTCPayServer.Controllers
if (_pullPaymentHostedService.SupportsLNURL(blob)) if (_pullPaymentHostedService.SupportsLNURL(blob))
{ {
var url = Url.Action("GetLNURLForPullPayment", "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, pullPaymentId = vm.Id }, Request.Scheme, Request.Host.ToString()); var url = Url.Action(nameof(UILNURLController.GetLNURLForPullPayment), "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, pullPaymentId = vm.Id }, Request.Scheme, Request.Host.ToString());
vm.LnurlEndpoint = url != null ? new Uri(url) : null; vm.LnurlEndpoint = url != null ? new Uri(url) : null;
vm.SetupDeepLink = $"boltcard://program?url={GetBoltcardDeeplinkUrl(vm, OnExistingBehavior.UpdateVersion)}";
vm.ResetDeepLink = $"boltcard://reset?url={GetBoltcardDeeplinkUrl(vm, OnExistingBehavior.KeepVersion)}";
} }
return View(nameof(ViewPullPayment), vm); return View(nameof(ViewPullPayment), vm);
} }
private string GetBoltcardDeeplinkUrl(ViewPullPaymentModel vm, OnExistingBehavior onExisting)
{
var registerUrl = Url.Action(nameof(GreenfieldPullPaymentController.RegisterBoltcard), "GreenfieldPullPayment",
new
{
pullPaymentId = vm.Id,
onExisting = onExisting.ToString()
}, Request.Scheme, Request.Host.ToString());
registerUrl = Uri.EscapeDataString(registerUrl);
return registerUrl;
}
[HttpGet("stores/{storeId}/pull-payments/edit/{pullPaymentId}")] [HttpGet("stores/{storeId}/pull-payments/edit/{pullPaymentId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> EditPullPayment(string storeId, string pullPaymentId) public async Task<IActionResult> EditPullPayment(string storeId, string pullPaymentId)

View File

@@ -71,6 +71,9 @@ namespace BTCPayServer.Models
public PaymentMethodId[] PaymentMethods { get; set; } public PaymentMethodId[] PaymentMethods { get; set; }
public string SetupDeepLink { get; set; }
public string ResetDeepLink { get; set; }
public string HubPath { get; set; } public string HubPath { get; set; }
public string ResetIn { get; set; } public string ResetIn { get; set; }
public string Email { get; set; } public string Email { get; set; }

View File

@@ -202,15 +202,15 @@
</p> </p>
@if (Model.LnurlEndpoint is not null) @if (Model.LnurlEndpoint is not null)
{ {
<p> <p id="BoltcardActions" style="visibility:hidden">
<a asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="configure-boltcard"> <a id="SetupBoltcard" asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="configure-boltcard">
Setup Boltcard Setup Boltcard
</a> </a>
<span>&nbsp;|&nbsp;</span> <span>&nbsp;|&nbsp;</span>
<a asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="reset-boltcard"> <a id="ResetBoltcard" asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="reset-boltcard">
Reset Boltcard Reset Boltcard
</a> </a>
</p> </p>
} }
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener"> <a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by <partial name="_StoreFooterLogo" /> Powered by <partial name="_StoreFooterLogo" />
@@ -226,6 +226,15 @@
<script src="~/vendor/ur-registry/urlib.min.js" asp-append-version="true"></script> <script src="~/vendor/ur-registry/urlib.min.js" asp-append-version="true"></script>
<script> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const isAndroid = /(android)/i.test(navigator.userAgent);
if (isAndroid) {
document.getElementById("SetupBoltcard").setAttribute('target', '_blank');
document.getElementById("SetupBoltcard").setAttribute('href', @Safe.Json(@Model.SetupDeepLink));
document.getElementById("ResetBoltcard").setAttribute('target', '_blank');
document.getElementById("ResetBoltcard").setAttribute('href', @Safe.Json(@Model.ResetDeepLink));
}
document.getElementById("BoltcardActions").style.visibility = "visible";
window.qrApp = initQRShow({}); window.qrApp = initQRShow({});
delegate('click', 'button[page-qr]', event => { delegate('click', 'button[page-qr]', event => {
qrApp.title = "Pull Payment QR"; qrApp.title = "Pull Payment QR";