Compare commits

..

4 Commits

Author SHA1 Message Date
425796a386 bump NTag424 lib 2024-04-24 21:51:32 +09:00
e5a445b383 Unify BalanceViews reset and topup 2024-04-22 13:54:52 +09:00
335d6f69b5 Remove Boltcard Top-Up plugin 2024-04-22 12:32:21 +09:00
105ff29451 Boltcard factory plugin 2024-04-22 12:32:20 +09:00
118 changed files with 2006 additions and 1676 deletions
BTCPayServer.Abstractions
BTCPayServer.Client
BTCPayServer.Common
BTCPayServer.Data
BTCPayServer.PluginPacker
BTCPayServer.Rating
BTCPayServer.Tests
BTCPayServer
BTCPayServer.csproj
Components
StoreRecentInvoices
StoreRecentTransactions
TruncateCenter
Controllers
Data/Payouts/LightningLike
DerivationSchemeParser.cs
Filters
HostedServices
Hosting
Models
Plugins
Properties
SearchString.cs
Services
Views
wwwroot
Build
Changelog.mdDockerfile
docs

@ -32,9 +32,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlSanitizer" Version="8.0.838" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.5" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0-beta.2" />
</ItemGroup>
<ItemGroup>

@ -31,7 +31,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BTCPayServer.Lightning.Common" Version="1.5.1" />
<PackageReference Include="NBitcoin" Version="7.0.37" />
<PackageReference Include="NBitcoin" Version="7.0.34" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>

@ -109,7 +109,7 @@ namespace BTCPayServer.Client
{
var response = await _httpClient.SendAsync(
CreateHttpRequest(
$"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}/lnurl",
$"/api/v1/pull-payments/{pullPaymentId}/lnurl",
method: HttpMethod.Get), cancellationToken);
return await HandleResponse<PullPaymentLNURL>(response);
}

@ -1,5 +1,4 @@
using System;
using System.Globalization;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
@ -59,8 +58,6 @@ namespace BTCPayServer.Client.JsonConverters
return null;
return TimeSpan.Zero;
}
if (reader.TokenType == JsonToken.String && TimeSpan.TryParse(reader.Value?.ToString(), CultureInfo.InvariantCulture, out var res))
return res;
if (reader.TokenType != JsonToken.Integer)
throw new JsonObjectException("Invalid timespan, expected integer", reader);
return ToTimespan((long)reader.Value);

@ -1,5 +1,3 @@
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models;
public class LightningAddressData
@ -8,5 +6,5 @@ public class LightningAddressData
public string CurrencyCode { get; set; }
public decimal? Min { get; set; }
public decimal? Max { get; set; }
public JObject InvoiceMetadata { get; set; }
}

@ -1,8 +1,5 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models
{
@ -16,9 +13,7 @@ namespace BTCPayServer.Client.Models
public bool? Archived { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset Created { get; set; }
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } = new Dictionary<string, JToken>();
}
}
public class PointOfSaleAppData : AppDataBase
{

@ -4,7 +4,7 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="NBXplorer.Client" Version="4.3.1" />
<PackageReference Include="NBXplorer.Client" Version="4.3.0" />
<PackageReference Include="NicolasDorier.StandardConfiguration" Version="2.0.1" />
</ItemGroup>
<ItemGroup Condition="'$(Altcoins)' != 'true'">

@ -3,11 +3,11 @@
<Import Project="../Build/Common.csproj" />
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />

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

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
@ -110,22 +109,9 @@ namespace BTCPayServer.PluginPacker
private static Type[] GetAllExtensionTypesFromAssembly(Assembly assembly)
{
return GetLoadableTypes(assembly).Where(type =>
return assembly.GetTypes().Where(type =>
typeof(IBTCPayServerPlugin).IsAssignableFrom(type) &&
!type.IsAbstract).ToArray();
}
static Type[] GetLoadableTypes(Assembly assembly)
{
if (assembly == null)
throw new ArgumentNullException(nameof(assembly));
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException e)
{
return e.Types.Where(t => t != null).ToArray();
}
}
}
}

@ -4,9 +4,9 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="NBitcoin" Version="7.0.37" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.9" />
<PackageReference Include="NBitcoin" Version="7.0.34" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="DigitalRuby.ExchangeSharp" Version="1.0.4" />
</ItemGroup>

@ -93,7 +93,7 @@ namespace BTCPayServer.Services.Rates
if (ticker != null)
{
var pair = GetCurrencyPair(symbol);
if (pair is not null && ticker.Bid <= ticker.Ask)
if (pair is not null)
result.Add(new PairRate(pair, new BidAsk(ticker.Bid, ticker.Ask)));
}
}

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<Import Project="../Build/Common.csproj" />
<PropertyGroup>
<NoWarn>$(NoWarn),xUnit1031</NoWarn>
@ -23,8 +23,8 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.15" />
<PackageReference Include="Selenium.Support" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver" Version="4.22.0" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="125.0.6422.14100" />
<PackageReference Include="Selenium.WebDriver" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="121.0.6167.8500" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<PrivateAssets>all</PrivateAssets>

@ -47,6 +47,7 @@ using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OpenQA.Selenium.DevTools.V100.DOMSnapshot;
using Xunit;
using Xunit.Abstractions;
using StoreData = BTCPayServer.Data.StoreData;
@ -871,13 +872,6 @@ namespace BTCPayServer.Tests
Assert.IsType<MultisigDerivationStrategy>(((P2WSHDerivationStrategy)strategyBase).Inner);
Assert.Equal(expected, strategyBase.ToString());
foreach (var space in new[] { "\r\n", " ", "\t" })
{
var expectedWithNewLines = $"2-of-tpubDDXgATYzdQkHHhZZCMcNJj8BGDENvzMVou5v9NdxiP4rxDLj33nS233dGFW4htpVZSJ6zds9eVqAV9RyRHHiKtwQKX8eR4n4KN3Dwmj7A3h-{space}tpubDC8a54NFtQtMQAZ97VhoU9V6jVTvi9w4Y5SaAXJSBYETKg3AoX5CCKndznhPWxJUBToPCpT44s86QbKdGpKAnSjcMTGW4kE6UQ8vpBjcybW-tpubDChjnP9LXNrJp43biqjY7FH93wgRRNrNxB4Q8pH7PPRy8UPcH2S6V46WGVJ47zVGF7SyBJNCpnaogsFbsybVQckGtVhCkng3EtFn8qmxptS";
strategyBase = parser.Parse(expectedWithNewLines);
Assert.Equal(expected, strategyBase.ToString());
}
var inner = (MultisigDerivationStrategy)((P2WSHDerivationStrategy)strategyBase).Inner;
Assert.False(inner.IsLegacy);
Assert.Equal(3, inner.Keys.Count);
@ -894,7 +888,7 @@ namespace BTCPayServer.Tests
Assert.True(((DirectDerivationStrategy)strategyBase).Segwit);
// Failure cases
Assert.Throws<FormatException>(() => { parser.Parse("xpubZ661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw"); });
Assert.Throws<FormatException>(() => { parser.Parse("xpub 661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw"); }); // invalid format because of space
Assert.Throws<ParsingException>(() => { parser.ParseOutputDescriptor("invalid"); }); // invalid in general
Assert.Throws<ParsingException>(() => { parser.ParseOutputDescriptor("wpkh([8b60afd1/49h/0h/0h]xpub661MyMwAFXkMnyoBjyHndD3QwRbcGVBsTGeNZN6QGVHcfz4MPzBUxjSevweNFQx7SqmMHLdSA4FteGsRrEriu4pnVZMZWnruFFAYZATtcDw/0/*)#9x4vkw48"); }); // invalid checksum
}
@ -1353,22 +1347,17 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
filter = "status:abed, status:abed2";
search = new SearchString(filter);
Assert.Null(search.TextSearch);
Assert.Null(search.TextFilters);
Assert.Equal("", search.TextSearch);
Assert.Equal("status:abed, status:abed2", search.ToString());
Assert.Throws<KeyNotFoundException>(() => search.Filters["test"]);
Assert.Equal(2, search.Filters["status"].Count);
Assert.Equal("abed", search.Filters["status"].First());
Assert.Equal("abed2", search.Filters["status"].Skip(1).First());
filter = "StartDate:2019-04-25 01:00 AM, hekki,orderid:MYORDERID,orderid:MYORDERID_2";
filter = "StartDate:2019-04-25 01:00 AM, hekki";
search = new SearchString(filter);
Assert.Equal("2019-04-25 01:00 AM", search.Filters["startdate"].First());
Assert.Equal("hekki", search.TextSearch);
Assert.Equal("orderid:MYORDERID,orderid:MYORDERID_2", search.TextFilters);
Assert.Equal("orderid:MYORDERID,orderid:MYORDERID_2,hekki", search.TextCombined);
Assert.Equal("StartDate:2019-04-25 01:00 AM", search.WithoutSearchText());
Assert.Equal(filter, search.ToString());
// modify search
filter = $"status:settled,exceptionstatus:paidLate,unusual:true, fulltext searchterm, storeid:{storeId},startdate:2019-04-25 01:00:00";

@ -1117,8 +1117,7 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain(null, card1keys);
var card2 = await client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
UID = uid,
OnExisting = OnExistingBehavior.UpdateVersion
UID = uid
});
Assert.Equal(1, card2.Version);
Assert.StartsWith("lnurlw://", card2.LNURLW);
@ -2161,17 +2160,6 @@ namespace BTCPayServer.Tests
Assert.Equal("BTC", pp.Currency);
Assert.True(pp.AutoApproveClaims);
Assert.Equal(0.79m, pp.Amount);
// If an invoice doesn't have payment because it has been marked as paid, we should still be able to refund it.
invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest { Amount = 5000.0m, Currency = "USD" });
await client.MarkInvoiceStatus(user.StoreId, invoice.Id, new MarkInvoiceStatusRequest { Status = InvoiceStatus.Settled });
var refund = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
{
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.CurrentRate
});
Assert.Equal(1.0m, refund.Amount);
Assert.Equal("BTC", refund.Currency);
}
[Fact(Timeout = TestTimeout)]
@ -3469,7 +3457,6 @@ namespace BTCPayServer.Tests
var store2 = (await adminClient.CreateStore(new CreateStoreRequest() { Name = "test2" })).Id;
var address1 = Guid.NewGuid().ToString("n").Substring(0, 8);
var address2 = Guid.NewGuid().ToString("n").Substring(0, 8);
var address3 = Guid.NewGuid().ToString("n").Substring(0, 8);
Assert.Empty(await adminClient.GetStoreLightningAddresses(store.Id));
Assert.Empty(await adminClient.GetStoreLightningAddresses(store2));
@ -3497,17 +3484,6 @@ namespace BTCPayServer.Tests
await adminClient.RemoveStoreLightningAddress(store2, address2);
Assert.Empty(await adminClient.GetStoreLightningAddresses(store2));
var store3 = (await adminClient.CreateStore(new CreateStoreRequest { Name = "test3" })).Id;
Assert.Empty(await adminClient.GetStoreLightningAddresses(store3));
var metadata = JObject.FromObject(new { test = 123 });
await adminClient.AddOrUpdateStoreLightningAddress(store3, address3, new LightningAddressData
{
InvoiceMetadata = metadata
});
var lnAddresses = await adminClient.GetStoreLightningAddresses(store3);
Assert.Single(lnAddresses);
Assert.Equal(metadata, lnAddresses[0].InvoiceMetadata);
}
[Fact(Timeout = 60 * 2 * 1000)]

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

