Compare commits

...

6 Commits

26 changed files with 1517 additions and 40 deletions

View File

@ -36,6 +36,17 @@ public static class HttpRequestExtensions
request.Path.ToUriComponent());
}
public static string GetCurrentUrlWithQueryString(this HttpRequest request)
{
return string.Concat(
request.Scheme,
"://",
request.Host.ToUriComponent(),
request.PathBase.ToUriComponent(),
request.Path.ToUriComponent(),
request.QueryString.ToUriComponent());
}
public static string GetCurrentPath(this HttpRequest request)
{
return string.Concat(

View File

@ -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; }
}

View File

@ -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;

View File

@ -14,6 +14,7 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.NTag424;
using BTCPayServer.Payments;
@ -2039,6 +2040,7 @@ namespace BTCPayServer.Tests
public async Task CanUsePullPaymentsViaUI()
{
using var s = CreateSeleniumTester();
s.Server.DeleteStore = false;
s.Server.ActivateLightning(LightningConnectionType.LndREST);
await s.StartAsync();
await s.Server.EnsureChannelsSetup();
@ -2161,7 +2163,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);
@ -2351,6 +2352,22 @@ namespace BTCPayServer.Tests
// Simulate a boltcard
{
// LNURL Withdraw support check with BTC denomination
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
s.Driver.FindElement(By.Id("NewPullPayment")).Click();
s.Driver.FindElement(By.Id("Name")).SendKeys("TopUpTest");
s.Driver.SetCheckbox(By.Id("AutoApproveClaims"), true);
s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys("100000");
s.Driver.FindElement(By.Id("Currency")).Clear();
s.Driver.FindElement(By.Id("Currency")).SendKeys("SATS" + Keys.Enter);
s.FindAlertMessage();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
s.Driver.FindElement(By.CssSelector("#lnurlwithdraw-button")).Click();
s.Driver.WaitForElement(By.Id("qr-code-data-input"));
lnurl = new Uri(LNURL.LNURL.Parse(s.Driver.FindElement(By.Id("qr-code-data-input")).GetAttribute("value"), out _).ToString().Replace("https", "http"));
var db = s.Server.PayTester.GetService<ApplicationDbContextFactory>();
var ppid = lnurl.AbsoluteUri.Split("/").Last();
var issuerKey = new IssuerKey(SettingsRepositoryExtensions.FixedKey());
@ -2365,6 +2382,25 @@ 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 pr = BOLT11PaymentRequest.Parse(callback.Pr, Network.RegTest);
Assert.Equal(LightMoney.Satoshis(100), pr.MinimumAmount);
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)}"));

View File

@ -22,6 +22,18 @@
<EmbeddedResource Remove="wwwroot\vendor\jquery-nice-select\**" />
<None Remove="Build\**" />
<None Remove="wwwroot\vendor\jquery-nice-select\**" />
<Content Update="Plugins\BoltcardBalance\Views\ScanCard.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Plugins\BoltcardFactory\Views\UpdateBoltcardFactory.cshtml">
<Pack>false</Pack>
</Content>
<Content Update="Plugins\BoltcardFactory\Views\ViewBoltcardFactory.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Plugins\BoltcardTopUp\Views\ScanCard.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\UIStorePullPayments\NewPullPayment.cshtml">
<Pack>false</Pack>
</Content>
@ -46,7 +58,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.22" />
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.23" />
<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

@ -24,9 +24,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
@ -157,20 +155,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var ppId = await _pullPaymentService.CreatePullPayment(new CreatePullPayment()
{
StartsAt = request.StartsAt,
ExpiresAt = request.ExpiresAt,
Period = request.Period,
BOLT11Expiration = request.BOLT11Expiration,
Name = request.Name,
Description = request.Description,
Amount = request.Amount,
Currency = request.Currency,
StoreId = storeId,
PaymentMethodIds = paymentMethods,
AutoApproveClaims = request.AutoApproveClaims
});
var ppId = await _pullPaymentService.CreatePullPayment(storeId, request);
var pp = await _pullPaymentService.GetPullPayment(ppId, false);
return this.Ok(CreatePullPaymentData(pp));
}

View File

@ -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,64 @@ 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 lnUrlMetadata = new Dictionary<string, string>();
lnUrlMetadata.Add("text/plain", "Boltcard Top-Up");
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
};
payRequest.Metadata = Newtonsoft.Json.JsonConvert.SerializeObject(lnUrlMetadata.Select(kv => new[] { kv.Key, kv.Value }));
if (amount is null)
return Ok(payRequest);
var cryptoCode = "BTC";
var currency = "BTC";
var invoiceAmount = LightMoney.FromUnit(amount.Value, LightMoneyUnit.MilliSatoshi).ToUnit(LightMoneyUnit.BTC);
if (pp.GetBlob().Currency == "SATS")
{
currency = "SATS";
invoiceAmount = LightMoney.FromUnit(amount.Value, LightMoneyUnit.MilliSatoshi).ToUnit(LightMoneyUnit.Satoshi);
}
LNURLController.ControllerContext.HttpContext = HttpContext;
var result = await LNURLController.GetLNURLRequest(
cryptoCode,
store,
store.GetStoreBlob(),
new CreateInvoiceRequest()
{
Currency = currency,
Amount = invoiceAmount
},
payRequest,
lnUrlMetadata,
[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 +136,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)!;
}
}

View File

@ -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,

View File

