Compare commits

..

29 Commits

Author SHA1 Message Date
78f33f0ca4 UI: Allow iframe context to get defined by opener (#6615)
The CSS to hide certain elements in the iframe context was introduced in #6574.

The mechanism to define the iframe context is now shifted to the opener, so that we can limit that behaviour to the mobile app.

Closes #6614.
2025-03-03 22:27:49 +09:00
a7e3cbb105 Adding endpoint to set server email settings (#6601)
* Adding endpoint in Greenfield to allow server email settings

* Adding related swagger file

* Refactoring EmailSettingsData to be more readable

* Adding server email masking

* Adding tests

* Update BTCPayServer/wwwroot/swagger/v1/swagger.template.serveremail.json

Co-authored-by: d11n <mail@dennisreimann.de>

* Masking smtp server email returned over greenfield api and test

* Retaining password if password mask is used

* Remove magic string *****

* Flatten request for server's settings. Fix bug on shared setting instances

* Remove useless doc

* Simplify code

* Fix Store Email settings page

---------

Co-authored-by: d11n <mail@dennisreimann.de>
Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2025-02-27 15:59:17 +09:00
8b5c5895f0 Selenium tests for Multisig on server (#6487)
* Adding MultisigTests

* Adding fetching of receive address and creating pending transaction

* Completing multisig test flow

* Reverting Selenium ChromeDriver version

* Adding generation of PSBTs

* Removing unnecessary lines

* PSBT test signing now working with multisig dervation scheme

* Updating SignTestPSBT test

* Reducing number of iterations for test funding, to speed up tests

* Bugfixing PSBT problem

* Ensuring that PSBT signing also works for pending transactions

* Ensuring we don't collect count duplicate signatures for same PSBTs

* Resolving bug in PendingTransactionService where Combine was modifying object

* Fixing bug where pending transaction was not broadcased if there was ReturnUrl

* Finally finishing Multisig Selenium test flow with signing PSBTs, broadcasting and cancelling them

* Small nit, waiting loaded element

* Nit: Use AssetElementNotFound

* Fix warning

* Remove code dups

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2025-02-25 14:39:57 +09:00
9d5baabc2c UI: Fix spacing of Lightning Address info on invoice details page 2025-02-19 09:38:12 +01:00
a7e910f7ff Merge pull request #6602 from btcpayserver/feat/lnd-0.18.5
Bumping LND to 0.18.5-beta
2025-02-18 23:36:32 -05:00
148e31721b Bumping LND to 0.18.5-beta in all yml files we've got 2025-02-18 22:17:24 -06:00
df16ad6418 Bumping LND to 0.18.5-beta 2025-02-18 22:09:55 -06:00
1c8ded9362 Simple nullability fix for StoreRepo.UpdateSettings 2025-02-15 10:18:22 +09:00
9e58a50dfd Merge pull request #6598 from dennisreimann/dashboard-remove-store-name
Dashboard: Remove store name headline
2025-02-11 14:25:06 +09:00
63750d69c4 Merge pull request #6596 from dennisreimann/fix-6592
Forms: Properly support checkbox type
2025-02-11 14:24:16 +09:00
cf6a356e08 Merge pull request #6597 from dennisreimann/fix-6590
Lightning Address: Display validation messages on failed creation
2025-02-11 14:23:21 +09:00
1c6657b8b4 Dashboard: Remove store name headline
Saves some space and isn't really necessary as the current store name is also visible in the sidebar.
2025-02-10 18:47:20 +01:00
32f1d5ea1d Lightning Address: Display validation messages on failed creation
Fixes #6590.
2025-02-10 15:27:36 +01:00
1d44cad847 Forms: Remove unsupported input types 2025-02-10 15:08:58 +01:00
88d8d1b848 Forms: Properly support checkbox type
Fixes #6592.
2025-02-10 15:08:03 +01:00
df82860ada Merge pull request #6594 from NicolasDorier/bump-nbx
Bump NBX
2025-02-10 16:00:36 +09:00
b184067df7 Merge pull request #6595 from NicolasDorier/replacement-log
If an On-Chain payment get replaced, log it in invoice logs rather than console
2025-02-10 15:46:19 +09:00
31c1d4795f If an On-Chain payment get replaced, log it in invoice logs rather than console 2025-02-10 15:29:38 +09:00
e59684fc6a Bump NBX 2025-02-10 15:26:35 +09:00
ddea59cb1b Merge pull request #6589 from NicolasDorier/refactbackreturn
Refactor: Remove cshtml duplication for back/url buttons in wizards
2025-02-07 17:13:04 +09:00
c37584328b Refactor: Remove cshtml duplication for back/url buttons in wizards 2025-02-07 16:57:50 +09:00
60b317a972 Merge pull request #6580 from reneaaron/fix/remove-lnurl-desc-hash-check
Remove LNURL description hash check
2025-02-06 19:02:42 +09:00
4fbcd89bb6 Only change opacity of amount 2025-02-06 17:27:09 +09:00
039e613524 fix: remove lnurl description hash check 2025-01-28 14:57:44 +01:00
192d339a79 Fix: Wallet's transaction had null blockhash on greenfield 2025-01-28 14:42:06 +09:00
7faf95552a Merge pull request #6577 from NicolasDorier/fartcoin
Fix: Invalid currency pair (FARTCOIN_USDC) may show in the logs when using kraken rate provider
2025-01-24 22:58:16 +09:00
bc1a1cf34f Fix: Invalid currency pair (FARTCOIN_USDC) may show in the logs when using kraken rate provider 2025-01-24 21:56:20 +09:00
9f4acdf8be Merge pull request #6576 from dennisreimann/fix-unconf
UI: Display unconfirmed transactions with lower opacity
2025-01-24 21:00:58 +09:00
8fc4aefa8f UI: Display unconfirmed transactions with lower opacity
Fixes a regression introduced in #6190 and also adds this display style to the dashboard list of recent transactions.
2025-01-24 10:08:14 +01:00
63 changed files with 890 additions and 351 deletions

View File

@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Client;
public partial class BTCPayServerClient
{
public virtual async Task<ServerEmailSettingsData> GetServerEmailSettings(CancellationToken token = default)
{
return await SendHttpRequest<ServerEmailSettingsData>("api/v1/server/email", null, HttpMethod.Get, token);
}
public virtual async Task<bool> UpdateServerEmailSettings(ServerEmailSettingsData request, CancellationToken token = default)
{
return await SendHttpRequest<bool>("api/v1/server/email", request, HttpMethod.Put, token);
}
}

View File

@ -4,29 +4,11 @@ namespace BTCPayServer.Client.Models;
public class EmailSettingsData
{
public string Server
{
get; set;
}
public int? Port
{
get; set;
}
public string Login
{
get; set;
}
public string Password
{
get; set;
}
public string From
{
get; set;
}
public string Server { get; set; }
public int? Port { get; set; }
public string Login { get; set; }
public string Password { get; set; }
public string From { get; set; }
public bool DisableCertificateCheck { get; set; }
[JsonIgnore]

View File

@ -0,0 +1,7 @@
namespace BTCPayServer.Client.Models
{
public class ServerEmailSettingsData : EmailSettingsData
{
public bool EnableStoresToUseServerEmailSettings { get; set; }
}
}

View File

@ -2,7 +2,7 @@
<Import Project="../Build/Version.csproj" Condition="Exists('../Build/Version.csproj')" />
<Import Project="../Build/Common.csproj" />
<ItemGroup>
<PackageReference Include="NBXplorer.Client" Version="4.3.6" />
<PackageReference Include="NBXplorer.Client" Version="4.3.9" />
<PackageReference Include="NicolasDorier.StandardConfiguration" Version="2.0.1" />
</ItemGroup>
</Project>

View File

@ -38,8 +38,6 @@ namespace BTCPayServer.Rating
ArgumentNullException.ThrowIfNull(str);
value = null;
str = str.Trim();
if (str.Length > 12)
return false;
var splitted = str.Split(new[] { '_', '-' }, StringSplitOptions.RemoveEmptyEntries);
if (splitted.Length == 2)
{

View File

@ -44,9 +44,9 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.11" />
<PackageReference Include="Selenium.Support" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver" Version="4.22.0" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="128.0.6613.11900" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="133.0.6943.5300" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

View File

@ -19,6 +19,19 @@ namespace BTCPayServer.Tests
{
public static class Extensions
{
public static Task<NewTransactionEvent> WaitReceive(this NBXplorer.WebsocketNotificationSession notifications, DerivationStrategyBase target, Func<NewTransactionEvent, bool> predicate = null, CancellationToken cancellationToken = default)
=> WaitNext<NewTransactionEvent>(notifications, e => e.DerivationStrategy == target && (predicate is null || predicate(e)), cancellationToken);
public static async Task<TEvent> WaitNext<TEvent>(this NBXplorer.WebsocketNotificationSession notifications, Func<TEvent, bool> predicate, CancellationToken cancellationToken = default) where TEvent : NewEventBase
{
retry:
var evt = await notifications.NextEventAsync(cancellationToken);
if (evt is TEvent { } e)
{
if (predicate(e))
return e;
}
goto retry;
}
public static Task<KeyPathInformation> ReserveAddressAsync(this BTCPayWallet wallet, DerivationStrategyBase derivationStrategyBase)
{
return wallet.ReserveAddressAsync(null, derivationStrategyBase, "test");

View File

@ -0,0 +1,227 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.BIP78.Sender;
using BTCPayServer.Controllers;
using BTCPayServer.Events;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Views.Wallets;
using NBitcoin;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using OpenQA.Selenium;
using Xunit;
using Xunit.Abstractions;
namespace BTCPayServer.Tests.FeatureTests;
[Collection(nameof(NonParallelizableCollectionDefinition))]
public class MultisigTests : UnitTestBase
{
public MultisigTests(ITestOutputHelper helper) : base(helper)
{
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task SignTestPSBT()
{
var cryptoCode = "BTC";
using var s = CreateSeleniumTester();
await s.StartAsync();
var network = s.Server.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var resp1 = generateWalletResp("tprv8ZgxMBicQKsPeGSkDtxjScBmmHP4rfSEPkf1vNmoqt5QjPTco2zPd6UVWkJf2fU8gdKPYRdDMizxtMRqmpVpxsWuqRxVs2d5VsEhwxaK3h7",
"57b3f43a/84'/1'/0'", "tpubDCzBHRPRcv7Y3utw1hZVrCar21gsj8vsXcehAG4z3R4NnmdMAASQwYYxGBd2f4q5s5ZFGvQBBFs1jVcGsXYoSTA1YFQPwizjsQLU12ibLyu", network);
var resp2 = generateWalletResp("tprv8ZgxMBicQKsPeC6Xuw83UJHgjnszEUjwH9E5f5FZ3fHgJHBQApo8CmFCsowcdwbRM119UnTqSzVWUsWGtLsxc8wnZa5L8xmEsvEpiyRj4Js",
"ee7d36c4/84'/1'/0'", "tpubDCetxnEjn8HXA5NrDZbKKTUUYoWCVC2V3X7Kmh3o9UYTfh9c3wTPKyCyeUrLkQ8KHYptEsBoQq6AgqPZiW5neEgb2kjKEr41q1qSevoPFDM", network);
var resp3 = generateWalletResp("tprv8ZgxMBicQKsPekSniuKwLtXpB82dSDV8ZAK4uLUHxkiHWfDtR5yYwNZiicKdpT3UYwzTTMvXESCm45KyAiH7kiJY6yk51neC9ZvmwDpNsQh",
"6c014fb3/84'/1'/0'", "tpubDCaTgjJfS5UEim6h66VpQBEZ2Tj6hHk8TzvL81HygdW1M8vZCRhUZLNhb3WTimyP2XMQRA3QGZPwwxUsEFQYK4EoRUWTcb9oB237FJ112tN", network);
var multisigDerivationScheme = $"wsh(multi(2,[{resp1.AccountKeyPath}]{resp1.DerivationScheme}/0/*," +
$"[{resp2.AccountKeyPath}]{resp2.DerivationScheme}/0/*," +
$"[{resp3.AccountKeyPath}]{resp3.DerivationScheme}/0/*))";
var strategy = UIStoresController.ParseDerivationStrategy(multisigDerivationScheme, network);
strategy.Source = "ManualDerivationScheme";
var derivationScheme = strategy.AccountDerivation;
var testPSBT =
"cHNidP8BAIkCAAAAAQmiSunnaKN7F4Jv5uHROfYbIZOckCck/Wo7gAQmi9hfAAAAAAD9////AtgbZgAAAAAAIgAgWCUFlU9eWkyxn0l0yQxs2rXQZ7d9Ry8LaYECaVC0TUGAlpgAAAAAACIAIFZxT+UIdhHZC4qFPhPQ6IXdX+44HIxCYcoh/bNOhB0hAAAAAAABAStAAf8AAAAAACIAIL2DDkfKwKHxZj2EKxXUd4uwf0IvPaCxUtAPq9snpq9TAQDqAgAAAAABAVuHuou9E5y6zUJaUreQD0wUeiPnT2aY+YU7QaPJOiQCAAAAAAD9////AkAB/wAAAAAAIgAgvYMOR8rAofFmPYQrFdR3i7B/Qi89oLFS0A+r2yemr1PM5AYpAQAAABYAFIlFupZkD07+GRo24WRS3IFcf+EuAkcwRAIgGi9wAcTfc0d0+j+Vg82aYklXCUsPg+g3jS+PTBTSQwkCIAPh5CZF18DTBKqWU2qdhNCbZ8Tp/NCEHjLJRHcH0oluASECWnI1s9ozQRL2qbK6JbLHzj9LlU9Pras3nZfq/njBJwhwAAAAAQVpUiECMCCasr2FRmRMiWkM/l1iraFR18td5SZ2APyQiaI0yY8hA8K96vH64BelUJiEPGwM6UTwRSfAJUR2j8dkw7i31fFTIQMlHLlaAPxw3fl1vaM1EofIirt79MXOryM54zpHwu1GlVOuIgIDwr3q8frgF6VQmIQ8bAzpRPBFJ8AlRHaPx2TDuLfV8VNHMEQCIANnprskJz8oVsetqOEViHtzhmSG8c36r3zmUIHwIoOhAiAZ1jBqj40iu2S/nMfiGyuCC/jSiSGik7YVwiwN+bbxPAEiBgIwIJqyvYVGZEyJaQz+XWKtoVHXy13lJnYA/JCJojTJjxhXs/Q6VAAAgAEAAIAAAACAAAAAAAUAAAAiBgMlHLlaAPxw3fl1vaM1EofIirt79MXOryM54zpHwu1GlRhsAU+zVAAAgAEAAIAAAACAAAAAAAUAAAAiBgPCverx+uAXpVCYhDxsDOlE8EUnwCVEdo/HZMO4t9XxUxjufTbEVAAAgAEAAIAAAACAAAAAAAUAAAAAAQFpUiEDa/J6SaiRjP1jhq9jpNxFKovEuWBz28seNMvsn0JC/ZIhA7p3bS7vLYB5UxlNN6YqkEDITyaMlk/i450q6+4woveAIQPTchIOrd+TNGBOX6il1HRZnBndyRoUj/hahbjTaAGHglOuIgIDa/J6SaiRjP1jhq9jpNxFKovEuWBz28seNMvsn0JC/ZIYV7P0OlQAAIABAACAAAAAgAEAAAABAAAAIgIDundtLu8tgHlTGU03piqQQMhPJoyWT+LjnSrr7jCi94AY7n02xFQAAIABAACAAAAAgAEAAAABAAAAIgID03ISDq3fkzRgTl+opdR0WZwZ3ckaFI/4WoW402gBh4IYbAFPs1QAAIABAACAAAAAgAEAAAABAAAAAAEBaVIhA/fCRR3MWwCgNuXMvlWLonY+TurUKOHXOSHALCck62deIQPqeQXD8ws9SDEDXSyD6a3WFlIGH+gDUf2/xAfw8HxE8iEC3LBRJYYxRzIeg9NxLGvtfATvFaKsO9D7AUjoTLZzke5TriICAtywUSWGMUcyHoPTcSxr7XwE7xWirDvQ+wFI6Ey2c5HuGGwBT7NUAACAAQAAgAAAAIAAAAAADAAAACICA+p5BcPzCz1IMQNdLIPprdYWUgYf6ANR/b/EB/DwfETyGO59NsRUAACAAQAAgAAAAIAAAAAADAAAACICA/fCRR3MWwCgNuXMvlWLonY+TurUKOHXOSHALCck62deGFez9DpUAACAAQAAgAAAAIAAAAAADAAAAAA=";
var signedPsbt = SignWithSeed(testPSBT, derivationScheme, resp1);
s.TestLogs.LogInformation($"Signed PSBT: {signedPsbt}");
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanEnableAndUseMultisigWallet()
{
var cryptoCode = "BTC";
using var s = CreateSeleniumTester();
await s.StartAsync();
// var invoiceRepository = s.Server.PayTester.GetService<InvoiceRepository>();
s.RegisterNewUser(true);
var storeData = s.CreateNewStore();
var explorerProvider = s.Server.PayTester.GetService<ExplorerClientProvider>();
var client = explorerProvider.GetExplorerClient(cryptoCode);
var req = new GenerateWalletRequest { ScriptPubKeyType = ScriptPubKeyType.Segwit, SavePrivateKeys = true };
// var resp1 = await client.GenerateWalletAsync(req);
// s.TestLogs.LogInformation($"Created hot wallet 1: {resp1.DerivationScheme} | {resp1.AccountKeyPath} | {resp1.MasterHDKey.ToWif()}");
// var resp2 = await client.GenerateWalletAsync(req);
// s.TestLogs.LogInformation($"Created hot wallet 2: {resp2.DerivationScheme} | {resp2.AccountKeyPath} | {resp2.MasterHDKey.ToWif()}");
// var resp3 = await client.GenerateWalletAsync(req);
// s.TestLogs.LogInformation($"Created hot wallet 3: {resp3.DerivationScheme} | {resp3.AccountKeyPath} | {resp3.MasterHDKey.ToWif()}");
var network = s.Server.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var resp1 = generateWalletResp("tprv8ZgxMBicQKsPeGSkDtxjScBmmHP4rfSEPkf1vNmoqt5QjPTco2zPd6UVWkJf2fU8gdKPYRdDMizxtMRqmpVpxsWuqRxVs2d5VsEhwxaK3h7",
"57b3f43a/84'/1'/0'", "tpubDCzBHRPRcv7Y3utw1hZVrCar21gsj8vsXcehAG4z3R4NnmdMAASQwYYxGBd2f4q5s5ZFGvQBBFs1jVcGsXYoSTA1YFQPwizjsQLU12ibLyu", network);
var resp2 = generateWalletResp("tprv8ZgxMBicQKsPeC6Xuw83UJHgjnszEUjwH9E5f5FZ3fHgJHBQApo8CmFCsowcdwbRM119UnTqSzVWUsWGtLsxc8wnZa5L8xmEsvEpiyRj4Js",
"ee7d36c4/84'/1'/0'", "tpubDCetxnEjn8HXA5NrDZbKKTUUYoWCVC2V3X7Kmh3o9UYTfh9c3wTPKyCyeUrLkQ8KHYptEsBoQq6AgqPZiW5neEgb2kjKEr41q1qSevoPFDM", network);
var resp3 = generateWalletResp("tprv8ZgxMBicQKsPekSniuKwLtXpB82dSDV8ZAK4uLUHxkiHWfDtR5yYwNZiicKdpT3UYwzTTMvXESCm45KyAiH7kiJY6yk51neC9ZvmwDpNsQh",
"6c014fb3/84'/1'/0'", "tpubDCaTgjJfS5UEim6h66VpQBEZ2Tj6hHk8TzvL81HygdW1M8vZCRhUZLNhb3WTimyP2XMQRA3QGZPwwxUsEFQYK4EoRUWTcb9oB237FJ112tN", network);
var multisigDerivationScheme = $"wsh(multi(2,[{resp1.AccountKeyPath}]{resp1.DerivationScheme}/0/*," +
$"[{resp2.AccountKeyPath}]{resp2.DerivationScheme}/0/*," +
$"[{resp3.AccountKeyPath}]{resp3.DerivationScheme}/0/*))";
var strategy = UIStoresController.ParseDerivationStrategy(multisigDerivationScheme, network);
strategy.Source = "ManualDerivationScheme";
var derivationScheme = strategy.AccountDerivation;
s.GoToWalletSettings();
s.Driver.FindElement(By.Id("ImportWalletOptionsLink")).Click();
s.Driver.FindElement(By.Id("ImportXpubLink")).Click();
s.Driver.FindElement(By.Id("DerivationScheme")).SendKeys(multisigDerivationScheme);
s.Driver.FindElement(By.Id("Continue")).Click();
s.Driver.FindElement(By.Id("Confirm")).Click();
s.TestLogs.LogInformation($"Multisig wallet setup: {multisigDerivationScheme}");
// enabling multisig
s.Driver.FindElement(By.Id("IsMultiSigOnServer")).Click();
s.Driver.FindElement(By.Id("DefaultIncludeNonWitnessUtxo")).Click();
s.Driver.FindElement(By.Id("SaveWalletSettings")).Click();
Assert.Contains("Wallet settings successfully updated.", s.FindAlertMessage().Text);
// fetch address from receive page
s.Driver.FindElement(By.Id("WalletNav-Receive")).Click();
var address = s.Driver.FindElement(By.Id("Address")).GetAttribute("data-text");
s.Driver.FindElement(By.XPath("//button[@value='fill-wallet']")).Click();
s.Driver.FindElement(By.Id("CancelWizard")).Click();
// we are creating a pending transaction
s.Driver.FindElement(By.Id("WalletNav-Send")).Click();
s.Driver.FindElement(By.Id("Outputs_0__DestinationAddress")).SendKeys(address);
var amount = "0.1";
s.Driver.FindElement(By.Id("Outputs_0__Amount")).SendKeys(amount);
s.Driver.FindElement(By.Id("CreatePendingTransaction")).Click();
// now clicking on View to sign transaction
await SignPendingTransactionWithKey(s, address, derivationScheme, resp1);
await SignPendingTransactionWithKey(s, address, derivationScheme, resp2);
// Broadcasting transaction and ensuring there is no longer broadcast button
s.Driver.WaitForElement(By.XPath("//a[text()='Broadcast']")).Click();
s.Driver.FindElement(By.Id("BroadcastTransaction")).Click();
Assert.Contains("Transaction broadcasted successfully", s.FindAlertMessage().Text);
s.Driver.AssertElementNotFound(By.XPath("//a[text()='Broadcast']"));
// Abort pending transaction flow
s.Driver.FindElement(By.Id("WalletNav-Send")).Click();
s.Driver.FindElement(By.Id("Outputs_0__DestinationAddress")).SendKeys(address);
s.Driver.FindElement(By.Id("Outputs_0__Amount")).SendKeys("0.2");
s.Driver.FindElement(By.Id("CreatePendingTransaction")).Click();
s.Driver.FindElement(By.XPath("//a[text()='Abort']")).Click();
s.Driver.FindElement(By.Id("ConfirmContinue")).Click();
Assert.Contains("Aborted Pending Transaction", s.FindAlertMessage().Text);
s.TestLogs.LogInformation($"Finished MultiSig Flow");
}
private async Task SignPendingTransactionWithKey(SeleniumTester s, string address,
DerivationStrategyBase derivationScheme, GenerateWalletResponse signingKey)
{
// getting to pending transaction page
s.Driver.WaitForElement(By.XPath("//a[text()='View']")).Click();
var transactionRow = s.Driver.FindElement(By.XPath($"//tr[td[text()='{address}']]"));
Assert.NotNull(transactionRow);
var signTransactionButton = s.Driver.FindElement(By.Id("SignTransaction"));
Assert.NotNull(signTransactionButton);
// fetching PSBT
s.Driver.FindElement(By.Id("PSBTOptionsExportHeader")).Click();
s.Driver.WaitForElement(By.Id("ShowRawVersion")).Click();
var psbt = s.Driver.WaitForElement(By.Id("psbt-base64")).Text;
while (string.IsNullOrEmpty(psbt))
{
psbt = s.Driver.FindElement(By.Id("psbt-base64")).Text;
}
// signing PSBT and entering it to submit
var signedPsbt = SignWithSeed(psbt, derivationScheme, signingKey);
s.Driver.FindElement(By.Id("PSBTOptionsImportHeader")).Click();
s.Driver.WaitForElement(By.Id("ImportedPSBT")).SendKeys(signedPsbt);
s.Driver.FindElement(By.Id("Decode")).Click();
}
private GenerateWalletResponse generateWalletResp(string tpriv, string keypath, string derivation, BTCPayNetwork network)
{
var key1 = new BitcoinExtKey(
ExtKey.Parse(tpriv, Network.RegTest),
Network.RegTest);
var parser = new DerivationSchemeParser(network);
var resp1 = new GenerateWalletResponse
{
MasterHDKey = key1,
DerivationScheme = parser.Parse(derivation),
AccountKeyPath = RootedKeyPath.Parse(keypath)
};
return resp1;
}
public string SignWithSeed(string psbtBase64, DerivationStrategyBase derivationStrategyBase,
GenerateWalletResponse resp)
{
var strMasterHdKey = resp.MasterHDKey;
var extKey = new BitcoinExtKey(strMasterHdKey, Network.RegTest);
var strKeypath = resp.AccountKeyPath.ToStringWithEmptyKeyPathAware();
RootedKeyPath rootedKeyPath = RootedKeyPath.Parse(strKeypath);
if (rootedKeyPath.MasterFingerprint != extKey.GetPublicKey().GetHDFingerPrint())
throw new Exception("Master fingerprint mismatch. Ensure the wallet matches the PSBT.");
// finished setting variables, now onto signing
var psbt = PSBT.Parse(psbtBase64, Network.RegTest);
// Sign the PSBT
extKey = extKey.Derive(rootedKeyPath.KeyPath);
psbt.Settings.SigningOptions = new SigningOptions();
var changed = psbt.PSBTChanged(() => psbt.SignAll(derivationStrategyBase, extKey, rootedKeyPath));
if (!changed)
throw new Exception("Failed to sign the PSBT. Ensure the inputs align with the account key path.");
// Return the updated and signed PSBT
return psbt.ToBase64();
}
}

View File

@ -20,9 +20,11 @@ using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.Lightning;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
@ -1536,7 +1538,7 @@ namespace BTCPayServer.Tests
});
}
[Fact]
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanProcessPayoutsExternally()
{
@ -4091,6 +4093,45 @@ namespace BTCPayServer.Tests
await AssertAPIError("store-user-role-orphaned", async () => await employeeClient.RemoveStoreUser(user.StoreId, employee.UserId));
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task ServerEmailTests()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var admin = tester.NewAccount();
await admin.GrantAccessAsync(true);
var adminClient = await admin.CreateClient(Policies.Unrestricted);
var data = new EmailSettingsData
{
From = "admin@admin.com",
Login = "admin@admin.com",
Password = "admin@admin.com",
Port = 1234,
Server = "admin.com",
};
var serverEmailSettings = new ServerEmailSettingsData
{
EnableStoresToUseServerEmailSettings = false
};
await adminClient.UpdateServerEmailSettings(serverEmailSettings);
var s = await adminClient.GetServerEmailSettings();
// email password is masked and not returned from the server once set
serverEmailSettings.Password = null;
Assert.Equal(JsonConvert.SerializeObject(s), JsonConvert.SerializeObject(serverEmailSettings));
await AssertValidationError(new[] { nameof(EmailSettingsData.From) },
async () => await adminClient.UpdateServerEmailSettings(new ServerEmailSettingsData
{
From = "invalid"
}));
// NOTE: This email test fails silently in EmailSender.cs#31, can't test, but leaving for the future as reminder
//await adminClient.SendEmail(admin.StoreId,
// new SendEmailRequest { Body = "lol", Subject = "subj", Email = "to@example.org" });
}
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
@ -4114,6 +4155,8 @@ namespace BTCPayServer.Tests
};
await adminClient.UpdateStoreEmailSettings(admin.StoreId, data);
var s = await adminClient.GetStoreEmailSettings(admin.StoreId);
// email password is masked and not returned from the server once set
data.Password = null;
Assert.Equal(JsonConvert.SerializeObject(s), JsonConvert.SerializeObject(data));
await AssertValidationError(new[] { nameof(EmailSettingsData.From) },
async () => await adminClient.UpdateStoreEmailSettings(admin.StoreId,
@ -4824,9 +4867,9 @@ clientBasic.UpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguratio
clientBasic.UpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingeckoOOO", Spread = -1m }));
await AssertValidationError(new[] { "currencyPair" }, () =>
clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingecko" }, new[] { "BTC_USD_USD_BTC" }));
clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingecko" }, new[] { "BTCUSDUSDBTC" }));
await AssertValidationError(new[] { "PreferredSource", "currencyPair" }, () =>
clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingeckoOOO" }, new[] { "BTC_USD_USD_BTC" }));
clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingeckoOOO" }, new[] { "BTCUSDUSDBTC" }));
}
}
}

View File

@ -426,7 +426,6 @@ namespace BTCPayServer.Tests
var notifications = await nbx.CreateWebsocketNotificationSessionAsync();
var alice = tester.NewAccount();
await alice.RegisterDerivationSchemeAsync("BTC", ScriptPubKeyType.Segwit, true);
await notifications.ListenDerivationSchemesAsync(new[] { alice.DerivationScheme });
BitcoinAddress address = null;
for (int i = 0; i < 5; i++)
@ -434,7 +433,7 @@ namespace BTCPayServer.Tests
address = (await nbx.GetUnusedAsync(alice.DerivationScheme, DerivationFeature.Deposit)).Address;
await tester.ExplorerNode.GenerateAsync(1);
tester.ExplorerNode.SendToAddress(address, Money.Coins(1.0m));
await notifications.NextEventAsync();
await notifications.WaitReceive(alice.DerivationScheme);
}
var paymentAddress = new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest);
var otherAddress = new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest);
@ -569,10 +568,9 @@ namespace BTCPayServer.Tests
await notifications.DisposeAsync();
notifications = await nbx.CreateWebsocketNotificationSessionAsync();
await notifications.ListenDerivationSchemesAsync(new[] { bob.DerivationScheme });
address = (await nbx.GetUnusedAsync(bob.DerivationScheme, DerivationFeature.Deposit)).Address;
tester.ExplorerNode.SendToAddress(address, Money.Coins(1.1m));
await notifications.NextEventAsync();
await notifications.WaitReceive(bob.DerivationScheme);
await bob.ModifyOnchainPaymentSettings(p => p.PayJoinEnabled = true);
var invoice = bob.BitPay.CreateInvoice(
new Invoice { Price = 0.1m, Currency = "BTC", FullNotifications = true });
@ -603,7 +601,7 @@ namespace BTCPayServer.Tests
proposal = proposal.SignAll(derivationSchemeSettings.AccountDerivation, alice.GenerateWalletResponseV.AccountHDKey, signingAccount.GetRootedKeyPath());
proposal.Finalize();
await tester.ExplorerNode.SendRawTransactionAsync(proposal.ExtractTransaction());
await notifications.NextEventAsync();
await notifications.WaitReceive(bob.DerivationScheme);
TestLogs.LogInformation("Abusing minFeeRate should give not enough money error");
invoice = bob.BitPay.CreateInvoice(

View File

@ -32,6 +32,7 @@ using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitcoin.Payment;
using NBXplorer.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OpenQA.Selenium;
@ -690,7 +691,7 @@ namespace BTCPayServer.Tests
// Store Emails without server fallback
s.GoToStore(StoreNavPages.Emails);
s.Driver.ElementDoesNotExist(By.Id("UseCustomSMTP"));
s.Driver.ElementDoesNotExist(By.Id("IsCustomSMTP"));
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
Assert.Contains("You need to configure email settings before this feature works", s.Driver.PageSource);
@ -705,12 +706,12 @@ namespace BTCPayServer.Tests
// Store Emails with server fallback
s.GoToStore(StoreNavPages.Emails);
Assert.False(s.Driver.FindElement(By.Id("UseCustomSMTP")).Selected);
Assert.False(s.Driver.FindElement(By.Id("IsCustomSMTP")).Selected);
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
Assert.DoesNotContain("You need to configure email settings before this feature works", s.Driver.PageSource);
s.GoToStore(StoreNavPages.Emails);
s.Driver.FindElement(By.Id("UseCustomSMTP")).Click();
s.Driver.FindElement(By.Id("IsCustomSMTP")).Click();
Thread.Sleep(250);
CanSetupEmailCore(s);
@ -731,7 +732,7 @@ namespace BTCPayServer.Tests
Assert.Contains("Store email rules saved", s.FindAlertMessage().Text);
s.GoToStore(StoreNavPages.Emails);
Assert.True(s.Driver.FindElement(By.Id("UseCustomSMTP")).Selected);
Assert.True(s.Driver.FindElement(By.Id("IsCustomSMTP")).Selected);
}
[Fact(Timeout = TestTimeout)]
@ -1884,11 +1885,9 @@ namespace BTCPayServer.Tests
//send money to addr and ensure it changed
var sess = await s.Server.ExplorerClient.CreateWebsocketNotificationSessionAsync();
await sess.ListenAllTrackedSourceAsync();
var nextEvent = sess.NextEventAsync();
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(receiveAddr, Network.RegTest),
Money.Parse("0.1"));
await nextEvent;
await sess.WaitNext<NewTransactionEvent>(e => e.Outputs.FirstOrDefault()?.Address.ToString() == receiveAddr);
await Task.Delay(200);
s.Driver.Navigate().Refresh();
s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click();
@ -1898,6 +1897,7 @@ namespace BTCPayServer.Tests
// Check the label is applied to the tx
s.Driver.WaitWalletTransactionsLoaded();
// Sometimes this fails in local, but not CI
Assert.Equal("label2", s.Driver.FindElement(By.XPath("//*[@id=\"WalletTransactionsList\"]//*[contains(@class, 'transaction-label')]")).Text);
//change the wallet and ensure old address is not there and generating a new one does not result in the prev one
@ -1960,7 +1960,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("SignTransaction")).Click();
// Back button should lead back to the previous page inside the send wizard
var backUrl = s.Driver.FindElement(By.Id("GoBack")).GetAttribute("href");
Assert.EndsWith($"/send?returnUrl={walletTransactionUri.AbsolutePath}", backUrl);
Assert.EndsWith($"/send?returnUrl={Uri.EscapeDataString(walletTransactionUri.AbsolutePath)}", backUrl);
// Cancel button should lead to the page that referred to the send wizard
var cancelUrl = s.Driver.FindElement(By.Id("CancelWizard")).GetAttribute("href");
Assert.EndsWith(walletTransactionUri.AbsolutePath, cancelUrl);

View File

@ -967,7 +967,6 @@ namespace BTCPayServer.Tests
using (var cts = new CancellationTokenSource(10000))
using (var listener = tester.ExplorerClient.CreateWebsocketNotificationSession())
{
listener.ListenAllDerivationSchemes();
var replaced = tester.ExplorerNode.SignRawTransaction(tx);
Thread.Sleep(1000); // Make sure the replacement has a different timestamp
var tx2 = tester.ExplorerNode.SendRawTransaction(replaced);

View File

@ -98,7 +98,7 @@ services:
custom:
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.16
image: nicolasdorier/nbxplorer:2.5.22
restart: unless-stopped
ports:
- "32838:32838"
@ -229,7 +229,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.18.4-beta
image: btcpayserver/lnd:v0.18.5-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -267,7 +267,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.18.4-beta
image: btcpayserver/lnd:v0.18.5-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"

View File

@ -62,7 +62,7 @@ services:
custom:
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.16
image: nicolasdorier/nbxplorer:2.5.22
restart: unless-stopped
ports:
- "32838:32838"
@ -187,7 +187,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.18.4-beta
image: btcpayserver/lnd:v0.18.5-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -225,7 +225,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.18.4-beta
image: btcpayserver/lnd:v0.18.5-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"

View File

@ -57,7 +57,7 @@ services:
custom:
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.16
image: nicolasdorier/nbxplorer:2.5.22
restart: unless-stopped
ports:
- "32838:32838"
@ -177,7 +177,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.18.4-beta
image: btcpayserver/lnd:v0.18.5-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -215,7 +215,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.18.4-beta
image: btcpayserver/lnd:v0.18.5-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"

View File

@ -95,7 +95,7 @@ services:
custom:
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.16
image: nicolasdorier/nbxplorer:2.5.22
restart: unless-stopped
ports:
- "32838:32838"
@ -215,7 +215,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.18.4-beta
image: btcpayserver/lnd:v0.18.5-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -253,7 +253,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.18.4-beta
image: btcpayserver/lnd:v0.18.5-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"

View File

@ -44,11 +44,11 @@
@foreach (var tx in Model.Transactions)
{
<tr>
<td>@tx.Timestamp.ToTimeAgo()</td>
<td>
<td class="align-middle">@tx.Timestamp.ToTimeAgo()</td>
<td class="align-middle">
<vc:truncate-center text="@tx.Id" link="@tx.Link" classes="truncate-center-id" />
</td>
<td>
<td class="align-middle">
@if (tx.Labels.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@ -70,18 +70,9 @@
}
</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>
}
<td class="align-middle text-end">
<span data-sensitive class="text-@(tx.Positive ? "success" : "danger")@(tx.IsConfirmed ? "" : " opacity-50")">@DisplayFormatter.Currency(tx.Balance, tx.Currency)</span>
</td>
</tr>
}
</tbody>

View File

@ -0,0 +1,89 @@
#nullable enable
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers.GreenField
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldServerEmailController : Controller
{
private readonly EmailSenderFactory _emailSenderFactory;
private readonly PoliciesSettings _policiesSettings;
readonly SettingsRepository _settingsRepository;
public GreenfieldServerEmailController(EmailSenderFactory emailSenderFactory, PoliciesSettings policiesSettings, SettingsRepository settingsRepository)
{
_emailSenderFactory = emailSenderFactory;
_policiesSettings = policiesSettings;
_settingsRepository = settingsRepository;
}
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/server/email")]
public async Task<IActionResult> ServerEmailSettings()
{
var email = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
var model = new ServerEmailSettingsData
{
EnableStoresToUseServerEmailSettings = !_policiesSettings.DisableStoresToUseServerEmailSettings,
From = email.From,
Server = email.Server,
Port = email.Port,
Login = email.Login,
DisableCertificateCheck = email.DisableCertificateCheck,
// Password is not returned
Password = null
};
return Ok(model);
}
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/server/email")]
public async Task<IActionResult> ServerEmailSettings(ServerEmailSettingsData request)
{
if (_policiesSettings.DisableStoresToUseServerEmailSettings == request.EnableStoresToUseServerEmailSettings)
{
_policiesSettings.DisableStoresToUseServerEmailSettings = !request.EnableStoresToUseServerEmailSettings;
await _settingsRepository.UpdateSetting(_policiesSettings);
}
// save
if (request.From is not null && !MailboxAddressValidator.IsMailboxAddress(request.From))
{
request.AddModelError(e => e.From,
"Invalid email address", this);
return this.CreateValidationError(ModelState);
}
var oldSettings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
// retaining the password if it exists and was not provided in request
if (string.IsNullOrEmpty(request.Password) &&
!string.IsNullOrEmpty(oldSettings?.Password))
request.Password = oldSettings.Password;
// important to save as EmailSettings otherwise it won't be able to be fetched
await _settingsRepository.UpdateSetting(new EmailSettings
{
Server = request.Server,
Port = request.Port,
Login = request.Login,
Password = request.Password,
From = request.From,
DisableCertificateCheck = request.DisableCertificateCheck
});
return Ok(true);
}
}
}

View File

@ -55,7 +55,6 @@ namespace BTCPayServer.Controllers.GreenField
[HttpGet("~/api/v1/stores/{storeId}/email")]
public IActionResult GetStoreEmailSettings()
{
var store = HttpContext.GetStoreData();
return store == null ? StoreNotFound() : Ok(FromModel(store));
}
@ -76,7 +75,13 @@ namespace BTCPayServer.Controllers.GreenField
"Invalid email address", this);
return this.CreateValidationError(ModelState);
}
var blob = store.GetStoreBlob();
// retaining the password if it exists and was not provided in request
if (string.IsNullOrEmpty(request.Password) && blob.EmailSettings?.Password != null)
request.Password = blob.EmailSettings?.Password;
blob.EmailSettings = request;
if (store.SetStoreBlob(blob))
{
@ -87,7 +92,11 @@ namespace BTCPayServer.Controllers.GreenField
}
private EmailSettings FromModel(Data.StoreData data)
{
return data.GetStoreBlob().EmailSettings ?? new();
var emailSettings = data.GetStoreBlob().EmailSettings;
if (emailSettings == null)
return new EmailSettings();
emailSettings.Password = null;
return emailSettings;
}
private IActionResult StoreNotFound()
{

View File

@ -782,22 +782,13 @@ namespace BTCPayServer
DescriptionHashOnly = true
};
invoice = await client.CreateInvoice(param);
if (!BOLT11PaymentRequest.Parse(invoice.BOLT11, network.NBitcoinNetwork)
.VerifyDescriptionHash(description))
{
return BadRequest(new LNUrlStatusResponse
{
Status = "ERROR",
Reason = "Lightning node could not generate invoice with a valid description hash"
});
}
}
catch (Exception ex)
{
return BadRequest(new LNUrlStatusResponse
{
Status = "ERROR",
Reason = "Lightning node could not generate invoice with description hash" + (
Reason = "Lightning node could not generate invoice" + (
string.IsNullOrEmpty(ex.Message) ? "" : $": {ex.Message}")
});
}

View File

@ -1263,10 +1263,8 @@ namespace BTCPayServer.Controllers
return View(model);
}
var oldSettings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
if (new ServerEmailsViewModel(oldSettings).PasswordSet)
{
if (!string.IsNullOrEmpty(oldSettings.Password))
model.Settings.Password = oldSettings.Password;
}
await _SettingsRepository.UpdateSetting(model.Settings);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;