@ -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)}"));
@ -2620,9 +2656,9 @@ namespace BTCPayServer.Tests
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
var cartData = s.Driver.FindElement(By.CssSelector("#CartData table"));
var items = cartData.FindElements(By.CssSelector("tbody tr"));
var sums = cartData.FindElements(By.CssSelector("tfoot tr"));
var additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table"));
var items = additionalData.FindElements(By.CssSelector("tbody tr"));
var sums = additionalData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(2, items.Count);
Assert.Equal(4, sums.Count);
Assert.Contains("Manual entry 1", items[0].FindElement(By.CssSelector("th")).Text);
@ -2638,33 +2674,6 @@ namespace BTCPayServer.Tests
Assert.Contains("Total", sums[3].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 222,21 €", sums[3].FindElement(By.CssSelector("td")).Text);
// Receipt print
s.Driver.FindElement(By.Id("ReceiptLinkPrint")).Click();
windows = s.Driver.WindowHandles;
Assert.Equal(3, windows.Count);
s.Driver.SwitchTo().Window(windows[2]);
var paymentDetails = s.Driver.WaitForElement(By.CssSelector("#PaymentDetails table"));
items = paymentDetails.FindElements(By.CssSelector("tr.cart-data"));
sums = paymentDetails.FindElements(By.CssSelector("tr.sums-data"));
Assert.Equal(2, items.Count);
Assert.Equal(4, sums.Count);
Assert.Contains("Manual entry 1", items[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 234,00 €", items[0].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Manual entry 2", items[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("0,56 €", items[1].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 234,56 €", sums[0].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Discount", sums[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("10% = 123,46 €", sums[1].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Tip", sums[2].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("10% = 111,11 €", sums[2].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Total", sums[3].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 222,21 €", sums[3].FindElement(By.CssSelector(".val")).Text);
s.Driver.Close();
s.Driver.SwitchTo().Window(windows[1]);
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// Once more with items
s.GoToUrl(editUrl);
s.Driver.FindElement(By.Id("ShowItems")).Click();
@ -2702,40 +2711,22 @@ namespace BTCPayServer.Tests
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
cartData = s.Driver.FindElement(By.CssSelector("#CartData table"));
items = cartData.FindElements(By.CssSelector("tbody tr"));
sums = cartData.FindElements(By.CssSelector("tfoot tr"));
additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table"));
items = additionalData.FindElements(By.CssSelector("tbody tr"));
sums = additionalData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(3, items.Count);
Assert.Single(sums);
Assert.Equal(2, sums.Count);
Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 1,00 € = 1,00 €", items[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("2 x 1,00 € = 2,00 €", items[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Manual entry 1", items[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Total", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector("td")).Text);
// Receipt print
s.Driver.FindElement(By.Id("ReceiptLinkPrint")).Click();
windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count);
s.Driver.SwitchTo().Window(windows[1]);
paymentDetails = s.Driver.WaitForElement(By.CssSelector("#PaymentDetails table"));
items = paymentDetails.FindElements(By.CssSelector("tr.cart-data"));
sums = paymentDetails.FindElements(By.CssSelector("tr.sums-data"));
Assert.Equal(3, items.Count);
Assert.Single(sums);
Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 x 1,00 € = 1,00 €", items[0].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("2 x 1,00 € = 2,00 €", items[1].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Manual entry 1", items[2].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Total", sums[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector(".val")).Text);
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
Assert.Contains("Total", sums[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("4,23 €", sums[1].FindElement(By.CssSelector("td")).Text);
// Guest user can access recent transactions
s.GoToHome();
@ -2882,9 +2873,9 @@ namespace BTCPayServer.Tests
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
var cartData = s.Driver.FindElement(By.CssSelector("#CartData table"));
var items = cartData.FindElements(By.CssSelector("tbody tr"));
var sums = cartData.FindElements(By.CssSelector("tfoot tr"));
var additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table"));
var items = additionalData.FindElements(By.CssSelector("tbody tr"));
var sums = additionalData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(7, items.Count);
Assert.Equal(4, sums.Count);
Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector("th")).Text);

@ -272,8 +272,7 @@ namespace BTCPayServer.Tests
"https://www.btse.com", // not allowing to be hit from circleci
"https://www.bitpay.com", // not allowing to be hit from circleci
"https://support.bitpay.com",
"https://www.coingecko.com", // unhappy service
"https://www.wasabiwallet.io/" // returning Forbidden
"https://www.coingecko.com" // unhappy service
};
foreach (var match in regex.Matches(text).OfType<Match>())

@ -2814,7 +2814,7 @@ namespace BTCPayServer.Tests
Password = "store@store.com",
Port = 1234,
Server = "store.com"
}), ""));
}), "", true));
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
}
@ -2985,7 +2985,7 @@ namespace BTCPayServer.Tests
}
[Fact]
[Trait("Integration", "Integration")]
[Trait("Selenium", "Selenium")]
public async Task CanCreateReports()
{
using var tester = CreateServerTester(newDb: true);
@ -3093,48 +3093,6 @@ namespace BTCPayServer.Tests
var invoiceIdIndex = report.GetIndex("InvoiceId");
var oldPaymentsCount = report.Data.Count(d => d[invoiceIdIndex].Value<string>() == "Q7RqoHLngK9svM4MgRyi9y");
Assert.Equal(8, oldPaymentsCount); // 10 payments, but 2 unaccounted
var addr = await tester.ExplorerNode.GetNewAddressAsync();
// Two invoices get refunded
for (int i = 0; i < 2; i++)
{
var inv = await client.CreateInvoice(acc.StoreId, new CreateInvoiceRequest() { Amount = 10m, Currency = "USD" });
await acc.PayInvoice(inv.Id);
await client.MarkInvoiceStatus(acc.StoreId, inv.Id, new MarkInvoiceStatusRequest() { Status = InvoiceStatus.Settled });
var refund = await client.RefundInvoice(acc.StoreId, inv.Id, new RefundInvoiceRequest() { RefundVariant = RefundVariant.Fiat, PaymentMethod = "BTC" });
async Task AssertData(string currency, decimal awaiting, decimal limit, decimal completed, bool fullyPaid)
{
report = await GetReport(acc, new() { ViewName = "Refunds" });
var currencyIndex = report.GetIndex("Currency");
var awaitingIndex = report.GetIndex("Awaiting");
var fullyPaidIndex = report.GetIndex("FullyPaid");
var completedIndex = report.GetIndex("Completed");
var limitIndex = report.GetIndex("Limit");
var d = Assert.Single(report.Data.Where(d => d[report.GetIndex("InvoiceId")].Value<string>() == inv.Id));
Assert.Equal(fullyPaid, (bool)d[fullyPaidIndex]);
Assert.Equal(currency, d[currencyIndex].Value<string>());
Assert.Equal(completed, (((JObject)d[completedIndex])["v"]).Value<decimal>());
Assert.Equal(awaiting, (((JObject)d[awaitingIndex])["v"]).Value<decimal>());
Assert.Equal(limit, (((JObject)d[limitIndex])["v"]).Value<decimal>());
}
await AssertData("USD", awaiting: 0.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
var payout = await client.CreatePayout(refund.Id, new CreatePayoutRequest() { Destination = addr.ToString(), PaymentMethod = "BTC" });
await AssertData("USD", awaiting: 10.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
await client.ApprovePayout(acc.StoreId, payout.Id, new ApprovePayoutRequest());
await AssertData("USD", awaiting: 10.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
if (i == 0)
{
await client.MarkPayoutPaid(acc.StoreId, payout.Id);
await AssertData("USD", awaiting: 0.0m, limit: 10.0m, completed: 10.0m, fullyPaid: true);
}
if (i == 1)
{
await client.CancelPayout(acc.StoreId, payout.Id);
await AssertData("USD", awaiting: 0.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
}
}
}
private async Task<StoreReportResponse> GetReport(TestAccount acc, StoreReportRequest req)

@ -18,6 +18,7 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.DevTools.V100.Network;
using OpenQA.Selenium.Support.UI;
using Xunit;
using Xunit.Abstractions;

@ -89,7 +89,7 @@ services:
- merchant_lnd
selenium:
image: selenium/standalone-chrome:125.0
image: selenium/standalone-chrome:101.0
extra_hosts:
- "tests:172.23.0.18"
expose:
@ -99,7 +99,7 @@ services:
custom:
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.4
image: nicolasdorier/nbxplorer:2.5.0
restart: unless-stopped
ports:
- "32838:32838"
@ -163,7 +163,7 @@ services:
- "bitcoin_datadir:/data"
customer_lightningd:
image: btcpayserver/lightning:v24.05
image: btcpayserver/lightning:v24.02.2
stop_signal: SIGKILL
restart: unless-stopped
environment:
@ -191,9 +191,8 @@ services:
- bitcoind
merchant_lightningd:
image: btcpayserver/lightning:v24.05
image: btcpayserver/lightning:v24.02.2
stop_signal: SIGKILL
restart: unless-stopped
environment:
EXPOSE_TCP: "true"
LIGHTNINGD_CHAIN: "btc"
@ -217,7 +216,6 @@ services:
- "merchant_lightningd_datadir:/root/.lightning"
depends_on:
- bitcoind
postgres:
image: postgres:13.4
environment:
@ -228,7 +226,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.18.1-beta
image: btcpayserver/lnd:v0.17.4-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -263,7 +261,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.18.1-beta
image: btcpayserver/lnd:v0.17.4-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -310,29 +308,27 @@ services:
- "tor_datadir:/home/tor/.tor"
- "torrcdir:/usr/local/etc/tor"
- "tor_servicesdir:/var/lib/tor/hidden_services"
monerod:
image: btcpayserver/monero:0.18.3.3
restart: unless-stopped
container_name: xmr_monerod
entrypoint: monerod --fixed-difficulty 200 --rpc-bind-ip=0.0.0.0 --confirm-external-bind --rpc-bind-port=18081 --block-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/block?cryptoCode=xmr&hash=%s" --testnet --no-igd --hide-my-port --offline --non-interactive
volumes:
- "monero_data:/home/monero/.bitmonero"
ports:
- "18081:18081"
image: btcpayserver/monero:0.18.2.2-5
restart: unless-stopped
container_name: xmr_monerod
entrypoint: sleep 999999
# entrypoint: monerod --fixed-difficulty 200 --rpc-bind-ip=0.0.0.0 --confirm-external-bind --rpc-bind-port=18081 --block-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/block?cryptoCode=xmr&hash=%s" --testnet --no-igd --hide-my-port --offline
volumes:
- "monero_data:/home/monero/.bitmonero"
ports:
- "18081:18081"
monero_wallet:
image: btcpayserver/monero:0.18.3.3
image: btcpayserver/monero:0.18.2.2-5
restart: unless-stopped
container_name: xmr_wallet_rpc
entrypoint: monero-wallet-rpc --testnet --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18082 --non-interactive --trusted-daemon --daemon-address=monerod:18081 --wallet-dir=/wallet --tx-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s"
entrypoint: monero-wallet-rpc --testnet --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18082 --non-interactive --trusted-daemon --daemon-address=monerod:18081 --wallet-file=/wallet/wallet.keys --password-file=/wallet/password --tx-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s"
ports:
- "18082:18082"
volumes:
- "./monero_wallet:/wallet"
depends_on:
- monerod
- monerod
litecoind:
restart: unless-stopped
image: btcpayserver/litecoin:0.18.1

@ -86,7 +86,7 @@ services:
- merchant_lnd
selenium:
image: selenium/standalone-chrome:125.0
image: selenium/standalone-chrome:101.0
extra_hosts:
- "tests:172.23.0.18"
expose:
@ -96,7 +96,7 @@ services:
custom:
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.4
image: nicolasdorier/nbxplorer:2.5.0
restart: unless-stopped
ports:
- "32838:32838"
@ -149,7 +149,7 @@ services:
- "bitcoin_datadir:/data"
customer_lightningd:
image: btcpayserver/lightning:v24.05
image: btcpayserver/lightning:v24.02.2
stop_signal: SIGKILL
restart: unless-stopped
environment:
@ -177,9 +177,8 @@ services:
- bitcoind
merchant_lightningd:
image: btcpayserver/lightning:v24.05
image: btcpayserver/lightning:v24.02.2
stop_signal: SIGKILL
restart: unless-stopped
environment:
EXPOSE_TCP: "true"
LIGHTNINGD_CHAIN: "btc"
@ -214,7 +213,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.18.1-beta
image: btcpayserver/lnd:v0.17.4-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -251,7 +250,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.18.1-beta
image: btcpayserver/lnd:v0.17.4-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"

@ -1,199 +0,0 @@
#!/bin/bash
USERHOST="btcpay.local"
BASE="https://localhost:14142"
API_BASE="$BASE/api/v1"
PASSWORD="rockstar"
# Ensure we are in the script directory
cd "$(dirname "${BASH_SOURCE}")"
# Create admin user
admin_id=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'email': 'admin@$USERHOST', 'password': '$PASSWORD', 'isAdministrator': true }" \
"$API_BASE/users" | jq -r '.id')
printf "Admin ID: %s\n" "$admin_id"
# Create unlimited access API key
admin_api_key=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'permissions': ['unrestricted'], 'label': 'Unrestricted' }" \
--user "admin@$USERHOST:$PASSWORD" \
"$API_BASE/api-keys" | jq -r '.apiKey')
printf "Admin API Key: %s\n" "$admin_api_key"
printf "\n"
# Create Store Owner
owner_id=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'email': 'owner@$USERHOST', 'password': '$PASSWORD', 'isAdministrator': false }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/users" | jq -r '.id')
printf "Store Owner ID: %s\n" "$owner_id"
# Create Store Manager
manager_id=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'email': 'manager@$USERHOST', 'password': '$PASSWORD', 'isAdministrator': false }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/users" | jq -r '.id')
printf "Store Manager ID: %s\n" "$manager_id"
# Create Store Employee
employee_id=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'email': 'employee@$USERHOST', 'password': '$PASSWORD', 'isAdministrator': false }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/users" | jq -r '.id')
printf "Store Employee ID: %s\n" "$employee_id"
printf "\n"
# Create Satoshis Steaks store
res=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'name': 'Satoshis Steaks', 'checkoutType': 'V2', 'lightningAmountInSatoshi': true, 'onChainWithLnInvoiceFallback': true, 'playSoundOnPayment': true, 'defaultCurrency': 'EUR' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores")
store_id_satoshis_steaks=$( echo $res | jq -r '.id')
if [ -z "${store_id_satoshis_steaks}" ]; then
printf "Error creating Satoshis Steaks store: %s\n" "$res"
exit 1
fi
printf "Satoshis Steaks Store ID: %s\n" "$store_id_satoshis_steaks"
# Create Hot Wallet for Satoshis Steaks store
wallet_enabled_satoshis_steaks=$(curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': 'tpubDC2mCtL7EPhey3qRgHXmKQRraxXgiuSTkHdJbDW22xLK1YMXy8jdEq7jx2UN5z1wU5xBWWZdSpAobG1bbZBTR4f8R3AjL31EzoexpngKUXM' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/payment-methods/BTC-CHAIN")
# Create Internal Node connection for Satoshis Steaks store
ln_enabled_satoshis_steaks=$(curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'connectionString': 'Internal Node' } }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/payment-methods/BTC-LN")
# LNURL settings
curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'lud12Enabled': true, 'useBech32Scheme': true } }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/payment-methods/BTC-LNURL" >/dev/null 2>&1
# Fund Satoshis Steaks wallet
btcaddress_satoshis_steaks=$(curl -s -k -X GET -H 'Content-Type: application/json' \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/payment-methods/onchain/BTC/wallet/address" | jq -r '.address')
./docker-bitcoin-cli.sh sendtoaddress "$btcaddress_satoshis_steaks" 6.15 >/dev/null 2>&1
printf "\n"
# Add store users to Satoshis Steaks store
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$owner_id', 'role': 'Owner' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/users" >/dev/null 2>&1
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$manager_id', 'role': 'Manager' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/users" >/dev/null 2>&1
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$employee_id', 'role': 'Employee' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/users" >/dev/null 2>&1
# Create Nakamoto Nuggets store
store_id_nakamoto_nuggets=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'name': 'Nakamoto Nuggets', 'checkoutType': 'V2', 'lightningAmountInSatoshi': true, 'onChainWithLnInvoiceFallback': true, 'playSoundOnPayment': true }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores" | jq -r '.id')
printf "Nakamoto Nuggets Store ID: %s\n" "$store_id_nakamoto_nuggets"
# Create Hot Wallet for Nakamoto Nuggets store
# Seed: "resist camera spread better amazing cliff giraffe duty betray throw twelve father"
wallet_enabled_nakamoto_nuggets=$(curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': 'tpubDD79XF4pzhmPSJ9AyUay9YbXAeD1c6nkUqC32pnKARJH6Ja5hGUfGc76V82ahXpsKqN6UcSGXMkzR34aZq4W23C6DAdZFaVrzWqzj24F8BC' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/payment-methods/BTC-CHAIN")
# Connect Nakamoto Nuggets with Merchant LND Lightning node
curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'connectionString': 'type=lnd-rest;server=http://lnd:lnd@127.0.0.1:35531/;allowinsecure=true' }}" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/payment-methods/BTC-LN" >/dev/null 2>&1
# LNURL settings
curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'lud12Enabled': true, 'useBech32Scheme': true } }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/payment-methods/BTC-LNURL" >/dev/null 2>&1
# Add store users to Nakamoto Nuggets store
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$owner_id', 'role': 'Owner' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/users" >/dev/null 2>&1
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$manager_id', 'role': 'Manager' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/users" >/dev/null 2>&1
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$employee_id', 'role': 'Employee' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/users" >/dev/null 2>&1
# Create Nakamoto Nuggets keypad app
keypad_app_id_nakamoto_nuggets=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'appName': 'Keypad', 'title': 'Keypad', 'defaultView': 'light', 'currency': 'SATS' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/{$store_id_nakamoto_nuggets}/apps/pos" | jq -r '.id')
printf "Nakamoto Nuggets Keypad POS ID: %s\n" "$keypad_app_id_nakamoto_nuggets"
# Create Nakamoto Nuggets cart app
cart_app_id_nakamoto_nuggets=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'appName': 'Cart', 'title': 'Cart', 'defaultView': 'cart', 'template': '[{\"id\":\"birell beer\",\"image\":\"https://i.imgur.com/r8N6rTU.png\",\"priceType\":\"Fixed\",\"price\":\"20\",\"title\":\"Birell Beer\",\"disabled\":false},{\"id\":\"flavoured birell beer\",\"image\":\"https://i.imgur.com/de43iUd.png\",\"priceType\":\"Fixed\",\"price\":\"20\",\"title\":\"Flavoured Birell Beer\",\"disabled\":false},{\"id\":\"wostok\",\"image\":\"https://i.imgur.com/gP6zqub.png\",\"priceType\":\"Fixed\",\"price\":\"25\",\"title\":\"Wostok\",\"disabled\":false},{\"id\":\"pilsner beer\",\"image\":\"https://i.imgur.com/M4EEaEP.png\",\"priceType\":\"Fixed\",\"price\":\"30\",\"title\":\"Pilsner Beer\",\"disabled\":false},{\"id\":\"club mate\",\"image\":\"https://i.imgur.com/H9p9Xwc.png\",\"priceType\":\"Fixed\",\"price\":\"35\",\"title\":\"Club Mate\",\"disabled\":false},{\"id\":\"seicha / selo / koka\",\"image\":\"https://i.imgur.com/ReW3RKe.png\",\"priceType\":\"Fixed\",\"price\":\"35\",\"title\":\"Seicha / Selo / Koka\",\"disabled\":false},{\"id\":\"limonada z kopanic\",\"image\":\"https://i.imgur.com/2Xb35Zs.png\",\"priceType\":\"Fixed\",\"price\":\"40\",\"title\":\"Limonada z Kopanic\",\"disabled\":false},{\"id\":\"mellow drink\",\"image\":\"https://i.imgur.com/ilDUWiP.png\",\"priceType\":\"Fixed\",\"price\":\"40\",\"title\":\"Mellow Drink\",\"disabled\":false},{\"id\":\"bacilli drink\",\"image\":\"https://i.imgur.com/3BsCLgG.png\",\"priceType\":\"Fixed\",\"price\":\"40\",\"title\":\"Bacilli Drink\",\"disabled\":false},{\"description\":\"\",\"id\":\"vincentka\",\"image\":\"https://i.imgur.com/99reAEg.png\",\"priceType\":\"Fixed\",\"price\":\"20\",\"title\":\"Vincentka\",\"disabled\":false,\"index\":\"-1\"},{\"id\":\"kinder bar\",\"image\":\"https://i.imgur.com/va9i6SQ.png\",\"priceType\":\"Fixed\",\"price\":\"20\",\"title\":\"Kinder bar\",\"disabled\":false},{\"id\":\"nutrend bar\",\"image\":\"https://i.imgur.com/zzdIup0.png\",\"priceType\":\"Fixed\",\"price\":\"15\",\"title\":\"Nutrend bar\",\"disabled\":false},{\"id\":\"yoghurt\",\"image\":\"https://i.imgur.com/biP4Dr8.png\",\"priceType\":\"Fixed\",\"price\":\"20\",\"title\":\"Yoghurt\",\"disabled\":false},{\"id\":\"mini magnum\",\"image\":\"https://i.imgur.com/tveN4Aa.png\",\"priceType\":\"Fixed\",\"price\":\"35\",\"title\":\"Mini Magnum\",\"disabled\":false},{\"description\":\"\",\"id\":\"nanuk do:pusy\",\"image\":\"https://i.imgur.com/EzZN6lV.png\",\"priceType\":\"Fixed\",\"price\":\"30\",\"title\":\"Nanuk DO:PUSY\",\"disabled\":false,\"index\":\"-1\"},{\"id\":\"alpro dessert\",\"image\":\"https://i.imgur.com/L0MHkcs.png\",\"priceType\":\"Fixed\",\"price\":\"30\",\"title\":\"Alpro dessert\",\"disabled\":false},{\"id\":\"mixitka bar\",\"image\":\"https://i.imgur.com/gHuTGK3.png\",\"priceType\":\"Fixed\",\"price\":\"30\",\"title\":\"Mixitka bar\",\"disabled\":false},{\"id\":\"instatni polivka\",\"image\":\"https://cdn.rohlik.cz/images/grocery/products/722313/722313-1598298944.jpg\",\"priceType\":\"Fixed\",\"price\":\"15\",\"title\":\"Instatni polivka\",\"disabled\":false},{\"id\":\"m&amp;s instatni polivka\",\"image\":\"https://i.imgur.com/Y8LCJbG.png\",\"priceType\":\"Fixed\",\"price\":\"60\",\"title\":\"M&amp;S instatni polivka\",\"disabled\":false}]' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/{$store_id_nakamoto_nuggets}/apps/pos" | jq -r '.id')
printf "Nakamoto Nuggets Cart POS ID: %s\n" "$cart_app_id_nakamoto_nuggets"
# Fund Nakamoto Nuggets wallet
btcaddress_nakamoto_nuggets=$(curl -s -k -X GET -H 'Content-Type: application/json' \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/payment-methods/onchain/BTC/wallet/address" | jq -r '.address')
./docker-bitcoin-cli.sh sendtoaddress "$btcaddress_nakamoto_nuggets" 6.15 >/dev/null 2>&1
printf "\n"
# Create External Lightning based store
store_id_externalln=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'name': 'External Lightning (LND)', 'checkoutType': 'V2', 'lightningAmountInSatoshi': true, 'onChainWithLnInvoiceFallback': true }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores" | jq -r '.id')
printf "External Lightning Store ID: %s\n" "$store_id_externalln"
# Connect External Lightning based store with Customer LND Lightning node
curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'connectionString': 'type=lnd-rest;server=http://lnd:lnd@127.0.0.1:35532/;allowinsecure=true' } }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_externalln/payment-methods/BTC-LN" >/dev/null 2>&1
# LNURL settings
curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'lud12Enabled': true, 'useBech32Scheme': true } }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_externalln/payment-methods/BTC-LNURL" >/dev/null 2>&1
printf "\n"
# Mine some blocks
./docker-bitcoin-generate.sh 5 >/dev/null 2>&1

