Compare commits

...

16 Commits

Author SHA1 Message Date
8128b023d5 Adding top margin on close button 2024-02-20 10:37:59 -06:00
b1c171a5d9 Pull Payment LNURLW have a paylink 2024-02-20 18:53:26 +09:00
692a13e0c8 Topup 2024-02-19 15:34:12 +09:00
d9b6e465c0 debug 2024-02-15 18:25:28 +09:00
a4485b5377 Boltcard Balance 2024-02-14 16:45:03 +09:00
4c0c2d2e94 fix 2024-02-09 16:33:57 +09:00
89062fcb10 debug 2024-02-09 12:35:00 +09:00
a2087ce722 fix 2024-02-09 12:28:58 +09:00
312997c063 Boltcard Factory plugin 2024-02-09 12:18:26 +09:00
9380d4ca48 Only show setup/reset when the page is fully loaded 2024-02-09 09:23:55 +09:00
d44ec19663 debug 2024-02-09 09:21:54 +09:00
12c871bfd8 debug 2024-02-09 09:21:54 +09:00
f86f858499 debug 2024-02-09 09:21:54 +09:00
7675dce000 Make test CanConfigureCheckout less flaky 2024-02-09 09:21:54 +09:00
b9ef41b8c3 Allow passing LNURLW to register boltcard 2024-02-09 09:21:53 +09:00
18fe420b74 If pull payment opened in mobile, use deeplink to setup card 2024-02-09 09:21:27 +09:00
39 changed files with 1855 additions and 56 deletions

View File

@ -23,5 +23,7 @@ namespace BTCPayServer.Client.Models
public DateTimeOffset? StartsAt { get; set; }
public string[] PaymentMethods { get; set; }
public bool AutoApproveClaims { get; set; }
public string EmbeddedCSS { get; set; }
public string CustomCSSLink { get; set; }
}
}

View File

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

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

@ -360,10 +360,13 @@ namespace BTCPayServer.Tests
expirySeconds.SendKeys("5");
s.Driver.FindElement(By.Id("Expire")).Click();
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("This invoice will expire in", paymentInfo.Text);
Assert.Contains("00:0", paymentInfo.Text);
Assert.DoesNotContain("Please send", paymentInfo.Text);
TestUtils.Eventually(() =>
{
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("This invoice will expire in", paymentInfo.Text);
Assert.Contains("00:0", paymentInfo.Text);
Assert.DoesNotContain("Please send", paymentInfo.Text);
});
// Configure countdown timer
s.GoToHome();

View File

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

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

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

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>