@ -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)
@ -108,6 +110,23 @@ namespace BTCPayServer.HostedServices
}
}
public Task<string> CreatePullPayment(string storeId, CreatePullPaymentRequest request)
{
return CreatePullPayment(new CreatePullPayment()
{
StartsAt = request.StartsAt,
ExpiresAt = request.ExpiresAt,
Period = request.Period,
BOLT11Expiration = request.BOLT11Expiration,
Name = request.Name,
Description = request.Description,
Amount = request.Amount,
Currency = request.Currency,
StoreId = storeId,
PaymentMethodIds = request.PaymentMethods.Select(p => PaymentMethodId.Parse(p)).ToArray(),
AutoApproveClaims = request.AutoApproveClaims
});
}
public async Task<string> CreatePullPayment(CreatePullPayment create)
{
ArgumentNullException.ThrowIfNull(create);
@ -263,7 +282,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,
@ -273,6 +292,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; }
@ -323,10 +344,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 || evt.EventCode == InvoiceEventCode.MarkedCompleted)
{
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)
@ -339,6 +370,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);
@ -373,10 +408,47 @@ 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 paidAmount = topUp.InvoiceEntity.PaidAmount.Net;
if (paidAmount == 0.0m)
// If marked as complete, the paid amount is not set.
// HOWEVER THIS FIX ONLY WORK IF THE INVOICE IS IN BTC REMOVE THIS HACK AFTER CONFERENCE
paidAmount = topUp.InvoiceEntity.Price;
var cryptoAmount = Math.Round(paidAmount / rate, 11);
var payoutBlob = new PayoutBlob()
{
CryptoAmount = -cryptoAmount,
Amount = -paidAmount,
Destination = topUp.InvoiceEntity.Id,
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);
}
@ -826,6 +898,10 @@ namespace BTCPayServer.HostedServices
return time;
}
public static string GetInternalTag(string id)
{
return $"PULLPAY#{id}";
}
class InternalPayoutPaidRequest
{
@ -880,25 +956,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)

View File

@ -0,0 +1,35 @@
#nullable enable
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Plugins.BoltcardFactory.Controllers;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Plugins.BoltcardBalance
{
public class BoltcardBalancePlugin : BaseBTCPayServerPlugin
{
public const string ViewsDirectory = "/Plugins/BoltcardBalance/Views";
public const string AppType = "BoltcardBalance";
public override string Identifier => "BTCPayServer.Plugins.BoltcardBalance";
public override string Name => "BoltcardBalance";
public override string Description => "Add ability to check the history and balance of a Boltcard";
public override void Execute(IServiceCollection services)
{
services.AddSingleton<IUIExtension>(new UIExtension($"{ViewsDirectory}/NavExtension.cshtml", "header-nav"));
base.Execute(services);
}
}
}

View File

@ -0,0 +1,143 @@
using System;
using System.Linq;
using System.Reflection.Metadata;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Models;
using BTCPayServer.NTag424;
using BTCPayServer.Plugins.BoltcardBalance.ViewModels;
using BTCPayServer.Plugins.BoltcardFactory;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NBitcoin.DataEncoders;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.BoltcardBalance.Controllers
{
[AutoValidateAntiforgeryToken]
public class UIBoltcardBalanceController : Controller
{
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly SettingsRepository _settingsRepository;
private readonly BTCPayServerEnvironment _env;
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
public UIBoltcardBalanceController(
ApplicationDbContextFactory dbContextFactory,
SettingsRepository settingsRepository,
BTCPayServerEnvironment env,
BTCPayNetworkJsonSerializerSettings serializerSettings)
{
_dbContextFactory = dbContextFactory;
_settingsRepository = settingsRepository;
_env = env;
_serializerSettings = serializerSettings;
}
[HttpGet("boltcards/balance")]
public async Task<IActionResult> ScanCard([FromQuery] string p = null, [FromQuery] string c = null, [FromQuery]string view = null)
{
if (p is null || c is null)
{
return View($"{BoltcardBalancePlugin.ViewsDirectory}/ScanCard.cshtml");
}
//return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", new BalanceViewModel()
//{
// AmountDue = 10000m,
// Currency = "SATS",
// Transactions = [new() { Date = DateTimeOffset.UtcNow, Balance = -3.0m }, new() { Date = DateTimeOffset.UtcNow, Balance = -5.0m }],
// ViewMode = Mode.Reset
//});
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
var boltData = issuerKey.TryDecrypt(p);
if (boltData?.Uid is null)
return NotFound();
var id = issuerKey.GetId(boltData.Uid);
var registration = await _dbContextFactory.GetBoltcardRegistration(issuerKey, boltData, true);
if (registration is null)
return NotFound();
return await GetBalanceView(registration, p, issuerKey, view);
}
[NonAction]
public async Task<IActionResult> GetBalanceView(BoltcardDataExtensions.BoltcardRegistration registration, string p, IssuerKey issuerKey, string view = null)
{
var ppId = registration.PullPaymentId;
var boltCardKeys = issuerKey.CreatePullPaymentCardKey(registration.UId, registration.Version, ppId).DeriveBoltcardKeys(issuerKey);
await using var ctx = _dbContextFactory.CreateContext();
var pp = await ctx.PullPayments.FindAsync(ppId);
if (pp is null)
return NotFound();
var blob = pp.GetBlob();
var payouts = (await ctx.Payouts.GetPayoutInPeriod(pp)
.OrderByDescending(o => o.Date)
.ToListAsync())
.Select(o => new
{
Entity = o,
Blob = o.GetBlob(_serializerSettings)
});
var totalPaid = payouts.Where(p => p.Entity.State != PayoutState.Cancelled).Select(p => p.Blob.Amount).Sum();
var bech32LNUrl = new Uri(Url.Action(nameof(UIBoltcardController.GetPayRequest), "UIBoltcard", new { p }, Request.Scheme), UriKind.Absolute);
bech32LNUrl = LNURL.LNURL.EncodeUri(bech32LNUrl, "payRequest", true);
var vm = new BalanceViewModel()
{
Currency = blob.Currency,
AmountDue = blob.Limit - totalPaid,
LNUrlBech32 = bech32LNUrl.AbsoluteUri,
LNUrlPay = Url.Action(nameof(UIBoltcardController.GetPayRequest), "UIBoltcard", new { p }, "lnurlp"),
BoltcardKeysResetLink = $"boltcard://reset?url={GetBoltcardDeeplinkUrl(pp.Id, OnExistingBehavior.KeepVersion)}",
WipeData = JObject.FromObject(new
{
version = 1,
action = "wipe",
k0 = Encoders.Hex.EncodeData(boltCardKeys.AppMasterKey.ToBytes()).ToUpperInvariant(),
k1 = Encoders.Hex.EncodeData(boltCardKeys.EncryptionKey.ToBytes()).ToUpperInvariant(),
k2 = Encoders.Hex.EncodeData(boltCardKeys.AuthenticationKey.ToBytes()).ToUpperInvariant(),
k3 = Encoders.Hex.EncodeData(boltCardKeys.K3.ToBytes()).ToUpperInvariant(),
k4 = Encoders.Hex.EncodeData(boltCardKeys.K4.ToBytes()).ToUpperInvariant(),
}).ToString(Newtonsoft.Json.Formatting.None),
PullPaymentLink = Url.Action(nameof(UIPullPaymentController.ViewPullPayment), "UIPullPayment", new { pullPaymentId = pp.Id }, Request.Scheme, Request.Host.ToString())
};
foreach (var payout in payouts)
{
vm.Transactions.Add(new BalanceViewModel.Transaction()
{
Date = payout.Entity.Date,
Balance = -payout.Blob.Amount,
Status = payout.Entity.State
});
}
vm.Transactions.Add(new BalanceViewModel.Transaction()
{
Date = pp.StartDate,
Balance = blob.Limit,
Status = PayoutState.Completed
});
vm.ViewMode = view?.Equals("Reset", StringComparison.OrdinalIgnoreCase) is true ? Mode.Reset : Mode.TopUp;
return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", vm);
}
private string GetBoltcardDeeplinkUrl(string ppId, OnExistingBehavior onExisting)
{
var registerUrl = Url.Action(nameof(GreenfieldPullPaymentController.RegisterBoltcard), "GreenfieldPullPayment",
new
{
pullPaymentId = ppId,
onExisting = onExisting.ToString()
}, Request.Scheme, Request.Host.ToString());
registerUrl = Uri.EscapeDataString(registerUrl);
return registerUrl;
}
}
}

