Add granular wallet permissions and move wallet to plugin (#7329)
What changed, and why it matters
This is a large refactoring commit that splits BTCPay Server's wallet functionality into a separate plugin and introduces finer-grained wallet permissions (view, create transactions, sign, broadcast, manage settings, etc.). It also adds new tests that verify users with only wallet permissions can access wallet pages and cannot perform actions outside their role. The change is primarily a security-hardening/permission-separation feature, not an obvious vulnerability fix, though it does include one small bug fix for pending-transaction broadcast matching.
Review the new permission matrix carefully for over/under-privilege, especially the implied/implied-by relationships in PermissionService and the authorization attributes on every moved wallet action. Ensure the plugin area routing does not bypass existing authorization filters or antiforgery protections. Verify that the pending-transaction broadcast matching fix is deployed and that no other pending-transaction operations rely solely on IDs without transaction-hash verification.
Security signals we found
Introduction of granular wallet permissions and role-based access control
Relocation of wallet controllers into a plugin area with updated authorization attributes
New tests assert that wallet-only users cannot access invoices, reports, payment requests, pull payments, payouts, or another store's wallet settings
New test asserts that a pending transaction is only marked broadcast when the broadcast transaction actually matches (hash), preventing cross-pending-transaction state changes
Removal of broad Policies.CanModifyStoreSettings requirement for wallet navigation; replaced with wallet-specific permissions
Evidence from the diff
The commit moves on-chain wallet setup/settings controllers from UIStoresController into a new UIStoreOnChainWalletsController under the Wallets plugin area, and moves UIWalletsController into BTCPayServer.Plugins.Wallets. It introduces WalletPolicies (CanViewWallet, CanCreateWalletTransactions, CanSignWalletTransactions, CanBroadcastWalletTransactions, CanManageWalletTransactions, CanManageWalletSettings, CanManageWallets) and a migration adding default store roles (Wallet Manager, Multisigner, Multisigner Guest). Views and navigation are updated to use the new area routes and permission attributes. A new test CanUseWalletRoles exercises role-based access control for wallet endpoints. A separate test CanOnlyMarkMatchingPendingTransactionAsBroadcast verifies that broadcasting a malleated transaction only marks the matching pending transaction as broadcast.
Changed components
BTCPayServer.Plugins.WalletsUIStoreOnChainWalletsControllerUIWalletsControllerWalletPoliciesStoreRoles / StoreRoleId permissionsMainNav / WalletNav / StoreWalletBalance / StoreRecentTransactions / LabelManager componentsPendingTransactionServiceGreenfield API role listDatabase migration 20260203123000_AddWalletRolesInspect captured patch +8429 / −7299
diff --git a/BTCPayServer.Data/Migrations/20260203123000_AddWalletRoles.cs b/BTCPayServer.Data/Migrations/20260203123000_AddWalletRoles.cs
new file mode 100644
index 0000000..1d4c92e
--- /dev/null
+++ b/BTCPayServer.Data/Migrations/20260203123000_AddWalletRoles.cs
@@ -0,0 +1,31 @@
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BTCPayServer.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260203123000_AddWalletRoles")]
+ public partial class AddWalletRoles : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("""
+ INSERT INTO "StoreRoles" ("Id", "Role", "Permissions")
+ VALUES
+ ('Wallet Manager', 'Wallet Manager', ARRAY['btcpay.store.canmanagewallets']::TEXT[]),
+ ('Multisigner', 'Multisigner', ARRAY['btcpay.store.canmanagewallettransactions']::TEXT[]),
+ ('Multisigner Guest', 'Multisigner Guest', ARRAY['btcpay.store.cansigntransactions']::TEXT[])
+ ON CONFLICT ("Id") DO NOTHING;
+ """);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ // Do not delete by name here: these default role names may already belong to user-created roles.
+ }
+ }
+}
diff --git a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs
index 906d4f4..3352d94 100644
--- a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs
+++ b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs
@@ -84,24 +84,25 @@ namespace BTCPayServer.Tests
response = controller.LightningSettings(user.StoreId, cryptoCode);
lnSettingsModel = (LightningSettingsViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.False(lnSettingsModel.Enabled);
+ var walletController = user.GetController<UIStoreOnChainWalletsController>();
// Setup wallet
WalletSetupViewModel setupVm;
var storeId = user.StoreId;
- response = await controller.GenerateWallet(storeId, cryptoCode, WalletSetupMethod.GenerateOptions, new WalletSetupRequest());
+ response = await walletController.GenerateWallet(storeId, cryptoCode, WalletSetupMethod.GenerateOptions, new WalletSetupRequest());
Assert.IsType<ViewResult>(response);
// Get enabled state from settings
- response = await controller.WalletSettings(user.StoreId, cryptoCode);
+ response = await walletController.WalletSettings(user.StoreId, cryptoCode);
var onchainSettingsModel = (WalletSettingsViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.NotNull(onchainSettingsModel?.DerivationScheme);
Assert.True(onchainSettingsModel.Enabled);
// Disable wallet
onchainSettingsModel.Enabled = false;
- response = await controller.UpdateWalletSettings(onchainSettingsModel);
+ response = await walletController.UpdateWalletSettings(onchainSettingsModel);
Assert.IsType<RedirectToActionResult>(response);
- response = await controller.WalletSettings(user.StoreId, cryptoCode);
+ response = await walletController.WalletSettings(user.StoreId, cryptoCode);
onchainSettingsModel = (WalletSettingsViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.NotNull(onchainSettingsModel?.DerivationScheme);
Assert.False(onchainSettingsModel.Enabled);
@@ -123,11 +124,11 @@ namespace BTCPayServer.Tests
Assert.Equal("LTC", invoice.CryptoInfo[0].CryptoCode);
// Removing the derivation scheme, should redirect to store page
- response = await controller.ConfirmDeleteWallet(user.StoreId, cryptoCode);
+ response = await walletController.ConfirmDeleteWallet(user.StoreId, cryptoCode);
Assert.IsType<RedirectToActionResult>(response);
// Setting it again should show the confirmation page
- response = await controller.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, DerivationScheme = oldScheme });
+ response = await walletController.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, DerivationScheme = oldScheme });
setupVm = (WalletSetupViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.True(setupVm.Confirmation);
@@ -135,40 +136,40 @@ namespace BTCPayServer.Tests
// cobo vault file
var content = "{\"ExtPubKey\":\"xpub6CEqRFZ7yZxCFXuEWZBAdnC8bdvu9SRHevaoU2SsW9ZmKhrCShmbpGZWwaR15hdLURf8hg47g4TpPGaqEU8hw5LEJCE35AUhne67XNyFGBk\",\"MasterFingerprint\":\"7a7563b5\",\"DerivationPath\":\"M\\/84'\\/0'\\/0'\",\"CoboVaultFirmwareVersion\":\"1.2.0(BTC-Only)\"}";
- response = await controller.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("cobovault.json", content) });
+ response = await walletController.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("cobovault.json", content) });
setupVm = (WalletSetupViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.True(setupVm.Confirmation);
- response = await controller.UpdateWallet(setupVm);
+ response = await walletController.UpdateWallet(setupVm);
Assert.IsType<RedirectToActionResult>(response);
- response = await controller.WalletSettings(storeId, cryptoCode);
+ response = await walletController.WalletSettings(storeId, cryptoCode);
var settingsVm = (WalletSettingsViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.Equal("CoboVault", settingsVm.Source);
// wasabi wallet file
content = "{\r\n \"EncryptedSecret\": \"6PYWBQ1zsukowsnTNA57UUx791aBuJusm7E4egXUmF5WGw3tcdG3cmTL57\",\r\n \"ChainCode\": \"waSIVbn8HaoovoQg/0t8IS1+ZCxGsJRGFT21i06nWnc=\",\r\n \"MasterFingerprint\": \"7a7563b5\",\r\n \"ExtPubKey\": \"xpub6CEqRFZ7yZxCFXuEWZBAdnC8bdvu9SRHevaoU2SsW9ZmKhrCShmbpGZWwaR15hdLURf8hg47g4TpPGaqEU8hw5LEJCE35AUhne67XNyFGBk\",\r\n \"PasswordVerified\": false,\r\n \"MinGapLimit\": 21,\r\n \"AccountKeyPath\": \"84'/0'/0'\",\r\n \"BlockchainState\": {\r\n \"Network\": \"RegTest\",\r\n \"Height\": \"0\"\r\n },\r\n \"HdPubKeys\": []\r\n}";
- response = await controller.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("wasabi.json", content) });
+ response = await walletController.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("wasabi.json", content) });
setupVm = (WalletSetupViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.True(setupVm.Confirmation);
- response = await controller.UpdateWallet(setupVm);
+ response = await walletController.UpdateWallet(setupVm);
Assert.IsType<RedirectToActionResult>(response);
- response = await controller.WalletSettings(storeId, cryptoCode);
+ response = await walletController.WalletSettings(storeId, cryptoCode);
settingsVm = (WalletSettingsViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.Equal("WasabiFile", settingsVm.Source);
// Can we upload coldcard settings? (Should fail, we are giving a mainnet file to a testnet network)
content = "{\"keystore\": {\"ckcc_xpub\": \"xpub661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw\", \"xpub\": \"ypub6WWc2gWwHbdnAAyJDnR4SPL1phRh7REqrPBfZeizaQ1EmTshieRXJC3Z5YoU4wkcdKHEjQGkh6AYEzCQC1Kz3DNaWSwdc1pc8416hAjzqyD\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/49'/0'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}";
- response = await controller.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("coldcard-ypub.json", content) });
+ response = await walletController.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("coldcard-ypub.json", content) });
setupVm = (WalletSetupViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.False(setupVm.Confirmation); // Should fail, we are giving a mainnet file to a testnet network
// And with a good file? (upub)
content = "{\"keystore\": {\"ckcc_xpub\": \"tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS\", \"xpub\": \"upub5DBYp1qGgsTrkzCptMGZc2x18pquLwGrBw6nS59T4NViZ4cni1mGowQzziy85K8vzkp1jVtWrSkLhqk9KDfvrGeB369wGNYf39kX8rQfiLn\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/49'/0'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}";
- response = await controller.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("coldcard-upub.json", content) });
+ response = await walletController.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("coldcard-upub.json", content) });
setupVm = (WalletSetupViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.True(setupVm.Confirmation);
- response = await controller.UpdateWallet(setupVm);
+ response = await walletController.UpdateWallet(setupVm);
Assert.IsType<RedirectToActionResult>(response);
- response = await controller.WalletSettings(storeId, cryptoCode);
+ response = await walletController.WalletSettings(storeId, cryptoCode);
settingsVm = (WalletSettingsViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.Equal("ElectrumFile", settingsVm.Source);
diff --git a/BTCPayServer.Tests/DatabaseTests.cs b/BTCPayServer.Tests/DatabaseTests.cs
index a813828..5462bc0 100644
--- a/BTCPayServer.Tests/DatabaseTests.cs
+++ b/BTCPayServer.Tests/DatabaseTests.cs
@@ -1,9 +1,14 @@
using System.Linq;
using System.Threading.Tasks;
+using BTCPayServer.Abstractions;
+using BTCPayServer.Data;
+using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using Dapper;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using NBitcoin;
using Newtonsoft.Json.Linq;
using Xunit;
using Xunit.Abstractions;
@@ -136,6 +141,68 @@ namespace BTCPayServer.Tests
}
}
+ [Fact]
+ public async Task CanOnlyMarkMatchingPendingTransactionAsBroadcast()
+ {
+ var tester = CreateDBTester();
+ await tester.MigrateUntil();
+ const string storeId = "TestStore";
+
+ await using (var ctx = tester.CreateContext())
+ {
+ await ctx.Database.GetDbConnection().ExecuteAsync("""
+ INSERT INTO "Stores" ("Id", "SpeedPolicy") VALUES (@storeId, 0);
+ """, new { storeId });
+ }
+
+ var networkProvider = CreateNetworkProvider();
+ var network = networkProvider.GetNetwork<BTCPayNetwork>("BTC").NBitcoinNetwork;
+ var service = new PendingTransactionService(
+ networkProvider,
+ tester.CreateContextFactory(),
+ new EventAggregator(new BTCPayServer.Logging.Logs()),
+ NullLogger<PendingTransactionService>.Instance);
+ var requestBaseUrl = RequestBaseUrl.FromUrl("https://example.com");
+ var psbtA = CreatePendingTransactionPSBT(network, 1, Money.Satoshis(10_000));
+ var psbtB = CreatePendingTransactionPSBT(network, 2, Money.Satoshis(20_000));
+ var pendingA = await service.CreatePendingTransaction(storeId, "BTC", psbtA, requestBaseUrl);
+ var pendingB = await service.CreatePendingTransaction(storeId, "BTC", psbtB, requestBaseUrl);
+
+ await service.Broadcasted(new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingA.Id), psbtB.GetGlobalTransaction());
+
+ await using (var ctx = tester.CreateContext())
+ {
+ var reloadedPendingA = await ctx.PendingTransactions.SingleAsync(p => p.Id == pendingA.Id);
+ Assert.Equal(PendingTransactionState.Pending, reloadedPendingA.State);
+ }
+
+ var malleatedTransaction = psbtA.GetGlobalTransaction().Clone();
+ malleatedTransaction.Inputs[0].ScriptSig = new Script(Op.GetPushOp(new byte[] { 1, 2, 3 }));
+ Assert.NotEqual(psbtA.GetGlobalTransaction().GetHash(), malleatedTransaction.GetHash());
+ await service.Broadcasted(new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingA.Id), malleatedTransaction);
+
+ await using (var ctx = tester.CreateContext())
+ {
+ var reloadedPendingA = await ctx.PendingTransactions.SingleAsync(p => p.Id == pendingA.Id);
+ var reloadedPendingB = await ctx.PendingTransactions.SingleAsync(p => p.Id == pendingB.Id);
+ Assert.Equal(PendingTransactionState.Broadcast, reloadedPendingA.State);
+ Assert.Equal(PendingTransactionState.Pending, reloadedPendingB.State);
+ }
+ }
+
+ private static PSBT CreatePendingTransactionPSBT(Network network, uint prevTxNonce, Money amount)
+ {
+ var tx = Transaction.Create(network);
+ tx.Version = 2;
+ tx.LockTime = LockTime.Zero;
+ tx.Inputs.Add(new TxIn(new OutPoint(uint256.Parse($"{prevTxNonce:x64}"), 0))
+ {
+ Sequence = Sequence.Final
+ });
+ tx.Outputs.Add(amount, new Key().GetScriptPubKey(ScriptPubKeyType.Legacy));
+ return PSBT.FromTransaction(tx, network);
+ }
+
[Fact]
public async Task CanMigrateInvoiceAddresses()
{
diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs
index e1ce5bd..ac04494 100644
--- a/BTCPayServer.Tests/GreenfieldAPITests.cs
+++ b/BTCPayServer.Tests/GreenfieldAPITests.cs
@@ -2987,7 +2987,7 @@ namespace BTCPayServer.Tests
});
var roles = await client.GetServerRoles();
- Assert.Equal(4, roles.Count);
+ Assert.Equal(7, roles.Count);
#pragma warning disable CS0618
var ownerRole = roles.Single(data => data.Role == StoreRoles.Owner);
var managerRole = roles.Single(data => data.Role == StoreRoles.Manager);
diff --git a/BTCPayServer.Tests/MultisigTests.cs b/BTCPayServer.Tests/MultisigTests.cs
index 7fbbf02..84d2fb7 100644
--- a/BTCPayServer.Tests/MultisigTests.cs
+++ b/BTCPayServer.Tests/MultisigTests.cs
@@ -33,7 +33,7 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
$"[{resp2.AccountKeyPath}]{resp2.DerivationScheme}/0/*," +
$"[{resp3.AccountKeyPath}]{resp3.DerivationScheme}/0/*))";
- var strategy = UIStoresController.ParseDerivationStrategy(multisigDerivationScheme, network);
+ var strategy = UIStoreOnChainWalletsController.ParseDerivationStrategy(multisigDerivationScheme, network);
strategy.Source = "ManualDerivationScheme";
var derivationScheme = strategy.AccountDerivation;
@@ -65,7 +65,7 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
$"[{resp2.AccountKeyPath}]{resp2.DerivationScheme}/0/*," +
$"[{resp3.AccountKeyPath}]{resp3.DerivationScheme}/0/*))";
- var strategy = UIStoresController.ParseDerivationStrategy(multisigDerivationScheme, network);
+ var strategy = UIStoreOnChainWalletsController.ParseDerivationStrategy(multisigDerivationScheme, network);
strategy.Source = "ManualDerivationScheme";
var derivationScheme = strategy.AccountDerivation;
await s.CreateNewStore();
diff --git a/BTCPayServer.Tests/RolesTests.cs b/BTCPayServer.Tests/RolesTests.cs
index 0d0cef7..ebd040e 100644
--- a/BTCPayServer.Tests/RolesTests.cs
+++ b/BTCPayServer.Tests/RolesTests.cs
@@ -3,10 +3,17 @@ using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
+using BTCPayServer.Data;
using BTCPayServer.Lightning;
+using BTCPayServer.Plugins.Wallets;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores;
+using BTCPayServer.Views.Wallets;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Playwright;
using Xunit;
using Xunit.Abstractions;
@@ -93,7 +100,7 @@ public class RolesTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testO
await s.GoToServer(ServerNavPages.Roles);
await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
var existingServerRoles = await s.Page.Locator("table tr").AllAsync();
- Assert.Equal(5, existingServerRoles.Count);
+ Assert.Equal(8, existingServerRoles.Count);
ILocator ownerRow = null;
ILocator managerRow = null;
ILocator employeeRow = null;
@@ -114,7 +121,8 @@ public class RolesTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testO
{
employeeRow = roleItem;
}
- else if (text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
+ else if (text.Contains("guest", StringComparison.InvariantCultureIgnoreCase) &&
+ !text.Contains("multisigner guest", StringComparison.InvariantCultureIgnoreCase))
{
guestRow = roleItem;
}
@@ -156,7 +164,8 @@ public class RolesTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testO
{
ownerRow = roleItem;
}
- else if (text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
+ else if (text.Contains("guest", StringComparison.InvariantCultureIgnoreCase) &&
+ !text.Contains("multisigner guest", StringComparison.InvariantCultureIgnoreCase))
{
guestRow = roleItem;
}
@@ -176,10 +185,9 @@ public class RolesTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testO
await s.CreateNewStore();
await s.GoToStore(StoreNavPages.Roles);
existingServerRoles = await s.Page.Locator("table tr").AllAsync();
- Assert.Equal(5, existingServerRoles.Count);
+ Assert.Equal(8, existingServerRoles.Count);
var serverRoleTexts = await Task.WhenAll(existingServerRoles.Select(async element => await element.TextContentAsync()));
- Assert.Equal(4, serverRoleTexts.Count(text => text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
-
+ Assert.Equal(7, serverRoleTexts.Count(text => text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
foreach (var roleItem in existingServerRoles)
{
var text = await roleItem.TextContentAsync();
@@ -200,7 +208,8 @@ public class RolesTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testO
{
var text = await roleItem.TextContentAsync();
Assert.NotNull(text);
- if (text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
+ if (text.Contains("guest", StringComparison.InvariantCultureIgnoreCase) &&
+ !text.Contains("multisigner guest", StringComparison.InvariantCultureIgnoreCase))
{
guestRow = roleItem;
break;
@@ -237,19 +246,19 @@ public class RolesTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testO
Assert.DoesNotContain(guestBadgeTexts3, text => text.Equals("server-wide", StringComparison.InvariantCultureIgnoreCase));
await s.GoToStore(StoreNavPages.Users);
var options = await s.Page.Locator("#Role option").AllAsync();
- Assert.Equal(4, options.Count);
+ Assert.Equal(7, options.Count);
var optionTexts = await Task.WhenAll(options.Select(async element => await element.TextContentAsync()));
Assert.Contains(optionTexts, text => text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
await s.CreateNewStore();
await s.GoToStore(StoreNavPages.Roles);
existingServerRoles = await s.Page.Locator("table tr").AllAsync();
- Assert.Equal(4, existingServerRoles.Count);
+ Assert.Equal(7, existingServerRoles.Count);
var serverRoleTexts2 = await Task.WhenAll(existingServerRoles.Select(async element => await element.TextContentAsync()));
- Assert.Equal(3, serverRoleTexts2.Count(text => text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
- Assert.Equal(0, serverRoleTexts2.Count(text => text.Contains("store role", StringComparison.InvariantCultureIgnoreCase)));
+ Assert.Equal(6, serverRoleTexts2.Count(text => text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
+ Assert.DoesNotContain(serverRoleTexts2, text => text.Contains("store role", StringComparison.InvariantCultureIgnoreCase));
await s.GoToStore(StoreNavPages.Users);
options = await s.Page.Locator("#Role option").AllAsync();
- Assert.Equal(3, options.Count);
+ Assert.Equal(6, options.Count);
var optionTexts2 = await Task.WhenAll(options.Select(async element => await element.TextContentAsync()));
Assert.DoesNotContain(optionTexts2, text => text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
@@ -274,6 +283,493 @@ public class RolesTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testO
Assert.DoesNotContain(Policies.CanModifyServerSettings, await s.Page.ContentAsync());
}
+ [Fact]
+ [Trait("Playwright", "Playwright")]
+ public async Task CanUseWalletRoles()
+ {
+ await using var s = CreatePlaywrightTester(newDb: true);
+ await s.StartAsync();
+
+ await s.RegisterNewUser(true);
+ await s.SkipWizard();
+ var (_, storeId) = await s.CreateNewStore();
+ await s.GoToStore();
+ await s.AddDerivationScheme();
+ await using var scope = s.Server.PayTester.GetService<IServiceScopeFactory>().CreateAsyncScope();
+ var storeRepo = scope.ServiceProvider.GetRequiredService<StoreRepository>();
+ var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
+ var handlers = scope.ServiceProvider.GetRequiredService<PaymentMethodHandlerDictionary>();
+ var walletId = new WalletId(storeId, "BTC");
+ var walletIdString = walletId.ToString();
+ var cryptoCode = walletId.CryptoCode;
+ await s.GoToWallet(navPages: WalletsNavPages.Receive);
+ var addressElement = s.Page.Locator("#Address");
+ await addressElement.ClickAsync();
+ var receiveAddress = await addressElement.GetAttributeAsync("data-text");
+ Assert.NotNull(receiveAddress);
+ await s.Page.ClickAsync("//button[@value='fill-wallet']");
+ await s.Page.ClickAsync("#CancelWizard");
+ await s.GoToStore(storeId);
+ var (_, otherStoreId) = await s.CreateNewStore(keepId: false);
+ await s.AddDerivationScheme(cryptoCode);
+ await s.GoToStore(storeId);
+
+ await s.Logout();
+ await s.GoToRegister();
+ var walletManager = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var multisigner = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var multisignerGuest = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var walletCreator = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var walletSigner = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var walletBroadcaster = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+ await s.GoToRegister();
+ var walletViewer = await s.RegisterNewUser();
+ await s.SkipWizard();
+ await s.Logout();
+
+ var walletManagerUser = await userManager.FindByEmailAsync(walletManager);
+ var multisignerUser = await userManager.FindByEmailAsync(multisigner);
+ var multisignerGuestUser = await userManager.FindByEmailAsync(multisignerGuest);
+ var walletCreatorUser = await userManager.FindByEmailAsync(walletCreator);
+ var walletSignerUser = await userManager.FindByEmailAsync(walletSigner);
+ var walletBroadcasterUser = await userManager.FindByEmailAsync(walletBroadcaster);
+ var walletViewerUser = await userManager.FindByEmailAsync(walletViewer);
+ Assert.NotNull(walletManagerUser);
+ Assert.NotNull(multisignerUser);
+ Assert.NotNull(multisignerGuestUser);
+ Assert.NotNull(walletCreatorUser);
+ Assert.NotNull(walletSignerUser);
+ Assert.NotNull(walletBroadcasterUser);
+ Assert.NotNull(walletViewerUser);
+ var permissionService = s.Server.PayTester.GetService<PermissionService>();
+ var walletCreatorRole = new StoreRoleId(storeId, "Wallet Creator");
+ var walletSignerRole = new StoreRoleId(storeId, "Wallet Signer");
+ var walletBroadcasterRole = new StoreRoleId(storeId, "Wallet Broadcaster");
+ var walletViewerRole = new StoreRoleId(storeId, "Wallet Viewer");
+ await storeRepo.AddOrUpdateStoreRole(walletCreatorRole, new[] { WalletPolicies.CanCreateWalletTransactions });
+ await storeRepo.AddOrUpdateStoreRole(walletSignerRole, new[] { WalletPolicies.CanCreateWalletTransactions, WalletPolicies.CanSignWalletTransactions });
+ await storeRepo.AddOrUpdateStoreRole(walletBroadcasterRole, new[] { WalletPolicies.CanBroadcastWalletTransactions });
+ await storeRepo.AddOrUpdateStoreRole(walletViewerRole, new[] { WalletPolicies.CanViewWallet });
+ await storeRepo.AddOrUpdateStoreUser(storeId, walletManagerUser.Id, new StoreRoleId("Wallet Manager"));
+ await storeRepo.AddOrUpdateStoreUser(storeId, multisignerUser.Id, new StoreRoleId("Multisigner"));
+ await storeRepo.AddOrUpdateStoreUser(storeId, multisignerGuestUser.Id, new StoreRoleId("Multisigner Guest"));
+ await storeRepo.AddOrUpdateStoreUser(storeId, walletCreatorUser.Id, walletCreatorRole);
+ await storeRepo.AddOrUpdateStoreUser(storeId, walletSignerUser.Id, walletSignerRole);
+ await storeRepo.AddOrUpdateStoreUser(storeId, walletBroadcasterUser.Id, walletBroadcasterRole);
+ await storeRepo.AddOrUpdateStoreUser(storeId, walletViewerUser.Id, walletViewerRole);
+ var walletManagerStore = await storeRepo.FindStore(storeId, walletManagerUser.Id);
+ var multisignerStore = await storeRepo.FindStore(storeId, multisignerUser.Id);
+ var multisignerGuestStore = await storeRepo.FindStore(storeId, multisignerGuestUser.Id);
+ var walletCreatorStore = await storeRepo.FindStore(storeId, walletCreatorUser.Id);
+ var walletSignerStore = await storeRepo.FindStore(storeId, walletSignerUser.Id);
+ var walletBroadcasterStore = await storeRepo.FindStore(storeId, walletBroadcasterUser.Id);
+ var walletViewerStore = await storeRepo.FindStore(storeId, walletViewerUser.Id);
+ Assert.NotNull(walletManagerStore);
+ Assert.NotNull(multisignerStore);
+ Assert.NotNull(multisignerGuestStore);
+ Assert.NotNull(walletCreatorStore);
+ Assert.NotNull(walletSignerStore);
+ Assert.NotNull(walletBroadcasterStore);
+ Assert.NotNull(walletViewerStore);
+ Assert.True(walletManagerStore.HasPolicy(walletManagerUser.Id, WalletPolicies.CanManageWallets, permissionService));
+ Assert.True(walletManagerStore.HasPolicy(walletManagerUser.Id, WalletPolicies.CanManageWalletSettings, permissionService));
+ Assert.True(walletManagerStore.HasPolicy(walletManagerUser.Id, WalletPolicies.CanViewWallet, permissionService));
+ Assert.Contains(permissionService.PermissionNodesByPolicy[WalletPolicies.CanManageWalletSettings].EnumerateDescendants(),
+ n => n.Definition.Policy == WalletPolicies.CanViewWallet);
+ Assert.Contains(permissionService.PermissionNodesByPolicy[WalletPolicies.CanManageWalletTransactions].EnumerateDescendants(),
+ n => n.Definition.Policy == WalletPolicies.CanViewWallet);
+ Assert.Contains(permissionService.PermissionNodesByPolicy[WalletPolicies.CanSignWalletTransactions].EnumerateDescendants(),
+ n => n.Definition.Policy == WalletPolicies.CanViewWallet);
+ Assert.Contains(permissionService.PermissionNodesByPolicy[WalletPolicies.CanCreateWalletTransactions].EnumerateDescendants(),
+ n => n.Definition.Policy == WalletPolicies.CanViewWallet);
+ Assert.Contains(permissionService.PermissionNodesByPolicy[WalletPolicies.CanBroadcastWalletTransactions].EnumerateDescendants(),
+ n => n.Definition.Policy == WalletPolicies.CanViewWallet);
+ Assert.Contains(permissionService.PermissionNodesByPolicy[WalletPolicies.CanCancelWalletTransactions].EnumerateDescendants(),
+ n => n.Definition.Policy == WalletPolicies.CanViewWallet);
+ Assert.True(multisignerStore.HasPolicy(multisignerUser.Id, WalletPolicies.CanViewWallet, permissionService));
+ Assert.True(multisignerGuestStore.HasPolicy(multisignerGuestUser.Id, WalletPolicies.CanViewWallet, permissionService));
+ Assert.True(walletCreatorStore.HasPolicy(walletCreatorUser.Id, WalletPolicies.CanCreateWalletTransactions, permissionService));
+ Assert.False(walletCreatorStore.HasPolicy(walletCreatorUser.Id, WalletPolicies.CanSignWalletTransactions, permissionService));
+ Assert.False(walletCreatorStore.HasPolicy(walletCreatorUser.Id, WalletPolicies.CanManageWalletTransactions, permissionService));
+ Assert.True(walletSignerStore.HasPolicy(walletSignerUser.Id, WalletPolicies.CanCreateWalletTransactions, permissionService));
+ Assert.True(walletSignerStore.HasPolicy(walletSignerUser.Id, WalletPolicies.CanSignWalletTransactions, permissionService));
+ Assert.True(walletBroadcasterStore.HasPolicy(walletBroadcasterUser.Id, WalletPolicies.CanBroadcastWalletTransactions, permissionService));
+ Assert.False(walletBroadcasterStore.HasPolicy(walletBroadcasterUser.Id, WalletPolicies.CanCreateWalletTransactions, permissionService));
+ Assert.False(walletBroadcasterStore.HasPolicy(walletBroadcasterUser.Id, WalletPolicies.CanSignWalletTransactions, permissionService));
+ Assert.True(walletViewerStore.HasPolicy(walletViewerUser.Id, WalletPolicies.CanViewWallet, permissionService));
+ Assert.False(walletViewerStore.HasPolicy(walletViewerUser.Id, WalletPolicies.CanCreateWalletTransactions, permissionService));
+ Assert.False(walletViewerStore.HasPolicy(walletViewerUser.Id, WalletPolicies.CanSignWalletTransactions, permissionService));
+ Assert.False(walletViewerStore.HasPolicy(walletViewerUser.Id, WalletPolicies.CanBroadcastWalletTransactions, permissionService));
+
+ string StoreIndex(string id) => $"/stores/{id}/index";
+ string StorePath(string id, string subPath) => $"/stores/{id}/{subPath}";
+ string WalletsIndex() => "/wallets";
+ string WalletTx(string id) => $"/wallets/{id}";
+ string WalletSend(string id) => $"/wallets/{id}/send";
+ string WalletSign(string id) => $"/wallets/{id}/sign";
+ string WalletPsbt(string id) => $"/wallets/{id}/psbt";
+ string WalletPsbtReady(string id) => $"/wallets/{id}/psbt/ready";
+ string WalletImport(string id, string code) => $"/stores/{id}/onchain/{code}/import";
+ string WalletImportSeed(string id, string code) => $"/stores/{id}/onchain/{code}/import/seed";
+ string WalletSettings(string id, string code) => $"/stores/{id}/onchain/{code}/settings";
+ string WalletSeed(string id, string code) => $"/stores/{id}/onchain/{code}/seed";
+ string WalletDelete(string id, string code) => $"/stores/{id}/onchain/{code}/delete";
+
+ async Task<DerivationSchemeSettings> GetStoreWalletSettings(string id)
+ {
+ var store = await storeRepo.FindStore(id);
+ Assert.NotNull(store);
+ var settings = store.GetDerivationSchemeSettings(handlers, cryptoCode);
+ Assert.NotNull(settings);
+ return settings;
+ }
+
+ async Task AssertWalletPostForbidden(string action, string command)
+ {
+ await s.GoToUrl(WalletPsbt(walletIdString));
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ await s.Page.EvaluateAsync(
+ @"({ action, command }) => {
+ const tokenElement = document.querySelector('input[name=""__RequestVerificationToken""]');
+ if (!tokenElement) throw new Error('Missing antiforgery token');
+ const form = document.createElement('form');
+ form.method = 'post';
+ form.action = action;
+ for (const [name, value] of [
+ ['__RequestVerificationToken', tokenElement.value],
+ ['PSBT', 'not-a-psbt'],
+ ['command', command]
+ ]) {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = name;
+ input.value = value;
+ form.appendChild(input);
+ }
+ document.body.appendChild(form);
+ form.submit();
+ }",
+ new { action, command });
+ await s.Page.WaitForURLAsync("**/errors/403**");
+ Assert.Contains("/errors/403", s.Page.Url, StringComparison.OrdinalIgnoreCase);
+ }
+
+ async Task AssertWalletPostNotForbidden(string action, string command)
+ {
+ await s.GoToUrl(WalletPsbt(walletIdString));
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ var responseTask = s.Page.WaitForResponseAsync(response =>
+ response.Request.Method == "POST" &&
+ response.Url.Contains(action, StringComparison.OrdinalIgnoreCase));
+ await s.Page.EvaluateAsync(
+ @"({ action, command }) => {
+ const tokenElement = document.querySelector('input[name=""__RequestVerificationToken""]');
+ if (!tokenElement) throw new Error('Missing antiforgery token');
+ const form = document.createElement('form');
+ form.method = 'post';
+ form.action = action;
+ for (const [name, value] of [
+ ['__RequestVerificationToken', tokenElement.value],
+ ['PSBT', 'not-a-psbt'],
+ ['command', command]
+ ]) {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = name;
+ input.value = value;
+ form.appendChild(input);
+ }
+ document.body.appendChild(form);
+ form.submit();
+ }",
+ new { action, command });
+ var response = await responseTask;
+ await response.FinishedAsync();
+ Assert.NotEqual(403, response.Status);
+ Assert.True(response.Status < 500, $"Unexpected status code {response.Status} for {command} {action}");
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ }
+
+ async Task AssertWalletSettingsCannotTargetAnotherStore()
+ {
+ await s.GoToUrl(WalletSettings(storeId, cryptoCode));
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+
+ var responseTask = s.Page.WaitForResponseAsync(response =>
+ response.Request.Method == "POST" &&
+ response.Url.Contains($"/stores/{storeId}/onchain/{cryptoCode}/settings/wallet",
+ StringComparison.OrdinalIgnoreCase));
+
+ await s.Page.EvaluateAsync(
+ @"({ otherStoreId }) => {
+ const form = document.getElementById('walletSettingsForm');
+ if (!form) throw new Error('Missing wallet settings form');
+
+ const storeId = document.createElement('input');
+ storeId.type = 'hidden';
+ storeId.name = 'StoreId';
+ storeId.value = otherStoreId;
+ form.appendChild(storeId);
+
+ const label = document.getElementById('Label');
+ if (label) label.value = 'retargeted';
+
+ form.submit();
+ }",
+ new { otherStoreId });
+
+ var response = await responseTask;
+ Assert.Equal(302, response.Status);
+
+ var otherWalletSettings = await GetStoreWalletSettings(otherStoreId);
+ Assert.NotEqual("retargeted", otherWalletSettings.Label);
+ }
+
+ async Task AssertSigningOptionsPsbtVisibility(bool visible)
+ {
+ await s.GoToUrl(WalletPsbt(walletIdString));
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ await s.Page.EvaluateAsync(
+ @"({ action }) => {
+ const tokenElement = document.querySelector('input[name=""__RequestVerificationToken""]');
+ if (!tokenElement) throw new Error('Missing antiforgery token');
+ const form = document.createElement('form');
+ form.method = 'post';
+ form.action = action;
+ for (const [name, value] of [
+ ['__RequestVerificationToken', tokenElement.value],
+ ['PSBT', 'not-a-psbt']
+ ]) {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = name;
+ input.value = value;
+ form.appendChild(input);
+ }
+ document.body.appendChild(form);
+ form.submit();
+ }",
+ new { action = WalletSign(walletIdString) });
+ await s.Page.WaitForURLAsync("**/sign");
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ Assert.Equal(visible ? 1 : 0, await s.Page.Locator("#SignWithPSBT").CountAsync());
+ }
+
+ async Task AssertWalletSendScheduleDenied()
+ {
+ await s.GoToUrl(WalletSend(walletIdString));
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ Assert.Equal(0, await s.Page.Locator("#ScheduleTransaction").CountAsync());
+ await s.Page.EvaluateAsync(
+ @"() => {
+ const tokenElement = document.querySelector('input[name=""__RequestVerificationToken""]');
+ if (!tokenElement) throw new Error('Missing antiforgery token');
+ const form = document.createElement('form');
+ form.method = 'post';
+ form.action = window.location.pathname;
+ for (const [name, value] of [
+ ['__RequestVerificationToken', tokenElement.value],
+ ['command', 'schedule']
+ ]) {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = name;
+ input.value = value;
+ form.appendChild(input);
+ }
+ document.body.appendChild(form);
+ form.submit();
+ }");
+ await s.Page.WaitForURLAsync("**/errors/403**");
+ Assert.Contains("/errors/403", s.Page.Url, StringComparison.OrdinalIgnoreCase);
+ }
+
+ await s.LogIn(walletManager);
+ await s.AssertPageAccess(true, StoreIndex(storeId));
+ await s.AssertPageAccess(true, WalletsIndex());
+ await s.AssertPageAccess(true, WalletTx(walletIdString));
+ await s.AssertPageAccess(true, WalletSend(walletIdString));
+ await s.AssertPageAccess(true, WalletPsbt(walletIdString));
+ await s.AssertPageAccess(true, WalletSettings(storeId, cryptoCode));
+ await s.AssertPageAccess(false, WalletSeed(storeId, cryptoCode));
+ await s.AssertPageAccess(false, StorePath(storeId, "invoices"));
+ await s.AssertPageAccess(false, StorePath(storeId, "reports"));
+ await s.AssertPageAccess(false, StorePath(storeId, "payment-requests"));
+ await s.AssertPageAccess(false, StorePath(storeId, "pull-payments"));
+ await s.AssertPageAccess(false, StorePath(storeId, "payouts"));
+ await s.AssertPageAccess(true, WalletImport(storeId, cryptoCode));
+ await s.AssertPageAccess(false, WalletImportSeed(storeId, cryptoCode));
+ await AssertWalletSettingsCannotTargetAnotherStore();
+ await s.GoToUrl(StoreIndex(storeId));
+ await s.GoToUrl(WalletImport(storeId, cryptoCode));
+ await s.Page.ClickAsync("#CancelWizard");
+ Assert.DoesNotContain("/errors/403", s.Page.Url, StringComparison.OrdinalIgnoreCase);
+ Assert.True(
+ s.Page.Url.Contains("/wallets", StringComparison.OrdinalIgnoreCase) ||
+ s.Page.Url.Contains(StoreIndex(storeId), StringComparison.OrdinalIgnoreCase));
+ await s.GoToUrl(StoreIndex(storeId));
+ await s.Logout();
+
+ await s.LogIn(multisigner);
+ await s.AssertPageAccess(true, StoreIndex(storeId));
+ await s.AssertPageAccess(true, WalletsIndex());
+ await s.AssertPageAccess(true, WalletTx(walletIdString));
+ await s.AssertPageAccess(true, WalletSend(walletIdString));
+ await s.AssertPageAccess(true, WalletPsbt(walletIdString));
+ await s.AssertPageAccess(false, WalletSettings(storeId, cryptoCode));
+ await s.AssertPageAccess(false, WalletSeed(storeId, cryptoCode));
+ await s.AssertPageAccess(false, StorePath(storeId, "invoices"));
+ await s.AssertPageAccess(false, StorePath(storeId, "reports"));
+ await s.AssertPageAccess(false, StorePath(storeId, "payment-requests"));
+ await s.AssertPageAccess(false, StorePath(storeId, "pull-payments"));
+ await s.AssertPageAccess(false, StorePath(storeId, "payouts"));
+ await AssertWalletSendScheduleDenied();
+ await AssertSigningOptionsPsbtVisibility(true);
+ await s.GoToUrl(StoreIndex(storeId));
+ await s.Logout();
+
+ await s.LogIn(walletCreator);
+ await s.AssertPageAccess(true, StoreIndex(storeId));
+ await s.AssertPageAccess(true, WalletsIndex());
+ await s.AssertPageAccess(true, WalletTx(walletIdString));
+ await s.AssertPageAccess(true, WalletSend(walletIdString));
+ await s.GoToUrl(WalletSend(walletIdString));
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ Assert.Equal(1, await s.Page.Locator("#CreatePendingTransaction").CountAsync());
+ Assert.Equal(0, await s.Page.Locator("#SignTransaction").CountAsync());
+ Assert.Equal(0, await s.Page.Locator("#CreatePSBT").CountAsync());
+ Assert.Equal(0, await s.Page.Locator("#Comment").CountAsync());
+ await s.Page.FillAsync("#Outputs_0__DestinationAddress", receiveAddress);
+ await s.Page.FillAsync("#Outputs_0__Amount", "0.1");
+ await s.Page.ClickAsync("#CreatePendingTransaction");
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ Assert.Equal(0, await s.Page.Locator("th:has-text('Signatures')").CountAsync());
+ Assert.Equal(0, await s.Page.Locator("th:has-text('Scheme')").CountAsync());
+ await AssertWalletPostForbidden(WalletSend(walletIdString), "sign");
+ await AssertWalletPostForbidden(WalletPsbt(walletIdString), "save-psbt");
+ await s.GoToUrl(StoreIndex(storeId));
+ await s.Logout();
+
+ await s.LogIn(walletSigner);
+ await s.AssertPageAccess(true, StoreIndex(storeId));
+ await s.AssertPageAccess(true, WalletsIndex());
+ await s.AssertPageAccess(true, WalletTx(walletIdString));
+ await s.AssertPageAccess(true, WalletSend(walletIdString));
+ await s.GoToUrl(WalletSend(walletIdString));
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ Assert.Equal(1, await s.Page.Locator("#SignTransaction").CountAsync());
+ Assert.Equal(0, await s.Page.Locator("#CreatePendingTransaction").CountAsync());
+ Assert.Equal(1, await s.Page.Locator("#CreatePSBT").CountAsync());
+ await AssertSigningOptionsPsbtVisibility(true);
+ await AssertWalletPostNotForbidden(WalletPsbt(walletIdString), "save-psbt");
+ await AssertWalletPostForbidden(WalletPsbtReady(walletIdString), "broadcast");
+ await s.GoToUrl(StoreIndex(storeId));
+ await s.Logout();
+
+ await s.LogIn(walletBroadcaster);
+ await s.AssertPageAccess(true, StoreIndex(storeId));
+ await s.AssertPageAccess(true, WalletsIndex());
+ await s.AssertPageAccess(true, WalletTx(walletIdString));
+ await s.AssertPageAccess(false, WalletSend(walletIdString));
+ await s.AssertPageAccess(true, WalletPsbt(walletIdString));
+ await s.GoToUrl(WalletPsbt(walletIdString));
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ Assert.Equal(0, await s.Page.Locator("#Decode").CountAsync());
+ await AssertWalletPostForbidden(WalletPsbt(walletIdString), "save-psbt");
+ await AssertWalletPostForbidden(WalletPsbt(walletIdString), "createpending");
+ await AssertWalletPostNotForbidden(WalletPsbtReady(walletIdString), "broadcast");
+ await s.GoToUrl(StoreIndex(storeId));
+ await s.Logout();
+
+ await s.LogIn(multisignerGuest);
+ await s.AssertPageAccess(true, StoreIndex(storeId));
+ await s.AssertPageAccess(true, WalletsIndex());
+ await s.AssertPageAccess(true, WalletTx(walletIdString));
+ await s.AssertPageAccess(false, WalletSend(walletIdString));
+ await s.AssertPageAccess(true, WalletPsbt(walletIdString));
+ await s.AssertPageAccess(false, WalletSettings(storeId, cryptoCode));
+ await s.AssertPageAccess(false, WalletSeed(storeId, cryptoCode));
+ await s.AssertPageAccess(false, StorePath(storeId, "invoices"));
+ await s.AssertPageAccess(false, StorePath(storeId, "reports"));
+ await s.AssertPageAccess(false, StorePath(storeId, "payment-requests"));
+ await s.AssertPageAccess(false, StorePath(storeId, "pull-payments"));
+ await s.AssertPageAccess(false, StorePath(storeId, "payouts"));
+ await AssertSigningOptionsPsbtVisibility(true);
+ await AssertWalletPostNotForbidden(WalletPsbt(walletIdString), "update");
+ await AssertWalletPostNotForbidden(WalletPsbt(walletIdString), "combine");
+ await AssertWalletPostNotForbidden(WalletPsbt(walletIdString), "save-psbt");
+ await AssertWalletPostNotForbidden($"{WalletPsbt(walletIdString)}/combine", "combine");
+ await AssertWalletPostForbidden(WalletPsbtReady(walletIdString), "broadcast");
+ await s.GoToUrl(StoreIndex(storeId));
+ await s.Logout();
+
+ await s.LogIn(walletViewer);
+ await s.AssertPageAccess(true, StoreIndex(storeId));
+ await s.AssertPageAccess(true, WalletsIndex());
+ await s.AssertPageAccess(true, WalletTx(walletIdString));
+ await s.AssertPageAccess(false, WalletSend(walletIdString));
+ await s.AssertPageAccess(true, WalletPsbt(walletIdString));
+ await s.AssertPageAccess(false, WalletSettings(storeId, cryptoCode));
+ await AssertWalletPostForbidden(WalletPsbt(walletIdString), "createpending");
+ await AssertWalletPostForbidden(WalletPsbt(walletIdString), "update");
+ await AssertWalletPostForbidden(WalletPsbt(walletIdString), "combine");
+ await AssertWalletPostForbidden(WalletPsbt(walletIdString), "save-psbt");
+ await AssertWalletPostForbidden($"{WalletPsbt(walletIdString)}/combine", "combine");
+ await AssertWalletPostForbidden(WalletSign(walletIdString), "seed");
+ await AssertWalletPostForbidden(WalletPsbtReady(walletIdString), "broadcast");
+ await s.GoToUrl(StoreIndex(storeId));
+ await s.Logout();
+
+ foreach (var url in new[]
+ {
+ StorePath(storeId, $"onchain/{cryptoCode}"),
+ WalletSettings(storeId, cryptoCode)
+ })
+ {
+ await s.GoToUrl(url);
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ await s.Page.ContentAsync();
+ Assert.True(s.Page.Url.Contains("/login", StringComparison.OrdinalIgnoreCase));
+ }
+
+ await s.LogIn(walletManager);
+ await s.GoToUrl(WalletDelete(storeId, cryptoCode));
+ await s.Page.EvaluateAsync(
+ @"({ otherStoreId }) => {
+ const form = document.getElementById('ConfirmForm');
+ if (!form) throw new Error('Missing confirm form');
+ for (const [name, value] of [['storeId', otherStoreId], ['StoreId', otherStoreId]]) {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = name;
+ input.value = value;
+ form.appendChild(input);
+ }
+ form.submit();
+ }",
+ new { otherStoreId });
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ Assert.DoesNotContain("/errors/403", s.Page.Url, StringComparison.OrdinalIgnoreCase);
+ await GetStoreWalletSettings(otherStoreId);
+ }
+
[Fact]
[Trait("Playwright", "Playwright")]
public async Task ServerRolesLinkedCorrectlyFromStoreRolesPage()
diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs
index a22a344..73237b1 100644
--- a/BTCPayServer.Tests/TestAccount.cs
+++ b/BTCPayServer.Tests/TestAccount.cs
@@ -144,11 +144,11 @@ namespace BTCPayServer.Tests
public async Task ModifyOnchainPaymentSettings(Action<WalletSettingsViewModel> modify)
{
- var storeController = GetController<UIStoresController>();
- var response = await storeController.WalletSettings(StoreId, "BTC");
+ var walletController = GetController<UIStoreOnChainWalletsController>();
+ var response = await walletController.WalletSettings(StoreId, "BTC");
WalletSettingsViewModel walletSettings = (WalletSettingsViewModel)((ViewResult)response).Model;
modify(walletSettings);
- storeController.UpdateWalletSettings(walletSettings).GetAwaiter().GetResult();
+ walletController.UpdateWalletSettings(walletSettings).GetAwaiter().GetResult();
}
public T GetController<T>(bool setImplicitStore = true) where T : ControllerBase
@@ -182,7 +182,7 @@ namespace BTCPayServer.Tests
if (StoreId is null)
await CreateStoreAsync();
SupportedNetwork = parent.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
- var store = parent.PayTester.GetController<UIStoresController>(UserId, StoreId, true);
+ var walletController = parent.PayTester.GetController<UIStoreOnChainWalletsController>(UserId, StoreId, true);
var generateRequest = new WalletSetupRequest
{
@@ -190,9 +190,9 @@ namespace BTCPayServer.Tests
SavePrivateKeys = importKeysToNBX,
};
- await store.GenerateWallet(StoreId, cryptoCode, WalletSetupMethod.HotWallet, generateRequest);
- Assert.NotNull(store.GenerateWalletResponse);
- GenerateWalletResponseV = store.GenerateWalletResponse;
+ await walletController.GenerateWallet(StoreId, cryptoCode, WalletSetupMethod.HotWallet, generateRequest);
+ Assert.NotNull(walletController.GenerateWalletResponse);
+ GenerateWalletResponseV = walletController.GenerateWalletResponse;
return new WalletId(StoreId, cryptoCode);
}
diff --git a/BTCPayServer/Components/LabelManager/Default.cshtml b/BTCPayServer/Components/LabelManager/Default.cshtml
index 2c0a6ec..3ab6b91 100644
--- a/BTCPayServer/Components/LabelManager/Default.cshtml
+++ b/BTCPayServer/Components/LabelManager/Default.cshtml
@@ -1,5 +1,6 @@
@using NBitcoin.DataEncoders
@using NBitcoin
+@using BTCPayServer.Plugins.Wallets
@model BTCPayServer.Components.LabelManager.LabelViewModel
@{
var elementId = "a" + Encoders.Base58.EncodeData(RandomUtils.GetBytes(16));
@@ -16,6 +17,7 @@
var fetchUrl = isWalletScoped
? Url.Action("LabelsJson", "UIWallets", new {
+ area = WalletsPlugin.Area,
walletId = Model.WalletObjectId.WalletId,
excludeTypes = Safe.Json(Model.ExcludeTypes),
linkedType = Model.LinkedType
@@ -29,7 +31,7 @@
var updateUrl = !Model.AutoUpdate
? string.Empty
: isWalletScoped
- ? Url.Action("UpdateLabels", "UIWallets", new { walletId = Model.WalletObjectId.WalletId })
+ ? Url.Action("UpdateLabels", "UIWallets", new { area = WalletsPlugin.Area, walletId = Model.WalletObjectId.WalletId })
: Url.Action("UpdateStoreLabels", "UIStores", new { storeId = Model.StoreId });
}
diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml
index 865da4b..c0d0089 100644
--- a/BTCPayServer/Components/MainNav/Default.cshtml
+++ b/BTCPayServer/Components/MainNav/Default.cshtml
@@ -4,6 +4,7 @@
@using BTCPayServer.Views.Wallets
@using BTCPayServer.Client
@using BTCPayServer.Plugins
+@using BTCPayServer.Plugins.Wallets
@using BTCPayServer.Services
@using BTCPayServer.Views.Apps
@using BTCPayServer.Configuration
@@ -94,10 +95,10 @@
</div>
</div>
<div class="accordion-item">
- <header class="accordion-header" id="Nav-Wallets-Header" permission="@Policies.CanModifyStoreSettings">
+ <header class="accordion-header" id="Nav-Wallets-Header" permission="@WalletPolicies.CanViewWallet">
<div text-translate="true" class="accordion-button">Wallets</div>
</header>
- <div id="Nav-Wallets" class="accordion-collapse" aria-labelledby="Nav-Wallets-Header" permission="@Policies.CanModifyStoreSettings">
+ <div id="Nav-Wallets" class="accordion-collapse" aria-labelledby="Nav-Wallets-Header" permission="@WalletPolicies.CanViewWallet">
<div class="accordion-body">
<ul class="navbar-nav">
@foreach (var scheme in Model.DerivationSchemes.OrderBy(scheme => scheme.Collapsed))
@@ -106,14 +107,14 @@
<li class="nav-item" data-testid="Wallet-@scheme.Crypto">
@if (isSetUp && scheme.WalletSupported)
{
- <a layout-menu-item="@nameof(WalletsNavPages.Transactions)-@scheme.Crypto" asp-area="" asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@scheme.WalletId">
+ <a layout-menu-item="@nameof(WalletsNavPages.Transactions)-@scheme.Crypto" asp-area="Wallets" asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@scheme.WalletId" permission="@WalletPolicies.CanViewWallet">
<span class="me-2 btcpay-status btcpay-status--@(scheme.Enabled ? "enabled" : "pending")"></span>
<span>@PrettyName.PrettyName(scheme.PaymentMethodId)</span>
</a>
}
else
{
- <a layout-menu-item="@nameof(WalletsNavPages.Transactions)-@scheme.Crypto" asp-area="" asp-controller="UIStores" asp-action="SetupWallet" asp-route-cryptoCode="@scheme.Crypto" asp-route-storeId="@Model.Store.Id">
+ <a layout-menu-item="@nameof(WalletsNavPages.Transactions)-@scheme.Crypto" asp-area="Wallets" asp-controller="UIStoreOnChainWallets" asp-action="SetupWallet" asp-route-cryptoCode="@scheme.Crypto" asp-route-storeId="@Model.Store.Id" permission="@WalletPolicies.CanManageWalletSettings">
<span class="me-2 btcpay-status btcpay-status--@(scheme.Enabled ? "enabled" : "pending")"></span>
<span>@PrettyName.PrettyName(scheme.PaymentMethodId)</span>
</a>
@@ -125,7 +126,7 @@
{
<li class="nav-item nav-item-sub">
<a layout-menu-item="@nameof(WalletsNavPages.Send)-@scheme.Crypto"
- asp-area="" asp-controller="UIWallets" asp-action="WalletSend" asp-route-walletId="@scheme.WalletId" text-translate="true">Send</a>
+ asp-area="Wallets" asp-controller="UIWallets" asp-action="WalletSend" asp-route-walletId="@scheme.WalletId" text-translate="true" permission="@WalletPolicies.CanCreateWalletTransactions">Send</a>
</li>
}
@@ -133,13 +134,13 @@
<li class="nav-item nav-item-sub">
<a
layout-menu-item="@nameof(WalletsNavPages.Receive)-@scheme.Crypto"
- asp-area="" asp-controller="UIWallets" asp-action="WalletReceive" asp-route-walletId="@scheme.WalletId" text-translate="true">Receive</a>
+ asp-area="Wallets" asp-controller="UIWallets" asp-action="WalletReceive" asp-route-walletId="@scheme.WalletId" text-translate="true" permission="@WalletPolicies.CanViewWallet">Receive</a>
</li>
<li class="nav-item nav-item-sub">
<a
layout-menu-item="@nameof(WalletsNavPages.Settings)-@scheme.Crypto"
- asp-area="" asp-controller="UIStores" asp-action="WalletSettings" asp-route-cryptoCode="@scheme.WalletId.CryptoCode" asp-route-storeId="@scheme.WalletId.StoreId" text-translate="true">Settings</a>
+ asp-area="Wallets" asp-controller="UIStoreOnChainWallets" asp-action="WalletSettings" asp-route-cryptoCode="@scheme.WalletId.CryptoCode" asp-route-storeId="@scheme.WalletId.StoreId" text-translate="true" permission="@WalletPolicies.CanManageWalletSettings">Settings</a>
</li>
<vc:ui-extension-point location="wallet-nav" model="@Model" />
}
@@ -156,7 +157,7 @@
<a
data-testid="Lightning-@scheme.CryptoCode"
layout-menu-item="@nameof(StoreNavPages.Lightning)-@scheme.CryptoCode"
- asp-area="" asp-controller="UIStores" asp-action="Lightning" asp-route-cryptoCode="@scheme.CryptoCode" asp-route-storeId="@Model.Store.Id">
+ asp-area="" asp-controller="UIStores" asp-action="Lightning" asp-route-cryptoCode="@scheme.CryptoCode" asp-route-storeId="@Model.Store.Id" permission="@Policies.CanModifyStoreSettings">
<span class="me-2 btcpay-status btcpay-status--@status"></span>
<span>@PrettyName.PrettyName(scheme.PaymentMethodId)</span>
</a>
@@ -166,7 +167,7 @@
<a
data-testid="Lightning-@scheme.CryptoCode"
layout-menu-item="@nameof(StoreNavPages.LightningSettings)-@scheme.CryptoCode"
- asp-area="" asp-controller="UIStores" asp-action="SetupLightningNode" asp-route-cryptoCode="@scheme.CryptoCode" asp-route-storeId="@Model.Store.Id">
+ asp-area="" asp-controller="UIStores" asp-action="SetupLightningNode" asp-route-cryptoCode="@scheme.CryptoCode" asp-route-storeId="@Model.Store.Id" permission="@Policies.CanModifyStoreSettings">
<span class="me-2 btcpay-status btcpay-status--@(scheme.Enabled ? "enabled" : "pending")"></span>
<span>@PrettyName.PrettyName(scheme.PaymentMethodId)</span>
</a>
@@ -175,7 +176,7 @@
@if (ViewData.IsCategory(WellKnownCategories.ForLightning(scheme.CryptoCode)))
{
<li class="nav-item nav-item-sub">
- <a layout-menu-item="@(nameof(StoreNavPages.LightningSettings))-@scheme.CryptoCode" asp-controller="UIStores" asp-action="LightningSettings" asp-route-storeId="@Model.Store.Id" asp-route-cryptoCode="@scheme.CryptoCode" text-translate="true">Settings</a>
+ <a layout-menu-item="@(nameof(StoreNavPages.LightningSettings))-@scheme.CryptoCode" asp-controller="UIStores" asp-action="LightningSettings" asp-route-storeId="@Model.Store.Id" asp-route-cryptoCode="@scheme.CryptoCode" text-translate="true" permission="@Policies.CanModifyStoreSettings">Settings</a>
</li>
<vc:ui-extension-point location="lightning-nav" model="@Model"/>
}
@@ -184,7 +185,8 @@
</ul>
</div>
</div>
- <div class="accordion-item">
+ <div class="accordion-item"
+ permission="@($"{Policies.CanViewInvoices},{Policies.CanViewReports},{Policies.CanViewPaymentRequests},{Policies.CanViewPullPayments},{Policies.CanViewPayouts}")">
<header class="accordion-header" id="Nav-Payments-Header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#Nav-Payments" aria-expanded="true" aria-controls="Nav-Payments">
<span text-translate="true">Payments</span>
diff --git a/BTCPayServer/Components/StoreRecentTransactions/Default.cshtml b/BTCPayServer/Components/StoreRecentTransactions/Default.cshtml
index 478db21..1287545 100644
--- a/BTCPayServer/Components/StoreRecentTransactions/Default.cshtml
+++ b/BTCPayServer/Components/StoreRecentTransactions/Default.cshtml
@@ -7,7 +7,7 @@
<h3 text-translate="true">Recent Transactions</h3>
@if (Model.Transactions.Any())
{
- <a asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId" text-translate="true">View All</a>
+ <a asp-area="Wallets" asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId" text-translate="true">View All</a>
}
</header>
@if (Model.InitialRendering)
diff --git a/BTCPayServer/Components/StoreWalletBalance/Default.cshtml b/BTCPayServer/Components/StoreWalletBalance/Default.cshtml
index 886d76f..52f8a4c 100644
--- a/BTCPayServer/Components/StoreWalletBalance/Default.cshtml
+++ b/BTCPayServer/Components/StoreWalletBalance/Default.cshtml
@@ -1,4 +1,5 @@
@using BTCPayServer.Client.Models
+@using BTCPayServer.Plugins.Wallets
@model BTCPayServer.Components.StoreWalletBalance.StoreWalletBalanceViewModel
<div id="StoreWalletBalance-@Model.StoreId-@Model.CryptoCode" class="widget store-wallet-balance">
<div class="d-flex gap-3 align-items-center justify-content-between mb-2">
@@ -40,7 +41,7 @@
else if (Model.MissingWalletConfig)
{
<p>
- We would like to show you a chart of your balance but you have not yet <a href="@Url.Action("SetupWallet", "UIStores", new { storeId = Model.StoreId, cryptoCode = Model.CryptoCode })">configured a wallet</a>.
+ We would like to show you a chart of your balance but you have not yet <a href="@Url.Action("SetupWallet", "UIStoreOnChainWallets", new { area = WalletsPlugin.Area, storeId = Model.StoreId, cryptoCode = Model.CryptoCode })">configured a wallet</a>.
</p>
}
else
@@ -59,7 +60,7 @@
let rate = null;
const id = `StoreWalletBalance-${storeId}-${cryptoCode}`;
- const baseUrl = @Safe.Json(Url.Action("WalletHistogram", "UIWallets", new { walletId = Model.WalletId, type = HistogramType.Week }));
+ const baseUrl = @Safe.Json(Url.Action("WalletHistogram", "UIWallets", new { area = WalletsPlugin.Area, walletId = Model.WalletId, type = HistogramType.Week }));
const valueTransform = value => rate ? DashboardUtils.displayDefaultCurrency(value, rate, defaultCurrency, divisibility) : value
const labelCount = 6
const tooltip = Chartist.plugins.tooltip2({
diff --git a/BTCPayServer/Components/WalletNav/Default.cshtml b/BTCPayServer/Components/WalletNav/Default.cshtml
index 72667e9..243830a 100644
--- a/BTCPayServer/Components/WalletNav/Default.cshtml
+++ b/BTCPayServer/Components/WalletNav/Default.cshtml
@@ -2,7 +2,7 @@
@inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Components.WalletNav.WalletNavViewModel
-<a asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId" class="unobtrusive-link">
+<a asp-area="Wallets" asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId" class="unobtrusive-link">
<h2 class="my-1">@Model.Label</h2>
<div class="text-muted fw-semibold" data-sensitive>
@DisplayFormatter.Currency(Model.Balance, Model.Network.CryptoCode)
diff --git a/BTCPayServer/Controllers/UIAppsController.cs b/BTCPayServer/Controllers/UIAppsController.cs
index 147a13b..d826bca 100644
--- a/BTCPayServer/Controllers/UIAppsController.cs
+++ b/BTCPayServer/Controllers/UIAppsController.cs
@@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
+using BTCPayServer.Plugins.Wallets;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
@@ -144,7 +145,7 @@ namespace BTCPayServer.Controllers
object text = _networkProvider.DefaultNetwork?.CryptoCode switch
{
null => StringLocalizer["To create a {0} app, you need to set up a wallet first", vm.AppType],
- {} cryptoCode => ViewLocalizer["To create a {0} app, you need to <a href='{1}' class='alert-link'>set up a wallet</a> first", vm.AppType, Url.Action(nameof(UIStoresController.SetupWallet), "UIStores", new { cryptoCode, storeId })!]
+ {} cryptoCode => ViewLocalizer["To create a {0} app, you need to <a href='{1}' class='alert-link'>set up a wallet</a> first", vm.AppType, Url.Action(nameof(UIStoreOnChainWalletsController.SetupWallet), "UIStoreOnChainWallets", new { area = WalletsPlugin.Area, cryptoCode, storeId })!]
};
TempData.SetStatusMessageModel(new StatusMessageModel
{
diff --git a/BTCPayServer/Controllers/UIHomeController.cs b/BTCPayServer/Controllers/UIHomeController.cs
index ed7f762..033bfa1 100644
--- a/BTCPayServer/Controllers/UIHomeController.cs
+++ b/BTCPayServer/Controllers/UIHomeController.cs
@@ -98,6 +98,7 @@ namespace BTCPayServer.Controllers
var m = new PermissionMetadata { PermissionName = def.Policy };
m.SubPermissions = permissionService.PermissionNodesByPolicy[m.PermissionName]
.EnumerateDescendants(false).Select(e => e.Definition.Policy)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(e => e, StringComparer.OrdinalIgnoreCase)
.ToList();
nodes.Add(def.Policy, m);
diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
index 07905b3..903ec5d 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
@@ -19,6 +19,7 @@ using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Payouts;
+using BTCPayServer.Plugins.Wallets;
using BTCPayServer.Plugins.Webhooks.Views;
using BTCPayServer.Rating;
using BTCPayServer.Services;
@@ -674,6 +675,7 @@ namespace BTCPayServer.Controllers
AspController = "UIWallets",
AspAction = nameof(UIWalletsController.WalletBumpFee),
RouteParameters = {
+ { "area", WalletsPlugin.Area },
{ "walletId", new WalletId(storeId, network.CryptoCode).ToString() },
{ "returnUrl", Url.Action(nameof(ListInvoices), new { storeId }) }
},
@@ -1308,7 +1310,7 @@ namespace BTCPayServer.Controllers
object text = _NetworkProvider.DefaultNetwork?.CryptoCode switch
{
null => StringLocalizer["To create an invoice, you need to setup a wallet first"],
- {} cryptoCode => ViewLocalizer["To create an invoice, you need to <a href='{0}'>setup a wallet</a> first", Url.Action(nameof(UIStoresController.SetupWallet), "UIStores", new { cryptoCode, storeId })!]
+ {} cryptoCode => ViewLocalizer["To create an invoice, you need to <a href='{0}'>setup a wallet</a> first", Url.Action(nameof(UIStoreOnChainWalletsController.SetupWallet), "UIStoreOnChainWallets", new { area = WalletsPlugin.Area, cryptoCode, storeId })!]
};
TempData.SetStatusMessageModel(new StatusMessageModel
{
diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs
index bf59e03..577d139 100644
--- a/BTCPayServer/Controllers/UIPaymentRequestController.cs
+++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs
@@ -17,6 +17,7 @@ using BTCPayServer.Models;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.PaymentRequest;
+using BTCPayServer.Plugins.Wallets;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Labels;
@@ -679,7 +680,7 @@ namespace BTCPayServer.Controllers
object text = _networkProvider.DefaultNetwork?.CryptoCode switch
{
null => StringLocalizer["To create a payment request, you need to set up a wallet first"],
- {} cryptoCode => ViewLocalizer["To create a payment request, you need to <a href='{0}'>setup a wallet</a> first", Url.Action(nameof(UIStoresController.SetupWallet), "UIStores", new { cryptoCode, storeId })!]
+ {} cryptoCode => ViewLocalizer["To create a payment request, you need to <a href='{0}'>setup a wallet</a> first", Url.Action(nameof(UIStoreOnChainWalletsController.SetupWallet), "UIStoreOnChainWallets", new { area = WalletsPlugin.Area, cryptoCode, storeId })!]
};
TempData.SetStatusMessageModel(new StatusMessageModel
{
diff --git a/BTCPayServer/Controllers/UIStoresController.Onchain.cs b/BTCPayServer/Controllers/UIStoresController.Onchain.cs
deleted file mode 100644
index 53bcb0d..0000000
--- a/BTCPayServer/Controllers/UIStoresController.Onchain.cs
+++ /dev/null
@@ -1,768 +0,0 @@
-using System;
-using System.IO;
-using System.Linq;
-using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Abstractions.Models;
-using BTCPayServer.Client;
-using BTCPayServer.Controllers.Greenfield;
-using BTCPayServer.Data;
-using BTCPayServer.Events;
-using BTCPayServer.Models.StoreViewModels;
-using BTCPayServer.Payments;
-using BTCPayServer.Payments.Bitcoin;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-using NBitcoin;
-using NBitcoin.DataEncoders;
-using NBXplorer;
-using NBXplorer.Models;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Controllers;
-
-public partial class UIStoresController
-{
- [HttpGet("{storeId}/onchain/{cryptoCode}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<ActionResult> SetupWallet(WalletSetupViewModel vm)
- {
- var checkResult = IsAvailable(vm.CryptoCode, out var store, out _);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- var derivation = GetExistingDerivationStrategy(vm.CryptoCode, store);
- vm.DerivationScheme = derivation?.AccountDerivation.ToString();
-
- var perm = await CanUseHotWallet();
- vm.SetPermission(perm);
-
- return View(vm);
- }
-
- [HttpGet("{storeId}/onchain/{cryptoCode}/import/{method?}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> ImportWallet(WalletSetupViewModel vm)
- {
- var checkResult = IsAvailable(vm.CryptoCode, out _, out var network);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- var perm = await CanUseHotWallet();
- vm.Network = network;
- vm.SetPermission(perm);
- vm.SupportTaproot = network.NBitcoinNetwork.Consensus.SupportTaproot;
- vm.SupportSegwit = network.NBitcoinNetwork.Consensus.SupportSegwit;
-
- if (vm.Method == null)
- {
- vm.Method = WalletSetupMethod.ImportOptions;
- }
- else if (vm.Method == WalletSetupMethod.Seed)
- {
- vm.SetupRequest = new WalletSetupRequest();
- }
-
- return View(vm.ViewName, vm);
- }
-
- [HttpPost("{storeId}/onchain/{cryptoCode}/modify")]
- [HttpPost("{storeId}/onchain/{cryptoCode}/import/{method}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> UpdateWallet(WalletSetupViewModel vm)
- {
- var checkResult = IsAvailable(vm.CryptoCode, out var store, out var network);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- vm.Network = network;
- DerivationSchemeSettings strategy = null;
- PaymentMethodId paymentMethodId = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode);
- BitcoinLikePaymentHandler handler = (BitcoinLikePaymentHandler)_handlers[paymentMethodId];
- var wallet = _walletProvider.GetWallet(network);
- if (wallet == null)
- {
- return NotFound();
- }
-
- if (vm.WalletFile != null)
- {
- string fileContent = null;
- try
- {
- fileContent = await ReadAllText(vm.WalletFile);
- }
- catch
- {
- // ignored
- }
-
- if (fileContent is null || !_onChainWalletParsers.TryParseWalletFile(fileContent, network, out strategy, out _))
- {
- ModelState.AddModelError(nameof(vm.WalletFile), StringLocalizer["Import failed, make sure you import a compatible wallet format"]);
- return View(vm.ViewName, vm);
- }
- }
- else if (!string.IsNullOrEmpty(vm.WalletFileContent))
- {
- if (!_onChainWalletParsers.TryParseWalletFile(vm.WalletFileContent, network, out strategy, out var error))
- {
- ModelState.AddModelError(nameof(vm.WalletFileContent), StringLocalizer["QR import failed: {0}", error]);
- return View(vm.ViewName, vm);
- }
- }
- else if (!string.IsNullOrEmpty(vm.DerivationScheme))
- {
- try
- {
- strategy = ParseDerivationStrategy(vm.DerivationScheme, network);
- strategy.Source = "ManualDerivationScheme";
- if (!string.IsNullOrEmpty(vm.AccountKey))
- {
- var accountKey = new BitcoinExtPubKey(vm.AccountKey, network.NBitcoinNetwork);
- var accountSettings =
- strategy.AccountKeySettings.FirstOrDefault(a => a.AccountKey == accountKey);
- if (accountSettings != null)
- {
- accountSettings.AccountKeyPath =
- vm.KeyPath == null ? null : KeyPath.Parse(vm.KeyPath);
- accountSettings.RootFingerprint = string.IsNullOrEmpty(vm.RootFingerprint)
- ? null
- : new HDFingerprint(Encoders.Hex.DecodeData(vm.RootFingerprint));
- }
- }
- vm.DerivationScheme = strategy.AccountDerivation.ToString();
- ModelState.Remove(nameof(vm.DerivationScheme));
- }
- catch (Exception ex)
- {
- ModelState.AddModelError(nameof(vm.DerivationScheme), StringLocalizer["Invalid wallet format: {0}", ex.Message]);
- return View(vm.ViewName, vm);
- }
- }
- else if (!string.IsNullOrEmpty(vm.Config))
- {
- try
- {
- strategy = handler.ParsePaymentMethodConfig(JToken.Parse(_dataProtector.UnprotectString(vm.Config)));
- }
- catch
- {
- ModelState.AddModelError(nameof(vm.Config), StringLocalizer["Config file was not in the correct format"]);
- return View(vm.ViewName, vm);
- }
- }
-
- if (strategy is null)
- {
- ModelState.AddModelError(nameof(vm.DerivationScheme), StringLocalizer["Please provide your extended public key"]);
- return View(vm.ViewName, vm);
- }
-
- vm.Config = _dataProtector.ProtectString(JToken.FromObject(strategy, handler.Serializer).ToString());
- ModelState.Remove(nameof(vm.Config));
-
- var storeBlob = store.GetStoreBlob();
- if (vm.Confirmation)
- {
- try
- {
- await wallet.TrackAsync(strategy.AccountDerivation);
- store.SetPaymentMethodConfig(_handlers[paymentMethodId], strategy);
- storeBlob.SetExcluded(paymentMethodId, false);
- storeBlob.PayJoinEnabled = strategy.IsHotWallet && !(vm.SetupRequest?.PayJoinEnabled is false);
- store.SetStoreBlob(storeBlob);
- }
- catch
- {
- ModelState.AddModelError(nameof(vm.DerivationScheme), StringLocalizer["NBXplorer is unable to track this derivation scheme. You may need to update it."]);
- return View(vm.ViewName, vm);
- }
- await _storeRepo.UpdateStore(store);
- _eventAggregator.Publish(new WalletChangedEvent { WalletId = new WalletId(vm.StoreId, vm.CryptoCode) });
-
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Wallet settings for {0} have been updated.", network.CryptoCode].Value;
-
- // This is success case when derivation scheme is added to the store
- return RedirectToAction(nameof(WalletSettings), new { storeId = vm.StoreId, cryptoCode = vm.CryptoCode });
- }
- return ConfirmAddresses(vm, strategy, network);
- }
-
- [HttpGet("{storeId}/onchain/{cryptoCode}/generate/{method?}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> GenerateWallet(WalletSetupViewModel vm)
- {
- var checkResult = IsAvailable(vm.CryptoCode, out _, out var network);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- var isHotWallet = vm.Method == WalletSetupMethod.HotWallet;
- var isColdWallet = vm.Method == WalletSetupMethod.WatchOnly;
- var perm = await CanUseHotWallet();
- if (isHotWallet && !perm.CanCreateHotWallet)
- return NotFound();
- if (isColdWallet && !perm.CanCreateColdWallet)
- return NotFound();
- vm.SetPermission(perm);
- vm.SupportTaproot = network.NBitcoinNetwork.Consensus.SupportTaproot;
- vm.SupportSegwit = network.NBitcoinNetwork.Consensus.SupportSegwit;
- vm.Network = network;
-
- if (vm.Method == null)
- {
- vm.Method = WalletSetupMethod.GenerateOptions;
- }
- else
- {
- var canUsePayJoin = perm.CanCreateHotWallet && isHotWallet && network.SupportPayJoin;
- vm.SetupRequest = new WalletSetupRequest
- {
- SavePrivateKeys = isHotWallet,
- CanUsePayJoin = canUsePayJoin,
- PayJoinEnabled = canUsePayJoin
- };
- }
-
- return View(vm.ViewName, vm);
- }
-
- internal GenerateWalletResponse GenerateWalletResponse;
-
- [HttpPost("{storeId}/onchain/{cryptoCode}/generate/{method}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> GenerateWallet(string storeId, string cryptoCode, WalletSetupMethod method, WalletSetupRequest request)
- {
- var checkResult = IsAvailable(cryptoCode, out _, out var network);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- var perm = await CanUseHotWallet();
- if ((!perm.CanCreateHotWallet && request.SavePrivateKeys) ||
- (!perm.CanCreateColdWallet && !request.SavePrivateKeys))
- {
- return NotFound();
- }
- var handler = _handlers.GetBitcoinHandler(cryptoCode);
- var client = _explorerProvider.GetExplorerClient(cryptoCode);
- var isImport = method == WalletSetupMethod.Seed;
- var vm = new WalletSetupViewModel
- {
- StoreId = storeId,
- CryptoCode = cryptoCode,
- Method = method,
- SetupRequest = request,
- Confirmation = !isImport,
- Network = network,
- Source = isImport ? "SeedImported" : "NBXplorerGenerated",
- IsHotWallet = isImport ? request.SavePrivateKeys : method == WalletSetupMethod.HotWallet,
- SupportTaproot = network.NBitcoinNetwork.Consensus.SupportTaproot,
- SupportSegwit = network.NBitcoinNetwork.Consensus.SupportSegwit
- };
- vm.SetPermission(perm);
- if (isImport && string.IsNullOrEmpty(request.ExistingMnemonic))
- {
- ModelState.AddModelError(nameof(request.ExistingMnemonic), StringLocalizer["Please provide your existing seed"]);
- return View(vm.ViewName, vm);
- }
-
- GenerateWalletResponse response;
- try
- {
- response = await client.GenerateWalletAsync(request);
- if (response == null)
- {
- throw new Exception("Node unavailable");
- }
- }
- catch (Exception e)
- {
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Error,
- Message = StringLocalizer["There was an error generating your wallet: {0}", e.Message].Value
- });
- return View(vm.ViewName, vm);
- }
-
- var derivationSchemeSettings = new DerivationSchemeSettings(response.DerivationScheme, network);
- if (method == WalletSetupMethod.Seed)
- {
- derivationSchemeSettings.Source = "ImportedSeed";
- derivationSchemeSettings.IsHotWallet = request.SavePrivateKeys;
- }
- else
- {
- derivationSchemeSettings.Source = "NBXplorerGenerated";
- derivationSchemeSettings.IsHotWallet = method == WalletSetupMethod.HotWallet;
- }
-
- var accountSettings = derivationSchemeSettings.AccountKeySettings[0];
- accountSettings.AccountKeyPath = response.AccountKeyPath.KeyPath;
- accountSettings.RootFingerprint = response.AccountKeyPath.MasterFingerprint;
- derivationSchemeSettings.AccountOriginal = response.DerivationScheme.ToString();
-
- // Set wallet properties from generate response
- vm.RootFingerprint = response.AccountKeyPath.MasterFingerprint.ToString();
- vm.AccountKey = response.AccountHDKey.Neuter().ToWif();
- vm.KeyPath = response.AccountKeyPath.KeyPath.ToString();
- vm.Config = _dataProtector.ProtectString(JToken.FromObject(derivationSchemeSettings, handler.Serializer).ToString());
-
- var result = await UpdateWallet(vm);
-
- if (!ModelState.IsValid || result is not RedirectToActionResult)
- return result;
-
- if (!isImport)
- {
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Success,
- Html = "<span class='text-centered'>" + StringLocalizer["Your wallet has been generated."].Value + "</span>"
- });
- var seedVm = new RecoverySeedBackupViewModel
- {
- CryptoCode = cryptoCode,
- Mnemonic = response.Mnemonic,
- Passphrase = response.Passphrase,
- IsStored = request.SavePrivateKeys,
- ReturnUrl = Url.Action(nameof(GenerateWalletConfirm), new { storeId, cryptoCode })
- };
- if (_btcPayEnv.IsDeveloping)
- {
- GenerateWalletResponse = response;
- }
- return this.RedirectToRecoverySeedBackup(seedVm);
- }
-
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Warning,
- Message = StringLocalizer["Please check your addresses and confirm."].Value
- });
- return result;
- }
-
- // The purpose of this action is to show the user a success message, which confirms
- // that the store settings have been updated after generating a new wallet.
- [HttpGet("{storeId}/onchain/{cryptoCode}/generate/confirm")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public ActionResult GenerateWalletConfirm(string storeId, string cryptoCode)
- {
- var checkResult = IsAvailable(cryptoCode, out _, out var network);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Wallet settings for {0} have been updated.", network.CryptoCode].Value;
-
- var walletId = new WalletId(storeId, cryptoCode);
- return RedirectToAction(nameof(UIWalletsController.WalletTransactions), "UIWallets", new { walletId });
- }
-
- [HttpGet("{storeId}/onchain/{cryptoCode}/settings")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> WalletSettings(string storeId, string cryptoCode)
- {
- var checkResult = IsAvailable(cryptoCode, out var store, out var network);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- var derivation = GetExistingDerivationStrategy(cryptoCode, store);
- if (derivation == null)
- {
- return NotFound();
- }
-
- var storeBlob = store.GetStoreBlob();
- var excludeFilters = storeBlob.GetExcludedPaymentMethods();
- var perm = await CanUseHotWallet();
- var client = _explorerProvider.GetExplorerClient(network);
-
- var handler = _handlers.GetBitcoinHandler(cryptoCode);
-
- var vm = new WalletSettingsViewModel
- {
- StoreId = storeId,
- CryptoCode = cryptoCode,
- WalletId = new WalletId(storeId, cryptoCode),
- Enabled = !excludeFilters.Match(handler.PaymentMethodId),
- Network = network,
- IsHotWallet = derivation.IsHotWallet,
- Source = derivation.Source,
- RootFingerprint = derivation.GetFirstAccountKeySettings().RootFingerprint.ToString(),
- DerivationScheme = derivation.AccountDerivation?.ToString(),
- DerivationSchemeInput = derivation.AccountOriginal,
- KeyPath = derivation.GetFirstAccountKeySettings().AccountKeyPath?.ToString(),
- UriScheme = network.NBitcoinNetwork.UriScheme,
- Label = derivation.Label,
- NBXSeedAvailable = derivation.IsHotWallet &&
- perm.CanCreateHotWallet &&
- !string.IsNullOrEmpty(await client.GetMetadataAsync<string>(derivation.AccountDerivation,
- WellknownMetadataKeys.MasterHDKey)),
- AccountKeys = (derivation.AccountKeySettings ?? [])
- .Select(e => new WalletSettingsAccountKeyViewModel
- {
- AccountKey = e.AccountKey.ToString(),
- MasterFingerprint = e.RootFingerprint is { } fp ? fp.ToString() : null,
- AccountKeyPath = e.AccountKeyPath == null ? "" : $"m/{e.AccountKeyPath}"
- }).ToList(),
- Config = _dataProtector.ProtectString(JToken.FromObject(derivation, handler.Serializer).ToString()),
- PayJoinEnabled = storeBlob.PayJoinEnabled,
- CanUsePayJoin = perm.CanCreateHotWallet && network.SupportPayJoin && derivation.IsHotWallet,
- CanUseHotWallet = perm.CanCreateHotWallet,
- StoreName = store.StoreName,
- CanSetupMultiSig = (derivation.AccountKeySettings ?? []).Length > 1,
- IsMultiSigOnServer = derivation.IsMultiSigOnServer,
- DefaultIncludeNonWitnessUtxo = derivation.DefaultIncludeNonWitnessUtxo
- };
-
- ViewData["ReplaceDescription"] = WalletReplaceWarning(derivation.IsHotWallet);
- ViewData["RemoveDescription"] = WalletRemoveWarning(derivation.IsHotWallet, network.CryptoCode);
-
- return View(vm);
- }
-
- [HttpPost("{storeId}/onchain/{cryptoCode}/settings/wallet")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> UpdateWalletSettings(WalletSettingsViewModel vm)
- {
- var checkResult = IsAvailable(vm.CryptoCode, out var store, out var network);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- var derivation = GetExistingDerivationStrategy(vm.CryptoCode, store);
- if (derivation == null)
- {
- return NotFound();
- }
- var handler = _handlers.GetBitcoinHandler(vm.CryptoCode);
- var storeBlob = store.GetStoreBlob();
- var excludeFilters = storeBlob.GetExcludedPaymentMethods();
- var currentlyEnabled = !excludeFilters.Match(handler.PaymentMethodId);
- var enabledChanged = currentlyEnabled != vm.Enabled;
- var payjoinChanged = storeBlob.PayJoinEnabled != vm.PayJoinEnabled;
- var needUpdate = enabledChanged || payjoinChanged;
- string errorMessage = null;
-
- if (enabledChanged) storeBlob.SetExcluded(handler.PaymentMethodId, !vm.Enabled);
- if (payjoinChanged && network.SupportPayJoin) storeBlob.PayJoinEnabled = vm.PayJoinEnabled;
- if (needUpdate) store.SetStoreBlob(storeBlob);
-
- if (derivation.Label != vm.Label ||
- derivation.IsMultiSigOnServer != vm.IsMultiSigOnServer ||
- derivation.DefaultIncludeNonWitnessUtxo != vm.DefaultIncludeNonWitnessUtxo)
- {
- needUpdate = true;
- derivation.Label = vm.Label;
- derivation.IsMultiSigOnServer = vm.IsMultiSigOnServer;
- derivation.DefaultIncludeNonWitnessUtxo = vm.DefaultIncludeNonWitnessUtxo;
- }
-
- for (int i = 0; i < derivation.AccountKeySettings.Length; i++)
- {
- try
- {
- var strKeyPath = vm.AccountKeys[i].AccountKeyPath;
- var accountKeyPath = string.IsNullOrWhiteSpace(strKeyPath) ? null : new KeyPath(strKeyPath);
-
- bool pathsDiffer = accountKeyPath != derivation.AccountKeySettings[i].AccountKeyPath;
-
- if (pathsDiffer)
- {
- needUpdate = true;
- derivation.AccountKeySettings[i].AccountKeyPath = accountKeyPath;
- }
- }
- catch (Exception ex)
- {
- errorMessage = $"{ex.Message}: {vm.AccountKeys[i].AccountKeyPath}";
- }
-
- try
- {
- HDFingerprint? rootFingerprint = string.IsNullOrWhiteSpace(vm.AccountKeys[i].MasterFingerprint)
- ? null
- : new HDFingerprint(Encoders.Hex.DecodeData(vm.AccountKeys[i].MasterFingerprint));
-
- if (rootFingerprint != null && derivation.AccountKeySettings[i].RootFingerprint != rootFingerprint)
- {
- needUpdate = true;
- derivation.AccountKeySettings[i].RootFingerprint = rootFingerprint;
- }
- }
- catch (Exception ex)
- {
- errorMessage = $"{ex.Message}: {vm.AccountKeys[i].MasterFingerprint}";
- }
- }
-
- if (needUpdate)
- {
- store.SetPaymentMethodConfig(handler, derivation);
-
- await _storeRepo.UpdateStore(store);
-
- if (string.IsNullOrEmpty(errorMessage))
- {
- var successMessage = "Wallet settings successfully updated.";
- if (enabledChanged)
- {
- _eventAggregator.Publish(new WalletChangedEvent { WalletId = new WalletId(vm.StoreId, vm.CryptoCode) });
- successMessage += $" {vm.CryptoCode} on-chain payments are now {(vm.Enabled ? "enabled" : "disabled")} for this store.";
- }
-
- if (payjoinChanged && storeBlob.PayJoinEnabled && network.SupportPayJoin)
- {
- var config = store.GetPaymentMethodConfig<DerivationSchemeSettings>(PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode), _handlers);
- if (config?.IsHotWallet is not true)
- {
- successMessage += " However, PayJoin will not work, as this isn't a <a href='https://docs.btcpayserver.org/HotWallet/' class='alert-link' target='_blank'>hot wallet</a>.";
- }
- }
-
- TempData[WellKnownTempData.SuccessMessage] = successMessage;
- }
- else
- {
- TempData[WellKnownTempData.ErrorMessage] = errorMessage;
- }
- }
-
- return RedirectToAction(nameof(WalletSettings), new { vm.StoreId, vm.CryptoCode });
- }
-
- [HttpGet("{storeId}/onchain/{cryptoCode}/seed")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> WalletSeed(string storeId, string cryptoCode, CancellationToken cancellationToken = default)
- {
- var checkResult = IsAvailable(cryptoCode, out var store, out var network);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- var derivation = GetExistingDerivationStrategy(cryptoCode, store);
- if (derivation == null)
- {
- return NotFound();
- }
-
- if (!(await CanUseHotWallet()).CanCreateHotWallet)
- return NotFound();
-
- var client = _explorerProvider.GetExplorerClient(network);
- if (await GetSeed(client, derivation) != null)
- {
- var mnemonic = await client.GetMetadataAsync<string>(derivation.AccountDerivation,
- WellknownMetadataKeys.Mnemonic, cancellationToken);
- var recoveryVm = new RecoverySeedBackupViewModel
- {
- CryptoCode = cryptoCode,
- Mnemonic = mnemonic,
- IsStored = true,
- RequireConfirm = false,
- ReturnUrl = Url.Action(nameof(WalletSettings), new { storeId, cryptoCode })
- };
- return this.RedirectToRecoverySeedBackup(recoveryVm);
- }
-
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Error,
- Message = StringLocalizer["The seed was not found"].Value
- });
-
- return RedirectToAction(nameof(WalletSettings));
- }
-
- [HttpGet("{storeId}/onchain/{cryptoCode}/replace")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public ActionResult ReplaceWallet(string storeId, string cryptoCode)
- {
- var checkResult = IsAvailable(cryptoCode, out var store, out var network);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- var derivation = GetExistingDerivationStrategy(cryptoCode, store);
-
- return View("Confirm", new ConfirmModel
- {
- Title = StringLocalizer["Replace {0} wallet", network.CryptoCode],
- Description = WalletReplaceWarning(derivation.IsHotWallet),
- Action = StringLocalizer["Setup new wallet"]
- });
- }
-
- [HttpPost("{storeId}/onchain/{cryptoCode}/replace")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public IActionResult ConfirmReplaceWallet(string storeId, string cryptoCode)
- {
- var checkResult = IsAvailable(cryptoCode, out var store, out _);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- var derivation = GetExistingDerivationStrategy(cryptoCode, store);
- if (derivation == null)
- {
- return NotFound();
- }
-
- return RedirectToAction(nameof(SetupWallet), new { storeId, cryptoCode });
- }
-
- [HttpGet("{storeId}/onchain/{cryptoCode}/delete")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public ActionResult DeleteWallet(string storeId, string cryptoCode)
- {
- var checkResult = IsAvailable(cryptoCode, out var store, out var network);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- var derivation = GetExistingDerivationStrategy(cryptoCode, store);
-
- return View("Confirm", new ConfirmModel
- {
- Title = StringLocalizer["Remove {0} wallet", network.CryptoCode],
- Description = WalletRemoveWarning(derivation.IsHotWallet, network.CryptoCode),
- Action = StringLocalizer["Delete"]
- });
- }
-
- [HttpPost("{storeId}/onchain/{cryptoCode}/delete")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> ConfirmDeleteWallet(string storeId, string cryptoCode)
- {
- var checkResult = IsAvailable(cryptoCode, out var store, out var network);
- if (checkResult != null)
- {
- return checkResult;
- }
-
- var derivation = GetExistingDerivationStrategy(cryptoCode, store);
- if (derivation == null)
- {
- return NotFound();
- }
-
- store.SetPaymentMethodConfig(PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode), null);
-
- await _storeRepo.UpdateStore(store);
- _eventAggregator.Publish(new WalletChangedEvent { WalletId = new WalletId(storeId, cryptoCode) });
-
- TempData[WellKnownTempData.SuccessMessage] =
- $"On-Chain payment for {network.CryptoCode} has been removed.";
-
- return RedirectToAction(nameof(GeneralSettings), new { storeId });
- }
-
- private IActionResult ConfirmAddresses(WalletSetupViewModel vm, DerivationSchemeSettings strategy, BTCPayNetwork network)
- {
- vm.DerivationScheme = strategy.AccountDerivation.ToString();
- vm.AddressSamples = new();
- if (!string.IsNullOrEmpty(vm.DerivationScheme))
- {
- var result = GreenfieldStoreOnChainPaymentMethodsController.GetPreviewResultData(0, 10, network, strategy.AccountDerivation);
- foreach (var r in result.Addresses)
- {
- vm.AddressSamples.Add((r.KeyPath, r.Address));
- }
- }
- vm.Confirmation = true;
- ModelState.Remove(nameof(vm.Config)); // Remove the cached value
- return View("ImportWallet/ConfirmAddresses", vm);
- }
-
- private ActionResult IsAvailable(string cryptoCode, out StoreData store, out BTCPayNetwork network)
- {
- store = HttpContext.GetStoreDataOrNull();
- network = cryptoCode == null ? null : _explorerProvider.GetNetwork(cryptoCode);
- return store == null || network == null ? NotFound() : null;
- }
-
- private DerivationSchemeSettings GetExistingDerivationStrategy(string cryptoCode, StoreData store)
- {
- return store.GetPaymentMethodConfig<DerivationSchemeSettings>(PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode), _handlers);
- }
-
- private async Task<string> GetSeed(ExplorerClient client, DerivationSchemeSettings derivation)
- {
- return derivation.IsHotWallet &&
- await client.GetMetadataAsync<string>(derivation.AccountDerivation, WellknownMetadataKeys.MasterHDKey) is { } seed &&
- !string.IsNullOrEmpty(seed) ? seed : null;
- }
-
- private async Task<WalletCreationPermissions> CanUseHotWallet()
- {
- return await _authorizationService.CanUseHotWallet(_policiesSettings, User);
- }
-
- private async Task<string> ReadAllText(IFormFile file)
- {
- using var stream = new StreamReader(file.OpenReadStream());
- return await stream.ReadToEndAsync();
- }
-
- private string WalletWarning(bool isHotWallet, string info)
- {
- var walletType = isHotWallet ? "hot" : "watch-only";
- var additionalText = isHotWallet
- ? ""
- : " or imported it into an external wallet. If you no longer have access to your private key (recovery seed), immediately replace the wallet";
- return
- $"<p class=\"text-danger fw-bold\">Please note that this is a <strong>{_html.Encode(walletType)} wallet</strong>!</p>" +
- $"<p class=\"text-danger fw-bold\">Do not proceed if you have not backed up the wallet{_html.Encode(additionalText)}.</p>" +
- $"<p class=\"text-start mb-0\">This action will erase the current wallet data from the server. {_html.Encode(info)}</p>";
- }
-
- private string WalletReplaceWarning(bool isHotWallet)
- {
- return WalletWarning(isHotWallet,
- "The current wallet will be replaced once you finish the setup of the new wallet. " +
- "If you cancel the setup, the current wallet will stay active.");
- }
-
- private string WalletRemoveWarning(bool isHotWallet, string cryptoCode)
- {
- return WalletWarning(isHotWallet,
- $"The store won't be able to receive {cryptoCode} onchain payments until a new wallet is set up.");
- }
-
- internal static DerivationSchemeSettings ParseDerivationStrategy(string derivationScheme, BTCPayNetwork network)
- {
- var parser = new DerivationSchemeParser(network);
- var isOD = Regex.Match(derivationScheme, @"\(.*?\)");
- if (isOD.Success)
- {
- return parser.ParseOD(derivationScheme);
- }
-
- var strategy = parser.Parse(derivationScheme);
- return new DerivationSchemeSettings(strategy, network);
- }
-}
diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs
index 73f3301..cdf4d7e 100644
--- a/BTCPayServer/Controllers/UIStoresController.cs
+++ b/BTCPayServer/Controllers/UIStoresController.cs
@@ -7,6 +7,7 @@ using BTCPayServer.Client;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
+using BTCPayServer.Plugins.Wallets;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
@@ -130,17 +131,32 @@ public partial class UIStoresController : Controller
[HttpGet("{storeId}/index")]
public async Task<IActionResult> Index(string storeId)
{
- if ((await _authorizationService.AuthorizeAsync(User, Policies.CanModifyStoreSettings)).Succeeded)
+ var userId = GetUserId();
+ if (userId is null)
+ return Forbid();
+ var store = await _storeRepo.FindStore(storeId, userId);
+ if (store is null)
+ return NotFound();
+ IActionResult? redirect = null;
+ if ((await _authorizationService.AuthorizeAsync(User, storeId, Policies.CanModifyStoreSettings)).Succeeded)
{
- HttpContext.SetPreferredStoreId(storeId);
- return RedirectToAction("Dashboard", new { storeId });
+ redirect = RedirectToAction(nameof(Dashboard), new { storeId });
}
- if ((await _authorizationService.AuthorizeAsync(User, Policies.CanViewInvoices)).Succeeded)
+ else if ((await _authorizationService.AuthorizeAsync(User, storeId, Policies.CanViewInvoices)).Succeeded)
{
- HttpContext.SetPreferredStoreId(storeId);
- return RedirectToAction("ListInvoices", "UIInvoice", new { storeId });
+ redirect = RedirectToAction(nameof(UIInvoiceController.ListInvoices), "UIInvoice", new { storeId });
}
- return Forbid();
+ else if ((await _authorizationService.AuthorizeAsync(User, storeId, WalletPolicies.CanViewWallet)).Succeeded)
+ {
+ redirect = RedirectToAction(nameof(UIWalletsController.ListWallets), "UIWallets", new { area = WalletsPlugin.Area });
+ }
+
+ if (redirect is null)
+ return Forbid();
+
+ HttpContext.SetStoreData(store);
+ HttpContext.SetPreferredStoreId(storeId);
+ return redirect;
}
public StoreData CurrentStore => HttpContext.GetStoreData();
diff --git a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs b/BTCPayServer/Controllers/UIWalletsController.PSBT.cs
deleted file mode 100644
index a3b02bf..0000000
--- a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs
+++ /dev/null
@@ -1,642 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Abstractions.Models;
-using BTCPayServer.BIP78.Sender;
-using BTCPayServer.Data;
-using BTCPayServer.ModelBinders;
-using BTCPayServer.Models.WalletViewModels;
-using BTCPayServer.Payments.PayJoin.Sender;
-using BTCPayServer.Services;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-using NBitcoin;
-using NBitcoin.Payment;
-using NBXplorer;
-using NBXplorer.Models;
-
-namespace BTCPayServer.Controllers
-{
- public partial class UIWalletsController
- {
- [NonAction]
- [Obsolete("Use CreatePSBT(string storeId, BTCPayNetwork network, DerivationSchemeSettings derivationSettings, WalletSendModel sendModel, CancellationToken cancellationToken) instead")]
- public Task<CreatePSBTResponse> CreatePSBT(BTCPayNetwork network, DerivationSchemeSettings derivationSettings, WalletSendModel sendModel,
- CancellationToken cancellationToken)
- => CreatePSBT(null, network, derivationSettings, sendModel, cancellationToken);
- [NonAction]
- public async Task<CreatePSBTResponse> CreatePSBT(string storeId, BTCPayNetwork network, DerivationSchemeSettings derivationSettings, WalletSendModel sendModel, CancellationToken cancellationToken)
- {
- var nbx = ExplorerClientProvider.GetExplorerClient(network);
- var psbtRequest = new CreatePSBTRequest()
- {
- RBF = network.SupportRBF ? true : null,
- AlwaysIncludeNonWitnessUTXO = sendModel.AlwaysIncludeNonWitnessUTXO,
- IncludeGlobalXPub = derivationSettings.IsMultiSigOnServer,
- };
- if (sendModel.InputSelection)
- {
- psbtRequest.IncludeOnlyOutpoints = sendModel.SelectedInputs?.Select(OutPoint.Parse).ToList() ?? new List<OutPoint>();
- }
- foreach (var transactionOutput in sendModel.Outputs)
- {
- var psbtDestination = new CreatePSBTDestination();
- psbtRequest.Destinations.Add(psbtDestination);
- psbtDestination.Destination = BitcoinAddress.Create(transactionOutput.DestinationAddress, network.NBitcoinNetwork);
- psbtDestination.Amount = Money.Coins(transactionOutput.Amount ?? 0.0m);
- psbtDestination.SubstractFees = transactionOutput.SubtractFeesFromOutput;
- }
-
- var pending = await _pendingTransactionService.GetPendingTransactions(network.CryptoCode, storeId ?? "");
- psbtRequest.ExcludeOutpoints = pending.SelectMany(p => p.OutpointsUsed).Select(OutPoint.Parse).ToList();
- psbtRequest.FeePreference = new FeePreference();
- if (sendModel.FeeSatoshiPerByte is decimal v and > decimal.Zero)
- {
- psbtRequest.FeePreference.ExplicitFeeRate = new FeeRate(v);
- }
- if (sendModel.NoChange && psbtRequest.Destinations is [{ Destination: { } firstDest }])
- {
- psbtRequest.ExplicitChangeAddress = firstDest;
- }
-
- var psbt = (await nbx.CreatePSBTAsync(derivationSettings.AccountDerivation, psbtRequest, cancellationToken));
- if (psbt == null)
- throw new NotSupportedException(StringLocalizer["You need to update your version of NBXplorer"]);
-
- return psbt;
- }
-
- [HttpPost("{walletId}/sign")]
- public async Task<IActionResult> WalletSign([ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId, WalletPSBTViewModel vm, string command = null)
- {
- var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
- var psbt = await vm.GetPSBT(network?.NBitcoinNetwork, ModelState);
-
- if (psbt is null || vm.InvalidPSBT)
- {
- return View("WalletSigningOptions", new WalletSigningOptionsModel
- {
- SigningContext = vm.SigningContext,
- ReturnUrl = vm.ReturnUrl,
- BackUrl = vm.BackUrl
- });
- }
-
- switch (command)
- {
- case "vault":
- return ViewVault(walletId, vm);
- case "seed":
- return SignWithSeed(walletId, vm.SigningContext, vm.ReturnUrl, vm.BackUrl);
- case "decode":
- return await WalletPSBT(walletId, vm, "decode");
- }
-
- if (await CanUseHotWallet())
- {
- var derivationScheme = GetDerivationSchemeSettings(walletId);
- if (derivationScheme?.IsHotWallet is true)
- {
- var extKey = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)
- .GetMetadataAsync<string>(derivationScheme.AccountDerivation,
- WellknownMetadataKeys.MasterHDKey);
- if (extKey != null)
- {
- return await SignWithSeed(walletId, new SignWithSeedViewModel
- {
- SeedOrKey = extKey,
- SigningContext = vm.SigningContext,
- ReturnUrl = vm.ReturnUrl,
- BackUrl = vm.BackUrl
- });
- }
- }
- }
- return View("WalletSigningOptions", new WalletSigningOptionsModel
- {
- SigningContext = vm.SigningContext,
- ReturnUrl = vm.ReturnUrl,
- BackUrl = vm.BackUrl
- });
- }
-
- [HttpGet("{walletId}/psbt")]
- public async Task<IActionResult> WalletPSBT([ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId, string returnUrl)
- {
- var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
- if (network is null)
- return NotFound();
- var referer = HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath;
- var vm = new WalletPSBTViewModel
- {
- BackUrl = string.IsNullOrEmpty(returnUrl) ? null : referer,
- ReturnUrl = returnUrl ?? referer,
- CryptoCode = network.CryptoCode
- };
-
- var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
- if (derivationSchemeSettings == null)
- return NotFound();
- vm.NBXSeedAvailable = await CanUseHotWallet() && derivationSchemeSettings.IsHotWallet;
- return View(vm);
- }
-
- [HttpPost("{walletId}/psbt")]
- public async Task<IActionResult> WalletPSBT(
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId,
- WalletPSBTViewModel vm, string command)
- {
- var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
- if (network is null)
- return NotFound();
- vm.CryptoCode = network.CryptoCode;
-
- var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
- if (derivationSchemeSettings == null)
- return NotFound();
-
- 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)
- {
- return View(vm);
- }
- if (psbt is null)
- {
- return View("WalletPSBT", vm);
- }
-
- switch (command)
- {
- case "createpending":
- await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl());
- return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
- case "sign":
- return await WalletSign(walletId, vm);
- case "collect" when vm.SigningContext.PendingTransactionId is not null:
- return await RedirectToWalletPSBTReady(walletId,
- new WalletPSBTReadyViewModel
- {
- SigningContext = vm.SigningContext, ReturnUrl = vm.ReturnUrl, BackUrl = vm.BackUrl
- });
- case "decode":
- ModelState.Remove(nameof(vm.PSBT));
- ModelState.Remove(nameof(vm.FileName));
- ModelState.Remove(nameof(vm.UploadedPSBTFile));
- await FetchTransactionDetails(walletId, derivationSchemeSettings, vm, network);
- return View("WalletPSBTDecoded", vm);
-
- case "save-psbt":
- return FilePSBT(psbt, vm.FileName);
-
- case "update":
- psbt = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbt);
- if (psbt == null)
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["You need to update your version of NBXplorer"].Value;
- return View(vm);
- }
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["PSBT updated!"].Value;
- return RedirectToWalletPSBT(new WalletPSBTViewModel
- {
- PSBT = psbt.ToBase64(),
- FileName = vm.FileName,
- ReturnUrl = vm.ReturnUrl,
- BackUrl = vm.BackUrl
- });
-
- case "combine":
- ModelState.Remove(nameof(vm.PSBT));
- return View(nameof(WalletPSBTCombine), new WalletPSBTCombineViewModel
- {
- OtherPSBT = psbt.ToBase64(),
- ReturnUrl = vm.ReturnUrl,
- BackUrl = vm.BackUrl
- });
-
- case "broadcast":
- return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel
- {
- SigningContext = new SigningContextModel(psbt),
- ReturnUrl = vm.ReturnUrl,
- BackUrl = vm.BackUrl
- });
-
- default:
- return View("WalletPSBTDecoded", vm);
- }
- }
-
- private async Task<PSBT> GetPayjoinProposedTX(BitcoinUrlBuilder bip21, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork, CancellationToken cancellationToken)
- {
- var cloned = psbt.Clone();
- cloned = cloned.Finalize();
- await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), cloned.ExtractTransaction(), btcPayNetwork);
- using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- cts.CancelAfter(TimeSpan.FromSeconds(30));
- var minRelayFee = _dashboard.Get(btcPayNetwork.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee;
- _payjoinClient.MinimumFeeRate = minRelayFee;
- return await _payjoinClient.RequestPayjoin(bip21, new PayjoinWallet(derivationSchemeSettings), psbt, cts.Token);
- }
-
- private async Task FetchTransactionDetails(WalletId walletId, DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network)
- {
- var psbtObject = PSBT.Parse(vm.SigningContext.PSBT, network.NBitcoinNetwork);
- if (!psbtObject.IsAllFinalized())
- psbtObject = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbtObject) ?? psbtObject;
- IHDKey signingKey = null;
- RootedKeyPath signingKeyPath = null;
- try
- {
- signingKey = new BitcoinExtPubKey(vm.SigningKey, network.NBitcoinNetwork);
- }
- catch { }
- try
- {
- signingKey ??= new BitcoinExtKey(vm.SigningKey, network.NBitcoinNetwork);
- }
- catch { }
-
- try
- {
- signingKeyPath = RootedKeyPath.Parse(vm.SigningKeyPath);
- }
- catch { }
-
- if (signingKey == null || signingKeyPath == null)
- {
- var signingKeySettings = derivationSchemeSettings.GetFirstAccountKeySettings();
- if (signingKey == null)
- {
- signingKey = signingKeySettings.AccountKey;
- vm.SigningKey = signingKey.ToString();
- }
- if (vm.SigningKeyPath == null)
- {
- signingKeyPath = signingKeySettings.GetRootedKeyPath();
- vm.SigningKeyPath = signingKeyPath?.ToString();
- }
- }
-
- // fetch helper that will be used to convert the value to fiat
- FiatRate rate = null;
- try
- {
- rate = await FetchRate(walletId);
- }
- catch (Exception)
- {
- // keeping model simple
- // vm.RateError = ex.Message;
- }
-
- //
- if (psbtObject.IsAllFinalized())
- {
- vm.CanCalculateBalance = false;
- }
- else
- {
- var balanceChange = psbtObject.GetBalance(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath);
- var replacement = Money.Satoshis(vm.SigningContext.BalanceChangeFromReplacement);
- if (replacement != Money.Zero)
- {
- vm.ReplacementBalanceChange = new WalletPSBTReadyViewModel.AmountViewModel()
- {
- BalanceChange = ValueToString(replacement, network, rate),
- Positive = replacement >= Money.Zero
- };
- balanceChange += replacement;
- }
- vm.BalanceChange = ValueToString(balanceChange, network, rate);
- vm.CanCalculateBalance = true;
- vm.Positive = balanceChange >= Money.Zero;
- }
- vm.Inputs = new List<WalletPSBTReadyViewModel.InputViewModel>();
- var inputToObjects = new Dictionary<uint, ObjectTypeId[]>();
- var outputToObjects = new Dictionary<string, ObjectTypeId>();
- foreach (var input in psbtObject.Inputs)
- {
- var inputVm = new WalletPSBTReadyViewModel.InputViewModel();
- vm.Inputs.Add(inputVm);
- var txOut = input.GetTxOut();
- var mine = input.HDKeysFor(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath).Any();
- var balanceChange2 = txOut?.Value ?? Money.Zero;
- if (mine)
- balanceChange2 = -balanceChange2;
- inputVm.BalanceChange = ValueToString(balanceChange2, network, rate);
- inputVm.Positive = balanceChange2 >= Money.Zero;
- inputVm.Index = (int)input.Index;
-
- var walletObjectIds = new List<ObjectTypeId>();
- walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Utxo, input.PrevOut.ToString()));
- walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Tx, input.PrevOut.Hash.ToString()));
- var address = txOut?.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString();
- if (address != null)
- walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Address, address));
- inputToObjects.Add(input.Index, walletObjectIds.ToArray());
-
- }
- vm.Destinations = new List<WalletPSBTReadyViewModel.DestinationViewModel>();
- foreach (var output in psbtObject.Outputs)
- {
- var dest = new WalletPSBTReadyViewModel.DestinationViewModel();
- vm.Destinations.Add(dest);
- var mine = output.HDKeysFor(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath).Any();
- var balanceChange2 = output.Value;
- if (!mine)
- balanceChange2 = -balanceChange2;
- dest.Balance = ValueToString(balanceChange2, network, rate);
- dest.Positive = balanceChange2 >= Money.Zero;
- dest.Destination = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString() ?? output.ScriptPubKey.ToString();
- var address = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString();
- if (address != null)
- outputToObjects.Add(dest.Destination, new ObjectTypeId(WalletObjectData.Types.Address, address));
-
- }
-
- if (psbtObject.TryGetFee(out var fee))
- {
- vm.Destinations.Add(new WalletPSBTReadyViewModel.DestinationViewModel
- {
- Positive = false,
- Balance = ValueToString(-fee, network, rate),
- Destination = "Mining fees"
- });
- }
- if (psbtObject.TryGetEstimatedFeeRate(out var feeRate))
- {
- vm.FeeRate = feeRate.ToString();
- }
-
- if (!psbtObject.IsAllFinalized())
- {
- var sanityErrors = new List<PSBTError>();
- foreach (var input in psbtObject.Inputs)
- {
- if (input.IsFinalized())
- continue;
-
- if (input.GetSignableCoin(out var missingCoin) is null)
- {
- sanityErrors.Add(new PSBTError(input.Index, missingCoin));
- }
- else if (!input.TryFinalizeInput(out var err))
- {
- sanityErrors.Add(err[0]);
- }
- }
- if (sanityErrors.Count > 0)
- vm.SetErrors(sanityErrors);
- }
-
- var combinedTypeIds = inputToObjects.Values.SelectMany(ids => ids).Concat(outputToObjects.Values)
- .DistinctBy(id => $"{id.Type}:{id.Id}").ToArray();
-
- var labelInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, combinedTypeIds);
- foreach (KeyValuePair<uint, ObjectTypeId[]> inputToObject in inputToObjects)
- {
- var keys = inputToObject.Value.Select(id => id.Id).ToArray();
- WalletTransactionInfo ix = null;
- foreach (var key in keys)
- {
- if (!labelInfo.TryGetValue(key, out var i))
- continue;
- if (ix is null)
- {
- ix = i;
- }
- else
- {
- ix.Merge(i);
- }
- }
- if (ix is null)
- continue;
-
- var labels = _labelService.CreateTransactionTagModels(ix, Request);
- var input = vm.Inputs.First(model => model.Index == inputToObject.Key);
- input.Labels = labels;
- }
- foreach (var outputToObject in outputToObjects)
- {
- if (!labelInfo.TryGetValue(outputToObject.Value.Id, out var ix))
- continue;
- var labels = _labelService.CreateTransactionTagModels(ix, Request);
- var destination = vm.Destinations.First(model => model.Destination == outputToObject.Key);
- destination.Labels = labels;
- }
-
- }
-
- [HttpPost("{walletId}/psbt/ready")]
- public async Task<IActionResult> WalletPSBTReady(
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId, WalletPSBTViewModel vm, string command, CancellationToken cancellationToken = default)
- {
- var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
- if (network is null)
- return NotFound();
- PSBT psbt = await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
- if (vm.InvalidPSBT || psbt is null)
- {
- if (vm.InvalidPSBT)
- vm.Errors.Add(StringLocalizer["Invalid PSBT"]);
- return View(nameof(WalletPSBT), vm);
- }
- DerivationSchemeSettings derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
- if (derivationSchemeSettings == null)
- return NotFound();
-
- await FetchTransactionDetails(walletId, derivationSchemeSettings, vm, network);
-
- switch (command)
- {
- case "payjoin":
- string error;
- try
- {
- var proposedPayjoin = await GetPayjoinProposedTX(new BitcoinUrlBuilder(vm.SigningContext.PayJoinBIP21, network.NBitcoinNetwork), psbt,
- derivationSchemeSettings, network, cancellationToken);
- vm.SigningContext ??= new();
- try
- {
- proposedPayjoin.Settings.SigningOptions = new SigningOptions
- {
- EnforceLowR = vm.SigningContext.EnforceLowR is not false
- };
- var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork);
- proposedPayjoin = proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation,
- extKey,
- RootedKeyPath.Parse(vm.SigningKeyPath));
- vm.SigningContext.PSBT = proposedPayjoin.ToBase64();
- vm.SigningContext.OriginalPSBT = psbt.ToBase64();
- proposedPayjoin.Finalize();
- var hash = proposedPayjoin.ExtractTransaction().GetHash();
- await WalletRepository.AddWalletTransactionAttachment(walletId, hash, Attachment.Payjoin());
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Success,
- AllowDismiss = false,
- Html = $"The payjoin transaction has been successfully broadcasted ({proposedPayjoin.ExtractTransaction().GetHash()})"
- });
- return await WalletPSBTReady(walletId, vm, "broadcast");
- }
- catch (Exception)
- {
- TempData.SetStatusMessageModel(new StatusMessageModel()
- {
- Severity = StatusMessageModel.StatusSeverity.Warning,
- AllowDismiss = false,
- Html =
- "This transaction has been coordinated between the receiver and you to create a <a href='https://en.bitcoin.it/wiki/PayJoin' target='_blank'>payjoin transaction</a> by adding inputs from the receiver.<br/>" +
- "The amount being sent may appear higher but is in fact almost same.<br/><br/>" +
- "If you cancel or refuse to sign this transaction, the payment will proceed without payjoin"
- });
- vm.SigningContext.PSBT = proposedPayjoin.ToBase64();
- vm.SigningContext.OriginalPSBT = psbt.ToBase64();
- return ViewVault(walletId, vm);
- }
- }
- catch (PayjoinReceiverException ex)
- {
- error = StringLocalizer["The payjoin receiver could not complete the payjoin: {0}", ex.Message];
- }
- catch (PayjoinSenderException ex)
- {
- error = StringLocalizer["We rejected the receiver's payjoin proposal: {0}", ex.Message];
- }
- catch (Exception ex)
- {
- error = StringLocalizer["Unexpected payjoin error: {0}", ex.Message];
- }
-
- //we possibly exposed the tx to the receiver, so we need to broadcast straight away
- psbt.Finalize();
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Warning,
- AllowDismiss = false,
- Html = $"The payjoin transaction could not be created.<br/>The original transaction was broadcasted instead ({psbt.ExtractTransaction().GetHash()})<br/><br/>" + error
- });
- return await WalletPSBTReady(walletId, vm, "broadcast");
- case "broadcast" when !psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors):
- vm.SetErrors(errors);
- return View(nameof(WalletPSBT), vm);
- case "broadcast":
- {
- var transaction = psbt.ExtractTransaction();
- try
- {
- var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction, cancellationToken);
- if (!broadcastResult.Success)
- {
- if (!string.IsNullOrEmpty(vm.SigningContext.OriginalPSBT))
- {
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Warning,
- AllowDismiss = false,
- Html = $"The payjoin transaction could not be broadcasted: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}<br/>The transaction has been reverted back to its original format and has been broadcast."
- });
- vm.SigningContext.PSBT = vm.SigningContext.OriginalPSBT;
- vm.SigningContext.OriginalPSBT = null;
- return await WalletPSBTReady(walletId, vm, "broadcast");
- }
-
- vm.Errors.Add(StringLocalizer["RPC Error while broadcasting: {0}", $"{broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}"]);
- return View(nameof(WalletPSBT), vm);
- }
- else
- {
- var wallet = _walletProvider.GetWallet(network);
- var derivationSettings = GetDerivationSchemeSettings(walletId);
- if (derivationSettings is not null)
- wallet.InvalidateCache(derivationSettings.AccountDerivation);
- }
- }
- catch (Exception ex)
- {
- vm.Errors.Add(StringLocalizer["Error while broadcasting: {0}", ex.Message]);
- return View(nameof(WalletPSBT), vm);
- }
-
- if (!TempData.HasStatusMessage())
- {
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Transaction broadcasted successfully ({0})", transaction.GetHash()].Value;
- }
-
- if (!string.IsNullOrEmpty(vm.SigningContext?.Comment))
- {
- var txObjId = new WalletObjectId(walletId, WalletObjectData.Types.Tx, transaction.GetHash().ToString());
- await WalletRepository.SetWalletObjectComment(txObjId, vm.SigningContext.Comment);
- }
-
- if (vm.SigningContext.PendingTransactionId is not null)
- {
- await _pendingTransactionService.Broadcasted(GetPendingTxId(walletId, vm.SigningContext.PendingTransactionId));
- }
-
- if (!string.IsNullOrEmpty(vm.ReturnUrl))
- {
- return LocalRedirect(vm.ReturnUrl);
- }
-
- return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
- }
- case "analyze-psbt":
- return RedirectToWalletPSBT(new WalletPSBTViewModel
- {
- PSBT = psbt.ToBase64(),
- ReturnUrl = vm.ReturnUrl,
- BackUrl = vm.BackUrl
- });
- case "decode":
- await FetchTransactionDetails(walletId, derivationSchemeSettings, vm, network);
- return View("WalletPSBTDecoded", vm);
- default:
- vm.Errors.Add(StringLocalizer["Unknown command"]);
- return View(nameof(WalletPSBT), vm);
- }
- }
-
- private IActionResult FilePSBT(PSBT psbt, string fileName)
- {
- return File(psbt.ToBytes(), "application/octet-stream", fileName);
- }
-
- [HttpPost("{walletId}/psbt/combine")]
- public async Task<IActionResult> WalletPSBTCombine([ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId, WalletPSBTCombineViewModel vm)
- {
- var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
- var psbt = await vm.GetPSBT(network?.NBitcoinNetwork, ModelState);
- if (psbt == null)
- {
- return View(vm);
- }
- var sourcePSBT = vm.GetSourcePSBT(network?.NBitcoinNetwork, ModelState);
- if (sourcePSBT is null)
- {
- return View(vm);
- }
- sourcePSBT = sourcePSBT.Combine(psbt);
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["PSBT Successfully combined!"].Value;
- return RedirectToWalletPSBT(new WalletPSBTViewModel
- {
- PSBT = sourcePSBT.ToBase64(),
- ReturnUrl = vm.ReturnUrl
- });
- }
- }
-}
diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs
deleted file mode 100644
index 11c6e1c..0000000
--- a/BTCPayServer/Controllers/UIWalletsController.cs
+++ /dev/null
@@ -1,2066 +0,0 @@
-#nullable enable
-using System;
-using System.Collections.Generic;
-using System.Data.Common;
-using System.Globalization;
-using System.Linq;
-using System.Net.Mime;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Abstractions.Models;
-using BTCPayServer.BIP78.Sender;
-using BTCPayServer.Client;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Data;
-using BTCPayServer.HostedServices;
-using BTCPayServer.ModelBinders;
-using BTCPayServer.Models;
-using BTCPayServer.Models.WalletViewModels;
-using BTCPayServer.Payments;
-using BTCPayServer.Payments.Bitcoin;
-using BTCPayServer.Payments.PayJoin;
-using BTCPayServer.Payouts;
-using BTCPayServer.Rating;
-using BTCPayServer.Services;
-using BTCPayServer.Services.Invoices;
-using BTCPayServer.Services.Labels;
-using BTCPayServer.Services.Rates;
-using BTCPayServer.Services.Stores;
-using BTCPayServer.Services.Wallets;
-using BTCPayServer.Services.Wallets.Export;
-using Dapper;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.WebUtilities;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Localization;
-using NBitcoin;
-using NBXplorer;
-using NBXplorer.DerivationStrategy;
-using NBXplorer.Models;
-using Newtonsoft.Json;
-using static BTCPayServer.Models.WalletViewModels.WalletBumpFeeViewModel;
-using StoreData = BTCPayServer.Data.StoreData;
-
-namespace BTCPayServer.Controllers
-{
- [Route("wallets")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- //16mb psbts
- [RequestFormLimits(ValueLengthLimit = FormReader.DefaultValueLengthLimit * 4)]
- public partial class UIWalletsController : Controller
- {
- private StoreRepository Repository { get; }
- private WalletRepository WalletRepository { get; }
- private BTCPayNetworkProvider NetworkProvider { get; }
- private ExplorerClientProvider ExplorerClientProvider { get; }
- private IServiceProvider ServiceProvider { get; }
- private RateFetcher RateFetcher { get; }
- private IStringLocalizer StringLocalizer { get; }
- private readonly NBXplorerDashboard _dashboard;
- private readonly IAuthorizationService _authorizationService;
- private readonly IFeeProviderFactory _feeRateProvider;
- private readonly BTCPayWalletProvider _walletProvider;
- private readonly WalletReceiveService _walletReceiveService;
- private readonly SettingsRepository _settingsRepository;
- private readonly DelayedTransactionBroadcaster _broadcaster;
- private readonly PayjoinClient _payjoinClient;
- private readonly LabelService _labelService;
- private readonly PaymentMethodHandlerDictionary _handlers;
- private readonly DefaultRulesCollection _defaultRules;
- private readonly Dictionary<PaymentMethodId, ICheckoutModelExtension> _paymentModelExtensions;
- private readonly TransactionLinkProviders _transactionLinkProviders;
- private readonly InvoiceRepository _invoiceRepository;
- private readonly PullPaymentHostedService _pullPaymentHostedService;
- private readonly WalletHistogramService _walletHistogramService;
-
- private readonly PendingTransactionService _pendingTransactionService;
- readonly CurrencyNameTable _currencyTable;
- private readonly DisplayFormatter _displayFormatter;
-
- public UIWalletsController(
- PendingTransactionService pendingTransactionService,
- StoreRepository repo,
- WalletRepository walletRepository,
- CurrencyNameTable currencyTable,
- BTCPayNetworkProvider networkProvider,
- NBXplorerDashboard dashboard,
- WalletHistogramService walletHistogramService,
- RateFetcher rateProvider,
- IAuthorizationService authorizationService,
- ExplorerClientProvider explorerProvider,
- IFeeProviderFactory feeRateProvider,
- BTCPayWalletProvider walletProvider,
- WalletReceiveService walletReceiveService,
- SettingsRepository settingsRepository,
- DelayedTransactionBroadcaster broadcaster,
- PayjoinClient payjoinClient,
- IServiceProvider serviceProvider,
- PullPaymentHostedService pullPaymentHostedService,
- LabelService labelService,
- DefaultRulesCollection defaultRules,
- PaymentMethodHandlerDictionary handlers,
- Dictionary<PaymentMethodId, ICheckoutModelExtension> paymentModelExtensions,
- IStringLocalizer stringLocalizer,
- TransactionLinkProviders transactionLinkProviders,
- InvoiceRepository invoiceRepository,
- DisplayFormatter displayFormatter)
- {
- _pendingTransactionService = pendingTransactionService;
- _currencyTable = currencyTable;
- _labelService = labelService;
- _defaultRules = defaultRules;
- _handlers = handlers;
- _paymentModelExtensions = paymentModelExtensions;
- _transactionLinkProviders = transactionLinkProviders;
- _invoiceRepository = invoiceRepository;
- Repository = repo;
- WalletRepository = walletRepository;
- RateFetcher = rateProvider;
- _authorizationService = authorizationService;
- NetworkProvider = networkProvider;
- _dashboard = dashboard;
- ExplorerClientProvider = explorerProvider;
- _feeRateProvider = feeRateProvider;
- _walletProvider = walletProvider;
- _walletReceiveService = walletReceiveService;
- _settingsRepository = settingsRepository;
- _broadcaster = broadcaster;
- _payjoinClient = payjoinClient;
- _pullPaymentHostedService = pullPaymentHostedService;
- ServiceProvider = serviceProvider;
- _walletHistogramService = walletHistogramService;
- StringLocalizer = stringLocalizer;
- _displayFormatter = displayFormatter;
- }
-
- [HttpGet("{walletId}/pending/{pendingTransactionId}/cancel")]
- public IActionResult CancelPendingTransaction(
- [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- string pendingTransactionId)
- {
- return View("Confirm", new ConfirmModel("Abort Pending Transaction",
- "Proceeding with this action will invalidate Pending Transaction and all accepted signatures.",
- "Confirm Abort"));
- }
- [HttpPost("{walletId}/pending/{pendingTransactionId}/cancel")]
- public async Task<IActionResult> CancelPendingTransactionConfirmed(
- [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- string pendingTransactionId)
- {
- await _pendingTransactionService.CancelPendingTransaction(GetPendingTxId(walletId, pendingTransactionId));
- TempData.SetStatusMessageModel(new StatusMessageModel()
- {
- Severity = StatusMessageModel.StatusSeverity.Success,
- Message = $"Aborted Pending Transaction {pendingTransactionId}"
- });
- return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
- }
-
-
- [HttpGet("{walletId}/pending/{pendingTransactionId}")]
- public async Task<IActionResult> ViewPendingTransaction(
- [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- string pendingTransactionId)
- {
- var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
- var pendingTransaction =
- await _pendingTransactionService.GetPendingTransaction(GetPendingTxId(walletId, pendingTransactionId));
- if (pendingTransaction is null || network is null)
- return NotFound();
- var blob = pendingTransaction.GetBlob();
- if (blob?.PSBT is null)
- return NotFound();
- var currentPsbt = PSBT.Parse(blob.PSBT, network.NBitcoinNetwork);
- foreach (CollectedSignature collectedSignature in blob.CollectedSignatures)
- {
- var psbt = PSBT.Parse(collectedSignature.ReceivedPSBT, network.NBitcoinNetwork);
- currentPsbt = currentPsbt.Combine(psbt);
- }
-
- var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
-
- var vm = new WalletPSBTViewModel()
- {
- CryptoCode = network.CryptoCode,
- SigningContext = new SigningContextModel(currentPsbt)
- {
- PendingTransactionId = pendingTransactionId,
- PSBT = currentPsbt.ToBase64(),
- },
- };
- await FetchTransactionDetails(walletId, derivationSchemeSettings, vm, network);
- await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
- return View("WalletPSBTDecoded", vm);
- }
-
- private PendingTransactionService.PendingTransactionFullId GetPendingTxId(WalletId walletId, string pendingTransactionId)
- => new (walletId.CryptoCode, walletId.StoreId, pendingTransactionId);
-
-
- [Route("{walletId}/transactions/bump")]
- [Route("{walletId}/transactions/{transactionId}/bump")]
- public async Task<IActionResult> WalletBumpFee([ModelBinder(typeof(WalletIdModelBinder))]
- [FromQuery]
- WalletId walletId,
- WalletBumpFeeViewModel model,
- CancellationToken cancellationToken = default)
- {
- var paymentMethod = GetDerivationSchemeSettings(walletId);
- if (paymentMethod is null)
- return NotFound();
-
- var wallet = _walletProvider.GetWallet(walletId.CryptoCode);
- var bumpable = await wallet.GetBumpableTransactions(paymentMethod.AccountDerivation, cancellationToken);
-
- var bumpTarget = model.GetBumpTarget()
- // Remove from the selected targets everything that isn't bumpable
- .Filter(bumpable.Where(o => (o.Value.CPFP || o.Value.RBF) && o.Value.ReplacementInfo != null).Select(o => o.Key).ToHashSet());
-
- var explorer = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
- var txs = await GetUnconfWalletTxInfo(explorer, paymentMethod.AccountDerivation, bumpTarget.GetTransactionIds(), cancellationToken);
-
- // Remove from the selected targets everything for which we don't have the transaction info
- bumpTarget = bumpTarget.Filter(txs.Select(t => t.Key).ToHashSet());
-
- model.ReturnUrl ??= Url.WalletTransactions(walletId)!;
-
- decimal minBumpFee;
- if (bumpTarget.GetSingleTransactionId() is { } txId)
- {
- var inf = bumpable[txId];
- if (inf.RBF)
- model.BumpFeeMethods.Add(new("RBF", "RBF"));
- if (inf.CPFP)
- model.BumpFeeMethods.Add(new("CPFP", "CPFP"));
-
- // We calculate the effective fee rate using all the ancestors and descendant.
- model.CurrentFeeSatoshiPerByte = inf.ReplacementInfo!.GetEffectiveFeeRate().SatoshiPerByte;
- minBumpFee = inf.ReplacementInfo.CalculateNewMinFeeRate().SatoshiPerByte;
- }
- else if (bumpTarget.GetTransactionIds().Any())
- {
- model.BumpFeeMethods.Add(new("CPFP", "CPFP"));
- // If we bump multiple transactions, we calculate the effective fee rate without
- // taking into account descendants. This isn't super correct... but good enough for our purposes.
- // This is because we would have the risk of double counting the fees otherwise.
- var currentFeeRate = GetTransactionsFeeInfo(bumpTarget, txs, null).CurrentFeeRate.SatoshiPerByte;
- model.CurrentFeeSatoshiPerByte = currentFeeRate;
- minBumpFee = currentFeeRate + 1.0m;
- }
- else
- {
- this.TempData.SetStatusMessageModel(new StatusMessageModel()
- {
- Severity = StatusMessageModel.StatusSeverity.Error,
- Message =
- bumpable switch
- {
- { Support: BumpableSupport.NotCompatible } => StringLocalizer["This version of NBXplorer is not compatible. Please update to 2.5.22 or above"],
- { Support: BumpableSupport.NotConfigured } => StringLocalizer["Please set NBXPlorer's PostgreSQL connection string to make this feature available."],
- { Support: BumpableSupport.NotSynched } => StringLocalizer["Please wait for your node to be synched"],
- _ => StringLocalizer["None of the selected transaction can be fee bumped"]
- }
- });
- return LocalRedirect(model.ReturnUrl);
- }
-
- model.IsMultiSigOnServer = paymentMethod.IsMultiSigOnServer;
- var recommendedFees = await GetRecommendedFees(wallet.Network, _feeRateProvider);
-
- foreach (var option in recommendedFees)
- {
- if (option is null)
- continue;
- if (option.FeeRate < minBumpFee)
- option.FeeRate = minBumpFee;
- }
-
- model.RecommendedSatoshiPerByte =
- recommendedFees.Where(option => option != null).ToList();
- model.FeeSatoshiPerByte ??= recommendedFees.Skip(1).FirstOrDefault()?.FeeRate;
- if (HttpContext.Request.Method != HttpMethods.Post)
- {
- model.Command = null;
- }
- if (!ModelState.IsValid || model.Command is null || model.FeeSatoshiPerByte is null)
- return View(nameof(WalletBumpFee), model);
-
- var targetFeeRate = new FeeRate(model.FeeSatoshiPerByte.Value);
- model.BumpMethod ??= model.BumpFeeMethods switch
- {
- { Count: 1 } => model.BumpFeeMethods[0].Value,
- _ => "RBF"
- };
- PSBT? psbt = null;
- SigningContextModel? signingContext = null;
- var feeBumpUrl = Url.Action(nameof(WalletBumpFee), new { walletId, transactionId = bumpTarget.GetSingleTransactionId(), model.FeeSatoshiPerByte, model.BumpMethod, model.TransactionHashes, model.Outpoints })!;
- if (model.BumpMethod == "CPFP")
- {
- var utxos = await explorer.GetUTXOsAsync(paymentMethod.AccountDerivation, cancellationToken);
-
- List<OutPoint> bumpableUTXOs = bumpTarget.GetMatchedOutpoints(utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0).Select(u => u.Outpoint));
- if (bumpableUTXOs.Count == 0)
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["There isn't any UTXO available to bump fee with CPFP"].Value;
- return LocalRedirect(model.ReturnUrl);
- }
-
- var createPSBT = new CreatePSBTRequest()
- {
- RBF = true,
- AlwaysIncludeNonWitnessUTXO = paymentMethod.DefaultIncludeNonWitnessUtxo,
- IncludeGlobalXPub = paymentMethod.IsMultiSigOnServer,
- IncludeOnlyOutpoints = bumpableUTXOs,
- SpendAllMatchingOutpoints = true,
- FeePreference = new FeePreference()
- {
- ExplicitFee = GetTransactionsFeeInfo(bumpTarget, txs, targetFeeRate).MissingFee,
- ExplicitFeeRate = targetFeeRate
- }
- };
-
- try
- {
- var psbtResponse = await explorer.CreatePSBTAsync(paymentMethod.AccountDerivation, createPSBT, cancellationToken);
-
- signingContext = new SigningContextModel
- {
- EnforceLowR = psbtResponse.Suggestions?.ShouldEnforceLowR,
- ChangeAddress = psbtResponse.ChangeAddress?.ToString(),
- PSBT = psbtResponse.PSBT.ToHex()
- };
- psbt = psbtResponse.PSBT;
- }
- catch (Exception ex)
- {
- TempData[WellKnownTempData.ErrorMessage] = ex.Message;
-
- return LocalRedirect(model.ReturnUrl);
- }
- }
- else if (model.BumpMethod == "RBF")
- {
- // RBF is only supported for a single tx
- var tx = txs[bumpTarget.GetSingleTransactionId()!];
- var changeOutput = tx.Outputs.FirstOrDefault(o => o.Feature == DerivationFeature.Change);
- if (changeOutput is null &&
- tx is { Transaction: { Outputs: [{ ScriptPubKey: {} singleAddress }] }})
- changeOutput = new() { ScriptPubKey = singleAddress, Index = 0 };
- if (tx.Inputs.Count != tx.Transaction?.Inputs.Count ||
- changeOutput is null)
- {
- this.ModelState.AddModelError(nameof(model.BumpMethod), StringLocalizer["This transaction can't be RBF'd"]);
- return View(nameof(WalletBumpFee), model);
- }
-
- IActionResult ChangeTooSmall(WalletBumpFeeViewModel vm, Money? missing)
- {
- if (missing is not null)
- ModelState.AddModelError(nameof(vm.FeeSatoshiPerByte), StringLocalizer["The change output is too small to pay for additional fee. (Missing {0} BTC)", missing.ToDecimal(MoneyUnit.BTC)]);
- else
- ModelState.AddModelError(nameof(vm.FeeSatoshiPerByte), StringLocalizer["The change output is too small to pay for additional fee."]);
- return View(nameof(WalletBumpFee), vm);
- }
-
- var bumpableTx = bumpable[tx.TransactionId].ReplacementInfo!;
- if (targetFeeRate < bumpableTx.CalculateNewMinFeeRate())
- {
- ModelState.AddModelError(nameof(model.FeeSatoshiPerByte), StringLocalizer["The selected fee rate is too small. The minimum is {0} sat/byte", bumpableTx.CalculateNewMinFeeRate().SatoshiPerByte]);
- return View(nameof(WalletBumpFee), model);
- }
-
- var bumpResult = bumpableTx.CalculateBumpResult(targetFeeRate);
- var createPSBT = new CreatePSBTRequest()
- {
- RBF = true,
- AlwaysIncludeNonWitnessUTXO = paymentMethod.DefaultIncludeNonWitnessUtxo,
- IncludeGlobalXPub = paymentMethod.IsMultiSigOnServer,
- IncludeOnlyOutpoints = tx.Transaction.Inputs.Select(i => i.PrevOut).ToList(),
- SpendAllMatchingOutpoints = true,
- DisableFingerprintRandomization = true,
- FeePreference = new FeePreference()
- {
- ExplicitFee = bumpResult.NewTxFee
- },
- ExplicitChangeAddress = changeOutput switch
- {
- { Address: {} addr } => PSBTDestination.Create(addr),
- { ScriptPubKey: {} scriptPubKey } => PSBTDestination.Create(scriptPubKey),
- _ => throw new InvalidOperationException("Invalid change output")
- },
- Destinations = tx.Transaction.Outputs.AsIndexedOutputs()
- .Select(o => new CreatePSBTDestination()
- {
- Amount = o.N == changeOutput.Index ? o.TxOut.Value - bumpResult.BumpTxFee : o.TxOut.Value,
- Destination = o.TxOut.ScriptPubKey,
- }).ToList()
- };
- var missingFundsOutput = createPSBT.Destinations.FirstOrDefault(d => d.Amount < Money.Zero);
- if (missingFundsOutput is not null)
- return ChangeTooSmall(model, -missingFundsOutput.Amount);
-
- try
- {
- var psbtResponse = await explorer.CreatePSBTAsync(paymentMethod.AccountDerivation, createPSBT, cancellationToken);
-
- signingContext = new SigningContextModel
- {
- EnforceLowR = psbtResponse.Suggestions?.ShouldEnforceLowR,
- ChangeAddress = psbtResponse.ChangeAddress?.ToString(),
- PSBT = psbtResponse.PSBT.ToHex(),
- BalanceChangeFromReplacement = (-(Money)tx.BalanceChange).Satoshi
- };
- psbt = psbtResponse.PSBT;
- }
- catch (NBXplorerException ex) when (ex.Error.Code == "output-too-small")
- {
- return ChangeTooSmall(model, null);
- }
- catch (NBXplorerException ex)
- {
- ModelState.AddModelError(nameof(model.TransactionId), StringLocalizer["Unable to create the replacement transaction ({0})", ex.Error.Message]);
- return View(nameof(WalletBumpFee), model);
- }
- }
-
- if (psbt is not null && signingContext is not null)
- {
- if (psbt.TryGetFinalizedHash(out var hash))
- await this.WalletRepository.EnsureWalletObject(new WalletObjectId(walletId, WalletObjectData.Types.Tx, hash.ToString()),
- new Newtonsoft.Json.Linq.JObject()
- {
- ["bumpFeeMethod"] = model.BumpMethod
- });
- switch (model.Command)
- {
- case "createpending":
- await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl());
- return RedirectToWalletList(walletId);
- default:
- // case "sign":
- return await WalletSign(walletId, new WalletPSBTViewModel()
- {
- SigningContext = signingContext,
- BackUrl = feeBumpUrl,
- ReturnUrl = model.ReturnUrl
- });
- }
- }
-
- // Ask choice to user
- return View(nameof(WalletBumpFee), model);
- }
-
- private async Task<Dictionary<uint256, TransactionInformation>> GetUnconfWalletTxInfo(ExplorerClient client, DerivationStrategyBase derivationStrategyBase, HashSet<uint256> txs, CancellationToken cancellationToken)
- {
- var txWalletInfo = new Dictionary<uint256, TransactionInformation>();
- var getTransactionAsync = txs.Select(t => client.GetTransactionAsync(derivationStrategyBase, t, cancellationToken)).ToArray();
- await Task.WhenAll(getTransactionAsync);
- foreach (var t in getTransactionAsync)
- {
- var r = await t;
- if (r is not
- {
- Confirmations: 0,
- Transaction: not null
- })
- continue;
- txWalletInfo.Add(r.TransactionId, r);
- }
- return txWalletInfo;
- }
-
- private (Money MissingFee, FeeRate CurrentFeeRate) GetTransactionsFeeInfo(BumpTarget target, Dictionary<uint256, TransactionInformation> txs, FeeRate? newFeeRate)
- {
- Money missingFee = Money.Zero;
- int totalSize = 0;
- Money totalFee = Money.Zero;
- // In theory, we should calculate using the effective fee rate of all bumped transactions.
- // In practice, it's a bit complicated to get... meh, that's good enough.
- foreach (var bumpedTx in target.GetTransactionIds().Select(o => txs[o]))
- {
- var size = bumpedTx.Metadata?.VirtualSize ?? bumpedTx.Transaction?.GetVirtualSize() ?? 200;
- var feePaid = bumpedTx.Metadata?.Fees;
- if (feePaid is null)
- // This shouldn't normally happen, as NBX indexes the fee if the transaction is in the mempool
- continue;
- if (newFeeRate is not null)
- {
- var expectedFeePaid = newFeeRate.GetFee(size);
- missingFee += Money.Max(Money.Zero, expectedFeePaid - feePaid);
- }
- totalSize += size;
- totalFee += feePaid;
- }
- return (missingFee, new FeeRate(totalFee, totalSize));
- }
-
- private IActionResult RedirectToWalletList(WalletId walletId)
- {
- return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
- }
-
- [HttpPost]
- [Route("{walletId}")]
- public async Task<IActionResult> ModifyTransaction(
- // We need addlabel and addlabelclick. addlabel is the + button if the label does not exists,
- // addlabelclick is if the user click on existing label. For some reason, reusing the same name attribute for both
- // does not work
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId, string transactionId,
- string? addlabel = null,
- string? addlabelclick = null,
- string? addcomment = null,
- string? removelabel = null)
- {
- addlabel = addlabel ?? addlabelclick;
- // Hack necessary when the user enter a empty comment and submit.
- // For some reason asp.net consider addcomment null instead of empty string...
- try
- {
- if (addcomment == null && Request.Form.TryGetValue(nameof(addcomment), out _))
- {
- addcomment = string.Empty;
- }
- }
- catch { }
- /////////
-
- var paymentMethod = GetDerivationSchemeSettings(walletId);
- if (paymentMethod == null)
- return NotFound();
-
- var txObjId = new WalletObjectId(walletId, WalletObjectData.Types.Tx, transactionId);
- if (addlabel != null)
- {
- await WalletRepository.AddWalletObjectLabels(txObjId, addlabel);
- }
- else if (removelabel != null)
- {
- await WalletRepository.RemoveWalletObjectLabels(txObjId, removelabel);
- }
- else if (addcomment != null)
- {
- await WalletRepository.SetWalletObjectComment(txObjId, addcomment);
- }
- return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
- }
-
- [HttpGet]
- [AllowAnonymous]
- public async Task<IActionResult> ListWallets()
- {
- if (GetUserId() is not string userId)
- {
- return Challenge(AuthenticationSchemes.Cookie);
- }
- var wallets = new ListWalletsViewModel();
- var stores = await Repository.GetStoresByUserId(userId);
-
- var onChainWallets = stores
- .SelectMany(s => s.GetPaymentMethodConfigs<DerivationSchemeSettings>(_handlers)
- .Select(d => (
- Wallet: _walletProvider.GetWallet(((IHasNetwork)_handlers[d.Key]).Network),
- DerivationStrategy: d.Value.AccountDerivation,
- Network: ((IHasNetwork)_handlers[d.Key]).Network))
- .Where(o => o.Wallet != null && o.Network.WalletSupported)
- .Select(o => (Wallet: o.Wallet,
- Store: s,
- Balance: GetBalanceString(o.Wallet, o.DerivationStrategy),
- DerivationStrategy: o.DerivationStrategy,
- Network: o.Network)))
- .ToList();
-
- foreach (var wallet in onChainWallets)
- {
- ListWalletsViewModel.WalletViewModel walletVm = new ListWalletsViewModel.WalletViewModel();
- wallets.Wallets.Add(walletVm);
- walletVm.Balance = await wallet.Balance + " " + wallet.Wallet.Network.CryptoCode;
-
-
- walletVm.CryptoCode = wallet.Network.CryptoCode;
- walletVm.StoreId = wallet.Store.Id;
- walletVm.Id = new WalletId(wallet.Store.Id, wallet.Network.CryptoCode);
- walletVm.StoreName = wallet.Store.StoreName;
-
- var money = await GetBalanceAsMoney(wallet.Wallet, wallet.DerivationStrategy);
- wallets.BalanceForCryptoCode[wallet.Network] = wallets.BalanceForCryptoCode.ContainsKey(wallet.Network)
- ? wallets.BalanceForCryptoCode[wallet.Network].Add(money)
- : money;
- }
-
- return View(wallets);
- }
-
- [HttpGet("{walletId}")]
- [HttpGet("{walletId}/transactions")]
- public async Task<IActionResult> WalletTransactions(
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId,
- string? labelFilter = null,
- int skip = 0,
- int count = 50,
- bool loadTransactions = false,
- CancellationToken cancellationToken = default
- )
- {
- var paymentMethod = GetDerivationSchemeSettings(walletId);
- if (paymentMethod == null)
- return NotFound();
- var network = _handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
- var wallet = _walletProvider.GetWallet(network);
-
- // We can't filter at the database level if we need to apply label filter
- var preFiltering = string.IsNullOrEmpty(labelFilter);
- var model = new ListTransactionsViewModel { Skip = skip, Count = count };
- const int maxVisibleLabels = 20;
-
- model.PendingTransactions = await _pendingTransactionService.GetPendingTransactions(walletId.CryptoCode, walletId.StoreId);
- model.Rates = GetCurrentStore().GetStoreBlob().GetTrackedRates().ToList();
-
- var labelsWithUsage = await WalletRepository.GetWalletLabelsByLinkedTypeWithUsage(walletId, WalletObjectData.Types.Tx, includeUnusedLabels: true);
- model.Labels.AddRange(labelsWithUsage
- .Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color), c.UsageCount)));
- model.PopularLabels = labelsWithUsage
- .OrderByDescending(c => c.UsageCount)
- .ThenBy(c => c.Label, StringComparer.OrdinalIgnoreCase)
- .Take(maxVisibleLabels)
- .OrderBy(c => c.Label, StringComparer.OrdinalIgnoreCase)
- .Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color), c.UsageCount))
- .ToList();
-
- IList<TransactionHistoryLine>? transactions = null;
- Dictionary<string, WalletTransactionInfo>? walletTransactionsInfo = null;
- if (loadTransactions)
- {
- transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, preFiltering ? skip : null, preFiltering ? count : null, cancellationToken: cancellationToken);
- walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, transactions.Select(t => t.TransactionId.ToString()).ToArray());
- }
- if (labelFilter != null)
- {
- model.PaginationQuery = new Dictionary<string, object> { { "labelFilter", labelFilter } };
- }
- if (transactions == null || walletTransactionsInfo is null)
- {
- model.Transactions = new List<ListTransactionsViewModel.TransactionViewModel>();
- }
- else
- {
- var bumpable = transactions.Any(tx => tx.Confirmations == 0) ? await wallet.GetBumpableTransactions(paymentMethod.AccountDerivation, cancellationToken) : new();
- var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(walletId.CryptoCode);
- foreach (var tx in transactions)
- {
- var vm = new ListTransactionsViewModel.TransactionViewModel();
- vm.Id = tx.TransactionId.ToString();
- vm.Link = _transactionLinkProviders.GetTransactionLink(pmi, vm.Id);
- vm.Timestamp = tx.SeenAt;
- vm.Positive = tx.BalanceChange.GetValue(wallet.Network) >= 0;
- vm.Balance = tx.BalanceChange.ShowMoney(wallet.Network);
- vm.IsConfirmed = tx.Confirmations != 0;
- vm.HistoryLine = tx;
- // If support isn't possible, we want the user to be able to click so he can see why it doesn't work
- vm.CanBumpFee =
- tx.Confirmations == 0 &&
- (bumpable.Support is not BumpableSupport.Ok || (bumpable.TryGetValue(tx.TransactionId, out var i) ? i.RBF || i.CPFP : false));
- if (walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var transactionInfo))
- {
- var labels = _labelService.CreateTransactionTagModels(transactionInfo, Request);
- vm.Tags.AddRange(labels);
- vm.Comment = transactionInfo.Comment;
- vm.InvoiceId = transactionInfo.Attachments.FirstOrDefault(a => a.Type == WalletObjectData.Types.Invoice)?.Id;
- vm.WalletRateBook = transactionInfo.Rates;
- }
-
- if (labelFilter == null ||
- vm.Tags.Any(l => l.Text.Equals(labelFilter, StringComparison.OrdinalIgnoreCase)))
- model.Transactions.Add(vm);
- }
-
- var trackedCurrencies = GetCurrentStore().GetStoreBlob().GetTrackedRates();
- var rates = await _invoiceRepository.GetRatesOfInvoices(model.Transactions.Select(r => r.InvoiceId).Where(r => r is not null).ToHashSet());
- foreach (var vm in model.Transactions)
- {
- if (vm.InvoiceId is null)
- continue;
- rates.TryGetValue(vm.InvoiceId, out var book);
- vm.InvoiceRateBook = book;
- }
-
- foreach (var vm in model.Transactions)
- {
- var book = vm.InvoiceRateBook ?? new();
- if (vm.WalletRateBook is not null)
- book.AddRates(vm.WalletRateBook);
- foreach (var trackedCurrency in trackedCurrencies)
- {
- var exists = book.TryGetRate(new CurrencyPair(network.CryptoCode, trackedCurrency), out var rate);
- vm.Rates.Add(exists ? _displayFormatter.Currency(rate, trackedCurrency) : null);
- }
- }
-
- model.Total = preFiltering ? null : model.Transactions.Count;
- // if we couldn't filter at the db level, we need to apply skip and count
- if (!preFiltering)
- {
- model.Transactions = model.Transactions.Skip(skip).Take(count).ToList();
- }
- }
-
- model.CryptoCode = walletId.CryptoCode;
-
- //If ajax call then load the partial view
- return Request.Headers["X-Requested-With"] == "XMLHttpRequest"
- ? PartialView("_WalletTransactionsList", model)
- : View(model);
- }
-
- [HttpGet("{walletId}/histogram/{type}")]
- public async Task<IActionResult> WalletHistogram(
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId, HistogramType type)
- {
- var store = GetCurrentStore();
- var data = await _walletHistogramService.GetHistogram(store, walletId, type);
- if (data == null)
- return NotFound();
-
- return Json(data);
- }
-
- [HttpGet("{walletId}/receive")]
- public async Task<IActionResult> WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- [FromQuery] string? returnUrl = null)
- {
- var paymentMethod = GetDerivationSchemeSettings(walletId);
- if (paymentMethod == null)
- return NotFound();
- var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
- if (network == null)
- return NotFound();
- var store = GetCurrentStore();
- var address = (await _walletReceiveService.GetOrGenerate(walletId)).Address;
- var allowedPayjoin = paymentMethod.IsHotWallet && store.GetStoreBlob().PayJoinEnabled;
- var bip21 = network.GenerateBIP21(address?.ToString(), null);
- if (allowedPayjoin)
- {
- var endpoint = Url.ActionAbsolute(Request, nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint",
- new { cryptoCode = walletId.CryptoCode }).ToString();
- bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey, endpoint);
- }
-
- string[]? labels = null;
- if (address is not null)
- {
- var info = await WalletRepository.GetWalletObject(new WalletObjectId(walletId, WalletObjectData.Types.Address,
- address.ToString()));
- labels = info?.GetNeighbours().Where(data => data.Type == WalletObjectData.Types.Label)
- .Select(data => data.Id).ToArray();
- }
- return View(new WalletReceiveViewModel
- {
- CryptoCode = walletId.CryptoCode,
- Address = address?.ToString(),
- CryptoImage = GetImage(network),
- PaymentLink = bip21.ToString(),
- ReturnUrl = returnUrl,
- SelectedLabels = labels ?? Array.Empty<string>()
- });
- }
-
- [HttpPost("{walletId}/receive")]
- public async Task<IActionResult> WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- WalletReceiveViewModel vm, string command)
- {
- var paymentMethod = GetDerivationSchemeSettings(walletId);
- if (paymentMethod == null)
- return NotFound();
- var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
- if (network == null)
- return NotFound();
- switch (command)
- {
- case "generate-new-address":
- await _walletReceiveService.GetOrGenerate(walletId, true);
- break;
- case "fill-wallet":
- var cheater = ServiceProvider.GetService<Cheater>();
- if (cheater != null)
- await SendFreeMoney(cheater, walletId, paymentMethod);
- break;
- }
- return RedirectToAction(nameof(WalletReceive), new { walletId, returnUrl = vm.ReturnUrl });
- }
-
- [HttpGet("{walletId}/addresses")]
- public async Task<IActionResult> ReservedAddresses(
- [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId)
- {
- var paymentMethod = GetDerivationSchemeSettings(walletId);
- if (paymentMethod == null)
- return NotFound();
-
- var labeledAddresses = await WalletRepository.GetReservedAddressesWithDetails(walletId);
- if (labeledAddresses.Count != 0)
- {
- var connectionFactory = ServiceProvider.GetRequiredService<NBXplorerConnectionFactory>();
- if (!connectionFactory.Available)
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Reserved Addresses requires access to the NBXplorer database."].Value;
- return RedirectToAction(nameof(WalletReceive), new { walletId });
- }
-
- try
- {
- labeledAddresses = await FilterReservedAddressesToCurrentDerivation(connectionFactory, walletId, paymentMethod, labeledAddresses);
- }
- catch (DbException)
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Reserved Addresses is temporarily unavailable because the NBXplorer database cannot be reached."].Value;
- return RedirectToAction(nameof(WalletReceive), new { walletId });
- }
- }
-
- var vm = new ReservedAddressesViewModel
- {
- WalletId = walletId.ToString(),
- CryptoCode = walletId.CryptoCode,
- Addresses = labeledAddresses
- };
-
- return View(vm);
- }
-
- private async Task<List<ReservedAddress>> FilterReservedAddressesToCurrentDerivation(
- NBXplorerConnectionFactory connectionFactory,
- WalletId walletId,
- DerivationSchemeSettings paymentMethod,
- List<ReservedAddress> labeledAddresses)
- {
- if (labeledAddresses.Count == 0 || paymentMethod.AccountDerivation is null)
- return labeledAddresses;
-
- var addresses = labeledAddresses.Select(a => a.Address).ToArray();
- var currentWalletId = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(
- walletId.CryptoCode,
- paymentMethod.AccountDerivation.ToString());
-
- await using var conn = await connectionFactory.OpenConnection();
- var activeAddresses = (await conn.QueryAsync<string>(
- """
- SELECT DISTINCT searched.addr
- FROM unnest(@addresses) AS searched(addr)
- JOIN scripts s
- ON s.code = @code
- AND s.addr = searched.addr
- JOIN wallets_scripts ws
- ON ws.code = s.code
- AND ws.script = s.script
- WHERE ws.wallet_id = @walletId
- """,
- new
- {
- addresses,
- code = walletId.CryptoCode,
- walletId = currentWalletId
- })).ToHashSet(StringComparer.Ordinal);
-
- return labeledAddresses
- .Where(address => activeAddresses.Contains(address.Address))
- .ToList();
- }
-
- private async Task SendFreeMoney(Cheater cheater, WalletId walletId, DerivationSchemeSettings paymentMethod)
- {
- var c = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
- var cashCow = cheater.GetCashCow(walletId.CryptoCode);
- if (walletId.CryptoCode == "LBTC")
- {
- await cashCow.SendCommandAsync("rescanblockchain");
- }
- 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);
- var b = cashCow.PrepareBatch();
- Random r = new Random();
- List<Task<uint256>> sending = new List<Task<uint256>>();
- foreach (var a in addresses)
- {
- sending.Add(b.SendToAddressAsync((await a).Address, Money.Coins(0.1m) + Money.Satoshis(r.Next(0, 90_000_000))));
- }
- await b.SendBatchAsync();
- await cashCow.GenerateAsync(1);
-
- var factory = ServiceProvider.GetRequiredService<NBXplorerConnectionFactory>();
-
- // Wait it sync...
- await Task.Delay(1000);
- await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode).WaitServerStartedAsync();
- await Task.Delay(1000);
- await using var conn = await factory.OpenConnection();
-
- var txIds = sending.Select(s => s.Result.ToString()).ToArray();
- await conn.ExecuteAsync(
- "UPDATE txs t SET seen_at=(NOW() - (random() * (interval '90 days'))) " +
- "FROM unnest(@txIds) AS r (tx_id) WHERE r.tx_id=t.tx_id;", new { txIds });
- await Task.Delay(1000);
- await conn.ExecuteAsync("REFRESH MATERIALIZED VIEW wallets_history;");
- }
-
- private async Task<bool> CanUseHotWallet()
- {
- var policies = await _settingsRepository.GetSettingAsync<PoliciesSettings>();
- return (await _authorizationService.CanUseHotWallet(policies, User)).CanCreateHotWallet;
- }
-
- [HttpGet("{walletId}/send")]
- public async Task<IActionResult> WalletSend(
- [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- string? defaultDestination = null, string? defaultAmount = null, string[]? bip21 = null,
- [FromQuery] string? returnUrl = null)
- {
- var store = await Repository.FindStore(walletId.StoreId);
- var paymentMethod = GetDerivationSchemeSettings(walletId);
- if (paymentMethod == null || store is null)
- return NotFound();
- var network = this.NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
- if (network == null || network.ReadonlyWallet)
- return NotFound();
-
- double.TryParse(defaultAmount, out var amount);
-
- var model = new WalletSendModel
- {
- CryptoCode = walletId.CryptoCode,
- ReturnUrl = returnUrl ?? HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath,
- IsMultiSigOnServer = paymentMethod.IsMultiSigOnServer,
- AlwaysIncludeNonWitnessUTXO = paymentMethod.DefaultIncludeNonWitnessUtxo
- };
- if (bip21?.Any() is true)
- {
- var messagePresent = TempData.HasStatusMessage();
- foreach (var link in bip21)
- {
- if (!string.IsNullOrEmpty(link))
- {
- await LoadFromBIP21(walletId, model, link, network, messagePresent);
- }
- }
- }
-
- if (!(model.Outputs?.Any() is true))
- {
- model.Outputs = new List<WalletSendModel.TransactionOutput>()
- {
- new WalletSendModel.TransactionOutput()
- {
- Amount = Convert.ToDecimal(amount), DestinationAddress = defaultDestination
- }
- };
- }
- var recommendedFeesAsync = GetRecommendedFees(network, _feeRateProvider);
- var balance = _walletProvider.GetWallet(network).GetBalance(paymentMethod.AccountDerivation);
- model.NBXSeedAvailable = await GetSeed(walletId, network) != null;
- var Balance = await balance;
- model.CurrentBalance = (Balance.Available ?? Balance.Total).GetValue(network);
- if (Balance.Immature is null)
- model.ImmatureBalance = 0;
- else
- model.ImmatureBalance = Balance.Immature.GetValue(network);
-
- var recommendedFees = await recommendedFeesAsync;
- model.RecommendedSatoshiPerByte =
- recommendedFees.Where(option => option != null).ToList();
-
- model.FeeSatoshiPerByte = recommendedFees.Skip(1).FirstOrDefault()?.FeeRate;
- model.CryptoDivisibility = network.Divisibility;
-
- try
- {
- var r = await FetchRate(walletId);
-
- model.Rate = r.Rate;
- model.FiatDivisibility = _currencyTable.GetNumberFormatInfo(r.Fiat, true)
- .CurrencyDecimalDigits;
- model.Fiat = r.Fiat;
- }
- catch (Exception ex) { model.RateError = ex.Message; }
-
- return View(model);
- }
-
- public record FiatRate(decimal Rate, string Fiat);
- private async Task<FiatRate> FetchRate(WalletId walletId)
- {
- var store = await Repository.FindStore(walletId.StoreId);
- if (store is null)
- throw new Exception("Store not found");
- var storeData = store.GetStoreBlob();
- var rateRules = storeData.GetRateRules(_defaultRules);
- storeData.Spread = 0.0m;
- var currencyPair = new CurrencyPair(walletId.CryptoCode, storeData.DefaultCurrency);
-
- using CancellationTokenSource cts = new();
- cts.CancelAfter(TimeSpan.FromSeconds(5));
- var result = await RateFetcher.FetchRate(currencyPair, rateRules, new StoreIdRateContext(store.Id), cts.Token)
- .WithCancellation(cts.Token);
-
- if (result.BidAsk == null)
- {
- throw new Exception(
- $"{result.EvaluatedRule} ({string.Join(", ", result.Errors.OfType<object>().ToArray())})");
- }
-
- return new (result.BidAsk.Center, currencyPair.Right);
- }
-
- private static async Task<WalletSendModel.FeeRateOption?[]> GetRecommendedFees(BTCPayNetwork network, IFeeProviderFactory feeProviderFactory)
- {
- var feeProvider = feeProviderFactory.CreateFeeProvider(network);
- List<WalletSendModel.FeeRateOption?> options = new();
- foreach (var time in new[] {
- TimeSpan.FromMinutes(10.0), TimeSpan.FromMinutes(60.0), TimeSpan.FromHours(6.0),
- TimeSpan.FromHours(24.0),
- })
- {
- try
- {
- var result = await feeProvider.GetFeeRateAsync((int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time));
- options.Add(new WalletSendModel.FeeRateOption()
- {
- Target = time,
- FeeRate = result.SatoshiPerByte
- });
- }
- catch (Exception)
- {
- options.Add(null);
- }
- }
- return options.ToArray();
- }
-
- private async Task<string?> GetSeed(WalletId walletId, BTCPayNetwork network)
- {
- return await CanUseHotWallet() &&
- GetDerivationSchemeSettings(walletId) is DerivationSchemeSettings s &&
- s.IsHotWallet &&
- ExplorerClientProvider.GetExplorerClient(network) is ExplorerClient client &&
- await client.GetMetadataAsync<string>(s.AccountDerivation, WellknownMetadataKeys.MasterHDKey) is
- string seed &&
- !string.IsNullOrEmpty(seed)
- ? seed
- : null;
- }
-
- [HttpPost("{walletId}/send")]
- public async Task<IActionResult> WalletSend(
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId, WalletSendModel vm, string command = "", CancellationToken cancellation = default,
- string? bip21 = "")
- {
- var store = await Repository.FindStore(walletId.StoreId);
- if (store == null)
- return NotFound();
- var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
- if (network == null || network.ReadonlyWallet)
- return NotFound();
-
- vm.NBXSeedAvailable = await GetSeed(walletId, network) != null;
- if (!string.IsNullOrEmpty(bip21))
- {
- vm.Outputs?.Clear();
- await LoadFromBIP21(walletId, vm, bip21, network, TempData.HasStatusMessage());
- }
-
- decimal transactionAmountSum = 0;
- if (command == "toggle-input-selection")
- {
- vm.InputSelection = !vm.InputSelection;
- }
- if (vm.InputSelection)
- {
- var schemeSettings = GetDerivationSchemeSettings(walletId);
- if (schemeSettings is null)
- return NotFound();
-
- var utxos = await _walletProvider.GetWallet(network)
- .GetUnspentCoins(schemeSettings.AccountDerivation, false, cancellation);
- var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(vm.CryptoCode);
- var walletTransactionsInfoAsync = await this.WalletRepository.GetWalletTransactionsInfo(walletId,
- utxos.SelectMany(GetWalletObjectsQuery.Get).Distinct().ToArray());
- vm.InputsAvailable = utxos.Select(coin =>
- {
- walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info1);
- walletTransactionsInfoAsync.TryGetValue(coin.Address.ToString(), out var info2);
- walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.ToString(), out var info3);
- var info = WalletRepository.Merge(info1, info2, info3);
- return new WalletSendModel.InputSelectionOption()
- {
- Outpoint = coin.OutPoint.ToString(),
- Amount = coin.Value.GetValue(network),
- Comment = info?.Comment,
- Labels = _labelService.CreateTransactionTagModels(info, Request),
- Link = _transactionLinkProviders.GetTransactionLink(pmi, coin.OutPoint.ToString()),
- Confirmations = coin.Confirmations,
- Timestamp = coin.Timestamp
- };
- }).ToArray();
- }
-
- if (command == "toggle-input-selection")
- {
- ModelState.Clear();
- return View(vm);
- }
- vm.Outputs ??= new();
- if (!string.IsNullOrEmpty(bip21))
- {
- if (!vm.Outputs.Any())
- {
- vm.Outputs.Add(new WalletSendModel.TransactionOutput());
- }
- return View(vm);
- }
- if (command == "add-output")
- {
- ModelState.Clear();
- vm.Outputs.Add(new WalletSendModel.TransactionOutput());
- return View(vm);
- }
- if (command.StartsWith("remove-output", StringComparison.InvariantCultureIgnoreCase))
- {
- ModelState.Clear();
- var index = int.Parse(
- command.Substring(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1),
- CultureInfo.InvariantCulture);
- vm.Outputs.RemoveAt(index);
- return View(vm);
- }
-
- if (!vm.Outputs.Any())
- {
- ModelState.AddModelError(string.Empty,
- "Please add at least one transaction output");
- return View(vm);
- }
-
- var bypassBalanceChecks = command == "schedule";
-
- var subtractFeesOutputsCount = new List<int>();
- var substractFees = vm.Outputs.Any(o => o.SubtractFeesFromOutput);
- for (var i = 0; i < vm.Outputs.Count; i++)
- {
- var transactionOutput = vm.Outputs[i];
- if (transactionOutput.SubtractFeesFromOutput)
- {
- subtractFeesOutputsCount.Add(i);
- }
- transactionOutput.DestinationAddress = transactionOutput.DestinationAddress?.Trim() ?? string.Empty;
-
- var inputName =
- string.Format(CultureInfo.InvariantCulture, "Outputs[{0}].",
- i.ToString(CultureInfo.InvariantCulture)) +
- nameof(transactionOutput.DestinationAddress);
- try
- {
- var address = BitcoinAddress.Create(transactionOutput.DestinationAddress, network.NBitcoinNetwork);
- if (address is TaprootAddress)
- {
- var supportTaproot = _dashboard.Get(network.CryptoCode)?.Status?.BitcoinStatus?.Capabilities
- ?.CanSupportTaproot;
- if (!(supportTaproot is true))
- {
- ModelState.AddModelError(inputName,
- "You need to update your full node, and/or NBXplorer (Version >= 2.1.56) to be able to send to a taproot address.");
- }
- }
- }
- catch
- {
- ModelState.AddModelError(inputName, "Invalid address");
- }
-
- if (!bypassBalanceChecks && transactionOutput.Amount.HasValue)
- {
- transactionAmountSum += transactionOutput.Amount.Value;
-
- if (vm.CurrentBalance == transactionOutput.Amount.Value &&
- !transactionOutput.SubtractFeesFromOutput)
- vm.AddModelError(model => model.Outputs[i].SubtractFeesFromOutput,
- "You are sending your entire balance to the same destination, you should subtract the fees",
- this);
- }
- }
-
- if (!bypassBalanceChecks)
- {
- if (subtractFeesOutputsCount.Count > 1)
- {
- foreach (var subtractFeesOutput in subtractFeesOutputsCount)
- {
- vm.AddModelError(model => model.Outputs[subtractFeesOutput].SubtractFeesFromOutput,
- "You can only subtract fees from one output", this);
- }
- }
- else if (vm.CurrentBalance == transactionAmountSum && !substractFees)
- {
- ModelState.AddModelError(string.Empty,
- "You are sending your entire balance, you should subtract the fees from an output");
- }
-
- if (vm.CurrentBalance < transactionAmountSum)
- {
- for (var i = 0; i < vm.Outputs.Count; i++)
- {
- vm.AddModelError(model => model.Outputs[i].Amount,
- "You are sending more than what you own", this);
- }
- }
-
- if (vm.FeeSatoshiPerByte is decimal fee)
- {
- if (fee < 0)
- {
- vm.AddModelError(model => model.FeeSatoshiPerByte,
- "The fee rate should be above 0", this);
- }
- }
- }
-
- if (!ModelState.IsValid)
- return View(vm);
-
- 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 obj = await WalletRepository.GetWalletObject(walletObjectAddress);
- if (obj is null)
- {
- await WalletRepository.EnsureWalletObject(walletObjectAddress);
- }
- await WalletRepository.AddWalletObjectLabels(walletObjectAddress, labels);
- }
-
- var derivationScheme = GetDerivationSchemeSettings(walletId);
- if (derivationScheme is null)
- return NotFound();
- CreatePSBTResponse psbtResponse;
- if (command == "schedule")
- {
- var pmi = PayoutTypes.CHAIN.GetPayoutMethodId(walletId.CryptoCode);
- var claims =
- vm.Outputs.Where(output => string.IsNullOrEmpty(output.PayoutId)).Select(output => new ClaimRequest()
- {
- Destination = new AddressClaimDestination(
- BitcoinAddress.Create(output.DestinationAddress, network.NBitcoinNetwork)),
- ClaimedAmount = output.Amount,
- PayoutMethodId = pmi,
- StoreId = walletId.StoreId,
- PreApprove = true,
- }).ToArray();
- var someFailed = false;
- string? message = null;
- string? errorMessage = null;
- var result = new Dictionary<ClaimRequest, ClaimRequest.ClaimResult>();
- foreach (ClaimRequest claimRequest in claims)
- {
- var response = await _pullPaymentHostedService.Claim(claimRequest);
- result.Add(claimRequest, response.Result);
- if (response.Result == ClaimRequest.ClaimResult.Ok)
- {
- if (message is null)
- {
- message = "Payouts scheduled:<br/>";
- }
-
- message += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination}<br/>";
-
- }
- else
- {
- someFailed = true;
- if (errorMessage is null)
- {
- errorMessage = "Payouts failed to be scheduled:<br/>";
- }
-
- switch (response.Result)
- {
- case ClaimRequest.ClaimResult.Duplicate:
- errorMessage += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination} - address reuse<br/>";
- break;
- case ClaimRequest.ClaimResult.AmountTooLow:
- errorMessage += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination} - amount too low<br/>";
- break;
- }
- }
- }
-
- if (message is not null && errorMessage is not null)
- {
- message += $"<br/><br/>{errorMessage}";
- }
- else if (message is null && errorMessage is not null)
- {
- message = errorMessage;
- }
- TempData.SetStatusMessageModel(new StatusMessageModel()
- {
- Severity = someFailed ? StatusMessageModel.StatusSeverity.Warning :
- StatusMessageModel.StatusSeverity.Success,
- Html = message
- });
- return RedirectToAction("Payouts", "UIStorePullPayments",
- new
- {
- storeId = walletId.StoreId,
- PaymentMethodId = pmi.ToString(),
- payoutState = PayoutState.AwaitingPayment,
- });
- }
-
- try
- {
- psbtResponse = await CreatePSBT(walletId.StoreId, network, derivationScheme, vm, cancellation);
- }
- catch (NBXplorerException ex)
- {
- ModelState.AddModelError(string.Empty, ex.Error.Message);
- return View(vm);
- }
- catch (NotSupportedException)
- {
- ModelState.AddModelError(string.Empty, "You need to update your version of NBXplorer");
- return View(vm);
- }
-
- var psbt = psbtResponse.PSBT;
- derivationScheme.RebaseKeyPaths(psbt);
-
- var signingContext = new SigningContextModel
- {
- PayJoinBIP21 = vm.PayJoinBIP21,
- EnforceLowR = psbtResponse.Suggestions?.ShouldEnforceLowR,
- ChangeAddress = psbtResponse.ChangeAddress?.ToString(),
- PSBT = psbt.ToHex(),
- Comment = vm.Comment
- };
-
- if (!psbt.IsReadyToSign() && command == "sign")
- command = "analyze-psbt";
- switch (command)
- {
- case "createpending":
- await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl());
- return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
- case "sign":
- return await WalletSign(walletId, new WalletPSBTViewModel
- {
- SigningContext = signingContext,
- ReturnUrl = vm.ReturnUrl,
- BackUrl = this.Url.WalletSend(walletId)
- });
- case "analyze-psbt":
- var name =
- $"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt";
- return RedirectToWalletPSBT(new WalletPSBTViewModel { PSBT = psbt.ToBase64(), FileName = name });
- default:
- return View(vm);
- }
- }
-
-
- private async Task LoadFromBIP21(WalletId walletId, WalletSendModel vm, string bip21,
- BTCPayNetwork network, bool statusMessagePresent)
- {
- BitcoinAddress? address = null;
- vm.Outputs ??= new();
- try
- {
- var uriBuilder = new NBitcoin.Payment.BitcoinUrlBuilder(bip21, network.NBitcoinNetwork);
- var output = new WalletSendModel.TransactionOutput
- {
- Amount = uriBuilder.Amount?.ToDecimal(MoneyUnit.BTC),
- DestinationAddress = uriBuilder.Address?.ToString(),
- SubtractFeesFromOutput = false,
- PayoutId = uriBuilder.UnknownParameters.ContainsKey("payout")
- ? uriBuilder.UnknownParameters["payout"]
- : null
- };
- if (!string.IsNullOrEmpty(uriBuilder.Label))
- {
- output.Labels = output.Labels.Append(uriBuilder.Label).ToArray();
- }
- vm.Outputs.Add(output);
- address = uriBuilder.Address;
- // only set SetStatusMessageModel if there is not message already or there is label / message in uri builder
- if (!statusMessagePresent)
- {
- if (!string.IsNullOrEmpty(uriBuilder.Label) || !string.IsNullOrEmpty(uriBuilder.Message))
- {
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Info,
- Html =
- $"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to <strong>{uriBuilder.Label}</strong>")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for <strong>{uriBuilder.Message}</strong>")}"
- });
- }
- }
-
- if (uriBuilder.TryGetPayjoinEndpoint(out _))
- vm.PayJoinBIP21 = uriBuilder.ToString();
- }
- catch
- {
- try
- {
- address = BitcoinAddress.Create(bip21, network.NBitcoinNetwork);
- vm.Outputs.Add(new WalletSendModel.TransactionOutput
- {
- DestinationAddress = address.ToString()
- });
- }
- catch
- {
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Error,
- Message = StringLocalizer["The provided BIP21 payment URI was malformed"].Value
- });
- }
- }
-
- ModelState.Clear();
- if (address is not null)
- {
- var addressLabels = await WalletRepository.GetWalletLabels(new WalletObjectId(walletId, WalletObjectData.Types.Address, address.ToString()));
- vm.Outputs.Last().Labels = vm.Outputs.Last().Labels.Concat(addressLabels.Select(tuple => tuple.Label)).ToArray();
- }
- }
-
- private IActionResult ViewVault(WalletId walletId, WalletPSBTViewModel vm)
- {
- return View(nameof(WalletSendVault),
- new WalletSendVaultModel
- {
- SigningContext = vm.SigningContext,
- WalletId = walletId.ToString(),
- ReturnUrl = vm.ReturnUrl,
- BackUrl = vm.BackUrl
- });
- }
-
- [HttpPost("{walletId}/vault")]
- public async Task<IActionResult> WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- WalletSendVaultModel model)
- {
- TempData.SetStatusSuccess(StringLocalizer["Transaction successfully signed"].Value);
- return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel
- {
- SigningContext = model.SigningContext,
- ReturnUrl = model.ReturnUrl,
- BackUrl = model.BackUrl
- });
- }
-
- private async Task<IActionResult> RedirectToWalletPSBTReady(WalletId walletId, WalletPSBTReadyViewModel vm)
- {
- if (vm.SigningContext.PendingTransactionId is not null)
- {
- var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode)?.NBitcoinNetwork;
- if (network is null)
- return NotFound();
- var psbt = PSBT.Parse(vm.SigningContext.PSBT, network);
- var pendingTransaction = await _pendingTransactionService.CollectSignature(GetPendingTxId(walletId, vm.SigningContext.PendingTransactionId), psbt, CancellationToken.None);
-
- if (pendingTransaction != null)
- return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
- }
-
- var redirectVm = new PostRedirectViewModel
- {
- AspController = "UIWallets",
- AspAction = nameof(WalletPSBTReady),
- RouteParameters = { { "walletId", this.RouteData?.Values["walletId"]?.ToString() } },
- FormParameters =
- {
- { "SigningKey", vm.SigningKey },
- { "SigningKeyPath", vm.SigningKeyPath },
- { "command", "decode" }
- }
- };
- AddSigningContext(redirectVm, vm.SigningContext);
- if (!string.IsNullOrEmpty(vm.SigningContext.OriginalPSBT) &&
- !string.IsNullOrEmpty(vm.SigningContext.PSBT))
- {
- //if a hw device signed a payjoin, we want it broadcast instantly
- redirectVm.FormParameters.Remove("command");
- redirectVm.FormParameters.Add("command", "broadcast");
- }
- if (vm.ReturnUrl != null)
- {
- redirectVm.FormParameters.Add("returnUrl", vm.ReturnUrl);
- }
- if (vm.BackUrl != null)
- {
- redirectVm.FormParameters.Add("backUrl", vm.BackUrl);
- }
- return View("PostRedirect", redirectVm);
- }
-
- private void AddSigningContext(PostRedirectViewModel redirectVm, SigningContextModel signingContext)
- {
- if (signingContext is null)
- return;
- redirectVm.FormParameters.Add("SigningContext.PSBT", signingContext.PSBT);
- redirectVm.FormParameters.Add("SigningContext.OriginalPSBT", signingContext.OriginalPSBT);
- redirectVm.FormParameters.Add("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21);
- redirectVm.FormParameters.Add("SigningContext.EnforceLowR",
- signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture));
- redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress);
- redirectVm.FormParameters.Add("SigningContext.PendingTransactionId", signingContext.PendingTransactionId);
- redirectVm.FormParameters.Add("SigningContext.BalanceChangeFromReplacement", signingContext.BalanceChangeFromReplacement.ToString());
- redirectVm.FormParameters.Add("SigningContext.Comment", signingContext.Comment);
- }
-
- private IActionResult RedirectToWalletPSBT(WalletPSBTViewModel vm)
- {
- var redirectVm = new PostRedirectViewModel
- {
- AspController = "UIWallets",
- AspAction = nameof(WalletPSBT),
- RouteParameters = { { "walletId", RouteData.Values["walletId"]?.ToString() } },
- FormParameters =
- {
- { "psbt", vm.PSBT },
- { "fileName", vm.FileName },
- { "backUrl", vm.BackUrl },
- { "returnUrl", vm.ReturnUrl },
- { "command", "decode" }
- }
- };
- return View("PostRedirect", redirectVm);
- }
-
- [HttpGet("{walletId}/psbt/seed")]
- public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- SigningContextModel signingContext, string? returnUrl, string? backUrl)
- {
- return View(nameof(SignWithSeed), new SignWithSeedViewModel
- {
- SigningContext = signingContext,
- ReturnUrl = returnUrl,
- BackUrl = backUrl
- });
- }
-
- [HttpPost("{walletId}/psbt/seed")]
- public async Task<IActionResult> SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- SignWithSeedViewModel viewModel)
- {
- if (!ModelState.IsValid)
- {
- return View("SignWithSeed", viewModel);
- }
- var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
- if (network == null)
- throw new FormatException("Invalid value for crypto code");
- ExtKey extKey = viewModel.GetExtKey(network.NBitcoinNetwork);
-
- if (extKey is null)
- {
- ModelState.AddModelError(nameof(viewModel.SeedOrKey),
- "Seed or Key was not in a valid format. It is either the 12/24 words or starts with xprv");
- }
-
- var psbt = PSBT.Parse(viewModel.SigningContext.PSBT, network.NBitcoinNetwork);
-
- if (!psbt.IsReadyToSign(out var errors))
- {
- ModelState.AddModelError(nameof(viewModel.SigningContext.PSBT), BuildErrorMessage(errors));
- }
-
- if (!ModelState.IsValid)
- {
- return View("SignWithSeed", viewModel);
- }
- // It will never throw, this make nullable check below happy
- ArgumentNullException.ThrowIfNull(extKey);
-
- ExtKey? signingKey = null;
- var settings = GetDerivationSchemeSettings(walletId);
- if (settings is null)
- return NotFound();
- var signingKeySettings = settings.GetAccountKeySettingsFromRoot(extKey);
- if (signingKeySettings is null)
- {
- // Let's try best effort if RootFingerprint isn't configured, but AccountKeyPath is
- signingKeySettings = settings.AccountKeySettings
- .FirstOrDefault(a => a.RootFingerprint is null && a.AccountKeyPath is not null);
- if (signingKeySettings is not null)
- signingKeySettings.RootFingerprint = extKey.GetPublicKey().GetHDFingerPrint();
- }
- RootedKeyPath? rootedKeyPath = signingKeySettings?.GetRootedKeyPath();
- if (rootedKeyPath is null || signingKeySettings is null)
- {
- ModelState.AddModelError(nameof(viewModel.SeedOrKey),
- "The master fingerprint and/or account key path of your seed are not set in the wallet settings.");
- return View(nameof(SignWithSeed), viewModel);
- }
- // The user gave the root key, let's try to rebase the PSBT, and derive the account private key
- psbt.RebaseKeyPaths(signingKeySettings.AccountKey, rootedKeyPath);
- signingKey = extKey.Derive(rootedKeyPath.KeyPath);
-
- psbt.Settings.SigningOptions = new SigningOptions()
- {
- EnforceLowR = !(viewModel.SigningContext?.EnforceLowR is false)
- };
- var changed = psbt.PSBTChanged(() => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath));
- if (!changed)
- {
- var update = new UpdatePSBTRequest() { PSBT = psbt, DerivationScheme = settings.AccountDerivation };
- update.RebaseKeyPaths = settings.GetPSBTRebaseKeyRules().ToList();
- psbt = (await ExplorerClientProvider.GetExplorerClient(network).UpdatePSBTAsync(update))?.PSBT;
- changed = psbt is not null && psbt.PSBTChanged(() =>
- psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath));
- if (!changed)
- {
- ModelState.AddModelError(nameof(viewModel.SeedOrKey),
- "Impossible to sign the transaction. Probable causes: Incorrect account key path in wallet settings or PSBT already signed.");
- return View(nameof(SignWithSeed), viewModel);
- }
- }
- ModelState.Remove(nameof(viewModel.SigningContext.PSBT));
- viewModel.SigningContext ??= new();
- viewModel.SigningContext.PSBT = psbt?.ToBase64();
- return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel
- {
- SigningKey = signingKey.GetWif(network.NBitcoinNetwork).ToString(),
- SigningKeyPath = rootedKeyPath?.ToString(),
- SigningContext = viewModel.SigningContext,
- ReturnUrl = viewModel.ReturnUrl,
- BackUrl = viewModel.BackUrl
- });
- }
-
- private static string BuildErrorMessage(PSBTError[] errors)
- {
- StringBuilder errorMessage = new();
- errorMessage.Append("PSBT is not ready to be signed.");
- if (errors.Length == 1)
- {
- errorMessage.Append($" ({errors[0]})");
- }
- else
- {
- errorMessage.AppendLine();
- foreach (var error in errors.Take(5))
- {
- errorMessage.AppendLine(error.ToString());
- }
- }
- if (errors.Length > 5)
- errorMessage.Append($"{errors.Length - 5} more errors...");
- return errorMessage.ToString();
- }
-
- private WalletPSBTReadyViewModel.StringAmounts ValueToString(Money v, BTCPayNetworkBase network,
- FiatRate? rate) =>
- new(
- CryptoAmount : _displayFormatter.Currency(v.ToDecimal(MoneyUnit.BTC), network.CryptoCode),
- FiatAmount : rate is null ? null
- : _displayFormatter.Currency(rate.Rate * v.ToDecimal(MoneyUnit.BTC), rate.Fiat)
- );
-
- [HttpGet("{walletId}/rescan")]
- public async Task<IActionResult> WalletRescan(
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId)
- {
- if (walletId?.StoreId == null)
- return NotFound();
- var paymentMethod = GetDerivationSchemeSettings(walletId);
- if (paymentMethod == null)
- return NotFound();
-
- var vm = new RescanWalletModel();
- vm.IsFullySync = _dashboard.IsFullySynched(walletId.CryptoCode, out var unused);
- vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings))
- .Succeeded;
- vm.IsSupportedByCurrency =
- _dashboard.Get(walletId.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanScanTxoutSet == true;
- var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
- var scanProgress = await explorer.GetScanUTXOSetInformationAsync(paymentMethod.AccountDerivation);
- if (scanProgress != null)
- {
- vm.PreviousError = scanProgress.Error;
- if (scanProgress.Status == ScanUTXOStatus.Queued || scanProgress.Status == ScanUTXOStatus.Pending)
- {
- if (scanProgress.Progress == null)
- {
- vm.Progress = 0;
- }
- else
- {
- vm.Progress = scanProgress.Progress.OverallProgress;
- vm.RemainingTime = TimeSpan.FromSeconds(scanProgress.Progress.RemainingSeconds).PrettyPrint();
- }
- }
-
- if (scanProgress.Status == ScanUTXOStatus.Complete)
- {
- vm.LastSuccess = scanProgress.Progress;
- vm.TimeOfScan = (scanProgress.Progress!.CompletedAt!.Value - scanProgress.Progress.StartedAt)
- .PrettyPrint();
- }
- }
-
- return View(vm);
- }
-
- [HttpPost("{walletId}/rescan")]
- [Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> WalletRescan(
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId, RescanWalletModel vm)
- {
- if (walletId?.StoreId == null)
- return NotFound();
- var paymentMethod = GetDerivationSchemeSettings(walletId);
- if (paymentMethod == null)
- return NotFound();
- var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
- try
- {
- await explorer.ScanUTXOSetAsync(paymentMethod.AccountDerivation, vm.BatchSize, vm.GapLimit,
- vm.StartingIndex);
- _walletProvider.GetWallet(walletId.CryptoCode).InvalidateCache(paymentMethod.AccountDerivation);
- }
- catch (NBXplorerException ex) when (ex.Error.Code == "scanutxoset-in-progress")
- {
- }
-
- return RedirectToAction();
- }
-
- internal DerivationSchemeSettings? GetDerivationSchemeSettings(WalletId walletId)
- {
- return GetCurrentStore().GetDerivationSchemeSettings(_handlers, walletId.CryptoCode);
- }
-
- private static async Task<IMoney> GetBalanceAsMoney(BTCPayWallet wallet,
- DerivationStrategyBase derivationStrategy)
- {
- using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
- try
- {
- var b = await wallet.GetBalance(derivationStrategy, cts.Token);
- return b.Available ?? b.Total;
- }
- catch
- {
- return Money.Zero;
- }
- }
-
- internal async Task<string> GetBalanceString(BTCPayWallet wallet, DerivationStrategyBase? derivationStrategy)
- {
- if (derivationStrategy is null)
- return "--";
- try
- {
- return (await GetBalanceAsMoney(wallet, derivationStrategy)).ShowMoney(wallet.Network);
- }
- catch
- {
- return "--";
- }
- }
-
- [HttpPost("{walletId}/actions")]
- public async Task<IActionResult> WalletActions(
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId, string command,
- string[] selectedTransactions,
- CancellationToken cancellationToken = default)
- {
- var derivationScheme = GetDerivationSchemeSettings(walletId);
- var network = _handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
- if (derivationScheme == null || network.ReadonlyWallet)
- return NotFound();
-
- switch (command)
- {
- case "cpfp":
- {
- selectedTransactions ??= Array.Empty<string>();
- if (selectedTransactions.Length == 0)
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["No transaction selected"].Value;
- return RedirectToAction(nameof(WalletTransactions), new { walletId });
- }
-
- var parameters = new MultiValueDictionary<string, string>();
- parameters.Add("walletId", walletId.ToString());
- int i = 0;
- foreach (var tx in selectedTransactions)
- {
- parameters.Add($"transactionHashes[{i}]", tx);
- i++;
- }
- return View("PostRedirect",
- new PostRedirectViewModel
- {
- AspController = "UIWallets",
- AspAction = nameof(WalletBumpFee),
- RouteParameters = { { "walletId", walletId.ToString() } },
- FormParameters = parameters
- });
- }
- case "prune":
- {
- var result = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)
- .PruneAsync(derivationScheme.AccountDerivation, new PruneRequest(), cancellationToken);
- if (result.TotalPruned == 0)
- {
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The wallet is already pruned"].Value;
- }
- else
- {
- TempData[WellKnownTempData.SuccessMessage] =
- StringLocalizer["The wallet has been successfully pruned ({0} transactions have been removed from the history)", result.TotalPruned].Value;
- }
-
- return RedirectToAction(nameof(WalletTransactions), new { walletId });
- }
- case "clear" when User.IsInRole(Roles.ServerAdmin):
- {
- if (Version.TryParse(_dashboard.Get(walletId.CryptoCode)?.Status?.Version ?? "0.0.0.0",
- out var v) &&
- v < new Version(2, 2, 4))
- {
- TempData[WellKnownTempData.ErrorMessage] =
- "This version of NBXplorer doesn't support this operation, please upgrade to 2.2.4 or above";
- }
- else
- {
- await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)
- .WipeAsync(derivationScheme.AccountDerivation, cancellationToken);
- TempData[WellKnownTempData.SuccessMessage] =
- "The transactions have been wiped out, to restore your balance, rescan the wallet.";
- }
-
- return RedirectToAction(nameof(WalletTransactions), new { walletId });
- }
- default:
- return NotFound();
- }
- }
-
- [HttpGet("{walletId}/export")]
- public async Task<IActionResult> Export(
- [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- string format, string? labelFilter = null, CancellationToken cancellationToken = default)
- {
- var paymentMethod = GetDerivationSchemeSettings(walletId);
- if (paymentMethod == null)
- return NotFound();
-
- var network = _handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
- var wallet = _walletProvider.GetWallet(network);
- var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId, (string[]?)null);
- var input = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, cancellationToken: cancellationToken);
- var walletTransactionsInfo = await walletTransactionsInfoAsync;
- var export = new TransactionsExport(wallet, walletTransactionsInfo);
- var res = export.Process(input, format);
- var fileType = format switch
- {
- "csv" => "csv",
- "json" => "json",
- "bip329" => "jsonl",
- _ => throw new ArgumentOutOfRangeException(nameof(format), format, null)
- };
- var mimeType = format switch
- {
- "csv" => "text/csv",
- "json" => "application/json",
- "bip329" => "application/jsonl", // Ongoing discussion: https://github.com/wardi/jsonlines/issues/19
- _ => throw new ArgumentOutOfRangeException(nameof(format), format, null)
- };
- var cd = new ContentDisposition
- {
- FileName = $"btcpay-{walletId}-{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.{fileType}",
- Inline = true
- };
- Response.Headers.Add("Content-Disposition", cd.ToString());
- Response.Headers.Add("X-Content-Type-Options", "nosniff");
- return Content(res, mimeType);
- }
-
- public class UpdateLabelsRequest
- {
- public string? Id { get; set; }
- public string? Type { get; set; }
- public string[]? Labels { get; set; }
- }
-
- [HttpPost("{walletId}/update-labels")]
- [IgnoreAntiforgeryToken]
- public async Task<IActionResult> UpdateLabels(
- [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- [FromBody] UpdateLabelsRequest request)
- {
- if (string.IsNullOrEmpty(request.Type) || string.IsNullOrEmpty(request.Id) || request.Labels is null)
- return BadRequest();
-
- var objid = new WalletObjectId(walletId, request.Type, request.Id);
- var obj = await WalletRepository.GetWalletObject(objid);
- if (obj is null)
- {
- await WalletRepository.EnsureWalletObject(objid);
- }
- else
- {
- var currentLabels = obj.GetNeighbours().Where(data => data.Type == WalletObjectData.Types.Label).ToArray();
- var toRemove = currentLabels.Where(data => !request.Labels.Contains(data.Id)).Select(data => data.Id).ToArray();
- await WalletRepository.RemoveWalletObjectLabels(objid, toRemove);
- }
- await WalletRepository.AddWalletObjectLabels(objid, request.Labels);
- return Ok();
- }
-
- [HttpGet("{walletId}/labels.json")]
- [IgnoreAntiforgeryToken]
- public async Task<IActionResult> LabelsJson(
- [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- bool excludeTypes,
- string? type = null,
- string? id = null,
- string? linkedType = null)
- {
- var walletObjectId = !string.IsNullOrEmpty(type) && !string.IsNullOrEmpty(id)
- ? new WalletObjectId(walletId, type, id)
- : null;
- var labels = walletObjectId != null
- ? await WalletRepository.GetWalletLabels(walletObjectId)
- : !string.IsNullOrEmpty(linkedType)
- ? await WalletRepository.GetWalletLabelsByLinkedType(walletId, linkedType)
- : await WalletRepository.GetWalletLabels(walletId);
- return Ok(labels
- .Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
- .Select(tuple => new WalletLabelModel
- {
- Label = tuple.Label,
- Color = tuple.Color,
- TextColor = ColorPalette.Default.TextColor(tuple.Color)
- }));
- }
-
- [HttpGet("{walletId}/labels")]
- public async Task<IActionResult> WalletLabels(
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId)
- {
- var labels = await WalletRepository.GetWalletLabels(walletId);
-
- var vm = new WalletLabelsModel
- {
- WalletId = walletId,
- Labels = labels
- .Where(l => !WalletObjectData.Types.AllTypes.Contains(l.Label))
- .Select(tuple => new WalletLabelModel
- {
- Label = tuple.Label,
- Color = tuple.Color,
- TextColor = ColorPalette.Default.TextColor(tuple.Color)
- })
- };
-
- return View(vm);
- }
-
- [HttpPost("{walletId}/labels/{id}/delete")]
- public async Task<IActionResult> DeleteWalletLabel(
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId, string id)
- {
- var labels = new[] { id };
-
- if (await WalletRepository.RemoveWalletLabels(walletId, labels))
- {
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The label has been successfully deleted."].Value;
- }
- else
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["The label could not be deleted."].Value;
- }
-
- return RedirectToAction(nameof(WalletLabels), new { walletId });
- }
-
- [HttpPost("{walletId}/labels/{id}/edit")]
- public async Task<IActionResult> EditWalletLabel(
- [ModelBinder(typeof(WalletIdModelBinder))]
- WalletId walletId, string id, string newLabel)
- {
- if (string.IsNullOrWhiteSpace(newLabel))
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Label name cannot be empty."].Value;
- return RedirectToAction(nameof(WalletLabels), new { walletId });
- }
-
- newLabel = newLabel.Trim();
- if (newLabel == id)
- {
- return RedirectToAction(nameof(WalletLabels), new { walletId });
- }
-
- if (await WalletRepository.RenameWalletLabel(walletId, id, newLabel))
- {
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The label has been successfully renamed."].Value;
- }
- else
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["The label could not be renamed."].Value;
- }
-
- return RedirectToAction(nameof(WalletLabels), new { walletId });
- }
-
- private string? GetImage(BTCPayNetwork network)
- {
- var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode);
- if (_paymentModelExtensions.TryGetValue(pmi, out var extension))
- {
- return Request.GetRelativePathOrAbsolute(Url.Content(extension.Image));
- }
- return null;
- }
-
- private string? GetUserId() => User.GetIdOrNull();
-
- private StoreData GetCurrentStore() => HttpContext.GetStoreData();
- }
-
- public class WalletReceiveViewModel
- {
- public string? CryptoImage { get; set; }
- public string? CryptoCode { get; set; }
- public string? Address { get; set; }
- public string? PaymentLink { get; set; }
- public string? ReturnUrl { get; set; }
- public string[]? SelectedLabels { get; set; }
- }
-
- public class SendToAddressResult
- {
- [JsonProperty("psbt")] public string? PSBT { get; set; }
- }
-}
diff --git a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs
index 6f7c7a0..3b932b8 100644
--- a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs
+++ b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs
@@ -14,6 +14,7 @@ using BTCPayServer.Logging;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payouts;
+using BTCPayServer.Plugins.Wallets;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Notifications;
@@ -315,11 +316,14 @@ public class BitcoinLikePayoutHandler : IPayoutHandler, IHasNetwork
}
}
if (bip21.Any())
- return new RedirectToActionResult("WalletSend", "UIWallets", new { walletId = new WalletId(storeId, Network.CryptoCode).ToString(), bip21 });
- return new RedirectToActionResult("Payouts", "UIWallets", new
+ return new RedirectToActionResult("WalletSend", "UIWallets", new { area = WalletsPlugin.Area, walletId = new WalletId(storeId, Network.CryptoCode).ToString(), bip21 });
+ return new RedirectToActionResult("Payouts", "UIStorePullPayments", new
{
- walletId = new WalletId(storeId, Network.CryptoCode).ToString(),
- pullPaymentId = pullPaymentIds.Length == 1 ? pullPaymentIds.First() : null
+ area = "",
+ storeId,
+ pullPaymentId = pullPaymentIds.Length == 1 ? pullPaymentIds.First() : null,
+ payoutMethodId = PayoutMethodId.ToString(),
+ payoutState = PayoutState.AwaitingPayment
});
}
diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs
index 513de99..d220af5 100644
--- a/BTCPayServer/Extensions/UrlHelperExtensions.cs
+++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs
@@ -4,6 +4,7 @@ using BTCPayServer.Abstractions;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
+using BTCPayServer.Plugins.Wallets;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -12,10 +13,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? WalletSend(this IUrlHelper helper, WalletId walletId) => helper.Action(nameof(UIWalletsController.WalletSend), new { area = WalletsPlugin.Area, 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 });
+ => helper.Action(nameof(UIWalletsController.WalletTransactions), new { area = WalletsPlugin.Area, 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)
@@ -50,7 +51,7 @@ namespace Microsoft.AspNetCore.Mvc
return urlHelper.GetUriByAction(
action: nameof(UIWalletsController.WalletTransactions),
controller: "UIWallets",
- values: new { walletId = walletId.ToString() },
+ values: new { area = WalletsPlugin.Area, walletId = walletId.ToString() },
baseUrl
);
}
diff --git a/BTCPayServer/HostedServices/PendingTransactionService.cs b/BTCPayServer/HostedServices/PendingTransactionService.cs
index 722eafe..31a263b 100644
--- a/BTCPayServer/HostedServices/PendingTransactionService.cs
+++ b/BTCPayServer/HostedServices/PendingTransactionService.cs
@@ -256,13 +256,16 @@ public class PendingTransactionService(
});
}
- public async Task Broadcasted(PendingTransactionFullId id)
+ public async Task Broadcasted(PendingTransactionFullId id, Transaction transaction)
{
await using var ctx = dbContextFactory.CreateContext();
var pt = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
p.CryptoCode == id.CryptoCode && p.StoreId == id.StoreId && p.Id == id.Id &&
(p.State == PendingTransactionState.Pending || p.State == PendingTransactionState.Signed));
if (pt is null) return;
+ var pendingPsbt = TryParsePendingPSBT(pt);
+ if (pendingPsbt is null || !HasSameTransactionIntent(pendingPsbt.GetGlobalTransaction(), transaction))
+ return;
pt.State = PendingTransactionState.Broadcast;
await ctx.SaveChangesAsync();
EventAggregator.Publish(new PendingTransactionEvent
@@ -272,6 +275,51 @@ public class PendingTransactionService(
});
}
+ private PSBT? TryParsePendingPSBT(PendingTransaction pendingTransaction)
+ {
+ var network = networkProvider.GetNetwork<BTCPayNetwork>(pendingTransaction.CryptoCode);
+ if (network is null || pendingTransaction.GetBlob()?.PSBT is not { } psbt)
+ return null;
+ try
+ {
+ return PSBT.Parse(psbt, network.NBitcoinNetwork);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to parse pending transaction PSBT {PendingTransactionId}", pendingTransaction.Id);
+ return null;
+ }
+ }
+
+ private static bool HasSameTransactionIntent(Transaction pendingTransaction, Transaction broadcastTransaction)
+ {
+ if (pendingTransaction.Version != broadcastTransaction.Version ||
+ pendingTransaction.LockTime != broadcastTransaction.LockTime ||
+ pendingTransaction.Inputs.Count != broadcastTransaction.Inputs.Count ||
+ pendingTransaction.Outputs.Count != broadcastTransaction.Outputs.Count)
+ return false;
+
+ for (var i = 0; i < pendingTransaction.Inputs.Count; i++)
+ {
+ var pendingInput = pendingTransaction.Inputs[i];
+ var broadcastInput = broadcastTransaction.Inputs[i];
+ if (pendingInput.PrevOut != broadcastInput.PrevOut ||
+ pendingInput.Sequence != broadcastInput.Sequence)
+ return false;
+ }
+
+ for (var i = 0; i < pendingTransaction.Outputs.Count; i++)
+ {
+ var pendingOutput = pendingTransaction.Outputs[i];
+ var broadcastOutput = broadcastTransaction.Outputs[i];
+ if (pendingOutput.Value != broadcastOutput.Value ||
+ pendingOutput.ScriptPubKey != broadcastOutput.ScriptPubKey)
+ return false;
+ }
+
+ return true;
+ }
+
public record PendingTransactionEventWhy this scored 45/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.