Boltcard integration (#5419)

* Boltcard integration

* Add API for boltcard registration
This commit is contained in:
Nicolas Dorier
2023-12-06 09:17:58 +09:00
committed by GitHub
parent b13f140b86
commit d050c8e3b2
25 changed files with 981 additions and 74 deletions

View File

@@ -20,6 +20,12 @@ namespace BTCPayServer.Client
return await HandleResponse<PullPaymentData>(response);
}
public virtual async Task<RegisterBoltcardResponse> RegisterBoltcard(string pullPaymentId, RegisterBoltcardRequest request, CancellationToken cancellationToken = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}/boltcards", bodyPayload: request, method: HttpMethod.Post), cancellationToken);
return await HandleResponse<RegisterBoltcardResponse>(response);
}
public virtual async Task<PullPaymentData[]> GetPullPayments(string storeId, bool includeArchived = false, CancellationToken cancellationToken = default)
{
Dictionary<string, object> query = new Dictionary<string, object>();

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Text;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models
{
public enum OnExistingBehavior
{
KeepVersion,
UpdateVersion
}
public class RegisterBoltcardRequest
{
[JsonConverter(typeof(HexJsonConverter))]
[JsonProperty("UID")]
public byte[] UID { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public OnExistingBehavior? OnExisting { get; set; }
}
public class RegisterBoltcardResponse
{
[JsonProperty("LNURLW")]
public string LNURLW { get; set; }
public int Version { get; set; }
[JsonProperty("K0")]
public string K0 { get; set; }
[JsonProperty("K1")]
public string K1 { get; set; }
[JsonProperty("K2")]
public string K2 { get; set; }
[JsonProperty("K3")]
public string K3 { get; set; }
[JsonProperty("K4")]
public string K4 { get; set; }
}
}

View File

@@ -1,4 +1,4 @@
using BTCPayServer.Data;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@@ -0,0 +1,38 @@
using System.Security.Permissions;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20231020135844_AddBoltcardsTable")]
public partial class AddBoltcardsTable : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "boltcards",
columns: table => new
{
id = table.Column<string>(maxLength: 32, nullable: false),
counter = table.Column<int>(type: "INT", nullable: false, defaultValue: 0),
ppid = table.Column<string>(maxLength: 30, nullable: true),
version = table.Column<int>(nullable: false, defaultValue: 0)
},
constraints: table =>
{
table.PrimaryKey("PK_id", x => x.id);
table.ForeignKey("FK_boltcards_PullPayments", x => x.ppid, "PullPayments", "Id", onDelete: ReferentialAction.SetNull);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable("boltcards");
}
}
}

View File