View File

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

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,50 @@ public class UIBoltcardController : Controller
public ApplicationDbContextFactory ContextFactory { get; }
public BTCPayServerEnvironment Env { get; }
[AllowAnonymous]
[HttpGet("~/boltcard/pay")]
public async Task<IActionResult> GetPayRequest([FromQuery] string? p, [FromQuery] long? amount = null)
{
var issuerKey = await SettingsRepository.GetIssuerKey(Env);
var piccData = issuerKey.TryDecrypt(p);
if (piccData is null)
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Invalid PICCData" });
piccData = new BoltcardPICCData(piccData.Uid, int.MaxValue - 10); // do not check the counter
var registration = await ContextFactory.GetBoltcardRegistration(issuerKey, piccData, false);
var pp = await _ppService.GetPullPayment(registration!.PullPaymentId, false);
var store = await _storeRepository.FindStore(pp.StoreId);
var payRequest = new LNURLPayRequest
{
Tag = "payRequest",
MinSendable = LightMoney.Satoshis(1.0m),
MaxSendable = LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC),
Callback = new Uri(GetPayLink(p, Request.Scheme), UriKind.Absolute),
CommentAllowed = 0
};
if (amount is null)
return Ok(payRequest);
var cryptoCode = "BTC";
LNURLController.ControllerContext.HttpContext = HttpContext;
var result = await LNURLController.GetLNURLRequest(
cryptoCode,
store,
store.GetStoreBlob(),
new CreateInvoiceRequest()
{
Currency = "BTC",
Amount = LightMoney.FromUnit(amount.Value, LightMoneyUnit.MilliSatoshi).ToUnit(LightMoneyUnit.BTC)
},
payRequest,
null,
[PullPaymentHostedService.GetInternalTag(pp.Id)]);
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest2)
return result;
payRequest = payRequest2;
var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last();
return await LNURLController.GetLNURLForInvoice(invoiceId, cryptoCode, amount.Value, null);
}
[AllowAnonymous]
[HttpGet("~/boltcard")]
public async Task<IActionResult> GetWithdrawRequest([FromQuery] string? p, [FromQuery] string? c, [FromQuery] string? pr, [FromQuery] string? k1, CancellationToken cancellationToken)
@ -65,6 +122,16 @@ public class UIBoltcardController : Controller
if (!cardKey.CheckSunMac(c, piccData))
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Replayed or expired query" });
LNURLController.ControllerContext.HttpContext = HttpContext;
return await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken);
var res = await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken);
if (res is not OkObjectResult ok || ok.Value is not LNURLWithdrawRequest withdrawRequest)
return res;
var paylink = GetPayLink(p, "lnurlp");
withdrawRequest.PayLink = new Uri(paylink, UriKind.Absolute);
return res;
}
private string GetPayLink(string? p, string scheme)
{
return Url.Action(nameof(GetPayRequest), "UIBoltcard", new { p }, scheme)!;
}
}

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

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

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,25 @@ 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,
EmbeddedCSS = request.EmbeddedCSS,
CustomCSSLink = request.CustomCSSLink
});
}
public async Task<string> CreatePullPayment(CreatePullPayment create)
{
ArgumentNullException.ThrowIfNull(create);
@ -263,7 +284,7 @@ namespace BTCPayServer.HostedServices
return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId);
}
record TopUpRequest(string PullPaymentId, InvoiceEntity InvoiceEntity);
class PayoutRequest
{
public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> completionSource,
@ -273,6 +294,8 @@ namespace BTCPayServer.HostedServices
ArgumentNullException.ThrowIfNull(completionSource);
Completion = completionSource;
ClaimRequest = request;
if (request.StoreId is null)
throw new ArgumentNullException(nameof(request.StoreId));
}
public TaskCompletionSource<ClaimRequest.ClaimResponse> Completion { get; set; }
@ -323,10 +346,20 @@ namespace BTCPayServer.HostedServices
{
payoutHandler.StartBackgroundCheck(Subscribe);
}
_eventAggregator.Subscribe<Events.InvoiceEvent>(TopUpInvoice);
return new[] { Loop() };
}
private void TopUpInvoice(InvoiceEvent evt)
{
if (evt.EventCode == InvoiceEventCode.Completed)
{
foreach (var pullPaymentId in evt.Invoice.GetInternalTags("PULLPAY#"))
{
_Channel.Writer.TryWrite(new TopUpRequest(pullPaymentId, evt.Invoice));
}
}
}
private void Subscribe(params Type[] events)
{
foreach (Type @event in events)
@ -339,6 +372,10 @@ namespace BTCPayServer.HostedServices
{
await foreach (var o in _Channel.Reader.ReadAllAsync())
{
if (o is TopUpRequest topUp)
{
await HandleTopUp(topUp);
}
if (o is PayoutRequest req)
{
await HandleCreatePayout(req);
@ -373,10 +410,40 @@ namespace BTCPayServer.HostedServices
}
}
private async Task HandleTopUp(TopUpRequest topUp)
{
var pp = await this.GetPullPayment(topUp.PullPaymentId, false);
using var ctx = _dbContextFactory.CreateContext();
var payout = new Data.PayoutData()
{
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
Date = DateTimeOffset.UtcNow,
State = PayoutState.Completed,
PullPaymentDataId = pp.Id,
PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(),
Destination = null,
StoreDataId = pp.StoreId
};
var rate = topUp.InvoiceEntity.Rates["BTC"];
var cryptoAmount = Math.Round(topUp.InvoiceEntity.PaidAmount.Net / rate, 11);
var payoutBlob = new PayoutBlob()
{
CryptoAmount = -cryptoAmount,
Amount = -topUp.InvoiceEntity.PaidAmount.Net,
Destination = null,
Metadata = new JObject(),
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
await ctx.SaveChangesAsync();
}
public bool SupportsLNURL(PullPaymentBlob blob)
{
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
id.PaymentType == LightningPaymentType.Instance &&
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
id.PaymentType == LightningPaymentType.Instance &&
_networkProvider.DefaultNetwork.CryptoCode == id.CryptoCode);
return pms is not null && _lnurlSupportedCurrencies.Contains(blob.Currency);
}
@ -633,7 +700,7 @@ namespace BTCPayServer.HostedServices
{
Amount = claimed,
Destination = req.ClaimRequest.Destination.ToString(),
Metadata = req.ClaimRequest.Metadata?? new JObject(),
Metadata = req.ClaimRequest.Metadata ?? new JObject(),
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
@ -826,6 +893,10 @@ namespace BTCPayServer.HostedServices
return time;
}
public static string GetInternalTag(string id)
{
return $"PULLPAY#{id}";
}
class InternalPayoutPaidRequest
{
@ -880,25 +951,25 @@ namespace BTCPayServer.HostedServices
{
null when destination.Amount is null && ppCurrency is null => ("Amount is not specified in destination or payout request", null),
null when destination.Amount is null => (null, null),
null when destination.Amount != null => (null,destination.Amount),
not null when destination.Amount is null => (null,amount),
null when destination.Amount != null => (null, destination.Amount),
not null when destination.Amount is null => (null, amount),
not null when destination.Amount != null && amount != destination.Amount &&
destination.IsExplicitAmountMinimum &&
payoutCurrency == "BTC" && ppCurrency == "SATS" &&
new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount =>
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null),
not null when destination.Amount != null && amount != destination.Amount &&
destination.IsExplicitAmountMinimum &&
!(payoutCurrency == "BTC" && ppCurrency == "SATS") &&
amount < destination.Amount =>
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null),
not null when destination.Amount != null && amount != destination.Amount &&
!destination.IsExplicitAmountMinimum =>
($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null),
_ => (null, amount)
};
}
public static string GetErrorMessage(ClaimResult result)
{
switch (result)

View File

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

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,108 @@
using System;
using System.Linq;
using System.Reflection.Metadata;
using System.Threading.Tasks;
using AngleSharp.Dom;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Plugins.BoltcardBalance.ViewModels;
using BTCPayServer.Plugins.BoltcardFactory;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
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)
{
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 }]
//});
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.PullPaymentId);
}
[NonAction]
public async Task<IActionResult> GetBalanceView(string ppId)
{
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 vm = new BalanceViewModel()
{
Currency = blob.Currency,
AmountDue = blob.Limit - totalPaid
};
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
});
return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", vm);
}
}
}