@ -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>
@ -51,8 +63,8 @@
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.6.0" />
<PackageReference Include="CsvHelper" Version="32.0.3" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.1.28" />
<PackageReference Include="Fido2" Version="2.0.2" />
<PackageReference Include="Fido2.AspNet" Version="2.0.2" />
<PackageReference Include="LNURL" Version="0.0.34" />
@ -77,8 +89,8 @@
<PackageReference Include="TwentyTwenty.Storage.Azure" Version="2.12.1" />
<PackageReference Include="TwentyTwenty.Storage.Google" Version="2.12.1" />
<PackageReference Include="TwentyTwenty.Storage.Local" Version="2.12.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.1" />
</ItemGroup>
<ItemGroup>

@ -34,36 +34,34 @@
}
else if (Model.Invoices.Any())
{
<div class="table-responsive mt-3 mb-0">
<table class="table table-hover mb-0">
<thead>
<table class="table table-hover mt-3 mb-0">
<thead>
<tr>
<th class="w-125px">Date</th>
<th class="text-nowrap">Invoice Id</th>
<th>Status</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
@foreach (var invoice in Model.Invoices)
{
<tr>
<th class="w-125px">Date</th>
<th class="text-nowrap">Invoice Id</th>
<th>Status</th>
<th class="text-end">Amount</th>
<td>@invoice.Date.ToTimeAgo()</td>
<td>
<a asp-controller="UIInvoice" asp-action="Invoice" asp-route-invoiceId="@invoice.InvoiceId" class="text-break">@invoice.InvoiceId</a>
</td>
<td>
<vc:invoice-status state="invoice.Status" payments="invoice.Details.Payments" invoice-id="@invoice.InvoiceId"
is-archived="invoice.Details.Archived" has-refund="invoice.HasRefund" />
</td>
<td class="text-end">
<span data-sensitive>@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</span>
</td>
</tr>
</thead>
<tbody>
@foreach (var invoice in Model.Invoices)
{
<tr>
<td>@invoice.Date.ToTimeAgo()</td>
<td>
<a asp-controller="UIInvoice" asp-action="Invoice" asp-route-invoiceId="@invoice.InvoiceId" class="text-break">@invoice.InvoiceId</a>
</td>
<td>
<vc:invoice-status state="invoice.Status" payments="invoice.Details.Payments" invoice-id="@invoice.InvoiceId"
is-archived="invoice.Details.Archived" has-refund="invoice.HasRefund" />
</td>
<td class="text-end">
<span data-sensitive>@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</tbody>
</table>
}
else
{

@ -31,63 +31,61 @@
}
else if (Model.Transactions.Any())
{
<div class="table-responsive mt-3 mb-0">
<table class="table table-hover mb-0">
<thead>
<table class="table table-hover mt-3 mb-0">
<thead>
<tr>
<th class="w-125px">Date</th>
<th>Transaction</th>
<th>Labels</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
@foreach (var tx in Model.Transactions)
{
<tr>
<th class="w-125px">Date</th>
<th>Transaction</th>
<th>Labels</th>
<th class="text-end">Amount</th>
<td>@tx.Timestamp.ToTimeAgo()</td>
<td>
<vc:truncate-center text="@tx.Id" link="@tx.Link" classes="truncate-center-id" />
</td>
<td>
@if (tx.Labels.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in tx.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span>
@if (!string.IsNullOrEmpty(label.Link))
{
<a class="transaction-label-info transaction-details-icon" href="@label.Link"
target="_blank" rel="noreferrer noopener" title="@label.Tooltip" data-bs-html="true"
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info" />
</a>
}
</div>
}
</div>
}
</td>
@if (tx.Positive)
{
<td class="text-end text-success">
<span data-sensitive>@DisplayFormatter.Currency(tx.Balance, tx.Currency)</span>
</td>
}
else
{
<td class="text-end text-danger">
<span data-sensitive>@DisplayFormatter.Currency(tx.Balance, tx.Currency)</span>
</td>
}
</tr>
</thead>
<tbody>
@foreach (var tx in Model.Transactions)
{
<tr>
<td>@tx.Timestamp.ToTimeAgo()</td>
<td>
<vc:truncate-center text="@tx.Id" link="@tx.Link" classes="truncate-center-id" />
</td>
<td>
@if (tx.Labels.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in tx.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span>
@if (!string.IsNullOrEmpty(label.Link))
{
<a class="transaction-label-info transaction-details-icon" href="@label.Link"
target="_blank" rel="noreferrer noopener" title="@label.Tooltip" data-bs-html="true"
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info" />
</a>
}
</div>
}
</div>
}
</td>
@if (tx.Positive)
{
<td class="text-end text-success">
<span data-sensitive>@DisplayFormatter.Currency(tx.Balance, tx.Currency)</span>
</td>
}
else
{
<td class="text-end text-danger">
<span data-sensitive>@DisplayFormatter.Currency(tx.Balance, tx.Currency)</span>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</tbody>
</table>
}
else
{

@ -4,23 +4,23 @@
var isTruncated = !string.IsNullOrEmpty(Model.Start) && !string.IsNullOrEmpty(Model.End);
@if (Model.Copy) classes += " truncate-center--copy";
@if (Model.Elastic) classes += " truncate-center--elastic";
var prefix = Model.IsVue ? ":" : "";
}
<span class="truncate-center @classes" id="@Model.Id" data-text="@Model.Text">
<span class="truncate-center @classes"@(!string.IsNullOrEmpty(Model.Id) ? $"id={Model.Id}" : null) data-text=@Safe.Json(Model.Text)>
@if (Model.IsVue)
{
<span class="truncate-center-truncated" data-bs-toggle="tooltip" :title="@Model.Text">
<span class="truncate-center-truncated" data-bs-toggle="tooltip" :title=@Safe.Json(Model.Text)>
@if (Model.Elastic)
{
<span class="truncate-center-start" v-text="@Model.Text"></span>
<span class="truncate-center-start" v-text=@Safe.Json(Model.Text)></span>
}
else
{
<span class="truncate-center-start" v-text="@(Model.Text).length > 2 * @(Model.Padding) ? (@(Model.Text).slice(0, @(Model.Padding)) + '…') : @(Model.Text)"></span>
<span class="truncate-center-start" v-text=@Safe.Json($"{Model.Text}.slice(0, {Model.Padding})")></span>
<span>…</span>
}
<span class="truncate-center-end" v-text="@(Model.Text).slice(-@(Model.Padding))" v-if="@(Model.Text).length > 2 * @(Model.Padding)"></span>
<span class="truncate-center-end" v-text=@Safe.Json($"{Model.Text}.slice(-{Model.Padding})")></span>
</span>
<span class="truncate-center-text" v-text="@Model.Text"></span>
<span class="truncate-center-text" v-text=@Safe.Json(Model.Text)></span>
}
else
{
@ -35,13 +35,13 @@
}
@if (Model.Copy)
{
<button type="button" class="btn btn-link p-0" @(prefix)data-clipboard="@Model.Text">
<button type="button" class="btn btn-link p-0" @(Model.IsVue ? ":" : string.Empty)data-clipboard=@Safe.Json(Model.Text)>
<vc:icon symbol="copy" />
</button>
}
@if (!string.IsNullOrEmpty(Model.Link))
{
<a @(prefix)href="@Model.Link" rel="noreferrer noopener" target="_blank">
<a @(Model.IsVue ? ":" : "")href="@Model.Link" rel="noreferrer noopener" target="_blank">
<vc:icon symbol="info" />
</a>
}

@ -213,7 +213,6 @@ namespace BTCPayServer.Controllers.Greenfield
}
[HttpDelete("~/api/v1/apps/{appId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> DeleteApp(string appId)
{
var app = await _appService.GetApp(appId, null, includeArchived: true);

@ -401,14 +401,6 @@ namespace BTCPayServer.Controllers.Greenfield
var accounting = invoicePaymentMethod.Calculate();
var cryptoPaid = accounting.Paid;
var dueAmount = accounting.TotalDue;
// If no payment, but settled and marked, assume it has been fully paid
if (cryptoPaid is 0 && invoice is { Status: InvoiceStatusLegacy.Confirmed or InvoiceStatusLegacy.Complete, ExceptionStatus: InvoiceExceptionStatus.Marked })
{
cryptoPaid = accounting.TotalDue;
dueAmount = 0;
}
var cdCurrency = _currencyNameTable.GetCurrencyData(invoice.Currency, true);
var paidCurrency = Math.Round(cryptoPaid * invoicePaymentMethod.Rate, cdCurrency.Divisibility);
var rateResult = await _rateProvider.FetchRate(
@ -476,6 +468,7 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState);
}
var dueAmount = accounting.TotalDue;
createPullPayment.Currency = cryptoCode;
createPullPayment.Amount = Math.Round(paidAmount - dueAmount, appliedDivisibility);
createPullPayment.AutoApproveClaims = true;

@ -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;
@ -199,7 +197,6 @@ namespace BTCPayServer.Controllers.Greenfield
if (pp is null)
return PullPaymentNotFound();
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
BoltcardPICCData? picc = null;
// LNURLW is used by deeplinks
if (request?.LNURLW is not null)
@ -215,12 +212,11 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW should contains a 'p=' parameter");
return this.CreateValidationError(ModelState);
}
if (issuerKey.TryDecrypt(p) is not BoltcardPICCData o)
if (issuerKey.TryDecrypt(p) is not BoltcardPICCData picc)
{
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW 'p=' parameter cannot be decrypted");
return this.CreateValidationError(ModelState);
}
picc = o;
request.UID = picc.Uid;
}
@ -242,54 +238,8 @@ namespace BTCPayServer.Controllers.Greenfield
_ => request.OnExisting
};
BoltcardKeys keys;
int version;
var registration = await _dbContextFactory.GetBoltcardRegistration(issuerKey, request.UID);
if (request.OnExisting is OnExistingBehavior.UpdateVersion ||
(request.OnExisting is null && registration?.PullPaymentId is null))
{
version = await _dbContextFactory.LinkBoltcardToPullPayment(pullPaymentId, issuerKey, request.UID, request.OnExisting);
keys = issuerKey.CreatePullPaymentCardKey(request.UID, version, pullPaymentId).DeriveBoltcardKeys(issuerKey);
}
else
{
if (registration?.PullPaymentId is null)
{
ModelState.AddModelError(nameof(request.UID), "This card isn't registered");
return this.CreateValidationError(ModelState);
}
if (pullPaymentId != registration.PullPaymentId)
{
ModelState.AddModelError(nameof(request.UID), "This card is registered on a different pull payment");
return this.CreateValidationError(ModelState);
}
var ppId = registration.PullPaymentId;
version = registration.Version;
int retryCount = 0;
retry:
keys = issuerKey.CreatePullPaymentCardKey(request!.UID, version, ppId).DeriveBoltcardKeys(issuerKey);
// The server version may be higher than the card.
// If that is the case, let's try a few versions until we find the right one
// by checking c.
if (request?.LNURLW is { } lnurlw &&
ExtractC(lnurlw) is string c &&
picc is not null)
{
if (!keys.AuthenticationKey.CheckSunMac(c, picc))
{
retryCount++;
version--;
if (version < 0 || retryCount > 5)
{
ModelState.AddModelError(nameof(request.UID), "Unable to get keys of this card, it might be caused by a version mismatch");
return this.CreateValidationError(ModelState);
}
goto retry;
}
}
}
var version = await _dbContextFactory.LinkBoltcardToPullPayment(pullPaymentId, issuerKey, request.UID, request.OnExisting);
var keys = issuerKey.CreatePullPaymentCardKey(request.UID, version, pullPaymentId).DeriveBoltcardKeys(issuerKey);
var boltcardUrl = Url.Action(nameof(UIBoltcardController.GetWithdrawRequest), "UIBoltcard");
boltcardUrl = Request.GetAbsoluteUri(boltcardUrl);
@ -308,7 +258,7 @@ retry:
return Ok(resp);
}
public static string? Extract(string? url, string param, int size)
private string? ExtractP(string? url)
{
if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out var uri))
return null;
@ -316,13 +266,11 @@ retry:
if (num == -1)
return null;
string input = uri.AbsoluteUri.Substring(num);
Match match = Regex.Match(input, param + "=([a-f0-9A-F]{" + size + "})");
Match match = Regex.Match(input, "p=([a-f0-9A-F]{32})");
if (!match.Success)
return null;
return match.Groups[1].Value;
}
public static string? ExtractP(string? url) => Extract(url, "p", 32);
public static string? ExtractC(string? url) => Extract(url, "c", 16);
[HttpGet("~/api/v1/pull-payments/{pullPaymentId}")]
[AllowAnonymous]