@@ -197,10 +197,11 @@ retry:
driver.FindElement(selector).Click();
}
[DebuggerHidden]
public static bool ElementDoesNotExist(this IWebDriver driver, By selector)
{
Assert.Throws<NoSuchElementException>(() =>
Assert.Throws<NoSuchElementException>(
[DebuggerStepThrough]
() =>
{
driver.FindElement(selector);
});

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
@@ -1085,6 +1086,38 @@ namespace BTCPayServer.Tests
Assert.Equal(12.303228134m, test4.Amount);
Assert.Equal("BTC", test4.Currency);
// Check we can register Boltcard
var uid = new byte[7];
RandomNumberGenerator.Fill(uid);
var card = await client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
UID = uid
});
Assert.Equal(0, card.Version);
var card1keys = new[] { card.K0, card.K1, card.K2, card.K3, card.K4 };
Assert.DoesNotContain(null, card1keys);
var card2 = await client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
UID = uid
});
Assert.Equal(1, card2.Version);
Assert.StartsWith("lnurlw://", card2.LNURLW);
Assert.EndsWith("/boltcard", card2.LNURLW);
var card2keys = new[] { card2.K0, card2.K1, card2.K2, card2.K3, card2.K4 };
Assert.DoesNotContain(null, card2keys);
for (int i = 0; i < card1keys.Length; i++)
{
if (i == 1)
Assert.Contains(card1keys[i], card2keys);
else
Assert.DoesNotContain(card1keys[i], card2keys);
}
var card3 = await client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
UID = uid,
OnExisting = OnExistingBehavior.KeepVersion
});
Assert.Equal(card2.Version, card3.Version);
// Test with SATS denomination values
var testSats = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest()
{

View File

@@ -6,6 +6,7 @@ using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
@@ -17,6 +18,7 @@ using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
@@ -25,6 +27,8 @@ using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores;
using BTCPayServer.Views.Wallets;
using Dapper;
using ExchangeSharp;
using LNURL;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@@ -2116,6 +2120,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector("#lnurlwithdraw-button")).Click();
s.Driver.WaitForElement(By.Id("qr-code-data-input"));
// Try to use lnurlw via the QR Code
var lnurl = new Uri(LNURL.LNURL.Parse(s.Driver.FindElement(By.Id("qr-code-data-input")).GetAttribute("value"), out _).ToString().Replace("https", "http"));
s.Driver.FindElement(By.CssSelector("button[data-bs-dismiss='modal']")).Click();
var info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(lnurl, s.Server.PayTester.HttpClient));
@@ -2126,7 +2131,7 @@ namespace BTCPayServer.Tests
Assert.Equal(info.CurrentBalance, new LightMoney(0.0000001m, LightMoneyUnit.BTC));
var bolt2 = (await s.Server.CustomerLightningD.CreateInvoice(
new LightMoney(0.0000001m, LightMoneyUnit.BTC),
new LightMoney(0.00000005m, LightMoneyUnit.BTC),
$"LNurl w payout test {DateTime.UtcNow.Ticks}",
TimeSpan.FromHours(1), CancellationToken.None));
var response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null,null);
@@ -2141,6 +2146,52 @@ namespace BTCPayServer.Tests
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// Simulate a boltcard
{
var db = s.Server.PayTester.GetService<ApplicationDbContextFactory>();
var ppid = lnurl.AbsoluteUri.Split("/").Last();
var issuerKey = new IssuerKey(SettingsRepositoryExtensions.FixedKey());
var uid = RandomNumberGenerator.GetBytes(7);
var cardKey = issuerKey.CreateCardKey(uid, 0);
var keys = cardKey.DeriveBoltcardKeys(issuerKey);
await db.LinkBoltcardToPullPayment(ppid, issuerKey, uid);
var piccData = new byte[] { 0xc7 }.Concat(uid).Concat(new byte[] { 1, 0, 0, 0, 0, 0, 0, 0 }).ToArray();
var p = keys.EncryptionKey.Encrypt(piccData);
var c = keys.AuthenticationKey.GetSunMac(uid, 1);
var boltcardUrl = new Uri(s.Server.PayTester.ServerUri.AbsoluteUri + $"boltcard?p={Encoders.Hex.EncodeData(p).ToStringUpperInvariant()}&c={Encoders.Hex.EncodeData(c).ToStringUpperInvariant()}");
// 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);
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)}"));
await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(fakeBoltcardUrl, s.Server.PayTester.HttpClient));
bolt2 = (await s.Server.CustomerLightningD.CreateInvoice(
new LightMoney(0.00000005m, LightMoneyUnit.BTC),
$"LNurl w payout test2 {DateTime.UtcNow.Ticks}",
TimeSpan.FromHours(1), CancellationToken.None));
response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null, null);
Assert.Equal("OK", response.Status);
// No replay should be possible
await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient));
response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null, null);
Assert.Equal("ERROR", response.Status);
Assert.Contains("Replayed", response.Reason);
// Check the state of the registration, counter should have increased
var reg = await db.GetBoltcardRegistration(issuerKey, uid);
Assert.Equal((ppid, 1, 0), (reg.PullPaymentId, reg.Counter, reg.Version));
await db.SetBoltcardResetState(issuerKey, uid);
// After reset, counter is 0, version unchanged and ppId null
reg = await db.GetBoltcardRegistration(issuerKey, uid);
Assert.Equal((null, 0, 0), (reg.PullPaymentId, reg.Counter, reg.Version));
await db.LinkBoltcardToPullPayment(ppid, issuerKey, uid);
// Relink should bump Version
reg = await db.GetBoltcardRegistration(issuerKey, uid);
Assert.Equal((ppid, 0, 1), (reg.PullPaymentId, reg.Counter, reg.Version));
}
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
s.Driver.FindElement(By.Id("NewPullPayment")).Click();
s.Driver.FindElement(By.Id("Name")).SendKeys("PP1");
@@ -2221,6 +2272,12 @@ namespace BTCPayServer.Tests
s.Driver.Close();
}
private string RandomBytes(int count)
{
var c = RandomNumberGenerator.GetBytes(count);
return Encoders.Hex.EncodeData(c);
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]

View File

@@ -0,0 +1,42 @@
using Newtonsoft.Json.Linq;
using System.Net.WebSockets;
using System.Threading.Tasks;
using System.Threading;
using BTCPayServer.NTag424;
using NBitcoin.DataEncoders;
using System;
using SocketIOClient;
namespace BTCPayServer
{
public class APDUVaultTransport : IAPDUTransport
{
private readonly VaultClient _vaultClient;
public APDUVaultTransport(VaultClient vaultClient)
{
_vaultClient = vaultClient;
}
public async Task WaitForCard(CancellationToken cancellationToken)
{
await _vaultClient.SendVaultRequest("/wait-for-card", null, cancellationToken);
}
public async Task WaitForRemoved(CancellationToken cancellationToken)
{
await _vaultClient.SendVaultRequest("/wait-for-disconnected", null, cancellationToken);
}
public async Task<NtagResponse> SendAPDU(byte[] apdu, CancellationToken cancellationToken)
{
var resp = await _vaultClient.SendVaultRequest("/",
new JObject()
{
["apdu"] = Encoders.Hex.EncodeData(apdu)
}, cancellationToken);
var data = Encoders.Hex.DecodeData(resp["data"].Value<string>());
return new NtagResponse(data, resp["status"].Value<ushort>());
}
}
}

View File

@@ -46,6 +46,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.17" />
<PackageReference Include="YamlDotNet" Version="8.0.0" />
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />

View File