View File

@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Plugins.BoltcardBalance.ViewModels
{
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>();
}
}

View File

@ -0,0 +1,58 @@
@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)
{
<a href="#" id="CancelWizard" class="cancel mt-2">
<vc:icon symbol="close" />
</a>
}
</nav>
<div class="d-flex justify-content-center">
<div class="d-flex flex-column">
<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>
</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,14 @@
@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>
</li>

View File

@ -0,0 +1,150 @@
@{
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="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.GetCurrentUrl()" />
</div>
</div>
<p class="text-secondary">NFC not supported in this device</p>
</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 delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function showBalance(lnurlw) {
setState("Submitting");
await 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 xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = 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("WaitingForCard");
document.getElementById("balance-table").innerHTML = "";
});
setState("ShowBalance");
}
else {
setState("WaitingForCard");
}
};
xhttp.open('GET', url, true);
xhttp.send(new FormData());
}
async function startScan() {
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 }) => {
const record = message.records[0];
const textDecoder = new TextDecoder('utf-8');
const decoded = textDecoder.decode(record.data);
await showBalance(decoded);
};
}
function setState(state)
{
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");
}
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 () => {
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);
// 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,220 @@
#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,
EmbeddedCSS = req.EmbeddedCSS,
CustomCSSLink = req.CustomCSSLink,
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,
CustomCSSLink = model.CustomCSSLink,
Amount = model.Amount,
AutoApproveClaims = model.AutoApproveClaims,
EmbeddedCSS = model.EmbeddedCSS,
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