@ -1,5 +1,4 @@
#nullable enable
using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
@ -9,8 +8,6 @@ using BTCPayServer.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using AuthenticationSchemes = BTCPayServer.Abstractions.Constants.AuthenticationSchemes;
using LightningAddressData = BTCPayServer.Client.Models.LightningAddressData;
@ -34,13 +31,12 @@ namespace BTCPayServer.Controllers.Greenfield
var blob = data.GetBlob();
if (blob is null)
return new LightningAddressData();
return new LightningAddressData
return new LightningAddressData()
{
Username = data.Username,
Max = blob.Max,
Min = blob.Min,
CurrencyCode = blob.CurrencyCode,
InvoiceMetadata = blob.InvoiceMetadata
CurrencyCode = blob.CurrencyCode
};
}
@ -87,17 +83,16 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(data.Min), "Minimum must be greater than 0 if provided.");
return this.CreateValidationError(ModelState);
}
if (await _lightningAddressService.Set(new Data.LightningAddressData
if (await _lightningAddressService.Set(new Data.LightningAddressData()
{
StoreDataId = storeId,
Username = username
}.SetBlob(new LightningAddressDataBlob
}.SetBlob(new LightningAddressDataBlob()
{
Max = data.Max,
Min = data.Min,
CurrencyCode = data.CurrencyCode,
InvoiceMetadata = data.InvoiceMetadata
CurrencyCode = data.CurrencyCode
})))
{
return await GetStoreLightningAddress(storeId, username);

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

@ -162,10 +162,10 @@ namespace BTCPayServer.Controllers
model.Overpaid = details.Overpaid;
model.StillDue = details.StillDue;
model.HasRates = details.HasRates;
if (additionalData.TryGetValue("receiptData", out object? receiptData))
if (additionalData.ContainsKey("receiptData"))
{
model.ReceiptData = (Dictionary<string, object>)receiptData;
model.ReceiptData = (Dictionary<string, object>)additionalData["receiptData"];
additionalData.Remove("receiptData");
}
@ -213,7 +213,7 @@ namespace BTCPayServer.Controllers
{
InvoiceId = i.Id,
OrderId = i.Metadata?.OrderId,
RedirectUrl = i.RedirectURL?.AbsoluteUri ?? i.Metadata?.OrderUrl,
OrderUrl = i.Metadata?.OrderUrl,
Status = i.Status.ToModernStatus(),
Currency = i.Currency,
Timestamp = i.InvoiceTime,
@ -226,42 +226,15 @@ namespace BTCPayServer.Controllers
{
return View(vm);
}
var metaData = PosDataParser.ParsePosData(i.Metadata?.ToJObject());
var additionalData = metaData
.Where(dict => !InvoiceAdditionalDataExclude.Contains(dict.Key))
.ToDictionary(dict => dict.Key, dict => dict.Value);
// Split receipt data into cart and additional data
if (additionalData.TryGetValue("receiptData", out object? combinedReceiptData))
{
var receiptData = new Dictionary<string, object>((Dictionary<string, object>)combinedReceiptData, StringComparer.OrdinalIgnoreCase);
string[] cartKeys = ["cart", "subtotal", "discount", "tip", "total"];
// extract cart data and lowercase keys to handle data uniformly in PosData partial
if (receiptData.Keys.Any(key => cartKeys.Contains(key.ToLowerInvariant())))
{
vm.CartData = new Dictionary<string, object>();
foreach (var key in cartKeys)
{
if (!receiptData.ContainsKey(key)) continue;
// add it to cart data and remove it from the general data
vm.CartData.Add(key.ToLowerInvariant(), receiptData[key]);
receiptData.Remove(key);
}
}
// assign the rest to additional data and remove empty values
if (receiptData.Any())
{
vm.AdditionalData = receiptData
.Where(x => !string.IsNullOrEmpty(x.Value.ToString()))
.ToDictionary(x => x.Key, x => x.Value);
}
}
JToken? receiptData = null;
i.Metadata?.AdditionalData?.TryGetValue("receiptData", out receiptData);
var payments = ViewPaymentRequestViewModel.PaymentRequestInvoicePayment.GetViewModels(i, _displayFormatter, _transactionLinkProviders);
vm.Amount = i.PaidAmount.Net;
vm.Payments = receipt.ShowPayments is false ? null : payments;
vm.AdditionalData = PosDataParser.ParsePosData(receiptData);
return View(print ? "InvoiceReceiptPrint" : "InvoiceReceipt", vm);
}
@ -368,11 +341,6 @@ namespace BTCPayServer.Controllers
accounting = paymentMethod.Calculate();
cryptoPaid = accounting.Paid;
dueAmount = accounting.TotalDue;
if (cryptoPaid is 0 && invoice is { Status: InvoiceStatusLegacy.Confirmed or InvoiceStatusLegacy.Complete, ExceptionStatus: InvoiceExceptionStatus.Marked })
{
cryptoPaid = accounting.TotalDue;
dueAmount = 0;
}
paidAmount = cryptoPaid.RoundToSignificant(appliedDivisibility);
}
@ -1105,7 +1073,7 @@ namespace BTCPayServer.Controllers
storeIds.Add(i);
}
model.Search = fs;
model.SearchText = fs.TextCombined;
model.SearchText = fs.TextSearch;
var apps = await _appService.GetAllApps(GetUserId(), false, storeId);
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, apps, timezoneOffset);

@ -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;
@ -90,14 +91,11 @@ namespace BTCPayServer
_pluginHookService = pluginHookService;
_invoiceActivator = invoiceActivator;
}
[EnableCors(CorsPolicies.All)]
[HttpGet("withdraw/pp/{pullPaymentId}")]
public Task<IActionResult> GetLNURLForPullPayment(string cryptoCode, string pullPaymentId, [FromQuery] string pr, CancellationToken cancellationToken)
{
return GetLNURLForPullPayment(cryptoCode, pullPaymentId, pr, pullPaymentId, cancellationToken);
}
[NonAction]
internal async Task<IActionResult> GetLNURLForPullPayment(string cryptoCode, string pullPaymentId, string pr, string k1, CancellationToken cancellationToken)
{
@ -299,11 +297,11 @@ namespace BTCPayServer
return NotFound();
}
var createInvoice = new CreateInvoiceRequest
var createInvoice = new CreateInvoiceRequest()
{
Amount = item?.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? null : item?.Price,
Amount = item?.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup? null: item?.Price,
Currency = currencyCode,
Checkout = new InvoiceDataBase.CheckoutOptions
Checkout = new InvoiceDataBase.CheckoutOptions()
{
RedirectURL = app.AppType switch
{
@ -315,7 +313,6 @@ namespace BTCPayServer
AdditionalSearchTerms = new[] { AppService.GetAppSearchTerm(app) }
};
var allowOverpay = item?.PriceType is not ViewPointOfSaleViewModel.ItemPriceType.Fixed;
var invoiceMetadata = new InvoiceMetadata { OrderId = AppService.GetRandomOrderId() };
if (item != null)
{
@ -330,7 +327,7 @@ namespace BTCPayServer
store.GetStoreBlob(),
createInvoice,
additionalTags: new List<string> { AppService.GetAppInternalTag(appId) },
allowOverpay: allowOverpay);
allowOverpay: false);
}
public class EditLightningAddressVM
@ -440,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,
@ -457,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();
@ -499,7 +503,7 @@ namespace BTCPayServer
});
}
public async Task<IActionResult> GetLNURLRequest(
internal async Task<IActionResult> GetLNURLRequest(
string cryptoCode,
Data.StoreData store,
Data.StoreBlob blob,
@ -526,9 +530,7 @@ namespace BTCPayServer
return this.CreateAPIError(null, e.Message);
}
lnurlRequest = await CreateLNUrlRequestFromInvoice(cryptoCode, i, store, blob, lnurlRequest, lnUrlMetadata, allowOverpay);
return lnurlRequest is null
? BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Unable to create LNURL request." })
: Ok(lnurlRequest);
return lnurlRequest is null ? NotFound() : Ok(lnurlRequest);
}
private async Task<LNURLPayRequest> CreateLNUrlRequestFromInvoice(

@ -227,7 +227,6 @@ namespace BTCPayServer.Controllers
var supported = ppBlob.SupportedPaymentMethods;
PaymentMethodId paymentMethodId = null;
IClaimDestination destination = null;
string error = null;
if (string.IsNullOrEmpty(vm.SelectedPaymentMethod))
{
foreach (var pmId in supported)
@ -236,7 +235,6 @@ namespace BTCPayServer.Controllers
(IClaimDestination dst, string err) = handler == null
? (null, "No payment handler found for this payment method")
: await handler.ParseAndValidateClaimDestination(pmId, vm.Destination, ppBlob, cancellationToken);
error = err;
if (dst is not null && err is null)
{
paymentMethodId = pmId;
@ -249,15 +247,12 @@ namespace BTCPayServer.Controllers
{
paymentMethodId = supported.FirstOrDefault(id => vm.SelectedPaymentMethod == id.ToString());
var payoutHandler = paymentMethodId is null ? null : _payoutHandlers.FindPayoutHandler(paymentMethodId);
if (payoutHandler is not null)
{
(destination, error) = await payoutHandler.ParseAndValidateClaimDestination(paymentMethodId, vm.Destination, ppBlob, cancellationToken);
}
destination = payoutHandler is null ? null : (await payoutHandler.ParseAndValidateClaimDestination(paymentMethodId, vm.Destination, ppBlob, cancellationToken)).destination;
}
if (destination is null)
{
ModelState.AddModelError(nameof(vm.Destination), error ?? "Invalid destination or payment method");
ModelState.AddModelError(nameof(vm.Destination), "Invalid destination or payment method");
return await ViewPullPayment(pullPaymentId);
}

@ -64,27 +64,7 @@ public partial class UIReportsController
decimal randomValue = ((decimal)rand.NextDouble() * range) + from;
return decimal.Round(randomValue, precision);
}
JObject GetFormattedAmount()
{
string? curr = null;
decimal value = 0m;
int offset = 0;
while (curr is null)
{
curr = row[fi - 1 - offset]?.ToString();
value = curr switch
{
"USD" or "EUR" or "CHF" => GenerateDecimal(30_000m, 60_000, 2),
"JPY" => GenerateDecimal(400_0000m, 1000_0000m, 0),
_ => 0.0m
};
if (value != 0.0m)
break;
curr = null;
offset++;
}
return DisplayFormatter.ToFormattedAmount(value, curr);
}
var fiatCurrency = rand.NextSingle() > 0.2 ? "USD" : TakeOne("JPY", "EUR", "CHF");
var cryptoCurrency = rand.NextSingle() > 0.2 ? "BTC" : TakeOne("LTC", "DOGE", "DASH");
@ -136,11 +116,14 @@ public partial class UIReportsController
return Encoders.Hex.EncodeData(GenerateBytes(32));
if (f.Name == "Rate")
{
return GetFormattedAmount();
}
if (f.Type == "amount")
{
return GetFormattedAmount();
var curr = row[fi - 1]?.ToString();
var value = curr switch
{
"USD" or "EUR" or "CHF" => GenerateDecimal(30_000m, 60_000, 2),
"JPY" => GenerateDecimal(400_0000m, 1000_0000m, 0),
_ => GenerateDecimal(30_000m, 60_000, 2)
};
return DisplayFormatter.ToFormattedAmount(value, curr);
}
return null;
}

@ -195,11 +195,10 @@ namespace BTCPayServer.Controllers
if (store == null)
return NotFound();
ViewBag.UseCustomSMTP = useCustomSMTP;
model.FallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
? await storeSender.FallbackSender.GetEmailSettings()
: null;
if (model.FallbackSettings is null) useCustomSMTP = true;
ViewBag.UseCustomSMTP = useCustomSMTP;
if (useCustomSMTP)
{
model.Settings.Validate("Settings.", ModelState);

@ -151,12 +151,13 @@ namespace BTCPayServer.Controllers
WalletId walletId, WalletPSBTViewModel vm, string command = null)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork);
vm.BackUrl ??= HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath;
if (psbt is null || vm.InvalidPSBT)
{
ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT");
return View("WalletSigningOptions", new WalletSigningOptionsModel
{
SigningContext = vm.SigningContext,
@ -240,9 +241,10 @@ namespace BTCPayServer.Controllers
vm.NBXSeedAvailable = await CanUseHotWallet() && derivationSchemeSettings.IsHotWallet;
vm.BackUrl ??= HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath;
var psbt = await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork);
if (vm.InvalidPSBT)
{
ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT");
return View(vm);
}
if (psbt is null)
@ -475,7 +477,7 @@ namespace BTCPayServer.Controllers
WalletId walletId, WalletPSBTViewModel vm, string command, CancellationToken cancellationToken = default)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
PSBT psbt = await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
PSBT psbt = await vm.GetPSBT(network.NBitcoinNetwork);
if (vm.InvalidPSBT || psbt is null)
{
if (vm.InvalidPSBT)
@ -635,14 +637,16 @@ namespace BTCPayServer.Controllers
WalletId walletId, WalletPSBTCombineViewModel vm)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork);
if (psbt == null)
{
ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT");
return View(vm);
}
var sourcePSBT = vm.GetSourcePSBT(network.NBitcoinNetwork, ModelState);
if (sourcePSBT is null)
var sourcePSBT = vm.GetSourcePSBT(network.NBitcoinNetwork);
if (sourcePSBT == null)
{
ModelState.AddModelError(nameof(vm.OtherPSBT), "Invalid PSBT");
return View(vm);
}
sourcePSBT = sourcePSBT.Combine(psbt);

@ -738,7 +738,7 @@ namespace BTCPayServer.Controllers
foreach (var transactionOutput in vm.Outputs.Where(output => output.Labels?.Any() is true))
{
var labels = transactionOutput.Labels.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
var walletObjectAddress = new WalletObjectId(walletId, WalletObjectData.Types.Address, transactionOutput.DestinationAddress);
var walletObjectAddress = new WalletObjectId(walletId, WalletObjectData.Types.Address, transactionOutput.DestinationAddress.ToLowerInvariant());
var obj = await WalletRepository.GetWalletObject(walletObjectAddress);
if (obj is null)
{

@ -11,7 +11,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
Bolt11 = bolt11 ?? throw new ArgumentNullException(nameof(bolt11));
PaymentRequest = paymentRequest;
PaymentHash = paymentRequest.Hash;
Amount = paymentRequest.MinimumAmount.MilliSatoshi == LightMoney.Zero ? null: paymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
Amount = paymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
}
public override string ToString()

@ -73,11 +73,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var t = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, cancellationToken);
var rawInfo = await LNURL.LNURL.FetchInformation(lnurl, CreateClient(lnurl), t.Token);
if(rawInfo is null)
return (null, "The LNURL / Lightning Address provided was not online.");
if(rawInfo is not LNURLPayRequest info)
return (null, "The LNURL was not a valid LNURL Pay request.");
var info = (LNURLPayRequest)(await LNURL.LNURL.FetchInformation(lnurl, CreateClient(lnurl), t.Token));
lnurlTag = info.Tag;
}
@ -147,27 +143,9 @@ namespace BTCPayServer.Data.Payouts.LightningLike
return Task.CompletedTask;
}
public async Task<decimal> GetMinimumPayoutAmount(PaymentMethodId paymentMethod, IClaimDestination claimDestination)
public Task<decimal> GetMinimumPayoutAmount(PaymentMethodId paymentMethodId, IClaimDestination claimDestination)
{
if(claimDestination is LNURLPayClaimDestinaton lnurlPayClaimDestinaton)
{
try
{
var lnurl = lnurlPayClaimDestinaton.LNURL.IsValidEmail()
? LNURL.LNURL.ExtractUriFromInternetIdentifier(lnurlPayClaimDestinaton.LNURL)
: LNURL.LNURL.Parse(lnurlPayClaimDestinaton.LNURL, out var lnurlTag);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var rawInfo = await LNURL.LNURL.FetchInformation(lnurl, CreateClient(lnurl), timeout.Token);
if (rawInfo is LNURLPayRequest info)
return info.MinSendable.ToDecimal(LightMoneyUnit.BTC);
}
catch
{
// ignored
}
}
return Money.Satoshis(1).ToDecimal(MoneyUnit.BTC);
return Task.FromResult(Money.Satoshis(1).ToDecimal(MoneyUnit.BTC));
}
public Dictionary<PayoutState, List<(string Action, string Text)>> GetPayoutSpecificActions()