@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
@@ -10,6 +11,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Security;
using BTCPayServer.Services;
@@ -19,6 +21,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using NBitcoin.DataEncoders;
using Newtonsoft.Json.Linq;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
@@ -37,6 +40,8 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly IAuthorizationService _authorizationService;
private readonly SettingsRepository _settingsRepository;
private readonly BTCPayServerEnvironment _env;
public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService,
LinkGenerator linkGenerator,
@@ -45,7 +50,9 @@ namespace BTCPayServer.Controllers.Greenfield
Services.BTCPayNetworkJsonSerializerSettings serializerSettings,
IEnumerable<IPayoutHandler> payoutHandlers,
BTCPayNetworkProvider btcPayNetworkProvider,
IAuthorizationService authorizationService)
IAuthorizationService authorizationService,
SettingsRepository settingsRepository,
BTCPayServerEnvironment env)
{
_pullPaymentService = pullPaymentService;
_linkGenerator = linkGenerator;
@@ -55,6 +62,8 @@ namespace BTCPayServer.Controllers.Greenfield
_payoutHandlers = payoutHandlers;
_networkProvider = btcPayNetworkProvider;
_authorizationService = authorizationService;
_settingsRepository = settingsRepository;
_env = env;
}
[HttpGet("~/api/v1/stores/{storeId}/pull-payments")]
@@ -187,6 +196,46 @@ namespace BTCPayServer.Controllers.Greenfield
};
}
[HttpPost]
[Route("~/api/v1/pull-payments/{pullPaymentId}/boltcards")]
[AllowAnonymous]
public async Task<IActionResult> RegisterBoltcard(string pullPaymentId, RegisterBoltcardRequest request)
{
if (pullPaymentId is null)
return PullPaymentNotFound();
var pp = await _pullPaymentService.GetPullPayment(pullPaymentId, false);
if (pp is null)
return PullPaymentNotFound();
if (request?.UID is null || request.UID.Length != 7)
{
ModelState.AddModelError(nameof(request.UID), "The UID is required and should be 7 bytes");
return this.CreateValidationError(ModelState);
}
if (!_pullPaymentService.SupportsLNURL(pp.GetBlob()))
{
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);
var version = await _dbContextFactory.LinkBoltcardToPullPayment(pullPaymentId, issuerKey, request.UID, request.OnExisting);
var keys = issuerKey.CreateCardKey(request.UID, version).DeriveBoltcardKeys(issuerKey);
var boltcardUrl = Url.Action(nameof(UIBoltcardController.GetWithdrawRequest), "UIBoltcard");
boltcardUrl = Request.GetAbsoluteUri(boltcardUrl);
boltcardUrl = Regex.Replace(boltcardUrl, "^https?://", "lnurlw://");
return Ok(new RegisterBoltcardResponse()
{
LNURLW = boltcardUrl,
Version = version,
K0 = Encoders.Hex.EncodeData(keys.AppMasterKey.ToBytes()).ToUpperInvariant(),
K1 = Encoders.Hex.EncodeData(keys.EncryptionKey.ToBytes()).ToUpperInvariant(),
K2 = Encoders.Hex.EncodeData(keys.AuthenticationKey.ToBytes()).ToUpperInvariant(),
K3 = Encoders.Hex.EncodeData(keys.K3.ToBytes()).ToUpperInvariant(),
K4 = Encoders.Hex.EncodeData(keys.K4.ToBytes()).ToUpperInvariant(),
});
}
[HttpGet("~/api/v1/pull-payments/{pullPaymentId}")]
[AllowAnonymous]
public async Task<IActionResult> GetPullPayment(string pullPaymentId)

View File

@@ -1275,6 +1275,11 @@ namespace BTCPayServer.Controllers.Greenfield
return GetFromActionResult<PayoutData>(await GetController<GreenfieldPullPaymentController>().GetPayout(pullPaymentId, payoutId));
}
public override async Task<RegisterBoltcardResponse> RegisterBoltcard(string pullPaymentId, RegisterBoltcardRequest request, CancellationToken cancellationToken = default)
{
return GetFromActionResult<RegisterBoltcardResponse>(await GetController<GreenfieldPullPaymentController>().RegisterBoltcard(pullPaymentId, request));
}
public override async Task<PullPaymentLNURL> GetPullPaymentLNURL(string pullPaymentId, CancellationToken cancellationToken = default)
{
return GetFromActionResult<PullPaymentLNURL>(await GetController<GreenfieldPullPaymentController>().GetPullPaymentLNURL(pullPaymentId));

View File

@@ -0,0 +1,70 @@
#nullable enable
using Dapper;
using System.Linq;
using System.Security;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.NTag424;
using BTCPayServer.Services;
using LNURL;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Threading;
using System;
using NBitcoin.DataEncoders;
using System.Text.Json.Serialization;
namespace BTCPayServer.Controllers;
public class UIBoltcardController : Controller
{
public class BoltcardSettings
{
[JsonConverter(typeof(NBitcoin.JsonConverters.HexJsonConverter))]
public byte[]? IssuerKey { get; set; }
}
public UIBoltcardController(
UILNURLController lnUrlController,
SettingsRepository settingsRepository,
ApplicationDbContextFactory contextFactory,
BTCPayServerEnvironment env)
{
LNURLController = lnUrlController;
SettingsRepository = settingsRepository;
ContextFactory = contextFactory;
Env = env;
}
public UILNURLController LNURLController { get; }
public SettingsRepository SettingsRepository { get; }
public ApplicationDbContextFactory ContextFactory { get; }
public BTCPayServerEnvironment Env { get; }
[AllowAnonymous]
[HttpGet("~/boltcard")]
public async Task<IActionResult> GetWithdrawRequest([FromQuery] string? p, [FromQuery] string? c, [FromQuery] string? pr, [FromQuery] string? k1, CancellationToken cancellationToken)
{
if (p is null || c is null)
{
var k1s = k1?.Split('-');
if (k1s is not { Length: 2 })
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Missing p, c, or k1 query parameter" });
p = k1s[0];
c = k1s[1];
}
var issuerKey = await SettingsRepository.GetIssuerKey(Env);
var piccData = issuerKey.TryDecrypt(p);
if (piccData is null)
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Invalid PICCData" });
var registration = await ContextFactory.GetBoltcardRegistration(issuerKey, piccData, updateCounter: pr is not null);
if (registration?.PullPaymentId is null)
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Replayed or expired query" });
var cardKey = issuerKey.CreateCardKey(piccData.Uid, registration.Version);
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);
}
}