View File

@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Plugins.BoltcardBalance.ViewModels
{
public enum Mode
{
TopUp,
Reset
}
public class BalanceViewModel
{
public class Transaction
{
public DateTimeOffset Date { get; set; }
public bool Positive => Balance >= 0;
public decimal Balance { get; set; }
public PayoutState Status { get; internal set; }
}
public string Currency { get; set; }
public decimal AmountDue { get; set; }
public List<Transaction> Transactions { get; set; } = new List<Transaction>();
public string LNUrlBech32 { get; set; }
public string BoltcardKeysResetLink { get; set; }
public string PullPaymentLink { get; set; }
public string LNUrlPay { get; set; }
public string WipeData{ get; set; }
public Mode ViewMode { get; set; }
}
}

View File

@ -0,0 +1,111 @@
@using BTCPayServer.Plugins.BoltcardBalance.ViewModels
@using BTCPayServer.Services
@inject DisplayFormatter DisplayFormatter
@model BalanceViewModel
@{
Layout = null;
}
<div class="col col-12 col-lg-12 mb-4">
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded">
<nav id="wizard-navbar">
@if (this.ViewData["NoCancelWizard"] is not true)
{
@if (Model.ViewMode == Mode.TopUp)
{
<button type="button" class="btn btn-secondary only-for-js mt-4" id="lnurlwithdraw-button">
<span class="fa fa-qrcode fa-2x" title="Deposit"></span>
</button>
}
<a href="#" id="CancelWizard" class="cancel mt-4">
<vc:icon symbol="close" />
</a>
}
</nav>
<div class="d-flex justify-content-center">
<div class="d-flex flex-column justify-content-center align-items-center">
<dl class="mb-0 mt-md-4">
<div class="d-flex d-print-inline-block flex-column mb-4">
<dt class="h4 fw-semibold text-nowrap text-primary text-print-default order-2 order-sm-1 mb-1">@DisplayFormatter.Currency(Model.AmountDue, Model.Currency)</dt>
</div>
</dl>
@if (Model.ViewMode == Mode.TopUp)
{
<div class="lnurl-pay boltcard-details d-none">
<vc:qr-code data="@Model.LNUrlBech32" />
</div>
}
</div>
</div>
@if (Model.ViewMode == Mode.Reset)
{
@if (Model.AmountDue > 0)
{
<div class="d-flex justify-content-center">
<a class="btn btn-outline-primary" href="@Model.PullPaymentLink">Sweep remaining balance</a>
</div>
}
<div class="d-flex justify-content-center nfc-supported mt-2">
<div class="boltcard-reset boltcard-details text-center">
<div class="d-flex justify-content-center">
<div class="input-group">
<a class="btn btn-outline-danger form-control" href="@Model.BoltcardKeysResetLink">
<div style="margin-top:3px">Reset Boltcard</div>
</a>
<button type="button" class="btn btn-outline-danger input-group-btn" id="show-wipe-qr">
<span class="fa fa-qrcode fa-2x" title="Show wipe QR"></span>
</button>
</div>
</div>
<div id="wipe-qr" class="d-none mt-2 cursor-pointer" data-clipboard-target="#qr-wipe-code-data-input">
<div class="d-flex">
<vc:qr-code data="@Model.WipeData" />
</div>
<div class="d-flex">
<div class="input-group input-group-sm mt-3">
<input type="text" class="form-control" readonly value="@Model.WipeData" id="qr-wipe-code-data-input">
<button type="button" class="btn btn-outline-secondary px-3" data-clipboard-target="#qr-wipe-code-data-input">
<vc:icon symbol="copy" />
</button>
</div>
</div>
</div>
<p class="text-secondary mt-2">Requires installing the <a href="https://play.google.com/store/apps/details?id=com.lightningnfcapp&hl=en&gl=US">Bolt Card Creator app</a></p>
</div>
</div>
}
</div>
</div>
@if (Model.Transactions.Any())
{
<div class="col col-12 col-lg-12 mb-4">
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th class="date-col">Date</th>
<th class="amount-col">Amount</th>
</tr>
</thead>
<tbody>
@foreach (var tx in Model.Transactions)
{
<tr>
<td class="date-col">@tx.Date.ToBrowserDate(ViewsRazor.DateDisplayFormat.Relative)</td>
<td class="amount-col">
<span data-sensitive class="text-@(tx.Positive ? "success" : "danger")">@DisplayFormatter.Currency(tx.Balance, Model.Currency, DisplayFormatter.CurrencyFormat.Code)</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}

View File

@ -0,0 +1,18 @@
@using BTCPayServer.Client
@using BTCPayServer.Plugins.BoltcardFactory
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Views.Apps
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.TagHelpers
<li class="nav-item">
<a asp-area="" asp-controller="UIBoltcardBalance" asp-action="ScanCard" class="nav-link">
<vc:icon symbol="pay-button" />
<span>Boltcard Balance</span>
</a>
<a asp-area="" asp-controller="UIBoltcardBalance" asp-action="ScanCard" asp-route-view="Reset" class="nav-link">
<vc:icon symbol="pay-button" />
<span>Boltcard reset</span>
</a>
</li>

View File

@ -0,0 +1,252 @@
@{
ViewData["Title"] = "Boltcard Balances";
ViewData["ShowFooter"] = false;
Layout = "/Views/Shared/_LayoutWizard.cshtml";
}
@section PageHeadContent
{
<style>
.amount-col {
text-align: right;
white-space: nowrap;
}
</style>
}
<header class="text-center">
<h1>Consult balance</h1>
<p class="lead text-secondary mt-3" id="explanation">Scan your card for consulting the balance</p>
</header>
<div id="body" class="my-4">
<div id="error" class="d-flex align-items-center justify-content-center d-none">
<p class="text-danger"></p>
</div>
<div id="actions" class="d-flex align-items-center justify-content-center d-none">
<div class="d-flex gap-3 mt-3 mt-sm-0">
<a id="start-scan-btn" class="btn btn-primary" href="#">Ask permission...</a>
</div>
</div>
<div id="qr" class="d-flex flex-column align-items-center justify-content-center d-none">
<div class="d-inline-flex flex-column" style="width:256px">
<div class="qr-container mb-2">
<vc:qr-code data="@Context.Request.GetCurrentUrlWithQueryString()" />
</div>
</div>
<p class="text-secondary">NFC not supported in this device</p>
<p class="text-secondary">Please use Chrome on Android, or a lightning wallet that supports Bolt cards, or extract the NFC value from the card (using an NFC reader app) and input below</p>
<div class="input-group">
<input type="text" class="form-control" id="nfc-manual-value" placeholder="lnurlw:..." />
<button class="btn btn-secondary" id="nfc-manual-submit">Extract</button>
</div>
</div>
<div id="scanning-btn" class="d-flex align-items-center justify-content-center d-none">
<div class="d-flex gap-3 mt-3 mt-sm-0">
<a id="scanning-btn-link" class="action-button" style="font-size: 50px;" ></a>
</div>
</div>
<div id="balance" class="row">
<div id="balance-table"></div>
</div>
</div>
<script>
(function () {
var permissionGranted = false;
var ndef = null;
var abortController = null;
function handleError(e){
if (e) {
document.querySelector("#error p").innerHTML = e.message;
document.getElementById("error").classList.remove("d-none");
}
else
{
document.getElementById("error").classList.add("d-none");
}
}
function toggleDetailsWhenPressed(buttonId, className)
{
var button = document.getElementById(buttonId);
if (button) {
var el = document.getElementsByClassName("boltcard-details");
button.addEventListener("click", function () {
for (var i = 0; i < el.length; i++) {
if (el[i].classList.contains(className)) {
if (el[i].classList.contains("d-none"))
el[i].classList.remove("d-none");
else
el[i].classList.add("d-none");
}
else {
el[i].classList.add("d-none");
}
}
});
}
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function showBalance(lnurlw) {
try {
const initState = (!('NDEFReader' in window)) ? "NFCNotSupported" : "WaitingForCard";
setState("Submitting");
var uiDelay = delay(1000);
var url = window.location.href.replace("#", "");
url = url.split("?")[0] + "?" + lnurlw.split("?")[1];
// url = "https://testnet.demo.btcpayserver.org/boltcards/balance?p=...&c=..."
var params = new URLSearchParams(window.location.search);
if (params.toString()) {
url += "&" + params;
}
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = async function () {
if (this.readyState == 4 && this.status == 200 && this.responseText) {
document.getElementById("balance-table").innerHTML = this.responseText;
document.getElementById("CancelWizard").addEventListener("click", function (e) {
e.preventDefault();
setState(initState);
document.getElementById("balance-table").innerHTML = "";
});
toggleDetailsWhenPressed('lnurlwithdraw-button', 'lnurl-pay');
toggleDetailsWhenPressed('reset-button', 'boltcard-reset');
await uiDelay;
setState("ShowBalance");
}
else if(this.readyState == 4 && this.status == 404) {
setState(initState);
handleError(new Error("Initialized by a different provider"));
}
else {
setState(initState);
}
};
xhttp.open('GET', url, true);
xhttp.send(new FormData());
}catch (e) {
handleError(e);
}
}
async function startScan() {
try {
if (!('NDEFReader' in window)) {
return;
}
ndef = new NDEFReader();
abortController = new AbortController();
abortController.signal.onabort = () => setState("WaitingForCard");
await ndef.scan({ signal: abortController.signal })
setState("WaitingForCard");
ndef.onreading = async ({ message }) => {
handleError(null);
const record = message.records[0];
if (message.records.length === 0)
{
setState("WaitingForCard");
handleError(new Error("Card is blank"));
return;
}
const textDecoder = new TextDecoder('utf-8');
const decoded = textDecoder.decode(record.data);
await showBalance(decoded);
};
}
catch (e) {
handleError(e);
}
}
function setState(state)
{
document.querySelector("#error p").innerHTML = "";
document.getElementById("error").classList.add("d-none");
document.getElementById("actions").classList.add("d-none");
document.getElementById("qr").classList.add("d-none");
document.getElementById("scanning-btn").classList.add("d-none");
document.getElementById("balance").classList.add("d-none");
if (state === "NFCNotSupported")
{
document.getElementById("qr").classList.remove("d-none");
document.querySelectorAll(".nfc-supported").forEach(el => {
el.classList.add("d-none");
});
}
else if (state === "WaitingForPermission")
{
document.getElementById("actions").classList.remove("d-none");
}
else if (state === "WaitingForCard")
{
document.getElementById("scanning-btn").classList.remove("d-none");
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-wifi\"></i>";
}
else if (state == "Submitting")
{
document.getElementById("scanning-btn").classList.remove("d-none");
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-spinner\"></i>"
}
else if (state == "ShowBalance") {
document.getElementById("scanning-btn").classList.remove("d-none");
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-wifi\"></i>";
document.getElementById("balance").classList.remove("d-none");
}
}
document.addEventListener("DOMContentLoaded", async () => {
try {
var nfcSupported = 'NDEFReader' in window;
if (!nfcSupported) {
setState("NFCNotSupported");
}
else {
setState("WaitingForPermission");
var granted = (await navigator.permissions.query({ name: 'nfc' })).state === 'granted';
if (granted)
{
setState("WaitingForCard");
startScan();
}
}
delegate('click', "#start-scan-btn", startScan);
delegate('click', "#nfc-manual-submit", async function () {
var value = document.getElementById("nfc-manual-value").value;
if (value) {
await showBalance(value);
document.querySelector(".boltcard-reset").classList.add("d-none");
}
});
delegate('click', "#show-wipe-qr", ()=>{
const el = document.getElementById("wipe-qr");
if (el.classList.contains("d-none")){
el.classList.remove("d-none");
} else {
el.classList.add("d-none")
}
});
delegate('click', "#wipe-qr", ()=>{
});
}
catch (e) {
handleError(e);
}
// showBalance("lnurl://ewfw?p=test&c=test");
});
})();
</script>

View File

@ -0,0 +1,74 @@
#nullable enable
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Plugins.BoltcardFactory.Controllers;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Plugins.BoltcardFactory
{
public class BoltcardFactoryPlugin : BaseBTCPayServerPlugin
{
public const string ViewsDirectory = "/Plugins/BoltcardFactory/Views";
public const string AppType = "BoltcardFactory";
public override string Identifier => "BTCPayServer.Plugins.BoltcardFactory";
public override string Name => "BoltcardFactory";
public override string Description => "Allow the creation of a consequential number of Boltcards in an efficient way";
internal class BoltcardFactoryAppType : AppBaseType
{
private readonly LinkGenerator _linkGenerator;
private readonly IOptions<BTCPayServerOptions> _btcPayServerOptions;
public BoltcardFactoryAppType(
LinkGenerator linkGenerator,
IOptions<BTCPayServerOptions> btcPayServerOptions)
{
Type = AppType;
Description = "Boltcard Factories";
_linkGenerator = linkGenerator;
_btcPayServerOptions = btcPayServerOptions;
}
public override Task<string> ConfigureLink(AppData app)
{
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UIBoltcardFactoryController.UpdateBoltcardFactory),
"UIBoltcardFactory", new { appId = app.Id }, _btcPayServerOptions.Value.RootPath)!);
}
public override Task<object?> GetInfo(AppData appData)
{
return Task.FromResult<object?>(null);
}
public override Task SetDefaultSettings(AppData appData, string defaultCurrency)
{
appData.SetSettings(new CreatePullPaymentRequest());
return Task.CompletedTask;
}
public override Task<string> ViewLink(AppData app)
{
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UIBoltcardFactoryController.ViewBoltcardFactory),
"UIBoltcardFactory", new { appId = app.Id }, _btcPayServerOptions.Value.RootPath)!);
}
}
public override void Execute(IServiceCollection services)
{
services.AddSingleton<IUIExtension>(new UIExtension($"{ViewsDirectory}/NavExtension.cshtml", "header-nav"));
services.AddSingleton<AppBaseType, BoltcardFactoryAppType>();
base.Execute(services);
}
}
}