@ -244,15 +244,13 @@ namespace BTCPayServer.Data.Payouts.LightningLike
var lm = new LightMoney(blob.CryptoAmount.Value, LightMoneyUnit.BTC);
if (lm > lnurlInfo.MaxSendable || lm < lnurlInfo.MinSendable)
{
payoutData.State = PayoutState.Cancelled;
return (null, new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message =
$"The LNURL provided would not generate an invoice of {lm.ToDecimal(LightMoneyUnit.Satoshi)} sats"
$"The LNURL provided would not generate an invoice of {lm.MilliSatoshi}msats"
});
}

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using NBitcoin;
using NBitcoin.Scripting;
using NBXplorer.DerivationStrategy;
@ -101,7 +100,6 @@ namespace BTCPayServer
{
ArgumentNullException.ThrowIfNull(str);
str = str.Trim();
str = Regex.Replace(str, @"\s+", "");
HashSet<string> hintedLabels = new HashSet<string>();
if (!Network.Consensus.SupportSegwit)
{

@ -37,11 +37,11 @@ namespace BTCPayServer.Filters
}
// If we have an appId, we can redirect to the canonical domain
if ((string)context.RouteContext.RouteData.Values["appId"] is { } appId)
if ((string)context.RouteContext.RouteData.Values["appId"] is { } appId && !req.IsOnion())
{
var redirectDomain = mapping.FirstOrDefault(item => item.AppId == appId)?.Domain;
// App is accessed via path, redirect to canonical domain
if (!string.IsNullOrEmpty(redirectDomain) && req.Method != "POST" && !req.HasFormContentType && !req.IsOnion())
if (!string.IsNullOrEmpty(redirectDomain) && req.Method != "POST" && !req.HasFormContentType)
{
var uri = new UriBuilder(req.Scheme, redirectDomain);
if (req.Host.Port.HasValue)

@ -109,6 +109,7 @@ namespace BTCPayServer.HostedServices
}
}
}
public Task<string> CreatePullPayment(string storeId, CreatePullPaymentRequest request)
{
return CreatePullPayment(new CreatePullPayment()
@ -291,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; }
@ -341,11 +344,11 @@ namespace BTCPayServer.HostedServices
{
payoutHandler.StartBackgroundCheck(Subscribe);
}
_eventAggregator.Subscribe<Events.InvoiceEvent>(TopUpInvoiceCore);
_eventAggregator.Subscribe<Events.InvoiceEvent>(TopUpInvoice);
return new[] { Loop() };
}
private void TopUpInvoiceCore(InvoiceEvent evt)
private void TopUpInvoice(InvoiceEvent evt)
{
if (evt.EventCode == InvoiceEventCode.Completed || evt.EventCode == InvoiceEventCode.MarkedCompleted)
{
@ -355,7 +358,6 @@ namespace BTCPayServer.HostedServices
}
}
}
private void Subscribe(params Type[] events)
{
foreach (Type @event in events)
@ -372,7 +374,6 @@ namespace BTCPayServer.HostedServices
{
await HandleTopUp(topUp);
}
if (o is PayoutRequest req)
{
await HandleCreatePayout(req);
@ -410,7 +411,6 @@ namespace BTCPayServer.HostedServices
private async Task HandleTopUp(TopUpRequest topUp)
{
var pp = await this.GetPullPayment(topUp.PullPaymentId, false);
var currency = pp.GetBlob().Currency;
using var ctx = _dbContextFactory.CreateContext();
var payout = new Data.PayoutData()
@ -423,11 +423,15 @@ namespace BTCPayServer.HostedServices
Destination = null,
StoreDataId = pp.StoreId
};
if (topUp.InvoiceEntity.Currency != currency ||
currency is not ("SATS" or "BTC"))
return;
var paidAmount = topUp.InvoiceEntity.Price;
var cryptoAmount = paidAmount;
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()
{
@ -701,7 +705,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);

@ -304,7 +304,6 @@ namespace BTCPayServer.Hosting
});
services.TryAddSingleton<BTCPayNetworkProvider>();
services.AddExceptionHandler<PluginExceptionHandler>();
services.TryAddSingleton<AppService>();
services.AddTransient<PluginService>();
services.AddSingleton<PluginHookService>();
@ -348,8 +347,6 @@ namespace BTCPayServer.Hosting
htmlSanitizer.AllowedAttributes.Add("webkitallowfullscreen");
htmlSanitizer.AllowedAttributes.Add("allowfullscreen");
htmlSanitizer.AllowedSchemes.Add("mailto");
htmlSanitizer.AllowedSchemes.Add("bitcoin");
htmlSanitizer.AllowedSchemes.Add("lightning");
return htmlSanitizer;
});
@ -388,7 +385,6 @@ namespace BTCPayServer.Hosting
services.AddReportProvider<ProductsReportProvider>();
services.AddReportProvider<PayoutsReportProvider>();
services.AddReportProvider<LegacyInvoiceExportReportProvider>();
services.AddReportProvider<RefundsReportProvider>();
services.AddWebhooks();
services.AddSingleton<BitcoinLikePayoutHandler>();
services.AddSingleton<IPayoutHandler>(provider => provider.GetRequiredService<BitcoinLikePayoutHandler>());

@ -293,7 +293,6 @@ namespace BTCPayServer.Hosting
app.UseStatusCodePagesWithReExecute("/errors/{0}");
app.UseExceptionHandler("/errors/{0}");
app.UsePayServer();
app.UseRouting();
app.UseCors();

@ -17,9 +17,8 @@ namespace BTCPayServer.Models.InvoicingModels
public decimal Amount { get; set; }
public DateTimeOffset Timestamp { get; set; }
public Dictionary<string, object> AdditionalData { get; set; }
public Dictionary<string, object> CartData { get; set; }
public ReceiptOptions ReceiptOptions { get; set; }
public List<ViewPaymentRequestViewModel.PaymentRequestInvoicePayment> Payments { get; set; }
public string RedirectUrl { get; set; }
public string OrderUrl { get; set; }
}
}

@ -2,7 +2,6 @@ using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels
@ -18,7 +17,7 @@ namespace BTCPayServer.Models.WalletViewModels
public string BackUrl { get; set; }
public string ReturnUrl { get; set; }
public PSBT GetSourcePSBT(Network network, ModelStateDictionary modelState)
public PSBT GetSourcePSBT(Network network)
{
if (!string.IsNullOrEmpty(OtherPSBT))
{
@ -26,12 +25,12 @@ namespace BTCPayServer.Models.WalletViewModels
{
return NBitcoin.PSBT.Parse(OtherPSBT, network);
}
catch (Exception ex)
{ modelState.AddModelError(nameof(OtherPSBT), ex.Message); }
catch
{ }
}
return null;
}
public async Task<PSBT> GetPSBT(Network network, ModelStateDictionary modelState)
public async Task<PSBT> GetPSBT(Network network)
{
if (UploadedPSBTFile != null)
{
@ -46,9 +45,8 @@ namespace BTCPayServer.Models.WalletViewModels
{
return NBitcoin.PSBT.Load(bytes, network);
}
catch (FormatException ex)
catch
{
modelState.AddModelError(nameof(UploadedPSBTFile), ex.Message);
return null;
}
}
@ -58,10 +56,8 @@ namespace BTCPayServer.Models.WalletViewModels
{
return NBitcoin.PSBT.Parse(PSBT, network);
}
catch (FormatException ex)
{
modelState.AddModelError(nameof(UploadedPSBTFile), ex.Message);
}
catch
{ }
}
return null;
}

@ -4,7 +4,6 @@ using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels
@ -36,9 +35,9 @@ namespace BTCPayServer.Models.WalletViewModels
public IFormFile UploadedPSBTFile { get; set; }
public async Task<PSBT> GetPSBT(Network network, ModelStateDictionary modelState)
public async Task<PSBT> GetPSBT(Network network)
{
var psbt = await GetPSBTCore(network, modelState);
var psbt = await GetPSBTCore(network);
if (psbt != null)
{
Decoded = psbt.ToString();
@ -53,7 +52,7 @@ namespace BTCPayServer.Models.WalletViewModels
}
public bool InvalidPSBT { get; set; }
async Task<PSBT> GetPSBTCore(Network network, ModelStateDictionary modelState)
async Task<PSBT> GetPSBTCore(Network network)
{
if (UploadedPSBTFile != null)
{
@ -69,20 +68,16 @@ namespace BTCPayServer.Models.WalletViewModels
}
return NBitcoin.PSBT.Load(bytes, network);
}
catch (Exception ex)
catch (Exception)
{
using var stream = new StreamReader(UploadedPSBTFile.OpenReadStream());
PSBT = await stream.ReadToEndAsync();
modelState.Remove(nameof(PSBT));
modelState.AddModelError(nameof(PSBT), ex.Message);
InvalidPSBT = true;
}
}
if (SigningContext != null && !string.IsNullOrEmpty(SigningContext.PSBT))
{
PSBT = SigningContext.PSBT;
modelState.Remove(nameof(PSBT));
InvalidPSBT = false;
}
if (!string.IsNullOrEmpty(PSBT))
{
@ -91,11 +86,8 @@ namespace BTCPayServer.Models.WalletViewModels
InvalidPSBT = false;
return NBitcoin.PSBT.Parse(PSBT, network);
}
catch (Exception ex) when (!InvalidPSBT)
{
modelState.AddModelError(nameof(PSBT), ex.Message);
InvalidPSBT = true;
}
catch
{ InvalidPSBT = true; }
}
return null;
}

@ -1,10 +0,0 @@
using Newtonsoft.Json;
namespace BTCPayServer.Services.Altcoins.Monero.RPC.Models
{
public partial class OpenWalletErrorResponse
{
[JsonProperty("code")] public int Code { get; set; }
[JsonProperty("message")] public string Message { get; set; }
}
}

@ -1,10 +0,0 @@
using Newtonsoft.Json;
namespace BTCPayServer.Services.Altcoins.Monero.RPC.Models
{
public partial class OpenWalletRequest
{
[JsonProperty("filename")] public string Filename { get; set; }
[JsonProperty("password")] public string Password { get; set; }
}
}

@ -1,12 +0,0 @@
using Newtonsoft.Json;
namespace BTCPayServer.Services.Altcoins.Monero.RPC.Models
{
public partial class OpenWalletResponse
{
[JsonProperty("id")] public string Id { get; set; }
[JsonProperty("jsonrpc")] public string Jsonrpc { get; set; }
[JsonProperty("result")] public object Result { get; set; }
[JsonProperty("error")] public OpenWalletErrorResponse Error { get; set; }
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -13,6 +13,7 @@ namespace BTCPayServer.Plugins.PayButton.Models
[ModelBinder(BinderType = typeof(InvariantDecimalModelBinder))]
public decimal? Price { get; set; }
public string InvoiceId { get; set; }
[Required]
public string Currency { get; set; }
public string DefaultPaymentMethod { get; set; }
public PaymentMethodOptionViewModel.Format[] PaymentMethods { get; set; }

@ -64,17 +64,5 @@ namespace BTCPayServer.Plugins
var result = await httpClient.GetStringAsync($"api/v1/plugins{queryString}");
return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings) ?? throw new InvalidOperationException();
}
public async Task<PublishedVersion> GetPlugin(string pluginSlug, string version)
{
try
{
var result = await httpClient.GetStringAsync($"api/v1/plugins/{pluginSlug}/versions/{version}");
return JsonConvert.DeserializeObject<PublishedVersion>(result, serializerSettings);
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
}
}
}

@ -1,53 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Configuration;
using BTCPayServer.Logging;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Hosting.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Plugins
{
public class PluginExceptionHandler : IExceptionHandler
{
readonly string _pluginDir;
readonly IHostApplicationLifetime _applicationLifetime;
private readonly Logs _logs;
public PluginExceptionHandler(IOptions<DataDirectories> options, IHostApplicationLifetime applicationLifetime, Logs logs)
{
_applicationLifetime = applicationLifetime;
_logs = logs;
_pluginDir = options.Value.PluginDir;
}
public ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (!GetDisablePluginIfCrash(httpContext) ||
!PluginManager.IsExceptionByPlugin(exception, out var pluginName))
return ValueTask.FromResult(false);
_logs.Configuration.LogError(exception, $"Unhandled exception caused by plugin '{pluginName}', disabling it and restarting...");
PluginManager.DisablePlugin(_pluginDir, pluginName);
_ = Task.Delay(3000).ContinueWith((t) => _applicationLifetime.StopApplication());
// Returning true here means we will see Error 500 error message.
// Returning false means that the user will see a stacktrace.
return ValueTask.FromResult(false);
}
public static bool GetDisablePluginIfCrash(HttpContext httpContext)
{
return httpContext.Items.TryGetValue("DisablePluginIfCrash", out object renderingDashboard) ||
renderingDashboard is not true;
}
public static void SetDisablePluginIfCrash(HttpContext httpContext)
{
httpContext.Items.TryAdd("DisablePluginIfCrash", true);
}
}
}