View File

@@ -90,9 +90,13 @@ namespace BTCPayServer
_pluginHookService = pluginHookService;
_invoiceActivator = invoiceActivator;
}
[HttpGet("withdraw/pp/{pullPaymentId}")]
public async Task<IActionResult> GetLNURLForPullPayment(string cryptoCode, string pullPaymentId, [FromQuery] string pr, CancellationToken cancellationToken)
public Task<IActionResult> GetLNURLForPullPayment(string cryptoCode, string pullPaymentId, [FromQuery] string pr, CancellationToken cancellationToken)
{
return GetLNURLForPullPayment(cryptoCode, pullPaymentId, pr, pullPaymentId, cancellationToken);
}
[NonAction]
internal async Task<IActionResult> GetLNURLForPullPayment(string cryptoCode, string pullPaymentId, string pr, string k1, CancellationToken cancellationToken)
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
if (network is null || !network.SupportLightning)
@@ -119,7 +123,7 @@ namespace BTCPayServer
var request = new LNURLWithdrawRequest
{
MaxWithdrawable = LightMoney.FromUnit(remaining, unit),
K1 = pullPaymentId,
K1 = k1,
BalanceCheck = new Uri(Request.GetCurrentUrl()),
CurrentBalance = LightMoney.FromUnit(remaining, unit),
MinWithdrawable =

View File

@@ -0,0 +1,202 @@
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Models;
using BTCPayServer.NTag424;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NBitcoin.DataEncoders;
using System;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using static BTCPayServer.BoltcardDataExtensions;
namespace BTCPayServer.Controllers
{
public partial class UIPullPaymentController
{
[AllowAnonymous]
[HttpGet("pull-payments/{pullPaymentId}/boltcard/{command}")]
public IActionResult SetupBoltcard(string pullPaymentId, string command)
{
return View(nameof(SetupBoltcard), new SetupBoltcardViewModel()
{
ReturnUrl = Url.Action(nameof(ViewPullPayment), "UIPullPayment", new { pullPaymentId }),
WebsocketPath = Url.Action(nameof(VaultNFCBridgeConnection), "UIPullPayment", new { pullPaymentId }),
Command = command
});
}
[AllowAnonymous]
[HttpPost("pull-payments/{pullPaymentId}/boltcard/{command}")]
public IActionResult SetupBoltcardPost(string pullPaymentId, string command)
{
TempData[WellKnownTempData.SuccessMessage] = "Boltcard is configured";
return RedirectToAction(nameof(ViewPullPayment), new { pullPaymentId });
}
record CardOrigin
{
public record Blank() : CardOrigin;
public record ThisIssuer(BoltcardRegistration Registration) : CardOrigin;
public record ThisIssuerConfigured(string PullPaymentId, BoltcardRegistration Registration) : ThisIssuer(Registration);
public record OtherIssuer() : CardOrigin;
public record ThisIssuerReset(BoltcardRegistration Registration) : ThisIssuer(Registration);
}
[HttpGet]
[Route("pull-payments/{pullPaymentId}/nfc/bridge")]
public async Task<IActionResult> VaultNFCBridgeConnection(string pullPaymentId)
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
return NotFound();
var pp = await _pullPaymentHostedService.GetPullPayment(pullPaymentId, false);
if (pp is null)
return NotFound();
if (!_pullPaymentHostedService.SupportsLNURL(pp.GetBlob()))
return BadRequest();
var boltcardUrl = Url.Action(nameof(UIBoltcardController.GetWithdrawRequest), "UIBoltcard");
boltcardUrl = Request.GetAbsoluteUri(boltcardUrl);
var websocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
var vaultClient = new VaultClient(websocket);
var transport = new APDUVaultTransport(vaultClient);
var ntag = new Ntag424(transport);
using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)))
{
next:
while (websocket.State == System.Net.WebSockets.WebSocketState.Open)
{
try
{
var command = await vaultClient.GetNextCommand(cts.Token);
var permission = await vaultClient.AskPermission(VaultServices.NFC, cts.Token);
if (permission is null)
{
await vaultClient.Show(VaultMessageType.Error, "BTCPay Server Vault does not seem to be running, you can download it on <a target=\"_blank\" href=\"https://github.com/btcpayserver/BTCPayServer.Vault/releases/latest\">Github</a>.", cts.Token);
goto next;
}
await vaultClient.Show(VaultMessageType.Ok, "BTCPayServer successfully connected to the vault.", cts.Token);
if (permission is false)
{
await vaultClient.Show(VaultMessageType.Error, "The user declined access to the vault.", cts.Token);
goto next;
}
await vaultClient.Show(VaultMessageType.Ok, "Access to vault granted by owner.", cts.Token);
await vaultClient.Show(VaultMessageType.Processing, "Waiting for NFC to be presented...", cts.Token);
await transport.WaitForCard(cts.Token);
await vaultClient.Show(VaultMessageType.Ok, "NFC detected.", cts.Token);
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
CardOrigin cardOrigin = await GetCardOrigin(pullPaymentId, ntag, issuerKey, cts.Token);
if (cardOrigin is CardOrigin.OtherIssuer)
{
await vaultClient.Show(VaultMessageType.Error, "This card is already configured for another issuer", cts.Token);
goto next;
}
bool success = false;
switch (command)
{
case "configure-boltcard":
await vaultClient.Show(VaultMessageType.Processing, "Configuring Boltcard...", cts.Token);
if (cardOrigin is CardOrigin.Blank || cardOrigin is CardOrigin.ThisIssuerReset)
{
await ntag.AuthenticateEV2First(0, AESKey.Default, cts.Token);
var uid = await ntag.GetCardUID();
try
{
var version = await _dbContextFactory.LinkBoltcardToPullPayment(pullPaymentId, issuerKey, uid);
var cardKey = issuerKey.CreateCardKey(uid, version);
await ntag.SetupBoltcard(boltcardUrl, BoltcardKeys.Default, cardKey.DeriveBoltcardKeys(issuerKey));
}
catch
{
await _dbContextFactory.SetBoltcardResetState(issuerKey, uid);
throw;
}
await vaultClient.Show(VaultMessageType.Ok, "The card is now configured", cts.Token);
}
else if (cardOrigin is CardOrigin.ThisIssuer)
{
await vaultClient.Show(VaultMessageType.Ok, "This card is already properly configured", cts.Token);
}
success = true;
break;
case "reset-boltcard":
await vaultClient.Show(VaultMessageType.Processing, "Resetting Boltcard...", cts.Token);
if (cardOrigin is CardOrigin.Blank)
{
await vaultClient.Show(VaultMessageType.Ok, "This card is already in a factory state", cts.Token);
}
else if (cardOrigin is CardOrigin.ThisIssuer thisIssuer)
{
var cardKey = issuerKey.CreateCardKey(thisIssuer.Registration.UId, thisIssuer.Registration.Version);
await ntag.ResetCard(issuerKey, cardKey);
await _dbContextFactory.SetBoltcardResetState(issuerKey, thisIssuer.Registration.UId);
await vaultClient.Show(VaultMessageType.Ok, "Card reset succeed", cts.Token);
}
success = true;
break;
}
if (success)
{
await vaultClient.Show(VaultMessageType.Processing, "Please remove the NFC from the card reader", cts.Token);
await transport.WaitForRemoved(cts.Token);
await vaultClient.Show(VaultMessageType.Ok, "Thank you!", cts.Token);
await vaultClient.SendSimpleMessage("done", cts.Token);
}
}
catch (Exception) when (websocket.State != WebSocketState.Open || cts.IsCancellationRequested)
{
await WebsocketHelper.CloseSocket(websocket);
}
catch (Exception ex)
{
try
{
await vaultClient.Show(VaultMessageType.Error, "Unexpected error: " + ex.Message, ex.ToString(), cts.Token);
}
catch { }
}
}
}
return new EmptyResult();
}
private async Task<CardOrigin> GetCardOrigin(string pullPaymentId, Ntag424 ntag, IssuerKey issuerKey, CancellationToken cancellationToken)
{
CardOrigin cardOrigin;
Uri uri = await ntag.TryReadNDefURI(cancellationToken);
if (uri is null)
{
cardOrigin = new CardOrigin.Blank();
}
else
{
var piccData = issuerKey.TryDecrypt(uri);
if (piccData is null)
{
cardOrigin = new CardOrigin.OtherIssuer();
}
else
{
var res = await _dbContextFactory.GetBoltcardRegistration(issuerKey, piccData.Uid);
if (res != null && res.PullPaymentId is null)
cardOrigin = new CardOrigin.ThisIssuerReset(res);
else if (res?.PullPaymentId != pullPaymentId)
cardOrigin = new CardOrigin.OtherIssuer();
else
cardOrigin = new CardOrigin.ThisIssuerConfigured(res.PullPaymentId, res);
}
}
return cardOrigin;
}
}
}