View File

@ -47,7 +47,7 @@ public partial class UIStoresController
{
vm.Rules ??= [];
int commandIndex = 0;
var indSep = command.Split(':', StringSplitOptions.RemoveEmptyEntries);
if (indSep.Length > 1)
{
@ -154,15 +154,15 @@ public partial class UIStoresController
{
[Required]
public string Trigger { get; set; }
public bool CustomerEmail { get; set; }
public string To { get; set; }
[Required]
public string Subject { get; set; }
[Required]
public string Body { get; set; }
}
@ -174,51 +174,65 @@ public partial class UIStoresController
if (store == null)
return NotFound();
var emailSender = await _emailSenderFactory.GetEmailSender(store.Id);
var data = await emailSender.GetEmailSettings() ?? new EmailSettings();
var fallbackSettings = emailSender is StoreEmailSender { FallbackSender: { } fallbackSender }
? await fallbackSender.GetEmailSettings()
: null;
var settings = data != fallbackSettings ? data : new EmailSettings();
return View(new EmailsViewModel(settings, fallbackSettings));
var settings = await GetCustomSettings(store.Id);
return View(new EmailsViewModel(settings.Custom ?? new())
{
IsFallbackSetup = settings.Fallback is not null,
IsCustomSMTP = settings.Custom is not null || settings.Fallback is null
});
}
record AllEmailSettings(EmailSettings Custom, EmailSettings Fallback);
private async Task<AllEmailSettings> GetCustomSettings(string storeId)
{
var sender = await _emailSenderFactory.GetEmailSender(storeId) as StoreEmailSender;
if (sender is null)
return new(null, null);
var fallback = sender.FallbackSender is { } fb ? await fb.GetEmailSettings() : null;
if (fallback?.IsComplete() is not true)
fallback = null;
return new(await sender.GetCustomSettings(), fallback);
}
[HttpPost("{storeId}/email-settings")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command, [FromForm] bool useCustomSMTP = false)
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
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)
var settings = await GetCustomSettings(store.Id);
model.IsFallbackSetup = settings.Fallback is not null;
if (!model.IsFallbackSetup)
model.IsCustomSMTP = true;
if (model.IsCustomSMTP)
{
model.Settings.Validate("Settings.", ModelState);
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
{
ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
}
if (!ModelState.IsValid)
return View(model);
}
var storeBlob = store.GetStoreBlob();
var currentSettings = store.GetStoreBlob().EmailSettings;
if (model is { IsCustomSMTP: true, Settings: { Password: null } })
model.Settings.Password = currentSettings?.Password;
if (command == "Test")
{
try
{
if (useCustomSMTP)
{
if (model.PasswordSet)
{
model.Settings.Password = store.GetStoreBlob().EmailSettings.Password;
}
}
if (string.IsNullOrEmpty(model.TestEmail))
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
if (!ModelState.IsValid)
return View(model);
var settings = useCustomSMTP ? model.Settings : model.FallbackSettings;
using var client = await settings.CreateSmtpClient();
var message = settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{store.StoreName}: Email test", StringLocalizer["You received it, the BTCPay Server SMTP settings work."], false);
var clientSettings = (model.IsCustomSMTP ? model.Settings : settings.Fallback) ?? new();
using var client = await clientSettings.CreateSmtpClient();
var message = clientSettings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{store.StoreName}: Email test", StringLocalizer["You received it, the BTCPay Server SMTP settings work."], false);
await client.SendAsync(message);
await client.DisconnectAsync(true);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email sent to {0}. Please verify you received it.", model.TestEmail].Value;
@ -229,29 +243,24 @@ public partial class UIStoresController
}
return View(model);
}
if (command == "ResetPassword")
else if (command == "ResetPassword")
{
var storeBlob = store.GetStoreBlob();
storeBlob.EmailSettings.Password = null;
if (storeBlob.EmailSettings is not null)
storeBlob.EmailSettings.Password = null;
store.SetStoreBlob(storeBlob);
await _storeRepo.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
}
var unsetCustomSMTP = !useCustomSMTP && store.GetStoreBlob().EmailSettings is not null;
if (useCustomSMTP || unsetCustomSMTP)
else if (!model.IsCustomSMTP && currentSettings is not null)
{
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
{
ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
}
if (!ModelState.IsValid)
return View(model);
var storeBlob = store.GetStoreBlob();
if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, model.FallbackSettings).PasswordSet)
{
model.Settings.Password = storeBlob.EmailSettings.Password;
}
storeBlob.EmailSettings = unsetCustomSMTP ? null : model.Settings;
storeBlob.EmailSettings = null;
store.SetStoreBlob(storeBlob);
await _storeRepo.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["You are now using server's email settings"].Value;
}
else if (model.IsCustomSMTP)
{
storeBlob.EmailSettings = model.Settings;
store.SetStoreBlob(storeBlob);
await _storeRepo.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings modified"].Value;

View File

@ -789,7 +789,7 @@ public partial class UIStoresController
$"The store won't be able to receive {cryptoCode} onchain payments until a new wallet is set up.");
}
private DerivationSchemeSettings ParseDerivationStrategy(string derivationScheme, BTCPayNetwork network)
internal static DerivationSchemeSettings ParseDerivationStrategy(string derivationScheme, BTCPayNetwork network)
{
var parser = new DerivationSchemeParser(network);
var isOD = Regex.Match(derivationScheme, @"\(.*?\)");

View File

@ -239,6 +239,7 @@ namespace BTCPayServer.Controllers
vm.NBXSeedAvailable = await CanUseHotWallet() && derivationSchemeSettings.IsHotWallet;
vm.BackUrl ??= HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath;
vm.SigningContext.PSBT = vm.PSBT;
var psbt = await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
if (vm.InvalidPSBT)
{
@ -259,6 +260,18 @@ namespace BTCPayServer.Controllers
ModelState.Remove(nameof(vm.PSBT));
ModelState.Remove(nameof(vm.FileName));
ModelState.Remove(nameof(vm.UploadedPSBTFile));
// for pending transactions we collect signature from PSBT and redirect if everything is good
if (vm.SigningContext.PendingTransactionId is not null)
{
return await RedirectToWalletPSBTReady(walletId,
new WalletPSBTReadyViewModel
{
SigningContext = vm.SigningContext, ReturnUrl = vm.ReturnUrl, BackUrl = vm.BackUrl
});
}
// for regular transactions we decode PSBT and show the details
await FetchTransactionDetails(walletId, derivationSchemeSettings, vm, network);
return View("WalletPSBTDecoded", vm);
@ -603,16 +616,18 @@ namespace BTCPayServer.Controllers
{
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Transaction broadcasted successfully ({0})", transaction.GetHash()].Value;
}
if (!string.IsNullOrEmpty(vm.ReturnUrl))
{
return LocalRedirect(vm.ReturnUrl);
}
if (vm.SigningContext.PendingTransactionId is not null)
{
await _pendingTransactionService.Broadcasted(walletId.CryptoCode, walletId.StoreId,
vm.SigningContext.PendingTransactionId);
}
if (!string.IsNullOrEmpty(vm.ReturnUrl))
{
return LocalRedirect(vm.ReturnUrl);
}
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
}
case "analyze-psbt":

View File

@ -462,7 +462,7 @@ namespace BTCPayServer.Controllers
{
await cashCow.SendCommandAsync("rescanblockchain");
}
var addresses = Enumerable.Range(0, 200).Select(_ => c.GetUnusedAsync(paymentMethod.AccountDerivation, DerivationFeature.Deposit, reserve: true)).ToArray();
var addresses = Enumerable.Range(0, 10).Select(_ => c.GetUnusedAsync(paymentMethod.AccountDerivation, DerivationFeature.Deposit, reserve: true)).ToArray();
await Task.WhenAll(addresses);
await cashCow.GenerateAsync(addresses.Length / 8);

View File

@ -12,6 +12,10 @@ namespace Microsoft.AspNetCore.Mvc
public static class UrlHelperExtensions
{
#nullable enable
public static string? WalletSend(this IUrlHelper helper, WalletId walletId) => helper.Action(nameof(UIWalletsController.WalletSend), new { walletId });
public static string? WalletTransactions(this IUrlHelper helper, string walletId) => WalletTransactions(helper, WalletId.Parse(walletId));
public static string? WalletTransactions(this IUrlHelper helper, WalletId walletId)
=> helper.Action(nameof(UIWalletsController.WalletTransactions), new { walletId });
public static Uri ActionAbsolute(this IUrlHelper helper, HttpRequest request, string? action, string? controller, object? values)
=> request.GetAbsoluteUriNoPathBase(new Uri(helper.Action(action, controller, values) ?? "", UriKind.Relative));
public static Uri ActionAbsolute(this IUrlHelper helper, HttpRequest request, string? action, string? controller)

View File

@ -11,13 +11,9 @@ public class HtmlInputFormProvider : FormComponentProviderBase
{
foreach (var t in new[] {
"text",
"radio",
"checkbox",
"password",
"file",
"hidden",
"button",
"submit",
"color",
"date",
"datetime-local",
@ -25,13 +21,9 @@ public class HtmlInputFormProvider : FormComponentProviderBase
"week",
"time",
"email",
"image",
"number",
"range",
"search",
"url",
"tel",
"reset"})
"tel"})
typeToComponentProvider.Add(t, this);
}
public override string View => "Forms/InputElement";