@ -258,27 +258,12 @@ namespace BTCPayServer.Plugins
private static IEnumerable<IBTCPayServerPlugin> GetPluginInstancesFromAssembly(Assembly assembly)
{
return GetTypesNotCrash(assembly).Where(type =>
return assembly.GetTypes().Where(type =>
typeof(IBTCPayServerPlugin).IsAssignableFrom(type) && type != typeof(PluginService.AvailablePlugin) &&
!type.IsAbstract).
Select(type => (IBTCPayServerPlugin)Activator.CreateInstance(type, Array.Empty<object>()));
}
private static IEnumerable<Type> GetTypesNotCrash(Assembly assembly)
{
try
{
// Strange crash with selenium
if (assembly.FullName.Contains("Selenium", StringComparison.OrdinalIgnoreCase))
return Array.Empty<Type>();
return assembly.GetTypes();
}
catch(ReflectionTypeLoadException ex)
{
return ex.Types.Where(t => t is not null).ToArray();
}
}
private static IBTCPayServerPlugin GetPluginInstanceFromAssembly(string pluginIdentifier, Assembly assembly)
{
return GetPluginInstancesFromAssembly(assembly).FirstOrDefault(plugin => plugin.Identifier == pluginIdentifier);

@ -70,11 +70,9 @@ namespace BTCPayServer.Plugins
var dest = _dataDirectories.Value.PluginDir;
var filedest = Path.Join(dest, pluginIdentifier + ".btcpay");
var filemanifestdest = Path.Join(dest, pluginIdentifier + ".json");
var pluginSelector = $"[{Uri.EscapeDataString(pluginIdentifier)}]";
version = Uri.EscapeDataString(version);
Directory.CreateDirectory(Path.GetDirectoryName(filedest));
var url = $"api/v1/plugins/{pluginSelector}/versions/{version}/download";
var manifest = (await _pluginBuilderClient.GetPlugin(pluginSelector, version))?.ManifestInfo?.ToObject<AvailablePlugin>();
var url = $"api/v1/plugins/[{Uri.EscapeDataString(pluginIdentifier)}]/versions/{Uri.EscapeDataString(version)}/download";
var manifest = (await _pluginBuilderClient.GetPublishedVersions(null, true)).Select(v => v.ManifestInfo.ToObject<AvailablePlugin>()).FirstOrDefault(p => p.Identifier == pluginIdentifier);
await File.WriteAllTextAsync(filemanifestdest, JsonConvert.SerializeObject(manifest, Formatting.Indented));
using var resp2 = await _pluginBuilderClient.HttpClient.GetAsync(url);
await using var fs = new FileStream(filedest, FileMode.Create, FileAccess.ReadWrite);

@ -356,9 +356,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var receiptData = new JObject();
if (choice is not null)
{
var dict = new Dictionary<string, string> { { "Title", choice.Title } };
if (!string.IsNullOrEmpty(choice.Description)) dict["Description"] = choice.Description;
receiptData = JObject.FromObject(dict);
receiptData = JObject.FromObject(new Dictionary<string, string>
{
{"Title", choice.Title},
{"Description", choice.Description},
});
}
else if (jposData is not null)
{

@ -0,0 +1,87 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Plugins.Shopify.ApiModels;
namespace BTCPayServer.Plugins.Shopify
{
public class OrderTransactionRegisterLogic
{
private readonly ShopifyApiClient _client;
public OrderTransactionRegisterLogic(ShopifyApiClient client)
{
_client = client;
}
private static string[] _keywords = new[] { "bitcoin", "btc", "btcpayserver", "btcpay server" };
public async Task<TransactionsCreateResp> Process(string orderId, string invoiceId, string currency, string amountCaptured, bool success)
{
currency = currency.ToUpperInvariant().Trim();
var existingShopifyOrderTransactions = (await _client.TransactionsList(orderId)).transactions;
//if there isn't a record for btcpay payment gateway, abort
var baseParentTransaction = existingShopifyOrderTransactions.FirstOrDefault(holder => _keywords.Any(a => holder.gateway.Contains(a, StringComparison.InvariantCultureIgnoreCase)));
if (baseParentTransaction is null)
{
return null;
}
//technically, this exploit should not be possible as we use internal invoice tags to verify that the invoice was created by our controlled, dedicated endpoint.
if (currency.ToUpperInvariant().Trim() != baseParentTransaction.currency.ToUpperInvariant().Trim())
{
// because of parent_id present, currency will always be the one from parent transaction
// malicious attacker could potentially exploit this by creating invoice
// in different currency and paying that one, registering order on Shopify as paid
// so if currency is supplied and is different from parent transaction currency we just won't register
return null;
}
var kind = "capture";
var parentId = baseParentTransaction.id;
var status = success ? "success" : "failure";
//find all existing transactions recorded around this invoice id
var existingShopifyOrderTransactionsOnSameInvoice =
existingShopifyOrderTransactions.Where(holder => holder.authorization == invoiceId);
//filter out the successful ones
var successfulActions =
existingShopifyOrderTransactionsOnSameInvoice.Where(holder => holder.status == "success").ToArray();
//of the successful ones, get the ones we registered as a valid payment
var successfulCaptures = successfulActions.Where(holder => holder.kind == "capture").ToArray();
//of the successful ones, get the ones we registered as a voiding of a previous successful payment
var refunds = successfulActions.Where(holder => holder.kind == "refund").ToArray();
//if we are working with a non-success registration, but see that we have previously registered this invoice as a success, we switch to creating a "void" transaction, which in shopify terms is a refund.
if (!success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
{
kind = "void";
parentId = successfulCaptures.Last().id;
status = "success";
}
//if we are working with a success registration, but can see that we have already had a successful transaction saved, get outta here
else if (success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
{
return null;
}
var createTransaction = new TransactionsCreateReq
{
transaction = new TransactionsCreateReq.DataHolder
{
parent_id = parentId,
currency = currency,
amount = amountCaptured,
kind = kind,
gateway = "BTCPayServer",
source = "external",
authorization = invoiceId,
status = status
}
};
var createResp = await _client.TransactionCreate(orderId, createTransaction);
return createResp;
}
}
}

@ -35,10 +35,10 @@ namespace BTCPayServer.Plugins.Shopify
}
private HttpRequestMessage CreateRequest(string shopName, HttpMethod method, string action,
string relativeUrl = null, string apiVersion = "2020-07")
string relativeUrl = null)
{
var url =
$"https://{(shopName.Contains('.', StringComparison.InvariantCulture) ? shopName : $"{shopName}.myshopify.com")}/{relativeUrl ?? ($"admin/api/{apiVersion}/" + action)}";
$"https://{(shopName.Contains('.', StringComparison.InvariantCulture) ? shopName : $"{shopName}.myshopify.com")}/{relativeUrl ?? ("admin/api/2020-07/" + action)}";
var req = new HttpRequestMessage(method, url);
return req;
}
@ -115,15 +115,6 @@ namespace BTCPayServer.Plugins.Shopify
return JObject.Parse(strResp)["order"].ToObject<ShopifyOrder>();
}
public async Task<ShopifyOrder> CancelOrder(string orderId)
{
var req = CreateRequest(_credentials.ShopName, HttpMethod.Post,
$"orders/{orderId}/cancel.json?restock=true", null, "2024-04");
var strResp = await SendRequest(req);
return JObject.Parse(strResp)["order"].ToObject<ShopifyOrder>();
}
public async Task<long> OrdersCount()
{

@ -0,0 +1,116 @@
using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Plugins.Shopify.Models;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Plugins.Shopify
{
public class ShopifyOrderMarkerHostedService : EventHostedServiceBase
{
private readonly StoreRepository _storeRepository;
private readonly IHttpClientFactory _httpClientFactory;
public ShopifyOrderMarkerHostedService(EventAggregator eventAggregator,
StoreRepository storeRepository,
IHttpClientFactory httpClientFactory,
Logs logs) : base(eventAggregator, logs)
{
_storeRepository = storeRepository;
_httpClientFactory = httpClientFactory;
}
public const string SHOPIFY_ORDER_ID_PREFIX = "shopify-";
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
base.SubscribeToEvents();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is InvoiceEvent invoiceEvent && !new[]
{
InvoiceEvent.Created, InvoiceEvent.ExpiredPaidPartial,
InvoiceEvent.ReceivedPayment, InvoiceEvent.PaidInFull
}.Contains(invoiceEvent.Name))
{
var invoice = invoiceEvent.Invoice;
var shopifyOrderId = invoice.GetInternalTags(SHOPIFY_ORDER_ID_PREFIX).FirstOrDefault();
if (shopifyOrderId != null)
{
if (new[] { InvoiceStatusLegacy.Invalid, InvoiceStatusLegacy.Expired }.Contains(invoice.GetInvoiceState()
.Status) && invoice.ExceptionStatus != InvoiceExceptionStatus.None)
{
//you have failed us, customer
await RegisterTransaction(invoice, shopifyOrderId, false);
}
else if (new[] { InvoiceStatusLegacy.Complete, InvoiceStatusLegacy.Confirmed }.Contains(
invoice.Status))
{
await RegisterTransaction(invoice, shopifyOrderId, true);
}
}
}
await base.ProcessEvent(evt, cancellationToken);
}
private async Task RegisterTransaction(InvoiceEntity invoice, string shopifyOrderId, bool success)
{
var storeData = await _storeRepository.FindStore(invoice.StoreId);
var storeBlob = storeData.GetStoreBlob();
// ensure that store in question has shopify integration turned on
// and that invoice's orderId has shopify specific prefix
var settings = storeBlob.GetShopifySettings();
if (settings?.IntegratedAt.HasValue == true)
{
var client = CreateShopifyApiClient(settings);
if (!await client.OrderExists(shopifyOrderId))
{
// don't register transactions for orders that don't exist on shopify
return;
}
// if we got this far, we likely need to register this invoice's payment on Shopify
// OrderTransactionRegisterLogic has check if transaction is already registered which is why we're passing invoice.Id
try
{
var logic = new OrderTransactionRegisterLogic(client);
var resp = await logic.Process(shopifyOrderId, invoice.Id, invoice.Currency,
invoice.Price.ToString(CultureInfo.InvariantCulture), success);
if (resp != null)
{
Logs.PayServer.LogInformation($"Registered order transaction {invoice.Price}{invoice.Currency} on Shopify. " +
$"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}, Success: {success}");
}
}
catch (Exception ex)
{
Logs.PayServer.LogError(ex,
$"Shopify error while trying to register order transaction. " +
$"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}");
}
}
}
private ShopifyApiClient CreateShopifyApiClient(ShopifySettings shopify)
{
return new ShopifyApiClient(_httpClientFactory, shopify.CreateShopifyApiCredentials());
}
}
}

@ -14,8 +14,7 @@ namespace BTCPayServer.Plugins.Shopify
public override void Execute(IServiceCollection applicationBuilder)
{
applicationBuilder.AddSingleton<ShopifyService>();
applicationBuilder.AddSingleton<IHostedService, ShopifyService>(provider => provider.GetRequiredService<ShopifyService>());
applicationBuilder.AddSingleton<IHostedService, ShopifyOrderMarkerHostedService>();
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("Shopify/NavExtension", "header-nav"));
base.Execute(applicationBuilder);
}

@ -1,234 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Plugins.Shopify.ApiModels;
using BTCPayServer.Plugins.Shopify.Models;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Plugins.Shopify
{
public class ShopifyService : EventHostedServiceBase
{
private readonly StoreRepository _storeRepository;
private readonly InvoiceRepository _invoiceRepository;
private readonly IHttpClientFactory _httpClientFactory;
public ShopifyService(EventAggregator eventAggregator,
StoreRepository storeRepository,
InvoiceRepository invoiceRepository,
IHttpClientFactory httpClientFactory,
Logs logs) : base(eventAggregator, logs)
{
_storeRepository = storeRepository;
_invoiceRepository = invoiceRepository;
_httpClientFactory = httpClientFactory;
}
public const string SHOPIFY_ORDER_ID_PREFIX = "shopify-";
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
base.SubscribeToEvents();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is InvoiceEvent invoiceEvent && new[]
{
InvoiceEvent.MarkedCompleted,
InvoiceEvent.MarkedInvalid,
InvoiceEvent.Expired,
InvoiceEvent.Confirmed,
InvoiceEvent.Completed
}.Contains(invoiceEvent.Name))
{
var invoice = invoiceEvent.Invoice;
var shopifyOrderId = invoice.GetInternalTags(SHOPIFY_ORDER_ID_PREFIX).FirstOrDefault();
if (shopifyOrderId != null)
{
var success = invoice.Status.ToModernStatus() switch
{
InvoiceStatus.Settled => true,
InvoiceStatus.Invalid or InvoiceStatus.Expired => false,
_ => (bool?)null
};
if (success.HasValue)
await RegisterTransaction(invoice, shopifyOrderId, success.Value);
}
}
await base.ProcessEvent(evt, cancellationToken);
}
private async Task RegisterTransaction(InvoiceEntity invoice, string shopifyOrderId, bool success)
{
var storeData = await _storeRepository.FindStore(invoice.StoreId);
var storeBlob = storeData.GetStoreBlob();
// ensure that store in question has shopify integration turned on
// and that invoice's orderId has shopify specific prefix
var settings = storeBlob.GetShopifySettings();
if (settings?.IntegratedAt.HasValue == true)
{
var client = CreateShopifyApiClient(settings);
if (!await client.OrderExists(shopifyOrderId))
{
// don't register transactions for orders that don't exist on shopify
return;
}
// if we got this far, we likely need to register this invoice's payment on Shopify
// OrderTransactionRegisterLogic has check if transaction is already registered which is why we're passing invoice.Id
try
{
var resp = await Process(client, shopifyOrderId, invoice.Id, invoice.Currency,
invoice.Price.ToString(CultureInfo.InvariantCulture), success);
if (resp != null)
{
await _invoiceRepository.AddInvoiceLogs(invoice.Id, resp);
}
}
catch (Exception ex)
{
Logs.PayServer.LogError(ex,
$"Shopify error while trying to register order transaction. " +
$"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}");
}
}
}
private ShopifyApiClient CreateShopifyApiClient(ShopifySettings shopify)
{
return new ShopifyApiClient(_httpClientFactory, shopify.CreateShopifyApiCredentials());
}
private static string[] _keywords = new[] {"bitcoin", "btc", "btcpayserver", "btcpay server"};
public async Task<InvoiceLogs> Process(ShopifyApiClient client, string orderId, string invoiceId,
string currency, string amountCaptured, bool success)
{
var result = new InvoiceLogs();
currency = currency.ToUpperInvariant().Trim();
var existingShopifyOrderTransactions = (await client.TransactionsList(orderId)).transactions;
//if there isn't a record for btcpay payment gateway, abort
var baseParentTransaction = existingShopifyOrderTransactions.FirstOrDefault(holder =>
_keywords.Any(a => holder.gateway.Contains(a, StringComparison.InvariantCultureIgnoreCase)));
if (baseParentTransaction is null)
{
result.Write("Couldn't find the order on Shopify.", InvoiceEventData.EventSeverity.Error);
return result;
}
//technically, this exploit should not be possible as we use internal invoice tags to verify that the invoice was created by our controlled, dedicated endpoint.
if (currency.ToUpperInvariant().Trim() != baseParentTransaction.currency.ToUpperInvariant().Trim())
{
// because of parent_id present, currency will always be the one from parent transaction
// malicious attacker could potentially exploit this by creating invoice
// in different currency and paying that one, registering order on Shopify as paid
// so if currency is supplied and is different from parent transaction currency we just won't register
result.Write("Currency mismatch on Shopify.", InvoiceEventData.EventSeverity.Error);
return result;
}
var kind = "capture";
var parentId = baseParentTransaction.id;
var status = success ? "success" : "failure";
//find all existing transactions recorded around this invoice id
var existingShopifyOrderTransactionsOnSameInvoice =
existingShopifyOrderTransactions.Where(holder => holder.authorization == invoiceId);
//filter out the successful ones
var successfulActions =
existingShopifyOrderTransactionsOnSameInvoice.Where(holder => holder.status == "success").ToArray();
//of the successful ones, get the ones we registered as a valid payment
var successfulCaptures = successfulActions.Where(holder => holder.kind == "capture").ToArray();
//of the successful ones, get the ones we registered as a voiding of a previous successful payment
var refunds = successfulActions.Where(holder => holder.kind == "refund").ToArray();
//if we are working with a non-success registration, but see that we have previously registered this invoice as a success, we switch to creating a "void" transaction, which in shopify terms is a refund.
if (!success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
{
kind = "void";
parentId = successfulCaptures.Last().id;
status = "success";
result.Write(
"A transaction was previously recorded against the Shopify order. Creating a void transaction.",
InvoiceEventData.EventSeverity.Warning);
}
else if (!success)
{
kind = "void";
status = "success";
result.Write("Attempting to void the payment on Shopify order due to failure in payment.",
InvoiceEventData.EventSeverity.Warning);
}
//if we are working with a success registration, but can see that we have already had a successful transaction saved, get outta here
else if (success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
{
result.Write("A transaction was previously recorded against the Shopify order. Skipping.",
InvoiceEventData.EventSeverity.Warning);
return result;
}
var createTransaction = new TransactionsCreateReq
{
transaction = new TransactionsCreateReq.DataHolder
{
parent_id = parentId,
currency = currency,
amount = amountCaptured,
kind = kind,
gateway = "BTCPayServer",
source = "external",
authorization = invoiceId,
status = status
}
};
var createResp = await client.TransactionCreate(orderId, createTransaction);
if (createResp.transaction is null)
{
result.Write("Failed to register the transaction on Shopify.", InvoiceEventData.EventSeverity.Error);
}
else
{
result.Write(
$"Successfully registered the transaction on Shopify. tx status:{createResp.transaction.status}, kind: {createResp.transaction.kind}, order id:{createResp.transaction.order_id}",
InvoiceEventData.EventSeverity.Info);
}
if (!success)
{
try
{
await client.CancelOrder(orderId);
result.Write("Cancelling the Shopify order.", InvoiceEventData.EventSeverity.Warning);
}
catch (Exception e)
{
result.Write($"Failed to cancel the Shopify order. {e.Message}",
InvoiceEventData.EventSeverity.Error);
}
}
return result;
}
}
}