View File

@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Amazon.S3.Model;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
@@ -10,20 +13,27 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.DataEncoders;
using NdefLibrary.Ndef;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Controllers
{
public class UIPullPaymentController : Controller
public partial class UIPullPaymentController : Controller
{
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly CurrencyNameTable _currencyNameTable;
@@ -33,6 +43,8 @@ namespace BTCPayServer.Controllers
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly StoreRepository _storeRepository;
private readonly BTCPayServerEnvironment _env;
private readonly SettingsRepository _settingsRepository;
public UIPullPaymentController(ApplicationDbContextFactory dbContextFactory,
CurrencyNameTable currencyNameTable,
@@ -41,7 +53,9 @@ namespace BTCPayServer.Controllers
BTCPayNetworkProvider networkProvider,
BTCPayNetworkJsonSerializerSettings serializerSettings,
IEnumerable<IPayoutHandler> payoutHandlers,
StoreRepository storeRepository)
StoreRepository storeRepository,
BTCPayServerEnvironment env,
SettingsRepository settingsRepository)
{
_dbContextFactory = dbContextFactory;
_currencyNameTable = currencyNameTable;
@@ -50,6 +64,8 @@ namespace BTCPayServer.Controllers
_serializerSettings = serializerSettings;
_payoutHandlers = payoutHandlers;
_storeRepository = storeRepository;
_env = env;
_settingsRepository = settingsRepository;
_networkProvider = networkProvider;
}
@@ -225,10 +241,10 @@ namespace BTCPayServer.Controllers
return await ViewPullPayment(pullPaymentId);
}
var amtError = ClaimRequest.IsPayoutAmountOk(destination, vm.ClaimedAmount == 0? null: vm.ClaimedAmount, paymentMethodId.CryptoCode, ppBlob.Currency);
var amtError = ClaimRequest.IsPayoutAmountOk(destination, vm.ClaimedAmount == 0 ? null : vm.ClaimedAmount, paymentMethodId.CryptoCode, ppBlob.Currency);
if (amtError.error is not null)
{
ModelState.AddModelError(nameof(vm.ClaimedAmount), amtError.error );
ModelState.AddModelError(nameof(vm.ClaimedAmount), amtError.error);
}
else if (amtError.amount is not null)
{

View File

@@ -16,7 +16,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using System.Text.Json.Nodes;
using System.Threading;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;

View File

@@ -0,0 +1,62 @@
#nullable enable
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.NTag424;
using Dapper;
using Microsoft.EntityFrameworkCore;
using NBitcoin.DataEncoders;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer;
public static class BoltcardDataExtensions
{
public static async Task<int> LinkBoltcardToPullPayment(this ApplicationDbContextFactory dbContextFactory, string pullPaymentId, IssuerKey issuerKey, byte[] uid, OnExistingBehavior? onExisting = null)
{
onExisting ??= OnExistingBehavior.UpdateVersion;
using var ctx = dbContextFactory.CreateContext();
var conn = ctx.Database.GetDbConnection();
string onConflict = onExisting switch
{
OnExistingBehavior.KeepVersion => "UPDATE SET ppid=excluded.ppid, version=boltcards.version",
OnExistingBehavior.UpdateVersion => "UPDATE SET ppid=excluded.ppid, version=excluded.version+1",
_ => throw new NotSupportedException()
};
return await conn.QueryFirstOrDefaultAsync<int>(
$"INSERT INTO boltcards(id, ppid) VALUES (@id, @ppid) ON CONFLICT (id) DO {onConflict} RETURNING version", new
{
id = GetId(issuerKey, uid),
ppid = pullPaymentId
});
}
public static async Task SetBoltcardResetState(this ApplicationDbContextFactory dbContextFactory, IssuerKey issuerKey, byte[] uid)
{
using var ctx = dbContextFactory.CreateContext();
var conn = ctx.Database.GetDbConnection();
await conn.ExecuteAsync("UPDATE boltcards SET ppid=NULL, counter=0 WHERE id=@id", new
{
id = GetId(issuerKey, uid)
});
}
static string GetId(IssuerKey issuerKey, byte[] uid) => Encoders.Hex.EncodeData(issuerKey.GetId(uid));
public record BoltcardRegistration(string? PullPaymentId, string Id, byte[] UId, int Version, int Counter);
public static async Task<BoltcardRegistration?> GetBoltcardRegistration(this ApplicationDbContextFactory dbContextFactory, IssuerKey issuerKey, BoltcardPICCData piccData, bool updateCounter)
{
using var ctx = dbContextFactory.CreateContext();
var conn = ctx.Database.GetDbConnection();
var query = updateCounter ? "UPDATE boltcards SET counter=@counter WHERE id=@id AND counter < @counter RETURNING ppid, id, version, counter"
: "SELECT ppid, id, version, counter FROM boltcards WHERE id=@id AND counter < @counter";
var res = await conn.QueryFirstOrDefaultAsync(query, new { id = GetId(issuerKey, piccData.Uid), counter = piccData.Counter });
if (res is null)
return null;
return new BoltcardRegistration(res.ppid, res.id, piccData.Uid, res.version, res.counter);
}
public static Task<BoltcardRegistration?> GetBoltcardRegistration(this ApplicationDbContextFactory dbContextFactory, IssuerKey issuerKey, byte[] uid)
{
var data = new BoltcardPICCData(uid, int.MaxValue);
return GetBoltcardRegistration(dbContextFactory, issuerKey, data, false);
}
}

View File

@@ -5,6 +5,7 @@ using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Configuration;
using BTCPayServer.Logging;
using BTCPayServer.NTag424;
using BTCPayServer.Services;
using Microsoft.Extensions.Logging;

View File

@@ -0,0 +1,32 @@
#nullable enable
using BTCPayServer.NTag424;
using BTCPayServer.Services;
using static BTCPayServer.Controllers.UIBoltcardController;
using System.Threading.Tasks;
namespace BTCPayServer;
public static class SettingsRepositoryExtensions
{
public static async Task<IssuerKey> GetIssuerKey(this SettingsRepository settingsRepository, BTCPayServerEnvironment env)
{
var settings = await settingsRepository.GetSettingAsync<BoltcardSettings>(nameof(BoltcardSettings));
AESKey issuerKey;
if (settings?.IssuerKey is byte[] bytes)
{
issuerKey = new AESKey(bytes);
}
else
{
issuerKey = env.CheatMode && env.IsDeveloping ? FixedKey() : AESKey.Random();
settings = new BoltcardSettings() { IssuerKey = issuerKey.ToBytes() };
await settingsRepository.UpdateSetting(settings, nameof(BoltcardSettings));
}
return new IssuerKey(issuerKey);
}
internal static AESKey FixedKey()
{
byte[] v = new byte[16];
v[0] = 1;
return new AESKey(v);
}
}

View File

@@ -0,0 +1,9 @@
namespace BTCPayServer.Models
{
public class SetupBoltcardViewModel
{
public string ReturnUrl { get; set; }
public string WebsocketPath { get; set; }
public string Command { get; set; }
}
}

View File

@@ -0,0 +1,83 @@
@using BTCPayServer.Controllers
@model SetupBoltcardViewModel
@{
Layout = "_LayoutWizard";
}
@section Navbar {
<a href="@Url.EnsureLocal(Model.ReturnUrl, Context.Request)" id="CancelWizard" class="cancel">
<vc:icon symbol="close" />
</a>
}
<header class="text-center">
<h1>@ViewData["Title"]</h1>
<p class="lead text-secondary mt-3">Using BTCPay Server Vault (NFC)</p>
</header>
<div id="walletAlert" class="alert alert-warning alert-dismissible my-4" style="display:none;" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">
<vc:icon symbol="close" />
</button>
<span id="alertMessage"></span>
</div>
<div id="body" class="my-4">
<form id="broadcastForm" method="post" style="display:none;">
<input type="hidden" asp-for="WebsocketPath" />
<input type="hidden" asp-for="ReturnUrl" />
</form>
<div id="vaultPlaceholder"></div>
<button id="vault-retry" class="btn btn-primary" style="display:none;" type="button">Retry</button>
<button id="vault-confirm" class="btn btn-primary" style="display:none;"></button>
</div>
<partial name="VaultElements" />
@section PageFootContent
{
<script src="~/js/vaultbridge.js" type="text/javascript" defer="defer" asp-append-version="true"></script>
<script src="~/js/vaultbridge.ui.js" type="text/javascript" defer="defer" asp-append-version="true"></script>
<script>
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function askSign() {
var websocketPath = $("#WebsocketPath").val();
var loc = window.location, ws_uri;
if (loc.protocol === "https:") {
ws_uri = "wss:";
} else {
ws_uri = "ws:";
}
ws_uri += "//" + loc.host;
ws_uri += websocketPath;
var html = $("#VaultConnection").html();
$("#vaultPlaceholder").html(html);
var vaultUI = new vaultui.VaultBridgeUI(ws_uri);
var command = @Safe.Json(Model.Command);
while (!await vaultUI.sendBackendCommand(command)) {
await vaultUI.waitRetryPushed();
}
await delay(2000);
$("#broadcastForm").submit();
}
document.addEventListener("DOMContentLoaded", function () {
askSign();
});
var alertMsg = document.getElementById("alertMessage");
var walletAlert = document.getElementById("walletAlert");
var isSafari = window.safari !== undefined;
if (isSafari)
{
alertMsg.innerHTML = "Safari doesn't support BTCPay Server Vault. Please use a different browser. (<a class=\"alert-link\" href=\"https://bugs.webkit.org/show_bug.cgi?id=171934\" target=\"_blank\" rel=\"noreferrer noopener\">More information</a>)";
walletAlert.style.display = null;
}
var isBrave = navigator.brave !== undefined;
if (isBrave)
{
alertMsg.innerHTML = "Brave supports BTCPay Server Vault, but you need to disable Brave Shields. (<a class=\"alert-link\" href=\"https://www.updateland.com/how-to-turn-off-brave-shields/\" target=\"_blank\" rel=\"noreferrer noopener\">More information</a>)";
walletAlert.style.display = null;
}
</script>
}

View File

@@ -31,7 +31,9 @@
<link href="~/vendor/bootstrap-vue/bootstrap-vue.min.css" asp-append-version="true" rel="stylesheet"/>
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
<style>
.no-marker > ul { list-style-type: none; }
.no-marker > ul {
list-style-type: none;
}
</style>
</head>
<body class="min-vh-100">
@@ -203,13 +205,25 @@
Edit pull payment
</a>
</p>
@if (Model.LnurlEndpoint is not null)
{
<p>
<a asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="configure-boltcard">
Setup Boltcard
</a>
<span>&nbsp;|&nbsp;</span>
<a asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="reset-boltcard">
Reset Boltcard
</a>
</p>
}
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by <partial name="_StoreFooterLogo" />
</a>
</footer>
</div>
<partial name="ShowQR" />
<partial name="CameraScanner"/>
<partial name="CameraScanner" />
<partial name="LayoutFoot" />
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>

View File

@@ -60,12 +60,14 @@ var vaultui = (function () {
* @type {VaultBridgeUI}
*/
var self = this;
/**
* @type {string}
*/
this.backend_uri = backend_uri;
/**
* @type {vault.VaultBridge}
*/
this.bridge = null;
/**
* @type {string}
*/
@@ -127,6 +129,18 @@ var vaultui = (function () {
console.warn(json.details);
}
}
function showMessage(message) {
let type = 'ok';
if (message.type === 'Error')
type = 'failed';
if (message.type === 'Processing')
type = '?';
show(new VaultFeedback(type, message.message, ""));
if (type.debug)
console.warn(type.debug);
}
async function needRetry(json) {
if (json.hasOwnProperty("error")) {
var handled = false;
@@ -212,7 +226,26 @@ var vaultui = (function () {
}
return true;
};
this.sendBackendCommand = async function (command) {
if (!self.bridge || self.bridge.socket.readyState !== 1) {
self.bridge = await vault.connectToBackendSocket(self.backend_uri);
}
show(VaultFeedbacks.vaultLoading);
self.bridge.socket.send(command);
while (true) {
var json = await self.bridge.waitBackendMessage();
if (json.command === 'showMessage') {
showMessage(json);
if (json.type === "Error") {
showRetry();
return false;
}
}
if (json.command == 'done') {
return true;
}
}
}
this.askForDisplayAddress = async function (rootedKeyPath) {
if (!await self.ensureConnectedToBackend())
return false;

View File

@@ -1,5 +1,116 @@
{
"paths": {
"/api/v1/pull-payments/{pullPaymentId}/boltcards": {
"parameters": [
{
"name": "pullPaymentId",
"in": "path",
"required": true,
"description": "The ID of the pull payment",
"schema": {
"type": "string"
}
}
],
"post": {
"operationId": "PullPayments_LinkBoltcard",
"summary": "Link a boltcard to a pull payment",
"description": "Linking a boltcard to a pull payment will allow you to pay via NFC with it, the money will be sent from the pull payment. The boltcard keys are generated using [Deterministic Boltcard Key Generation](https://github.com/boltcard/boltcard/blob/main/docs/DETERMINISTIC.md).",
"requestBody": {
"content": {
"application/json": {
"schema": {
"required": [ "UID" ],
"type": "object",
"properties": {
"UID": {
"type": "string",
"example": "46ab87ff36a3b7",
"description": "The `UID` of the NTag424",
"nullable": false
},
"onExisting": {
"type": "string",
"x-enumNames": [
"KeepVersion",
"UpdateVersion"
],
"enum": [
"KeepVersion",
"UpdateVersion"
],
"default": "UpdateVersion",
"description": "What to do if the boltcard is already linked.\n * `KeepVersion` will return the keys (K0-K4) that are already registered.\n * `UpdateVersion` will increment the version of the key, and thus return different keys (K0-K4). (See [Deterministic Boltcard Key Generation](https://github.com/boltcard/boltcard/blob/main/docs/DETERMINISTIC.md))"
}
}
}
}
}
},
"responses": {
"200": {
"description": "The boltcard has been linked to the pull payment.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"LNURLW": {
"type": "string",
"description": "The lnurl withdraw of the server",
"example": "lnurlw://example.com/boltcard"
},
"version": {
"type": "number",
"description": "The version of the registration (See [Deterministic Boltcard Key Generation](https://github.com/boltcard/boltcard/blob/main/docs/DETERMINISTIC.md))"
},
"K0": {
"type": "string",
"description": "The public key K0 of the boltcard",
"example": "02a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b"
},
"K1": {
"type": "string",
"description": "The public key K1 of the boltcard",
"example": "02a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b"
},
"K2": {
"type": "string",
"description": "The public key K2 of the boltcard",
"example": "02a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b"
},
"K3": {
"type": "string",
"description": "The public key K3 of the boltcard",
"example": "02a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b"
},
"K4": {
"type": "string",
"description": "The public key K4 of the boltcard",
"example": "02a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b"
}
}
}
}
}
},
"404": {
"description": "The pull payment has not been found. Wellknown error code is: `pullpayment-not-found`",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"tags": [
"Pull payments (Public)"
]
}
},
"/api/v1/stores/{storeId}/pull-payments": {
"parameters": [
{