View File

@ -123,12 +123,7 @@ public class PendingTransactionService(
await using var ctx = dbContextFactory.CreateContext();
var pendingTransaction =
await ctx.PendingTransactions.FindAsync(new object[] { cryptoCode, txId.ToString() }, cancellationToken);
if (pendingTransaction is null)
{
return null;
}
if (pendingTransaction.State != PendingTransactionState.Pending)
if (pendingTransaction is null || pendingTransaction.State != PendingTransactionState.Pending)
{
return null;
}
@ -138,21 +133,43 @@ public class PendingTransactionService(
{
return null;
}
var originalPsbtWorkingCopy = PSBT.Parse(blob.PSBT, network.NBitcoinNetwork);
// Deduplicate: Check if this exact PSBT (Base64) was already collected
var newPsbtBase64 = psbt.ToBase64();
if (blob.CollectedSignatures.Any(s => s.ReceivedPSBT == newPsbtBase64))
{
return pendingTransaction; // Avoid duplicate signature collection
}
foreach (var collectedSignature in blob.CollectedSignatures)
{
var collectedPsbt = PSBT.Parse(collectedSignature.ReceivedPSBT, network.NBitcoinNetwork);
originalPsbtWorkingCopy = originalPsbtWorkingCopy.Combine(collectedPsbt);
originalPsbtWorkingCopy.Combine(collectedPsbt); // combine changes the object
}
var originalPsbtWorkingCopyWithNewPsbt = originalPsbtWorkingCopy.Combine(psbt);
//check if we have more signatures than before
if (originalPsbtWorkingCopyWithNewPsbt.Inputs.All(i =>
i.PartialSigs.Count >= originalPsbtWorkingCopy.Inputs[(int)i.Index].PartialSigs.Count))
var originalPsbtWorkingCopyWithNewPsbt = originalPsbtWorkingCopy.Clone(); // Clone before modifying
originalPsbtWorkingCopyWithNewPsbt.Combine(psbt);
// Check if new signatures were actually added
bool newSignaturesCollected = false;
for (int i = 0; i < originalPsbtWorkingCopy.Inputs.Count; i++)
{
if (originalPsbtWorkingCopyWithNewPsbt.Inputs[i].PartialSigs.Count >
originalPsbtWorkingCopy.Inputs[i].PartialSigs.Count)
{
newSignaturesCollected = true;
break;
}
}
if (newSignaturesCollected)
{
blob.CollectedSignatures.Add(new CollectedSignature
{
ReceivedPSBT = psbt.ToBase64(), Timestamp = DateTimeOffset.UtcNow
ReceivedPSBT = newPsbtBase64,
Timestamp = DateTimeOffset.UtcNow
});
pendingTransaction.SetBlob(blob);
}
@ -163,6 +180,7 @@ public class PendingTransactionService(
}
await ctx.SaveChangesAsync(cancellationToken);
if (broadcastIfComplete && pendingTransaction.State == PendingTransactionState.Signed)
{
var explorerClient = explorerClientProvider.GetExplorerClient(network);
@ -182,6 +200,8 @@ public class PendingTransactionService(
return pendingTransaction;
}
public async Task<PendingTransaction?> GetPendingTransaction(string cryptoCode, string storeId, string txId)
{
await using var ctx = dbContextFactory.CreateContext();

View File

@ -7,9 +7,8 @@ namespace BTCPayServer.Models;
public class EmailsViewModel
{
public EmailSettings Settings { get; set; }
public EmailSettings FallbackSettings { get; set; }
public bool PasswordSet { get; set; }
[MailboxAddress]
[Display(Name = "Test Email")]
public string TestEmail { get; set; }
@ -18,14 +17,14 @@ public class EmailsViewModel
{
}
public EmailsViewModel(EmailSettings settings, EmailSettings fallbackSettings = null)
public EmailsViewModel(EmailSettings settings)
{
Settings = settings;
FallbackSettings = fallbackSettings;
PasswordSet = !string.IsNullOrEmpty(settings?.Password);
PasswordSet = !string.IsNullOrWhiteSpace(settings?.Password);
}
public bool IsSetup() => Settings?.IsComplete() is true;
public bool IsFallbackSetup() => FallbackSettings?.IsComplete() is true;
public bool UsesFallback() => IsFallbackSetup() && Settings == FallbackSettings;
public bool IsFallbackSetup { get; set; }
public bool IsCustomSMTP { get; set; }
}

View File

@ -0,0 +1,25 @@
#nullable enable
using System;
using BTCPayServer.Controllers;
namespace BTCPayServer.Models
{
public interface IHasBackAndReturnUrl
{
string? BackUrl { get; set; }
string? ReturnUrl { get; set; }
(string? backUrl, string? returnUrl) NormalizeBackAndReturnUrl()
{
var backUrl = BackUrl;
if (backUrl is not null && ReturnUrl is not null)
{
var queryParam = $"returnUrl={Uri.EscapeDataString(ReturnUrl)}";
if (backUrl.Contains('?'))
backUrl = $"{backUrl}&{queryParam}";
else
backUrl = $"{backUrl}?{queryParam}";
}
return (backUrl, ReturnUrl);
}
}
}

View File

@ -4,7 +4,7 @@ using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels
{
public class SignWithSeedViewModel
public class SignWithSeedViewModel : IHasBackAndReturnUrl
{
public SigningContextModel SigningContext { get; set; } = new SigningContextModel();

View File

@ -7,7 +7,7 @@ using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels
{
public class WalletPSBTCombineViewModel
public class WalletPSBTCombineViewModel : IHasBackAndReturnUrl
{
public string OtherPSBT { get; set; }
[Display(Name = "PSBT to combine with…")]

View File

@ -5,7 +5,7 @@ using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels
{
public class WalletPSBTReadyViewModel
public class WalletPSBTReadyViewModel : IHasBackAndReturnUrl
{
public SigningContextModel SigningContext { get; set; } = new SigningContextModel();
public string SigningKey { get; set; }
@ -27,6 +27,12 @@ namespace BTCPayServer.Models.WalletViewModels
public string BalanceChange { get; set; }
public IEnumerable<TransactionTagModel> Labels { get; set; } = new List<TransactionTagModel>();
}
public class AmountViewModel
{
public bool Positive { get; set; }
public string BalanceChange { get; set; }
}
public AmountViewModel ReplacementBalanceChange { get; set; }
public bool HasErrors => Inputs.Count == 0 || Inputs.Any(i => !string.IsNullOrEmpty(i.Error));
public string BalanceChange { get; set; }
public bool CanCalculateBalance { get; set; }

View File

@ -5,7 +5,7 @@ using BTCPayServer.Services.Labels;
namespace BTCPayServer.Models.WalletViewModels
{
public class WalletSendModel
public class WalletSendModel : IHasBackAndReturnUrl
{
public enum ThreeStateBool
{

View File

@ -1,6 +1,6 @@
namespace BTCPayServer.Models.WalletViewModels
{
public class WalletSendVaultModel
public class WalletSendVaultModel : IHasBackAndReturnUrl
{
public string WalletId { get; set; }
public string WebsocketPath { get; set; }

View File

@ -2,7 +2,7 @@ using System;
namespace BTCPayServer.Models.WalletViewModels
{
public class WalletSigningOptionsModel
public class WalletSigningOptionsModel : IHasBackAndReturnUrl
{
public SigningContextModel SigningContext { get; set; }
public string BackUrl { get; set; }

View File

@ -128,7 +128,7 @@ namespace BTCPayServer.Payments.Bitcoin
return;
if (_Cts.IsCancellationRequested)
return;
var session = await client.CreateWebsocketNotificationSessionAsync(_Cts.Token).ConfigureAwait(false);
var session = await client.CreateWebsocketNotificationSessionLegacyAsync(_Cts.Token).ConfigureAwait(false);
if (!_SessionsByCryptoCode.TryAdd(network.CryptoCode, session))
{
await session.DisposeAsync();
@ -291,7 +291,9 @@ namespace BTCPayServer.Payments.Bitcoin
result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED);
if (!accounted && payment.Accounted && tx.Confirmations != -1)
{
Logs.PayServer.LogInformation($"{wallet.Network.CryptoCode}: The transaction {tx.TransactionHash} has been replaced.");
var logs = new InvoiceLogs();
logs.Write($"The transaction {tx.TransactionHash} has been replaced.", InvoiceEventData.EventSeverity.Warning);
await _InvoiceRepository.AddInvoiceLogs(invoice.Id, logs);
}
if (paymentData.PayjoinInformation is PayjoinInformation pj)
{

View File

@ -29,14 +29,27 @@ namespace BTCPayServer.Services.Mails
var store = await StoreRepository.FindStore(StoreId);
if (store is null)
return null;
var emailSettings = GetCustomSettings(store);
if (emailSettings is not null)
return emailSettings;
if (FallbackSender is not null)
return await FallbackSender.GetEmailSettings();
return null;
}
public async Task<EmailSettings?> GetCustomSettings()
{
var store = await StoreRepository.FindStore(StoreId);
if (store is null)
return null;
return GetCustomSettings(store);
}
EmailSettings? GetCustomSettings(StoreData store)
{
var emailSettings = store.GetStoreBlob().EmailSettings;
if (emailSettings?.IsComplete() is true)
{
return emailSettings;
}
if (FallbackSender is not null)
return await FallbackSender.GetEmailSettings();
return null;
}

View File

@ -26,12 +26,13 @@ namespace BTCPayServer.Services
public async Task<T?> GetSettingAsync<T>(string? name = null) where T : class
{
name ??= typeof(T).FullName ?? string.Empty;
return await _memoryCache.GetOrCreateAsync(GetCacheKey(name), async entry =>
var data = await _memoryCache.GetOrCreateAsync(GetCacheKey(name), async entry =>
{
await using var ctx = _ContextFactory.CreateContext();
var data = await ctx.Settings.Where(s => s.Id == name).FirstOrDefaultAsync();
return data == null ? default : Deserialize<T>(data.Value);
return data?.Value;
});
return data is string ? Deserialize<T>(data) : null;
}
public async Task UpdateSetting<T>(T obj, string? name = null) where T : class
{
@ -49,7 +50,7 @@ namespace BTCPayServer.Services
await ctx.SaveChangesAsync();
}
}
_memoryCache.Set(GetCacheKey(name), obj);
_memoryCache.Remove(GetCacheKey(name));
_EventAggregator.Publish(new SettingsChanged<T>()
{
Settings = obj,

View File

@ -613,7 +613,7 @@ retry:
return data.ToDictionary(pair => pair.Key, pair => Deserialize<T>(pair.Value.Value));
}
public async Task UpdateSetting<T>(string storeId, string name, T obj) where T : class
public async Task UpdateSetting<T>(string storeId, string name, T? obj) where T : class
{
await using var ctx = _ContextFactory.CreateContext();
StoreSettingData? settings = null;

View File

@ -269,7 +269,7 @@ namespace BTCPayServer.Services.Wallets
SeenAt = row.seen_at,
TransactionId = uint256.Parse(row.tx_id),
Confirmations = row.confs,
BlockHash = string.IsNullOrEmpty(row.asset_id) ? null : uint256.Parse(row.blk_id)
BlockHash = string.IsNullOrEmpty(row.blk_id) ? null : uint256.Parse(row.blk_id)
});
}
return lines;

View File

@ -49,7 +49,7 @@
<label asp-for="Settings.Password" class="form-label" text-translate="true">Password</label>
@if (!Model.PasswordSet)
{
<input asp-for="Settings.Password" type="password" class="form-control"/>
<input asp-for="Settings.Password" type="password" value="@Model.Settings.Password" class="form-control" />
<span asp-validation-for="Settings.Password" class="text-danger"></span>
}
else

View File

@ -1,6 +1,7 @@
@model BTCPayServer.Abstractions.Form.Field
@{
var isInvalid = ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid;
var isCheckbox = Model.Type == "checkbox";
var errors = isInvalid ? ViewContext.ModelState[Model.Name].Errors : null;
}
@if (Model.Type == "hidden")
@ -8,21 +9,43 @@
<input id="@Model.Name" type="@Model.Type" name="@Model.Name" value="@Model.Value" />
return;
}
<div class="form-group">
<label class="form-label" for="@Model.Name"@(Model.Required ? " data-required" : "")>
@Safe.Raw(Model.Label)
</label>
<input id="@Model.Name" type="@Model.Type" class="form-control @(errors is null ? "" : "is-invalid")"
name="@Model.Name" value="@Model.Value" data-val="true" readonly="@Model.Constant"
@if (!string.IsNullOrEmpty(Model.HelpText))
{
@Safe.Raw($" aria-describedby=\"HelpText-{Model.Name}\"")
}
@if (Model.Required)
{
@Safe.Raw($" data-val-required=\"{Model.Label} is required.\" required")
}
/>
<div class="form-@(isCheckbox ? "check" : "group")">
@if (isCheckbox)
{
<input id="@Model.Name" type="@Model.Type" class="form-check-input @(errors is null ? "" : "is-invalid")"
name="@Model.Name" value="true" data-val="true" readonly="@Model.Constant"
@if (Model.Value == "true")
{
@Safe.Raw(" checked")
}
@if (!string.IsNullOrEmpty(Model.HelpText))
{
@Safe.Raw($" aria-describedby=\"HelpText-{Model.Name}\"")
}
@if (Model.Required)
{
@Safe.Raw($" data-val-required=\"{Model.Label} is required.\" required")
} />
<label class="form-check-label" for="@Model.Name"@(Model.Required ? " data-required" : "")>
@Safe.Raw(Model.Label)
</label>
}
else
{
<label class="form-label" for="@Model.Name"@(Model.Required ? " data-required" : "")>
@Safe.Raw(Model.Label)
</label>
<input id="@Model.Name" type="@Model.Type" class="form-control @(errors is null ? "" : "is-invalid")"
name="@Model.Name" value="@Model.Value" data-val="true" readonly="@Model.Constant"
@if (!string.IsNullOrEmpty(Model.HelpText))
{
@Safe.Raw($" aria-describedby=\"HelpText-{Model.Name}\"")
}
@if (Model.Required)
{
@Safe.Raw($" data-val-required=\"{Model.Label} is required.\" required")
} />
}
<span class="text-danger" data-valmsg-for="@Model.Name" data-valmsg-replace="true">@(isInvalid && errors.Any() ? errors.First().ErrorMessage : string.Empty)</span>
@if (!string.IsNullOrEmpty(Model.HelpText))
{

View File

@ -4,7 +4,7 @@
<script>
if (window.localStorage.getItem('btcpay-hide-sensitive-info') === 'true') { document.documentElement.setAttribute('data-hide-sensitive-info', 'true')}
if (window.location !== window.parent.location) { document.documentElement.setAttribute('data-within-iframe', 'true')}
if (window.location !== window.parent.location) { window.addEventListener('message', function(event) { try { const data = JSON.parse(event.data); if (data.context) { document.documentElement.setAttribute('data-context', data.context) } } catch {} }, false); }
</script>
@if (Theme.CustomTheme && Theme.CustomThemeCssUrl is not null)
{ // new customization uses theme file id provided by upload

View File

@ -78,13 +78,13 @@
</table>
@if (!string.IsNullOrEmpty(providedComment))
{
<div>
<div class="p-2">
<b>LNURL Comment</b>: @providedComment
</div>
}
@if (!string.IsNullOrEmpty(consumedLightningAddress))
{
<div>
<div class="p-2">
<b>Lightning address used</b>: @consumedLightningAddress
</div>
}

View File

@ -0,0 +1,15 @@
@model BTCPayServer.Models.IHasBackAndReturnUrl
@{
(var backUrl, var cancelUrl) = this.Model.NormalizeBackAndReturnUrl();
cancelUrl ??= Context.Request.Query["returnUrl"].ToString();
}
@if (backUrl != null)
{
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<vc:icon symbol="back" />
</a>
}
<a href="@Url.EnsureLocal(cancelUrl, Context.Request)" id="CancelWizard" class="cancel">
<vc:icon symbol="cross" />
</a>

View File

@ -149,7 +149,12 @@
</div>
</template>
<template id="field-type-input">
<div class="form-group mb-0">
<div class="form-check mb-0" v-if="type === 'checkbox'">
<input class="form-check-input" :id="name" :name="name" :type="type" v-model="value" />
<label class="form-check-label" :for="name" :data-required="required" v-sanitize="label"></label>
<div v-if="helpText" :id="`HelpText-{name}`" class="form-text" v-sanitize="helpText"></div>
</div>
<div class="form-groupcheck mb-0" v-else>
<label class="form-label" :for="name" :data-required="required" v-sanitize="label"></label>
<input class="form-control" :id="name" :name="name" :type="type" v-model="value" />
<div v-if="helpText" :id="`HelpText-{name}`" class="form-text" v-sanitize="helpText"></div>
@ -208,7 +213,7 @@
</h2>
</nav>
<div>
<button id="page-primary" type="submit" class="btn btn-primary order-sm-1">Save</button>
<button id="page-primary" type="submit" class="btn btn-primary order-sm-1">Save</button>
@if (!isNew)
{
<a class="btn btn-secondary" asp-action="ViewPublicForm" asp-route-formId="@formId" id="ViewForm" text-translate="true">View</a>

View File

@ -46,7 +46,7 @@
<form asp-action="EditLightningAddress" method="post">
@{
var showAddForm = !ViewContext.ViewData.ModelState.IsValid || !string.IsNullOrEmpty(Model.Add?.Username) || Model.Add?.Max != null || Model.Add?.Min != null || !string.IsNullOrEmpty(Model.Add?.CurrencyCode);
var showAdvancedOptions = !string.IsNullOrEmpty(Model.Add?.CurrencyCode) || Model.Add?.Min != null || Model.Add?.Max != null;
var showAdvancedOptions = !string.IsNullOrEmpty(Model.Add?.CurrencyCode) || !string.IsNullOrEmpty(Model.Add?.InvoiceMetadata) || Model.Add?.Min != null || Model.Add?.Max != null;
}
<div class="collapse @(showAddForm ? "show": "")" id="AddAddress">

View File

@ -14,7 +14,6 @@
var store = ViewContext.HttpContext.GetStoreData();
}
<h2 class="mb-2 mb-lg-3">@ViewData["Title"]</h2>
<partial name="_StatusMessage" />
@if (Model.IsSetUp)
@ -53,7 +52,7 @@
}
};
</script>
<div id="Dashboard" class="mt-4">
<div id="Dashboard">
<vc:ui-extension-point location="dashboard" model="@Model" />
@if (Model.WalletEnabled)
{

View File

@ -2,34 +2,33 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Models.EmailsViewModel
@{
var storeId = Context.GetStoreData().Id;
var hasCustomSettings = (Model.IsSetup() && !Model.UsesFallback()) || ViewBag.UseCustomSMTP ?? false;
ViewData.SetActivePage(StoreNavPages.Emails, StringLocalizer["Email Rules"], storeId);
var storeId = Context.GetStoreData().Id;
ViewData.SetActivePage(StoreNavPages.Emails, StringLocalizer["Email Rules"], storeId);
}
<form method="post" autocomplete="off" permissioned="@Policies.CanModifyStoreSettings">
<div class="sticky-header">
<h2 text-translate="true">Email Server</h2>
<div class="sticky-header">
<h2 text-translate="true">Email Server</h2>
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
</div>
<partial name="_StatusMessage" />
@if (Model.IsFallbackSetup())
{
<label class="d-flex align-items-center mb-4">
<input type="checkbox" id="UseCustomSMTP" name="UseCustomSMTP" value="true" checked="@hasCustomSettings" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings" aria-expanded="@hasCustomSettings" aria-controls="SmtpSettings" />
<div>
<span text-translate="true">Use custom SMTP settings for this store</span>
<div class="form-text" text-translate="true">Otherwise, the server's SMTP settings will be used to send emails.</div>
</div>
</label>
</div>
<partial name="_StatusMessage" />
@if (Model.IsFallbackSetup)
{
<label class="d-flex align-items-center mb-4">
<input type="checkbox" asp-for="IsCustomSMTP" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings" aria-expanded="@Model.IsCustomSMTP" aria-controls="SmtpSettings" />
<div>
<span text-translate="true">Use custom SMTP settings for this store</span>
<div class="form-text" text-translate="true">Otherwise, the server's SMTP settings will be used to send emails.</div>
</div>
</label>
<div class="collapse @(hasCustomSettings ? "show" : "")" id="SmtpSettings">
<partial name="EmailsBody" model="Model" />
<div class="collapse @(Model.IsCustomSMTP ? "show" : "")" id="SmtpSettings">
<partial name="EmailsBody" model="Model" />
</div>
}
else
{
<input type="hidden" name="UseCustomSMTP" value="true" />
<input type="hidden" id="IsCustomSMTPHidden" asp-for="IsCustomSMTP" />
<partial name="EmailsBody" model="Model" />
}

View File

@ -1,23 +1,14 @@
@using BTCPayServer.Controllers
@model SignWithSeedViewModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new { walletId });
var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null;
var walletId = Context.GetRouteValue("walletId").ToString();
Model.ReturnUrl ??= Url.WalletTransactions(walletId);
Layout = "_LayoutWizard";
ViewData.SetActivePage(WalletsNavPages.Send, StringLocalizer["Sign PSBT"], walletId);
}
@section Navbar {
@if (backUrl != null)
{
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<vc:icon symbol="back" />
</a>
}
<a href="@Url.EnsureLocal(cancelUrl, Context.Request)" id="CancelWizard" class="cancel">
<vc:icon symbol="cross" />
</a>
<partial name="_BackAndReturn" model="Model" />
}
<header class="text-center">

View File

@ -3,23 +3,14 @@
@model WalletPSBTViewModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new { walletId });
var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null;
Model.ReturnUrl ??= Url.WalletTransactions(walletId);
Layout = "_LayoutWizard";
ViewData.SetActivePage(WalletsNavPages.PSBT, StringLocalizer["Decode PSBT"], walletId);
Csp.UnsafeEval();
}
@section Navbar {
@if (backUrl != null)
{
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<vc:icon symbol="back" />
</a>
}
<a href="@Url.EnsureLocal(cancelUrl, Context.Request)" id="CancelWizard" class="cancel">
<vc:icon symbol="cross" />
</a>
<partial name="_BackAndReturn" model="Model" />
}
@section PageHeadContent {

View File

@ -2,22 +2,13 @@
@model WalletPSBTCombineViewModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new { walletId });
var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null;
Model.ReturnUrl ??= Url.WalletTransactions(walletId);
Layout = "_LayoutWizard";
ViewData.SetActivePage(WalletsNavPages.PSBT, StringLocalizer["Combine PSBT"], walletId);
}
@section Navbar {
@if (backUrl != null)
{
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<vc:icon symbol="back" />
</a>
}
<a href="@Url.EnsureLocal(cancelUrl, Context.Request)" id="CancelWizard" class="cancel">
<vc:icon symbol="cross" />
</a>
<partial name="_BackAndReturn" model="Model" />
}
<header class="text-center">

View File

@ -2,10 +2,9 @@
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model WalletPSBTViewModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new {walletId});
var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null;
var isReady = !Model.HasErrors;
var walletId = Context.GetRouteValue("walletId").ToString();
Model.ReturnUrl ??= Url.WalletTransactions(walletId);
var isReady = !Model.HasErrors;
var isSignable = !isReady;
var needsExport = !isSignable && !isReady;
Layout = "_LayoutWizard";
@ -78,15 +77,7 @@
}
@section Navbar {
@if (backUrl != null)
{
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<vc:icon symbol="back" />
</a>
}
<a href="@Url.EnsureLocal(cancelUrl, Context.Request)" id="CancelWizard" class="cancel">
<vc:icon symbol="cross" />
</a>
<partial name="_BackAndReturn" model="Model" />
}
<header class="text-center mb-3">
@ -212,6 +203,7 @@ else
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" enctype="multipart/form-data" class="mb-2">
<input type="hidden" asp-for="ReturnUrl" />
<input type="hidden" asp-for="BackUrl" />
<partial name="SigningContext" for="SigningContext" />
<div class="form-group">
<label for="ImportedPSBT" class="form-label" text-translate="true">PSBT content</label>
<textarea id="ImportedPSBT" name="PSBT" class="form-control" rows="5"></textarea>

View File

@ -7,8 +7,7 @@
@model WalletSendModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new { walletId });
var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null;
Model.ReturnUrl ??= Url.WalletTransactions(walletId);
Layout = "_LayoutWizard";
ViewData.SetActivePage(WalletsNavPages.Send, StringLocalizer["Send {0}", Model.CryptoCode], walletId);
Csp.Add("worker-src", "blob:");
@ -16,15 +15,7 @@
}
@section Navbar {
@if (backUrl != null)
{
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<vc:icon symbol="back" />
</a>
}
<a href="@Url.EnsureLocal(cancelUrl, Context.Request)" id="CancelWizard" class="cancel">
<vc:icon symbol="cross" />
</a>
<partial name="_BackAndReturn" model="Model" />
}
@section PageHeadContent

View File

@ -2,22 +2,13 @@
@model WalletSendVaultModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new { walletId });
var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null;
Model.ReturnUrl ??= Url.WalletTransactions(walletId);
Layout = "_LayoutWizard";
ViewData.SetActivePage(WalletsNavPages.Send, StringLocalizer["Sign the transaction"], walletId);
}
@section Navbar {
@if (backUrl != null)
{
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<vc:icon symbol="back" />
</a>
}
<a href="@Url.EnsureLocal(cancelUrl, Context.Request)" id="CancelWizard" class="cancel">
<vc:icon symbol="cross" />
</a>
<partial name="_BackAndReturn" model="Model" />
}
<header class="text-center">

View File

@ -3,22 +3,13 @@
@inject BTCPayNetworkProvider BTCPayNetworkProvider
@{
var walletId = WalletId.Parse(Context.GetRouteValue("walletId").ToString());
var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new { walletId });
var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null;
Model.ReturnUrl ??= Url.WalletTransactions(walletId);
Layout = "_LayoutWizard";
ViewData.SetActivePage(WalletsNavPages.Send, StringLocalizer["Sign the transaction"], walletId.ToString());
}
@section Navbar {
@if (backUrl != null)
{
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<vc:icon symbol="back" />
</a>
}
<a href="@Url.EnsureLocal(cancelUrl, Context.Request)" id="CancelWizard" class="cancel">
<vc:icon symbol="cross" />
</a>
<partial name="_BackAndReturn" model="Model" />
}
<header class="text-center">

View File

@ -42,10 +42,6 @@
order: 1;
}
}
.unconf > * {
opacity: 0.5;
}
#LoadingIndicator {
margin-bottom: 1.5rem;
@ -178,38 +174,24 @@
<div class="table-responsive-md">
<table class="table table-hover ">
<thead>
<th>
Id
</th>
<th>
State
</th>
<th>
Signature count
</th>
<th>
Actions
</th>
<th>Id</th>
<th>State</th>
<th>Signature count</th>
<th>Actions</th>
</thead>
@foreach (var pendingTransaction in Model.PendingTransactions)
{
<tr>
<td>@pendingTransaction.TransactionId</td>
<td>@pendingTransaction.State</td>
<td>@pendingTransaction.GetBlob().CollectedSignatures.Count</td>
<td>
@pendingTransaction.TransactionId
<a asp-action="ViewPendingTransaction" asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId"
>@(pendingTransaction.State == PendingTransactionState.Signed ? "Broadcast" : "View")</a>
-
<a asp-action="CancelPendingTransaction"
asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId">Abort</a>
</td>
<td>
@pendingTransaction.State
</td>
<td>
@pendingTransaction.GetBlob().CollectedSignatures.Count
</td>
<td>
<a asp-action="ViewPendingTransaction" asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId">
@(pendingTransaction.State == PendingTransactionState.Signed ? "Broadcast" : "View")
</a>-
<a asp-action="CancelPendingTransaction"
asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId">Abort</a>
</tr>
}
</table>
@ -232,9 +214,9 @@
</button>
</div>
</th>
<th text-translate="true" style="min-width:125px">Label</th>
<th text-translate="true">Transaction</th>
<th text-translate="true" class="amount-col">Amount</th>
<th text-translate="true" style="min-width:125px">Label</th>
<th text-translate="true">Transaction</th>
<th text-translate="true" class="amount-col">Amount</th>
<th></th>
</tr>
</thead>
@ -251,8 +233,8 @@
</div>
<form id="WalletActions" method="post" asp-action="WalletActions" asp-route-walletId="@walletId" permission="@Policies.CanModifyStoreSettings" class="d-inline-flex align-items-center gap-3">
<button id="BumpFee" name="command" type="submit" value="cpfp" class="btn btn-link">
<vc:icon symbol="actions-send" />
<span text-translate="true">Bump fee</span>
<vc:icon symbol="actions-send" />
<span text-translate="true">Bump fee</span>
</button>
</form>
</div>

View File

@ -25,7 +25,7 @@
<vc:truncate-center text="@transaction.Id" link="@transaction.Link" classes="truncate-center-id" />
</td>
<td class="amount-col">
<span data-sensitive class="text-@(transaction.Positive ? "success" : "danger")">@transaction.Balance</span>
<span data-sensitive class="text-@(transaction.Positive ? "success" : "danger")@(transaction.IsConfirmed ? "" : " opacity-50")">@transaction.Balance</span>
</td>
<td class="text-end">
<div class="d-inline-block">

View File

@ -10,7 +10,7 @@ document.addEventListener('DOMContentLoaded', () => {
let config = parseConfig($config.value) || {}
const specialFieldTypeOptions = ['fieldset', 'textarea', 'select', 'mirror']
const inputFieldTypeOptions = ['text', 'number', 'password', 'email', 'url', 'tel', 'date', 'hidden']
const inputFieldTypeOptions = ['text', 'number', 'password', 'email', 'url', 'tel', 'date', 'datetime-local', 'color', 'checkbox', 'hidden']
const fieldTypeOptions = inputFieldTypeOptions.concat(specialFieldTypeOptions)
const getFieldComponent = type => `field-type-${specialFieldTypeOptions.includes(type) ? type : 'input'}`

View File

@ -28,8 +28,8 @@
}
/* Iframe context */
[data-within-iframe] #StoreLink,
[data-within-iframe] .store-footer {
[data-context="btcpayapp"] #Checkout #StoreLink,
[data-context="btcpayapp"] #Checkout .store-footer {
display: none;
}

View File

@ -0,0 +1,127 @@
{
"paths": {
"/api/v1/server/email": {
"get": {
"tags": ["ServerEmail"],
"summary": "Get server email settings",
"description": "Retrieve the email settings configured for the server. The password field will be masked if present.",
"operationId": "ServerEmail_GetSettings",
"responses": {
"200": {
"description": "Server email settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerEmailSettingsData"
}
}
}
},
"403": {
"description": "Forbidden - Insufficient permissions"
}
},
"security": [
{
"API_Key": ["btcpay.server.canmodifyserversettings"],
"Basic": []
}
]
},
"put": {
"tags": ["ServerEmail"],
"summary": "Update server email settings",
"description": "Update server's email settings.",
"operationId": "ServerEmail_UpdateSettings",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerEmailSettingsData"
}
}
}
},
"responses": {
"200": {
"description": "Email settings updated successfully"
},
"400": {
"description": "Invalid request or email format",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"403": {
"description": "Forbidden - Insufficient permissions"
}
},
"security": [
{
"API_Key": ["btcpay.server.canmodifyserversettings"],
"Basic": []
}
]
}
}
},
"components": {
"schemas": {
"ServerEmailSettingsData": {
"allOf": [
{ "$ref": "#/components/schemas/EmailSettings" },
{
"type": "object",
"properties": {
"enableStoresToUseServerEmailSettings": {
"type": "boolean",
"description": "Indicates if stores can use server email settings"
}
}
}
]
},
"EmailSettings": {
"type": "object",
"properties": {
"from": {
"type": "string",
"description": "The sender email address"
},
"server": {
"type": "string",
"description": "SMTP server host"
},
"port": {
"type": "integer",
"description": "SMTP server port"
},
"login": {
"type": "string",
"description": "SMTP username"
},
"password": {
"type": "string",
"description": "SMTP password, masked in responses and retained if not updated",
"nullable": true
},
"disableCertificateCheck": {
"type": "boolean",
"description": "Use SSL for SMTP connection"
}
}
}
}
},
"tags": [
{
"name": "ServerEmail",
"description": "Server Email Settings operations"
}
]
}

View File

@ -11,7 +11,7 @@
"$ref": "#/components/parameters/StoreId"
}
],
"description": "View email settings of the specified store",
"description": "Retrieve the email settings configured for specific store. The password field will be masked if present.",
"operationId": "Stores_GetStoreEmailSettings",
"responses": {
"200": {