View File

@ -0,0 +1,150 @@
#nullable enable
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.NTag424;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders;
namespace BTCPayServer.Plugins.BoltcardFactory.Controllers
{
[ApiController]
[Route("apps")]
public class APIBoltcardFactoryController : ControllerBase
{
private readonly ILogger<APIBoltcardFactoryController> _logger;
private readonly AppService _appService;
private readonly SettingsRepository _settingsRepository;
private readonly BTCPayServerEnvironment _env;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly PullPaymentHostedService _ppService;
public APIBoltcardFactoryController(
ILogger<APIBoltcardFactoryController> logger,
AppService appService,
SettingsRepository settingsRepository,
BTCPayServerEnvironment env,
ApplicationDbContextFactory dbContextFactory,
PullPaymentHostedService ppService)
{
_logger = logger;
_appService = appService;
_settingsRepository = settingsRepository;
_env = env;
_dbContextFactory = dbContextFactory;
_ppService = ppService;
}
[HttpPost("{appId}/boltcards")]
[AllowAnonymous]
public async Task<IActionResult> RegisterBoltcard(string appId, RegisterBoltcardRequest? request, string? onExisting = null)
{
var app = await _appService.GetApp(appId, BoltcardFactoryPlugin.AppType);
if (app is null)
return NotFound();
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
// LNURLW is used by deeplinks
if (request?.LNURLW is not null)
{
if (request.UID is not null)
{
_logger.LogInformation("You should pass either LNURLW or UID but not both");
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)
{
_logger.LogInformation("The LNURLW should contains a 'p=' parameter");
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW should contains a 'p=' parameter");
return this.CreateValidationError(ModelState);
}
if (issuerKey.TryDecrypt(p) is not BoltcardPICCData picc)
{
_logger.LogInformation("The LNURLW 'p=' parameter cannot be decrypted");
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)
{
_logger.LogInformation("The UID is required and should be 7 bytes");
ModelState.AddModelError(nameof(request.UID), "The UID is required and should be 7 bytes");
return this.CreateValidationError(ModelState);
}
// 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
};
int version;
string ppId;
var registration = await _dbContextFactory.GetBoltcardRegistration(issuerKey, request.UID);
if (request.OnExisting == OnExistingBehavior.UpdateVersion)
{
var req = app.GetSettings<CreatePullPaymentRequest>();
ppId = await _ppService.CreatePullPayment(app.StoreDataId, req);
version = await _dbContextFactory.LinkBoltcardToPullPayment(ppId, issuerKey, request.UID, request.OnExisting);
}
// If it's a reset, do not create a new pull payment
else
{
if (registration?.PullPaymentId is null)
{
_logger.LogInformation("This card isn't registered");
ModelState.AddModelError(nameof(request.UID), "This card isn't registered");
return this.CreateValidationError(ModelState);
}
ppId = registration.PullPaymentId;
version = registration.Version;
}
var keys = issuerKey.CreatePullPaymentCardKey(request.UID, version, ppId).DeriveBoltcardKeys(issuerKey);
var boltcardUrl = Url.Action(nameof(UIBoltcardController.GetWithdrawRequest), "UIBoltcard");
boltcardUrl = Request.GetAbsoluteUri(boltcardUrl);
boltcardUrl = Regex.Replace(boltcardUrl, "^https?://", "lnurlw://");
var resp = 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(),
};
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;
}
}
}