@ -34,7 +34,6 @@ namespace BTCPayServer.Plugins.Shopify
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public class UIShopifyController : Controller
{
private readonly ShopifyService _shopifyService;
private readonly BTCPayServerEnvironment _btcPayServerEnvironment;
private readonly IOptions<BTCPayServerOptions> _btcPayServerOptions;
private readonly IWebHostEnvironment _webHostEnvironment;
@ -44,9 +43,7 @@ namespace BTCPayServer.Plugins.Shopify
private readonly IJsonHelper _jsonHelper;
private readonly IHttpClientFactory _clientFactory;
public UIShopifyController(
ShopifyService shopifyService,
BTCPayServerEnvironment btcPayServerEnvironment,
public UIShopifyController(BTCPayServerEnvironment btcPayServerEnvironment,
IOptions<BTCPayServerOptions> btcPayServerOptions,
IWebHostEnvironment webHostEnvironment,
StoreRepository storeRepository,
@ -55,7 +52,6 @@ namespace BTCPayServer.Plugins.Shopify
IJsonHelper jsonHelper,
IHttpClientFactory clientFactory)
{
_shopifyService = shopifyService;
_btcPayServerEnvironment = btcPayServerEnvironment;
_btcPayServerOptions = btcPayServerOptions;
_webHostEnvironment = webHostEnvironment;
@ -110,14 +106,14 @@ namespace BTCPayServer.Plugins.Shopify
public async Task<IActionResult> ShopifyInvoiceEndpoint(
string storeId, string orderId, decimal amount, bool checkOnly = false)
{
var shopifySearchTerm = $"{ShopifyService.SHOPIFY_ORDER_ID_PREFIX}{orderId}";
var shopifySearchTerm = $"{ShopifyOrderMarkerHostedService.SHOPIFY_ORDER_ID_PREFIX}{orderId}";
var matchedExistingInvoices = await _invoiceRepository.GetInvoices(new InvoiceQuery()
{
TextSearch = shopifySearchTerm,
StoreId = new[] { storeId }
});
matchedExistingInvoices = matchedExistingInvoices.Where(entity =>
entity.GetInternalTags(ShopifyService.SHOPIFY_ORDER_ID_PREFIX)
entity.GetInternalTags(ShopifyOrderMarkerHostedService.SHOPIFY_ORDER_ID_PREFIX)
.Any(s => s == orderId))
.ToArray();
@ -159,7 +155,7 @@ namespace BTCPayServer.Plugins.Shopify
if (client != null && order?.FinancialStatus == "pending" &&
firstInvoiceSettled.Status != InvoiceStatusLegacy.Paid)
{
await _shopifyService.Process(client, orderId, firstInvoiceSettled.Id,
await new OrderTransactionRegisterLogic(client).Process(orderId, firstInvoiceSettled.Id,
firstInvoiceSettled.Currency,
firstInvoiceSettled.Price.ToString(CultureInfo.InvariantCulture), true);
order = await client.GetOrder(orderId);

@ -70,47 +70,6 @@
},
"applicationUrl": "https://localhost:14142/"
},
"Altcoins": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"BTCPAY_EXPERIMENTALV2_CONFIRM": "true",
"BTCPAY_NETWORK": "regtest",
"BTCPAY_LAUNCHSETTINGS": "true",
"BTCPAY_PORT": "14142",
"BTCPAY_HttpsUseDefaultCertificate": "true",
"BTCPAY_VERBOSE": "true",
"BTCPAY_LTCEXPLORERURL": "http://127.0.0.1:32838/",
"BTCPAY_LBTCEXPLORERURL": "http://127.0.0.1:32838/",
"BTCPAY_BTCLIGHTNING": "type=clightning;server=tcp://127.0.0.1:30993/",
"BTCPAY_BTCEXTERNALLNDREST": "type=lnd-rest;server=http://lnd:lnd@127.0.0.1:35531/;allowinsecure=true",
"BTCPAY_BTCEXTERNALLNDSEEDBACKUP": "../BTCPayServer.Tests/TestData/LndSeedBackup/walletunlock.json",
"BTCPAY_BTCEXTERNALSPARK": "server=/spark/btc/;cookiefile=fake",
"BTCPAY_BTCEXTERNALCHARGE": "server=https://127.0.0.1:53280/mycharge/btc/;cookiefilepath=fake",
"BTCPAY_BTCEXTERNALRTL": "server=/rtl/api/authenticate/cookie;cookiefile=fake",
"BTCPAY_BTCEXTERNALTHUNDERHUB": "server=/thub/sso;cookiefile=fake",
"BTCPAY_BTCEXTERNALTORQ": "server=/torq/cookie-login;cookiefile=fake",
"BTCPAY_EXTERNALSERVICES": "totoservice:totolink;Lightning Terminal:/lit/;",
"BTCPAY_EXTERNALCONFIGURATOR": "passwordfile=testpwd;server=/configurator",
"BTCPAY_BTCEXPLORERURL": "http://127.0.0.1:32838/",
"BTCPAY_ALLOW-ADMIN-REGISTRATION": "true",
"BTCPAY_DISABLE-REGISTRATION": "false",
"ASPNETCORE_ENVIRONMENT": "Development",
"BTCPAY_CHAINS": "btc,ltc,lbtc,xmr",
"BTCPAY_POSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=btcpayserver",
"BTCPAY_DEBUGLOG": "debug.log",
"BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc",
"BTCPAY_SOCKSENDPOINT": "localhost:9050",
"BTCPAY_DOCKERDEPLOYMENT": "true",
"BTCPAY_RECOMMENDED-PLUGINS": "",
"BTCPAY_CHEATMODE": "true",
"BTCPAY_EXPLORERPOSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=nbxplorer",
"BTCPAY_XMR_DAEMON_URI": "http://127.0.0.1:18081",
"BTCPAY_XMR_WALLET_DAEMON_URI": "http://127.0.0.1:18082",
"BTCPAY_XMR_WALLET_DAEMON_WALLETDIR": "/path/to/monero_wallet"
},
"applicationUrl": "https://localhost:14142/"
},
"Altcoins-HTTPS": {
"commandName": "Project",
"launchBrowser": true,
@ -136,7 +95,7 @@
"BTCPAY_ALLOW-ADMIN-REGISTRATION": "true",
"BTCPAY_DISABLE-REGISTRATION": "false",
"ASPNETCORE_ENVIRONMENT": "Development",
"BTCPAY_CHAINS": "btc,ltc,lbtc,xmr",
"BTCPAY_CHAINS": "btc,ltc,lbtc",
"BTCPAY_POSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=btcpayserver",
"BTCPAY_SSHCONNECTION": "root@127.0.0.1:21622",
"BTCPAY_SSHPASSWORD": "opD3i2282D",
@ -146,10 +105,7 @@
"BTCPAY_DOCKERDEPLOYMENT": "true",
"BTCPAY_RECOMMENDED-PLUGINS": "",
"BTCPAY_CHEATMODE": "true",
"BTCPAY_EXPLORERPOSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=nbxplorer",
"BTCPAY_XMR_DAEMON_URI": "http://127.0.0.1:18081",
"BTCPAY_XMR_WALLET_DAEMON_URI": "http://127.0.0.1:18082",
"BTCPAY_XMR_WALLET_DAEMON_WALLETDIR": "/path/to/monero_wallet"
"BTCPAY_EXPLORERPOSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=nbxplorer"
},
"applicationUrl": "https://localhost:14142/"
}

@ -9,8 +9,7 @@ namespace BTCPayServer
{
private const char FilterSeparator = ',';
private const char ValueSeparator = ':';
private static readonly string[] StripFilters = ["status", "exceptionstatus", "unusual", "includearchived", "appid", "startdate", "enddate"];
private readonly string _originalString;
private readonly int _timezoneOffset;
@ -28,18 +27,12 @@ namespace BTCPayServer
.Where(kv => kv.Length == 2)
.Select(kv => new KeyValuePair<string, string>(UnifyKey(kv[0]), kv[1]))
.ToMultiValueDictionary(o => o.Key, o => o.Value);
// combine raw search term and filters which don't have a special UI (e.g. orderid)
var textFilters = Filters
.Where(f => !StripFilters.Contains(f.Key))
.Select(f => string.Join(FilterSeparator, f.Value.Select(v => $"{f.Key}{ValueSeparator}{v}"))).ToList();
TextFilters = textFilters.Any() ? string.Join(FilterSeparator, textFilters) : null;
TextSearch = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1)?.Trim();
var val = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1);
TextSearch = val != null ? val.Trim() : string.Empty;
}
public string TextSearch { get; private set; }
public string TextFilters { get; private set; }
public string TextCombined => string.Join(FilterSeparator, new []{ TextFilters, TextSearch }.Where(x => !string.IsNullOrEmpty(x)));
public MultiValueDictionary<string, string> Filters { get; }
@ -89,10 +82,9 @@ namespace BTCPayServer
public string WithoutSearchText()
{
var txt = ToString();
if (!string.IsNullOrEmpty(TextSearch)) txt = Finalize(txt.Replace(TextSearch, string.Empty));
if (!string.IsNullOrEmpty(TextFilters)) txt = Finalize(txt.Replace(TextFilters, string.Empty));
return Finalize(txt).Trim();
return string.IsNullOrEmpty(TextSearch)
? Finalize(ToString())
: Finalize(ToString()).Replace(TextSearch, string.Empty);
}
public string[] GetFilterArray(string key)
@ -152,7 +144,7 @@ namespace BTCPayServer
private static string Finalize(string str)
{
var value = str.Trim().TrimStart(FilterSeparator).TrimEnd(FilterSeparator);
var value = str.TrimStart(FilterSeparator).TrimEnd(FilterSeparator);
return string.IsNullOrEmpty(value) ? " " : value;
}
}

@ -249,28 +249,10 @@ namespace BTCPayServer.Services.Altcoins.Monero.UI
}
}
try
{
var response = await _MoneroRpcProvider.WalletRpcClients[cryptoCode].SendCommandAsync<OpenWalletRequest, OpenWalletResponse>("open_wallet", new OpenWalletRequest
{
Filename = "wallet",
Password = viewModel.WalletPassword
});
if (response?.Error != null)
{
throw new Exception(response.Error.Message);
}
}
catch (Exception ex)
{
ModelState.AddModelError(nameof(viewModel.AccountIndex), $"Could not open the wallet: {ex.Message}");
return View(viewModel);
}
return RedirectToAction(nameof(GetStoreMoneroLikePaymentMethod), new
{
cryptoCode,
StatusMessage = "View-only wallet files uploaded. The wallet will soon become available."
StatusMessage = "View-only wallet files uploaded. If they are valid the wallet will soon become available."
});
}

@ -1,133 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using Dapper;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Services.Reporting
{
public class RefundsReportProvider : ReportProvider
{
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
private readonly DisplayFormatter _displayFormatter;
private ViewDefinition CreateDefinition()
{
return new ViewDefinition
{
Fields = new List<StoreReportResponse.Field>
{
new("Date", "datetime"),
new("InvoiceId", "invoice_id"),
new("Currency", "string"),
new("Completed", "amount"),
new("Awaiting", "amount"),
new("Limit", "amount"),
new("FullyPaid", "boolean")
},
Charts =
{
new ()
{
Name = "Aggregated amount",
Groups = { "Currency" },
HasGrandTotal = false,
Aggregates = { "Awaiting", "Completed", "Limit" }
}
}
};
}
public override string Name => "Refunds";
public ApplicationDbContextFactory DbContextFactory { get; }
public RefundsReportProvider(
ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkJsonSerializerSettings serializerSettings,
DisplayFormatter displayFormatter)
{
DbContextFactory = dbContextFactory;
_serializerSettings = serializerSettings;
_displayFormatter = displayFormatter;
}
record RefundRow(DateTimeOffset Created, string InvoiceId, string PullPaymentId, string Currency, decimal Limit)
{
public decimal Completed { get; set; }
public decimal Awaiting { get; set; }
}
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
{
queryContext.ViewDefinition = CreateDefinition();
RefundRow? currentRow = null;
await using var ctx = DbContextFactory.CreateContext();
var conn = ctx.Database.GetDbConnection();
var rows = await conn.QueryAsync(
"""
SELECT i."Created", i."Id" AS "InvoiceId", p."State", p."PaymentMethodId", pp."Id" AS "PullPaymentId", pp."Blob" AS "ppBlob", p."Blob" AS "pBlob" FROM "Invoices" i
JOIN "Refunds" r ON r."InvoiceDataId"= i."Id"
JOIN "PullPayments" pp ON r."PullPaymentDataId"=pp."Id"
LEFT JOIN "Payouts" p ON p."PullPaymentDataId"=pp."Id"
WHERE i."StoreDataId" = @storeId
AND i."Created" >= @start AND i."Created" <= @end
AND pp."Archived" IS FALSE
ORDER BY i."Created", pp."Id"
""", new { start = queryContext.From, end = queryContext.To, storeId = queryContext.StoreId });
foreach (var r in rows)
{
PullPaymentBlob ppBlob = GetPullPaymentBlob(r);
PayoutBlob? pBlob = GetPayoutBlob(r);
if ((string)r.PullPaymentId != currentRow?.PullPaymentId)
{
AddRow(queryContext, currentRow);
currentRow = new(r.Created, r.InvoiceId, r.PullPaymentId, ppBlob.Currency, ppBlob.Limit);
}
if (pBlob is null)
continue;
var state = Enum.Parse<PayoutState>((string)r.State);
if (state == PayoutState.Cancelled)
continue;
if (state is PayoutState.Completed)
currentRow.Completed += pBlob.Amount;
else
currentRow.Awaiting += pBlob.Amount;
}
AddRow(queryContext, currentRow);
}
private PayoutBlob? GetPayoutBlob(dynamic r)
{
if (r.pBlob is null)
return null;
Data.PayoutData p = new Data.PayoutData();
p.PaymentMethodId = r.PaymentMethodId;
p.Blob = (string)r.pBlob;
return p.GetBlob(_serializerSettings);
}
private static PullPaymentBlob GetPullPaymentBlob(dynamic r)
{
Data.PullPaymentData pp = new Data.PullPaymentData();
pp.Blob = (string)r.ppBlob;
return pp.GetBlob();
}
private void AddRow(QueryContext queryContext, RefundRow? currentRow)
{
if (currentRow is null)
return;
var data = queryContext.AddData();
data.Add(currentRow.Created);
data.Add(currentRow.InvoiceId);
data.Add(currentRow.Currency);
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Completed, currentRow.Currency));
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Awaiting, currentRow.Currency));
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Limit, currentRow.Currency));
data.Add(currentRow.Limit <= currentRow.Completed);
}
}
}