@ -0,0 +1,22 @@
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Services.Apps;
using Microsoft.Extensions.DependencyInjection;
using static BTCPayServer.Plugins.BoltcardFactory.BoltcardFactoryPlugin;
namespace BTCPayServer.Plugins.BoltcardTopUp;
public class BoltcardTopUpPlugin : BaseBTCPayServerPlugin
{
public const string ViewsDirectory = "/Plugins/BoltcardTopUp/Views";
public override string Identifier => "BTCPayServer.Plugins.BoltcardTopUp";
public override string Name => "BoltcardTopUp";
public override string Description => "Add the ability to Top-Up 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,207 @@
using BTCPayServer.Client;
using BTCPayServer.Filters;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Authorization;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using System;
using Microsoft.AspNetCore.Mvc;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Data;
using BTCPayServer.Services.Rates;
using BTCPayServer.ModelBinders;
using BTCPayServer.Plugins.BoltcardBalance;
using System.Collections.Specialized;
using BTCPayServer.Client.Models;
using BTCPayServer.NTag424;
using BTCPayServer.Services;
using NBitcoin.DataEncoders;
using NBitcoin;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using Org.BouncyCastle.Ocsp;
using System.Security.Claims;
using BTCPayServer.Payments;
using BTCPayServer.Plugins.BoltcardBalance.Controllers;
using BTCPayServer.HostedServices;
namespace BTCPayServer.Plugins.BoltcardTopUp.Controllers
{
public class UIBoltcardTopUpController : Controller
{
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly SettingsRepository _settingsRepository;
private readonly BTCPayServerEnvironment _env;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly RateFetcher _rateFetcher;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly UIBoltcardBalanceController _boltcardBalanceController;
private readonly PullPaymentHostedService _ppService;
public UIBoltcardTopUpController(
ApplicationDbContextFactory dbContextFactory,
SettingsRepository settingsRepository,
BTCPayServerEnvironment env,
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
RateFetcher rateFetcher,
BTCPayNetworkProvider networkProvider,
UIBoltcardBalanceController boltcardBalanceController,
PullPaymentHostedService ppService,
CurrencyNameTable currencies)
{
_dbContextFactory = dbContextFactory;
_settingsRepository = settingsRepository;
_env = env;
_jsonSerializerSettings = jsonSerializerSettings;
_rateFetcher = rateFetcher;
_networkProvider = networkProvider;
_boltcardBalanceController = boltcardBalanceController;
_ppService = ppService;
Currencies = currencies;
}
public CurrencyNameTable Currencies { get; }
[HttpGet("~/stores/{storeId}/boltcards/top-up")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
[AutoValidateAntiforgeryToken]
public async Task<IActionResult> Keypad(string storeId, string currency = null)
{
var settings = new PointOfSaleSettings
{
Title = "Boltcards Top-Up"
};
currency ??= this.HttpContext.GetStoreData().GetStoreBlob().DefaultCurrency;
var numberFormatInfo = Currencies.GetNumberFormatInfo(currency);
double step = Math.Pow(10, -numberFormatInfo.CurrencyDecimalDigits);
//var store = new Data.StoreData();
//var storeBlob = new StoreBlob();
return View($"{BoltcardTopUpPlugin.ViewsDirectory}/Keypad.cshtml", new ViewPointOfSaleViewModel
{
Title = settings.Title,
//StoreName = store.StoreName,
//BrandColor = storeBlob.BrandColor,
//CssFileId = storeBlob.CssFileId,
//LogoFileId = storeBlob.LogoFileId,
Step = step.ToString(CultureInfo.InvariantCulture),
//ViewType = BTCPayServer.Plugins.PointOfSale.PosViewType.Light,
//ShowCustomAmount = settings.ShowCustomAmount,
//ShowDiscount = settings.ShowDiscount,
//ShowSearch = settings.ShowSearch,
//ShowCategories = settings.ShowCategories,
//EnableTips = settings.EnableTips,
//CurrencyCode = settings.Currency,
//CurrencySymbol = numberFormatInfo.CurrencySymbol,
CurrencyCode = currency,
CurrencyInfo = new ViewPointOfSaleViewModel.CurrencyInfoData
{
CurrencySymbol = string.IsNullOrEmpty(numberFormatInfo.CurrencySymbol) ? settings.Currency : numberFormatInfo.CurrencySymbol,
Divisibility = numberFormatInfo.CurrencyDecimalDigits,
DecimalSeparator = numberFormatInfo.CurrencyDecimalSeparator,
ThousandSeparator = numberFormatInfo.NumberGroupSeparator,
Prefixed = new[] { 0, 2 }.Contains(numberFormatInfo.CurrencyPositivePattern),
SymbolSpace = new[] { 2, 3 }.Contains(numberFormatInfo.CurrencyPositivePattern)
},
//Items = AppService.Parse(settings.Template, false),
//ButtonText = settings.ButtonText,
//CustomButtonText = settings.CustomButtonText,
//CustomTipText = settings.CustomTipText,
//CustomTipPercentages = settings.CustomTipPercentages,
//CustomCSSLink = settings.CustomCSSLink,
//CustomLogoLink = storeBlob.CustomLogo,
//AppId = "vouchers",
StoreId = storeId,
//Description = settings.Description,
//EmbeddedCSS = settings.EmbeddedCSS,
//RequiresRefundEmail = settings.RequiresRefundEmail
});
}
[HttpPost("~/stores/{storeId}/boltcards/top-up")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
[AutoValidateAntiforgeryToken]
public IActionResult Keypad(string storeId,
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal amount, string currency)
{
return RedirectToAction(nameof(ScanCard),
new
{
storeId = storeId,
amount = amount,
currency = currency
});
}
[HttpGet("~/stores/{storeId}/boltcards/top-up/scan")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
[AutoValidateAntiforgeryToken]
public async Task<IActionResult> ScanCard(string storeId,
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal amount, string currency)
{
return View($"{BoltcardTopUpPlugin.ViewsDirectory}/ScanCard.cshtml");
}
[HttpPost("~/stores/{storeId}/boltcards/top-up/scan")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
public async Task<IActionResult> ScanCard(string storeId,
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal amount, string currency, string p, string c)
{
//return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", new BoltcardBalance.ViewModels.BalanceViewModel()
//{
// AmountDue = 10000m,
// Currency = "SATS",
// Transactions = [new() { Date = DateTimeOffset.UtcNow, Balance = -3.0m }, new() { Date = DateTimeOffset.UtcNow, Balance = -5.0m }]
//});
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();
var pp = await _ppService.GetPullPayment(registration.PullPaymentId, false);
var rules = this.HttpContext.GetStoreData().GetStoreBlob().GetRateRules(_networkProvider);
var rateResult = await _rateFetcher.FetchRate(new Rating.CurrencyPair("BTC", currency), rules, default);
var cryptoAmount = Math.Round(amount / rateResult.BidAsk.Bid, 11);
var ppCurrency = pp.GetBlob().Currency;
rateResult = await _rateFetcher.FetchRate(new Rating.CurrencyPair(ppCurrency, currency), rules, default);
var ppAmount = Math.Round(amount / rateResult.BidAsk.Bid, Currencies.GetNumberFormatInfo(ppCurrency).CurrencyDecimalDigits);
using var ctx = _dbContextFactory.CreateContext();
var payout = new Data.PayoutData()
{
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
Date = DateTimeOffset.UtcNow,
State = PayoutState.Completed,
PullPaymentDataId = registration.PullPaymentId,
PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(),
Destination = null,
StoreDataId = storeId
};
var payoutBlob = new PayoutBlob()
{
CryptoAmount = -cryptoAmount,
Amount = -ppAmount,
Destination = null,
Metadata = new JObject(),
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
await ctx.SaveChangesAsync();
_boltcardBalanceController.ViewData["NoCancelWizard"] = true;
return await _boltcardBalanceController.GetBalanceView(registration.PullPaymentId);
}
}
}

View File

@ -0,0 +1,48 @@
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@{
Layout = "PointOfSale/Public/_Layout";
Csp.UnsafeEval();
}
@section PageHeadContent {
<link href="~/pos/keypad.css" asp-append-version="true" rel="stylesheet" />
}
@section PageFootContent {
<script>var srvModel = @Safe.Json(Model);</script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/pos/common.js" asp-append-version="true"></script>
<script src="~/pos/keypad.js" asp-append-version="true"></script>
}
<div id="PosKeypad" class="public-page-wrap">
<partial name="_StatusMessage" />
<partial name="_StoreHeader" model="(Model.Title, null as StoreBrandingViewModel)" />
<form id="app" method="post"
asp-route-storeId="@Model.StoreId"
asp-antiforgery="true" v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
<input type="hidden" name="posdata" v-model="posdata" id="posdata">
<input type="hidden" name="amount" v-model="totalNumeric">
<input type="hidden" name="currency" v-model="currencyCode">
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
<div class="fw-semibold text-muted" id="Currency">{{currencyCode}}</div>
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(total, false) }}</div>
<div class="text-muted text-center mt-2" id="Calculation">{{ calculation }}</div>
</div>
<div class="keypad">
<button v-for="k in keys" :key="k" :disabled="k === '+' && mode !== 'amounts'" v-on:click.prevent="keyPressed(k)" v-on:dblclick.prevent="doubleClick(k)" type="button" class="btn btn-secondary btn-lg" :data-key="k">{{ k }}</button>
</div>
<button class="btn btn-lg btn-primary mx-3" type="submit" :disabled="payButtonLoading" id="pay-button">
<div v-if="payButtonLoading" class="spinner-border spinner-border-sm" id="pay-button-spinner" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<template v-else>Top-Up Card</template>
</button>
</form>
<footer class="store-footer">
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by <partial name="_StoreFooterLogo" />
</a>
</footer>
</div>

View File

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

View File

@ -0,0 +1,150 @@
@{
ViewData["Title"] = "Boltcard TopUps";
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>Boltcard Top-Up</h1>
<p class="lead text-secondary mt-3" id="explanation">Scan your card to top it up</p>
</header>
<div id="body" class="my-4">
<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">
<div class="qr-container mb-2">
<vc:qr-code data="@Context.Request.GetCurrentUrl()" />
</div>
</div>
<p class="text-secondary">NFC not supported in this device</p>
</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;
var scanned = false;
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function showBalance(lnurlw) {
setState("Submitting");
await delay(1000);
var url = window.location.href.replace("#", "");
url = url.split("?")[0] + "?" + lnurlw.split("?")[1] + "&" + url.split("?")[1];
// url = "https://testnet.demo.btcpayserver.org/boltcards/balance?p=...&c=..."
scanned = true;
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200 && this.responseText) {
document.getElementById("balance-table").innerHTML = this.responseText;
setState("ShowBalance");
}
else {
scanned = false;
setState("WaitingForCard");
}
};
xhttp.open('POST', url, true);
xhttp.send(new FormData());
}
async function startScan() {
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 }) => {
const record = message.records[0];
const textDecoder = new TextDecoder('utf-8');
const decoded = textDecoder.decode(record.data);
await showBalance(decoded);
};
}
function setState(state)
{
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");
}
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("explanation").classList.add("d-none");
document.getElementById("scanning-btn").classList.remove("d-none");
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-bitcoin\"></i>";
document.getElementById("balance").classList.remove("d-none");
}
}
document.addEventListener("DOMContentLoaded", async () => {
var nfcSupported = 'NDEFReader' in window;
if (!nfcSupported) {
setState("NFCNotSupported");
//setState("ShowBalance");
}
else {
setState("WaitingForPermission");
var granted = (await navigator.permissions.query({ name: 'nfc' })).state === 'granted';
if (granted)
{
setState("WaitingForCard");
startScan();
}
}
delegate('click', "#start-scan-btn", startScan);
//showBalance("lnurl://ewfw?p=test&c=test");
});
})();
</script>