View File

@ -0,0 +1,216 @@
#nullable enable
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Plugins.PointOfSale;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Rendering;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Rates;
using System.Text.RegularExpressions;
using System;
using BTCPayServer.Services.Stores;
using BTCPayServer.Payments;
using Microsoft.AspNetCore.Identity;
using BTCPayServer.Client.Models;
using Org.BouncyCastle.Ocsp;
using BTCPayServer.NTag424;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using BTCPayServer.Services;
using BTCPayServer.HostedServices;
using System.Threading;
using BTCPayServer.Plugins.BoltcardFactory.ViewModels;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Models;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Plugins.BoltcardFactory.Controllers
{
[AutoValidateAntiforgeryToken]
[Route("apps")]
public class UIBoltcardFactoryController : Controller
{
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly CurrencyNameTable _currencies;
private readonly AppService _appService;
private readonly StoreRepository _storeRepository;
private readonly CurrencyNameTable _currencyNameTable;
private readonly IAuthorizationService _authorizationService;
public UIBoltcardFactoryController(
IEnumerable<IPayoutHandler> payoutHandlers,
CurrencyNameTable currencies,
AppService appService,
StoreRepository storeRepository,
CurrencyNameTable currencyNameTable,
IAuthorizationService authorizationService)
{
_payoutHandlers = payoutHandlers;
_currencies = currencies;
_appService = appService;
_storeRepository = storeRepository;
_currencyNameTable = currencyNameTable;
_authorizationService = authorizationService;
}
public Data.StoreData CurrentStore => HttpContext.GetStoreData();
private AppData GetCurrentApp() => HttpContext.GetAppData();
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpGet("{appId}/settings/boltcardfactory")]
public async Task<IActionResult> UpdateBoltcardFactory(string appId)
{
if (CurrentStore is null || GetCurrentApp() is null)
return NotFound();
var paymentMethods = await _payoutHandlers.GetSupportedPaymentMethods(CurrentStore);
if (!paymentMethods.Any())
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Message = "You must enable at least one payment method before creating a pull payment.",
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(UIStoresController.Dashboard), "UIStores", new { storeId = CurrentStore.Id });
}
var req = GetCurrentApp().GetSettings<CreatePullPaymentRequest>();
return base.View($"{BoltcardFactoryPlugin.ViewsDirectory}/UpdateBoltcardFactory.cshtml", CreateViewModel(paymentMethods, req));
}
private static NewPullPaymentModel CreateViewModel(List<PaymentMethodId> paymentMethods, CreatePullPaymentRequest req)
{
return new NewPullPaymentModel
{
Name = req.Name,
Currency = req.Currency,
Amount = req.Amount,
AutoApproveClaims = req.AutoApproveClaims,
Description = req.Description,
PaymentMethods = req.PaymentMethods,
BOLT11Expiration = req.BOLT11Expiration?.TotalDays is double v ? (long)v : 30,
PaymentMethodItems =
paymentMethods.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true))
};
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpPost("{appId}/settings/boltcardfactory")]
public async Task<IActionResult> UpdateBoltcardFactory(string appId, NewPullPaymentModel model)
{
if (CurrentStore is null)
return NotFound();
var storeId = CurrentStore.Id;
var paymentMethodOptions = await _payoutHandlers.GetSupportedPaymentMethods(CurrentStore);
model.PaymentMethodItems =
paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true));
model.Name ??= string.Empty;
model.Currency = model.Currency?.ToUpperInvariant()?.Trim() ?? String.Empty;
model.PaymentMethods ??= new List<string>();
if (!model.PaymentMethods.Any())
{
// Since we assign all payment methods to be selected by default above we need to update
// them here to reflect user's selection so that they can correct their mistake
model.PaymentMethodItems =
paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), false));
ModelState.AddModelError(nameof(model.PaymentMethods), "You need at least one payment method");
}
if (_currencyNameTable.GetCurrencyData(model.Currency, false) is null)
{
ModelState.AddModelError(nameof(model.Currency), "Invalid currency");
}
if (model.Amount <= 0.0m)
{
ModelState.AddModelError(nameof(model.Amount), "The amount should be more than zero");
}
if (model.Name.Length > 50)
{
ModelState.AddModelError(nameof(model.Name), "The name should be maximum 50 characters.");
}
var selectedPaymentMethodIds = model.PaymentMethods.Select(PaymentMethodId.Parse).ToArray();
if (!selectedPaymentMethodIds.All(id => selectedPaymentMethodIds.Contains(id)))
{
ModelState.AddModelError(nameof(model.Name), "Not all payment methods are supported");
}
if (!ModelState.IsValid)
return View(model);
model.AutoApproveClaims = model.AutoApproveClaims && (await
_authorizationService.AuthorizeAsync(User, CurrentStore.Id, Policies.CanCreatePullPayments)).Succeeded;
var req = new CreatePullPaymentRequest()
{
Name = model.Name,
Description = model.Description,
Currency = model.Currency,
Amount = model.Amount,
AutoApproveClaims = model.AutoApproveClaims,
BOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration),
PaymentMethods = model.PaymentMethods.ToArray()
};
var app = GetCurrentApp();
app.SetSettings(req);
await _appService.UpdateOrCreateApp(app);
var paymentMethods = await _payoutHandlers.GetSupportedPaymentMethods(CurrentStore);
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Pull payment request created",
Severity = StatusMessageModel.StatusSeverity.Success
});
return View($"{BoltcardFactoryPlugin.ViewsDirectory}/UpdateBoltcardFactory.cshtml", CreateViewModel(paymentMethods, req));
}
private async Task<string?> GetStoreDefaultCurrentIfEmpty(string storeId, string? currency)
{
if (string.IsNullOrWhiteSpace(currency))
{
currency = (await _storeRepository.FindStore(storeId))?.GetStoreBlob()?.DefaultCurrency;
}
return currency?.Trim().ToUpperInvariant();
}
private int[] ListSplit(string list, string separator = ",")
{
if (string.IsNullOrEmpty(list))
{
return Array.Empty<int>();
}
// Remove all characters except numeric and comma
Regex charsToDestroy = new Regex(@"[^\d|\" + separator + "]");
list = charsToDestroy.Replace(list, "");
return list.Split(separator, StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray();
}
[HttpGet("/apps/{appId}/boltcardfactory")]
[DomainMappingConstraint(BoltcardFactoryPlugin.AppType)]
[AllowAnonymous]
public IActionResult ViewBoltcardFactory(string appId)
{
var vm = new ViewBoltcardFactoryViewModel();
vm.SetupDeepLink = $"boltcard://program?url={GetBoltcardDeeplinkUrl(appId, OnExistingBehavior.UpdateVersion)}";
vm.ResetDeepLink = $"boltcard://reset?url={GetBoltcardDeeplinkUrl(appId, OnExistingBehavior.KeepVersion)}";
return View($"{BoltcardFactoryPlugin.ViewsDirectory}/ViewBoltcardFactory.cshtml", vm);
}
private string GetBoltcardDeeplinkUrl(string appId, OnExistingBehavior onExisting)
{
var registerUrl = Url.Action(nameof(APIBoltcardFactoryController.RegisterBoltcard), "APIBoltcardFactory",
new
{
appId = appId,
onExisting = onExisting.ToString()
}, Request.Scheme, Request.Host.ToString());
registerUrl = Uri.EscapeDataString(registerUrl!);
return registerUrl;
}
}
}