@ -29,7 +29,7 @@ public class TransactionLinkProviders : Dictionary<PaymentMethodId, TransactionL
{
foreach ((var pmi, var prov) in this)
{
var overrideLink = links.FirstOrDefault(item =>
var overrideLink = links.SingleOrDefault(item =>
item.CryptoCode.Equals(pmi.CryptoCode, StringComparison.InvariantCultureIgnoreCase) ||
item.CryptoCode.Equals(pmi.ToString(), StringComparison.InvariantCultureIgnoreCase));
prov.OverrideBlockExplorerLink = overrideLink?.Link ?? prov.BlockExplorerLinkDefault;

@ -91,7 +91,7 @@ namespace BTCPayServer.Services.Wallets.Export
{
using StringWriter writer = new();
using var csvWriter = new CsvHelper.CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture), true);
csvWriter.Context.RegisterClassMap<ExportTransactionMap>();
csvWriter.Configuration.RegisterClassMap<ExportTransactionMap>();
csvWriter.WriteHeader<ExportTransaction>();
csvWriter.NextRecord();
csvWriter.WriteRecords(invoices);
@ -105,7 +105,7 @@ namespace BTCPayServer.Services.Wallets.Export
public ExportTransactionMap()
{
AutoMap(CultureInfo.InvariantCulture);
Map(m => m.Labels).Convert(row => row.Value.Labels == null ? string.Empty : string.Join(", ", row.Value.Labels));
Map(m => m.Labels).ConvertUsing(row => row.Labels == null ? string.Empty : string.Join(", ", row.Labels));
}
}

@ -326,7 +326,7 @@
<div class="accordion-body">
<div class="form-group">
<label asp-for="CustomCSSLink" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener" title="More information...">
<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" />

@ -1,7 +1,6 @@
@using BTCPayServer.Client
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Models.ServerViewModels
@model BTCPayServer.Models.EmailsViewModel
<div class="row">
@ -45,7 +44,7 @@
<div class="form-text">For many email providers (like Gmail) your login is your email address.</div>
<span asp-validation-for="Settings.Login" class="text-danger"></span>
</div>
<div class="form-group" permission="@(Model is ServerEmailsViewModel ? Policies.CanModifyServerSettings : Policies.CanModifyStoreSettings)">
<div class="form-group" permission="@Policies.CanModifyStoreSettings">
@if (!Model.PasswordSet)
{
<label asp-for="Settings.Password" class="form-label"></label>
@ -82,6 +81,7 @@
<script>
document.addEventListener("DOMContentLoaded", function () {
delegate('click', '#quick-fill .dropdown-menu a', function (e) {
e.preventDefault();

@ -72,7 +72,6 @@ else
{
var item = Model.Items[x];
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && item.Price == 0) continue;
<div class="d-flex flex-wrap">
<div class="card px-0" data-id="@x">
<div class="card-body p-3 d-flex flex-column gap-2">

@ -304,7 +304,7 @@
<div class="accordion-body">
<div class="form-group">
<label asp-for="CustomCSSLink" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener" title="More information...">
<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" />

@ -1,4 +1,3 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model (Dictionary<string, object> Items, int Level)
@functions {
@ -11,20 +10,14 @@
@if (Model.Items.Any())
{
@* Use titlecase and lowercase versions for backwards-compatibility *@
string[] cartKeys = ["cart", "subtotal", "discount", "tip", "total"];
var hasCart = Model.Items.ContainsKey("Cart");
<table class="table my-0" v-pre>
@if (Model.Items.Keys.Any(key => cartKeys.Contains(key.ToLowerInvariant())))
@if (hasCart || (Model.Items.ContainsKey("Subtotal") && Model.Items.ContainsKey("Total")))
{
_ = Model.Items.TryGetValue("cart", out var cart) || Model.Items.TryGetValue("Cart", out cart);
var hasTotal = Model.Items.TryGetValue("total", out var total) || Model.Items.TryGetValue("Total", out total);
var hasSubtotal = Model.Items.TryGetValue("subtotal", out var subtotal) || Model.Items.TryGetValue("subTotal", out subtotal) || Model.Items.TryGetValue("Subtotal", out subtotal);
var hasDiscount = Model.Items.TryGetValue("discount", out var discount) || Model.Items.TryGetValue("Discount", out discount);
var hasTip = Model.Items.TryGetValue("tip", out var tip) || Model.Items.TryGetValue("Tip", out tip);
if (cart is Dictionary<string, object> { Keys.Count: > 0 } cartDict)
@if (hasCart)
{
<tbody>
@foreach (var (key, value) in cartDict)
@foreach (var (key, value) in (Dictionary<string, object>)Model.Items["Cart"])
{
<tr>
<th>@key</th>
@ -33,46 +26,35 @@
}
</tbody>
}
else if (cart is ICollection<object> { Count: > 0 } cartCollection)
{
<tbody>
@foreach (var value in cartCollection)
<tfoot style="border-top-width:@(hasCart ? "3px" : "0")">
@if (Model.Items.ContainsKey("Subtotal"))
{
<tr>
<td>@value</td>
<th>Subtotal</th>
<td class="text-end">@Model.Items["Subtotal"]</td>
</tr>
}
@if (Model.Items.ContainsKey("Discount"))
{
<tr>
<th>Discount</th>
<td class="text-end">@Model.Items["Discount"]</td>
</tr>
}
@if (Model.Items.ContainsKey("Tip"))
{
<tr>
<th>Tip</th>
<td class="text-end">@Model.Items["Tip"]</td>
</tr>
}
@if (Model.Items.ContainsKey("Total"))
{
<tr style="border-top-width:3px">
<th>Total</th>
<td class="text-end">@Model.Items["Total"]</td>
</tr>
}
</tbody>
}
<tfoot style="border-top-width:0">
@if (hasSubtotal && (hasDiscount || hasTip))
{
<tr style="border-top-width:3px">
<th>Subtotal</th>
<td class="text-end">@subtotal</td>
</tr>
}
@if (hasDiscount)
{
<tr>
<th>Discount</th>
<td class="text-end">@discount</td>
</tr>
}
@if (hasTip)
{
<tr>
<th>Tip</th>
<td class="text-end">@tip</td>
</tr>
}
@if (hasTotal)
{
<tr style="border-top-width:3px">
<th>Total</th>
<td class="text-end">@total</td>
</tr>
}
</tfoot>
}
else

@ -18,11 +18,11 @@
@RenderBody()
</div>
</section>
@if (ViewData["ShowFooter"] is not false)
{
<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>

@ -52,7 +52,6 @@
</button>
</nav>
<section id="payment" v-if="isActive">
<div v-if="srvModel.itemDesc && srvModel.itemDesc !== srvModel.storeName" v-text="srvModel.itemDesc" class="fw-semibold text-center text-muted mb-3"></div>
<div class="d-flex justify-content-center mt-1 text-center">
@if (Model.IsUnsetTopUp)
{

@ -431,7 +431,7 @@
</table>
</div>
}
@if (Model.ReceiptData?.Any() is true)
@if (Model.ReceiptData != null && Model.ReceiptData.Any())
{
<div>
<h3 class="mb-3">
@ -443,7 +443,7 @@
<partial name="PosData" model="(Model.ReceiptData, 1)" />
</div>
}
@if (Model.AdditionalData?.Any() is true)
@if (Model.AdditionalData != null && Model.AdditionalData.Any())
{
<div>
<h3 class="mb-3">

@ -35,8 +35,8 @@
#InvoiceSummary { gap: var(--btcpay-space-l); }
#PaymentDetails table tbody tr:first-child td { padding-top: 1rem; }
#PaymentDetails table tbody:not(:last-child) tr:last-child > th,td { padding-bottom: 1rem; }
#AdditionalData td > table:last-child, #CartData td > table:last-child { margin-bottom: 0 !important; }
#AdditionalData table > tbody > tr:first-child > td > h4, #CartData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; }
#posData td > table:last-child { margin-bottom: 0 !important; }
#posData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; }
</style>
</head>
<body class="min-vh-100">
@ -62,7 +62,7 @@
{
if (Model.ReceiptOptions.ShowQR is true)
{
<vc:qr-code data="@Context.Request.GetCurrentUrl()" />
<vc:qr-code data="@Context.Request.GetCurrentUrl()"></vc:qr-code>
}
<div class="d-flex gap-4 mb-0 flex-fill">
<dl class="d-flex flex-column gap-4 mb-0 flex-fill">
@ -82,7 +82,7 @@
</div>
}
</dl>
<a href="?print=true" class="flex-grow-0 align-self-start btn btn-secondary d-print-none fs-4" target="_blank" id="ReceiptLinkPrint">Print</a>
<a href="?print=true" class="flex-grow-0 align-self-start btn btn-secondary d-print-none fs-4" target="_blank">Print</a>
</div>
}
</div>
@ -102,15 +102,6 @@
</div>
</div>
}
if (Model.CartData?.Any() is true)
{
<div id="CartData" class="tile">
<h2 class="h4 mb-3">Cart</h2>
<div class="table-responsive my-0">
<partial name="PosData" model="(Model.CartData, 1)" />
</div>
</div>
}
if (Model.Payments?.Any() is true)
{
<div id="PaymentDetails" class="tile">
@ -173,9 +164,9 @@
</div>
}
}
@if (!string.IsNullOrEmpty(Model.RedirectUrl))
@if (!string.IsNullOrEmpty(Model.OrderUrl))
{
<a href="@Model.RedirectUrl" class="btn btn-secondary rounded-pill mx-auto mt-3" rel="noreferrer noopener" target="_blank">Return to @(string.IsNullOrEmpty(Model.StoreName) ? "store" : Model.StoreName)</a>
<a href="@Model.OrderUrl" class="btn btn-secondary rounded-pill mx-auto mt-3" rel="noreferrer noopener" target="_blank">Return to @(string.IsNullOrEmpty(Model.StoreName) ? "store" : Model.StoreName)</a>
}
</div>
</main>

@ -1,17 +1,4 @@
@model BTCPayServer.Models.InvoicingModels.InvoiceReceiptViewModel
@functions {
public bool IsManualEntryCart(Dictionary<string, object> additionalData)
{
_ = additionalData.TryGetValue("cart", out var data) || additionalData.TryGetValue("Cart", out data);
if (data is Dictionary<string, object> cart)
{
return cart.Count == 1 && cart.ContainsKey("Manual entry 1");
}
return false;
}
}
@using BTCPayServer.Client.Models
@using BTCPayServer.Components.QRCode
@using BTCPayServer.Services
@ -103,7 +90,6 @@
}
else
{
var hasCart = Model.CartData?.Any() is true;
<div id="PaymentDetails">
<div class="my-2 text-center small">
@if (!string.IsNullOrEmpty(Model.OrderId))
@ -113,92 +99,53 @@
@Model.Timestamp.ToBrowserDate()
</div>
<table class="table table-borderless table-sm small my-0">
@if (Model.AdditionalData?.Any() is true)
<tr>
<td class="text-nowrap text-secondary">Total</td>
<td class="text-end fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</td>
</tr>
<tr>
<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")))
{
@foreach (var (key, value) in Model.AdditionalData)
@if (Model.AdditionalData.ContainsKey("Cart"))
{
<tr class="additional-data">
<td class="text-secondary">@key</td>
<td class="text-end">@value</td>
@foreach (var (key, value) in (Dictionary<string, object>)Model.AdditionalData["Cart"])
{
<tr>
<td class="text-secondary">@key</td>
<td class="text-end">@value</td>
</tr>
}
}
@if (Model.AdditionalData.ContainsKey("Subtotal"))
{
<tr>
<td class="text-secondary">Subtotal</td>
<td class="text-end">@Model.AdditionalData["Subtotal"]</td>
</tr>
}
@if (Model.AdditionalData.ContainsKey("Discount"))
{
<tr>
<td class="text-secondary">Discount</td>
<td class="text-end">@Model.AdditionalData["Discount"]</td>
</tr>
}
@if (Model.AdditionalData.ContainsKey("Tip"))
{
<tr>
<td class="text-secondary">Tip</td>
<td class="text-end">@Model.AdditionalData["Tip"]</td>
</tr>
}
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
}
@if (hasCart && !IsManualEntryCart(Model.CartData))
{
_ = Model.CartData.TryGetValue("cart", out var cart) || Model.CartData.TryGetValue("Cart", out cart);
var hasTotal = Model.CartData.TryGetValue("total", out var total) || Model.CartData.TryGetValue("Total", out total);
var hasSubtotal = Model.CartData.TryGetValue("subtotal", out var subtotal) || Model.CartData.TryGetValue("subTotal", out subtotal) || Model.CartData.TryGetValue("Subtotal", out subtotal);
var hasDiscount = Model.CartData.TryGetValue("discount", out var discount) || Model.CartData.TryGetValue("Discount", out discount);
var hasTip = Model.CartData.TryGetValue("tip", out var tip) || Model.CartData.TryGetValue("Tip", out tip);
if (cart is Dictionary<string, object> { Keys.Count: > 0 } cartDict)
{
@foreach (var (key, value) in cartDict)
{
<tr class="cart-data">
<td class="key text-secondary">@key</td>
<td class="val text-end">@value</td>
</tr>
}
}
else if (cart is ICollection<object> { Count: > 0 } cartCollection)
{
@foreach (var value in cartCollection)
{
<tr>
<td class="val text-end">@value</td>
</tr>
}
}
if (hasSubtotal && (hasDiscount || hasTip))
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
<tr class="sums-data">
<td class="key text-secondary">Subtotal</td>
<td class="val text-end">@subtotal</td>
</tr>
}
if (hasDiscount)
{
<tr class="sums-data">
<td class="key text-secondary">Discount</td>
<td class="val text-end">@discount</td>
</tr>
}
if (hasTip)
{
<tr class="sums-data">
<td class="key text-secondary">Tip</td>
<td class="val text-end">@tip</td>
</tr>
}
if (hasTotal)
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
<tr class="sums-data">
<td class="key text-secondary">Total</td>
<td class="val text-end fw-semibold">@total</td>
</tr>
}
}
else
{
<tr class="sums-data">
<td class="key text-nowrap text-secondary">Total</td>
<td class="val text-end fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</td>
</tr>
}
@if (Model.Payments?.Any() is true)
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
@for (var i = 0; i < Model.Payments.Count; i++)
{
var payment = Model.Payments[i];
@ -207,33 +154,33 @@
<tr>
<td colspan="2" class="text-nowrap text-secondary">Payment @(i + 1)</td>
</tr>
<tr class="payment-data">
<tr>
<td class="text-nowrap">Received</td>
<td>@payment.ReceivedDate.ToBrowserDate()</td>
</tr>
}
<tr class="payment-data">
<tr>
<td class="text-nowrap text-secondary">@(Model.Payments.Count == 1 ? "Paid" : "")</td>
<td class="text-end">@payment.AmountFormatted</td>
</tr>
<tr class="payment-data">
<tr>
<td colspan="2" class="text-end">@payment.PaidFormatted</td>
</tr>
<tr class="payment-data">
<tr>
<td class="text-nowrap text-secondary">Rate</td>
<td class="text-end">@payment.RateFormatted</td>
</tr>
@if (!string.IsNullOrEmpty(payment.Destination))
{
<tr class="payment-data">
<tr>
<td class="text-nowrap text-secondary">Destination</td>
<td class="text-break">
@if (payment.Destination.Length > 69)
{
<span>
<span>@payment.Destination[..19]</span>
<span>@payment.Destination[..30]</span>
<span>...</span>
<span>@payment.Destination.Substring(payment.Destination.Length - 20, 20)</span>
<span>@payment.Destination.Substring(payment.Destination.Length - 30, 30)</span>
</span>
}
else
@ -245,7 +192,7 @@
}
@if (!string.IsNullOrEmpty(payment.PaymentProof))
{
<tr class="payment-data">
<tr>
<td class="text-nowrap text-secondary">Pay Proof</td>
<td class="text-break">@payment.PaymentProof</td>
</tr>
@ -269,9 +216,7 @@
<hr class="w-100 my-0 bg-none"/>
</center>
</body>
<script src="~/main/utils.js" asp-append-version="true"></script>
<script>
formatDateTimes();
window.print();
</script>
</html>

@ -3,6 +3,7 @@
@model BTCPayServer.Services.Altcoins.Monero.UI.UIMoneroLikeStoreController.MoneroLikePaymentMethodListViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage("Monero Settings", "Monero Settings", "Monero Settings");
ViewData["NavPartialName"] = "../UIStores/_Nav";
}

@ -144,7 +144,7 @@
<div class="accordion-body">
<div class="form-group">
<label asp-for="CustomCSSLink" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener" title="More information...">
<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" />

@ -74,7 +74,7 @@
<div class="accordion-body">
<div class="form-group">
<label asp-for="CustomCSSLink" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener">
<a href="https://docs.btcpayserver.org/Development/Theme/#2-bootstrap-themes" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<input asp-for="CustomCSSLink" class="form-control" />

@ -58,7 +58,7 @@
<select class="form-select w-auto" asp-for="SelectedPaymentMethod" asp-items="Model.PaymentMethods.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString()))"></select>
}
<button type="button" class="btn btn-secondary only-for-js" data-bs-toggle="modal" data-bs-target="#scanModal" title="Scan destination with camera" id="scandestination-button">
<vc:icon symbol="scan-qr"/>
<i class="fa fa-camera"></i>
</button>
</div>
</div>
@ -202,7 +202,7 @@
</p>
@if (Model.LnurlEndpoint is not null)
{
<p id="BoltcardActions">
<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>
@ -229,10 +229,11 @@
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("SetupBoltcard").setAttribute('href', @Safe.Json(@Model.SetupDeepLink));
document.getElementById("ResetBoltcard").setAttribute('target', '_blank');
document.getElementById("ResetBoltcard").setAttribute('href', @Safe.Json(Model.ResetDeepLink));
document.getElementById("ResetBoltcard").setAttribute('href', @Safe.Json(@Model.ResetDeepLink));
}
document.getElementById("BoltcardActions").style.visibility = "visible";
window.qrApp = initQRShow({});
delegate('click', 'button[page-qr]', event => {

@ -20,7 +20,6 @@
#app .table-responsive { max-height: 80vh; }
#app #charts { gap: var(--btcpay-space-l) var(--btcpay-space-xxl); }
#app #charts article { flex: 1 1 450px; }
main .dropdown-menu.show { z-index: 99999; }
</style>
}
@ -33,7 +32,7 @@
</a>
</h2>
<div class="d-flex flex-wrap gap-3">
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true" asp-route-viewName="@Model.Request?.ViewName">Create fake data</a>
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true" asp-route-viewName="@Model.Request?.ViewName">Create fake date</a>
<button id="exportCSV" class="btn btn-primary text-nowrap" type="button">Export</button>
</div>
</div>
@ -134,7 +133,7 @@
<template v-else-if="srv.result.fields[columnIndex].type === 'tx_id'">
<vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" link="getExplorerUrl(value, row[columnIndex-1])" />
</template>
<template v-else-if="value && ['Address'].includes(srv.result.fields[columnIndex].name)" >
<template v-else-if="value && ['Address', 'PaymentId'].includes(srv.result.fields[columnIndex].name)" >
<vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" />
</template>
<template v-else-if="srv.result.fields[columnIndex].type === 'datetime'">{{ displayDate(value) }}</template>

Some files were not shown because too many files have changed in this diff Show More