View File

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

View File

@ -49,7 +49,7 @@
<div class="inputWithIcon _copyInput">
<input type="text" class="checkoutTextbox" v-bind:value="srvModel.invoiceBitcoinUrl" readonly="readonly"/>
<img v-bind:src="srvModel.cryptoImage" v-if="hasPayjoin"/>
<i class="fa fa-user-secret" v-else/>
<i class="fa fa-user-secret" v-else/>
</div>
</div>
</nav>

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

@ -12,6 +12,7 @@
@await RenderSectionAsync("PageFootContent", false)
}
<nav id="wizard-navbar">
@await RenderSectionAsync("Navbar", false)
</nav>

View File

@ -2,8 +2,8 @@
@using BTCPayServer.Abstractions.Contracts
@model (string Title, StoreBrandingViewModel StoreBranding)
@{
var logoUrl = !string.IsNullOrEmpty(Model.StoreBranding.LogoFileId)
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.StoreBranding.LogoFileId)
var logoUrl = !string.IsNullOrEmpty(Model.StoreBranding?.LogoFileId)
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.StoreBranding?.LogoFileId)
: null;
}
<header class="store-header" v-pre>

View File

@ -202,15 +202,15 @@
</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>
<p id="BoltcardActions" style="visibility:hidden">
<a id="SetupBoltcard" asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="configure-boltcard">
Setup Boltcard
</a>
<span>&nbsp;|&nbsp;</span>
<a id="ResetBoltcard" 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" />
@ -226,6 +226,15 @@
<script src="~/vendor/ur-registry/urlib.min.js" asp-append-version="true"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
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');
document.getElementById("ResetBoltcard").setAttribute('href', @Safe.Json(@Model.ResetDeepLink));
}
document.getElementById("BoltcardActions").style.visibility = "visible";
window.qrApp = initQRShow({});
delegate('click', 'button[page-qr]', event => {
qrApp.title = "Pull Payment QR";