View File

@ -0,0 +1,7 @@
namespace BTCPayServer.Plugins.BoltcardFactory.ViewModels;
public class ViewBoltcardFactoryViewModel
{
public string SetupDeepLink { get; set; }
public string ResetDeepLink { get; set; }
}

View File

@ -0,0 +1,37 @@
@using BTCPayServer.Client
@using BTCPayServer.Plugins.BoltcardFactory
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Views.Apps
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Plugins.PointOfSale
@using BTCPayServer.Services.Apps
@inject AppService AppService;
@model BTCPayServer.Components.MainNav.MainNavViewModel
@{
var store = Context.GetStoreData();
}
@if (store != null)
{
var appType = AppService.GetAppType(BoltcardFactoryPlugin.AppType)!;
<li class="nav-item" permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIApps" asp-action="CreateApp" asp-route-storeId="@store.Id" asp-route-appType="@appType.Type" class="nav-link @ViewData.IsActivePage(AppsNavPages.Create, appType.Type)" id="@($"StoreNav-Create{appType.Type}")">
<vc:icon symbol="pointofsale" />
<span>@appType.Description</span>
</a>
</li>
@foreach (var app in Model.Apps.Where(app => app.AppType == appType.Type))
{
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIBoltcardFactory" asp-action="UpdateBoltcardFactory" asp-route-appId="@app.Id" class="nav-link @ViewData.IsActivePage(AppsNavPages.Update, app.Id)" id="@($"StoreNav-App-{app.Id}")">
<span>@app.AppName</span>
</a>
</li>
<li class="nav-item nav-item-sub" not-permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIBoltcardFactory" asp-action="ViewBoltcardFactory" asp-route-appId="@app.Id" class="nav-link">
<span>@app.AppName</span>
</a>
</li>
}
}

View File

@ -0,0 +1,130 @@
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Client
@using BTCPayServer.Views.Stores
@model BTCPayServer.Models.WalletViewModels.NewPullPaymentModel
@{
ViewData["Title"] = "Update Boltcard Factory";
Layout = "/Views/Shared/_Layout.cshtml";
}
@section PageHeadContent {
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true"/>
}
@section PageFootContent {
<partial name="_ValidationScriptsPartial"/>
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
}
<form method="post" asp-route-appId="@Context.GetRouteValue("appId")" asp-action="UpdateBoltcardFactory">
<div class="sticky-header d-flex align-items-center justify-content-between">
<h2 class="mb-0">@ViewData["Title"]</h2>
<div class="d-flex gap-3 mt-3 mt-sm-0">
<a class="btn btn-secondary" asp-controller="UIBoltcardFactory" asp-action="ViewBoltcardFactory" asp-route-appId="@Context.GetRouteValue("appId")" id="ViewApp" target="_blank">View</a>
<input type="submit" value="Save" class="btn btn-primary order-sm-1" id="Save" />
</div>
</div>
<partial name="_StatusMessage"/>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label asp-for="Name" class="form-label"></label>
<input asp-for="Name" class="form-control"/>
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="row">
<div class="form-group col-8">
<label asp-for="Amount" class="form-label" data-required></label>
<input asp-for="Amount" class="form-control" inputmode="decimal"/>
<span asp-validation-for="Amount" class="text-danger"></span>
</div>
<div class="form-group col-4">
<label asp-for="Currency" class="form-label"></label>
<input asp-for="Currency" class="form-control w-auto" currency-selection />
<span asp-validation-for="Currency" class="text-danger"></span>
</div>
<div class="form-group col-12" permission="@Policies.CanCreatePullPayments">
<div class="form-check ">
<input asp-for="AutoApproveClaims" type="checkbox" class="form-check-input"/>
<label asp-for="AutoApproveClaims" class="form-check-label"></label>
<span asp-validation-for="AutoApproveClaims" class="text-danger"></span>
</div>
</div>
</div>
<div class="form-group mb-4">
<label asp-for="PaymentMethods" class="form-label"></label>
@foreach (var item in Model.PaymentMethodItems)
{
<div class="form-check mb-2">
<label class="form-label">
<input name="PaymentMethods" class="form-check-input" type="checkbox" value="@item.Value" @(item.Selected ? "checked" : "")>
@item.Text
</label>
</div>
}
<span asp-validation-for="PaymentMethods" class="text-danger mt-0"></span>
</div>
</div>
<div class="col-lg-9">
<div class="form-group mb-4">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control richtext"></textarea>
<span asp-validation-for="Description" class="text-danger"></span>
</div>
<h4 class="mt-5 mb-2">Additional Options</h4>
<div class="form-group">
<div class="accordion" id="additional">
<div class="accordion-item">
<h2 class="accordion-header" id="additional-custom-css-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-custom-css" aria-expanded="false" aria-controls="additional-custom-css">
Custom CSS
<vc:icon symbol="caret-down"/>
</button>
</h2>
<div id="additional-custom-css" class="accordion-collapse collapse" aria-labelledby="additional-custom-css-header">
<div class="accordion-body">
<div class="form-group">
<label asp-for="CustomCSSLink" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#2-bootstrap-themes" target="_blank" rel="noreferrer noopener" title="More information...">
<vc:icon symbol="info" />
</a>
<input asp-for="CustomCSSLink" class="form-control"/>
<span asp-validation-for="CustomCSSLink" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="EmbeddedCSS" class="form-label"></label>
<textarea asp-for="EmbeddedCSS" rows="10" cols="40" class="form-control"></textarea>
<span asp-validation-for="EmbeddedCSS" class="text-danger"></span>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="additional-lightning-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-lightning" aria-expanded="false" aria-controls="additional-lightning">
Lightning network settings
<vc:icon symbol="caret-down"/>
</button>
</h2>
<div id="additional-lightning" class="accordion-collapse collapse" aria-labelledby="additional-lightning-header">
<div class="accordion-body">
<div class="form-group">
<label asp-for="BOLT11Expiration" class="form-label"></label>
<div class="input-group">
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:12ch;"/>
<span class="input-group-text">days</span>
</div>
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,44 @@
@model ViewBoltcardFactoryViewModel
@{
ViewData["Title"] = "Boltcard factory";
Layout = "/Views/Shared/_LayoutWizard.cshtml";
}
<header class="text-center">
<h1>Program Boltcards</h1>
<p class="lead text-secondary mt-3" id="explanation">Using Boltcard NFC Programmer</p>
</header>
<div id="body" class="my-4">
<div id="actions" class="d-flex align-items-center justify-content-center" style="visibility:hidden">
<div class="d-flex gap-3 mt-3 mt-sm-0">
<a class="btn btn-primary" href="@Model.SetupDeepLink" target="_blank">Setup</a>
<a class="btn btn-danger" href="@Model.ResetDeepLink" target="_blank">Reset</a>
</div>
</div>
<div id="qr" class="d-flex align-items-center justify-content-center">
<div class="d-inline-flex flex-column" style="width:256px" style="visibility:hidden">
<div class="qr-container mb-2">
<vc:qr-code data="@Context.Request.GetCurrentUrl()" />
</div>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
var isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
document.getElementById("actions").style.visibility = "visible";
document.getElementById("qr").style.visibility = "hidden";
}
else {
document.getElementById("actions").style.visibility = "hidden";
document.getElementById("qr").style.visibility = "visible";
document.getElementById("explanation").innerText = "Scan the QR code with your mobile device";
}
});
</script>

View File

@ -0,0 +1 @@
@using BTCPayServer.Plugins.BoltcardFactory.ViewModels;

View File

@ -18,8 +18,11 @@
@RenderBody()
</div>
</section>
<partial name="_Footer"/>
<partial name="LayoutFoot" />
@await RenderSectionAsync("PageFootContent", false)
@if (ViewData["ShowFooter"] is not false)
{
<partial name="_Footer"/>
}
<partial name="LayoutFoot" />
@await RenderSectionAsync("PageFootContent", false)
</body>
</html>

View File

@ -1,4 +1,17 @@
@model BTCPayServer.Models.InvoicingModels.InvoiceReceiptViewModel
@functions {
public bool IsManualEntryCart(object data)
{
if (data is Dictionary<string, object>)
{
Dictionary<string, object> cart = (Dictionary<string, object>)data;
return cart.Count == 1 && cart.ContainsKey("Manual entry 1");
}
return false;
}
}
@using BTCPayServer.Client.Models
@using BTCPayServer.Components.QRCode
@using BTCPayServer.Services
@ -107,7 +120,8 @@
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
@if (Model.AdditionalData?.Any() is true &&
(Model.AdditionalData.ContainsKey("Cart") || Model.AdditionalData.ContainsKey("Discount") || Model.AdditionalData.ContainsKey("Tip")))
((Model.AdditionalData.ContainsKey("Cart") && !IsManualEntryCart(Model.AdditionalData["Cart"]))
|| Model.AdditionalData.ContainsKey("Discount") || Model.AdditionalData.ContainsKey("Tip")))
{
@if (Model.AdditionalData.ContainsKey("Cart"))
{
@ -178,9 +192,9 @@
@if (payment.Destination.Length > 69)
{
<span>
<span>@payment.Destination[..30]</span>
<span>@payment.Destination[..19]</span>
<span>...</span>
<span>@payment.Destination.Substring(payment.Destination.Length - 30, 30)</span>
<span>@payment.Destination.Substring(payment.Destination.Length - 20, 20)</span>
</span>
}
else

View File

@ -226,8 +226,8 @@
<script src="~/vendor/ur-registry/urlib.min.js" asp-append-version="true"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const isAndroid = /(android)/i.test(navigator.userAgent);
if (isAndroid) {
var isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
document.getElementById("SetupBoltcard").setAttribute('target', '_blank');
document.getElementById("SetupBoltcard").setAttribute('href', @Safe.Json(@Model.SetupDeepLink));
document.getElementById("ResetBoltcard").setAttribute('target', '_blank');